OpenConcerto

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

svn://code.openconcerto.org/openconcerto

Rev

Rev 174 | 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-2019 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.sql.view.list;

import org.openconcerto.openoffice.XMLFormatVersion;
import org.openconcerto.openoffice.spreadsheet.SpreadSheet;
import org.openconcerto.sql.Log;
import org.openconcerto.sql.TM;
import org.openconcerto.sql.element.SQLComponent;
import org.openconcerto.sql.element.SQLElement;
import org.openconcerto.sql.element.SQLElementDirectory;
import org.openconcerto.sql.model.RowRef;
import org.openconcerto.sql.model.SQLField;
import org.openconcerto.sql.model.SQLRow;
import org.openconcerto.sql.model.SQLRowAccessor;
import org.openconcerto.sql.model.SQLRowValues;
import org.openconcerto.sql.model.SQLTable;
import org.openconcerto.sql.model.Where;
import org.openconcerto.sql.request.ComboSQLRequest.KeepMode;
import org.openconcerto.sql.request.ListSQLRequest;
import org.openconcerto.sql.request.UpdateBuilder;
import org.openconcerto.sql.users.User;
import org.openconcerto.sql.users.UserManager;
import org.openconcerto.sql.users.rights.TableAllRights;
import org.openconcerto.sql.view.FileTransfertHandler;
import org.openconcerto.sql.view.IListener;
import org.openconcerto.sql.view.RowMetadata;
import org.openconcerto.sql.view.RowMetadataCache;
import org.openconcerto.sql.view.list.IListeAction.ButtonsBuilder;
import org.openconcerto.sql.view.list.IListeAction.IListeEvent;
import org.openconcerto.sql.view.list.IListeAction.PopupBuilder;
import org.openconcerto.sql.view.list.IListeAction.PopupEvent;
import org.openconcerto.sql.view.list.RowAction.PredicateRowAction;
import org.openconcerto.sql.view.list.action.ListEvent;
import org.openconcerto.sql.view.list.action.SQLRowValuesAction;
import org.openconcerto.ui.FontUtils;
import org.openconcerto.ui.FormatEditor;
import org.openconcerto.ui.MenuUtils;
import org.openconcerto.ui.PopupMouseListener;
import org.openconcerto.ui.SwingThreadUtils;
import org.openconcerto.ui.list.selection.BaseListStateModel;
import org.openconcerto.ui.list.selection.ListSelection;
import org.openconcerto.ui.list.selection.ListSelectionState;
import org.openconcerto.ui.state.JTableStateManager;
import org.openconcerto.ui.table.AlternateTableCellRenderer;
import org.openconcerto.ui.table.ColumnSizeAdjustor;
import org.openconcerto.ui.table.TableColumnModelAdapter;
import org.openconcerto.ui.table.TablePopupMouseListener;
import org.openconcerto.ui.table.ViewTableModel;
import org.openconcerto.ui.table.XTableColumnModel;
import org.openconcerto.utils.CollectionUtils;
import org.openconcerto.utils.CompareUtils;
import org.openconcerto.utils.FormatGroup;
import org.openconcerto.utils.StringUtils;
import org.openconcerto.utils.TableModelSelectionAdapter;
import org.openconcerto.utils.TableSorter;
import org.openconcerto.utils.Tuple2;
import org.openconcerto.utils.cc.IPredicate;
import org.openconcerto.utils.cc.ITransformer;
import org.openconcerto.utils.convertor.StringClobConvertor;
import org.openconcerto.utils.text.BooleanFormat;

import java.awt.Color;
import java.awt.Component;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.ActionEvent;
import java.awt.event.HierarchyEvent;
import java.awt.event.HierarchyListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeListenerProxy;
import java.beans.PropertyChangeSupport;
import java.io.File;
import java.io.IOException;
import java.sql.Clob;
import java.sql.Time;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.text.Format;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.EventObject;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.function.BiFunction;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.DropMode;
import javax.swing.InputMap;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.event.AncestorEvent;
import javax.swing.event.AncestorListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.event.TableColumnModelEvent;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.TableCellRenderer;
import javax.swing.table.TableColumn;
import javax.swing.table.TableColumnModel;
import javax.swing.table.TableModel;

import net.jcip.annotations.GuardedBy;

/**
 * Une liste de lignes correspondant à une ListSQLRequest. Diagramme pour la sélection :
 * <img src="doc-files/listSelection.png"/><br/>
 * 
 * @author ILM Informatique
 */
public final class IListe extends JPanel {

    static private final class LockAction extends SQLRowValuesAction {
        private final boolean lock;

        public LockAction(final boolean lock) {
            super(false, true, (e) -> {
                final List<Number> ids = e.getSelectedIDs();
                final SQLTable t = e.getTable();
                final UpdateBuilder update = new UpdateBuilder(t);
                update.setObject(SQLComponent.READ_ONLY_FIELD, lock ? SQLComponent.READ_ONLY_VALUE : SQLComponent.READ_WRITE_VALUE);
                final User user = UserManager.getUser();
                if (user != null)
                    update.setObject(SQLComponent.READ_ONLY_USER_FIELD, user.getId());
                update.setWhere(new Where(t.getKey(), ids));
                t.getDBSystemRoot().getDataSource().execute(update.asString());
                // don't fire too many times, as each one will cause UpdateQueue to issue a
                // request
                final Collection<? extends Number> fireIDs = ids.size() < 12 ? ids : Collections.singleton(SQLRow.NONEXISTANT_ID);
                for (final Number fireID : fireIDs)
                    t.fireTableModified(fireID.intValue(), update.getFieldsNames());
            });
            this.setName(TM.tr(lock ? "ilist.lockRows" : "ilist.unlockRows"));
            this.lock = lock;
        }

        @Override
        public boolean enabledFor(ListEvent evt) {
            boolean hasRight = TableAllRights.currentUserHasRight(this.lock ? TableAllRights.USER_UI_LOCK_ROW : TableAllRights.USER_UI_UNLOCK_ROW, evt.getTable());
            return !evt.getSelectedRowAccessors().isEmpty() && hasRight;
        }
    }

    private static LockAction LOCK_ACTION;
    private static LockAction UNLOCK_ACTION;

    private static final LockAction getLockAction() {
        assert SwingUtilities.isEventDispatchThread();
        // don't create too early as we might not have the localisation available. Further some
        // applications will never use it.
        if (LOCK_ACTION == null)
            LOCK_ACTION = new LockAction(true);
        return LOCK_ACTION;
    }

    private static final LockAction getUnlockAction() {
        assert SwingUtilities.isEventDispatchThread();
        if (UNLOCK_ACTION == null)
            UNLOCK_ACTION = new LockAction(false);
        return UNLOCK_ACTION;
    }

    private static final int MD_BATCH_SIZE = 100;
    private static final RowMetadataCache MD_CACHE = new RowMetadataCache(120, 5000, IListe.class.getName());

    /**
     * When this system property is set, table {@link JTableStateManager state} is never read nor
     * written. I.e. the user can change the table state but it will be reset at each launch.
     */
    public static final String STATELESS_TABLE_PROP = "org.openconcerto.sql.list.statelessTable";
    private static final String SELECTION_DATA_PROPNAME = "selectionData";

    static private final class FormatRenderer extends DefaultTableCellRenderer {
        private final Format fmt;

        private FormatRenderer(Format fmt) {
            super();
            this.fmt = fmt;
        }

        @Override
        protected void setValue(Object value) {
            this.setText(value == null ? "" : this.fmt.format(value));
        }
    }

    private static boolean FORCE_ALT_CELL_RENDERER = false;
    static final String SEP = " ► ";

    // DefaultTableCellRenderer is stateful, so safer to not share (JTable also has private
    // instances, see createDefaultRenderers())
    public static final TableCellRenderer createDateRenderer() {
        return new FormatRenderer(DateFormat.getDateInstance(DateFormat.MEDIUM));
    }

    public static final TableCellRenderer createTimeRenderer() {
        return new FormatRenderer(DateFormat.getTimeInstance(DateFormat.SHORT));
    }

    public static final TableCellRenderer createDateTimeRenderer() {
        return new FormatRenderer(DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT));
    }

    private static final Map<Class<?>, FormatGroup> FORMATS;
    static {
        FORMATS = new HashMap<Class<?>, FormatGroup>();
        FORMATS.put(Date.class, new FormatGroup(DateFormat.getDateInstance(DateFormat.SHORT), DateFormat.getDateInstance(DateFormat.MEDIUM), DateFormat.getDateInstance(DateFormat.LONG)));
        // longer first otherwise seconds are not displayed by the cell editor and will be lost
        FORMATS.put(Time.class, new FormatGroup(DateFormat.getTimeInstance(DateFormat.MEDIUM), DateFormat.getTimeInstance(DateFormat.SHORT)));
        FORMATS.put(Timestamp.class, new FormatGroup(DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM), DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT),
                DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.MEDIUM), DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT)));

    }

    public static final void remove(InputMap m, KeyStroke key) {
        InputMap current = m;
        while (current != null) {
            current.remove(key);
            current = current.getParent();
        }
    }

    /**
     * Whether to force table cell renderers to always be alternate. I.e. even after the list
     * creation, if the renderer of a cell is changed, a listener will wrap it in an
     * {@link AlternateTableCellRenderer} if necessary.
     * 
     * @param force <code>true</code> to listen to renderer change, and wrap it in an
     *        {@link AlternateTableCellRenderer}.
     */
    public static void setForceAlternateCellRenderer(boolean force) {
        FORCE_ALT_CELL_RENDERER = force;
    }

    public static final IListe get(EventObject evt) {
        return SwingThreadUtils.getAncestorOrSelf(IListe.class, (Component) evt.getSource());
    }

    // *** instance

    private final JTable jTable;
    private final JTextField filter;
    private boolean debugFilter;
    @GuardedBy("this")
    private FilterWorker filterWorker;
    // optional popup on the table
    private final JPopupMenu popup;
    private final TableSorter sorter;
    @GuardedBy("this")
    // record the source when non-displayable (ie getModel() == null), also allow to be read outside
    // of the EDT
    private SQLTableModelSource src;
    private boolean adjustVisible;
    private ColumnSizeAdjustor tcsa;

    private final Map<IListeAction, ButtonsBuilder> rowActions;
    // double-click
    private IListeAction defaultRowAction;
    private final JPanel btnPanel;
    private final Map<Class<?>, FormatGroup> searchFormats;

    // * selection
    private final List<IListener> listeners;
    private final List<IListener> naListeners;

    // * listeners
    private final PropertyChangeSupport supp;
    // for not adjusting listeners
    private final ListSelectionListener selectionListener;
    private final TableModelListener selectionDataListener;
    // filter
    private final PropertyChangeListener filterListener;
    // listen on model's properties
    private final List<PropertyChangeListener> modelPCListeners;

    private final ListSelectionState state;
    private final JTableStateManager tableStateManager;

    private int retainCount = 0;

    private boolean cellModificationAllowed = ITableModel.isDefaultCellsEditable();
    private boolean orderModificationAllowed = ITableModel.isDefaultOrderEditable();

    public IListe(final SQLTableModelSource req) {
        this(req, null);
    }

    public IListe(final SQLTableModelSource req, final File configFile) {
        if (req == null)
            throw new NullPointerException("Création d'une IListe avec une requete null");

        this.rowActions = new LinkedHashMap<IListeAction, ButtonsBuilder>();
        this.supp = new PropertyChangeSupport(this);
        this.listeners = new ArrayList<IListener>();
        this.naListeners = new ArrayList<IListener>();
        this.modelPCListeners = new ArrayList<PropertyChangeListener>();

        this.sorter = new TableSorter();
        this.jTable = new JTable(this.sorter) {

            // By default the tooltip doesn't follow the mouse if the string remains the same
            // (probably for performance reasons)
            private final boolean followMouseWorkaround = !Boolean.getBoolean("jtable.tooltip_follow_mouse.disable");

            @Override
            public String getToolTipText(MouseEvent event) {
                final String original = super.getToolTipText(event);

                // Locate the row under the event location
                final int rowIndex = rowAtPoint(event.getPoint());
                // has already happened on M3 (not sure how)
                if (rowIndex < 0)
                    return original;

                final List<String> infoL = new ArrayList<String>();
                if (original != null) {
                    final String html = "<html>";
                    if (original.startsWith(html))
                        // -1 since the closing tag is </html>
                        infoL.add(original.substring(html.length(), original.length() - html.length() - 1));
                    else
                        infoL.add(original);
                }

                final SQLRowAccessor row = ITableModel.getLine(this.getModel(), rowIndex).getRowAccessor();

                final RowRef cacheKey = row.getRowRef();
                final RowMetadata md = MD_CACHE.get(cacheKey);
                if (md != null) {
                    final String create = getLine(true, md);
                    final String modif = getLine(false, md);
                    if (create == null && modif == null) {
                        infoL.add(TM.tr("ilist.metadata.na"));
                    } else {
                        if (create != null)
                            infoL.add(create);
                        if (modif != null)
                            infoL.add(modif);
                    }
                    // TODO locked by

                } else {
                    final int half = MD_BATCH_SIZE / 2;
                    final int firstIndex = Math.max(0, rowIndex - half);
                    final int lastIndex = Math.min(getRowCount(), rowIndex + half);
                    final Set<Number> ids = CollectionUtils.newHashSet(MD_BATCH_SIZE);
                    for (int i = firstIndex; i < lastIndex; i++) {
                        ids.add(ITableModel.getLine(this.getModel(), i).getRowAccessor().getIDNumber());
                    }
                    MD_CACHE.fetch(cacheKey, ids);

                    infoL.add(TM.tr("ilist.metadata.loading"));
                }

                final String info;
                if (infoL.size() == 0) {
                    info = null;
                } else {
                    final StringBuilder sb = new StringBuilder(256);
                    sb.append("<html>");
                    sb.append(CollectionUtils.join(infoL, "<br/>"));
                    sb.append("</html>");
                    if (this.followMouseWorkaround) {
                        // This force the JRE to repaint the tooltip at the mouse location :
                        // 1. even without mainInfo changing (e.g. "unavailable")
                        // 2. but only when changing row
                        // Otherwise (e.g. adding or not a space at the end, for each call) the
                        // tooltip is drawn continuously and CPU load is quite heavy.
                        sb.append("<!--");
                        sb.append(rowIndex);
                        sb.append("-->");
                    }
                    info = sb.toString();
                }

                return info;
            }

            public String getLine(final boolean created, final RowMetadata md) {
                final Date date = created ? md.getCreation() : md.getModification();
                final Integer userID = created ? md.getUserCreate() : md.getUserModify();
                if (userID == null && date == null)
                    return null;

                final int userParam;
                final String firstName, lastName;
                if (userID != null) {
                    userParam = 1;
                    final User user = UserManager.getInstance().getUser(userID);
                    firstName = user.getFirstName();
                    lastName = user.getName();
                } else {
                    userParam = 0;
                    firstName = null;
                    lastName = null;
                }

                return TM.tr("ilist.metadata", created ? 1 : 0, userParam, firstName, lastName, date == null ? 0 : 1, date);
            }

            @Override
            protected TableColumnModel createDefaultColumnModel() {
                // allow to hide columns
                return new XTableColumnModel();
            }

            // only load from XML once
            private boolean stateLoaded = false;

            @Override
            public void createDefaultColumnsFromModel() {
                final XTableColumnModel cm = getColumnModel() instanceof XTableColumnModel ? (XTableColumnModel) getColumnModel() : null;
                final Set<Object> invisibleCols = new HashSet<Object>();
                if (cm != null) {
                    // Remove any current columns, including invisible ones
                    while (cm.getColumnCount(false) > 0) {
                        final TableColumn col = cm.getColumn(0, false);
                        if (!cm.isColumnVisible(col)) {
                            if (!invisibleCols.add(col.getIdentifier()))
                                throw new IllegalStateException("Duplicate identifier " + col.getIdentifier());
                        }
                        cm.removeColumn(col);
                    }
                }
                super.createDefaultColumnsFromModel();
                final boolean stateLoadedByThisMethod;
                // don't try to load state from XML, when e.g. the list is switching to debug
                if (this.stateLoaded) {
                    stateLoadedByThisMethod = false;
                } else {
                    // only load when all columns are created
                    stateLoadedByThisMethod = loadTableState();
                    this.stateLoaded = stateLoadedByThisMethod;
                }
                // don't overwrite state loaded by XML
                if (!stateLoadedByThisMethod && cm != null) {
                    for (final TableColumn col : new ArrayList<TableColumn>(cm.getColumns(false))) {
                        cm.setColumnVisible(col, !invisibleCols.contains(col.getIdentifier()));
                    }
                }
            };
        };
        this.adjustVisible = true;
        this.tcsa = null;
        this.filter = new JTextField();
        this.filter.setEditable(false);
        this.debugFilter = false;

        // do not handle F2, let our application use it :
        // remove F2 keybinding, use space
        final InputMap tm = this.jTable.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
        remove(tm, KeyStroke.getKeyStroke("F2"));
        tm.put(KeyStroke.getKeyStroke(' '), "startEditing");
        // don't auto start, otherwise F2 will trigger the edition
        this.jTable.putClientProperty("JTable.autoStartsEdit", Boolean.FALSE);

        // Better look
        this.jTable.setShowHorizontalLines(false);
        this.jTable.setGridColor(new Color(230, 230, 230));
        this.jTable.setRowHeight(FontUtils.getPreferredRowHeight(this.jTable));

        this.popup = new JPopupMenu();
        TablePopupMouseListener.add(this.jTable, new ITransformer<MouseEvent, JPopupMenu>() {
            @Override
            public JPopupMenu transformChecked(MouseEvent input) {
                return updatePopupMenu(true);
            }
        });
        this.jTable.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                if (e.getClickCount() == 2)
                    performDefaultAction(e);
            }
        });

        this.selectionListener = new ListSelectionListener() {
            public void valueChanged(ListSelectionEvent e) {
                if (!e.getValueIsAdjusting()) {
                    fireNASelectionId();
                    updateButtons();
                }
            }
        };
        this.selectionDataListener = new TableModelListener() {
            @Override
            public void tableChanged(TableModelEvent e) {
                // assert we're not listening to the sorter since we're interested in data change
                // not sort order
                assert e.getSource() instanceof ITableModel;
                boolean fire = false;
                // insert or delete don't change the content of current selection (e.g. if the
                // deleted row was part of the selection "selectedIDs" will change)
                // a change in the name or order of columns doesn't mean the SQL values are updated
                if (e.getType() == TableModelEvent.UPDATE && e.getFirstRow() != TableModelEvent.HEADER_ROW) {
                    // see TableModelEvent(TableModel) constructor
                    if (e.getLastRow() == Integer.MAX_VALUE) {
                        // since JTable uses a regular listener to update its selection and the
                        // listeners are called in reverse order, the selection isn't yet cleared by
                        // JTable.tableChanged(). Thus if the table was just shrunk, the selection
                        // might be out of bounds. So don't fire now, let
                        // JTable.clearSelectionAndLeadAnchor() do it.
                        fire = false;
                    } else {
                        // do fire if only some rows were updated as in this case, no selection
                        // change will occur.
                        for (int i = e.getFirstRow(); !fire && i <= e.getLastRow(); i++) {
                            if (getJTable().getSelectionModel().isSelectedIndex(IListe.this.sorter.viewIndex(i)))
                                fire = true;
                        }
                    }
                }
                if (fire)
                    IListe.this.supp.firePropertyChange(SELECTION_DATA_PROPNAME, null, null);
            }
        };
        this.filterListener = new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent evt) {
                updateFilter();
            }
        };
        this.jTable.getColumnModel().addColumnModelListener(new TableColumnModelAdapter() {
            // invoked by toggleAutoAdjust(), ITableModel.setDebug() or updateColNames()
            @Override
            public void columnAdded(TableColumnModelEvent e) {
                updateCols(e.getToIndex());
            }
        });
        this.tableStateManager = new JTableStateManager(this.jTable);
        this.setConfigFile(configFile);

        // MAYBE only set this.src and let the model be null so that the mere creation of an IListe
        // does not spawn several threads and access the db. But a lot of code assumes there's
        // immediately a model.
        this.setSource(req);
        this.state = ListSelectionState.manage(this.jTable.getSelectionModel(), new TableListStateModel(this.sorter));
        this.state.addPropertyChangeListener("selectedIndex", new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent evt) {
                final Number newValue = (Number) evt.getNewValue();
                // if there's no selection (eg some lines were removed)
                // don't try to scroll (it will go to the top)
                if (newValue.intValue() >= 0)
                    IListe.this.jTable.scrollRectToVisible(IListe.this.jTable.getCellRect(newValue.intValue(), 0, true));
            }
        });
        this.state.addPropertyChangeListener("selectedID", new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent evt) {
                fireSelectionId(((Number) evt.getNewValue()).intValue(), IListe.this.jTable.getSelectedColumn());
            }
        });
        // don't use userSelectedIDs as we need to fire when the whole list is changed, see
        // this.selectionDataListener
        this.state.addPropertyChangeListener("selectedIDs", new PropertyChangeListener() {
            public void propertyChange(PropertyChangeEvent evt) {
                IListe.this.supp.firePropertyChange(SELECTION_DATA_PROPNAME, null, null);
            }
        });
        // this.jTable.setEnabled(!updating) ne sert à rien
        // car les updates du ITableModel se font de manière synchrone dans la EDT
        // donc on ne peut faire aucune action pendant les maj

        this.btnPanel = new JPanel(new FlowLayout(FlowLayout.LEADING));
        this.addListenerOnModel(new PropertyChangeListener() {
            @Override
            public void propertyChange(PropertyChangeEvent evt) {
                // let the header buttons know that the rows have changed
                final boolean doneUpdating = "updating".equals(evt.getPropertyName()) && Boolean.FALSE.equals(evt.getNewValue());
                if (doneUpdating || "cellsEditable".equals(evt.getPropertyName()))
                    updateButtons();
            }
        });
        this.searchFormats = new HashMap<Class<?>, FormatGroup>(this.getFormats());
        // localized boolean search
        this.searchFormats.put(Boolean.class, new FormatGroup(new BooleanFormat(), BooleanFormat.getNumberInstance(), BooleanFormat.createYesNo(Locale.getDefault())));
        // on edition we want to force the user to enter a time, so it doesn't blindly paste a date
        // and erase the time part. But on search it's quicker to filter with > 25/12/99
        final List<Format> wAndwoTime = new ArrayList<Format>();
        wAndwoTime.addAll(this.searchFormats.get(Timestamp.class).getFormats());
        wAndwoTime.addAll(this.searchFormats.get(Date.class).getFormats());
        this.searchFormats.put(Timestamp.class, new FormatGroup(wAndwoTime));

        uiInit();
    }

    /**
     * Formats used for editing cells.
     * 
     * @return a mapping between cell value's class and its format.
     */
    public final Map<Class<?>, FormatGroup> getFormats() {
        return FORMATS;
    }

    public final Map<Class<?>, FormatGroup> getSearchFormats() {
        return this.searchFormats;
    }

    public final RowAction addRowAction(Action action) {
        return this.addRowAction(action, null);
    }

    public final RowAction addRowAction(Action action, String id) {
        // for backward compatibility don't put in header
        final RowAction res = new PredicateRowAction(action, false, true, id).setPredicate(IListeEvent.getSingleSelectionPredicate());
        this.addIListeAction(res);
        return res;
    }

    // Transitional class while we convert RowAction to SQLRowValuesAction
    @Deprecated
    static public final class ConvertedAction extends SQLRowValuesAction {
        private final RowAction rowAction;

        public ConvertedAction(final RowAction a) {
            super(a.inHeader(), a.inPopupMenu(), a.getID(), (evt) -> {
                a.getAction().actionPerformed(new ActionEvent(evt.getSource(), ActionEvent.ACTION_PERFORMED, null));
            });
            this.rowAction = a;
            if (a.getAction().getValue(Action.NAME) != null)
                this.setName(String.valueOf(a.getAction().getValue(Action.NAME)));
        }

        @Override
        public boolean enabledFor(ListEvent evt) {
            return this.getRowAction().enabledFor(evt);
        }

        public final RowAction getRowAction() {
            return this.rowAction;
        }
    }

    public final RowAction addRowValuesAction(SQLRowValuesAction a) {
        final RowAction action;
        if (a instanceof ConvertedAction) {
            action = ((ConvertedAction) a).getRowAction();
        } else {
            action = new RowAction(new AbstractAction(a.getName()) {
                @Override
                public void actionPerformed(ActionEvent e) {
                    a.getAction().accept(IListe.get(e).createListEvent());
                }
            }, a.inHeader(), a.inPopupMenu(), a.getID()) {

                @Override
                public boolean enabledFor(ListEvent evt) {
                    return a.enabledFor(evt);
                }

            };
        }
        if (this.addIListeAction(action))
            return action;
        else
            return null;
    }

    public final Map<SQLRowValuesAction, IListeAction> addRowValuesActions(Collection<? extends SQLRowValuesAction> actions) {
        final Map<SQLRowValuesAction, IListeAction> res = new IdentityHashMap<>();
        for (final SQLRowValuesAction a : actions) {
            final RowAction action = addRowValuesAction(a);
            if (action != null)
                res.put(a, action);
        }
        return res;
    }

    public final void addIListeActions(Collection<? extends IListeAction> actions) {
        for (final IListeAction a : actions)
            this.addIListeAction(a);
    }

    private final int findGroupIndex(final String groupName) {
        if (groupName != null) {
            final Component[] components = this.btnPanel.getComponents();
            for (int i = components.length - 1; i >= 0; i--) {
                final JComponent comp = (JComponent) components[i];
                if (groupName.equals(comp.getClientProperty(ButtonsBuilder.GROUPNAME_PROPNAME))) {
                    return i + 1;
                }
            }
        }
        return -1;
    }

    public final boolean addIListeAction(IListeAction action) {
        // we need to handle addition of an already added action at least for setDefaultRowAction()
        if (this.rowActions.containsKey(action))
            return false;
        final ButtonsBuilder headerBtns = action.getHeaderButtons();
        this.rowActions.put(action, headerBtns);
        if (headerBtns.getContent().size() > 0) {
            updateButton(headerBtns, this.createListEvent());
            for (final JButton headerBtn : headerBtns.getContent().keySet()) {
                headerBtn.setOpaque(false);
                this.btnPanel.add(headerBtn, findGroupIndex((String) headerBtn.getClientProperty(ButtonsBuilder.GROUPNAME_PROPNAME)));
            }
            this.btnPanel.setVisible(true);
        }
        return true;
    }

    public final void removeIListeActions(Collection<? extends IListeAction> actions) {
        for (final IListeAction a : actions)
            this.removeIListeAction(a);
    }

    public final void removeIListeAction(IListeAction action) {
        final ButtonsBuilder headerBtns = this.rowActions.remove(action);
        // handle the removal of inexistent action (ButtonsBuilder can not be null)
        if (headerBtns == null)
            return;
        for (final JButton headerBtn : headerBtns.getContent().keySet()) {
            this.btnPanel.remove(headerBtn);
            if (this.btnPanel.getComponentCount() == 0)
                this.btnPanel.setVisible(false);
            this.btnPanel.revalidate();
        }
        if (action.equals(this.defaultRowAction))
            this.setDefaultRowAction(null);
    }

    private void updateButtons() {
        final IListeEvent evt = this.createListEvent();
        for (final ButtonsBuilder btns : this.rowActions.values()) {
            this.updateButton(btns, evt);
        }
    }

    private void updateButton(final ButtonsBuilder btns, final IListeEvent evt) {
        for (final Entry<JButton, IPredicate<IListeEvent>> e : btns.getContent().entrySet()) {
            e.getKey().setEnabled(e.getValue().evaluateChecked(evt));
        }
    }

    private JPopupMenu updatePopupMenu(final boolean onRows) {
        this.popup.removeAll();
        final PopupEvent evt = this.createPopupEvent(onRows);
        final Action defaultAction = this.defaultRowAction != null ? this.defaultRowAction.getDefaultAction(evt) : null;
        final VirtualMenu menu = VirtualMenu.createRoot(null);
        for (final IListeAction a : this.rowActions.keySet()) {
            final PopupBuilder popupContent = a.getPopupContent(evt);
            if (defaultAction != null && a == this.defaultRowAction) {
                /**
                 * If popup actions are ["Dial 03", "Dial 06"] then getDefaultAction() must not
                 * always return the same instance "if land line is default then Dial 03 else Dial
                 * 06" otherwise we can't find its matching menu item in the popup. IOW the check
                 * should be done in getDefaultAction(), which should then return one of the popup
                 * actions.
                 * <p>
                 * Also, the IListeAction can just choose not to return the default action in its
                 * menu.
                 */
                final JMenuItem defaultMI = popupContent.getRootMenuItem(defaultAction);
                if (defaultMI == null)
                    Log.get().info("Default action not found at the root level of popup for " + this);
                else
                    defaultMI.setFont(defaultMI.getFont().deriveFont(Font.BOLD));
            }
            menu.merge(popupContent.getMenu());
        }

        for (final Entry<JMenuItem, List<String>> e : menu.getContent().entrySet()) {
            MenuUtils.addMenuItem(e.getKey(), this.popup, e.getValue());
        }

        return this.popup;
    }

    /**
     * Set the action performed when double-clicking a row.
     * 
     * @param action the default action, can be <code>null</code>.
     */
    public final void setDefaultRowAction(final IListeAction action) {
        this.defaultRowAction = action;
        if (action != null)
            this.addIListeAction(action);
    }

    public final IListeAction getDefaultRowAction() {
        return this.defaultRowAction;
    }

    private void performDefaultAction(MouseEvent e) {
        // special method needed since sometimes getPopupContent() can access the DB (optionally
        // creating threads) or be slow
        if (this.defaultRowAction != null) {
            final Action defaultAction = this.defaultRowAction.getDefaultAction(this.createListEvent());
            if (defaultAction != null)
                defaultAction.actionPerformed(new ActionEvent(e.getSource(), e.getID(), null, e.getWhen(), e.getModifiers()));
        }
    }

    final IListeEvent createListEvent() {
        return createEvent((vals, accessors) -> new IListeEvent(this, vals, accessors));
    }

    final PopupEvent createPopupEvent(final boolean onRows) {
        return createEvent((vals, accessors) -> new PopupEvent(this, vals, accessors, onRows));
    }

    private final <E extends ListEvent> E createEvent(final BiFunction<List<SQLRowValues>, List<? extends SQLRowAccessor>, E> ctor) {
        final List<SQLRowValues> vals;
        final List<? extends SQLRowAccessor> accessors;
        if (this.getSource().getKeepMode() == KeepMode.GRAPH) {
            vals = this.getSelectedRows();
            accessors = vals;
        } else {
            vals = null;
            accessors = this.getSelectedRowAccessors();
        }
        return ctor.apply(vals, accessors);
    }

    private void uiInit() {
        // * filter
        this.filter.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                if (e.isAltDown()) {
                    invertDebug();
                }
            }
        });
        FontUtils.setFontFor(this.filter, SEP);
        // initially hide to limit modifications for instances which don't need the filter, see
        // setFilter() comment
        this.setFilter(null);
        this.updateFilter();

        // * JTable

        // active/désactive le mode DEBUG du tableModel en ALT-clickant sur les entêtes des colonnes
        this.jTable.getTableHeader().addMouseListener(new MouseAdapter() {

            @Override
            public void mouseClicked(MouseEvent e) {
                if (e.isAltDown()) {
                    final boolean debug = IListe.this.getModel().isDebug();
                    IListe.this.getModel().setDebug(!debug);
                    setDebug(!debug);
                }
            }

            static private final String COL_INDEX_KEY = "tableColIndex";

            private final JPopupMenu popupMenu;
            private final Action toggleWidth;
            private final Action toggleVisibility;
            private final Action setAllVisible;

            {
                this.popupMenu = new JPopupMenu();
                this.toggleWidth = new AbstractAction(TM.tr("ilist.setColumnsWidth")) {
                    @Override
                    public void actionPerformed(ActionEvent e) {
                        toggleAutoAdjust();
                    }
                };
                this.toggleVisibility = new AbstractAction() {
                    @Override
                    public void actionPerformed(ActionEvent e) {
                        final XTableColumnModel colModel = (XTableColumnModel) getJTable().getColumnModel();
                        final JComponent cb = (JComponent) e.getSource();
                        final int columnIndex = ((Number) cb.getClientProperty(COL_INDEX_KEY)).intValue();
                        final TableColumn col = colModel.getColumn(columnIndex, false);
                        final boolean newValue = !colModel.isColumnVisible(col);
                        // Workaround for crash on linux Java 16 with Nimbus L&F or Flat L&F
                        IListe.this.jTable.getTableHeader().setDraggedColumn(null);

                        // don't remove last column
                        if (newValue || colModel.getColumnCount(true) > 1)
                            colModel.setColumnVisible(col, newValue);
                    }
                };
                this.setAllVisible = new AbstractAction() {
                    @Override
                    public void actionPerformed(ActionEvent e) {
                        final XTableColumnModel colModel = (XTableColumnModel) getJTable().getColumnModel();
                        colModel.setAllColumnsVisible();
                    }
                };
            }

            @Override
            public void mousePressed(MouseEvent e) {
                maybeShowPopup(e);
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                maybeShowPopup(e);
            }

            private void maybeShowPopup(MouseEvent e) {
                if (e.isPopupTrigger()) {
                    this.popupMenu.removeAll();

                    if (IListe.this.adjustVisible) {
                        final JCheckBoxMenuItem cb = new JCheckBoxMenuItem(this.toggleWidth);
                        cb.setSelected(isAutoAdjusting());
                        this.popupMenu.add(cb);
                    }

                    if (getJTable().getColumnModel() instanceof XTableColumnModel) {
                        if (this.popupMenu.getComponentCount() > 0)
                            this.popupMenu.addSeparator();
                        this.setAllVisible.putValue(Action.NAME, TM.tr("ilist.showAllColumns"));
                        this.popupMenu.add(this.setAllVisible);

                        final XTableColumnModel colModel = (XTableColumnModel) getJTable().getColumnModel();
                        int i = 0;
                        final boolean disableLastCol = colModel.getColumnCount(true) == 1;
                        for (final TableColumn c : colModel.getColumns(false)) {
                            final JCheckBoxMenuItem cb = new JCheckBoxMenuItem(this.toggleVisibility);
                            // speed up display of menu
                            cb.setText(StringUtils.Shortener.Ellipsis.getBoundedLengthString(String.valueOf(c.getHeaderValue()), 200));
                            final boolean isVisible = colModel.isColumnVisible(c);
                            cb.setSelected(isVisible);
                            cb.setEnabled(!isVisible || !disableLastCol);
                            if (!cb.isEnabled())
                                cb.setToolTipText(TM.tr("ilist.lastCol"));
                            cb.putClientProperty(COL_INDEX_KEY, i++);
                            this.popupMenu.add(cb);
                        }
                    }

                    if (this.popupMenu.getComponentCount() > 0)
                        this.popupMenu.show((Component) e.getSource(), e.getX(), e.getY());
                }
            }

        });
        // use SQLTableModelColumn.getToolTip()
        this.jTable.getTableHeader().setDefaultRenderer(new TableCellRenderer() {
            private final TableCellRenderer orig = IListe.this.jTable.getTableHeader().getDefaultRenderer();

            @Override
            public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
                final Component res = this.orig.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
                if (res instanceof JComponent) {
                    // column is the view index
                    final SQLTableModelColumn col = getSource().getColumn(table.convertColumnIndexToModel(column));
                    ((JComponent) res).setToolTipText(col.getToolTip());
                }
                return res;
            }
        });
        this.jTable.setDefaultRenderer(Clob.class, new DefaultTableCellRenderer() {
            @Override
            public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
                return super.getTableCellRendererComponent(table, StringClobConvertor.INSTANCE.unconvert((Clob) value), isSelected, hasFocus, row, column);
            }
        });
        this.jTable.setDefaultRenderer(Date.class, createDateRenderer());
        this.jTable.setDefaultRenderer(Time.class, createTimeRenderer());
        this.jTable.setDefaultRenderer(Timestamp.class, createDateTimeRenderer());
        for (final Map.Entry<Class<?>, FormatGroup> e : this.getFormats().entrySet())
            this.jTable.setDefaultEditor(e.getKey(), new FormatEditor(e.getValue()));
        this.sorter.setTableHeader(this.jTable.getTableHeader());
        this.addAncestorListener(new AncestorListener() {

            // these callbacks are called later than the change, and by that time the visibility
            // might have changed several times thus use isShowing() to avoid flip-flopping for
            // nothing

            @Override
            public void ancestorRemoved(AncestorEvent event) {
                visibilityChanged();
            }

            @Override
            public void ancestorAdded(AncestorEvent event) {
                visibilityChanged();
            }

            @Override
            public void ancestorMoved(AncestorEvent event) {
                // nothing to do
            }
        });
        // we used to rm this listener, possibly to avoid events once dead, but this doesn't seem
        // necessary anymore
        this.jTable.getSelectionModel().addListSelectionListener(this.selectionListener);

        // TODO speed up like IListPanel buttons
        // works because "JTable.autoStartsEdit" is false
        // otherwise mets un + a la fin de la cellule courante
        if (this.getSource().getReq().isTableOrder()) {
            this.jTable.addKeyListener(new KeyAdapter() {
                public void keyTyped(KeyEvent e) {
                    if (e.getKeyChar() == '+') {
                        deplacerDe(1);
                    } else if (e.getKeyChar() == '-') {
                        deplacerDe(-1);
                    }
                }
            });

            // DnD
            this.jTable.setDragEnabled(true);
            this.jTable.setDropMode(DropMode.INSERT_ROWS);
            this.jTable.setTransferHandler(new IListeTransferHandler());
        }

        final JScrollPane scrollPane = new JScrollPane(this.jTable);
        scrollPane.setFocusable(false);
        scrollPane.addMouseListener(new PopupMouseListener() {
            @Override
            protected JPopupMenu createPopup(MouseEvent e) {
                return updatePopupMenu(false);
            }
        });

        this.setLayout(new GridBagLayout());
        final GridBagConstraints c = new GridBagConstraints(0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0);
        this.add(this.filter, c);

        c.gridy++;
        this.btnPanel.setVisible(false);
        this.btnPanel.setOpaque(false);
        this.add(this.btnPanel, c);

        c.weighty = 1;
        c.gridy++;
        this.add(scrollPane, c);

        // destroy if non displayable
        this.addHierarchyListener(new HierarchyListener() {
            public void hierarchyChanged(HierarchyEvent e) {
                if ((e.getChangeFlags() & HierarchyEvent.DISPLAYABILITY_CHANGED) != 0)
                    dispChanged();
            }
        });

        this.setOpaque(false);
        this.setTransferHandler(new FileTransfertHandler(getSource().getPrimaryTable()));

        if (this.getSource().getPrimaryTable().getFieldRaw(SQLComponent.READ_ONLY_FIELD) != null) {
            this.addRowValuesAction(getUnlockAction());
            this.addRowValuesAction(getLockAction());
        }
    }

    protected synchronized final void invertDebug() {
        this.setDebug(!this.debugFilter);
    }

    protected synchronized final void setDebug(boolean b) {
        this.debugFilter = b;
        updateFilter();
    }

    // thread-safe
    private synchronized void updateFilter() {
        if (this.filterWorker != null) {
            this.filterWorker.cancel(true);
        }
        final FilterWorker worker;
        if (!this.hasRequest()) {
            worker = new RowFilterWorker(null);
        } else if (this.debugFilter) {
            worker = new WhereFilterWorker(this.getRequest().getInstanceWhere());
        } else {
            worker = new RowFilterWorker(this.getRequest().getFilterRows());
        }
        this.filterWorker = worker;
        this.filterWorker.execute();
    }

    /**
     * Sets the filter label.
     * 
     * @param text the text to display, <code>null</code> to hide the label.
     */
    private void setFilter(String text) {
        final boolean currentVisible = this.filter.isVisible();
        final boolean newVisible = text != null;
        // limit modifications due to a bug in Swing that can bring the frame to the back.
        if (newVisible || currentVisible) {
            this.filter.setText(text == null ? "" : text);
            this.filter.setVisible(newVisible);
            this.revalidate();
        }
    }

    public void selectID(final int id) {
        this.selectIDs(Collections.singleton(id));
    }

    public void selectIDs(final Collection<Integer> ids) {
        if (!SwingUtilities.isEventDispatchThread())
            throw new IllegalStateException("not in EDT");
        // no need to put a runnable in the model queue to wait for an inserted ID to actually
        // show up in the list, the ListSelectionState will record the userID and select it after
        // the update
        if (!isDead())
            this.state.selectIDs(ids);
    }

    // retourne l'ID de la ligne rowIndex à l'écran.
    public int idFromIndex(int rowIndex) {
        return this.state.idFromIndex(rowIndex);
    }

    /**
     * Cherche une chaîne de caractères dans la liste et reclasse les éléments trouvés au début
     * 
     * @param s la chaîne de caractères recherchées
     * @param column la colonne dans laquelle chercher, <code>null</code> pour toutes.
     */
    public void search(String s, String column) {
        this.search(s, column, null);
    }

    public void search(String s, String column, Runnable r) {
        // Determine sur quelle colonne on cherche
        this.getModel().searchContains(s, this.getModel().getColumnNames().indexOf(column), r);
    }

    // Export en tableau OpenOffice
    public void exporter(File file) throws IOException {
        exporter(file, false, XMLFormatVersion.getDefault());
    }

    public File exporter(File file, final boolean onlySelection, final XMLFormatVersion version) throws IOException {
        return SpreadSheet.export(getExportModel(onlySelection), file, version);
    }

    protected TableModel getExportModel(final boolean onlySelection) {
        final ViewTableModel res = new ViewTableModel(this.jTable);
        return onlySelection ? new TableModelSelectionAdapter(res, this.jTable.getSelectedRows()) : res;
    }

    public void update() {
        this.getModel().updateAll();
    }

    /**
     * Retourne le nombre de ligne de cette liste.
     * 
     * @return le nombre de ligne de cette liste, -1 si {@link #isDead()}.
     */
    public int getRowCount() {
        if (isDead()) {
            return -1;
        }
        return this.getTableModel().getRowCount();
    }

    public int getTotalRowCount() {
        if (isDead()) {
            return -1;
        }
        return this.getModel().getTotalRowCount();
    }

    public final boolean isDead() {
        return this.getTableModel() == null;
    }

    /**
     * Retourne le nombre d'éléments contenu dans cette liste. C'est à dire la somme du champs
     * 'quantité' ou 'nombre d'essai DDR'.
     * 
     * @return la somme ou -1 s'il n'y a pas de champs quantité.
     */
    public int getItemCount() {
        int count = -1;
        if (!this.isDead()) {
            int fieldIndex = -1;
            // ATTN ne marche que si qte est dans les listFields, donc dans le tableModel
            // sinon on pourrait faire un SUM(QUANTITE)
            final SQLField qte;
            final SQLTable t = this.getModel().getTable();
            if (t.contains("QUANTITE"))
                qte = t.getField("QUANTITE");
            else
                qte = t.getFieldRaw("NB_ESSAI_DDR");

            if (qte != null) {
                int i = 0;
                for (final SQLTableModelColumn col : this.getModel().getCols()) {
                    if (CollectionUtils.getSole(col.getFields()) == qte)
                        fieldIndex = i;
                    i++;
                }
            }
            if (fieldIndex > 0) {
                count = 0;
                for (int j = 0; j < this.getTableModel().getRowCount(); j++) {
                    count += ((Number) this.getTableModel().getValueAt(j, fieldIndex)).intValue();
                }
            }
        }
        return count;
    }

    public void deplacerDe(final int inc) {
        if (isSorted())
            return;
        this.getModel().moveBy(this.getSelectedRows(), inc, true);
    }

    /**
     * The currently selected id.
     * 
     * @return the currently selected id or -1 if no selection.
     */
    public int getSelectedId() {
        return this.state.getSelectedID();
    }

    public final boolean hasSelection() {
        return this.jTable.getSelectedRow() >= 0;
    }

    public final ListSelection getSelection() {
        return this.state;
    }

    /**
     * Return the line at the passed index.
     * 
     * @param viewIndex the index in the JTable.
     * @return the line at the passed index.
     * @see ITableModel#getLine(TableModel, int)
     */
    public final ListSQLLine getLine(int viewIndex) {
        return ITableModel.getLine(this.getJTable().getModel(), viewIndex);
    }

    // protect our internal values
    private <R> R getRow(int index, final Class<R> clazz) {
        final ListSQLLine line = this.getLine(index);
        final Object toCast;
        if (clazz == SQLRowValues.class) {
            toCast = line.getRow().toImmutable();
        } else if (clazz == SQLRow.class) {
            toCast = line.getRowAccessor().asRow();
        } else if (clazz == SQLRowAccessor.class) {
            toCast = line.getRowAccessor();
        } else if (clazz == ListSQLLine.class) {
            toCast = line;
        } else {
            throw new IllegalArgumentException("Not implemented : " + clazz);
        }
        return clazz.cast(toCast);
    }

    private SQLRow fetchRow(int id) {
        if (id < SQLRow.MIN_VALID_ID) {
            return null;
        } else
            return this.getSource().getPrimaryTable().getRow(id);
    }

    public SQLRow fetchSelectedRow() {
        return this.fetchRow(this.getSelectedId());
    }

    public SQLRowValues getSelectedRow() {
        return this.getSelectedRow(SQLRowValues.class);
    }

    public SQLRowAccessor getSelectedRowAccessor() {
        return this.getSelectedRow(SQLRowAccessor.class);
    }

    // selected row cannot be inferred from iterateSelectedRows() since the user might have selected
    // the last row anywhere in the selection
    private final <R extends SQLRowAccessor> R getSelectedRow(final Class<R> clazz) {
        final int selectedIndex = this.state.getSelectedIndex().intValue();
        if (selectedIndex == BaseListStateModel.INVALID_INDEX)
            return null;
        else
            return this.getRow(selectedIndex, clazz);
    }

    public final SQLRow getDesiredRow() {
        return this.fetchRow(this.getSelection().getUserSelectedID());
    }

    public final List<SQLRowValues> getSelectedRows() {
        return iterateSelectedRows(SQLRowValues.class);
    }

    public final List<ListSQLLine> getSelectedLines() {
        return iterateSelectedRows(ListSQLLine.class);
    }

    public final List<SQLRowAccessor> getSelectedRowAccessors() {
        return iterateSelectedRows(SQLRowAccessor.class);
    }

    private final <R> List<R> iterateSelectedRows(final Class<R> clazz) {
        final ListSelectionModel selectionModel = this.getJTable().getSelectionModel();
        if (selectionModel.isSelectionEmpty())
            return Collections.emptyList();

        final int start = selectionModel.getMinSelectionIndex();
        final int stop = selectionModel.getMaxSelectionIndex();
        final List<R> res = new ArrayList<R>();
        for (int i = start; i <= stop; i++) {
            if (selectionModel.isSelectedIndex(i)) {
                try {
                    res.add(getRow(i, clazz));
                } catch (IndexOutOfBoundsException e) {
                    throw new IllegalStateException("The selected row at " + i
                            + " is not in the model : it has been changed before Swing could update the selection. E.g. the DB was changed on mousePressed and Swing updated the selection on mouseReleased.",
                            e);
                }
            }
        }
        return res;
    }

    public final void setAdjustVisible(boolean b) {
        this.adjustVisible = b;
    }

    protected final void toggleAutoAdjust() {
        if (this.tcsa == null) {
            this.tcsa = new ColumnSizeAdjustor(this.jTable);
        } else {
            this.tcsa.setInstalled(!this.tcsa.isInstalled());
        }
    }

    public final boolean isAutoAdjusting() {
        if (this.tcsa == null) {
            return false;
        } else
            return this.tcsa.isInstalled();
    }

    // *** Listeners ***//

    public void addIListener(IListener l) {
        this.listeners.add(l);
    }

    public void addNonAdjustingIListener(IListener l) {
        this.naListeners.add(l);
    }

    /**
     * Adds a listener to the list that's notified each time a change to the data model occurs. This
     * includes when this is not displayable and the model becomes empty.
     * 
     * @param l the listener.
     * @see #retain()
     */
    public void addListener(TableModelListener l) {
        // sorter is final, only its own model (ITableModel) changes
        this.sorter.addTableModelListener(l);
    }

    public void removeListener(TableModelListener l) {
        this.sorter.removeTableModelListener(l);
    }

    /**
     * To be notified when the table is being sorted. Each time a sort is requested you'll be
     * notified twice to indicate the beginning and end of the sort. Don't confuse it with the
     * sortED status.
     * 
     * @param l the listener.
     * @see #isSorted()
     */
    public void addSortListener(PropertyChangeListener l) {
        this.sorter.addPropertyChangeListener(new PropertyChangeListenerProxy("sorting", l));
    }

    /**
     * Whether this list is sorted by a column.
     * 
     * @return true if this list is sorted.
     */
    public boolean isSorted() {
        return this.sorter.isSorting();
    }

    public final void setSortingEnabled(final boolean b) {
        this.sorter.setSortingEnabled(b);
    }

    public final boolean isSortingEnabled() {
        return this.sorter.isSortingEnabled();
    }

    private void fireSelectionId(int id, int selectedColumn) {
        for (IListener l : this.listeners) {
            l.selectionId(id, selectedColumn);
        }
    }

    protected final void fireNASelectionId() {
        final int id = this.getSelectedId();
        for (IListener l : this.naListeners) {
            l.selectionId(id, -1);
        }
    }

    public final void addModelListener(final PropertyChangeListener l) {
        this.supp.addPropertyChangeListener("model", l);
    }

    public final void rmModelListener(final PropertyChangeListener l) {
        this.supp.removePropertyChangeListener("model", l);
    }

    /**
     * Ensure that the passed listener will always listen on our current {@link #getModel() model}
     * even if it changes. Warning: to signal model change
     * {@link PropertyChangeListener#propertyChange(PropertyChangeEvent)} will be called with a
     * <code>null</code> name.
     * 
     * @param l the listener.
     */
    public final void addListenerOnModel(final PropertyChangeListener l) {
        this.modelPCListeners.add(l);
        if (getModel() != null)
            getModel().addPropertyChangeListener(l);
    }

    public final void rmListenerOnModel(final PropertyChangeListener l) {
        this.modelPCListeners.remove(l);
        if (getModel() != null)
            getModel().rmPropertyChangeListener(l);
    }

    /**
     * Listen to the content of the selection, i.e. both selection ID change and data change of the
     * current selection. Note: <code>l</code> is called for each selection change, even when
     * {@link ListSelectionEvent#getValueIsAdjusting()} is <code>true</code>.
     * 
     * @param l the listener.
     */
    public final void addSelectionDataListener(final PropertyChangeListener l) {
        this.supp.addPropertyChangeListener(SELECTION_DATA_PROPNAME, l);
    }

    public final void removeSelectionDataListener(final PropertyChangeListener l) {
        this.supp.removePropertyChangeListener(SELECTION_DATA_PROPNAME, l);
    }

    protected final void visibilityChanged() {
        // test isDead() since in JComponent.removeNotify() first setDisplayable(false) (in super)
        // then firePropertyChange("ancestor", null).
        // thus we can still be visible while not displayable anymore
        if (!this.isDead())
            // we used to call isVisible() but that was incorrect : a component can be visible and
            // not on screen. E.g. the frame would be made invisible, so this method was called but
            // isVisible() hadn't changed (so still true) thus the model never slept (hence never
            // hibernated, hence never was emptied).
            this.getModel().setSleeping(!this.isShowing());
    }

    /**
     * The {@link ITableModel} of this list.
     * 
     * @return the model, <code>null</code> if destroyed.
     */
    public ITableModel getModel() {
        return (ITableModel) this.getTableModel();
    }

    public TableModel getTableModel() {
        assert SwingUtilities.isEventDispatchThread();
        return this.sorter.getTableModel();
    }

    private final void setTableModel(ITableModel t) {
        final ITableModel old = this.getModel();
        if (t == old)
            return;

        if (old != null) {
            for (final PropertyChangeListener l : this.modelPCListeners)
                old.rmPropertyChangeListener(l);
            old.removeTableModelListener(this.selectionDataListener);
            if (this.hasRequest())
                this.getRequest().rmWhereListener(this.filterListener);
        }
        this.sorter.setTableModel(t);
        if (t != null) {
            updateModelEditable();
            // no need to listen to source columns since our ITableModel does, then it
            // fireTableStructureChanged() and our JTable createDefaultColumnsFromModel() so
            // columnAdded() and thus updateCols() are called. Note: we might want to listen to
            // SQLTableModelColumn themselves (and not their list), e.g. if their renderers change.
            for (final PropertyChangeListener l : this.modelPCListeners) {
                t.addPropertyChangeListener(l);
                // signal to the listeners that the model has changed (ie all of its properties)
                l.propertyChange(new PropertyChangeEvent(t, null, null, null));
            }
            // listen to the SQL model and not this.sorter since change in sorting doesn't change
            // the selection nor its data. Full listener since not all values are displayed.
            t.addTableModelListener(this.selectionDataListener, true);
            if (this.hasRequest()) {
                this.getRequest().addWhereListener(this.filterListener);
                // the where might have changed since we last listened
                this.filterListener.propertyChange(null);
            }
        }
        this.supp.firePropertyChange("model", old, t);
    }

    // must be called when columnModel or getSource() changes
    private void updateCols(final int index) {
        final TableColumnModel columnModel = this.jTable.getColumnModel();
        final int start = index < 0 ? 0 : index;
        final int stop = index < 0 ? columnModel.getColumnCount() : index + 1;
        for (int i = start; i < stop; i++) {
            final TableColumn col = columnModel.getColumn(i);
            final SQLTableModelColumn srcCol = this.getSource().getColumn(col.getModelIndex());
            srcCol.install(col);
            col.setIdentifier(srcCol.getIdentifier());
            if (FORCE_ALT_CELL_RENDERER)
                AlternateTableCellRenderer.setRendererAndListen(col);
            else
                AlternateTableCellRenderer.setRenderer(col);
        }
    }

    public final boolean hasRequest() {
        return this.getSource() instanceof SQLTableModelSourceOnline;
    }

    public final ListSQLRequest getRequest() {
        return this.getSource().getReq();
    }

    public final void setSource(SQLTableModelSource src) {
        if (src == null)
            throw new NullPointerException();
        synchronized (this) {
            // necessary to limit table model changes, since it recreates columns (and thus forget
            // about customizations, eg renderers)
            if (this.src == src)
                return;

            this.src = src;
        }
        this.setTableModel(new ITableModel(src));
    }

    public synchronized final SQLTableModelSource getSource() {
        return this.src;
    }

    public final File getConfigFile() {
        // can be null if this is called before the end of the constructor
        return this.tableStateManager == null ? null : this.tableStateManager.getConfigFile();
    }

    public final void setConfigFile(File configFile) {
        if (Boolean.getBoolean(STATELESS_TABLE_PROP))
            configFile = null;
        final File oldFile = this.getConfigFile();
        if (!CompareUtils.equals(oldFile, configFile)) {
            if (configFile == null)
                this.tableStateManager.endAutoSave();
            this.tableStateManager.setConfigFile(configFile);
            if (oldFile == null)
                this.tableStateManager.beginAutoSave();
            loadTableState();
        }
    }

    public final boolean saveTableState() throws IOException {
        final boolean hasFile = this.getConfigFile() != null;
        if (hasFile)
            this.tableStateManager.saveState();
        return hasFile;
    }

    private boolean loadTableState() {
        // - if configFile changes setConfigFile() calls us
        // - if the model changes, fireTableStructureChanged() is called and thus
        // JTable.createDefaultColumnsFromModel() which calls us
        if (this.getConfigFile() != null && this.getModel() != null)
            return this.tableStateManager.loadState();
        else
            return false;
    }

    /**
     * Allow this list to be garbage collected. This method is necessary since this instance is
     * listener of SQLTable which will never be gc'd.
     */
    private final void dispChanged() {
        final boolean requiredToLive = this.isDisplayable() || this.retainCount > 0;
        if (!requiredToLive && !this.isDead()) {
            this.setTableModel(null);
        } else if (requiredToLive && this.isDead()) {
            this.setTableModel(new ITableModel(this.getSource()));
        }
    }

    /**
     * Allow this to stay alive even if undisplayable. Attention, you must call {@link #release()}
     * for each {@link #retain()} otherwise this instance will never be garbage collected.
     */
    public final void retain() {
        this.retainCount++;
        this.dispChanged();
    }

    public final void release() {
        if (this.retainCount == 0)
            throw new IllegalStateException("Unbalanced release");
        this.retainCount--;
        this.dispChanged();
    }

    public JTable getJTable() {
        return this.jTable;
    }

    private void updateModelEditable() {
        final ITableModel m = this.getModel();
        if (m != null) {
            m.setCellsEditable(this.isCellModificationAllowed() && !getSource().getElem().isPrivate());
            m.setOrderEditable(this.isOrderModificationAllowed() && (!getSource().getElem().isPrivate()));
        }
    }

    public void setModificationAllowed(boolean b) {
        this.setCellModificationAllowed(b);
        this.setOrderModificationAllowed(b);
    }

    public void setCellModificationAllowed(boolean b) {
        this.cellModificationAllowed = b;
        updateModelEditable();
    }

    public boolean isCellModificationAllowed() {
        return this.cellModificationAllowed;
    }

    public void setOrderModificationAllowed(boolean b) {
        this.orderModificationAllowed = b;
        updateModelEditable();
    }

    public boolean isOrderModificationAllowed() {
        return this.orderModificationAllowed;
    }

    @Override
    public void grabFocus() {
        this.jTable.grabFocus();
    }

    // *** workers

    private abstract class FilterWorker extends SwingWorker<String, Object> {

        @Override
        protected final void done() {
            if (!this.isCancelled()) {
                // if doInBackground() wasn't cancelled, display our result
                try {
                    setFilter(this.get());
                } catch (Exception e) {
                    if (e instanceof ExecutionException && ((ExecutionException) e).getCause() instanceof InterruptedException) {
                        final String msg = this.getClass() + " interruped";
                        Log.get().fine(msg);
                        setFilter(msg);
                    } else {
                        e.printStackTrace();
                        setFilter(e.getLocalizedMessage());
                    }
                }
                synchronized (IListe.this) {
                    // only doInBackground() can be cancelled, so this might have received cancel()
                    // after doInBackground() had completed but before done() had been called
                    // thus filterWorker is not always this instance
                    if (IListe.this.filterWorker == this) {
                        IListe.this.filterWorker = null;
                    }
                }
            }
        }

    }

    private final class WhereFilterWorker extends FilterWorker {
        private final Where w;

        private WhereFilterWorker(Where r) {
            this.w = r;
        }

        @Override
        protected String doInBackground() throws InterruptedException {
            return this.w == null ? "No where" : this.w.getClause();
        }

    }

    private final class RowFilterWorker extends FilterWorker {
        private final Collection<SQLRow> rows;

        private RowFilterWorker(Collection<SQLRow> r) {
            this.rows = r;
        }

        @Override
        protected String doInBackground() throws InterruptedException {
            if (this.getRows() == null)
                return null;

            // attend 1 peu avant de faire des requetes, comme ca si le filtre change
            // tout le temps, on ne commence meme pas (sleep jette InterruptedExn)
            Thread.sleep(60);

            final List<String> ancestors = new ArrayList<String>();
            final SQLElementDirectory dir = getSource().getElem().getDirectory();
            // always put the description of getRows(), but only put their ancestor if they all have
            // the same parent
            Tuple2<SQLRow, String> parentAndDesc = getParent(this.getRows(), dir);
            ancestors.add(parentAndDesc.get1());
            SQLRow current = parentAndDesc.get0();
            while (current != null) {
                if (Thread.interrupted()) {
                    throw new InterruptedException();
                }
                final SQLElement elem = dir.getElement(current.getTable());
                ancestors.add(0, elem.getDescription(current));
                current = elem.getForeignParent(current);
            }

            return CollectionUtils.join(ancestors, SEP);
        }

        private Tuple2<SQLRow, String> getParent(Collection<SQLRow> rows, final SQLElementDirectory dir) throws InterruptedException {
            SQLRow parent = null;
            boolean sameParent = true;
            final List<String> desc = new ArrayList<String>(rows.size());

            for (final SQLRow current : rows) {
                if (Thread.interrupted()) {
                    throw new InterruptedException();
                }
                final SQLElement elem = dir.getElement(current.getTable());
                if (parent == null || sameParent) {
                    final SQLRow currentParent = elem.getForeignParent(current);
                    if (parent == null)
                        parent = currentParent;
                    else if (!parent.equals(currentParent))
                        sameParent = false;
                }
                desc.add(elem.getDescription(current));
            }

            return Tuple2.create(sameParent ? parent : null, CollectionUtils.join(desc, " ●"));
        }

        private final Collection<SQLRow> getRows() {
            return this.rows;
        }

        @Override
        public String toString() {
            return super.toString() + " on " + this.getRows();
        }
    }
}