/*
* @(#)MultiThumbSliderUI.java
*
* $Date: 2015-01-23 04:18:36 -0800 (Fri, 23 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 com.bric.plaf;
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.awt.geom.RoundRectangle2D;
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 com.bric.geom.ShapeBounds;
import com.bric.math.MathG;
import com.bric.swing.MultiThumbSlider;
import com.bric.swing.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) {
slider.repaint();
}
};
/** A thumb shape.
*/
public static enum Thumb {
Circle() {
@Override
public Shape getShape(float width,float height,boolean leftEdge,boolean rightEdge,boolean sharpEdgesHint) {
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,boolean sharpEdgesHint) {
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 {
if(sharpEdgesHint) {
p.moveTo(0, height/2);
p.lineTo(-k, height/2-k);
p.lineTo(-k, -height/2+1);
p.lineTo(-k+1, -height/2);
p.lineTo(k-1, -height/2);
p.lineTo(k, -height/2+1);
p.lineTo(k, height/2-k);
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,boolean sharpEdgesHint) {
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 {
if(sharpEdgesHint)
return new Rectangle2D.Float(-width/2,-height/2,width,height);
return new RoundRectangle2D.Float(-width/2,-height/2,width,height,4,4);
}
}
},
Hourglass() {
@Override
public Shape getShape(float width,float height,boolean leftEdge,boolean rightEdge,boolean sharpEdgesHint) {
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, !sliderUI.getThumbAntialiasing()));
if(sliderUI.slider.getOrientation()==SwingConstants.VERTICAL) {
path.transform(AffineTransform.getRotateInstance(-Math.PI/2));
}
path.transform( AffineTransform.getTranslateInstance(MathG.roundInt(x), MathG.roundInt(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.
* @param sharpEdgesHint if true then this may return something more polygonal with the
* assumption that antialiasing is turned off. If false then this should instead
* return something with bezier curves.
* @return the shape of this thumb.
*/
public abstract Shape getShape(float width,float height,boolean leftEdge,boolean rightEdge,boolean sharpEdgesHint);
}
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( DEPTH, getPreferredComponentDepth());
if(mySlider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
return new Dimension(MAX_LENGTH, k);
}
return new Dimension(k, MAX_LENGTH);
}
@Override
public Dimension getMinimumSize(JComponent s) {
MultiThumbSlider<T> mySlider = (MultiThumbSlider<T>)s;
int k = Math.max( DEPTH, getPreferredComponentDepth());
if(mySlider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
return new Dimension(MIN_LENGTH, k);
}
return new Dimension(k, MIN_LENGTH);
}
@Override
public Dimension getPreferredSize(JComponent s) {
MultiThumbSlider<T> mySlider = (MultiThumbSlider<T>)s;
int k = Math.max( DEPTH, getPreferredComponentDepth());
if(mySlider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
return new Dimension(PREF_LENGTH, k);
}
return new Dimension(k, 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() {
values = slider.getValues();
positions = slider.getThumbPositions();
selectedThumb = slider.getSelectedThumb(false);
}
public State(State s) {
selectedThumb = s.selectedThumb;
positions = new float[s.positions.length];
values = createSimilarArray(s.values, s.values.length);
System.arraycopy(s.positions,0,positions,0,positions.length);
System.arraycopy(s.values,0,values,0,values.length);
}
/** Strip values outside of [0,1] */
private void polish() {
while(positions[0]<0) {
float[] f2 = new float[positions.length-1];
System.arraycopy(positions,1,f2,0,positions.length-1);
T[] c2 = createSimilarArray(values, values.length-1);
System.arraycopy(values,1,c2,0,positions.length-1);
positions = f2;
values = c2;
selectedThumb++;
}
while(positions[positions.length-1]>1) {
float[] f2 = new float[positions.length-1];
System.arraycopy(positions,0,f2,0,positions.length-1);
T[] c2 = createSimilarArray(values, values.length-1);
System.arraycopy(values,0,c2,0,positions.length-1);
positions = f2;
values = c2;
selectedThumb--;
}
if(selectedThumb>=positions.length)
selectedThumb = -1;
}
/** Make the slider reflect this object */
public void install() {
polish();
slider.setValues(positions, values);
slider.setSelectedThumb(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[positions.length-1];
T[] c = createSimilarArray(values, values.length-1);
System.arraycopy(positions, 0, f, 0, index);
System.arraycopy(values, 0, c, 0, index);
System.arraycopy(positions, index+1, f, index, f.length-index);
System.arraycopy(values, index+1, c, index, f.length-index);
positions = f;
values = c;
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(positions[thumbIndexA]).compareTo( positions[thumbIndexB] );
int newState = new Float(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(!slider.isThumbOverlap()) {
Point2D aCenter = getThumbCenter(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 = slider.getCollisionPolicy();
if(Collision.JUMP_OVER_OTHER.equals(c) && (!slider.isThumbOverlap())) {
newPosition = Math.max(0, Math.min(1, newPosition));
for(int a = 0; a<positions.length; a++) {
if( isOverlap(a, thumbIndex, newPosition) ) {
if(revise) {
float alternative;
int maxWidth = Math.max( getThumbSize(a).width, getThumbSize(thumbIndex).width );
float trackSize = slider.getOrientation()==SwingConstants.HORIZONTAL ? trackRect.width : 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<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 = slider.getOrientation()==SwingConstants.HORIZONTAL ? trackRect.width : trackRect.height;
//offset is measured in pixels
for(int offset = 0; offset<2*maxWidth; offset++) {
if(positions[a]>positions[thumbIndex]) {
alternative = positions[a] - ((float)offset)/trackSize;
} else {
alternative = 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(slider.isThumbOverlap()) {
span = 0;
} else {
span = (float)ShapeBounds.getBounds( getThumbShape(thumbIndex) ).getWidth();
if(slider.getOrientation()==SwingConstants.HORIZONTAL){
span = span / ((float)trackRect.width);
} else {
span = span / ((float)trackRect.height);
}
}
int[] neighbors = getNeighbors(thumbIndex);
float newPosition = startingValue + requestedDelta;
processedThumbs.add(thumbIndex);
if(neighbors[0]==-1 && newPosition<0) {
setPosition(thumbIndex, 0, false);
} else if(neighbors[1]==-1 && newPosition>1) {
setPosition(thumbIndex, 1, false);
} else if(processedThumbs.add(neighbors[0]) && (newPosition<positions[neighbors[0]] || Math.abs(positions[neighbors[0]]-newPosition)<span-.0001)) {
NudgeRequest dependsOn = new NudgeRequest(neighbors[0], positions[neighbors[0]], (newPosition - span)-positions[neighbors[0]]);
dependsOn.process();
setPosition(thumbIndex, positions[dependsOn.thumbIndex]+span, false );
} else if(processedThumbs.add(neighbors[1]) && (newPosition>positions[neighbors[1]] || Math.abs(positions[neighbors[1]]-newPosition)<span-.0001)) {
NudgeRequest dependsOn = new NudgeRequest(neighbors[1], positions[neighbors[1]], (newPosition + span)-positions[neighbors[1]]);
dependsOn.process();
setPosition(thumbIndex, positions[dependsOn.thumbIndex]-span, false );
} else {
setPosition(thumbIndex, startingValue + requestedDelta, false);
}
}
}
float originalValue = positions[thumbIndex];
NudgeRequest rootRequest = new NudgeRequest(thumbIndex, positions[thumbIndex], newPosition - positions[thumbIndex]);
rootRequest.process();
return positions[thumbIndex]!=originalValue;
}
}
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<positions.length; a++) {
if(a!=thumbIndex) {
if(positions[thumbIndex]<positions[a]) {
float delta = positions[a] - positions[thumbIndex];
if(delta<rightNeighborDelta) {
rightNeighborDelta = delta;
rightNeighbor = a;
}
} else if(positions[thumbIndex]>positions[a]) {
float delta = positions[thumbIndex] - 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<thumbIndications.length; a++) {
if(a!=slider.getSelectedThumb()) {
if(a==currentIndicatedThumb) {
if(thumbIndications[a]<1) {
thumbIndications[a] = Math.min(1,thumbIndications[a]+.025f);
finished = false;
}
} else {
if(thumbIndications[a]>0) {
thumbIndications[a] = Math.max(0,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==currentIndicatedThumb) {
thumbIndications[a] = 1;
} else {
thumbIndications[a] = 0;
}
}
}
if(indicationGoal>indication+.01f) {
if(indication<.99f) {
indication = Math.min(1,indication+.1f);
finished = false;
}
} else if(indicationGoal<indication-.01f){
if(indication>.01f) {
indication = Math.max(0,indication-.1f);
finished = false;
}
}
}
if(!finished)
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) {
dx = 0;
dy = 0;
if(slider.isEnabled()==false) return;
if(e.getClickCount()>=2) {
if(slider.doDoubleClick(e.getX(),e.getY())) {
e.consume();
return;
}
} else if(e.isPopupTrigger()) {
int x = e.getX();
int y = e.getY();
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
if(x<trackRect.x || x>trackRect.x+trackRect.width)
return;
y = trackRect.y+trackRect.height;
} else {
if(y<trackRect.y || y>trackRect.y+trackRect.height)
return;
x = trackRect.x+trackRect.width;
}
if(slider.doPopup(x,y)) {
e.consume();
return;
}
}
mouseIsDown = true;
mouseMoved(e);
if(e.getSource()!=slider) {
throw new RuntimeException("only install this UI on the GradientSlider it was constructed with");
}
slider.requestFocus();
int index = getIndex(e);
if(index!=-1) {
if(slider.getOrientation()==SwingConstants.HORIZONTAL) {
dx = -e.getX()+thumbPositions[index];
} else {
dy = -e.getY()+thumbPositions[index];
}
}
if(index!=-1) {
slider.setSelectedThumb(index);
e.consume();
} else {
if(slider.isAutoAdding()) {
float k;
int v;
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
v = e.getX();
} else {
v = e.getY();
}
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
k = ((float)(v-trackRect.x))/((float)trackRect.width);
if(slider.isInverted())
k = 1-k;
} else {
k = ((float)(v-trackRect.y))/((float)trackRect.height);
if(slider.isInverted()==false)
k= 1-k;
}
if(k>0 && k<1) {
int added = slider.addThumb(k);
slider.setSelectedThumb(added);
}
e.consume();
} else {
if(slider.getSelectedThumb()!=-1) {
slider.setSelectedThumb(-1);
e.consume();
}
}
}
pressedState = new State();
}
private int getIndex(MouseEvent e) {
int v;
Rectangle2D shapeSum = new Rectangle2D.Double(trackRect.x, trackRect.y, trackRect.width, trackRect.height);
for(int a = 0; a<slider.getThumbCount(); a++) {
shapeSum.add(ShapeBounds.getBounds(getThumbShape(a)));
}
if(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-thumbPositions[0]);
int minIndex = 0;
for(int a = 1; a<thumbPositions.length; a++) {
int distance = Math.abs(v-thumbPositions[a]);
if(distance<min) {
min = distance;
minIndex = a;
} else if(distance==min) {
//two thumbs may perfectly overlap
if(v<thumbPositions[a]) {
//you clicked to the left of the fulcrum, so we should side with the smaller index
if(slider.isInverted()) {
//... unless it's inverted:
minIndex = a;
}
} else {
if(!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(slider.isEnabled()==false) return;
int i = getIndex(e);
setCurrentIndicatedThumb(i);
boolean b = (e.getX()>=0 && e.getX()<slider.getWidth() && e.getY()>=0 && e.getY()<slider.getHeight());
if(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==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 = slider.getThumbPositions();
float n = values[thumbIndex];
return getThumbCenter(n);
}
/** @return true if Thumbs should be rendered with curved antialiasing. False if
* a crisp pixelated appearance is expected.
*/
protected boolean getThumbAntialiasing() {
return true;
}
/** 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(slider.getOrientation()==MultiThumbSlider.VERTICAL) {
float y;
float height = (float)trackRect.height;
float x = (float)trackRect.getCenterX();
if(slider.isInverted()) {
y = (float)(position*height+trackRect.y);
} else {
y = (float)((1-position)*height+trackRect.y);
}
return new Point2D.Float(x,y);
} else {
float x;
float width = (float)trackRect.width;
float y = (float)trackRect.getCenterY();
if(slider.isInverted()) {
x = (float)((1-position)*width+trackRect.x);
} else {
x = (float)(position*width+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 defaultThumb = slider.isPaintTicks() ? Thumb.Triangle : Thumb.Circle;
Thumb thumb = getProperty(slider, THUMB_SHAPE_PROPERTY, defaultThumb);
return thumb;
}
private void setCurrentIndicatedThumb(int i) {
if(getProperty(slider,"MultiThumbSlider.indicateThumb","true").equals("false")) {
//never activate a specific thumb
i = -1;
}
currentIndicatedThumb = i;
boolean finished = true;
for(int a = 0; a<thumbIndications.length; a++) {
if(a==currentIndicatedThumb) {
if(thumbIndications[a]!=1) {
finished = false;
}
} else {
if(thumbIndications[a]!=0) {
finished = false;
}
}
}
if(!finished) {
synchronized(MultiThumbSliderUI.this) {
if(animatingThread==null || animatingThread.isAlive()==false) {
animatingThread = new Thread(animatingRunnable);
animatingThread.start();
}
}
}
}
private void setMouseInside(boolean b) {
mouseInside = b;
updateIndication();
}
public void mouseDragged(MouseEvent e) {
if(slider.isEnabled()==false) return;
e.translatePoint(dx, dy);
mouseMoved(e);
if(pressedState!=null && pressedState.selectedThumb!=-1) {
slider.setValueIsAdjusting(true);
State newState = new State(pressedState);
float v;
boolean outside;
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
v = ((float)(e.getX()-trackRect.x))/((float)trackRect.width);
if(slider.isInverted())
v = 1-v;
outside = (e.getY()<trackRect.y-10) || (e.getY()>trackRect.y+trackRect.height+10);
//don't whack the thumb off the slider if you happen to be *near* the edge:
if(e.getX()>trackRect.x-10 && e.getX()<trackRect.x+trackRect.width+10) {
if(v<0) v = 0;
if(v>1) v = 1;
}
} else {
v = ((float)(e.getY()-trackRect.y))/((float)trackRect.height);
if(slider.isInverted()==false)
v = 1-v;
outside = (e.getX()<trackRect.x-10) || (e.getX()>trackRect.x+trackRect.width+10);
if(e.getY()>trackRect.y-10 && e.getY()<trackRect.y+trackRect.height+10) {
if(v<0) v = 0;
if(v>1) v = 1;
}
}
if(newState.positions.length<=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 && slider.isThumbRemovalAllowed()) {
newState.removeThumb(newState.selectedThumb);
}
if(validatePositions(newState)) {
newState.install();
}
e.consume();
}
}
public void mouseReleased(MouseEvent e) {
if(slider.isEnabled()==false) return;
mouseIsDown = false;
if(pressedState!=null && slider.getThumbCount()<=pressedState.positions.length) {
mouseDragged(e); //go ahead and commit this final location
}
if(slider.isValueAdjusting()) {
slider.setValueIsAdjusting(false);
}
slider.repaint();
if(e.isPopupTrigger() && 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<=slider.getMinimumThumbnailCount() || (!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(slider,"MultiThumbSlider.indicateComponent","false").toString().equals("true") ) {
slider.setSelectedThumb(-1);
}
updateIndication();
c.repaint();
}
public void focusGained(FocusEvent e) {
Component c = (Component)e.getSource();
int i = slider.getSelectedThumb(false);
if(i==-1) {
int direction = 1;
if(slider.getOrientation()==MultiThumbSlider.VERTICAL)
direction *= -1;
if(slider.isInverted())
direction *= -1;
slider.setSelectedThumb( (direction==1) ? 0 : 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 = 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;
slider.setSelectedThumb(slider.addThumb(newPosition));
return true;
}
KeyListener keyListener = new KeyListener() {
public void keyPressed(KeyEvent e) {
if(slider.isEnabled()==false) return;
if(e.getSource()!=slider)
throw new RuntimeException("only install this UI on the GradientSlider it was constructed with");
int i = slider.getSelectedThumb();
int code = e.getKeyCode();
int orientation = 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 && slider.isInverted()==false) ||
(code==KeyEvent.VK_LEFT && 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 && slider.isInverted()==false) ||
(code==KeyEvent.VK_DOWN && 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 = slider.isInverted() ?
(int)(trackRect.x+trackRect.width*(1-slider.getThumbPositions()[i])) :
(int)(trackRect.x+trackRect.width*slider.getThumbPositions()[i]);
int y = trackRect.y+trackRect.height;
if(slider.doPopup(x, y)) {
e.consume();
return;
}
} else if(code==KeyEvent.VK_RIGHT &&
orientation==MultiThumbSlider.VERTICAL &&
i!=-1) {
//popup up!
int y = slider.isInverted() ?
(int)(trackRect.y+trackRect.height*slider.getThumbPositions()[i]) :
(int)(trackRect.y+trackRect.height*(1-slider.getThumbPositions()[i]));
int x = trackRect.x+trackRect.width;
if(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(slider.getThumbCount()>slider.getMinimumThumbnailCount() && slider.isThumbRemovalAllowed()) {
slider.removeThumb(i);
e.consume();
}
} else if(code==KeyEvent.VK_SPACE || code==KeyEvent.VK_ENTER) {
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();
slider.repaint();
} else if(name.equals(MultiThumbSlider.SELECTED_THUMB_PROPERTY) ||
name.equals(MultiThumbSlider.PAINT_TICKS_PROPERTY)) {
slider.repaint();
} else if(name.equals("MultiThumbSlider.indicateComponent")) {
setMouseInside(mouseInside);
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(slider.isEnabled() && (slider.hasFocus() || mouseInside)) {
indicationGoal = 1;
} else {
indicationGoal = 0;
}
if(getProperty(slider,"MultiThumbSlider.indicateComponent","false").equals("false")) {
//always turn on the "indication", so controls are always visible
indicationGoal = 1;
if(slider.isVisible()==false) { //when the component isn't yet initialized
indication = 1; //initialize it to fully indicated
}
}
if(indication!=indicationGoal) {
if(animatingThread==null || animatingThread.isAlive()==false) {
animatingThread = new Thread(animatingRunnable);
animatingThread.start();
}
}
}
}
protected synchronized void calculateGeometry() {
trackRect = calculateTrackRect();
float[] pos = slider.getThumbPositions();
if(thumbPositions.length!=pos.length) {
thumbPositions = new int[pos.length];
thumbIndications = new float[pos.length];
}
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
for(int a = 0; a<thumbPositions.length; a++) {
if(slider.isInverted()==false) {
thumbPositions[a] = trackRect.x+(int)(trackRect.width*pos[a]);
} else {
thumbPositions[a] = trackRect.x+(int)(trackRect.width*(1-pos[a]));
}
thumbIndications[a] = 0;
}
} else {
for(int a = 0; a<thumbPositions.length; a++) {
if(slider.isInverted()) {
thumbPositions[a] = trackRect.y+(int)(trackRect.height*pos[a]);
} else {
thumbPositions[a] = trackRect.y+(int)(trackRect.height*(1-pos[a]));
}
thumbIndications[a] = 0;
}
}
}
protected Rectangle calculateTrackRect() {
Insets i = new Insets(5,5,5,5);
int w, h;
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
w = slider.getWidth()-i.left-i.right;
h = Math.min(DEPTH, slider.getHeight()-i.top-i.bottom);
} else {
h = slider.getHeight()-i.top-i.bottom;
w = Math.min(DEPTH, slider.getWidth()-i.left-i.right);
}
return new Rectangle(slider.getWidth()/2-w/2,slider.getHeight()/2-h/2, w, h);
}
private void nudge(int thumbIndex,int direction) {
float pixelFraction;
if(slider.getOrientation()==MultiThumbSlider.HORIZONTAL) {
pixelFraction = 1f/(trackRect.width);
} else {
pixelFraction = 1f/(trackRect.height);
}
if(direction<0)
pixelFraction *= -1;
if(slider.isInverted())
pixelFraction *= -1;
if(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(focusListener);
slider.addKeyListener(keyListener);
slider.addComponentListener(compListener);
slider.addPropertyChangeListener(propertyListener);
slider.addPropertyChangeListener(THUMB_SHAPE_PROPERTY, thumbShapeListener);
calculateGeometry();
}
@Override
public void paint(Graphics g, JComponent slider2) {
if(slider2!=slider)
throw new RuntimeException("only use this UI on the GradientSlider it was constructed with");
Graphics2D g2 = (Graphics2D)g;
int w = slider.getWidth();
int h = slider.getHeight();
if(slider.isOpaque()) {
g.setColor(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(focusListener);
slider.removeKeyListener(keyListener);
slider.removeComponentListener(compListener);
slider.removePropertyChangeListener(propertyListener);
slider.removePropertyChangeListener(THUMB_SHAPE_PROPERTY, thumbShapeListener);
super.uninstallUI(slider);
}
}