/* * @(#)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); } }