ORIGINAL DRAFT

This month we have a colorful widget for you. While the JFC provides a pretty nice color picker, it doesn’t seem to go the extra mile that users of imaging software have come to expect. Having been exposed to some of that software, some users have become pretty sophisticated and you can certainly make a good impression on them by using a well designed color selection control, should you need one in your application.

This article explores a widget called JColor which provides six (6) views on the color spectrum in a single, easy to use interface. Figure 1 shows the JColor control in action, with the Blue view activated. The six views correspond directly with the red, green, blue of the RGB model and the hue, saturation and brightness of the HSB model.

Figure 1: JColor in the Blue View.

Figure 1: JColor in the Blue View.

The RGB and HSB color models are both based on three dimensions, something often difficult to visualize on a flat display. Our approach uses the two dimensions of a rectangle and an additional dimension in a vertical gradient. The view selected by the radio controls of our interface determine which dimension is displayed in the vertical gradient. The remaining two dimensions are represented by the two axes of the rectangle. You can pick colors graphically in any model, or type the RGB or HSB values directly in the fields.

Image Producers

Both the ColorSliderImage and ColorMatrixImage classes are implementations of the ImageProducer interface. An image producer implements a minimum interface that allows it to dynamically produce arbitrary images that can be read by an ImageConsumer and displayed by an ImageObserver.

The JFC offers a simple abstraction to the ImageProducer class called SyntheticImage, which provides a constructor that needs to know the width and height of the image and a computeRow method to actually produce the pixels one row at a time. This is very convenient, since very little coding is actually required to produce an image.

We implement two image producers. Both of them calculate a color gradient horizontally and one of them calculates an additional vertical gradient. In both cases, we use a set of constants that tell us which of the six (6) view modes we are in. The constants, presented in Listing 1, are RED, GREEN, BLUE, HUE, SATURATION and BRIGHTNESS. The images are produced dynamically through the interface.

Listing 2 shows the source code for ColorSliderImage, which produces a vertical gradient based on the current style. For the RED, GREEN, BLUE styles, we create a gradient between zero (0) and the specified color, representing a value between 0 and 255. The HUE, SATURATION and BRIGHTNESS values are determined by using the static HSBtoRGB method in the Java Color class.

Listing 3 shows the code for ColorMatrixImage, which produces a vertical and horizontal gradient covering the full two dimensional range specified by the current style. Notice that the ColorSliderImage actually represents the selected style, so ColorMatrixImage produces the remaining two dimensions. If, for example, the RED style is selected, the ColorMatrixImage will actually represent the green and blue color components.

Color Selectors

Two widgets need to be implemented to support interactive color selection. The JColorSlider and JColorMatrix controls use the image producers developed earlier to display the range of values available for each selection. Two visual cues are employed to show the user the current selection - arrows on the outside of the color area and a crosshair on each side of the current position.

The arrows are drawn as triangular polygons on the border of the control. The JColorSlider control provides arrows on the left and right of the current position. The JColorMatrix control draws left and right arrows, along with top and bottom arrows. To make all this easy to follow, we provide separate methods for each of the arrow drawing routines.

The crosshair is designed to handle the unpredictable underlying color spectrum. If the underlying color is dark, a black crosshair would be inefective. If we make the crosshair white, the same problem occurs when the underlying color is too bright. The solution is to use a combination of black and white with a black central crosshair and a while edge on each side of the black lines.

Listing 4 shows the AbstractColorSelector class from which both JColorSlider and JColorMatrix inherit. It encapsulates basic code for handling common member variables along with action listener registration and event handling. Empty MouseListener and KeyListener methods are also provided, since we’re primarily interested in the mousePressed and keyPressed events, we can ignore the others.

Listing 5 shows code for JColorSlider. We set the style and register to receive mouse, key and focus events. Besides setting the style member variable, the setStyle method sets the image to null before repainting, forcing a new image to be generated by the ColorSliderImage producer. The paintComponent method draws the image, arrows and crosshair, and the focus rectangle when appropriate.

The setYValue and getYValue methods handle setting and getting the vertical position. Internally, we calculate the actual pixel location. From the outside, these values are expected to be between 0 and 255. We also set the preferred size so that window packing will create ideal dimensions for the control, with a pixel for each discrete position. The object can be scaled as needed, however, so this is considered a guideline.

Listing 6 shows the code for JColorMatrix, which extends the JColorSlider control. All the vertical handling is identical, so we extend the JColorSlider behavior to handle horizontal positions. We add the setXValue and getXValue methods, top and bottom arrows and modify the code for paintMethod and drawCrosshair to accommodate the additional dimension. The getPreferredSize method returns symmetrical, ideal dimensions for a square area with a single pixel for each discrete unit. Note that if you make it smaller, you will loose some resolution, and making it larger repeats pixels, so the preferred size is highly recommended.

The JColor Control

The main JColor control is implemented as an extension to JPanel for flexibility. Using this strategy allows us to place it in any component or window. This is the most complex class in this collection, primarily because the JColor panel handles all the button, field and selector events, and coordinates the six views provided to display over the more than 16 million (24 bit) colors in the spectrum.

Figure 2: JColor Nested Panel Layout.

Figure 2: JColor Nested Panel Layout.

Figure 2 shows how the internal panels and components are arranged. The JColor constructor handles creating each of the instances and stores a reference to the buttons, fields and color selectors in member variables.

Lets quickly review behavior in the JColor widget. If the user selects one of the radio buttons, the matrix and slider view images are updated to reflect the color selection model. When the user types in a value directly in one of the fields, we clip values within the 0 to 255 range. If the field being edited is in the RGB range, we automatically switch to one of those views if one is not already active. We pick the view associated with the current field to be consistent. The same is true of entering values in the HSB fields if one of the views is not already active. A value changed in the matrix or slider component is immediately reflected in the field values and the swatch panel, keeping in mind which model is active at the time.

Most of the work is done when action events are triggered. Listing 7 shows the code for the actionPerformed method, with most of the field handlers omitted. The red field handler is sufficiently indicative of how the others work to get the idea.

Each of the radio buttons are handled first. In each case, the style value changes and a condition that checks for RadioButton events follows immediately to switch the slider and matrix styles dynamically. No matter what the source of the event, we call setColorText to update the field values and repaint the swatch to reflect the current color.

Listing 7 shows the condition that handles the red field events. We first check to see if one of the RGB radio selections is active. If none of them are, we switch views by setting the style value and updating the matrix and slider styles. In either case, we check to see which context is active and set the appropriate X or Y value in the slider or matrix controls. The same mechanism is applied to each of the fields in a set of subsequent conditions.

Figure 3: JColor in the Saturation View.

Figure 3: JColor in the Saturation View.

Listing 8 shows code for JColorTest which demonstrates usage. We create a JFrame and watch for the windowClosing event. The code puts a new JColor panel into the center and sets an initial color with the JColor.setColor method. We fetch the color with getColor on exit. Figure 3 shows JColorTest running. Notice that the JColorMatrix control has the focus and appears slightly lifted from the page.

Summary

The JColor widget provides developers with a color selection control that is both comprehensive and flexible. Designed to handle a wide variety of needs, it presents itself as an alternative to the JColorPicker control provided with the JFC. More importantly, however, it provides a sophisticated design that satisfies advanced imaging software needs as easily as the needs of a novice user.

Listing 1

public interface ColorConstants
{
  public static final int RED = 1;
  public static final int GREEN = 2;
  public static final int BLUE = 3;
  public static final int HUE = 4;
  public static final int SATURATION = 5;
  public static final int BRIGHTNESS = 6;
}

Listing 2

public class ColorSliderImage extends SyntheticImage
  implements ColorConstants
{
  protected int style;
	
  public ColorSliderImage(int width, int height, int style)
  {
    super(width, height);
    this.style = style;
  }

  protected void computeRow(int y, int[] row)
  {
    for(int x = 0; x < width; x++)
    {
      if (style == RED)
      {
        int r = 255 -
          (int)(((float)y / (float)height) * 255);
        row[x] = makeColor(r, 0, 0);
      }
      if (style == GREEN)
      {
        int g = 255 -
          (int)(((float)y / (float)height) * 255);
        row[x] = makeColor(0, g, 0);
      }
      if (style == BLUE)
      {
        int b = 255 -
          (int)(((float)y / (float)height) * 255);
        row[x] = makeColor(0, 0, b);
      }

      if (style == HUE)
      {
        float h = 1f - (float)y / (float)height;
        row[x] = Color.HSBtoRGB(h, 1f, 1f);
      }
      if (style == SATURATION)
      {
        float s = 1f - (float)y / (float)height;
        row[x] = Color.HSBtoRGB(1f, s, 1f);
      }
      if (style == BRIGHTNESS)
      {
        float b = 1f - (float)y / (float)height;
        row[x] = Color.HSBtoRGB(1f, 1f, b);
      }
    }
  }

  private int makeColor(int r, int g, int b)
  {
    return (0xff << 24) | (r << 16) | (g << 8) | b;
  }
}

Listing 3

public class ColorMatrixImage extends SyntheticImage
  implements ColorConstants
{
  protected int style;
	
  public ColorMatrixImage(int width, int height, int style)
  {
    super(width, height);
    this.style = style;
  }

  protected void computeRow(int y, int[] row)
  {
    for(int x = 0; x < width; x++)
    {
      if (style == RED)
      {
        int g = (int)(((float)x / (float)width) * 255);
        int b = 255 - (int)(((float)y / (float)height) * 255);
        row[x] = makeColor(0, g, b);
      }
      if (style == GREEN)
      {
        int r = (int)(((float)x / (float)width) * 255);
        int b = 255 - (int)(((float)y / (float)height) * 255);
        row[x] = makeColor(r, 0, b);
      }
      if (style == BLUE)
      {
        int r = (int)(((float)x / (float)width) * 255);
        int g = 255 - (int)(((float)y / (float)height) * 255);
        row[x] = makeColor(r, g, 0);
      }
      
      if (style == HUE)
      {
        float s = (float)x / (float)width;
        float b = 1f - (float)y / (float)height;
        row[x] = Color.HSBtoRGB(1f, s, b);
      }
      if (style == SATURATION)
      {
        float h = (float)x / (float)width;
        float b = 1f - (float)y / (float)height;
        row[x] = Color.HSBtoRGB(h, 1f, b);
      }
      if (style == BRIGHTNESS)
      {
        float h = (float)x / (float)width;
        float s = 1f - (float)y / (float)height;
        row[x] = Color.HSBtoRGB(h, s, 1f);
      }
    }
  }

  private int makeColor(int r, int g, int b)
  {
    return (0xff << 24) | (r << 16) | (g << 8) | b;
  }
}

Listing 4

public abstract class AbstractColorSelector extends JComponent
  implements MouseListener, KeyListener,
    FocusListener, ColorConstants
{
  protected Vector listeners = new Vector();
  protected ImageIcon image = null;
  protected Dimension size;
  protected int style;
	
  public void addActionListener(ActionListener listener)
  {
    listeners.addElement(listener);
  }

  public void removeActionListener(ActionListener listener)
  {
    listeners.removeElement(listener);
  }

  public void fireActionEvent()
  {
    Vector list = (Vector)listeners.clone();
    ActionEvent event = new ActionEvent(this,
      ActionEvent.ACTION_PERFORMED, "Action");
    ActionListener listener;
    for (int i = 0; i < list.size(); i++)
    {
      listener = (ActionListener)list.elementAt(i);
      listener.actionPerformed(event);
    }
  }

  public boolean isFocusTraversable()
  {
    return true;
  }
	
  public void mouseClicked(MouseEvent event) {}
  public void mouseReleased(MouseEvent event) {}
  public void mouseEntered(MouseEvent event) {}
  public void mouseExited(MouseEvent event) {}

  public void keyTyped(KeyEvent event) {}
  public void keyReleased(KeyEvent event) {}

  public void focusGained(FocusEvent event) {}
  public void focusLost(FocusEvent event) {}
}

Listing 5

public class JColorSlider extends AbstractColorSelector
{
  protected int y = 127 + 5;
  protected boolean hasFocus = false;

  public JColorSlider(int style)
  {
    setStyle(style);
    addKeyListener(this);
    addMouseListener(this);
    addFocusListener(this);
  }
	
  public void setStyle(int style)
  {
    this.style = style;
    image = null;
    repaint();
  }	
	
  public Dimension getPreferredSize()
  {
    return new Dimension(30, 266);
  }
	
  public int getYValue()
  {
    return 255 - (int)(((float)(y - 5) /
      (float)(getSize().height - 10)) * 256);
  }
	
  public void setYValue(int value)
  {
    y = 5 + (int)((getSize().height - 10) *
      ((float)(255 - value) / 256f));
    repaint();
  }
	
  public void paintComponent(Graphics g)
  {
    if (image == null || !size.equals(getSize()))
    {
      size = getSize();
      int w = size.width - 10;
      int h = size.height - 10;
      image = new ImageIcon(createImage(
        new ColorSliderImage(w, h, style)));
    }
    g.drawImage(image.getImage(), 5, 5, this);
    drawFocus(g);
    g.setColor(getForeground());
    drawLeftArrow(g, y);
    drawRightArrow(g, y);
    drawCrosshair(g, getSize().width / 2, y);
  }
	
  protected void drawFocus(Graphics g)
  {
    if (hasFocus)
    {
      int w = getSize().width - 5;
      int h = getSize().height - 5;
			
      g.setColor(getBackground().darker());
      g.drawLine(w, 4, w, h);
      g.drawLine(4, h, w, h);
      g.setColor(getBackground().brighter());
      g.drawLine(4, 4, w, 4);
      g.drawLine(4, 4, 4, h);
    }
  }

  protected void drawCrosshair(Graphics g, int x, int y)
  {
    g.setColor(Color.black);
    g.drawLine(x - 3, y, x - 7, y);
    g.drawLine(x + 3, y, x + 7, y);

    g.setColor(Color.white);
    g.drawLine(x - 3, y - 1, x - 7, y - 1);
    g.drawLine(x - 3, y + 1, x - 7, y + 1);
    g.drawLine(x + 3, y - 1, x + 7, y - 1);
    g.drawLine(x + 3, y + 1, x + 7, y + 1);
  }
	
  protected void drawLeftArrow(Graphics g, int y)
  {
    int w = 5;
    int h = 5;
    int l = 5;
    Polygon arrow = new Polygon();
    arrow.addPoint(l, y);
    arrow.addPoint(l - w, y - h);
    arrow.addPoint(l - w, y + h);
    arrow.addPoint(l, y);
    g.fillPolygon(arrow);
  }
	
  protected void drawRightArrow(Graphics g, int y)
  {
    int w = 5;
    int h = 5;
    int r = getSize().width - 5;
    Polygon arrow = new Polygon();
    arrow.addPoint(r, y);
    arrow.addPoint(r + w, y - h);
    arrow.addPoint(r + w, y + h);
    arrow.addPoint(r, y);
    g.fillPolygon(arrow);
  }
	
  public void mousePressed(MouseEvent event)
  {
    requestFocus();
    int yy = event.getY();
    if (yy < 5 || yy >= getSize().height - 5) return;
      y = yy;
    repaint();
    fireActionEvent();
  }

  public void keyPressed(KeyEvent event)
  {
    int code = event.getKeyCode();
    if (code == KeyEvent.VK_UP & y > 5) y--;
    if (code == KeyEvent.VK_DOWN &
      y < getSize().height - 6) y++;
    repaint();
    fireActionEvent();
  }

  public void focusGained(FocusEvent event)
  {
    hasFocus = true;
    repaint();
  }
	
  public void focusLost(FocusEvent event)
  {
    hasFocus = false;
    repaint();
  }
}

Listing 6

public class JColorMatrix extends JColorSlider
{
  protected int x = 128 + 5;

  public JColorMatrix(int style)
  {
    super(style);
  }

  public Dimension getPreferredSize()
  {
    return new Dimension(266, 266);
  }
	
  public int getXValue()
  {
    int value = (int)(((float)(x - 5) /
      (float)(getSize().width - 10)) * 256);
    return value;
  }
	
  public void setXValue(int value)
  {
    x = 5 + (int)((getSize().width - 10) *
      ((float)value / 256f));
    repaint();
  }
	
  public void paintComponent(Graphics g)
  {
    if (image == null || !size.equals(getSize()))
    {
      size = getSize();
      int w = size.width - 10;
      int h = size.height - 10;
      image = new ImageIcon(createImage(
        new ColorMatrixImage(w, h, style)));
    }
    g.drawImage(image.getImage(), 5, 5, this);
    drawFocus(g);
    g.setColor(getForeground());
    drawLeftArrow(g, y);
    drawRightArrow(g, y);
    drawTopArrow(g, x);
    drawBottomArrow(g, x);
    drawCrosshair(g, x, y);
  }

  protected void drawCrosshair(Graphics g, int x, int y)
  {
    g.setColor(Color.black);
    g.drawLine(x, y - 3, x, y - 6);
    g.drawLine(x, y + 3, x, y + 6);
    g.drawLine(x - 3, y, x - 6, y);
    g.drawLine(x + 3, y, x + 6, y);

    g.setColor(Color.white);
    g.drawLine(x - 1, y - 3, x - 1, y - 6);
    g.drawLine(x + 1, y - 3, x + 1, y - 6);
    g.drawLine(x - 1, y + 3, x - 1, y + 6);
    g.drawLine(x + 1, y + 3, x + 1, y + 6);
    g.drawLine(x - 3, y - 1, x - 6, y - 1);
    g.drawLine(x - 3, y + 1, x - 6, y + 1);
    g.drawLine(x + 3, y - 1, x + 6, y - 1);
    g.drawLine(x + 3, y + 1, x + 6, y + 1);
  }
	
  protected void drawTopArrow(Graphics g, int x)
  {
    int w = 5;
    int h = 5;
    int t = 5;
    Polygon arrow = new Polygon();
    arrow.addPoint(x, t);
    arrow.addPoint(x - w, t - h);
    arrow.addPoint(x + w, t - h);
    arrow.addPoint(x, t);
    g.fillPolygon(arrow);
  }
	
  public void drawBottomArrow(Graphics g, int y)
  {
    int w = 5;
    int h = 5;
    int b = getSize().height - 5;
    Polygon arrow = new Polygon();
    arrow.addPoint(x, b);
    arrow.addPoint(x - w, b + h);
    arrow.addPoint(x + w, b + h);
    arrow.addPoint(x, b);
    g.fillPolygon(arrow);
  }

  public void mousePressed(MouseEvent event)
  {
    requestFocus();
    int xx = event.getX();
    int yy = event.getY();
    if (yy < 5 || yy >= getSize().height - 5) return;
    if (xx < 5 || xx >= getSize().width - 5) return;
    x = xx;
    y = yy;
    repaint();
    fireActionEvent();
  }

  public void keyPressed(KeyEvent event)
  {
    int code = event.getKeyCode();
    if (code == KeyEvent.VK_UP & y > 5) y--;
    if (code == KeyEvent.VK_LEFT & x > 5) x--;
    if (code == KeyEvent.VK_DOWN &
      y < getSize().height - 6) y++;
    if (code == KeyEvent.VK_RIGHT &
      x < getSize().width - 6) x++;
    repaint();
    fireActionEvent();
  }
}

Listing 7

public void actionPerformed(ActionEvent event)
{
  Object source = event.getSource();
			
  if (source == rRadioRGB) style =  RED;
  if (source == gRadioRGB) style =  GREEN;
  if (source == bRadioRGB) style =  BLUE;
  if (source == hRadioHSB) style =  HUE;
  if (source == sRadioHSB) style =  SATURATION;
  if (source == bRadioHSB) style =  BRIGHTNESS;
  if (source instanceof JRadioButton)
  {
    slider.setStyle(style);
    matrix.setStyle(style);
  }
		
  if (source == rFieldRGB)
  {
    if (!(rRadioRGB.isSelected() ||
          gRadioRGB.isSelected() ||
          bRadioRGB.isSelected()))
    {
      rRadioRGB.setSelected(true);
      style = RED;
      slider.setStyle(style);
      matrix.setStyle(style);
    }
    if (style == RED)
      slider.setYValue(fieldValue(rFieldRGB));
    if (style == GREEN)
      matrix.setXValue(fieldValue(rFieldRGB));
    if (style == BLUE)
      matrix.setXValue(fieldValue(rFieldRGB));
  }

  // Radio button handlers omited

  setColorText();
  swatch.repaint();
}

Listing 8

public class JColorTest extends WindowAdapter
{
  static JColor jcolor;
	
  public void windowClosing(WindowEvent event)
  {
    System.out.println(jcolor.getColor().toString());
    System.exit(0);
  }
	
  public static void main(String[] args)
  {
    PLAF.setNativeLookAndFeel(true);
    jcolor = new JColor();
    JFrame frame = new JFrame("JColor Test");
    frame.getContentPane().add("Center", jcolor);
    frame.addWindowListener(new JColorTest());
    frame.pack();
    frame.show();
    jcolor.setColor(Color.green);
  }
}