OpenConcerto

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

svn://code.openconcerto.org/openconcerto

Rev

Rev 149 | 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.
 */
 
 /*
 * Created on 23 janv. 2005
 */
package org.openconcerto.ui.component;

import static org.openconcerto.ui.component.ComboLockedMode.LOCKED;
import static org.openconcerto.ui.component.ComboLockedMode.UNLOCKED;

import org.openconcerto.ui.component.InteractionMode.InteractionComponent;
import org.openconcerto.ui.component.combo.ISearchableComboPopup;
import org.openconcerto.ui.component.text.TextComponent;
import org.openconcerto.ui.valuewrapper.ValueChangeSupport;
import org.openconcerto.ui.valuewrapper.ValueWrapper;
import org.openconcerto.utils.CompareUtils;
import org.openconcerto.utils.IFutureTask;
import org.openconcerto.utils.checks.ValidListener;
import org.openconcerto.utils.checks.ValidState;
import org.openconcerto.utils.model.ListComboBoxModel;
import org.openconcerto.utils.text.DocumentFilterList;
import org.openconcerto.utils.text.SimpleDocumentFilter;
import org.openconcerto.utils.text.SimpleDocumentListener;

import java.awt.Button;
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.awt.event.KeyListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.regex.Pattern;

import javax.swing.ComboBoxEditor;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JList;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.event.DocumentEvent;
import javax.swing.text.AbstractDocument;
import javax.swing.text.BadLocationException;
import javax.swing.text.DocumentFilter;
import javax.swing.text.DocumentFilter.FilterBypass;
import javax.swing.text.JTextComponent;

import net.jcip.annotations.GuardedBy;

/**
 * A comboBox that can be editable or not, and whose values are taken from a ITextComboCache.
 * 
 * @author Sylvain CUAZ
 */
public class ITextCombo extends JComboBox implements ValueWrapper<String>, TextComponent, InteractionComponent {

    /**
     * System property, if <code>true</code> buttons children will not be focusable (allowing
     * quicker tab navigation).
     */
    public static final String SIMPLE_TRAVERSAL = "org.openconcerto.ui.simpleTraversal";

    private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d+");

    private static final String DEFAULTVALUE = "";

    private final String defaultValue;
    private final ComboLockedMode locked;
    private final ValueChangeSupport<String> supp;
    private KeyListener keyListener = null;
    private DocumentFilter docFilter = null;
    protected final boolean autoComplete;
    protected boolean keyPressed;
    private boolean completing;

    // cache
    @GuardedBy("EDT")
    private boolean cacheLoading;
    // only valid while cache is loading
    private String objToSelect;
    @GuardedBy("EDT")
    private InteractionMode modeToSet;
    private InteractionMode interactionBridge;
    protected boolean modifyingDoc;

    private ITextComboCache cache;

    public ITextCombo() {
        this(DEFAULTVALUE);
    }

    public ITextCombo(String defaultValue) {
        this(defaultValue, UNLOCKED);
    }

    public ITextCombo(boolean locked) {
        this(locked ? LOCKED : UNLOCKED);
    }

    public ITextCombo(ComboLockedMode mode) {
        this(DEFAULTVALUE, mode);
    }

    public ITextCombo(String defaultValue, ComboLockedMode mode) {
        super(new ListComboBoxModel());
        // messes with our checkCache
        this.getListModel().setSelectOnAdd(false);
        this.supp = new ValueChangeSupport<String>(this);
        this.locked = mode;

        this.defaultValue = defaultValue;

        this.autoComplete = true;
        this.keyPressed = false;
        this.completing = false;

        this.cache = null;
        this.cacheLoading = false;
        this.modifyingDoc = false;

        final int h = new JTextField("12").getPreferredSize().height;
        this.setMinimumSize(new Dimension(80, h));
        // Test de Preferred Size pour ne pas exploser les GridBagLayouts
        this.setPreferredSize(new Dimension(120, h));
        this.setInteractionMode(InteractionMode.READ_WRITE);

        // ATTN marche car locked est final, sinon il faudrait pouvoir enlever/ajouter les listeners
        if (this.isLocked()) {
            this.addActionListener(new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    ITextCombo.this.supp.fireValueChange();
                }
            });
        } else {
            // pour écouter quand notre contenu change
            // marche à la fois pour edition du texte et la sélection d'un élément
            final SimpleDocumentListener docListener = new SimpleDocumentListener() {
                public void update(DocumentEvent e) {
                    // if we are responsible for this event, ignore it
                    if (!ITextCombo.this.modifyingDoc)
                        setValue(SimpleDocumentListener.getText(e.getDocument()));
                    ITextCombo.this.supp.fireValueChange();
                }
            };
            // listen to editor changes as BasicComboBoxUI.uninstallUI() removes it (happens when
            // changing l&f or locking windows pro)
            this.addPropertyChangeListener("editor", new PropertyChangeListener() {
                {
                    // init
                    changeListener(getTextComp(), true);
                    assert ITextCombo.this.keyListener == null && ITextCombo.this.docFilter == null;
                }

                @Override
                public void propertyChange(PropertyChangeEvent evt) {
                    final JTextComponent oldTextComp = getTextComp((ComboBoxEditor) evt.getOldValue());
                    if (oldTextComp != null) {
                        changeListener(oldTextComp, false);
                        oldTextComp.removeKeyListener(ITextCombo.this.keyListener);
                        DocumentFilterList.remove((AbstractDocument) oldTextComp.getDocument(), ITextCombo.this.docFilter);
                    }

                    final JTextComponent newTextComp = getTextComp((ComboBoxEditor) evt.getNewValue());
                    if (newTextComp != null) {
                        changeListener(newTextComp, true);
                        addCompletionListeners(newTextComp);
                    }
                }

                private final void changeListener(final JTextComponent textComp, final boolean add) {
                    if (add)
                        textComp.getDocument().addDocumentListener(docListener);
                    else
                        textComp.getDocument().removeDocumentListener(docListener);
                }
            });
        }
        this.setRenderer(new DefaultListCellRenderer() {
            @Override
            public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
                final Component res = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
                // works because DefaultListCellRenderer reset the background (which is not the
                // case for DefaultTableCellRenderer)
                if (!isSelected && value != null && value.equals(getValue()))
                    ISearchableComboPopup.setCurrentValueBG(list, res);
                return res;
            }
        });

        if (Boolean.getBoolean(SIMPLE_TRAVERSAL)) {
            for (final Component child : this.getComponents()) {
                if (child instanceof JButton || child instanceof Button)
                    child.setFocusable(false);
            }
        }
        // set default value
        this.resetValue();
    }

    @Override
    public void configureEditor(ComboBoxEditor anEditor, Object anItem) {
        // quand on quitte une combo, elle fait setSelectedItem(), qui appelle editor.setItem()
        // qui fait editor.getComponent().setText(), quit fait un removeAll() suivi d'un addAll()
        // donc emptyChange(true) puis emptyChange(false).
        // Ce qui fait que quand on quitte une combo required pour cliquer sur "ajouter", le bouton
        // flashe (il passe brièvement en grisé) et on ne peut ajouter.
        if (!anEditor.getItem().equals(anItem))
            super.configureEditor(anEditor, anItem);
    }

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

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

    public final boolean hasCache() {
        return this.cache != null;
    }

    public final Future<? extends ITextCombo> initCache(String... values) {
        return this.initCache(Arrays.asList(values));
    }

    public final Future<? extends ITextCombo> initCache(List<String> values) {
        return this.initCache(new ImmutableITextComboCache(values));
    }

    public final Future<? extends ITextCombo> initCache(ITextComboCache cache) {
        if (cache == null)
            throw new NullPointerException("null cache");
        if (this.hasCache())
            throw new IllegalStateException("cache already set " + this.cache);

        this.cache = cache;
        assert this.hasCache();

        new MutableListComboPopupListener(new MutableListCombo() {
            public ComboLockedMode getMode() {
                return ITextCombo.this.getMode();
            }

            public Component getPopupComp() {
                return getEditor().getEditorComponent();
            }

            @Override
            public boolean canModifyCache() {
                return true;
            }

            public void addCurrentText() {
                ITextCombo.this.addCurrentText();
            }

            public void removeCurrentText() {
                ITextCombo.this.removeCurrentText();
            }

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

            @Override
            public void reload() {
                ITextCombo.this.loadCache(true);
            }
        }).listen();

        final Future<? extends ITextCombo> future = this.loadCache(false);

        // ATTN marche car locked est final
        if (!this.isLocked()) {
            this.keyListener = new KeyAdapter() {
                @Override
                public void keyTyped(KeyEvent e) {
                    // not keyPressed() else we activate the completion as soon as any key is
                    // pressed (even ctrl)
                    ITextCombo.this.keyPressed = true;
                }

                @Override
                public void keyReleased(KeyEvent e) {
                    ITextCombo.this.keyPressed = false;
                }
            };
            this.docFilter = new SimpleDocumentFilter() {
                @Override
                protected boolean change(FilterBypass fb, String newText, Mode mode) throws BadLocationException {
                    // do not complete a remove (otherwise impossible to remove the last char for
                    // example), only complete when the user is typing (eg a key is pressed)
                    // otherwise just setting the value to something that can be completed changes
                    // it.
                    if (mode != Mode.REMOVE && ITextCombo.this.autoComplete && ITextCombo.this.keyPressed)
                        return complete(fb, newText);
                    else
                        return true;
                }
            };
            addCompletionListeners(this.getTextComp());
        }
        return future;
    }

    protected final void addCompletionListeners(final JTextComponent textComp) {
        textComp.addKeyListener(ITextCombo.this.keyListener);
        DocumentFilterList.add((AbstractDocument) textComp.getDocument(), ITextCombo.this.docFilter);
    }

    protected final boolean complete(FilterBypass fb, final String originalText) throws BadLocationException {
        // no need to check the cache since we only use the combo items
        // and they only are modified by the EDT, our executing thread too
        boolean res = true;
        if (!this.completing) {
            this.completing = true;
            // ne completer que si le texte fait plus de 2 char et n'est pas que des chiffres
            if (canComplete(originalText)) {
                String completion = this.getCompletion(originalText);
                if (completion != null && !originalText.trim().equalsIgnoreCase(completion.trim())) {
                    fb.replace(0, fb.getDocument().getLength(), completion, null);
                    // we handled the modification
                    res = false;
                    this.getTextComp().setSelectionStart(originalText.length());
                    this.getTextComp().setSelectionEnd(completion.length());
                }
            }
            this.completing = false;
        }
        return res;
    }

    /**
     * hook to activate or not complete for a given text
     * 
     * @return true if completion must occur
     */
    public boolean canComplete(final String originalText) {
        return originalText.length() > 2 && !DIGIT_PATTERN.matcher(originalText).matches();
    }

    /**
     * Recherche si on peut completer la string avec les items de completion
     * 
     * @param string the start
     * @return <code>null</code> si pas trouve, sinon le mot complet
     */
    private String getCompletion(String string) {
        if (string.length() < 1) {
            return null;
        }

        int count = 0;
        String result = null;
        for (final Object obj : this.getListModel().getList()) {
            final String item = (String) obj;
            if (item.startsWith(string)) {
                count++;
                result = item;
            }
        }
        if (count == 1)
            return result;
        else
            return null;
    }

    private ListComboBoxModel getListModel() {
        return (ListComboBoxModel) this.getModel();
    }

    @Override
    public InteractionMode getInteractionMode() {
        return this.interactionBridge;
    }

    @Override
    public void setInteractionMode(InteractionMode mode) {
        assert SwingUtilities.isEventDispatchThread();
        if (this.cacheLoading) {
            this.modeToSet = mode;
        } else if (mode != this.interactionBridge) {
            this.interactionBridge = mode;
            // to support R/O we disable the combo and put and non editable text field
            super.setEnabled(mode.isEditable());
            final boolean superEditable = !isLocked() || mode == InteractionMode.READ_ONLY;
            super.setEditable(superEditable);
            if (superEditable) {
                final JTextComponent comp = getTextComp(this.getEditor());
                mode.applyTo(comp);
            } else {
                assert getTextComp(this.getEditor()) == null || !getTextComp(this.getEditor()).isDisplayable();
            }
        }
    }

    @Override
    public void setEditable(boolean b) {
        // cannot overload setEditable() to behave like JTextComponent : since we cannot overload
        // isEditable() this would translate to an incoherence between setEditable() and
        // isEditable()
        super.setEditable(b);
    }

    // cannot overload isEditable() to behave like JTextComponent since the look and feels don't
    // support it

    @Override
    public void setEnabled(boolean b) {
        this.setInteractionMode(b ? InteractionMode.READ_WRITE : InteractionMode.DISABLED);
    }

    // *** cache

    /**
     * Load the cache passed to {@link #initCache(ITextComboCache)} into this.
     * 
     * @param force <code>true</code> if the cache should be refreshed.
     * @return a future returning <code>this</code> after this is filled by the cache or
     *         <code>null</code> if this already being filled. NOTE : do not block on this future in
     *         the EDT, as it needs to finish in the EDT. So this would cause a deadlock.
     */
    public synchronized final Future<? extends ITextCombo> loadCache(final boolean force) {
        assert SwingUtilities.isEventDispatchThread();
        if (!this.cacheLoading) {
            this.modeToSet = this.getInteractionMode();
            this.setEnabled(false);
            // value cannot be changed by user since this UI is disabled
            this.objToSelect = this.getValue();
            this.cacheLoading = true;
            final FutureTask<? extends ITextCombo> noop = IFutureTask.createNoOp(this);
            final SwingWorker<List<String>, Object> sw = new SwingWorker<List<String>, Object>() {
                @Override
                protected List<String> doInBackground() throws Exception {
                    return force ? ITextCombo.this.cache.loadCache(false) : ITextCombo.this.cache.getCache();
                }

                @Override
                protected void done() {
                    synchronized (this) {
                        ITextCombo.this.modifyingDoc = true;
                    }
                    getListModel().removeAllElements();
                    try {
                        getListModel().addAll(this.get());
                    } catch (Exception e) {
                        e.printStackTrace();
                        getListModel().addElement(e.getLocalizedMessage());
                    }
                    synchronized (this) {
                        ITextCombo.this.modifyingDoc = false;
                        ITextCombo.this.cacheLoading = false;
                    }
                    // otherwise getSelectedItem() always returns null
                    if (isLocked() && getModel().getSize() == 0)
                        throw new IllegalStateException(ITextCombo.this + " locked but no items.");
                    // restaurer l'état
                    setInteractionMode(ITextCombo.this.modeToSet);
                    setValue(ITextCombo.this.objToSelect);
                    // we can't notify our caller at the time invokeLater() is called by the
                    // background thread since there's always a delay after setState() : see
                    // DoSubmitAccumulativeRunnable
                    noop.run();
                }
            };
            sw.execute();
            return noop;
        } else {
            return null;
        }
    }

    private final Object makeObj(final String item) {
        return item;
        // see #addItem ; not necessary since there's never any duplicates
    }

    /**
     * Add <code>s</code> to the list if it's not empty and not already present.
     * 
     * @param s the string to be added, can be <code>null</code>.
     * @return <code>true</code> if s is really added.
     */
    private final boolean addToCache(String s) {
        final boolean added = s != null && s.length() > 0 && this.getListModel().getList().indexOf(s) < 0;
        if (added) {
            final Object obj = makeObj(s);
            // BasicComboBoxUI$Handler.intervalAdded(ListDataEvent) calls contentsChanged() which
            // calls JComboBox.configureEditor() with the current selection. But the selection is
            // only set by BasicComboBoxUI.Handler.focusLost() so this.addItem() replaces the
            // current editor value by the current selection.
            this.completing = true;
            this.setSelectedItem(obj);
            this.completing = false;
            this.addItem(obj);
        }
        return added;
    }

    private final void removeCurrentText() {
        final String t = this.getTextComp().getText();
        this.cache.deleteFromCache(t);
        for (int i = 0; i < this.getItemCount(); i++) {
            final String o = (String) this.getItemAt(i);
            if (o.equals(t)) {
                this.removeItemAt(i);
                break;
            }
        }
    }

    private final void addCurrentText() {
        final String t = this.getTextComp().getText();
        if (this.addToCache(t)) {
            this.cache.addToCache(t);
        }
    }

    // *** value

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

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

    final boolean isCacheLoading() {
        return this.cacheLoading;
    }

    synchronized public final void setValue(String val) {
        if (!CompareUtils.equals(this.getValue(), val)) {
            if (this.cacheLoading) {
                this.objToSelect = val;
                this.supp.fireValueChange();
            } else {
                // complete only user input, not programmatic
                this.completing = true;
                this.setSelectedItem(makeObj(val));
                this.completing = false;
            }
        }
    }

    public void resetValue() {
        this.setValue(this.defaultValue);
    }

    @Override
    public String getValue() {
        if (this.cacheLoading)
            return this.objToSelect;
        else
            return this.getCurrentValue();
    }

    public String getCurrentValue() {
        // this.getSelectedItem() renvoie vide quand on tape du texte sans sélection
        final Object res;
        if (this.isLocked()) {
            res = this.getSelectedItem();
        } else {
            final ComboBoxEditor editor = this.getEditor();
            // as documented in the constructor, the editor can sometimes be null
            res = editor == null ? null : editor.getItem();
        }
        return (String) res;
    }

    public JComponent getComp() {
        return this;
    }

    @Override
    public ValidState getValidState() {
        // string toujours valide
        return ValidState.getTrueInstance();
    }

    @Override
    public void addValidListener(ValidListener l) {
        // nothing to do
    }

    @Override
    public void removeValidListener(ValidListener l) {
        // nothing to do
    }

    // document

    private final JTextComponent getTextComp(final ComboBoxEditor editor) {
        if (editor != null) {
            final Component editorComp = editor.getEditorComponent();
            if (editorComp instanceof JTextComponent)
                return (JTextComponent) editorComp;
        }
        return null;
    }

    @Override
    public JTextComponent getTextComp() {
        if (this.isLocked())
            return null;
        else
            return getTextComp(this.getEditor());
    }

    @Override
    public String toString() {
        return this.getClass().getName() + " " + this.locked + " cache: " + this.cache;
    }

}