OpenConcerto

Dépôt officiel du code source de l'ERP OpenConcerto
sonarqube

svn://code.openconcerto.org/openconcerto

Rev

Rev 21 | Rev 26 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 * 
 * Copyright 2011 OpenConcerto, by ILM Informatique. All rights reserved.
 * 
 * The contents of this file are subject to the terms of the GNU General Public License Version 3
 * only ("GPL"). You may not use this file except in compliance with the License. You can obtain a
 * copy of the License at http://www.gnu.org/licenses/gpl-3.0.html 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.
 */
 
 package org.openconcerto.ui.component.combo;

import static org.openconcerto.ui.component.ComboLockedMode.ITEMS_LOCKED;
import static org.openconcerto.ui.component.ComboLockedMode.LOCKED;
import static org.openconcerto.ui.component.ComboLockedMode.UNLOCKED;
import org.openconcerto.laf.LAFUtils;
import org.openconcerto.ui.component.ComboLockedMode;
import org.openconcerto.ui.component.ITextArea;
import org.openconcerto.ui.component.MutableListCombo;
import org.openconcerto.ui.component.MutableListComboPopupListener;
import org.openconcerto.ui.component.text.DocumentComponent;
import org.openconcerto.ui.component.text.TextComponent;
import org.openconcerto.ui.valuewrapper.ValueChangeSupport;
import org.openconcerto.ui.valuewrapper.ValueWrapper;
import org.openconcerto.utils.CollectionUtils;
import org.openconcerto.utils.CompareUtils;
import org.openconcerto.utils.cc.ITransformer;
import org.openconcerto.utils.cc.IdentityHashSet;
import org.openconcerto.utils.checks.ValidListener;
import org.openconcerto.utils.checks.ValidState;
import org.openconcerto.utils.model.DefaultIMutableListModel;
import org.openconcerto.utils.model.IListModel;
import org.openconcerto.utils.model.IMutableListModel;
import org.openconcerto.utils.model.ListComboBoxModel;
import org.openconcerto.utils.model.Reloadable;
import org.openconcerto.utils.text.SimpleDocumentListener;

import java.awt.Color;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.Image;
import java.awt.Insets;
import java.awt.LayoutManager2;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.color.ColorSpace;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.image.BufferedImage;
import java.awt.image.ColorConvertOp;
import java.beans.PropertyChangeListener;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;

import javax.swing.Action;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.JTextComponent;

/**
 * A lightweight component that allows to search for and select a single item. If a cache is
 * associated, autocompletion is enabled and a popup list is available.
 * <p>
 * Mode UNLOCKED: text input is allowed, the user can interact with the cache (add or remove via
 * right click)
 * </p>
 * <p>
 * Mode ITEMS_LOCKED: text input is allowed, add&remove operations in the cache are disabled
 * </p>
 * <p>
 * Mode LOCKED: the selection must be part of the cache list.
 * </p>
 * 
 * @param <T> type of item in the combo
 */
public class ISearchableCombo<T> extends JPanel implements ValueWrapper<T>, DocumentComponent, TextComponent {

    // Mode de filtrage de la popup de completion
    public static final SearchMode MODE_STARTWITH = new SearchMode.DefaultSearchMode(false);
    public static final SearchMode MODE_CONTAINS = new SearchMode.DefaultSearchMode(true);
    protected static final int LABEL_GAP = 2;
    protected static final int BTN_GAP = 3;

    private SearchMode modeCompletion = MODE_CONTAINS;
    // Popup de completion
    private ISearchableComboCompletionThread<T> completionThread;
    protected final ISearchableComboPopup<T> popupCompletion;
    // fullList
    private final DefaultIMutableListModel<ISearchableComboItem<T>> model;
    // list for the popup
    private final ListComboBoxModel listModel;
    private final ISearchableComboItem<T> emptyItem;
    private boolean includeEmpty;

    private final ComboLockedMode locked;
    private boolean searchable;
    private final ValueChangeSupport<T> supp;

    private final List<Action> actions;
    // cache
    private IListModel<T> cache;
    private JTextComponent text;
    private Insets textMargin;
    // icon
    private final JLabel label;
    // arrow
    private final JLabel btn;
    // to select from the btn or the non-editable editor
    private final MouseMotionListener dragL;
    // to display or hide the popup
    private final MouseListener clickL;

    private static Image imageSelectorEnabled;
    private static Image imageSelectorDisabled;
    // Option de filtrage
    private int minimumSearch = 1;
    private int maximumResult = 300;

    private final Map<T, ISearchableComboItem<T>> itemsByOriginalItem;
    protected boolean updating = false;
    protected boolean invalidEdit = false;
    private ITransformer<T, VarDesc> varDescTransf = null;
    private ITransformer<T, Icon> iconTransf = null;

    private boolean trace = false;

    public ISearchableCombo() {
        this(UNLOCKED);
    }

    public ISearchableCombo(final boolean locked) {
        this(locked ? LOCKED : ITEMS_LOCKED);
    }

    protected ISearchableCombo(final ComboLockedMode mode) {
        this(mode, 0, 0);
    }

    public ISearchableCombo(final ComboLockedMode mode, final int rows, final int columns) {
        this(mode, rows, columns, false);
    }

    public ISearchableCombo(final ComboLockedMode mode, final boolean textArea) {
        this(mode, 0, 0, textArea);
    }

    /**
     * Create a new instance.
     * 
     * @param mode the combo mode.
     * @param rows the number of rows >= 0
     * @param columns the number of columns >= 0
     * @param textArea <code>true</code> if the editor should be a text area (ie can have more than
     *        one line), ignored if <code>rows</code> >= 2.
     */
    public ISearchableCombo(final ComboLockedMode mode, final int rows, final int columns, final boolean textArea) {
        this.supp = new ValueChangeSupport<T>(this);
        this.locked = mode;

        // items
        this.actions = new ArrayList<Action>();
        this.cache = null;
        this.itemsByOriginalItem = new HashMap<T, ISearchableComboItem<T>>();
        this.model = new DefaultIMutableListModel<ISearchableComboItem<T>>();
        this.getModel().setSelectOnAdd(false);
        this.getModel().setSelectOnRm(false);
        this.getModel().addListDataListener(new ListDataListener() {

            public void contentsChanged(final ListDataEvent e) {
                // changement de selection
                if (e.getIndex0() == -1 && e.getIndex0() == e.getIndex1()) {
                    selectionChanged();
                }
            }

            public void intervalAdded(final ListDataEvent e) {
                // don't care
            }

            public void intervalRemoved(final ListDataEvent e) {
                // don't care
            }
        });

        // TODO allow customization: "none", "noone", "nothing", etc.
        this.emptyItem = new ISearchableComboItem<T>(null, new ToStringVarDesc("effacer"));
        // for non locked, just erase
        this.setIncludeEmpty(this.isLocked());

        // * UI
        this.listModel = new ListComboBoxModel();
        this.popupCompletion = new ISearchableComboPopup<T>(this.listModel, this);

        // no ComponentListener#componentShown() for popups
        this.popupCompletion.addPopupMenuListener(new PopupMenuListener() {

            public void popupMenuCanceled(final PopupMenuEvent e) {
                // don't care
            }

            public void popupMenuWillBecomeVisible(final PopupMenuEvent e) {
                // that way when the yellow btn (actualy the text field) is clicked
                // the popup ain't closed and we close it ourselves.
                // otherwise when the btn is clicked the popup is always closed
                // and we cannot know if we should open or not the popup
                ComboUtils.doNotCancelPopupHack(getTextComp());
                ComboUtils.doNotCancelPopupHack(getBtn());
            }

            public void popupMenuWillBecomeInvisible(final PopupMenuEvent e) {
                // remove otherwise when there's 2 ISearchableCombo, if you display the list of one
                // and immediately click on the other, the list won't close.
                ComboUtils.cancelPopupHack(getTextComp());
                ComboUtils.cancelPopupHack(getBtn());
            }

        });
        this.addComponentListener(new ComponentAdapter() {
            public void componentResized(final ComponentEvent e) {
                // ajuste la taille min de la popup
                ISearchableCombo.this.popupCompletion.setMinWith(ISearchableCombo.this.getBounds().width);
            }
        });
        this.dragL = new MouseMotionListener() {

            private Point last = null;

            public void mouseDragged(final MouseEvent e) {
                // if the mouse didn't move, nothing to do
                if (e.getPoint().equals(this.last))
                    return;
                this.last = e.getPoint();

                // SwingUtilities.convertPoint() is much less efficient
                final Point converted = e.getPoint();
                converted.translate(0, -ISearchableCombo.this.getHeight());

                ISearchableCombo.this.popupCompletion.updateListBoxSelection(converted);
            }

            public void mouseMoved(final MouseEvent e) {
            }
        };
        this.clickL = new MouseAdapter() {

            @Override
            public void mousePressed(final MouseEvent e) {
                if (e.getButton() != MouseEvent.BUTTON1)
                    return;

                if (this.isClickTarget(e)) {
                    // acquire focus so that we can listen to focusLost
                    getTextComp().requestFocusInWindow();
                    final boolean showing = ISearchableCombo.this.popupCompletion.isShowing();
                    if (showing)
                        hideCompletionPopup();
                    else
                        updateAutoCompletion(true);
                }
            }

            private boolean isClickTarget(final MouseEvent e) {
                final JComponent src = (JComponent) e.getSource();
                final boolean buttonClicked = src.isEnabled() && src.contains(e.getPoint());
                final boolean isTextClickable = !(src instanceof JTextComponent) || !((JTextComponent) src).isEditable();
                return buttonClicked && isTextClickable;
            }

            @Override
            public void mouseReleased(final MouseEvent e) {
                // si on relache ailleurs que sur le "bouton", fermer la popup
                if (ISearchableCombo.this.popupCompletion.isShowing() && !this.isClickTarget(e)) {
                    ISearchableCombo.this.popupCompletion.validateSelection();
                }
            }
        };

        this.completionThread = null;
        initImages();

        // the only child is the text component
        this.setLayout(new GridLayout(1, 1, 0, 0));

        // to display an icon
        this.label = new JLabel();

        this.btn = new JLabel(new ImageIcon(imageSelectorEnabled));
        this.btn.setDisabledIcon(new ImageIcon(imageSelectorDisabled));
        this.btn.setSize(getBtn().getPreferredSize());
        this.btn.setFocusable(false);
        this.btn.addMouseMotionListener(this.dragL);
        this.btn.addMouseListener(this.clickL);

        setTextEditor(rows, columns, textArea);

        // the background is provided by the text component
        this.getLabel().setOpaque(false);
        this.getBtn().setOpaque(false);
        this.setOpaque(false);

        // init this.btn
        this.setEnabled(true);
        this.setSearchable(true);
    }

    private static void initImages() {
        if (imageSelectorEnabled != null) {
            return;
        }

        final Image ic = new ImageIcon(ISearchableCombo.class.getResource("yellowDownArrow.png")).getImage();
        final int w = ic.getWidth(null);
        final int h = ic.getHeight(null);
        imageSelectorEnabled = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);

        final Graphics g = imageSelectorEnabled.getGraphics();
        g.setColor(new Color(255, 255, 255, 0));
        g.fillRect(0, 0, w, h);
        g.drawImage(ic, 0, 0, null);
        imageSelectorDisabled = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);

        final Graphics2D g2d = (Graphics2D) imageSelectorDisabled.getGraphics();
        g2d.setBackground(new Color(255, 255, 255, 0));

        final ColorSpace cs = ColorSpace.getInstance(ColorSpace.CS_GRAY);
        final ColorConvertOp op = new ColorConvertOp(cs, g2d.getRenderingHints());
        op.filter((BufferedImage) imageSelectorEnabled, (BufferedImage) imageSelectorDisabled);
    }

    private void log(final String s) {
        if (this.trace)
            Log.get().info(s);
    }

    public final void setDebug(final boolean trace) {
        this.trace = trace;
    }

    private DefaultIMutableListModel<ISearchableComboItem<T>> getModel() {
        return this.model;
    }

    private final JLabel getLabel() {
        return this.label;
    }

    protected final JComponent getBtn() {
        return this.btn;
    }

    @Override
    public void setEnabled(final boolean b) {
        super.setEnabled(b);
        this.text.setEnabled(b);
        // don't let the user think he can click, if there's nothing
        this.btn.setEnabled(b && this.cache != null);
    }

    protected final ComboLockedMode getMode() {
        return this.locked;
    }

    private boolean isLocked() {
        return this.locked == LOCKED;
    }

    public final boolean isSearchable() {
        return this.searchable;
    }

    public final void setSearchable(final boolean searchable) {
        this.searchable = searchable;
        this.text.setEditable(this.searchable || !this.isLocked());
        // swing removes our background
        if (!this.text.isEditable()) {
            this.text.setBackground(Color.WHITE);
        }
    }

    /**
     * Returns the actions added at the end of the list of items. The name of the action will be
     * displayed and its actionPerformed() invoked when chosen.
     * 
     * @return the list of actions
     */
    public final List<Action> getActions() {
        return this.actions;
    }

    // *** cache

    public final IListModel<T> getCache() {
        return this.cache;
    }

    public void initCache(final IListModel<T> acache) {
        if (acache == null)
            throw new NullPointerException("null cache");
        if (this.getCache() != null)
            throw new IllegalStateException("cache already set " + this.getCache());

        this.cache = acache;
        // the btn should now be enabled
        this.setEnabled(this.isEnabled());

        if (this.getMode() == ComboLockedMode.UNLOCKED) {
            if (!(acache instanceof IMutableListModel))
                throw new IllegalArgumentException(this + " is unlocked but " + acache + " is not mutable");
            final IMutableListModel<T> mutable = (IMutableListModel<T>) acache;
            final boolean isReloadable = mutable instanceof Reloadable;
            final Reloadable rel = isReloadable ? (Reloadable) mutable : null;
            new MutableListComboPopupListener(new MutableListCombo() {
                public ComboLockedMode getMode() {
                    return ISearchableCombo.this.getMode();
                }

                public Component getPopupComp() {
                    return getTextComp();
                }

                public void addCurrentText() {
                    final T newItem = stringToT(getTextComp().getText());
                    if (!mutable.getList().contains(newItem))
                        mutable.addElement(newItem);
                }

                public void removeCurrentText() {
                    mutable.removeElement(getValue());
                }

                @Override
                public boolean canReload() {
                    return isReloadable;
                }

                @Override
                public void reload() {
                    rel.reload();
                }
            }).listen();
        }

        addItemsFromCache(0, this.getCache().getSize() - 1);
        this.getCache().addListDataListener(new ListDataListener() {

            public void contentsChanged(final ListDataEvent e) {
                // selection change, see DefaultComboBoxModel#setSelectedItem()
                if (e.getIndex0() < 0)
                    return;

                // don't know what was changed, so remove and add
                final int equalsCount = CollectionUtils.equals(getModel().getList(), getCache().getList(), true, new ITransformer<ISearchableComboItem<T>, T>() {
                    @Override
                    public T transformChecked(final ISearchableComboItem<T> input) {
                        return input.getOriginal();
                    }
                });
                final int oldIndex1 = getModel().getSize() - 1 - equalsCount;
                final int newIndex1 = getCache().getSize() - 1 - equalsCount;
                if (oldIndex1 >= 0)
                    rmItemsFromModel(e.getIndex0(), oldIndex1);
                if (newIndex1 >= 0)
                    addItemsFromCache(e.getIndex0(), newIndex1);
            }

            public void intervalAdded(final ListDataEvent e) {
                // from ListDataEvent index0 == index1 when 1 change occurs
                // and subList is exclusive
                addItemsFromCache(e.getIndex0(), e.getIndex1());
            }

            public void intervalRemoved(final ListDataEvent e) {
                rmItemsFromModel(e.getIndex0(), e.getIndex1());
            }
        });
    }

    private void addItemsFromCache(final int index0, final int index1) {
        addItems(index0, getCache().getList().subList(index0, index1 + 1));
    }

    private void addItems(final int index, final Collection<T> originalItems) {
        // selection cannot change
        assert SwingUtilities.isEventDispatchThread();
        final ISearchableComboItem<T> sel = getSelection();
        final T selOriginal = sel == null ? null : sel.getOriginal();

        final List<ISearchableComboItem<T>> toAdd = new ArrayList<ISearchableComboItem<T>>(originalItems.size());
        for (final T originalItem : originalItems) {
            final ISearchableComboItem<T> textSelectorItem;
            if (this.itemsByOriginalItem.containsKey(originalItem)) {
                // allow another item with the same original : add another item to our model, but
                // keep the first one in itemsByOriginalItem (this map is only used in setValue() to
                // quickly find the ISearchableComboItem)
                textSelectorItem = createItem(originalItem);
                // see ISearchableComboPopup.validateSelection()
                assert !textSelectorItem.equals(this.itemsByOriginalItem.get(originalItem)) : "Have to not be equal to be able to choose one or the other";
            } else {
                // reuse the selected value, otherwise the popup will select nothing
                if (sel != null && CompareUtils.equals(selOriginal, originalItem))
                    textSelectorItem = sel;
                else
                    textSelectorItem = createItem(originalItem);
                this.itemsByOriginalItem.put(originalItem, textSelectorItem);
            }
            toAdd.add(textSelectorItem);
        }
        // only 1 fire
        this.getModel().addAll(index, toAdd);
    }

    private void rmItemsFromModel(final int index0, final int index1) {
        getModel().removeElementsAt(index0, index1);
        // remove from our map
        // ATTN for ~35000 items, new HashSet() got us from 6000ms to 8ms !
        this.itemsByOriginalItem.keySet().retainAll(new HashSet<T>(getCache().getList()));
    }

    // conversion

    private ISearchableComboItem<T> createItem(final T originalItem) {
        return new ISearchableComboItem<T>(originalItem, this.createVarDesc(originalItem));
    }

    private final VarDesc createVarDesc(final T o) {
        if (o instanceof VarDesc)
            return (VarDesc) o;
        else if (this.varDescTransf != null)
            return this.varDescTransf.transformChecked(o);
        else
            return new ToStringVarDesc(o);
    }

    public final void setVarDescFactory(final ITransformer<T, VarDesc> t) {
        this.varDescTransf = t;
    }

    final Icon getIcon(final ISearchableComboItem<T> i) {
        final T o = i.getOriginal();
        if (o instanceof Icon)
            return (Icon) o;
        else if (this.iconTransf != null)
            return this.iconTransf.transformChecked(o);
        else
            return null;
    }

    public final void setIconFactory(final ITransformer<T, Icon> t) {
        this.iconTransf = t;
    }

    protected T stringToT(final String t) {
        throw new IllegalStateException("use " + ISearchableTextCombo.class);
    }

    // *** value

    public void addValueListener(final PropertyChangeListener l) {
        this.supp.addValueListener(l);
    }

    public void rmValueListener(final PropertyChangeListener l) {
        this.supp.rmValueListener(l);
    }

    // get

    ISearchableComboItem<T> getSelection() {
        return this.getModel().getSelectedItem();
    }

    public T getValue() {
        final ISearchableComboItem<T> sel = this.getSelection();
        return sel == null ? null : sel.getOriginal();
    }

    // JComboBox compat API
    public final T getSelectedItem() {
        return this.getValue();
    }

    // set

    public void resetValue() {
        this.setValue((T) null);
    }

    public final void setValue(final T val) {
        this.setValue(val, true);
    }

    // JComboBox compat API
    public final void setSelectedItem(final T val) {
        this.setValue(val);
    }

    public final void setSelectedIndex(final int anIndex) {
        // pasted from JComboBox
        final int size = this.getCache().getSize();
        if (anIndex == -1) {
            setSelectedItem(null);
        } else if (anIndex < -1 || anIndex >= size) {
            throw new IllegalArgumentException("setSelectedIndex: " + anIndex + " out of bounds");
        } else {
            setSelectedItem(this.getCache().getElementAt(anIndex));
        }
    }

    private final boolean setValid(final boolean valid) {
        final boolean invalidChange = this.invalidEdit != !valid;
        if (invalidChange) {
            this.invalidEdit = !valid;
            this.text.setForeground(this.invalidEdit ? Color.GRAY : Color.BLACK);
        }
        return invalidChange;
    }

    private final void setValue(final T val, final boolean valid) {
        log("entering " + this.getClass().getSimpleName() + ".setValue " + val + " valid: " + valid);
        final boolean invalidChange = this.setValid(valid);

        if (!CompareUtils.equals(this.getValue(), val)) {
            log("this.getValue() != val :" + this.getValue());
            if (val == null)
                this.setSelection(null);
            else if (this.itemsByOriginalItem.containsKey(val)) {
                this.setSelection(this.itemsByOriginalItem.get(val));
            } else if (this.getMode() != LOCKED) {
                this.setSelection(createItem(val));
            } else {
                // for unknown values in LOCKED, act like the user has typed it,
                // that way the value is still displayed (albeit invalid)
                this.getTextComp().setText(createItem(val).asString());
                assert getValue() == null && this.invalidEdit;
            }
        } else if (invalidChange) {
            log("this.getValue() == val and invalidChange");
            // since val hasn't changed the model won't fire and thus our selectionChanged()
            // will not be called, but it has to since invalidEdit did change
            // so the text must be changed, and listeners notified
            this.selectionChanged();
        }
    }

    // perhaps try to factor with the other setValue()
    final void setValue(final ISearchableComboItem<T> val) {
        log("entering " + this.getClass().getSimpleName() + ".setValue(ISearchableComboItem) " + val);
        assert new IdentityHashSet<ISearchableComboItem<T>>(this.getModelValues()).contains(val) : "Item not in model, perhaps use setValue(T)";
        // valid since val is in our model
        final boolean invalidChange = this.setValid(true);

        if (!CompareUtils.equals(this.getSelection(), val)) {
            this.setSelection(val);
        } else if (invalidChange) {
            log("this.getSelection() == val and invalidChange");
            // since val hasn't changed the model won't fire and thus our selectionChanged()
            // will not be called, but it has to since invalidEdit did change
            // so the text must be changed, and listeners notified
            this.selectionChanged();
        }
    }

    private final void setSelection(final ISearchableComboItem<T> val) {
        log("entering " + this.getClass().getSimpleName() + ".setSelection " + val);
        this.getModel().setSelectedItem(val);
    }

    // as a result of setSelection() or a ITextSelectorItemsModel change
    protected final void selectionChanged() {
        this.updating = true;
        final ISearchableComboItem<T> sel = this.getModel().getSelectedItem();
        this.getLabel().setIcon(sel == null ? null : this.getIcon(sel));
        this.updateMargin();
        // si invalidEdit la selection means nothing, so don't change the textField
        if (!this.invalidEdit) {
            final String newText = sel == null ? "" : sel.asString();
            if (!this.text.getText().equals(newText)) {
                this.text.setText(newText);
                // display the beginning of the text
                this.text.getCaret().setDot(0);
            }
        }
        this.updating = false;

        this.supp.fireValueChange();
    }

    private int getLeftMargin() {
        final int labelWidth = (int) this.getLabel().getPreferredSize().getWidth();
        return this.textMargin.left + (labelWidth > 0 ? LABEL_GAP * 2 + labelWidth : 0);
    }

    private int getRightMargin() {
        return BTN_GAP + this.getBtn().getWidth() + BTN_GAP + this.textMargin.right;
    }

    // depends on label width (the arrow never changes)
    private void updateMargin() {
        final Insets origMarg = this.text.getMargin();
        this.text.setMargin(new Insets(origMarg.top, getLeftMargin(), origMarg.bottom, getRightMargin()));
    }

    protected final void docChanged(final DocumentEvent e) {
        if (!this.updating) {
            final String text = SimpleDocumentListener.getText(e.getDocument());
            if (this.isLocked()) {
                // value can only be set by the popup (or setMatchingCompletions())
                // except "" which means empty
                // this avoids having to decide between 2 different values with the same label, or
                // worse this is locked and one of those 2 values are not in us. In that case
                // setting the invalid one will in fact select the other.
                this.setValue(null, text.length() == 0);
            } else {
                this.setValue(stringToT(text));
            }
            if (this.isSearchable())
                this.updateAutoCompletion(false);
        }
    }

    // ** completion thread

    private void updateAutoCompletion(final boolean showAll) {
        if (this.getCache() == null) {
            return;
        }

        final String t = this.text.getText();
        if (this.completionThread != null) {
            this.completionThread.stopNow();
        }
        this.completionThread = new ISearchableComboCompletionThread<T>(this, showAll, t);
        this.completionThread.setPriority(Thread.MIN_PRIORITY);
        this.completionThread.start();
    }

    List<ISearchableComboItem<T>> getModelValues() {
        return this.getModel().getList();
    }

    // called by completionThread in EDT : no need to synch
    void setMatchingCompletions(final List<ISearchableComboItem<T>> l, final boolean showAll) {
        this.listModel.removeAllElements();
        if (showAll && this.includeEmpty()) {
            this.listModel.addElement(this.emptyItem);
        }
        this.listModel.addAll(l);
        this.listModel.addAll(this.actions);

        if (showAll) {
            this.showCompletionPopup();
        } else if (l.size() > 1) {
            this.showCompletionPopup();
        } else if (l.size() == 1) {
            final ISearchableComboItem<T> onlyCompletion = l.get(0);
            if (onlyCompletion.asString().trim().equalsIgnoreCase(this.text.getText().trim())) {
                this.hideCompletionPopup();
                this.setValue(onlyCompletion.getOriginal());
            } else {
                this.showCompletionPopup();
            }
        } else {
            this.hideCompletionPopup();
        }
    }

    public final boolean includeEmpty() {
        return this.includeEmpty;
    }

    public final void setIncludeEmpty(final boolean include) {
        this.includeEmpty = include;
    }

    void hideCompletionPopup() {
        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                ISearchableCombo.this.popupCompletion.close();
            }
        });
    }

    void showCompletionPopup() {
        if (this.isShowing()) {
            SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    ISearchableCombo.this.popupCompletion.open();
                }
            });
        }
    }

    /**
     * Set the column count on the current text editor.
     * 
     * @param columns the new column count.
     */
    public final void setColumns(int columns) {
        if (this.text instanceof JTextArea)
            ((JTextArea) this.text).setColumns(columns);
        else if (this.text instanceof JTextField)
            ((JTextField) this.text).setColumns(columns);
        else
            throw new IllegalStateException("No setColumns() on " + this.text.getClass());
    }

    public final void setRows(int rows) {
        this.setRows(rows, null);
    }

    /**
     * Set the row count on the current text editor if possible. Otherwise create a new text editor
     * with the desired rows.
     * 
     * @param rows the new row count.
     * @param textArea <code>true</code> if the editor should be a text area (i.e. can have more
     *        than one line), <code>null</code> to retain the current editor, ignored if
     *        <code>rows</code> >= 2.
     */
    public final void setRows(int rows, final Boolean textArea) {
        JTextComponent newText = null;
        if (this.text instanceof JTextArea) {
            final JTextArea ta = (JTextArea) this.text;
            if (textArea == Boolean.FALSE && rows == 1)
                newText = createTextField(ta.getColumns());
            else
                ta.setRows(rows);
        } else if (this.text instanceof JTextField) {
            final JTextField tf = (JTextField) this.text;
            if (textArea == Boolean.TRUE || rows > 1)
                newText = new ITextArea(rows, tf.getColumns());
        } else {
            throw new IllegalStateException("Neither JTextArea nor JTextField " + this.text.getClass());
        }
        if (newText != null) {
            this.setTextEditor(newText);
        }
    }

    private JTextField createTextField(int columns) {
        final boolean macLaF = UIManager.getLookAndFeel().getID().equals(LAFUtils.Mac_ID);
        // with the Mac l&f margins get ignored
        final JTextField tf = !macLaF ? new JTextField(columns) : new JTextField(columns) {
            @Override
            public Insets getInsets() {
                final Insets res = (Insets) super.getInsets().clone();
                res.left += getLeftMargin();
                res.right += getRightMargin();
                return res;
            }
        };
        return tf;
    }

    public final void setTextEditor(int rows, int columns, final boolean textArea) {
        setTextEditor(rows > 1 || textArea ? new ITextArea(rows, columns) : createTextField(columns));
    }

    // not public since (at least in the Mac l&f) some JTextComponent don't honor margins
    protected final void setTextEditor(final JTextComponent atext) {
        if (atext == null) {
            throw new IllegalArgumentException("null textEditor");
        }
        // remove previous
        if (this.text != null) {
            this.text.removeMouseMotionListener(this.dragL);
            this.text.removeMouseListener(this.clickL);
            this.remove(this.text);
            this.text.removeAll();
        }

        // customize the new one
        this.text = atext;
        this.textMargin = (Insets) this.text.getMargin().clone();
        // remove font from our private textField, thus the font can be set on us
        this.setFont(this.text.getFont());
        this.text.setFont(null);
        // don't set opaque since some laf use it for border (Nimbus uses the background to compute
        // borders so if we set it to true there will be a white outer line)

        // add it
        this.getTextComp().setLayout(new LayoutManager2() {

            @Override
            public void addLayoutComponent(String name, Component comp) {
                // not used
            }

            @Override
            public void addLayoutComponent(Component comp, Object constraints) {
                // not used
            }

            @Override
            public void removeLayoutComponent(Component comp) {
                // not used
            }

            @Override
            public void invalidateLayout(Container target) {
                // not used
            }

            @Override
            public Dimension preferredLayoutSize(Container parent) {
                return parent.getPreferredSize();
            }

            @Override
            public Dimension minimumLayoutSize(Container parent) {
                return parent.getMinimumSize();
            }

            @Override
            public Dimension maximumLayoutSize(Container target) {
                return target.getMaximumSize();
            }

            @Override
            public void layoutContainer(Container parent) {
                // these include the margin
                final Insets parentInsets = ((JComponent) parent).getInsets();

                getLabel().setSize(getLabel().getPreferredSize());
                getLabel().setLocation(parentInsets.left - getLabel().getWidth() - LABEL_GAP, parent.getHeight() / 2 - getLabel().getHeight() / 2);

                // easier to click
                getBtn().setSize(getBtn().getWidth(), parent.getHeight());
                getBtn().setLocation(parent.getWidth() - parentInsets.right + BTN_GAP, parent.getHeight() / 2 - getBtn().getHeight() / 2);
            }

            @Override
            public float getLayoutAlignmentX(Container target) {
                return Component.CENTER_ALIGNMENT;
            }

            @Override
            public float getLayoutAlignmentY(Container target) {
                return Component.CENTER_ALIGNMENT;
            }
        });

        this.text.add(this.getLabel());
        this.text.add(this.getBtn());
        this.updateMargin();
        this.add(this.text);
        // needed otherwise it grows but never shrinks
        this.setMinimumSize(new Dimension(this.getMinimumSize()));

        // listeners
        this.text.getDocument().addDocumentListener(new SimpleDocumentListener() {
            @Override
            public void update(final DocumentEvent e) {
                docChanged(e);
            }
        });

        this.text.addKeyListener(new KeyListener() {

            private boolean consume;

            private final boolean isAtLastLine(final JTextComponent src) {
                if (src.getDocument().getLength() == 0)
                    return true;
                else {
                    try {
                        final Rectangle caretView = src.modelToView(src.getCaret().getDot());
                        final Rectangle lastView = src.modelToView(src.getDocument().getLength() - 1);
                        return caretView.y >= lastView.y;
                    } catch (BadLocationException e1) {
                        // shouldn't happen since we're using the caret
                        e1.printStackTrace();
                        return false;
                    }
                }
            }

            public void keyPressed(final KeyEvent e) {
                final JTextComponent src = (JTextComponent) e.getSource();

                final ISearchableComboPopup<T> popup = ISearchableCombo.this.popupCompletion;
                // escape close the combo, like JComboBox
                if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
                    // otherwise the default binding clear the text comp, thus closing the popup
                    if (getTextComp().getDocument().getLength() == 0)
                        hideCompletionPopup();
                } else if (e.getKeyCode() == KeyEvent.VK_DOWN) {
                    if (popup.isShowing()) {
                        popup.selectNext();
                        e.consume();
                    } else if (this.isAtLastLine(src)) {
                        // act like we clicked the btn
                        updateAutoCompletion(true);
                    }

                } else if (e.getKeyCode() == KeyEvent.VK_UP) {
                    if (popup.isShowing()) {
                        popup.selectPrevious();
                        e.consume();
                    }

                } else if (e.getKeyCode() == KeyEvent.VK_ENTER) {
                    if (popup.isShowing()) {
                        popup.validateSelection();
                        // if there was a selection the popup is now closed
                        // otherwise let the event continue
                        if (!popup.isShowing())
                            e.consume();
                    }
                } else if (e.getKeyCode() == KeyEvent.VK_PAGE_DOWN) {
                    if (popup.isShowing()) {
                        popup.selectNextPage();
                        e.consume();
                    }
                } else if (e.getKeyCode() == KeyEvent.VK_PAGE_UP) {
                    if (popup.isShowing()) {
                        popup.selectPreviousPage();
                        e.consume();
                    }
                }

                // Evite les bips
                if (src.getDocument().getLength() == 0 && (e.getKeyCode() == KeyEvent.VK_DELETE || e.getKeyCode() == KeyEvent.VK_BACK_SPACE)) {
                    this.consume = true;
                    e.consume();
                }

            }

            public void keyReleased(final KeyEvent e) {
            }

            public void keyTyped(final KeyEvent e) {
                // Evite les bips
                if (this.consume) {
                    e.consume();
                    this.consume = false;
                }
            }
        });
        this.text.addFocusListener(new FocusAdapter() {
            @Override
            public void focusLost(FocusEvent e) {
                // close the popup when we leave this component (like JComboBox)
                hideCompletionPopup();
            }
        });
        this.text.addMouseListener(this.clickL);
        this.text.addMouseMotionListener(this.dragL);
    }

    public void setMinimumSearch(final int j) {
        this.minimumSearch = j;
    }

    /**
     * nombre de lettre mini pour chercher dans la liste
     * 
     * @return nombre de lettre minimum.
     */
    public int getMinimumSearch() {
        return this.minimumSearch;
    }

    public void setMaximumResult(final int j) {
        this.maximumResult = j;

    }

    public final SearchMode getCompletionMode() {
        return this.modeCompletion;
    }

    public final void setCompletionMode(SearchMode m) {
        this.modeCompletion = m;
    }

    boolean isEmptyItem(final ISearchableComboItem<T> val) {
        return val == this.emptyItem;
    }

    /**
     * nombre resultat max dans la combo
     * 
     * @return nombre resultat max dans la combo.
     */
    public int getMaximumResult() {
        return this.maximumResult;
    }

    public JComponent getComp() {
        return this;
    }

    // * valid

    @Override
    public ValidState getValidState() {
        final boolean res = this.getMode() != LOCKED || !this.invalidEdit;
        return ValidState.createCached(res, "la valeur ne fait pas partie des choix");
    }

    public void addValidListener(final ValidListener l) {
        this.supp.addValidListener(l);
    }

    @Override
    public void removeValidListener(ValidListener l) {
        this.supp.removeValidListener(l);
    }

    // document

    public Document getDocument() {
        if (this.isLocked())
            return null;

        return this.getTextComp().getDocument();
    }

    public JTextComponent getTextComp() {
        return this.text;
    }

    @Override
    public String toString() {
        final String c = this.getCache() != null ? "with cache" : "without cache";
        final String s = this.isSearchable() ? "" : "/non searchable";
        return this.getClass().getSimpleName() + "(" + this.getMode() + "/" + c + s + ")";
    }

    public String asString() {
        return this.toString() + ":" + this.getValue() + "(\"" + this.getTextComp().getText() + "\")";
    }

}