SliderState.java
package swingtree;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sprouts.From;
import sprouts.Val;
import sprouts.Var;
import swingtree.api.model.SliderTicks;
import swingtree.style.ComponentBackend;
import swingtree.threading.EventProcessor;
import javax.swing.BoundedRangeModel;
import javax.swing.Icon;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JSlider;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.plaf.UIResource;
import java.awt.Color;
import java.awt.Font;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
final class SliderState
{
private static final Logger log = LoggerFactory.getLogger(SliderState.class);
static SliderState of( JSlider slider, Class<? extends Number> numberType, EventProcessor eventProcessor ) {
return ComponentBackend.powering(slider)
.getOrSet(SliderState.class, () -> new SliderState(slider, numberType, eventProcessor));
}
private final JSlider _slider;
private final Class<? extends Number> _numberType;
private final EventProcessor _eventProcessor;
private final List<ChangeListener> _changeActions = new ArrayList<>();
private Number _min;
private Number _max;
private Number _value;
private @Nullable SliderTicks<?> _ticks;
private @Nullable Number _fractionalMajorTickSpacing;
private @Nullable Number _fractionalMinorTickSpacing;
private SliderGrid _grid;
private @Nullable Var<Number> _valueTarget;
private @Nullable Val<Number> _minSource;
private @Nullable Val<Number> _maxSource;
private int _lastSeenStep;
private int _writesStillOnTheirWay;
private boolean _isChangingTheSliderItself;
private SliderState( JSlider slider, Class<? extends Number> numberType, EventProcessor eventProcessor ) {
_slider = slider;
_numberType = numberType;
_eventProcessor = eventProcessor;
BoundedRangeModel model = slider.getModel();
_min = SliderGrid.convert(numberType, model.getMinimum());
_max = SliderGrid.convert(numberType, model.getMaximum());
_value = SliderGrid.convert(numberType, model.getValue());
_grid = SliderGrid.of(numberType, _min, _max, null);
_lastSeenStep = model.getValue();
slider.addChangeListener(new SliderChangeListener());
}
void setMin( Number min ) {
_min = SliderGrid.convert(_numberType, min);
_layOutForNewRange();
}
void setMax( Number max ) {
_max = SliderGrid.convert(_numberType, max);
_layOutForNewRange();
}
void setValue( Number value ) {
if ( _slider.getValueIsAdjusting() || _writesStillOnTheirWay > 0 )
return;
_value = SliderGrid.convert(_numberType, value);
_placeKnob();
}
void setTicks( SliderTicks<?> ticks ) {
if ( ticks.equals(_ticks) )
return;
_ticks = ticks;
_grid = SliderGrid.of(_numberType, _min, _max, ticks);
_placeKnob();
_installTicks(_grid, ticks);
}
void setMajorTickSpacing( Number spacing ) {
if ( _ticks != null )
return;
if ( SliderGrid.isWholeNumberType(_numberType) ) {
_slider.setMajorTickSpacing(spacing.intValue());
} else {
_fractionalMajorTickSpacing = SliderGrid.convert(_numberType, spacing);
_layOutForNewFractionalTickSpacing();
}
}
void setMinorTickSpacing( Number spacing ) {
if ( _ticks != null )
return;
if ( SliderGrid.isWholeNumberType(_numberType) ) {
_slider.setMinorTickSpacing(spacing.intValue());
} else {
_fractionalMinorTickSpacing = SliderGrid.convert(_numberType, spacing);
_layOutForNewFractionalTickSpacing();
}
}
void onChange( ChangeListener action ) {
_changeActions.add(action);
}
@SuppressWarnings("unchecked")
void keepMinWithin( Val<? extends Number> minSource ) {
_minSource = (Val<Number>) minSource;
}
@SuppressWarnings("unchecked")
void keepMaxWithin( Val<? extends Number> maxSource ) {
_maxSource = (Val<Number>) maxSource;
}
@SuppressWarnings("unchecked")
void writeUserChangesTo( Var<? extends Number> target ) {
_valueTarget = (Var<Number>) target;
}
private void _layOutForNewRange() {
SliderGrid grid = _gridForRangeAndTicks();
boolean gridChanged = !grid.equals(_grid);
_grid = grid;
_placeKnob();
SliderTicks<?> ticks = _ticks;
if ( ticks != null && gridChanged )
_installTicks(grid, ticks);
else if ( gridChanged )
_installFractionalTickSpacings(grid);
}
private void _layOutForNewFractionalTickSpacing() {
_grid = _gridForRangeAndTicks();
_placeKnob();
_installFractionalTickSpacings(_grid);
}
private SliderGrid _gridForRangeAndTicks() {
boolean hasFractionalTickSpacings = _fractionalMajorTickSpacing != null || _fractionalMinorTickSpacing != null;
if ( _ticks == null && hasFractionalTickSpacings )
return SliderGrid.ofFractionalTickSpacings(_numberType, _min, _max, _fractionalMajorTickSpacing, _fractionalMinorTickSpacing);
return SliderGrid.of(_numberType, _min, _max, _ticks);
}
private void _installFractionalTickSpacings( SliderGrid grid ) {
if ( _fractionalMajorTickSpacing != null )
_slider.setMajorTickSpacing(grid.majorSpacingInSteps());
if ( _fractionalMinorTickSpacing != null )
_slider.setMinorTickSpacing(grid.minorSpacingInSteps());
}
private void _placeKnob() {
SliderGrid grid = _grid;
int step = grid.stepOf(_value);
BoundedRangeModel model = _slider.getModel();
if ( model.getMinimum() != grid.intMin() || model.getMaximum() != grid.intMax() || model.getValue() != step )
_changeTheSliderItself(() -> model.setRangeProperties(step, model.getExtent(), grid.intMin(), grid.intMax(), model.getValueIsAdjusting()));
_lastSeenStep = model.getValue();
}
private void _installTicks( SliderGrid grid, SliderTicks<?> ticks ) {
Hashtable<Integer, JComponent> labels = _labelsFor(grid, ticks);
if ( labels.isEmpty() ) {
_slider.setPaintLabels(false);
_slider.setLabelTable(labels);
} else {
_slider.setLabelTable(labels);
_slider.setPaintLabels(true);
}
_slider.setMajorTickSpacing(grid.majorSpacingInSteps());
_slider.setMinorTickSpacing(grid.minorSpacingInSteps());
_slider.setPaintTicks(ticks.hasVisibleTickMarks() && grid.majorSpacingInSteps() > 0);
}
@SuppressWarnings("JdkObsolete")
private <N extends Number> Hashtable<Integer, JComponent> _labelsFor( SliderGrid grid, SliderTicks<N> ticks ) {
Hashtable<Integer, JComponent> labels = new Hashtable<>();
if ( ticks.hasLabelsAtMajorTicks() ) {
int count = grid.majorTickCount();
Function<N, String> text = ticks.majorTickLabelText().orElse(null);
int decimals = text == null ? _decimalsToWriteExactly(grid, count) : 0;
for ( int index = 0; index < count; index++ ) {
BigDecimal number = grid.numberAtMajorTick(index);
String labelText = text == null
? _numberText(number, decimals, ticks.labelLocale())
: _textOf(text, SliderGrid.convert(ticks.numberType(), number));
labels.put(grid.stepOfMajorTick(index), new TickLabel(_slider, labelText));
}
}
for ( N position : ticks.labelPositions() ) {
if ( !grid.isInRange(position) )
continue;
int step = grid.stepOf(position);
ticks.labelTextAt(position).ifPresent( text -> labels.put(step, new TickLabel(_slider, text)) );
ticks.labelIconAt(position).ifPresent( icon -> labels.put(step, new TickLabel(_slider, icon.find().orElse(null))) );
}
return labels;
}
private static int _decimalsToWriteExactly( SliderGrid grid, int count ) {
int decimals = 0;
for ( int index = 0; index < count; index++ )
decimals = Math.max(decimals, grid.numberAtMajorTick(index).stripTrailingZeros().scale());
return decimals;
}
private static String _numberText( BigDecimal number, int decimals, Locale locale ) {
BigDecimal rounded = number.setScale(decimals, RoundingMode.HALF_UP);
if ( Locale.ROOT.equals(locale) )
return rounded.toPlainString();
NumberFormat format = NumberFormat.getNumberInstance(locale);
format.setMinimumFractionDigits(decimals);
format.setMaximumFractionDigits(decimals);
return format.format(rounded);
}
private static <N extends Number> String _textOf( Function<N, String> text, N number ) {
try {
String result = text.apply(number);
return result == null ? "" : result;
} catch ( Exception e ) {
log.error(SwingTree.get().logMarker(), "Failed to compute the text of the slider label at {}.", number, e);
return "";
}
}
private void _onSliderChange( ChangeEvent event ) {
if ( _isChangingTheSliderItself )
return;
BoundedRangeModel model = _slider.getModel();
if ( model.getMinimum() != _grid.intMin() || model.getMaximum() != _grid.intMax() )
_followRangeSetOnTheSlider(model);
_onUserChange();
for ( ChangeListener action : _changeActions )
action.stateChanged(event);
}
private void _followRangeSetOnTheSlider( BoundedRangeModel model ) {
if ( SliderGrid.isWholeNumberType(_numberType) ) {
_min = SliderGrid.convert(_numberType, model.getMinimum());
_max = SliderGrid.convert(_numberType, model.getMaximum());
_layOutForNewRange();
}
else
_placeKnob();
}
private void _onUserChange() {
int step = _slider.getValue();
int chosenStep = _chooseStepFor(step);
_lastSeenStep = step;
if ( chosenStep != step && !_slider.getValueIsAdjusting() ) {
_changeTheSliderItself(() -> _slider.setValue(chosenStep));
_lastSeenStep = chosenStep;
}
Number number = _isStepOfTheValue(chosenStep) ? _value : _grid.numberAt(chosenStep);
_value = number;
Var<Number> target = _valueTarget;
if ( target == null )
return;
Val<Number> minSource = _minSource;
Val<Number> maxSource = _maxSource;
_writesStillOnTheirWay++;
_eventProcessor.registerAppEvent(() -> {
target.set(From.VIEW, _keepWithin(number, minSource, maxSource));
Number settled = target.get();
_eventProcessor.registerUIEvent(() -> _acknowledgeWrite(settled));
});
}
private void _acknowledgeWrite( Number settled ) {
_writesStillOnTheirWay--;
if ( _writesStillOnTheirWay == 0 )
setValue(settled);
}
private int _chooseStepFor( int step ) {
SliderTicks<?> ticks = _ticks;
if ( ticks == null || !ticks.isSnappingToTicks() || _isStepOfTheValue(step) )
return step;
if ( _slider.getValueIsAdjusting() || step == _lastSeenStep )
return _grid.nearestTick(step);
return _grid.nextTickTowards(step, _lastSeenStep);
}
private boolean _isStepOfTheValue( int step ) {
return _grid.isInRange(_value) && _grid.stepOf(_value) == step;
}
private static Number _keepWithin( Number number, @Nullable Val<Number> minSource, @Nullable Val<Number> maxSource ) {
if ( minSource != null ) {
Number min = minSource.orElseNull();
if ( min != null && number.doubleValue() < min.doubleValue() )
return min;
}
if ( maxSource != null ) {
Number max = maxSource.orElseNull();
if ( max != null && number.doubleValue() > max.doubleValue() )
return max;
}
return number;
}
private void _changeTheSliderItself( Runnable change ) {
boolean wasChangingTheSliderItself = _isChangingTheSliderItself;
_isChangingTheSliderItself = true;
try {
change.run();
} finally {
_isChangingTheSliderItself = wasChangingTheSliderItself;
}
}
private final class SliderChangeListener implements ChangeListener {
@Override
public void stateChanged( ChangeEvent event ) {
_onSliderChange(event);
}
}
private static final class TickLabel extends JLabel implements UIResource
{
private final @Nullable JSlider _owner;
TickLabel( JSlider owner, String text ) {
super(text, JLabel.CENTER);
_owner = owner;
setName("Slider.label");
}
TickLabel( JSlider owner, @Nullable Icon icon ) {
super(icon, JLabel.CENTER);
_owner = owner;
setName("Slider.label");
}
@Override
public Font getFont() {
Font font = super.getFont();
JSlider owner = _owner;
if ( owner == null || (font != null && !(font instanceof UIResource)) )
return font;
return owner.getFont();
}
@Override
public Color getForeground() {
Color foreground = super.getForeground();
JSlider owner = _owner;
if ( owner == null || (foreground != null && !(foreground instanceof UIResource)) )
return foreground;
Color ownerForeground = owner.getForeground();
if ( ownerForeground != null && !(ownerForeground instanceof UIResource) )
return ownerForeground;
return foreground;
}
}
}