AbstractMultiSlider.java
package org.djutils.swing.multislider;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionAdapter;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import javax.swing.BoxLayout;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.OverlayLayout;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.plaf.SliderUI;
import javax.swing.plaf.basic.BasicSliderUI;
import org.djutils.exceptions.Throw;
import org.djutils.logger.CategoryLogger;
/**
 * The {@code AbstractMultiSlider} forms the base of implementing a slider with multiple thumbs. The AbstractMultiSlider is
 * implemented by drawing a number of sliders on top of each other using an Swing {@code OverlayManager}, and passing the mouse
 * events from a glass pane on top to the correct slider(s). The class is a {@code ChangeListener} to listen to the changes of
 * individual sliders underneath.
 * <p>
 * Several models exist to indicate whether thumbs can pass each other or not, or be on top of each other or not.
 * </p>
 * <p>
 * The {@code AbstractMultiSlider} stores all values internally as int. Only when getting or setting values (or, e.g., the
 * minimum or maximum), the generic type T is used.
 * </p>
 * <p>
 * Copyright (c) 2024-2025 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
 * for project information <a href="https://djutils.org" target="_blank"> https://djutils.org</a>. The DJUTILS project is
 * distributed under a three-clause BSD-style license, which can be found at
 * <a href="https://djutils.org/docs/license.html" target="_blank"> https://djutils.org/docs/license.html</a>.
 * </p>
 * @author <a href="https://www.tudelft.nl/averbraeck">Alexander Verbraeck</a>
 * @param <T> the type of values that the {@code AbstractMultiSlider} stores and returns
 */
public abstract class AbstractMultiSlider<T> extends JComponent
{
    /** */
    private static final long serialVersionUID = 1L;
    /** the sliders that are stacked on top of each other. */
    private JSlider[] sliders;
    /** the current slider number in process (e.g., drag operation, mouse down). */
    private transient int busySlider = -1;
    /** whether an operation is busy or not. */
    private transient boolean busy = false;
    /** the 'glass pane' on top of the sliders to dispatch the mouse clicks and drags to the correct slider. */
    private final DispatcherPane dispatcherPane;
    /** the panel in which the thumb labels can be drawn if this is wanted. */
    private final LabelPanel labelPanel;
    /** the initial index values of the labels for the reset function. */
    private final int[] initialIndexValues;
    /** the last known index values of the labels for the state change function. */
    private final int[] lastIndexValues;
    /** the labels per thumb (per underlying slider). */
    private final Map<Integer, String> thumbLabels = new HashMap<>();
    /** whether we draw thumb labels or not. */
    private boolean drawThumbLabels = false;
    /** the track size lowest pixel (to calculate width for horizontal slider; height for vertical slider). */
    private int trackSizeLoPx;
    /** the track size highest pixel (to calculate width for horizontal slider; height for vertical slider). */
    private int trackSizeHiPx;
    /** MultiSlider restriction on passing. */
    private boolean passing = false;
    /** MultiSlider restriction on overlap. */
    private boolean overlap = true;
    /** busy testing whether the state change of an underlying slider is okay, to avoid stack overflow. */
    private boolean testingStateChange = false;
    /**
     * Only one <code>ChangeEvent</code> is needed for the {@code MultiSlider} since the event's only (read-only) state is the
     * source property. The source of events generated here is always "this". The event is created the first time that an event
     * notification is fired.
     */
    private transient ChangeEvent changeEvent = null;
    /**
     * Creates a slider with the specified orientation and the specified minimum, maximum, and initial values. The orientation
     * can be either horizontal or vertical.
     * @param minIndex the minimum index value of the slider
     * @param maxIndex the maximum index value of the slider
     * @param horizontal the orientation of the slider; true for horizontal, false for vertical
     * @param initialIndexValues the initial index values of the thumbs of the slider
     * @throws IllegalArgumentException if initial values are outside the min-max range, or if the number of thumbs is 0, or
     *             when the values are not in increasing order (which is important for restricting passing and overlap)
     */
    public AbstractMultiSlider(final int minIndex, final int maxIndex, final boolean horizontal,
            final int... initialIndexValues)
    {
        Throw.when(initialIndexValues.length == 0, IllegalArgumentException.class, "the number of thumbs cannot be zero");
        Throw.when(minIndex >= maxIndex, IllegalArgumentException.class, "min should be less than max");
        for (int i = 0; i < initialIndexValues.length; i++)
        {
            Throw.when(initialIndexValues[i] < minIndex || initialIndexValues[i] > maxIndex, IllegalArgumentException.class,
                    "all initial value should be between min and max (inclusive)");
            Throw.when(i > 0 && initialIndexValues[i] < initialIndexValues[i - 1], IllegalArgumentException.class,
                    "all initial value should be in increasing order or overlap");
        }
        // store the initial value array in a safe copy
        this.initialIndexValues = new int[initialIndexValues.length];
        this.lastIndexValues = new int[initialIndexValues.length];
        // based on the orientation, add a JPanel with two subpanels: one for the labels, and one for the sliders
        setLayout(new BorderLayout());
        JPanel topPanel = new JPanel();
        topPanel.setLayout(new BoxLayout(topPanel, horizontal ? BoxLayout.Y_AXIS : BoxLayout.X_AXIS));
        topPanel.setOpaque(false);
        add(topPanel, horizontal ? BorderLayout.NORTH : BorderLayout.WEST);
        // create the label panel
        this.labelPanel = new LabelPanel(this);
        this.labelPanel.setLayout(new BorderLayout());
        this.labelPanel.setPreferredSize(new Dimension(1000, 0));
        this.labelPanel.setOpaque(false);
        topPanel.add(this.labelPanel);
        // create the slider panel
        JPanel sliderPanel = new JPanel();
        sliderPanel.setOpaque(false);
        topPanel.add(sliderPanel);
        // put a glass pane on top that dispatches the mouse event to all panes
        this.dispatcherPane = new DispatcherPane(this);
        sliderPanel.add(this.dispatcherPane);
        // make the sliders and show them. Slider 0 at the bottom. This one will get ticks, etc.
        sliderPanel.setLayout(new OverlayLayout(sliderPanel));
        this.sliders = new JSlider[initialIndexValues.length];
        for (int i = initialIndexValues.length - 1; i >= 0; i--)
        {
            this.initialIndexValues[i] = initialIndexValues[i]; // create copy
            this.lastIndexValues[i] = initialIndexValues[i];
            var slider = new JSlider(horizontal ? SwingConstants.HORIZONTAL : SwingConstants.VERTICAL, minIndex, maxIndex,
                    initialIndexValues[i]);
            this.sliders[i] = slider;
            slider.setOpaque(false);
            slider.setPaintTrack(i == 0);
            sliderPanel.add(slider);
            // ensure movability of the slider by setting it again
            slider.setValue(initialIndexValues[i]);
            // set the initial labels (issue #71)
            this.thumbLabels.put(i, "");
        }
        this.dispatcherPane.setPreferredSize(new Dimension(this.sliders[0].getSize()));
        calculateTrackSize();
        // listen to resize events to (re)set the track width or height
        addComponentListener(new ComponentAdapter()
        {
            @Override
            public void componentResized(final ComponentEvent e)
            {
                super.componentResized(e);
                AbstractMultiSlider.this.setUI(getUI());
                calculateTrackSize();
                AbstractMultiSlider.this.dispatcherPane.revalidate();
                AbstractMultiSlider.this.labelPanel.revalidate();
                AbstractMultiSlider.this.labelPanel.repaint();
            }
        });
        // listen to state changes of underlying sliders and check if restrictions are met
        for (int i = 0; i < this.sliders.length; i++)
        {
            final int index = i;
            this.sliders[index].addChangeListener(new ChangeListener()
            {
                @Override
                public void stateChanged(final ChangeEvent e)
                {
                    if (!AbstractMultiSlider.this.testingStateChange)
                    {
                        AbstractMultiSlider.this.testingStateChange = true;
                        checkRestrictions(index);
                        AbstractMultiSlider.this.testingStateChange = false;
                    }
                    AbstractMultiSlider.this.fireStateChanged();
                }
            });
        }
        invalidate();
    }
    /**
     * Return the individual sliders, where slider[0] contains the formatting.
     * @return the individual sliders
     */
    protected JSlider[] getSliders()
    {
        return this.sliders;
    }
    /**
     * Return an individual slider with index i.
     * @param i the index for which to retrieve the slider
     * @return the individual slider with index i
     */
    public JSlider getSlider(final int i)
    {
        return this.sliders[i];
    }
    /**
     * Return the number of thumbs on this multislider.
     * @return the number of thumbs on this multislider
     */
    public int getNumberOfThumbs()
    {
        return this.sliders.length;
    }
    /**
     * Indicate that slider i is busy (e.g., mouse-down or a drag operation).
     * @param i the slider number that is busy
     */
    protected void setBusySlider(final int i)
    {
        this.busySlider = i;
    }
    /**
     * Return whether slider i is busy (e.g., mouse-down or a drag operation).
     * @param i the slider number to check
     * @return whether slier i is busy or not
     */
    protected boolean isBusySlider(final int i)
    {
        return this.busySlider == i;
    }
    /**
     * Return which slider is busy (e.g., mouse-down or a drag operation). The function returns -1 if no slider is busy.
     * @return the slider number of the busy slider, or -1 if no slider is busy with an action
     */
    protected int getBusySlider()
    {
        return this.busySlider;
    }
    /**
     * Return whether one of the sliders is busy (e.g., mouse-down or a drag operation). Note that the 'busy' flag is set BEFORE
     * the mouse event (e.g., mouse released, mouse exited) is handled. This means that when 'busy' is true, no operation is
     * taking place.
     * @return whether one of the sliders is busy or not
     */
    public boolean isBusy()
    {
        return this.busy;
    }
    /**
     * Set whether one of the sliders is busy (e.g., mouse-down or a drag operation). Note that the 'busy' flag has to be set
     * BEFORE the mouse event (e.g., mouse released, mouse exited) is handled. This means that when 'busy' is true, no operation
     * is taking place.
     * @param busy set whether one of the sliders is busy or not
     */
    protected void setBusy(final boolean busy)
    {
        this.busy = busy;
    }
    /**
     * Reset the slider values to the initial values.
     */
    public void resetToInitialValues()
    {
        for (int i = 0; i < this.sliders.length; i++)
        {
            this.sliders[i].setValue(this.initialIndexValues[i]);
            this.sliders[i].invalidate();
            this.sliders[i].repaint();
        }
        fireFinalValueChanged();
        invalidate();
        repaint();
    }
    /**
     * Return the label panel in which thumb labels can be drawn. The labels move with the thumbs.
     * @return the label panel in which thumb labels can be drawn
     */
    protected LabelPanel getLabelPanel()
    {
        return this.labelPanel;
    }
    /**
     * Set the thumb label for thumb i to the given label.
     * @param i the thumb number
     * @param label the label to display
     * @throws IndexOutOfBoundsException when thumb number is out of bounds
     */
    public void setThumbLabel(final int i, final String label)
    {
        Throw.when(i < 0 || i >= getNumberOfThumbs(), IndexOutOfBoundsException.class, "thumb number %d is out of bounds", i);
        this.thumbLabels.put(i, label);
        invalidate();
    }
    /**
     * Get the thumb label for thumb i.
     * @param i the thumb number
     * @return the label to display
     * @throws IndexOutOfBoundsException when thumb number is out of bounds
     */
    public String getThumbLabel(final int i)
    {
        Throw.when(i < 0 || i >= getNumberOfThumbs(), IndexOutOfBoundsException.class, "thumb number %d is out of bounds", i);
        return this.thumbLabels.get(i);
    }
    /**
     * Turn the thumb label display on or off.
     * @param b whether the thumbs are displayed or not
     * @param sizePx the height (for a horizontal slider) or width (for a vertical slider) of the label panel in pixels
     */
    public void setDrawThumbLabels(final boolean b, final int sizePx)
    {
        calculateTrackSize();
        JSlider js = getSlider(0);
        this.drawThumbLabels = b;
        if (b)
        {
            if (isHorizontal())
            {
                this.labelPanel.setPreferredSize(new Dimension(js.getWidth(), sizePx));
            }
            else
            {
                this.labelPanel.setPreferredSize(new Dimension(sizePx, js.getHeight()));
            }
        }
        else
        {
            if (isHorizontal())
            {
                this.labelPanel.setPreferredSize(new Dimension(js.getWidth(), 0));
            }
            else
            {
                this.labelPanel.setPreferredSize(new Dimension(0, js.getHeight()));
            }
        }
        this.labelPanel.revalidate();
        revalidate();
    }
    /**
     * Return whether thumb label display on or off.
     * @return whether the thumbs are displayed or not
     */
    public boolean isDrawThumbLabels()
    {
        return this.drawThumbLabels;
    }
    /**
     * Recalculate the track size (width for horizontal slider; height for vertical slider) after a resize operation.
     */
    protected void calculateTrackSize()
    {
        JSlider js = getSlider(0);
        BasicSliderUI ui = (BasicSliderUI) js.getUI();
        int loValue = getInverted() ? js.getMaximum() : js.getMinimum();
        int hiValue = getInverted() ? js.getMinimum() : js.getMaximum();
        if (isHorizontal())
        {
            this.trackSizeLoPx = 0;
            this.trackSizeHiPx = js.getWidth();
            int i = 0;
            while (i < js.getWidth() && ui.valueForXPosition(i) == loValue)
            {
                this.trackSizeLoPx = i++;
            }
            i = js.getWidth();
            while (i >= 0 && ui.valueForXPosition(i) == hiValue)
            {
                this.trackSizeHiPx = i--;
            }
        }
        else
        {
            this.trackSizeLoPx = 0;
            this.trackSizeHiPx = js.getHeight();
            int i = 0;
            while (i < js.getHeight() && ui.valueForYPosition(i) == hiValue)
            {
                this.trackSizeLoPx = i++;
            }
            i = js.getHeight();
            while (i >= 0 && ui.valueForYPosition(i) == loValue)
            {
                this.trackSizeHiPx = i--;
            }
        }
        // Adjust based on the number of values between minimum and maximum
        int nr = getIndexMaximum() - getIndexMinimum();
        int pxPerNr = (this.trackSizeHiPx - this.trackSizeLoPx) / nr;
        this.trackSizeLoPx = isHorizontal() ? this.trackSizeLoPx - pxPerNr / 2 : this.trackSizeLoPx + pxPerNr / 2;
        this.trackSizeHiPx = isHorizontal() ? this.trackSizeHiPx + pxPerNr / 2 : this.trackSizeHiPx - pxPerNr / 2;
    }
    /**
     * Calculate the track size (width for a horizontal slider; height for a vertical slider).
     * @return the track size (width for a horizontal slider; height for a vertical slider
     */
    protected int trackSize()
    {
        // recalculate the track size of the previous calculation was carried out before 'pack' or 'setSize'.
        if (this.trackSizeHiPx - this.trackSizeLoPx < 2)
        {
            calculateTrackSize();
        }
        return this.trackSizeHiPx - this.trackSizeLoPx;
    }
    /**
     * Return the track size lowest pixel (to calculate width for horizontal slider; height for vertical slider).
     * @return the track size lowest pixel
     */
    public int getTrackSizeLoPx()
    {
        return this.trackSizeLoPx;
    }
    /**
     * Return the track size highest pixel (to calculate width for horizontal slider; height for vertical slider).
     * @return the track size highest pixel
     */
    public int getTrackSizeHiPx()
    {
        return this.trackSizeHiPx;
    }
    /**
     * Calculate x pixel (horizontal) or y pixel (vertical) of thumb[i], relative to the panel of the JSlider.
     * @param i the slider number
     * @return the x pixel (horizontal) or y pixel (vertical) of thumb[i], relative to the panel of the JSlider
     */
    protected int thumbPositionPx(final int i)
    {
        JSlider slider = getSlider(i);
        int value = slider.getValue();
        int min = slider.getMinimum();
        int max = slider.getMaximum();
        int ts = trackSize();
        if (getInverted())
        {
            return this.trackSizeLoPx + (int) (1.0 * ts * (max - value) / (max - min));
        }
        return this.trackSizeLoPx + (int) (1.0 * ts * (value - min) / (max - min));
    }
    /**
     * Return the glass pane on top of the multislider.
     * @return the glass pane on top of the multislider
     */
    protected DispatcherPane getDispatcherPane()
    {
        return this.dispatcherPane;
    }
    /**
     * Gets the UI object which implements the L&F for this component.
     * @return the SliderUI object that implements the Slider L&F
     */
    @Override
    public SliderUI getUI()
    {
        return this.sliders[0].getUI();
    }
    /**
     * Sets the UI object which implements the L&F for all underlying sliders.
     * @param ui the SliderUI L&F object
     */
    public void setUI(final SliderUI ui)
    {
        for (var slider : this.sliders)
        {
            try
            {
                slider.setUI(ui.getClass().getDeclaredConstructor().newInstance());
            }
            catch (Exception exception)
            {
                // silently fail
            }
        }
        invalidate();
        calculateTrackSize();
    }
    /**
     * Resets the UI property to a value from the current look and feel for all underlying sliders.
     */
    @Override
    public void updateUI()
    {
        for (var slider : this.sliders)
        {
            slider.updateUI();
        }
        invalidate();
        calculateTrackSize();
    }
    /**
     * Returns the name of the L&F class that renders this component.
     * @return the string "SliderUI"
     */
    @Override
    public String getUIClassID()
    {
        return this.sliders[0].getUIClassID();
    }
    /**
     * Adds a ChangeListener to the multislider.
     * @param listener the ChangeListener to add
     */
    public void addChangeListener(final ChangeListener listener)
    {
        this.listenerList.add(ChangeListener.class, listener);
    }
    /**
     * Removes a ChangeListener from the multislider.
     * @param listener the ChangeListener to remove
     */
    public void removeChangeListener(final ChangeListener listener)
    {
        this.listenerList.remove(ChangeListener.class, listener);
    }
    /**
     * Returns an array of all the <code>ChangeListener</code>s added to this MultiSlider with addChangeListener().
     * @return all of the <code>ChangeListener</code>s added or an empty array if no listeners have been added
     */
    public ChangeListener[] getChangeListeners()
    {
        return this.listenerList.getListeners(ChangeListener.class);
    }
    /**
     * Send a {@code ChangeEvent}, whose source is this {@code MultiSlider}, to all {@code ChangeListener}s that have registered
     * interest in {@code ChangeEvent}s. This method is called each time a {@code ChangeEvent} is received from the model of one
     * of the underlying sliders.
     * <p>
     * The event instance is created if necessary, and stored in {@code changeEvent}.
     * </p>
     */
    protected void fireStateChanged()
    {
        // See if an actual state change has occurred
        boolean changed = false;
        for (int i = 0; i < getNumberOfThumbs(); i++)
        {
            if (this.lastIndexValues[i] != this.sliders[i].getValue())
            {
                changed = true;
                this.lastIndexValues[i] = this.sliders[i].getValue();
            }
        }
        if (changed)
        {
            // Note that the listener array has the classes at the even places, and the listeners at the odd places (yuck).
            Object[] listeners = this.listenerList.getListenerList();
            for (int i = listeners.length - 2; i >= 0; i -= 2)
            {
                if (listeners[i] == ChangeListener.class)
                {
                    if (this.changeEvent == null)
                    {
                        this.changeEvent = new ChangeEvent(this);
                    }
                    ((ChangeListener) listeners[i + 1]).stateChanged(this.changeEvent);
                }
            }
        }
    }
    /**
     * Adds a FinalValueChangeListener to the multislider.
     * @param listener the FinalValueChangeListener to add
     */
    public void addFinalValueChangeListener(final FinalValueChangeListener listener)
    {
        this.listenerList.add(FinalValueChangeListener.class, listener);
    }
    /**
     * Removes a FinalValueChangeListener from the multislider.
     * @param listener the FinalValueChangeListener to remove
     */
    public void removeFinalValueChangeListener(final FinalValueChangeListener listener)
    {
        this.listenerList.remove(FinalValueChangeListener.class, listener);
    }
    /**
     * Returns an array of all the <code>FinalValueChangeListener</code>s added to this MultiSlider with
     * addFinalValueChangeListener().
     * @return all of the <code>FinalValueChangeListener</code>s added or an empty array if no listeners have been added
     */
    public FinalValueChangeListener[] getFinalValueChangeListeners()
    {
        return this.listenerList.getListeners(FinalValueChangeListener.class);
    }
    /**
     * Send a {@code ChangeEvent}, whose source is this {@code MultiSlider}, to all {@code FinalValueChangeListener}s that have
     * registered interest in {@code ChangeEvent}s. This method is called when a change is final, e.g., after setValue(...),
     * setInitialValues(), mouse up, and leaving the slider window after a drag event. Note that the {@code ChangeEvent}s are
     * NOT fired when a value of an underlying slider is changed directly. The regular ChangeListener does fire these changes.
     * <p>
     * The event instance is created if necessary, and stored in {@code changeEvent}.
     * </p>
     */
    protected void fireFinalValueChanged()
    {
        // Note that the listener array has the classes at the even places, and the listeners at the odd places (yuck).
        Object[] listeners = this.listenerList.getListenerList();
        for (int i = listeners.length - 2; i >= 0; i -= 2)
        {
            if (listeners[i] == FinalValueChangeListener.class)
            {
                if (this.changeEvent == null)
                {
                    this.changeEvent = new ChangeEvent(this);
                }
                ((FinalValueChangeListener) listeners[i + 1]).stateChanged(this.changeEvent);
            }
        }
    }
    /**
     * Translate an index to a value.
     * @param index the index on the slider scale to convert
     * @return the corresponding value
     */
    protected abstract T mapIndexToValue(int index);
    /**
     * Translate a value to an index.
     * @param value the value to convert to an index
     * @return the corresponding index
     * @throws IllegalArgumentException when value cannot be mapped onto an index
     */
    protected abstract int mapValueToIndex(T value);
    /**
     * Returns the slider's current value for slider[i].
     * @param i the thumb to retrieve the value from
     * @return the current value of slider[i]
     * @throws IllegalArgumentException when no value is present for the index
     */
    public T getValue(final int i)
    {
        return mapIndexToValue(getIndexValue(i));
    }
    /**
     * Sets the slider's current value to {@code value}. This method forwards the new value to the model. If the new value is
     * different from the previous value, all change listeners are notified.
     * @param i the thumb to set the value for
     * @param value the new value
     */
    public void setValue(final int i, final T value)
    {
        int n = mapValueToIndex(value);
        setIndexValue(i, n);
    }
    /**
     * Returns the slider's current index value for slider[i] from the {@code BoundedRangeModel}.
     * @param i the thumb to retrieve the value from
     * @return the current index value of slider[i]
     */
    public int getIndexValue(final int i)
    {
        return this.sliders[i].getModel().getValue();
    }
    /**
     * Sets the slider's current index value to {@code n}. This method forwards the new value to the model.
     * <p>
     * The data model (an instance of {@code BoundedRangeModel}) handles any mathematical issues arising from assigning faulty
     * values. See the {@code BoundedRangeModel} documentation for details.
     * </p>
     * If the new value is different from the previous value, all change listeners are notified.
     * @param i the thumb to set the value for
     * @param n the new index value
     */
    protected void setIndexValue(final int i, final int n)
    {
        Throw.when(n < getIndexMinimum() || n > getIndexMaximum(), IllegalArgumentException.class,
                "setValue(%d) not in range [%d, %d]", n, getIndexMinimum(), getIndexMaximum());
        this.sliders[i].setValue(n);
        checkRestrictions(i);
        this.sliders[i].invalidate();
        this.sliders[i].repaint();
        fireFinalValueChanged();
    }
    /**
     * Returns the minimum value supported by the slider from the <code>BoundedRangeModel</code>.
     * @return the value of the model's minimum property
     */
    protected int getIndexMinimum()
    {
        return this.sliders[0].getMinimum();
    }
    /**
     * Sets the slider's minimum value to {@code minimum}. This method forwards the new minimum value to the models of all
     * underlying sliders.
     * <p>
     * The data model (an instance of {@code BoundedRangeModel}) handles any mathematical issues arising from assigning faulty
     * values. See the {@code BoundedRangeModel} documentation for details.
     * </p>
     * If the new minimum value is different from the previous minimum value, all change listeners are notified.
     * @param minimum the new minimum
     */
    protected void setIndexMinimum(final int minimum)
    {
        Throw.when(minimum >= getIndexMaximum(), IllegalArgumentException.class, "setMinimum(%d) >= maximum %d", minimum,
                getIndexMaximum());
        int oldMin = getIndexMinimum();
        for (var slider : this.sliders)
        {
            slider.setMinimum(minimum);
        }
        checkRestrictions();
        for (var slider : this.sliders)
        {
            slider.invalidate();
        }
        invalidate();
        fireFinalValueChanged();
        firePropertyChange("minimum", Integer.valueOf(oldMin), Integer.valueOf(minimum));
        calculateTrackSize();
    }
    /**
     * Return the minimum value supported by the multislider.
     * @return the minimum typed value of the multislider
     */
    public T getMinimum()
    {
        return mapIndexToValue(getIndexMinimum());
    }
    /**
     * Set the minimum value supported by the multislider.
     * @param minimum the new minimum typed value of the multislider
     */
    public void setMinimum(final T minimum)
    {
        setIndexMinimum(mapValueToIndex(minimum));
    }
    /**
     * Returns the maximum value supported by the slider from the <code>BoundedRangeModel</code>.
     * @return the value of the model's maximum property
     */
    protected int getIndexMaximum()
    {
        return this.sliders[0].getMaximum();
    }
    /**
     * Sets the slider's maximum value to {@code maximum}. This method forwards the new maximum value to the models of all
     * underlying sliders.
     * <p>
     * The data model (an instance of {@code BoundedRangeModel}) handles any mathematical issues arising from assigning faulty
     * values. See the {@code BoundedRangeModel} documentation for details.
     * </p>
     * If the new maximum value is different from the previous maximum value, all change listeners are notified.
     * @param maximum the new maximum
     */
    protected void setIndexMaximum(final int maximum)
    {
        Throw.when(maximum <= getIndexMinimum(), IllegalArgumentException.class, "setMaximum(%d) >= minimum %d", maximum,
                getIndexMinimum());
        int oldMax = getIndexMaximum();
        for (var slider : this.sliders)
        {
            slider.setMaximum(maximum);
        }
        checkRestrictions();
        for (var slider : this.sliders)
        {
            slider.invalidate();
        }
        fireFinalValueChanged();
        firePropertyChange("maximum", Integer.valueOf(oldMax), Integer.valueOf(maximum));
        invalidate();
        calculateTrackSize();
    }
    /**
     * Return the maximum value supported by the multislider.
     * @return the maximum typed value of the multislider
     */
    public T getMaximum()
    {
        return mapIndexToValue(getIndexMaximum());
    }
    /**
     * Set the maximum value supported by the multislider.
     * @param maximum the new maximum typed value of the multislider
     */
    public void setMaximum(final T maximum)
    {
        setIndexMaximum(mapValueToIndex(maximum));
    }
    /**
     * Returns the "extent" from the <code>BoundedRangeModel</code>. This represents the range of values "covered" by the thumb.
     * @return an int representing the extent
     */
    public int getExtent()
    {
        return this.sliders[0].getExtent();
    }
    /**
     * Sets the size of the range "covered" by the thumb for all underlying slider objects. Most look and feel implementations
     * will change the value by this amount if the user clicks on either side of the thumb. This method just forwards the new
     * extent value to the model.
     * <p>
     * The data model (an instance of {@code BoundedRangeModel}) handles any mathematical issues arising from assigning faulty
     * values. See the {@code BoundedRangeModel} documentation for details.
     * </p>
     * If the new extent value is different from the previous extent value, all change listeners are notified.
     * @param extent the new extent
     */
    public void setExtent(final int extent)
    {
        for (var slider : this.sliders)
        {
            slider.setExtent(extent);
        }
    }
    /**
     * Return this multislider's vertical or horizontal orientation.
     * @return {@code SwingConstants.VERTICAL} or {@code SwingConstants.HORIZONTAL}
     */
    public int getOrientation()
    {
        return this.sliders[0].getOrientation();
    }
    /**
     * Return whether the orientation of the multislider is horizontal or not.
     * @return true if the orientation of the multislider is horizontal, false when not.
     */
    public boolean isHorizontal()
    {
        return getOrientation() == SwingConstants.HORIZONTAL;
    }
    /**
     * Return whether the orientation of the multislider is vertical or not.
     * @return true if the orientation of the multislider is vertical , false when not.
     */
    public boolean isVertical()
    {
        return !isHorizontal();
    }
    /**
     * Set the slider's orientation to either {@code SwingConstants.VERTICAL} or {@code SwingConstants.HORIZONTAL}.
     * @param orientation {@code HORIZONTAL} or {@code VERTICAL}
     * @throws IllegalArgumentException if orientation is not one of {@code VERTICAL}, {@code HORIZONTAL}
     */
    public void setOrientation(final int orientation)
    {
        for (var slider : this.sliders)
        {
            slider.setOrientation(orientation);
            slider.invalidate();
        }
        this.dispatcherPane.setPreferredSize(new Dimension(this.sliders[0].getSize()));
        invalidate();
        calculateTrackSize();
    }
    @Override
    public void setFont(final Font font)
    {
        for (var slider : this.sliders)
        {
            slider.setFont(font);
            slider.invalidate();
        }
        invalidate();
        calculateTrackSize();
    }
    /**
     * Returns the dictionary of what labels to draw at which values.
     * @return the <code>Dictionary</code> containing labels and where to draw them
     */
    @SuppressWarnings("rawtypes")
    public Dictionary getLabelTable()
    {
        return this.sliders[0].getLabelTable();
    }
    /**
     * Specify what label will be drawn at any given value. The key-value pairs are of this format:
     * <code>{ Integer value, java.swing.JComponent label }</code>. An easy way to generate a standard table of value labels is
     * by using the {@code createStandardLabels} method.
     * @param labels new {@code Dictionary} of labels, or {@code null} to remove all labels
     */
    @SuppressWarnings("rawtypes")
    public void setLabelTable(final Dictionary labels)
    {
        for (var slider : this.sliders)
        {
            slider.setLabelTable(labels);
            slider.invalidate();
        }
        invalidate();
        calculateTrackSize();
    }
    /**
     * Creates a {@code Hashtable} of numerical text labels, starting at the slider minimum, and using the increment specified.
     * For example, if you call <code>createStandardLabels( 10 )</code> and the slider minimum is zero, then labels will be
     * created for the values 0, 10, 20, 30, and so on.
     * <p>
     * For the labels to be drawn on the slider, the returned {@code Hashtable} must be passed into {@code setLabelTable}, and
     * {@code setPaintLabels} must be set to {@code true}.
     * <p>
     * For further details on the makeup of the returned {@code Hashtable}, see the {@code setLabelTable} documentation.
     * @param increment distance between labels in the generated hashtable
     * @return a new {@code Hashtable} of labels
     * @throws IllegalArgumentException if {@code increment} is less than or equal to zero
     */
    public Hashtable<Integer, JComponent> createStandardLabels(final int increment)
    {
        return createStandardLabels(increment, getIndexMinimum());
    }
    /**
     * Creates a {@code Hashtable} of text labels, starting at the starting point specified, and using the increment specified.
     * For example, if you call <code>createStandardLabels( 10, 2 )</code>, then labels will be created for the index values 2,
     * 12, 22, 32, and so on.
     * <p>
     * For the labels to be drawn on the slider, the returned {@code Hashtable} must be passed into {@code setLabelTable}, and
     * {@code setPaintLabels} must be set to {@code true}.
     * <p>
     * For further details on the makeup of the returned {@code Hashtable}, see the {@code setLabelTable} documentation.
     * @param increment distance between labels in the generated hashtable
     * @param startIndex value at which the labels will begin
     * @return a new {@code Hashtable} of labels
     * @exception IllegalArgumentException if {@code start} is out of range, or if {@code increment} is less than or equal to
     *                zero
     */
    public Hashtable<Integer, JComponent> createStandardLabels(final int increment, final int startIndex)
    {
        Throw.when(increment <= 0, IllegalArgumentException.class, "increment should be > 0");
        Throw.when(startIndex < getIndexMinimum() || startIndex > getIndexMaximum(), IllegalArgumentException.class,
                "startIndex should be between minimum index and maximum index");
        Hashtable<Integer, JComponent> labels = new Hashtable<>();
        for (int i = startIndex; i <= getIndexMaximum(); i += increment)
        {
            labels.put(i, new JLabel(format(mapIndexToValue(i))));
        }
        return labels;
    }
    /**
     * Format a value for e.g., the labels of the slider. By default, the formatting is done with {@code toString()}, but this
     * can be overridden.
     * @param value the value to format
     * @return a formatted string representation of the value
     */
    protected String format(final T value)
    {
        return value.toString();
    }
    /**
     * Returns true if the value-range shown for the slider is reversed.
     * @return true if the slider values are reversed from their normal order
     */
    public boolean getInverted()
    {
        return this.sliders[0].getInverted();
    }
    /**
     * Specify true to reverse the value-range shown for the slider and false to put the value range in the normal order. The
     * order depends on the slider's <code>ComponentOrientation</code> property. Normal (non-inverted) horizontal sliders with a
     * <code>ComponentOrientation</code> value of <code>LEFT_TO_RIGHT</code> have their maximum on the right. Normal horizontal
     * sliders with a <code>ComponentOrientation</code> value of <code>RIGHT_TO_LEFT</code> have their maximum on the left.
     * Normal vertical sliders have their maximum on the top. These labels are reversed when the slider is inverted.
     * <p>
     * By default, the value of this property is {@code false}.
     * @param b true to reverse the slider values from their normal order
     */
    public void setInverted(final boolean b)
    {
        for (var slider : this.sliders)
        {
            slider.setInverted(b);
            slider.invalidate();
        }
        invalidate();
        calculateTrackSize();
    }
    /**
     * This method returns the major tick spacing. The number that is returned represents the distance, measured in values,
     * between each major tick mark. If you have a slider with a range from 0 to 50 and the major tick spacing is set to 10, you
     * will get major ticks next to the following values: 0, 10, 20, 30, 40, 50.
     * @return the number of values between major ticks
     */
    public int getMajorTickSpacing()
    {
        return this.sliders[0].getMajorTickSpacing();
    }
    /**
     * This method sets the major tick spacing. The number that is passed in represents the distance, measured in values,
     * between each major tick mark. If you have a slider with a range from 0 to 50 and the major tick spacing is set to 10, you
     * will get major ticks next to the following values: 0, 10, 20, 30, 40, 50.
     * <p>
     * In order for major ticks to be painted, {@code setPaintTicks} must be set to {@code true}.
     * </p>
     * This method will also set up a label table for you. If there is not already a label table, and the major tick spacing is
     * {@code > 0}, and {@code getPaintLabels} returns {@code true}, a standard label table will be generated (by calling
     * {@code createStandardLabels}) with labels at the major tick marks. For the example above, you would get text labels: "0",
     * "10", "20", "30", "40", "50". The label table is then set on the slider by calling {@code setLabelTable}.
     * @param n new value for the {@code majorTickSpacing} property
     */
    public void setMajorTickSpacing(final int n)
    {
        int oldValue = getMajorTickSpacing();
        for (var slider : this.sliders)
        {
            slider.setMajorTickSpacing(n);
            slider.invalidate();
        }
        firePropertyChange("majorTickSpacing", oldValue, n);
        invalidate();
    }
    /**
     * This method returns the minor tick spacing. The number that is returned represents the distance, measured in values,
     * between each minor tick mark. If you have a slider with a range from 0 to 50 and the minor tick spacing is set to 10, you
     * will get minor ticks next to the following values: 0, 10, 20, 30, 40, 50.
     * @return the number of values between minor ticks
     */
    public int getMinorTickSpacing()
    {
        return this.sliders[0].getMinorTickSpacing();
    }
    /**
     * This method sets the minor tick spacing. The number that is passed in represents the distance, measured in values,
     * between each minor tick mark. If you have a slider with a range from 0 to 50 and the minor tick spacing is set to 10, you
     * will get minor ticks next to the following values: 0, 10, 20, 30, 40, 50.
     * <p>
     * In order for minor ticks to be painted, {@code setPaintTicks} must be set to {@code true}.
     * @param n new value for the {@code minorTickSpacing} property
     * @see #getMinorTickSpacing
     * @see #setPaintTicks
     */
    public void setMinorTickSpacing(final int n)
    {
        int oldValue = getMinorTickSpacing();
        for (var slider : this.sliders)
        {
            slider.setMinorTickSpacing(n);
            slider.invalidate();
        }
        firePropertyChange("minorTickSpacing", oldValue, n);
        invalidate();
    }
    /**
     * Returns true if the thumb (and the data value it represents) resolve to the closest tick mark next to where the user
     * positioned the thumb.
     * @return true if the value snaps to the nearest tick mark, else false
     */
    public boolean getSnapToTicks()
    {
        return this.sliders[0].getSnapToTicks();
    }
    /**
     * Specifying true makes the thumb (and the data value it represents) resolve to the closest tick mark next to where the
     * user positioned the thumb. By default, this property is {@code false}.
     * @param b true to snap the thumb to the nearest tick mark
     */
    public void setSnapToTicks(final boolean b)
    {
        boolean oldValue = getSnapToTicks();
        for (var slider : this.sliders)
        {
            slider.setSnapToTicks(b);
        }
        firePropertyChange("snapToTicks", oldValue, b);
    }
    /**
     * Tells if tick marks are to be painted.
     * @return true if tick marks are painted, else false
     */
    public boolean getPaintTicks()
    {
        return this.sliders[0].getPaintTicks();
    }
    /**
     * Determines whether tick marks are painted on the slider. By default, this property is {@code false}.
     * @param b whether or not tick marks should be painted
     */
    public void setPaintTicks(final boolean b)
    {
        boolean oldValue = getPaintTicks();
        for (var slider : this.sliders)
        {
            slider.setPaintTicks(b);
            slider.invalidate();
        }
        firePropertyChange("paintTicks", oldValue, b);
        invalidate();
        calculateTrackSize();
    }
    /**
     * Tells if the track (area the slider slides in) is to be painted.
     * @return true if track is painted, else false
     */
    public boolean getPaintTrack()
    {
        return this.sliders[0].getPaintTrack();
    }
    /**
     * Determines whether the track is painted on the slider. By default, this property is {@code true}. It is up to the look
     * and feel to honor this property, some may choose to ignore it.
     * @param b whether or not to paint the slider track
     * @see #getPaintTrack
     */
    public void setPaintTrack(final boolean b)
    {
        boolean oldValue = getPaintTrack();
        this.sliders[0].setPaintTrack(b);
        firePropertyChange("paintTrack", oldValue, b);
        calculateTrackSize();
    }
    /**
     * Tells if labels are to be painted.
     * @return true if labels are painted, else false
     */
    public boolean getPaintLabels()
    {
        return this.sliders[0].getPaintLabels();
    }
    /**
     * Determines whether labels are painted on the slider.
     * <p>
     * This method will also set up a label table for you. If there is not already a label table, and the major tick spacing is
     * {@code > 0}, a standard label table will be generated (by calling {@code createStandardLabels}) with labels at the major
     * tick marks. The label table is then set on the slider by calling {@code setLabelTable}.
     * </p>
     * By default, this property is {@code false}.
     * @param b whether or not to paint labels
     */
    public void setPaintLabels(final boolean b)
    {
        boolean oldValue = getPaintLabels();
        for (var slider : this.sliders)
        {
            slider.setPaintLabels(b);
            slider.invalidate();
        }
        firePropertyChange("paintLabels", oldValue, b);
        invalidate();
        calculateTrackSize();
    }
    /**
     * Set whether passing of the thumbs is allowed, and check whether thumb values are in line with restrictions.
     * @param b whether passing of the thumbs is allowed or not
     */
    public void setPassing(final boolean b)
    {
        this.passing = b;
        if (!this.passing)
        {
            checkRestrictions();
        }
    }
    /**
     * Return whether passing of the thumbs is allowed.
     * @return whether passing of the thumbs is allowed or not
     */
    public boolean getPassing()
    {
        return this.passing;
    }
    /**
     * Set whether overlap of the thumbs is allowed, and check whether thumb values are in line with restrictions.
     * @param b whether overlap of the thumbs is allowed or not
     */
    public void setOverlap(final boolean b)
    {
        this.overlap = b;
        if (!this.overlap)
        {
            checkRestrictions();
        }
    }
    /**
     * Return whether overlap of the thumbs is allowed.
     * @return whether overlap of the thumbs is allowed or not
     */
    public boolean getOverlap()
    {
        return this.overlap;
    }
    /**
     * Check restrictions on all thumb values and correct values where necessary.
     * @return whether compliance with the restrictions is ok; false means violation
     */
    protected boolean checkRestrictions()
    {
        boolean ret = true;
        if (!getPassing())
        {
            for (int i = 1; i < getNumberOfThumbs(); i++)
            {
                // see if we need to push values 'up'
                if (getIndexValue(i) <= getIndexValue(i - 1))
                {
                    getSlider(i).setValue(getIndexValue(i - 1));
                    ret = false;
                }
            }
            for (int i = getNumberOfThumbs() - 1; i >= 1; i--)
            {
                // see if we need to push values 'down'
                if (getIndexValue(i) <= getIndexValue(i - 1))
                {
                    getSlider(i - 1).setValue(getIndexValue(i));
                    ret = false;
                }
            }
        }
        if (!getOverlap())
        {
            for (int i = 1; i < getNumberOfThumbs(); i++)
            {
                // see if we need to push values 'up'
                if (getIndexValue(i) <= getIndexValue(i - 1))
                {
                    getSlider(i).setValue(getIndexValue(i - 1) + 1);
                    ret = false;
                }
            }
            for (int i = getNumberOfThumbs() - 1; i >= 1; i--)
            {
                // see if we need to push values 'down'
                if (getIndexValue(i) <= getIndexValue(i - 1))
                {
                    getSlider(i - 1).setValue(getIndexValue(i) - 1);
                    ret = false;
                }
            }
        }
        if (!ret)
        {
            invalidate();
            repaint();
        }
        return ret;
    }
    /**
     * Check restrictions on the thumb values of thumb 'index' and correct values where necessary.
     * @param index the slider for which to check (the only one whose value should change)
     * @return whether compliance with the restrictions is ok; false means violation
     */
    protected boolean checkRestrictions(final int index)
    {
        boolean ret = true;
        if (!getPassing())
        {
            if (index > 0 && getIndexValue(index) <= getIndexValue(index - 1))
            {
                getSlider(index).setValue(getIndexValue(index - 1));
                ret = false;
            }
            if (index < getNumberOfThumbs() - 1 && getIndexValue(index) >= getIndexValue(index + 1))
            {
                getSlider(index).setValue(getIndexValue(index + 1));
                ret = false;
            }
        }
        if (!getOverlap())
        {
            if (index > 0 && getIndexValue(index) <= getIndexValue(index - 1))
            {
                getSlider(index).setValue(getIndexValue(index - 1) + 1);
                ret = false;
            }
            if (index < getNumberOfThumbs() - 1 && getIndexValue(index) >= getIndexValue(index + 1))
            {
                getSlider(index).setValue(getIndexValue(index + 1) - 1);
                ret = false;
            }
        }
        if (!ret)
        {
            getSlider(index).invalidate();
            getSlider(index).repaint();
        }
        return ret;
    }
    /**
     * The DispatcherPane class, which is a glass pane sitting on top of the sliders to dispatch the mouse event to the correct
     * slider class. Note that the mouse coordinates are relative to the component itself, so a translation might be needed. The
     * <code>SwingUtilities.convertPoint()</code> method can make the conversion.
     */
    protected static class DispatcherPane extends JComponent
    {
        /** */
        private static final long serialVersionUID = 1L;
        /** the pointer to the multislider object; protected to access it by the mouse handlers. */
        @SuppressWarnings("checkstyle:visibilitymodifier")
        protected final AbstractMultiSlider<?> multiSlider;
        /**
         * Return the closest slider number based on x (horizontal) or y (vertical) locations of the thumbs. When two or more
         * thumbs are at the exact same distance (e.g., because they overlap), the first slider found that has a movement option
         * in the direction of the mouse will be returned.
         * @param p the point (e.g., of a mouse position)
         * @return the index(es) of the closest slider(s)
         */
        int closestSliderIndex(final Point p)
        {
            if (this.multiSlider.getBusySlider() >= 0)
            {
                return this.multiSlider.getBusySlider();
            }
            int mindist = Integer.MAX_VALUE; // non-absolute lowest distance of (mouse) position in pixels
            int minIndex = -1; // int scale value of thumb at closest position
            int mini = -1; // thumb index of closest position
            int loi = Integer.MAX_VALUE; // lowest thumb number on closest position
            int hii = -1; // highest thumb number on closest position
            for (int i = 0; i < this.multiSlider.getNumberOfThumbs(); i++)
            {
                int posPx = this.multiSlider.thumbPositionPx(i);
                int dist = this.multiSlider.isHorizontal() ? posPx - p.x : posPx - (getHeight() - p.y);
                if (Math.abs(dist) == Math.abs(mindist))
                {
                    hii = i;
                }
                else if (Math.abs(dist) < Math.abs(mindist))
                {
                    mindist = dist;
                    mini = i;
                    minIndex = this.multiSlider.getIndexValue(i);
                    loi = i;
                    hii = i;
                }
            }
            // if only one closest slider (loi == hii), or no passing restrictions: move any slider!
            if (loi == hii || this.multiSlider.getPassing())
            {
                return mini;
            }
            if (minIndex == this.multiSlider.getIndexMinimum()) // hi movement only
            {
                return hii;
            }
            if (minIndex == this.multiSlider.getIndexMaximum()) // lo movement only
            {
                return loi;
            }
            if (mindist > 0) // mouse to the left
            {
                return loi;
            }
            return hii; // mouse to the right
        }
        /**
         * @param e the MouseEvent to dispatch to the sliders.
         * @param always indicates whether we always need to send the event
         * @return the slider index to which the event was dispatched; -1 if none
         */
        int dispatch(final MouseEvent e, final boolean always)
        {
            var slider = DispatcherPane.this.multiSlider.getSlider(0);
            Point pSlider = SwingUtilities.convertPoint(DispatcherPane.this, e.getPoint(), slider);
            if (always || (pSlider.x >= 0 && pSlider.x <= slider.getSize().width && pSlider.y >= 0
                    && pSlider.y <= slider.getSize().height))
            {
                int index = closestSliderIndex(pSlider);
                MouseEvent meSlider = new MouseEvent((Component) e.getSource(), e.getID(), e.getWhen(), e.getModifiersEx(),
                        pSlider.x, pSlider.y, e.getClickCount(), e.isPopupTrigger(), e.getButton());
                try
                {
                    DispatcherPane.this.multiSlider.getSlider(index).dispatchEvent(meSlider);
                }
                catch (Exception exception)
                {
                    CategoryLogger.always().error(exception, "error dispatching mouseEvent {}", meSlider);
                }
                setBusySlider(index);
                return index;
            }
            return -1;
        }
        /**
         * Reset the busy slider -- action over. Call this method BEFORE processing the MouseEvent. In that way, the
         * ChangeListener will fire a StateChange while the busy slider is -1 -- indicating that there is a final value.
         * @param index the slider number that is busy, or -1 if none
         */
        void setBusySlider(final int index)
        {
            this.multiSlider.setBusySlider(index);
        }
        /**
         * Indicate whether the multislider is busy with handling an action (e.g., drag, mouse down). Note that the busy flag
         * has to be set BEFORE the mouse event is handled, to allow a listener to the ChangeEvent to only react when an action
         * is completed.
         * @param b whether the multislider is busy with handling an action or not
         */
        void setBusy(final boolean b)
        {
            this.multiSlider.setBusy(b);
        }
        /**
         * Check whether minimum, maximum, passing or overlap restrictions were violated, and if so, adjust.
         * @param index the slider number for which to check
         */
        void checkRestrictions(final int index)
        {
            this.multiSlider.checkRestrictions(index);
        }
        /**
         * Create a glass pane on top of the sliders.
         * @param multiSlider the multislider for which this is the glass pane
         */
        public DispatcherPane(final AbstractMultiSlider<?> multiSlider)
        {
            this.multiSlider = multiSlider;
            setOpaque(false);
            addMouseListener(new MouseListener()
            {
                @Override
                public void mouseReleased(final MouseEvent e)
                {
                    setBusy(false);
                    int index = dispatch(e, false);
                    if (index >= 0)
                    {
                        checkRestrictions(index);
                        DispatcherPane.this.multiSlider.fireFinalValueChanged();
                    }
                    setBusySlider(-1);
                }
                @Override
                public void mousePressed(final MouseEvent e)
                {
                    setBusy(true);
                    int index = dispatch(e, false);
                    setBusySlider(index);
                    if (index < 0)
                    {
                        setBusy(false);
                    }
                }
                @Override
                public void mouseExited(final MouseEvent e)
                {
                    setBusy(false);
                    // emulate a mouse release somewhere on the current slider to force correct update
                    var ms = DispatcherPane.this.multiSlider;
                    if (ms.getBusySlider() >= 0)
                    {
                        JSlider js = ms.getSlider(ms.getBusySlider());
                        checkRestrictions(ms.getBusySlider());
                        MouseEvent meSlider = new MouseEvent(js, MouseEvent.MOUSE_RELEASED, e.getWhen(), 0, 10, 10, 1, false,
                                MouseEvent.BUTTON1);
                        js.dispatchEvent(meSlider);
                        ms.fireFinalValueChanged();
                    }
                    setBusySlider(-1);
                }
                @Override
                public void mouseEntered(final MouseEvent e)
                {
                    // no action
                }
                @Override
                public void mouseClicked(final MouseEvent e)
                {
                    // completely caught by pressed + released (or pressed + dragged + released)
                }
            });
            addMouseMotionListener(new MouseMotionAdapter()
            {
                @Override
                public void mouseDragged(final MouseEvent e)
                {
                    setBusy(true);
                    int index = dispatch(e, false);
                    setBusySlider(index);
                    if (index < 0)
                    {
                        setBusy(false);
                    }
                    else
                    {
                        checkRestrictions(index);
                    }
                }
                @Override
                public void mouseMoved(final MouseEvent e)
                {
                    // Note: possibly we can trigger an 'mouse entered' when the mouse enters the slider pane,
                    // and a 'mouse exited' when the mouse exits the slider pane.
                }
            });
        }
    }
    /**
     * The LabelPanel is draw above a horizontal slider or left of a vertical slider and displays labels for the thumbs of the
     * slider, so one can see which one is which.
     */
    protected static class LabelPanel extends JPanel
    {
        /** */
        private static final long serialVersionUID = 1L;
        /** a pointer to the multislider. */
        private final AbstractMultiSlider<?> multiSlider;
        /**
         * Default constructor.
         * @param multiSlider the multislider for which this is the LabelPanel.
         */
        public LabelPanel(final AbstractMultiSlider<?> multiSlider)
        {
            this.multiSlider = multiSlider;
            repaint();
            this.multiSlider.addChangeListener(new ChangeListener()
            {
                @Override
                public void stateChanged(final ChangeEvent e)
                {
                    repaint();
                }
            });
        }
        @Override
        public void paintComponent(final Graphics g)
        {
            super.paintComponent(g);
            if (LabelPanel.this.multiSlider.isDrawThumbLabels())
            {
                for (int i = 0; i < this.multiSlider.getNumberOfThumbs(); i++)
                {
                    int pos = this.multiSlider.thumbPositionPx(i);
                    String s = this.multiSlider.getThumbLabel(i);
                    int sw = g.getFontMetrics().stringWidth(s);
                    int sh = g.getFontMetrics().getHeight();
                    if (this.multiSlider.isHorizontal())
                    {
                        g.drawString(s, pos - sw / 2, 12);
                    }
                    else
                    {
                        g.drawString(s, getWidth() - sw - 10, getHeight() - pos + sh / 2 - 3);
                    }
                }
            }
        }
    }
    /**
     * The FinalValueChangeListener sends a final value to the listeners after mouse-up, leave focus, setValue(...), and
     * setInitialValues().
     */
    public interface FinalValueChangeListener extends ChangeListener
    {
        // no extra events
    }
}