MultiThumbSliderUI.java
/*
* @(#)MultiThumbSliderUI.java
*
* $Date: 2015-01-04 20:37:28 -0500 (Sun, 04 Jan 2015) $
*
* Copyright (c) 2011 by Jeremy Wood.
* All rights reserved.
*
* The copyright of this software is owned by Jeremy Wood.
* You may not use, copy or modify this software, except in
* accordance with the license agreement you entered into with
* Jeremy Wood. For details see accompanying license terms.
*
* This software is probably, but not necessarily, discussed here:
* https://javagraphics.java.net/
*
* That site should also contain the most recent official version
* of this software. (See the SVN repository for more details.)
*/
package org.opentrafficsim.gui.multislider;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.Toolkit;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.geom.AffineTransform;
import java.awt.geom.Ellipse2D;
import java.awt.geom.GeneralPath;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.lang.reflect.Array;
import java.util.HashSet;
import java.util.Set;
import javax.swing.JComponent;
import javax.swing.SwingConstants;
import javax.swing.UIManager;
import javax.swing.plaf.ComponentUI;
import org.opentrafficsim.gui.multislider.MultiThumbSlider.Collision;
/**
* This is the abstract UI for <code>MultiThumbSliders</code>
*/
public abstract class MultiThumbSliderUI<T> extends ComponentUI implements MouseListener, MouseMotionListener
{
/**
* The Swing client property associated with a Thumb.
* @see Thumb
*/
public static final String THUMB_SHAPE_PROPERTY = MultiThumbSliderUI.class.getName() + ".thumbShape";
PropertyChangeListener thumbShapeListener = new PropertyChangeListener()
{
@Override
public void propertyChange(PropertyChangeEvent evt)
{
MultiThumbSliderUI.this.slider.repaint();
}
};
/**
* A thumb shape.
*/
public static enum Thumb {
Circle() {
@Override
public Shape getShape(float width, float height, boolean leftEdge, boolean rightEdge)
{
Ellipse2D e = new Ellipse2D.Float(-width / 2f, -height / 2f, width, height);
return e;
}
},
Triangle() {
@Override
public Shape getShape(float width, float height, boolean leftEdge, boolean rightEdge)
{
float k = width / 2;
GeneralPath p = new GeneralPath();
float r = 5;
if ((leftEdge) && (!rightEdge))
{
k = k * 2;
p.moveTo(0, height / 2);
p.lineTo(-k, height / 2 - k);
p.lineTo(-k, -height / 2 + r);
p.curveTo(-k, -height / 2, -k, -height / 2, -k + r, -height / 2);
p.lineTo(0, -height / 2);
p.closePath();
}
else if ((rightEdge) && (!leftEdge))
{
k = k * 2;
p.moveTo(0, -height / 2);
p.lineTo(k - r, -height / 2);
p.curveTo(k, -height / 2, k, -height / 2, k, -height / 2 + r);
p.lineTo(k, height / 2 - k);
p.lineTo(0, height / 2);
p.closePath();
}
else
{
p.moveTo(0, height / 2);
p.lineTo(-k, height / 2 - k);
p.lineTo(-k, -height / 2 + r);
p.curveTo(-k, -height / 2, -k, -height / 2, -k + r, -height / 2);
p.lineTo(k - r, -height / 2);
p.curveTo(k, -height / 2, k, -height / 2, k, -height / 2 + r);
p.lineTo(k, height / 2 - k);
p.closePath();
}
return p;
}
},
Rectangle() {
@Override
public Shape getShape(float width, float height, boolean leftEdge, boolean rightEdge)
{
if ((leftEdge) && (!rightEdge))
{
return new Rectangle2D.Float(-width, -height / 2, width, height);
}
else if ((rightEdge) && (!leftEdge))
{
return new Rectangle2D.Float(0, -height / 2, width, height);
}
else
{
return new Rectangle2D.Float(-width / 2, -height / 2, width, height);
}
}
},
Hourglass() {
@Override
public Shape getShape(float width, float height, boolean leftEdge, boolean rightEdge)
{
GeneralPath p = new GeneralPath();
if ((leftEdge) && (!rightEdge))
{
float k = width;
p.moveTo(-width, -height / 2);
p.lineTo(0, -height / 2);
p.lineTo(0, height / 2);
p.lineTo(-width, height / 2);
p.lineTo(0, height / 2 - k);
p.lineTo(0, -height / 2 + k);
p.closePath();
}
else if ((rightEdge) && (!leftEdge))
{
float k = width;
p.moveTo(width, -height / 2);
p.lineTo(0, -height / 2);
p.lineTo(0, height / 2);
p.lineTo(width, height / 2);
p.lineTo(0, height / 2 - k);
p.lineTo(0, -height / 2 + k);
p.closePath();
}
else
{
float k = width / 2;
p.moveTo(-width / 2, -height / 2);
p.lineTo(width / 2, -height / 2);
p.lineTo(0, -height / 2 + k);
p.lineTo(0, height / 2 - k);
p.lineTo(width / 2, height / 2);
p.lineTo(-width / 2, height / 2);
p.lineTo(0, height / 2 - k);
p.lineTo(0, -height / 2 + k);
p.closePath();
}
return p;
}
};
/**
* Create a thumb that is centered at (0,0) for a horizontally oriented slider.
* @param sliderUI the slider UI this thumb relates to.
* @param x the x-coordinate where this thumb is centered.
* @param y the y-coordinate where this thumb is centered.
* @param width the width of the the thumb (assuming this is a horizontal slider)
* @param height the height of the the thumb (assuming this is a horizontal slider)
* @param leftEdge true if this is the left-most thumb
* @param rightEdge true if this is the right-most thumb.
* @return the shape of this thumb.
*/
public Shape getShape(MultiThumbSliderUI<?> sliderUI, float x, float y, int width, int height,
boolean leftEdge, boolean rightEdge)
{
// TODO: reinstate leftEdge and rightEdge once bug related to nudging
// adjacent thumbs is resolved.
GeneralPath path = new GeneralPath(getShape(width, height, false, false));
if (sliderUI.slider.getOrientation() == SwingConstants.VERTICAL)
{
path.transform(AffineTransform.getRotateInstance(-Math.PI / 2));
}
path.transform(AffineTransform.getTranslateInstance(x, y));
return path;
}
/**
* Create a thumb that is centered at (0,0) for a horizontally oriented slider.
* @param width the width of the the thumb (assuming this is a horizontal slider)
* @param height the height of the the thumb (assuming this is a horizontal slider)
* @param leftEdge true if this is the left-most thumb
* @param rightEdge true if this is the right-most thumb.
* @return the shape of this thumb.
*/
public abstract Shape getShape(float width, float height, boolean leftEdge, boolean rightEdge);
}
protected MultiThumbSlider<T> slider;
/**
* The maximum width returned by <code>getMaximumSize()</code>. (or if the slider is vertical, this is the maximum
* height.)
*/
int MAX_LENGTH = 300;
/**
* The minimum width returned by <code>getMinimumSize()</code>. (or if the slider is vertical, this is the minimum
* height.)
*/
int MIN_LENGTH = 50;
/**
* The maximum width returned by <code>getPreferredSize()</code>. (or if the slider is vertical, this is the
* preferred height.)
*/
int PREF_LENGTH = 140;
/**
* The height of a horizontal slider -- or width of a vertical slider.
*/
int DEPTH = 15;
/**
* The pixel position of the thumbs. This may be x or y coordinates, depending on whether this slider is horizontal
* or vertical
*/
int[] thumbPositions = new int[0];
/**
* A float from zero to one, indicating whether that thumb should be highlighted or not.
*/
protected float[] thumbIndications = new float[0];
/** This is used by the animating thread. The field indication is updated until it equals this value. */
private float indicationGoal = 0;
/**
* The overall indication of the thumbs. At one they should be opaque, at zero they should be transparent.
*/
float indication = 0;
/** The rectangle the track should be painted in. */
protected Rectangle trackRect = new Rectangle(0, 0, 0, 0);
public MultiThumbSliderUI(MultiThumbSlider<T> slider)
{
this.slider = slider;
}
@Override
public Dimension getMaximumSize(JComponent s)
{
MultiThumbSlider<T> mySlider = (MultiThumbSlider<T>) s;
int k = Math.max(this.DEPTH, getPreferredComponentDepth());
if (mySlider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
return new Dimension(this.MAX_LENGTH, k);
}
return new Dimension(k, this.MAX_LENGTH);
}
@Override
public Dimension getMinimumSize(JComponent s)
{
MultiThumbSlider<T> mySlider = (MultiThumbSlider<T>) s;
int k = Math.max(this.DEPTH, getPreferredComponentDepth());
if (mySlider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
return new Dimension(this.MIN_LENGTH, k);
}
return new Dimension(k, this.MIN_LENGTH);
}
@Override
public Dimension getPreferredSize(JComponent s)
{
MultiThumbSlider<T> mySlider = (MultiThumbSlider<T>) s;
int k = Math.max(this.DEPTH, getPreferredComponentDepth());
if (mySlider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
return new Dimension(this.PREF_LENGTH, k);
}
return new Dimension(k, this.PREF_LENGTH);
}
/**
* Return the typical height of a horizontally oriented slider, or the width of the vertically oriented slider.
* @return the typical height of a horizontally oriented slider, or the width of the vertically oriented slider.
*/
protected abstract int getPreferredComponentDepth();
/**
* This records the positions/values of each thumb. This is used when the mouse is pressed, so as the mouse is
* dragged values can get replaced and rearranged freely. (Including removing and adding thumbs)
*/
class State
{
T[] values;
float[] positions;
int selectedThumb;
public State()
{
this.values = MultiThumbSliderUI.this.slider.getValues();
this.positions = MultiThumbSliderUI.this.slider.getThumbPositions();
this.selectedThumb = MultiThumbSliderUI.this.slider.getSelectedThumb(false);
}
public State(State s)
{
this.selectedThumb = s.selectedThumb;
this.positions = new float[s.positions.length];
this.values = createSimilarArray(s.values, s.values.length);
System.arraycopy(s.positions, 0, this.positions, 0, this.positions.length);
System.arraycopy(s.values, 0, this.values, 0, this.values.length);
}
/** Strip values outside of [0,1] */
private void polish()
{
while (this.positions[0] < 0)
{
float[] f2 = new float[this.positions.length - 1];
System.arraycopy(this.positions, 1, f2, 0, this.positions.length - 1);
T[] c2 = createSimilarArray(this.values, this.values.length - 1);
System.arraycopy(this.values, 1, c2, 0, this.positions.length - 1);
this.positions = f2;
this.values = c2;
this.selectedThumb++;
}
while (this.positions[this.positions.length - 1] > 1)
{
float[] f2 = new float[this.positions.length - 1];
System.arraycopy(this.positions, 0, f2, 0, this.positions.length - 1);
T[] c2 = createSimilarArray(this.values, this.values.length - 1);
System.arraycopy(this.values, 0, c2, 0, this.positions.length - 1);
this.positions = f2;
this.values = c2;
this.selectedThumb--;
}
if (this.selectedThumb >= this.positions.length)
this.selectedThumb = -1;
}
/** Make the slider reflect this object */
public void install()
{
polish();
MultiThumbSliderUI.this.slider.setValues(this.positions, this.values);
MultiThumbSliderUI.this.slider.setSelectedThumb(this.selectedThumb);
}
/** This is a kludgy casting trick to make our arrays mesh with generics. */
private T[] createSimilarArray(T[] src, int length)
{
Class<?> componentType = src.getClass().getComponentType();
return (T[]) Array.newInstance(componentType, length);
}
public void removeThumb(int index)
{
float[] f = new float[this.positions.length - 1];
T[] c = createSimilarArray(this.values, this.values.length - 1);
System.arraycopy(this.positions, 0, f, 0, index);
System.arraycopy(this.values, 0, c, 0, index);
System.arraycopy(this.positions, index + 1, f, index, f.length - index);
System.arraycopy(this.values, index + 1, c, index, f.length - index);
this.positions = f;
this.values = c;
this.selectedThumb = -1;
}
public boolean setPosition(int thumbIndex, float newPosition)
{
return setPosition(thumbIndex, newPosition, true);
}
private boolean isCrossover(int thumbIndexA, int thumbIndexB, float newThumbBPosition)
{
if (thumbIndexA == thumbIndexB)
return false;
int oldState = new Float(this.positions[thumbIndexA]).compareTo(this.positions[thumbIndexB]);
int newState = new Float(this.positions[thumbIndexA]).compareTo(newThumbBPosition);
if (newState * oldState < 0)
return true;
return isOverlap(thumbIndexA, thumbIndexB, newThumbBPosition);
}
private boolean isOverlap(int thumbIndexA, int thumbIndexB, float newThumbBPosition)
{
if (thumbIndexA == thumbIndexB)
return false;
if (!MultiThumbSliderUI.this.slider.isThumbOverlap())
{
Point2D aCenter = getThumbCenter(this.positions[thumbIndexA]);
Point2D bCenter = getThumbCenter(newThumbBPosition);
Rectangle2D aBounds = ShapeBounds.getBounds(getThumbShape(thumbIndexA, aCenter));
Rectangle2D bBounds = ShapeBounds.getBounds(getThumbShape(thumbIndexB, bCenter));
return aBounds.intersects(bBounds) || aBounds.equals(bBounds);
}
return false;
}
private boolean setPosition(int thumbIndex, float newPosition, boolean revise)
{
Collision c = MultiThumbSliderUI.this.slider.getCollisionPolicy();
if (Collision.JUMP_OVER_OTHER.equals(c) && (!MultiThumbSliderUI.this.slider.isThumbOverlap()))
{
newPosition = Math.max(0, Math.min(1, newPosition));
for (int a = 0; a < this.positions.length; a++)
{
if (isOverlap(a, thumbIndex, newPosition))
{
if (revise)
{
float alternative;
int maxWidth = Math.max(getThumbSize(a).width, getThumbSize(thumbIndex).width);
float trackSize =
MultiThumbSliderUI.this.slider.getOrientation() == SwingConstants.HORIZONTAL ? MultiThumbSliderUI.this.trackRect.width
: MultiThumbSliderUI.this.trackRect.height;
newPosition = Math.max(0, Math.min(1, newPosition));
// offset is measured in pixels
for (int offset = 0; offset < 4 * maxWidth; offset++)
{
alternative = Math.max(0, Math.min(1, newPosition - ((float) offset) / trackSize));
if (!isOverlap(a, thumbIndex, alternative))
{
return setPosition(thumbIndex, alternative, false);
}
alternative = Math.max(0, Math.min(1, newPosition + ((float) offset) / trackSize));
if (!isOverlap(a, thumbIndex, alternative))
{
return setPosition(thumbIndex, alternative, false);
}
}
return false;
}
return false;
}
}
}
else if (Collision.STOP_AGAINST.equals(c))
{
for (int a = 0; a < this.positions.length; a++)
{
if (isCrossover(a, thumbIndex, newPosition))
{
// this move would cross thumbIndex over an existing thumb. This violates the collision policy:
if (revise)
{
float alternative;
int maxWidth = Math.max(getThumbSize(a).width, getThumbSize(thumbIndex).width);
float trackSize =
MultiThumbSliderUI.this.slider.getOrientation() == SwingConstants.HORIZONTAL ? MultiThumbSliderUI.this.trackRect.width
: MultiThumbSliderUI.this.trackRect.height;
// offset is measured in pixels
for (int offset = 0; offset < 2 * maxWidth; offset++)
{
if (this.positions[a] > this.positions[thumbIndex])
{
alternative = this.positions[a] - ((float) offset) / trackSize;
}
else
{
alternative = this.positions[a] + ((float) offset) / trackSize;
}
if (!isCrossover(a, thumbIndex, alternative))
{
return setPosition(thumbIndex, alternative, false);
}
}
return false;
}
return false;
}
}
}
else if (Collision.NUDGE_OTHER.equals(c))
{
if (revise)
{
final Set<Integer> processedThumbs = new HashSet<Integer>();
processedThumbs.add(-1);
class NudgeRequest
{
/** The index of the thumb this request wants to move. */
final int thumbIndex;
/** The original value of this thumb. */
final float startingValue;
/** The amount we're asking to change this value by. */
final float requestedDelta;
NudgeRequest(int thumbIndex, float startingValue, float requestedDelta)
{
this.thumbIndex = thumbIndex;
this.startingValue = startingValue;
this.requestedDelta = requestedDelta;
}
void process()
{
float span;
if (MultiThumbSliderUI.this.slider.isThumbOverlap())
{
span = 0;
}
else
{
span = (float) ShapeBounds.getBounds(getThumbShape(this.thumbIndex)).getWidth();
if (MultiThumbSliderUI.this.slider.getOrientation() == SwingConstants.HORIZONTAL)
{
span = span / ((float) MultiThumbSliderUI.this.trackRect.width);
}
else
{
span = span / ((float) MultiThumbSliderUI.this.trackRect.height);
}
}
int[] neighbors = getNeighbors(this.thumbIndex);
float newPosition = this.startingValue + this.requestedDelta;
processedThumbs.add(this.thumbIndex);
if (neighbors[0] == -1 && newPosition < 0)
{
setPosition(this.thumbIndex, 0, false);
}
else if (neighbors[1] == -1 && newPosition > 1)
{
setPosition(this.thumbIndex, 1, false);
}
else if (processedThumbs.add(neighbors[0])
&& (newPosition < State.this.positions[neighbors[0]] || Math.abs(State.this.positions[neighbors[0]]
- newPosition) < span - .0001))
{
NudgeRequest dependsOn =
new NudgeRequest(neighbors[0], State.this.positions[neighbors[0]], (newPosition - span)
- State.this.positions[neighbors[0]]);
dependsOn.process();
setPosition(this.thumbIndex, State.this.positions[dependsOn.thumbIndex] + span, false);
}
else if (processedThumbs.add(neighbors[1])
&& (newPosition > State.this.positions[neighbors[1]] || Math.abs(State.this.positions[neighbors[1]]
- newPosition) < span - .0001))
{
NudgeRequest dependsOn =
new NudgeRequest(neighbors[1], State.this.positions[neighbors[1]], (newPosition + span)
- State.this.positions[neighbors[1]]);
dependsOn.process();
setPosition(this.thumbIndex, State.this.positions[dependsOn.thumbIndex] - span, false);
}
else
{
setPosition(this.thumbIndex, this.startingValue + this.requestedDelta, false);
}
}
}
float originalValue = this.positions[thumbIndex];
NudgeRequest rootRequest =
new NudgeRequest(thumbIndex, this.positions[thumbIndex], newPosition - this.positions[thumbIndex]);
rootRequest.process();
return this.positions[thumbIndex] != originalValue;
}
}
this.positions[thumbIndex] = newPosition;
return true;
}
/**
* Return the left (lesser) neighbor and the right (greater) neighbor. Either index may be -1 if it is not
* available.
* @param thumbIndex the index of the thumb to examine.
* @return the left (lesser) neighbor and the right (greater) neighbor.
*/
int[] getNeighbors(int thumbIndex)
{
float leftNeighborDelta = 10;
float rightNeighborDelta = 10;
int leftNeighbor = -1;
int rightNeighbor = -1;
for (int a = 0; a < this.positions.length; a++)
{
if (a != thumbIndex)
{
if (this.positions[thumbIndex] < this.positions[a])
{
float delta = this.positions[a] - this.positions[thumbIndex];
if (delta < rightNeighborDelta)
{
rightNeighborDelta = delta;
rightNeighbor = a;
}
}
else if (this.positions[thumbIndex] > this.positions[a])
{
float delta = this.positions[thumbIndex] - this.positions[a];
if (delta < leftNeighborDelta)
{
leftNeighborDelta = delta;
leftNeighbor = a;
}
}
}
}
return new int[]{leftNeighbor, rightNeighbor};
}
}
Thread animatingThread = null;
Runnable animatingRunnable = new Runnable()
{
public void run()
{
boolean finished = false;
while (!finished)
{
synchronized (MultiThumbSliderUI.this)
{
finished = true;
for (int a = 0; a < MultiThumbSliderUI.this.thumbIndications.length; a++)
{
if (a != MultiThumbSliderUI.this.slider.getSelectedThumb())
{
if (a == MultiThumbSliderUI.this.currentIndicatedThumb)
{
if (MultiThumbSliderUI.this.thumbIndications[a] < 1)
{
MultiThumbSliderUI.this.thumbIndications[a] = Math.min(1, MultiThumbSliderUI.this.thumbIndications[a] + .025f);
finished = false;
}
}
else
{
if (MultiThumbSliderUI.this.thumbIndications[a] > 0)
{
MultiThumbSliderUI.this.thumbIndications[a] = Math.max(0, MultiThumbSliderUI.this.thumbIndications[a] - .025f);
finished = false;
}
}
}
else
{
// the selected thumb is painted as selected,
// so there's no indication to animate.
// just set the indication to whatever it should
// be and move on. No repainting.
if (a == MultiThumbSliderUI.this.currentIndicatedThumb)
{
MultiThumbSliderUI.this.thumbIndications[a] = 1;
}
else
{
MultiThumbSliderUI.this.thumbIndications[a] = 0;
}
}
}
if (MultiThumbSliderUI.this.indicationGoal > MultiThumbSliderUI.this.indication + .01f)
{
if (MultiThumbSliderUI.this.indication < .99f)
{
MultiThumbSliderUI.this.indication = Math.min(1, MultiThumbSliderUI.this.indication + .1f);
finished = false;
}
}
else if (MultiThumbSliderUI.this.indicationGoal < MultiThumbSliderUI.this.indication - .01f)
{
if (MultiThumbSliderUI.this.indication > .01f)
{
MultiThumbSliderUI.this.indication = Math.max(0, MultiThumbSliderUI.this.indication - .1f);
finished = false;
}
}
}
if (!finished)
MultiThumbSliderUI.this.slider.repaint();
// rest a little bit
long t = System.currentTimeMillis();
while (System.currentTimeMillis() - t < 20)
{
try
{
Thread.sleep(10);
}
catch (Exception e)
{
Thread.yield();
}
}
}
}
};
private int currentIndicatedThumb = -1;
protected boolean mouseInside = false;
protected boolean mouseIsDown = false;
private State pressedState;
private int dx, dy;
public void mousePressed(MouseEvent e)
{
this.dx = 0;
this.dy = 0;
if (this.slider.isEnabled() == false)
return;
if (e.getClickCount() >= 2)
{
if (this.slider.doDoubleClick(e.getX(), e.getY()))
{
e.consume();
return;
}
}
else if (e.isPopupTrigger())
{
int x = e.getX();
int y = e.getY();
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
if (x < this.trackRect.x || x > this.trackRect.x + this.trackRect.width)
return;
y = this.trackRect.y + this.trackRect.height;
}
else
{
if (y < this.trackRect.y || y > this.trackRect.y + this.trackRect.height)
return;
x = this.trackRect.x + this.trackRect.width;
}
if (this.slider.doPopup(x, y))
{
e.consume();
return;
}
}
this.mouseIsDown = true;
mouseMoved(e);
if (e.getSource() != this.slider)
{
throw new RuntimeException("only install this UI on the GradientSlider it was constructed with");
}
this.slider.requestFocus();
int index = getIndex(e);
if (index != -1)
{
if (this.slider.getOrientation() == SwingConstants.HORIZONTAL)
{
this.dx = -e.getX() + this.thumbPositions[index];
}
else
{
this.dy = -e.getY() + this.thumbPositions[index];
}
}
if (index != -1)
{
this.slider.setSelectedThumb(index);
e.consume();
}
else
{
if (this.slider.isAutoAdding())
{
float k;
int v;
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
v = e.getX();
}
else
{
v = e.getY();
}
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
k = ((float) (v - this.trackRect.x)) / ((float) this.trackRect.width);
if (this.slider.isInverted())
k = 1 - k;
}
else
{
k = ((float) (v - this.trackRect.y)) / ((float) this.trackRect.height);
if (this.slider.isInverted() == false)
k = 1 - k;
}
if (k > 0 && k < 1)
{
int added = this.slider.addThumb(k);
this.slider.setSelectedThumb(added);
}
e.consume();
}
else
{
if (this.slider.getSelectedThumb() != -1)
{
this.slider.setSelectedThumb(-1);
e.consume();
}
}
}
this.pressedState = new State();
}
private int getIndex(MouseEvent e)
{
int v;
Rectangle2D shapeSum = new Rectangle2D.Double(this.trackRect.x, this.trackRect.y, this.trackRect.width, this.trackRect.height);
for (int a = 0; a < this.slider.getThumbCount(); a++)
{
shapeSum.add(ShapeBounds.getBounds(getThumbShape(a)));
}
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
v = e.getX();
if (v < shapeSum.getMinX() || v > shapeSum.getMaxX())
{
return -1; // didn't click in the track;
}
}
else
{
v = e.getY();
if (v < shapeSum.getMinY() || v > shapeSum.getMaxY())
{
return -1;
}
}
int min = Math.abs(v - this.thumbPositions[0]);
int minIndex = 0;
for (int a = 1; a < this.thumbPositions.length; a++)
{
int distance = Math.abs(v - this.thumbPositions[a]);
if (distance < min)
{
min = distance;
minIndex = a;
}
else if (distance == min)
{
// two thumbs may perfectly overlap
if (v < this.thumbPositions[a])
{
// you clicked to the left of the fulcrum, so we should side with the smaller index
if (this.slider.isInverted())
{
// ... unless it's inverted:
minIndex = a;
}
}
else
{
if (!this.slider.isInverted())
minIndex = a;
}
}
}
if (min < getThumbSize(minIndex).width / 2)
{
return minIndex;
}
return -1;
}
public void mouseEntered(MouseEvent e)
{
mouseMoved(e);
}
public void mouseExited(MouseEvent e)
{
setCurrentIndicatedThumb(-1);
setMouseInside(false);
}
public void mouseClicked(MouseEvent e)
{
}
public void mouseMoved(MouseEvent e)
{
if (this.slider.isEnabled() == false)
return;
int i = getIndex(e);
setCurrentIndicatedThumb(i);
boolean b = (e.getX() >= 0 && e.getX() < this.slider.getWidth() && e.getY() >= 0 && e.getY() < this.slider.getHeight());
if (this.mouseIsDown)
b = true;
setMouseInside(b);
}
protected Dimension getThumbSize(int thumbIndex)
{
return new Dimension(16, 16);
}
/**
* Create the shape used to render a specific thumb.
* @param thumbIndex the index of the thumb to render.
* @return the shape used to render a specific thumb.
* @see #getThumbCenter(int)
* @see #getThumb(int)
*/
public Shape getThumbShape(int thumbIndex)
{
return getThumbShape(thumbIndex, null);
}
/**
* Create the shape used to render a specific thumb.
* @param thumbIndex the index of the thumb to render.
* @param center an optional center to focus the thumb around. If this is null then the current (real) center is
* used, but this can be supplied manually to consider possible shapes and visual size constraints based
* on the current collision policy.
* @return the shape used to render a specific thumb.
* @see #getThumbCenter(int)
* @see #getThumb(int)
*/
public Shape getThumbShape(int thumbIndex, Point2D center)
{
Thumb thumb = getThumb(thumbIndex);
if (center == null)
center = getThumbCenter(thumbIndex);
Dimension d = getThumbSize(thumbIndex);
return thumb.getShape(this, (float) center.getX(), (float) center.getY(), d.width, d.height, thumbIndex == 0,
thumbIndex == this.slider.getThumbCount() - 1);
}
/**
* Calculate the thumb center
* @param thumbIndex the index of the thumb to consult.
* @return the center of a given thumb
*/
public Point2D getThumbCenter(int thumbIndex)
{
float[] values = this.slider.getThumbPositions();
float n = values[thumbIndex];
return getThumbCenter(n);
}
/**
* Calculate the thumb center based on a fractional position
* @param position a value from [0,1]
* @return the center of a potential thumbnail for this position.
*/
public Point2D getThumbCenter(float position)
{
/*
* I'm on the fence about whether to document this as allowing null or not. Does this occur in the wild? If so:
* is this more an internal error than something we need to document/allow for?
*/
if (position < 0 || position > 1)
return null;
if (this.slider.getOrientation() == MultiThumbSlider.VERTICAL)
{
float y;
float height = (float) this.trackRect.height;
float x = (float) this.trackRect.getCenterX();
if (this.slider.isInverted())
{
y = (float) (position * height + this.trackRect.y);
}
else
{
y = (float) ((1 - position) * height + this.trackRect.y);
}
return new Point2D.Float(x, y);
}
else
{
float x;
float width = (float) this.trackRect.width;
float y = (float) this.trackRect.getCenterY();
if (this.slider.isInverted())
{
x = (float) ((1 - position) * width + this.trackRect.x);
}
else
{
x = (float) (position * width + this.trackRect.x);
}
return new Point2D.Float(x, y);
}
}
/**
* Return the Thumb option used to render a specific thumb. The default implementation here consults the client
* property MultiThumbSliderUI.THUMB_SHAPE_PROPERTY, and returns Circle by default.
* @param thumbIndex the index of the thumb to render.
* @return the Thumb option used to render a specific thumb.
*/
public Thumb getThumb(int thumbIndex)
{
Thumb thumb = getProperty(this.slider, THUMB_SHAPE_PROPERTY, Thumb.Circle);
return thumb;
}
private void setCurrentIndicatedThumb(int i)
{
if (getProperty(this.slider, "MultiThumbSlider.indicateThumb", "true").equals("false"))
{
// never activate a specific thumb
i = -1;
}
this.currentIndicatedThumb = i;
boolean finished = true;
for (int a = 0; a < this.thumbIndications.length; a++)
{
if (a == this.currentIndicatedThumb)
{
if (this.thumbIndications[a] != 1)
{
finished = false;
}
}
else
{
if (this.thumbIndications[a] != 0)
{
finished = false;
}
}
}
if (!finished)
{
synchronized (MultiThumbSliderUI.this)
{
if (this.animatingThread == null || this.animatingThread.isAlive() == false)
{
this.animatingThread = new Thread(this.animatingRunnable);
this.animatingThread.start();
}
}
}
}
private void setMouseInside(boolean b)
{
this.mouseInside = b;
updateIndication();
}
public void mouseDragged(MouseEvent e)
{
if (this.slider.isEnabled() == false)
return;
e.translatePoint(this.dx, this.dy);
mouseMoved(e);
if (this.pressedState != null && this.pressedState.selectedThumb != -1)
{
this.slider.setValueIsAdjusting(true);
State newState = new State(this.pressedState);
float v;
boolean outside;
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
v = ((float) (e.getX() - this.trackRect.x)) / ((float) this.trackRect.width);
if (this.slider.isInverted())
v = 1 - v;
outside = (e.getY() < this.trackRect.y - 10) || (e.getY() > this.trackRect.y + this.trackRect.height + 10);
// don't whack the thumb off the slider if you happen to be *near* the edge:
if (e.getX() > this.trackRect.x - 10 && e.getX() < this.trackRect.x + this.trackRect.width + 10)
{
if (v < 0)
v = 0;
if (v > 1)
v = 1;
}
}
else
{
v = ((float) (e.getY() - this.trackRect.y)) / ((float) this.trackRect.height);
if (this.slider.isInverted() == false)
v = 1 - v;
outside = (e.getX() < this.trackRect.x - 10) || (e.getX() > this.trackRect.x + this.trackRect.width + 10);
if (e.getY() > this.trackRect.y - 10 && e.getY() < this.trackRect.y + this.trackRect.height + 10)
{
if (v < 0)
v = 0;
if (v > 1)
v = 1;
}
}
if (newState.positions.length <= this.slider.getMinimumThumbnailCount())
{
outside = false; // I don't care if you are outside: no removing!
}
newState.setPosition(newState.selectedThumb, v);
// because we delegate mouseReleased() to this method:
if (outside && this.slider.isThumbRemovalAllowed())
{
newState.removeThumb(newState.selectedThumb);
}
if (validatePositions(newState))
{
newState.install();
}
e.consume();
}
}
public void mouseReleased(MouseEvent e)
{
if (this.slider.isEnabled() == false)
return;
this.mouseIsDown = false;
if (this.pressedState != null && this.slider.getThumbCount() <= this.pressedState.positions.length)
{
mouseDragged(e); // go ahead and commit this final location
}
if (this.slider.isValueAdjusting())
{
this.slider.setValueIsAdjusting(false);
}
this.slider.repaint();
if (e.isPopupTrigger() && this.slider.doPopup(e.getX(), e.getY()))
{
// on windows popuptriggers happen on mouseRelease
e.consume();
return;
}
}
/**
* This retrieves a property. If the component has this property manually set (by calling
* <code>component.putClientProperty()</code>), then that value will be returned. Otherwise this method refers to
* <code>UIManager.get()</code>. If that value is missing, this returns <code>defaultValue</code>
* @param jc
* @param propertyName the property name
* @param defaultValue if no other value is found, this is returned
* @return the property value
*/
public static <K> K getProperty(JComponent jc, String propertyName, K defaultValue)
{
Object jcValue = jc.getClientProperty(propertyName);
if (jcValue != null)
return (K) jcValue;
Object uiValue = UIManager.get(propertyName);
if (uiValue != null)
return (K) uiValue;
return defaultValue;
}
/**
* Makes sure the thumbs are in the right order.
* @param state
* @return true if the thumbs are valid. False if there are two thumbs with the same value (this is not allowed)
*/
protected boolean validatePositions(State state)
{
float[] p = state.positions;
Object[] c = state.values;
/**
* Don't let the user position a thumb outside of [0,1] if there are only 2 colors: colors outside [0,1] are
* deleted, and we can't delete colors so we get less than 2.
*/
if (p.length <= this.slider.getMinimumThumbnailCount() || (!this.slider.isThumbRemovalAllowed()))
{
/**
* Since the user can only manipulate 1 thumb at a time, only 1 thumb should be outside the domain of [0,1].
* So we *don't* have to reorganize c when we change p
*/
for (int a = 0; a < p.length; a++)
{
if (p[a] < 0)
{
p[a] = 0;
}
else if (p[a] > 1)
{
p[a] = 1;
}
}
}
// validate the new positions:
boolean checkAgain = true;
while (checkAgain)
{
checkAgain = false;
for (int a = 0; a < p.length - 1; a++)
{
if (p[a] > p[a + 1])
{
checkAgain = true;
float swap1 = p[a];
p[a] = p[a + 1];
p[a + 1] = swap1;
Object swap2 = c[a];
c[a] = c[a + 1];
c[a + 1] = swap2;
if (a == state.selectedThumb)
{
state.selectedThumb = a + 1;
}
else if (a + 1 == state.selectedThumb)
{
state.selectedThumb = a;
}
}
}
}
return true;
}
FocusListener focusListener = new FocusListener()
{
public void focusLost(FocusEvent e)
{
Component c = (Component) e.getSource();
if (getProperty(MultiThumbSliderUI.this.slider, "MultiThumbSlider.indicateComponent", "false").toString().equals("true"))
{
MultiThumbSliderUI.this.slider.setSelectedThumb(-1);
}
updateIndication();
c.repaint();
}
public void focusGained(FocusEvent e)
{
Component c = (Component) e.getSource();
int i = MultiThumbSliderUI.this.slider.getSelectedThumb(false);
if (i == -1)
{
int direction = 1;
if (MultiThumbSliderUI.this.slider.getOrientation() == MultiThumbSlider.VERTICAL)
direction *= -1;
if (MultiThumbSliderUI.this.slider.isInverted())
direction *= -1;
MultiThumbSliderUI.this.slider.setSelectedThumb((direction == 1) ? 0 : MultiThumbSliderUI.this.slider.getThumbCount() - 1);
}
updateIndication();
c.repaint();
}
};
/**
* This will try to add a thumb between index1 and index2.
* <P>
* This method will not add a thumb if there is already a very small distance between these two endpoints
* @param index1
* @param index2
* @return true if a new thumb was added
*/
protected boolean addThumb(int index1, int index2)
{
float pos1 = 0;
float pos2 = 1;
int min;
int max;
if (index1 < index2)
{
min = index1;
max = index2;
}
else
{
min = index2;
max = index1;
}
float[] positions = this.slider.getThumbPositions();
if (min >= 0)
pos1 = positions[min];
if (max < positions.length)
pos2 = positions[max];
if (pos2 - pos1 < .05)
return false;
float newPosition = (pos1 + pos2) / 2f;
this.slider.setSelectedThumb(this.slider.addThumb(newPosition));
return true;
}
KeyListener keyListener = new KeyListener()
{
public void keyPressed(KeyEvent e)
{
if (MultiThumbSliderUI.this.slider.isEnabled() == false)
return;
if (e.getSource() != MultiThumbSliderUI.this.slider)
throw new RuntimeException("only install this UI on the GradientSlider it was constructed with");
int i = MultiThumbSliderUI.this.slider.getSelectedThumb();
int code = e.getKeyCode();
int orientation = MultiThumbSliderUI.this.slider.getOrientation();
if (i != -1 && (code == KeyEvent.VK_RIGHT || code == KeyEvent.VK_LEFT)
&& orientation == MultiThumbSlider.HORIZONTAL
&& e.getModifiers() == Toolkit.getDefaultToolkit().getMenuShortcutKeyMask())
{
// insert a new thumb
int i2;
if ((code == KeyEvent.VK_RIGHT && MultiThumbSliderUI.this.slider.isInverted() == false)
|| (code == KeyEvent.VK_LEFT && MultiThumbSliderUI.this.slider.isInverted() == true))
{
i2 = i + 1;
}
else
{
i2 = i - 1;
}
addThumb(i, i2);
e.consume();
return;
}
else if (i != -1 && (code == KeyEvent.VK_UP || code == KeyEvent.VK_DOWN)
&& orientation == MultiThumbSlider.VERTICAL
&& e.getModifiers() == Toolkit.getDefaultToolkit().getMenuShortcutKeyMask())
{
// insert a new thumb
int i2;
if ((code == KeyEvent.VK_UP && MultiThumbSliderUI.this.slider.isInverted() == false)
|| (code == KeyEvent.VK_DOWN && MultiThumbSliderUI.this.slider.isInverted() == true))
{
i2 = i + 1;
}
else
{
i2 = i - 1;
}
addThumb(i, i2);
e.consume();
return;
}
else if (code == KeyEvent.VK_DOWN && orientation == MultiThumbSlider.HORIZONTAL && i != -1)
{
// popup up!
int x =
MultiThumbSliderUI.this.slider.isInverted() ? (int) (MultiThumbSliderUI.this.trackRect.x + MultiThumbSliderUI.this.trackRect.width
* (1 - MultiThumbSliderUI.this.slider.getThumbPositions()[i])) : (int) (MultiThumbSliderUI.this.trackRect.x + MultiThumbSliderUI.this.trackRect.width
* MultiThumbSliderUI.this.slider.getThumbPositions()[i]);
int y = MultiThumbSliderUI.this.trackRect.y + MultiThumbSliderUI.this.trackRect.height;
if (MultiThumbSliderUI.this.slider.doPopup(x, y))
{
e.consume();
return;
}
}
else if (code == KeyEvent.VK_RIGHT && orientation == MultiThumbSlider.VERTICAL && i != -1)
{
// popup up!
int y =
MultiThumbSliderUI.this.slider.isInverted() ? (int) (MultiThumbSliderUI.this.trackRect.y + MultiThumbSliderUI.this.trackRect.height * MultiThumbSliderUI.this.slider.getThumbPositions()[i])
: (int) (MultiThumbSliderUI.this.trackRect.y + MultiThumbSliderUI.this.trackRect.height * (1 - MultiThumbSliderUI.this.slider.getThumbPositions()[i]));
int x = MultiThumbSliderUI.this.trackRect.x + MultiThumbSliderUI.this.trackRect.width;
if (MultiThumbSliderUI.this.slider.doPopup(x, y))
{
e.consume();
return;
}
}
if (i != -1)
{
// move the selected thumb
if (code == KeyEvent.VK_RIGHT || code == KeyEvent.VK_DOWN)
{
nudge(i, 1);
e.consume();
}
else if (code == KeyEvent.VK_LEFT || code == KeyEvent.VK_UP)
{
nudge(i, -1);
e.consume();
}
else if (code == KeyEvent.VK_DELETE || code == KeyEvent.VK_BACK_SPACE)
{
if (MultiThumbSliderUI.this.slider.getThumbCount() > MultiThumbSliderUI.this.slider.getMinimumThumbnailCount() && MultiThumbSliderUI.this.slider.isThumbRemovalAllowed())
{
MultiThumbSliderUI.this.slider.removeThumb(i);
e.consume();
}
}
else if (code == KeyEvent.VK_SPACE || code == KeyEvent.VK_ENTER)
{
MultiThumbSliderUI.this.slider.doDoubleClick(-1, -1);
}
}
}
public void keyReleased(KeyEvent e)
{
}
public void keyTyped(KeyEvent e)
{
}
};
PropertyChangeListener propertyListener = new PropertyChangeListener()
{
public void propertyChange(PropertyChangeEvent e)
{
String name = e.getPropertyName();
if (name.equals(MultiThumbSlider.VALUES_PROPERTY) || name.equals(MultiThumbSlider.ORIENTATION_PROPERTY)
|| name.equals(MultiThumbSlider.INVERTED_PROPERTY))
{
calculateGeometry();
MultiThumbSliderUI.this.slider.repaint();
}
else if (name.equals(MultiThumbSlider.SELECTED_THUMB_PROPERTY)
|| name.equals(MultiThumbSlider.PAINT_TICKS_PROPERTY))
{
MultiThumbSliderUI.this.slider.repaint();
}
else if (name.equals("MultiThumbSlider.indicateComponent"))
{
setMouseInside(MultiThumbSliderUI.this.mouseInside);
MultiThumbSliderUI.this.slider.repaint();
}
}
};
ComponentListener compListener = new ComponentListener()
{
public void componentHidden(ComponentEvent e)
{
}
public void componentMoved(ComponentEvent e)
{
}
public void componentResized(ComponentEvent e)
{
calculateGeometry();
Component c = (Component) e.getSource();
c.repaint();
}
public void componentShown(ComponentEvent e)
{
}
};
protected void updateIndication()
{
synchronized (MultiThumbSliderUI.this)
{
if (this.slider.isEnabled() && (this.slider.hasFocus() || this.mouseInside))
{
this.indicationGoal = 1;
}
else
{
this.indicationGoal = 0;
}
if (getProperty(this.slider, "MultiThumbSlider.indicateComponent", "false").equals("false"))
{
// always turn on the "indication", so controls are always visible
this.indicationGoal = 1;
if (this.slider.isVisible() == false)
{ // when the component isn't yet initialized
this.indication = 1; // initialize it to fully indicated
}
}
if (this.indication != this.indicationGoal)
{
if (this.animatingThread == null || this.animatingThread.isAlive() == false)
{
this.animatingThread = new Thread(this.animatingRunnable);
this.animatingThread.start();
}
}
}
}
protected synchronized void calculateGeometry()
{
this.trackRect = calculateTrackRect();
float[] pos = this.slider.getThumbPositions();
if (this.thumbPositions.length != pos.length)
{
this.thumbPositions = new int[pos.length];
this.thumbIndications = new float[pos.length];
}
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
for (int a = 0; a < this.thumbPositions.length; a++)
{
if (this.slider.isInverted() == false)
{
this.thumbPositions[a] = this.trackRect.x + (int) (this.trackRect.width * pos[a]);
}
else
{
this.thumbPositions[a] = this.trackRect.x + (int) (this.trackRect.width * (1 - pos[a]));
}
this.thumbIndications[a] = 0;
}
}
else
{
for (int a = 0; a < this.thumbPositions.length; a++)
{
if (this.slider.isInverted())
{
this.thumbPositions[a] = this.trackRect.y + (int) (this.trackRect.height * pos[a]);
}
else
{
this.thumbPositions[a] = this.trackRect.y + (int) (this.trackRect.height * (1 - pos[a]));
}
this.thumbIndications[a] = 0;
}
}
}
protected Rectangle calculateTrackRect()
{
Insets i = new Insets(5, 5, 5, 5);
int w, h;
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
w = this.slider.getWidth() - i.left - i.right;
h = Math.min(this.DEPTH, this.slider.getHeight() - i.top - i.bottom);
}
else
{
h = this.slider.getHeight() - i.top - i.bottom;
w = Math.min(this.DEPTH, this.slider.getWidth() - i.left - i.right);
}
return new Rectangle(this.slider.getWidth() / 2 - w / 2, this.slider.getHeight() / 2 - h / 2, w, h);
}
private void nudge(int thumbIndex, int direction)
{
float pixelFraction;
if (this.slider.getOrientation() == MultiThumbSlider.HORIZONTAL)
{
pixelFraction = 1f / (this.trackRect.width);
}
else
{
pixelFraction = 1f / (this.trackRect.height);
}
if (direction < 0)
pixelFraction *= -1;
if (this.slider.isInverted())
pixelFraction *= -1;
if (this.slider.getOrientation() == MultiThumbSlider.VERTICAL)
pixelFraction *= -1;
// repeat a couple of times: it's possible we'll nudge two values
// so they're exactly equal, which will make validate() fail.
// in that case: move the value ANOTHER nudge to the left/right
// to really make a change. But make sure we still respect the [0,1] limits.
State state = new State();
int a = 0;
while (a < 10 && state.positions[thumbIndex] >= 0 && state.positions[thumbIndex] <= 1)
{
state.setPosition(thumbIndex, state.positions[thumbIndex] + pixelFraction);
if (validatePositions(state))
{
state.install();
return;
}
a++;
}
}
@Override
public void installUI(JComponent slider)
{
slider.addMouseListener(this);
slider.addMouseMotionListener(this);
slider.addFocusListener(this.focusListener);
slider.addKeyListener(this.keyListener);
slider.addComponentListener(this.compListener);
slider.addPropertyChangeListener(this.propertyListener);
slider.addPropertyChangeListener(THUMB_SHAPE_PROPERTY, this.thumbShapeListener);
calculateGeometry();
}
@Override
public void paint(Graphics g, JComponent slider2)
{
if (slider2 != this.slider)
throw new RuntimeException("only use this UI on the GradientSlider it was constructed with");
Graphics2D g2 = (Graphics2D) g;
int w = this.slider.getWidth();
int h = this.slider.getHeight();
if (this.slider.isOpaque())
{
g.setColor(this.slider.getBackground());
g.fillRect(0, 0, w, h);
}
if (slider2.hasFocus())
{
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
paintFocus(g2);
}
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
paintTrack(g2);
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
paintThumbs(g2);
}
protected abstract void paintTrack(Graphics2D g);
protected abstract void paintFocus(Graphics2D g);
protected abstract void paintThumbs(Graphics2D g);
@Override
public void uninstallUI(JComponent slider)
{
slider.removeMouseListener(this);
slider.removeMouseMotionListener(this);
slider.removeFocusListener(this.focusListener);
slider.removeKeyListener(this.keyListener);
slider.removeComponentListener(this.compListener);
slider.removePropertyChangeListener(this.propertyListener);
slider.removePropertyChangeListener(THUMB_SHAPE_PROPERTY, this.thumbShapeListener);
super.uninstallUI(slider);
}
}