SliderGrid.java
package swingtree;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.api.model.SliderTicks;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.util.Objects;
final class SliderGrid
{
private static final Logger log = LoggerFactory.getLogger(SliderGrid.class);
static final int PREFERRED_STEPS = 256;
static final int MAX_TICK_MARKS = 100_000;
private final Class<? extends Number> _numberType;
private final BigDecimal _min;
private final BigDecimal _max;
private final int _intMin;
private final int _intMax;
private final BigDecimal _numbersPerStepNumerator;
private final long _numbersPerStepDenominator;
private final int _majorSpacingInSteps;
private final int _minorSpacingInSteps;
private final @Nullable BigDecimal _majorSpacing;
static SliderGrid of(
Class<? extends Number> numberType,
Number min,
Number max,
@Nullable SliderTicks<?> ticks
) {
BigDecimal minimum = decimalOf(min);
BigDecimal maximum = decimalOf(max).max(minimum);
BigDecimal range = maximum.subtract(minimum);
@Nullable BigDecimal majorSpacing = ticks == null ? null : ticks.majorSpacing().map(SliderGrid::decimalOf).orElse(null);
int minorTicksBetween = ticks == null ? 0 : ticks.minorTicksBetween();
if ( isWholeNumberType(numberType) )
return _wholeNumberGrid(numberType, minimum, maximum, majorSpacing, minorTicksBetween);
else
return _fractionalGrid(numberType, minimum, maximum, range, majorSpacing, minorTicksBetween);
}
static SliderGrid ofFractionalTickSpacings(
Class<? extends Number> numberType,
Number min,
Number max,
@Nullable Number majorTickSpacing,
@Nullable Number minorTickSpacing
) {
BigDecimal minimum = decimalOf(min);
BigDecimal maximum = decimalOf(max).max(minimum);
BigDecimal range = maximum.subtract(minimum);
@Nullable BigDecimal majorSpacing = _positiveDecimalOrNull(majorTickSpacing);
@Nullable BigDecimal minorSpacing = _positiveDecimalOrNull(minorTickSpacing);
@Nullable BigDecimal commonDivisor = _largestCommonDivisor(majorSpacing, minorSpacing);
if ( range.signum() <= 0 || commonDivisor == null )
return _fractionalGrid(numberType, minimum, maximum, range, null, 0);
double partsInRange = range.divide(commonDivisor, MathContext.DECIMAL64).doubleValue();
if ( partsInRange > MAX_TICK_MARKS ) {
log.warn(SwingTree.get().logMarker(),
"A slider from {} to {} can only place major tick marks every {} and minor tick marks every {} exactly " +
"by dividing its range into parts of {}, which are more than {} parts. No tick marks are drawn.",
minimum.toPlainString(), maximum.toPlainString(), _plainOrZero(majorSpacing), _plainOrZero(minorSpacing),
commonDivisor.toPlainString(), MAX_TICK_MARKS
);
return _fractionalGrid(numberType, minimum, maximum, range, null, 0);
}
long stepsPerPart = Math.max(1, (long) Math.ceil(PREFERRED_STEPS / partsInRange));
int intMax = _clampToInt(Math.round(partsInRange * stepsPerPart));
int majorSteps = _clampToInt(_partsIn(majorSpacing, commonDivisor) * stepsPerPart);
int minorSteps = _clampToInt(_partsIn(minorSpacing, commonDivisor) * stepsPerPart);
return new SliderGrid(numberType, minimum, maximum, 0, intMax, commonDivisor, stepsPerPart, majorSteps, minorSteps, majorSpacing);
}
private static SliderGrid _wholeNumberGrid(
Class<? extends Number> numberType,
BigDecimal min,
BigDecimal max,
@Nullable BigDecimal majorSpacing,
int minorTicksBetween
) {
int intMin = _clampToInt(min.longValue());
int intMax = Math.max(intMin, _clampToInt(max.longValue()));
int majorSteps = 0;
int minorSteps = 0;
if ( majorSpacing != null ) {
if ( !_isWholeNumber(majorSpacing) ) {
log.warn(SwingTree.get().logMarker(),
"A slider for whole numbers cannot draw tick marks every {}, because that is not a whole number. " +
"No tick marks are drawn.", majorSpacing.toPlainString()
);
majorSpacing = null;
} else if ( _tickMarkCount(intMax - (long) intMin, majorSpacing.longValue(), minorTicksBetween) > MAX_TICK_MARKS ) {
_warnAboutTooManyTickMarks(majorSpacing, minorTicksBetween);
majorSpacing = null;
} else {
majorSteps = _clampToInt(majorSpacing.longValue());
if ( minorTicksBetween > 0 ) {
if ( majorSteps % (minorTicksBetween + 1) == 0 )
minorSteps = majorSteps / (minorTicksBetween + 1);
else
log.warn(SwingTree.get().logMarker(),
"A slider for whole numbers cannot fit {} minor tick marks between major tick marks {} apart, " +
"because they would not be whole numbers apart. No minor tick marks are drawn.",
minorTicksBetween, majorSteps
);
}
}
}
return new SliderGrid(numberType, min, max, intMin, intMax, BigDecimal.ONE, 1, majorSteps, minorSteps, majorSpacing);
}
private static SliderGrid _fractionalGrid(
Class<? extends Number> numberType,
BigDecimal min,
BigDecimal max,
BigDecimal range,
@Nullable BigDecimal majorSpacing,
int minorTicksBetween
) {
if ( range.signum() <= 0 )
return new SliderGrid(numberType, min, max, 0, 0, BigDecimal.ONE, 1, 0, 0, null);
if ( majorSpacing != null ) {
double finestTicksInRange = range.multiply(BigDecimal.valueOf(minorTicksBetween + 1L))
.divide(majorSpacing, MathContext.DECIMAL64)
.doubleValue();
if ( finestTicksInRange > MAX_TICK_MARKS ) {
_warnAboutTooManyTickMarks(majorSpacing, minorTicksBetween);
majorSpacing = null;
} else {
long stepsPerFinestTick = Math.max(1, (long) Math.ceil(PREFERRED_STEPS / finestTicksInRange));
long partsPerMajorSpacing = (minorTicksBetween + 1) * stepsPerFinestTick;
int intMax = _clampToInt(Math.round(finestTicksInRange * stepsPerFinestTick));
int majorSteps = _clampToInt(partsPerMajorSpacing);
int minorSteps = minorTicksBetween > 0 ? _clampToInt(stepsPerFinestTick) : 0;
return new SliderGrid(numberType, min, max, 0, intMax, majorSpacing, partsPerMajorSpacing, majorSteps, minorSteps, majorSpacing);
}
}
return new SliderGrid(numberType, min, max, 0, PREFERRED_STEPS, range, PREFERRED_STEPS, 0, 0, null);
}
private SliderGrid(
Class<? extends Number> numberType,
BigDecimal min,
BigDecimal max,
int intMin,
int intMax,
BigDecimal numbersPerStepNumerator,
long numbersPerStepDenominator,
int majorSpacingInSteps,
int minorSpacingInSteps,
@Nullable BigDecimal majorSpacing
) {
_numberType = numberType;
_min = min;
_max = max;
_intMin = intMin;
_intMax = intMax;
_numbersPerStepNumerator = numbersPerStepNumerator;
_numbersPerStepDenominator = numbersPerStepDenominator;
_majorSpacingInSteps = majorSpacingInSteps;
_minorSpacingInSteps = minorSpacingInSteps;
_majorSpacing = majorSpacing;
}
int intMin() { return _intMin; }
int intMax() { return _intMax; }
int majorSpacingInSteps() { return _majorSpacingInSteps; }
int minorSpacingInSteps() { return _minorSpacingInSteps; }
boolean isInRange( Number number ) {
BigDecimal decimal = decimalOf(number);
return decimal.compareTo(_min) >= 0 && decimal.compareTo(_max) <= 0;
}
int stepOf( Number number ) {
double offset = decimalOf(number).subtract(_min).doubleValue();
double steps = offset * _numbersPerStepDenominator / _numbersPerStepNumerator.doubleValue();
long step = _intMin + Math.round(steps);
return (int) Math.max(_intMin, Math.min(_intMax, step));
}
Number numberAt( int step ) {
if ( step <= _intMin )
return convert(_numberType, _min);
if ( step >= _intMax )
return convert(_numberType, _max);
BigDecimal offset = _numbersPerStepNumerator
.multiply(BigDecimal.valueOf(step - (long) _intMin))
.divide(BigDecimal.valueOf(_numbersPerStepDenominator), MathContext.DECIMAL64);
return convert(_numberType, _min.add(offset));
}
int majorTickCount() {
if ( _majorSpacingInSteps <= 0 )
return 0;
return (int) ((_intMax - (long) _intMin) / _majorSpacingInSteps) + 1;
}
int stepOfMajorTick( int index ) {
return _intMin + index * _majorSpacingInSteps;
}
BigDecimal numberAtMajorTick( int index ) {
return _majorSpacing == null ? _min : _min.add(_majorSpacing.multiply(BigDecimal.valueOf(index)));
}
int nearestTick( int step ) {
int spacing = _finestSpacingInSteps();
if ( spacing <= 0 )
return step;
long ticks = Math.round((step - (long) _intMin) / (double) spacing);
return _tickWithinRange(ticks);
}
int nextTickTowards( int step, int from ) {
int spacing = _finestSpacingInSteps();
if ( spacing <= 0 || step == from )
return nearestTick(step);
double ticks = (step - (long) _intMin) / (double) spacing;
boolean towardsTheMaximum = step > from;
int tick = _tickWithinRange(towardsTheMaximum ? (long) Math.ceil(ticks) : (long) Math.floor(ticks));
boolean tickLiesBehindTheStart = towardsTheMaximum ? tick < from : tick > from;
return tickLiesBehindTheStart ? from : tick;
}
private int _finestSpacingInSteps() {
return _minorSpacingInSteps > 0 ? _minorSpacingInSteps : _majorSpacingInSteps;
}
private int _tickWithinRange( long tick ) {
int spacing = _finestSpacingInSteps();
long lastTick = (_intMax - (long) _intMin) / spacing;
long clamped = Math.max(0, Math.min(lastTick, tick));
return (int) (_intMin + clamped * spacing);
}
static boolean isWholeNumberType( Class<?> numberType ) {
return numberType == Integer.class || numberType == Long.class || numberType == Short.class || numberType == Byte.class;
}
static BigDecimal decimalOf( Number number ) {
if ( number instanceof BigDecimal )
return (BigDecimal) number;
if ( number instanceof Double || number instanceof Float ) {
double asDouble = number.doubleValue();
if ( Double.isNaN(asDouble) || Double.isInfinite(asDouble) )
return BigDecimal.ZERO;
return new BigDecimal(number.toString());
}
return BigDecimal.valueOf(number.longValue());
}
@SuppressWarnings("unchecked")
static <N extends Number> N convert( Class<N> numberType, Number number ) {
if ( numberType == Integer.class ) return (N) Integer.valueOf(number.intValue());
if ( numberType == Long.class ) return (N) Long.valueOf(number.longValue());
if ( numberType == Short.class ) return (N) Short.valueOf(number.shortValue());
if ( numberType == Byte.class ) return (N) Byte.valueOf(number.byteValue());
if ( numberType == Float.class ) return (N) Float.valueOf(number.floatValue());
if ( numberType == Double.class ) return (N) Double.valueOf(number.doubleValue());
throw new IllegalArgumentException("Unsupported number type: " + numberType);
}
private static @Nullable BigDecimal _positiveDecimalOrNull( @Nullable Number number ) {
if ( number == null )
return null;
BigDecimal decimal = decimalOf(number);
return decimal.signum() > 0 ? decimal : null;
}
private static @Nullable BigDecimal _largestCommonDivisor( @Nullable BigDecimal a, @Nullable BigDecimal b ) {
if ( a == null )
return b;
if ( b == null )
return a;
int scale = Math.max(0, Math.max(a.stripTrailingZeros().scale(), b.stripTrailingZeros().scale()));
BigInteger divisor = a.setScale(scale).unscaledValue().gcd(b.setScale(scale).unscaledValue());
return new BigDecimal(divisor, scale);
}
private static long _partsIn( @Nullable BigDecimal spacing, BigDecimal part ) {
return spacing == null ? 0 : spacing.divide(part, MathContext.DECIMAL64).longValue();
}
private static String _plainOrZero( @Nullable BigDecimal decimal ) {
return decimal == null ? "0" : decimal.toPlainString();
}
private static boolean _isWholeNumber( BigDecimal decimal ) {
return decimal.signum() == 0 || decimal.stripTrailingZeros().scale() <= 0;
}
private static long _tickMarkCount( long range, long majorSpacing, int minorTicksBetween ) {
if ( majorSpacing <= 0 )
return 0;
return (range / majorSpacing) * (minorTicksBetween + 1);
}
private static void _warnAboutTooManyTickMarks( BigDecimal majorSpacing, int minorTicksBetween ) {
log.warn(SwingTree.get().logMarker(),
"A major spacing of {} with {} minor tick marks between major tick marks puts more than {} tick marks " +
"onto a slider. No tick marks are drawn.",
majorSpacing.toPlainString(), minorTicksBetween, MAX_TICK_MARKS
);
}
private static boolean _isSameDecimal( @Nullable BigDecimal a, @Nullable BigDecimal b ) {
if ( a == null )
return b == null;
return b != null && a.compareTo(b) == 0;
}
private static int _clampToInt( long value ) {
return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value));
}
@Override
public boolean equals( @Nullable Object obj ) {
if ( obj == this ) return true;
if ( !(obj instanceof SliderGrid) ) return false;
SliderGrid other = (SliderGrid) obj;
return _numberType == other._numberType
&& _intMin == other._intMin
&& _intMax == other._intMax
&& _numbersPerStepDenominator == other._numbersPerStepDenominator
&& _majorSpacingInSteps == other._majorSpacingInSteps
&& _minorSpacingInSteps == other._minorSpacingInSteps
&& _min.compareTo(other._min) == 0
&& _max.compareTo(other._max) == 0
&& _numbersPerStepNumerator.compareTo(other._numbersPerStepNumerator) == 0
&& _isSameDecimal(_majorSpacing, other._majorSpacing);
}
@Override
public int hashCode() {
return Objects.hash(_numberType, _intMin, _intMax, _numbersPerStepDenominator, _majorSpacingInSteps, _minorSpacingInSteps, _min.doubleValue(), _max.doubleValue());
}
@Override
public String toString() {
return getClass().getSimpleName() + "[" +
"numberType=" + _numberType.getSimpleName() + ", " +
"min=" + _min + ", max=" + _max + ", " +
"steps=" + _intMin + ".." + _intMax + ", " +
"majorSpacingInSteps=" + _majorSpacingInSteps + ", " +
"minorSpacingInSteps=" + _minorSpacingInSteps +
"]";
}
}