SliderTicks.java

package swingtree.api.model;

import org.jspecify.annotations.Nullable;
import sprouts.Association;
import sprouts.Pair;
import sprouts.Tuple;
import swingtree.api.IconDeclaration;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;

/**
 *  An immutable value describing the tick marks and the labels along a slider:
 *  where the major and minor tick marks are, whether they are drawn, whether the
 *  knob snaps to them, and which labels are shown at which numbers.
 *  Hand one of these to a slider through
 *  {@link swingtree.UIForSlider#withTicks(SliderTicks)}, or bind a property holding one through
 *  {@link swingtree.UIForSlider#withTicks(sprouts.Val)}:
 *  <pre>{@code
 *  UI.slider(UI.Axis.HORIZONTAL, 0, 100, volume)
 *  .withTicks(
 *      SliderTicks.of(Integer.class)
 *      .withMajorSpacing(25)
 *      .withMinorTicksBetween(4)
 *      .withLabelsAtMajorTicks( v -> v + "%" )
 *  );
 *  }</pre>
 *  This slider draws a major tick mark at 0, 25, 50, 75 and 100, labelled
 *  "0%", "25%", "50%", "75%" and "100%", and four minor tick marks between each
 *  pair of major tick marks, which puts a minor tick mark at every multiple of 5.
 *
 *  <h2>Numbers in the slider's own type</h2>
 *
 *  Every spacing and every label position is a number of the same type as the
 *  value of the slider, which is the {@code N} of this class. A slider for
 *  a {@code Double} property therefore has its tick marks declared in {@code Double}s:
 *  <pre>{@code
 *  UI.slider(UI.Axis.HORIZONTAL, 0.0, 1.0, opacity)
 *  .withTicks(
 *      SliderTicks.of(Double.class)
 *      .withMajorSpacing(0.25)
 *      .withTickMarksVisible(false)
 *      .withLabelsAtMajorTicks( v -> Math.round(v * 100) + "%" )
 *  );
 *  }</pre>
 *  A plain {@link javax.swing.JSlider} only knows whole numbers, so SwingTree
 *  maps fractional numbers onto a range of whole numbers behind the scenes. It
 *  chooses that range so that every tick mark lands exactly on one of its whole numbers.
 *
 *  <h2>Counting starts at the minimum</h2>
 *
 *  Tick marks, and the labels at the major tick marks, count from the minimum of
 *  the slider. On a slider running from 3 to 97 with a major spacing of 25, the major
 *  tick marks sit at 3, 28, 53 and 78. This is how {@link javax.swing.plaf.basic.BasicSliderUI},
 *  which the look and feels of Swing build on, draws tick marks, and a label is only useful
 *  where its tick mark is. A label which has to sit
 *  somewhere else can be placed at any number through {@link #withLabelAt(Number, String)}.
 *
 *  <h2>It is a value</h2>
 *
 *  Every method which sounds like it changes something returns a new {@link SliderTicks},
 *  leaving the one you called it on untouched. Two instances describing the same tick
 *  marks and labels are {@link #equals(Object)} to each other, which is how a slider bound
 *  to a property holding a {@link SliderTicks} knows whether anything actually changed.
 *  A label text function is compared by identity, so a method reference or a lambda
 *  which captures nothing is equal to itself every time it is evaluated.
 *  <p>
 *  Since this value holds no Swing component, it is safe to keep in a view model and
 *  to hand from the application thread to the UI thread.
 *  To declare a property of it, use {@link #classTyped(Class)}:
 *  <pre>{@code
 *  Val<SliderTicks<Integer>> ticks = showTicks.viewAs(
 *      SliderTicks.classTyped(Integer.class),
 *      show -> show ? SliderTicks.of(Integer.class).withMajorSpacing(10) : SliderTicks.of(Integer.class)
 *  );
 *  }</pre>
 *
 * @param <N> The number type of the slider, which is also the type of every spacing and label position.
 */
public final class SliderTicks<N extends Number>
{
    private static final Association<Number, Label> NO_LABELS = Association.betweenLinked(Number.class, Label.class);

    private final Class<N>                   _numberType;
    private final @Nullable N                _majorSpacing;
    private final int                        _minorTicksBetween;
    private final boolean                    _tickMarksVisible;
    private final boolean                    _snapsToTicks;
    private final boolean                    _labelsAtMajorTicks;
    private final @Nullable Function<N, String> _majorTickLabelText;
    private final Locale                     _labelLocale;
    private final Association<Number, Label> _labels;

    /**
     *  Creates a description of no tick marks and no labels for a slider whose value is
     *  of the given number type. This is where every {@link SliderTicks} starts out:
     *  <pre>{@code
     *  SliderTicks.of(Integer.class).withMajorSpacing(10)
     *  }</pre>
     *  The supported number types are {@link Integer}, {@link Long}, {@link Short},
     *  {@link Byte}, {@link Float} and {@link Double}, which are also the number types
     *  a slider can be bound to.
     *
     * @param numberType The type of the value of the slider, and therefore of every spacing and label position.
     * @param <N> The number type of the slider.
     * @return A {@link SliderTicks} with no tick marks and no labels.
     * @throws NullPointerException If {@code numberType} is {@code null}.
     * @throws IllegalArgumentException If {@code numberType} is not one of the supported number types.
     */
    public static <N extends Number> SliderTicks<N> of( Class<N> numberType ) {
        Objects.requireNonNull(numberType, "numberType");
        Class<N> wrapperType = _wrapperOf(numberType);
        return new SliderTicks<>(wrapperType, null, 0, true, false, false, null, Locale.ROOT, NO_LABELS);
    }

    /**
     *  An alternative to {@code SliderTicks.class} which keeps the number type in
     *  the type signature, so that you can declare a property holding a
     *  {@link SliderTicks} without casting:
     *  <pre>{@code
     *  Var<SliderTicks<Double>> ticks = Var.of(SliderTicks.classTyped(Double.class), SliderTicks.of(Double.class));
     *  }</pre>
     *
     * @param numberType The number type {@code N} in the returned {@code Class<SliderTicks<N>>}.
     * @param <N> The number type of the slider.
     * @return The {@code SliderTicks.class}, typed as {@code Class<SliderTicks<N>>}.
     * @throws NullPointerException If {@code numberType} is {@code null}.
     */
    @SuppressWarnings({"unchecked", "rawtypes"})
    public static <N extends Number> Class<SliderTicks<N>> classTyped( Class<N> numberType ) {
        Objects.requireNonNull(numberType, "numberType");
        return (Class) SliderTicks.class;
    }

    private SliderTicks(
        Class<N>                      numberType,
        @Nullable N                   majorSpacing,
        int                           minorTicksBetween,
        boolean                       tickMarksVisible,
        boolean                       snapsToTicks,
        boolean                       labelsAtMajorTicks,
        @Nullable Function<N, String> majorTickLabelText,
        Locale                        labelLocale,
        Association<Number, Label>    labels
    ) {
        _numberType         = numberType;
        _majorSpacing       = majorSpacing;
        _minorTicksBetween  = minorTicksBetween;
        _tickMarksVisible   = tickMarksVisible;
        _snapsToTicks       = snapsToTicks;
        _labelsAtMajorTicks = labelsAtMajorTicks;
        _majorTickLabelText = majorTickLabelText;
        _labelLocale        = labelLocale;
        _labels             = labels;
    }

    /**
     *  Returns the type of the value of the slider, which is also the type of every spacing and label position.
     *
     * @return The number type of the slider.
     */
    public Class<N> numberType() {
        return _numberType;
    }

    /**
     *  Returns a copy with major tick marks at the given spacing, counted from the minimum
     *  of the slider. A spacing of 10 on a slider running from 0 to 100 puts a major tick
     *  mark at 0, 10, 20 and so on up to 100.
     *  The major spacing is also how far the knob moves when the user clicks into the
     *  track beside it, or presses page up or page down, unless there are minor tick
     *  marks, whose spacing is used instead.
     *  <p>
     *  A spacing of zero removes the tick marks, and with them the labels at the major tick marks.
     *
     * @param spacing The distance between two neighbouring major tick marks, in the numbers of the slider.
     * @return A new {@link SliderTicks} with the given major spacing.
     * @throws NullPointerException If {@code spacing} is {@code null}.
     * @throws IllegalArgumentException If {@code spacing} is negative, not a number or infinite.
     */
    public SliderTicks<N> withMajorSpacing( N spacing ) {
        Objects.requireNonNull(spacing, "spacing");
        double asDouble = spacing.doubleValue();
        if ( Double.isNaN(asDouble) || Double.isInfinite(asDouble) )
            throw new IllegalArgumentException("The major spacing of slider ticks must be a finite number, but was " + spacing + ".");
        if ( asDouble < 0 )
            throw new IllegalArgumentException("The major spacing of slider ticks must not be negative, but was " + spacing + ".");
        @Nullable N majorSpacing = asDouble == 0 ? null : spacing;
        if ( Objects.equals(majorSpacing, _majorSpacing) )
            return this;
        return new SliderTicks<>(_numberType, majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, _labelsAtMajorTicks, _majorTickLabelText, _labelLocale, _labels);
    }

    /**
     *  Returns the distance between two neighbouring major tick marks, if there are tick marks.
     *
     * @return The major spacing, or an empty {@link Optional} if there are no tick marks.
     */
    public Optional<N> majorSpacing() {
        return Optional.ofNullable(_majorSpacing);
    }

    /**
     *  Returns a copy with the given number of minor tick marks between each pair of
     *  neighbouring major tick marks. Four minor tick marks between major tick marks
     *  which are 25 apart divide that distance into five equal parts, so a minor tick
     *  mark sits at every multiple of 5. Zero means that there are no minor tick marks.
     *  <p>
     *  Counting the tick marks between the major ones, instead of declaring a second
     *  spacing, makes it impossible to declare minor tick marks which miss the major ones.
     *  On a slider for whole numbers the major spacing must still be divisible into
     *  that many equal whole number parts: a major spacing of 25 with 3 minor tick marks
     *  between them would need a minor tick mark every 6.25, so SwingTree logs a warning
     *  and draws no minor tick marks.
     *
     * @param count The number of minor tick marks between two neighbouring major tick marks.
     * @return A new {@link SliderTicks} with the given number of minor tick marks.
     * @throws IllegalArgumentException If {@code count} is negative.
     */
    public SliderTicks<N> withMinorTicksBetween( int count ) {
        if ( count < 0 )
            throw new IllegalArgumentException("The number of minor tick marks between major tick marks must not be negative, but was " + count + ".");
        if ( count == _minorTicksBetween )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, count, _tickMarksVisible, _snapsToTicks, _labelsAtMajorTicks, _majorTickLabelText, _labelLocale, _labels);
    }

    /**
     *  Returns how many minor tick marks sit between two neighbouring major tick marks.
     *
     * @return The number of minor tick marks between two neighbouring major tick marks.
     */
    public int minorTicksBetween() {
        return _minorTicksBetween;
    }

    /**
     *  Returns a copy whose tick marks are drawn or not drawn, depending on the given flag.
     *  Tick marks are drawn by default.
     *  <p>
     *  Tick marks which are not drawn still exist: the labels at the major tick marks are
     *  still shown, and the knob still snaps to them if {@link #withSnapToTicks(boolean)}
     *  says so. This is how you build a slider with labels at 0, 25, 50, 75 and 100
     *  but without any marks on its track.
     *
     * @param visible {@code true} to draw the tick marks, {@code false} to only use their positions.
     * @return A new {@link SliderTicks} with the given visibility of its tick marks.
     */
    public SliderTicks<N> withTickMarksVisible( boolean visible ) {
        if ( visible == _tickMarksVisible )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, visible, _snapsToTicks, _labelsAtMajorTicks, _majorTickLabelText, _labelLocale, _labels);
    }

    /**
     *  Tells whether the tick marks are drawn, or whether only their positions are used.
     *
     * @return {@code true} if the tick marks are drawn, {@code false} if only their positions are used.
     */
    public boolean hasVisibleTickMarks() {
        return _tickMarksVisible;
    }

    /**
     *  Returns a copy whose tick marks the knob snaps to when the user moves it, or not,
     *  depending on the given flag. The knob snaps to the minor tick marks if there are
     *  any, and to the major tick marks otherwise. Without a major spacing there is nothing
     *  to snap to, and the flag has no effect.
     *  <p>
     *  While the user drags the knob, the value of the slider is the number at the nearest
     *  tick mark, and when the user lets go of the knob, it moves onto that tick mark.
     *  An arrow key moves the knob to the next tick mark in the direction of the key.
     *  <p>
     *  Snapping only ever applies to what the user does. When your application sets the
     *  value of the slider to a number between two tick marks, the knob sits between those
     *  tick marks, and the value is not changed behind your back.
     *
     * @param snap {@code true} to let the knob snap to the tick marks, {@code false} to let it move freely.
     * @return A new {@link SliderTicks} with the given snapping behaviour.
     */
    public SliderTicks<N> withSnapToTicks( boolean snap ) {
        if ( snap == _snapsToTicks )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, snap, _labelsAtMajorTicks, _majorTickLabelText, _labelLocale, _labels);
    }

    /**
     *  Tells whether the knob snaps to the tick marks when the user moves it.
     *
     * @return {@code true} if the knob snaps to the tick marks when the user moves it.
     */
    public boolean isSnappingToTicks() {
        return _snapsToTicks;
    }

    /**
     *  Returns a copy with a label at every major tick mark, showing the number at that
     *  tick mark. All labels show their numbers with the same count of decimal places,
     *  namely the fewest which write every one of them exactly: labels at 0, 0.25, 0.5,
     *  0.75 and 1 read "0.00", "0.25", "0.50", "0.75" and "1.00".
     *  <p>
     *  The numbers are written independently of the locale of the machine, with a dot as
     *  decimal separator and without grouping, so "1234.5" reads the same everywhere.
     *  To write them the way a particular locale does, use {@link #withLabelLocale(Locale)}.
     *  <p>
     *  The labels only exist while there is a major spacing, see {@link #withMajorSpacing(Number)}.
     *
     * @return A new {@link SliderTicks} with a number label at every major tick mark.
     */
    public SliderTicks<N> withLabelsAtMajorTicks() {
        if ( _labelsAtMajorTicks && _majorTickLabelText == null )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, true, null, _labelLocale, _labels);
    }

    /**
     *  Returns a copy with a label at every major tick mark, whose text is computed from
     *  the number at that tick mark by the given function:
     *  <pre>{@code
     *  SliderTicks.of(Integer.class).withMajorSpacing(25).withLabelsAtMajorTicks( v -> v + "%" )
     *  }</pre>
     *  The function receives the exact number at the tick mark, computed as the minimum of
     *  the slider plus a whole multiple of the major spacing, so a slider from 0.0 with a
     *  major spacing of 0.1 hands 0.3 to the function, and never 0.30000000000000004.
     *  <p>
     *  The labels only exist while there is a major spacing, see {@link #withMajorSpacing(Number)}.
     *
     * @param text The function computing the text of a label from the number at its tick mark.
     * @return A new {@link SliderTicks} with a label at every major tick mark.
     * @throws NullPointerException If {@code text} is {@code null}.
     */
    public SliderTicks<N> withLabelsAtMajorTicks( Function<N, String> text ) {
        Objects.requireNonNull(text, "text");
        if ( _labelsAtMajorTicks && _majorTickLabelText == text )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, true, text, _labelLocale, _labels);
    }

    /**
     *  Returns a copy without labels at the major tick marks. The labels placed at
     *  particular numbers through {@link #withLabelAt(Number, String)} are kept.
     *
     * @return A new {@link SliderTicks} without labels at the major tick marks.
     */
    public SliderTicks<N> withoutLabelsAtMajorTicks() {
        if ( !_labelsAtMajorTicks )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, false, null, _labelLocale, _labels);
    }

    /**
     *  Tells whether there is a label at every major tick mark.
     *
     * @return {@code true} if there is a label at every major tick mark.
     */
    public boolean hasLabelsAtMajorTicks() {
        return _labelsAtMajorTicks;
    }

    /**
     *  Returns the function computing the text of the labels at the major tick marks,
     *  if one was supplied through {@link #withLabelsAtMajorTicks(Function)}.
     *
     * @return The label text function, or an empty {@link Optional} if the labels at the major
     *         tick marks show their numbers, or if there are no labels at the major tick marks.
     */
    public Optional<Function<N, String>> majorTickLabelText() {
        return Optional.ofNullable(_majorTickLabelText);
    }

    /**
     *  Returns a copy whose labels at the major tick marks write their numbers the way the
     *  given locale does. With {@link Locale#GERMANY} a label at 1234.5 reads "1.234,5",
     *  whereas with {@link Locale#US} it reads "1,234.5".
     *  <p>
     *  The default is {@link Locale#ROOT}, which writes numbers independently of any locale:
     *  with a dot as decimal separator and without grouping, so the same label reads "1234.5".
     *  Pass {@code Locale.getDefault()} to follow the locale of the machine.
     *  <p>
     *  The locale only affects labels showing numbers through {@link #withLabelsAtMajorTicks()}.
     *  A text function passed to {@link #withLabelsAtMajorTicks(Function)} does its own formatting.
     *
     * @param locale The locale whose conventions the number labels follow.
     * @return A new {@link SliderTicks} with the given label locale.
     * @throws NullPointerException If {@code locale} is {@code null}.
     */
    public SliderTicks<N> withLabelLocale( Locale locale ) {
        Objects.requireNonNull(locale, "locale");
        if ( locale.equals(_labelLocale) )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, _labelsAtMajorTicks, _majorTickLabelText, locale, _labels);
    }

    /**
     *  Returns the locale whose conventions the number labels at the major tick marks follow.
     *
     * @return The label locale, which is {@link Locale#ROOT} unless {@link #withLabelLocale(Locale)} says otherwise.
     */
    public Locale labelLocale() {
        return _labelLocale;
    }

    /**
     *  Returns a copy with a text label at the given number, replacing any label which was
     *  at that number before. The number does not need to be at a tick mark:
     *  <pre>{@code
     *  SliderTicks.of(Integer.class)
     *  .withLabelAt(0, "Cold")
     *  .withLabelAt(50, "Warm")
     *  .withLabelAt(100, "Hot")
     *  }</pre>
     *  A label at a number outside the range of the slider is not shown.
     *  When a label at a particular number and a label at a major tick mark fall onto the
     *  same position, the label at the particular number is shown.
     *
     * @param value The number at which the label is shown.
     * @param text The text of the label.
     * @return A new {@link SliderTicks} with the given label.
     * @throws NullPointerException If {@code value} or {@code text} is {@code null}.
     */
    public SliderTicks<N> withLabelAt( N value, String text ) {
        Objects.requireNonNull(value, "value");
        Objects.requireNonNull(text, "text");
        return _withLabel(value, new Label(text, null));
    }

    /**
     *  Returns a copy with an icon label at the given number, replacing any label which was
     *  at that number before. This is how a volume slider shows a muted speaker at its start
     *  and a loud speaker at its end:
     *  <pre>{@code
     *  SliderTicks.of(Integer.class)
     *  .withLabelAt(0, Icons.MUTED)
     *  .withLabelAt(100, Icons.LOUD)
     *  }</pre>
     *  A label at a number outside the range of the slider is not shown.
     *  When a label at a particular number and a label at a major tick mark fall onto the
     *  same position, the label at the particular number is shown.
     *
     * @param value The number at which the label is shown.
     * @param icon The icon of the label.
     * @return A new {@link SliderTicks} with the given label.
     * @throws NullPointerException If {@code value} or {@code icon} is {@code null}.
     */
    public SliderTicks<N> withLabelAt( N value, IconDeclaration icon ) {
        Objects.requireNonNull(value, "value");
        Objects.requireNonNull(icon, "icon");
        return _withLabel(value, new Label(null, icon));
    }

    /**
     *  Returns a copy without the label at the given number, if there is one.
     *  The labels at the major tick marks are not affected.
     *
     * @param value The number of the label to remove.
     * @return A new {@link SliderTicks} without a label at the given number.
     * @throws NullPointerException If {@code value} is {@code null}.
     */
    public SliderTicks<N> withoutLabelAt( N value ) {
        Objects.requireNonNull(value, "value");
        if ( !_labels.containsKey(value) )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, _labelsAtMajorTicks, _majorTickLabelText, _labelLocale, _labels.remove(value));
    }

    /**
     *  Returns the numbers at which a label was placed through {@link #withLabelAt(Number, String)}
     *  or {@link #withLabelAt(Number, IconDeclaration)}, in the order they were placed.
     *
     * @return The positions of the labels placed at particular numbers.
     */
    @SuppressWarnings("unchecked")
    public Tuple<N> labelPositions() {
        List<N> positions = new ArrayList<>(_labels.size());
        for ( Pair<Number, Label> entry : _labels )
            positions.add((N) entry.first());
        return Tuple.of(_numberType, positions);
    }

    /**
     *  Returns the text of the label placed at the given number, if it is a text label.
     *
     * @param value The number of the label.
     * @return The text of the label at the given number, or an empty {@link Optional}
     *         if there is no label at that number, or if it is an icon label.
     */
    public Optional<String> labelTextAt( N value ) {
        Objects.requireNonNull(value, "value");
        return _labels.get(value).map( label -> label.text );
    }

    /**
     *  Returns the icon of the label placed at the given number, if it is an icon label.
     *
     * @param value The number of the label.
     * @return The icon of the label at the given number, or an empty {@link Optional}
     *         if there is no label at that number, or if it is a text label.
     */
    public Optional<IconDeclaration> labelIconAt( N value ) {
        Objects.requireNonNull(value, "value");
        return _labels.get(value).map( label -> label.icon );
    }

    private SliderTicks<N> _withLabel( N value, Label label ) {
        if ( _labels.get(value).map(label::equals).orElse(false) )
            return this;
        return new SliderTicks<>(_numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks, _labelsAtMajorTicks, _majorTickLabelText, _labelLocale, _labels.put(value, label));
    }

    @SuppressWarnings("unchecked")
    private static <N extends Number> Class<N> _wrapperOf( Class<N> numberType ) {
        if ( numberType == int.class    || numberType == Integer.class ) return (Class<N>) Integer.class;
        if ( numberType == long.class   || numberType == Long.class    ) return (Class<N>) Long.class;
        if ( numberType == short.class  || numberType == Short.class   ) return (Class<N>) Short.class;
        if ( numberType == byte.class   || numberType == Byte.class    ) return (Class<N>) Byte.class;
        if ( numberType == float.class  || numberType == Float.class   ) return (Class<N>) Float.class;
        if ( numberType == double.class || numberType == Double.class  ) return (Class<N>) Double.class;
        throw new IllegalArgumentException(
                "A slider supports the number types Integer, Long, Short, Byte, Float and Double, but not " + numberType.getName() + "."
            );
    }

    @Override
    public boolean equals( @Nullable Object obj ) {
        if ( obj == this ) return true;
        if ( !(obj instanceof SliderTicks) ) return false;
        SliderTicks<?> other = (SliderTicks<?>) obj;
        return _numberType         == other._numberType
            && _minorTicksBetween  == other._minorTicksBetween
            && _tickMarksVisible   == other._tickMarksVisible
            && _snapsToTicks       == other._snapsToTicks
            && _labelsAtMajorTicks == other._labelsAtMajorTicks
            && _majorTickLabelText == other._majorTickLabelText
            && Objects.equals(_majorSpacing, other._majorSpacing)
            && _labelLocale.equals(other._labelLocale)
            && _labels.equals(other._labels);
    }

    @Override
    public int hashCode() {
        return Objects.hash(
                    _numberType, _majorSpacing, _minorTicksBetween, _tickMarksVisible, _snapsToTicks,
                    _labelsAtMajorTicks, System.identityHashCode(_majorTickLabelText), _labelLocale, _labels
                );
    }

    @Override
    public String toString() {
        String majorTickLabels = !_labelsAtMajorTicks ? "none" : ( _majorTickLabelText == null ? "numbers" : "text" );
        String locale = _labelLocale.equals(Locale.ROOT) ? "ROOT" : _labelLocale.toLanguageTag();
        return getClass().getSimpleName() + "[" +
                    "numberType="         + _numberType.getSimpleName() + ", " +
                    "majorSpacing="       + _majorSpacing               + ", " +
                    "minorTicksBetween="  + _minorTicksBetween          + ", " +
                    "tickMarksVisible="   + _tickMarksVisible           + ", " +
                    "snapToTicks="        + _snapsToTicks               + ", " +
                    "majorTickLabels="    + majorTickLabels             + ", " +
                    "labelLocale="        + locale                      + ", " +
                    "labels="             + _labels                     +
                "]";
    }

    private static final class Label
    {
        final @Nullable String          text;
        final @Nullable IconDeclaration icon;

        Label( @Nullable String text, @Nullable IconDeclaration icon ) {
            this.text = text;
            this.icon = icon;
        }

        @Override
        public boolean equals( @Nullable Object obj ) {
            if ( obj == this ) return true;
            if ( !(obj instanceof Label) ) return false;
            Label other = (Label) obj;
            return Objects.equals(text, other.text) && Objects.equals(icon, other.icon);
        }

        @Override
        public int hashCode() {
            return Objects.hash(text, icon);
        }

        @Override
        public String toString() {
            return text != null ? "\"" + text + "\"" : String.valueOf(icon);
        }
    }
}