ORIGINAL DRAFT

It’s ironic how sometimes the simplest ideas can turn out to be the most development-intensive. This month’s Widget Factory participant is the seemingly modest JSpinner control, which lets you constrain user interface selections by using arrow buttons or up/down key strokes to increment or decrement values, typically in a field. JSpinner comes with a whole family of siblings to handle numbers, currency, percentage, date, time, lists, and custom values. It supports multiple field elements, custom renderers and a compound model to make it all possible.

Name Description
JSpinner Provides a pair of up and down arrow buttons and operates on a SpinModel to increment or decrement values. It handles keyboard events as well. It's up to other components to watch the model and update their views when they receive a ChangeEvent.
JSpinnerField A basic numerical spinner which uses a DefaultSpinRenderer and DefaultSpinModel to manage a single range of values. A SpinModel may contain more than one SpinRangeModel, but the JSpinnerField only requires one. This is the base class for all the other family members. It takes responsibility for certain mouse events, listening for model changes and focus handling.
JSpinnerPercent Percentage spinner, uses the NumberFormat.getPercentInstance to format, a locale-dependent percentage field.
JSpinnerCurrency Currency spinner, uses the NumberFormat.getCurrencyInstance to format, a locale-dependent currency field. This is a compound field, which supports incrementing and decrementing the integer and decimal values independently of each other.
JSpinnerTime Time spinner, uses the DateFormat.getTimeInsance to format, a locale-dependent time field. This implementation uses a SpinTimeModel to map the Calendar object onto a SpinModel. It is a compound field with three elements.
JSpinnerDate Date spinner, uses the DateFormat.getDateInsance to format, a locale-dependent date field. This implementation uses a SpinDateModel to map the Calendar object onto a SpinModel. It is a compound field with three elements.
JSpinnerList String list spinner, uses a ChoiceFormat to format a list selection. This is a simple field, useful in handling small lists of selected options.
JSpinnerColor A Color selection spinner example of using a custom SpinRenderer.
Table 1: The JSpinner family of classes

Table 1 shows the various spinner controls we’ll be implementing. JSpinner and JSpinnerField are the basic classes. The others stand as good examples of what can be done with a well designed premise. You’ll rarely tend to use JSpinner directly, usually reaching for JSpinnerField or one of its subclasses to do specific work. Figure 1 shows the JSpinner family at work.

Figure 1: JSpinnerTest with US Locale Selected.

Figure 1: JSpinnerTest with US Locale Selected.

Its worth noting that this installment of the Widget Factory has a large number of listings, but most of the classes involve only a small amount of code. The main classes, like JSpinnerField, DefaultSpinModel, DefaultSpinRangeModel and DefaultRenderer do most of the work. The SpinTimeModel and SpinDateModel are relatively uncomplicated, for example, as are various JSpinnerField extensions. Several of the listings are merely interfaces that maintain flexibility in our design, supporting reuse and extensibility.

Achitecture

The JSpinner architecture uses several interfaces to maximize flexibility. The SpinModel interface defines the methods required to access the model, which represents values in the JSpinner architecture. The SpinModel contains one or more instances of a class that implements the SpinRangeModel. Listing 1 shows the SpinModel interface. The SpinModel contains ranges which can be accessed by a field ID. These identifiers map directly onto the field identifiers provided by the text Format classes in the Java API. The SpinModel always has an active field and can get and set a list of field ID’s. This is important when you need switch between locales because the subfield order is not always the same. We also support the ChangeListener interface so that views can be updated when the model changes.

The SpinRangeModel is very much like the BoundedRangeModel provided by the Swing API, but it supports the use of decimal values. Listing 2 shows how a SpinRangeModel allows you to set and get the currently selected value, a minimum and maximum value, an increment (extent), and whether the model wraps or not when it hits a boundary. In contrast, the BoundedRangeModel does not permit the maximum value to ever be selected and is restricted to integer values. It also knows nothing about wrapping values, so it was necessary to invent a new model for the JSpinner controls. The BoundedRangeModel also supports the ChangeListener interface, which is used by the SpinModel to detect changes.

The SpinRenderer interface is designed to support custom renderers in JSpinner controls. The only required method is called getSpinCellRendererComponent and returns the rendering component. Listing 3 shows the SpinRenderer interface. We expect a reference to a JSpinnerField, the current value object, a flag indicating whether we have the focus, a Format instance and the field identifier for the currently selected field. We’ll cover this in more detail when we implement the DefaultSpinRenderer.

Figure 2 shows the basic relationship between JSpinner, JSpinnerField and the simplest model configuration.

Figure 2: JSpinner, JSpinnerField and Model Relationships.

Figure 2: JSpinner, JSpinnerField and Model Relationships.

The JSpinnerField class is the parent of all the other widgets implemented in this article. The JSpinner class handles the buttons and up/down activity. It operates directly on the model and can be used for other purposes requiring up/down activities. That’s why it was given the big ‘J’ prefix. You can create an arbitrary SpinModel if you like, regardless if whether you use a view to watch the results. Notice that change events from the SpinRangeModel are sent to the SpinModel. This happens automatically and you can watch for the SpinModel events comfortable in the certainty that you’ll never miss any other change events.

Modeling

The SpinModel and SpinRangeModel interface need concrete implementations to provide the functionality they expose. The DefaultSpinModel and DefaultSpinRangeModel provide a generic set of capabilities which most of the controls in this article use. Listing 4 shows the DefaultSpinModel class. The internal list of SpinRangeModel instances is maintained by a Hashtable which is access by a fieldID Integer. A separate, ordered list of fieldIDs is held in a Vector object, as are the registered change listeners. We also keep an activeField value, indicating the currently active SpinRangeModel.

To make life easier, we expose three constructors. The first simply creates an empty model. the second assumes we will use a single SpinRangeModel and takes the same set of arguments, creating the SpinRangeModel automatically. The third constructor assumes we plan to use two SpinRangeModels and does the same thing, automatically creating both for us. Any more than two subfields would make the constructor too complicated, so we assume its just as easy to add fields outside the constructor.

Listing 5 shows the DefaultSpinRangeModel class. The basics are pretty simple, with the constructor accepting each of the arguments and get/set accessors provided for each of the attributes. Worth noting, however, is that the stateChange event is not fired unless the setValueIsAdjusting method is called with a false argument. This is intended to defer the change event to avoid inconsistent states. My implementation is less robust than the Swing BoundedRangeModel but it works well enough in practice.

The JSpinner class is provided in Listing 6. The constructor expects a SpinModel instance and creates the up and down buttons using the Swing BasicArrowButton class. We register JSpinner as both an ActionListener and KeyListener to handle increment and decrement operations on the model. Most of the code that acts on the model is in the increment and decrement methods which handle boundary conditions, deciding whether to wrap or now. The JSpinner class also handles right and left arrow key strokes and changes the active field in the SpinModel. To make this work, you have to register JSpinner as a KeyListener form elsewhere, since the buttons never really get the focus.

JSpinnerField

The JSpinnerField, see Listing 7, is the simplest instance of the JSpinner family of controls, but because it is the parent of all the other family members, it is designed to handle general circumstaces. As such it contains more code than most of the other classes in this article. There are three constructors to let us create an empty JSpinnerField, one with a single SpinRangeModel or one with an arbitrary model, renderer and field Format class. Because we need to referesh the view after construction, we delegate subcomponent creation to an init method. This allows subclasses to control the call to refreshSpinView, which tends to be specific to selected implementations.

The setLocale method sets a localized instance of the Format class associated with the control. This is also overriden by child implementations. The updateFieldOrder method is required to determine what the correct field order is for a given locale. This is handled in a separate utility class called LocaleUtil, Listing 8, which effectively sorts the fields based on their starting location in the Format object. It also implements a findMouseInField method that lets us determine which field is active when the mouse is clicked on a JSpinnerField.

The JSpinnerField does the rendering through a SpinField class which expects a reference to the JSpinnerField object. As you can see in Listing 9, this class extends JPanel and uses a Swing CellRendererPane to do the actual rendering. The paintComponent call gets the current SpinRenderer and calls its getSpinCellRendererComponent method. We also override the getPrefferedSize and getMinimumSize to return the renderer’s preferred and minimum sizes.

Listing 10 shows the DefaultSpinRenderer, which extends JTextField and sets the editable flag to false. The getSpinCellRendererComponent method returns the current instance after calling setText with the current value object formated by the Format instance. It also uses the LocaleUtil.getFieldPosition method to determine what the current selection range is for display if we have the focus.

Simple Extensions

Having just covered the DefaultSpinRenderer, lets take a look at the JSpinnerColor class, which uses a custom renderer to spin through a short set of colors. The ColorSpinRender, shown in Listing 11, is pretty uncomplicated, it ignores most of the getSpinCellRendererComponent arguments and expects to see a Color object as the value. We use a white border to indicate the focus.

Listing 12 shows how simple the JSpinnerColor class is to implement. We extent the JSpinnerField class with a custom spin renderer and a simple model that ranges from zero to the length of our list. We store our list of Color object in a Vector for convenience. To avoid formating issues we declare an empty updateFieldOrder method. We overide the refreshSpinView to update the view when selection changes. All we have to do is get the active model field and range, and set the current list selection based on the active model value. Calling setValue on the spinField gives the renderer access to the active object.

The JSpinnerList class, Listing 13, does the same kind of thing without the extra complication of a custom renderer, given that it handles a string list. It overrides the setLocalle method because explicit strings are not localizable. The JSpinnerList and JSpinnerColor widgets demonstrate how easy it is to subclass JSpinnerField to create customized behavior. There is no restriction in the data you choose to represent, since the both the model and renderer are under your control. Of course, these implementations are not internationalizable unless you uses resource bundles or account for it directly in your code.

Internationalizable Spinners

The last four variations on our theme capitalize on the JSpinnerField infrastructure to handle internationalization through the Java Format class. Listing 14 shows the JSpinnerPercent class, which extends JSpinnerField and implements very little code. The constructor creates a model that uses 0.01 as an increment and sets NumberFormat.getPercentInstance() as the format class. We override setLocalle to reset the Format class if necessary.

Listing 15 shows the JSpinnerCurrency control, which is very similar but extends the model to use two ranges. One handles the integer portion of our currency field and the other handles the decimal value. We set the NumberFormat.getCurrencyInstance() format in both the constructor and the setLocale method. The only other thing we need to do is override the refreshSpinView method to properly set the value from the two model ranges. Figure 3 shows the relationship between classes in the JSpinnerCurrency control.

Figure 3: JSpinner, JSpinnerCurrency and Model Relationships.

Figure 3: JSpinner, JSpinnerCurrency and Model Relationships.

The last two widget variations are only slightly more complicated because they use custom models. Listings 16 and 17 show the TimeSpinModel and DateSpinModel, respectively. Both of them extend DefaultSpinModel but override the setRange and getRange methods to control where the values come from. They map the Calendar class values onto the range models as they are being retrieved and manage the Calendar instance that represents the time or date with a couple of accessor methods. You can do something similar to manage your own variations on a SpinModel.

Figure 4: JSpinnerTest with the French Locale.

Figure 4: JSpinnerTest with the French Locale.

The JSpinnerTime and JSpinnerDate classes are in Listings 18 and 19, respectively. They both override the constructor, setLocale and refreshSpinView methods, but are otherwise unencumbered. Listing 20 shows the JSpinnerTest harness used to test the controls. Figure 4 shows the way it looks when the French locale is selected.

Summary

As you’ve see, despite all the listings, JSpinner controls are actually quite simple to use. By providing customizable model and renderer interfaces, the variations you can implement are wide open, as it should be with any open architecture. All of the internationalization issues are essentially transparent, thanks to the Format classes provided in the Java API. It would have been easy to write this article around a simpler implementation, but the strength of these widgets is larely in their customizability and the lessons learned from effective design. I hope you’ll agree that even this simple widget turned out to be instructive and worth the investment.

Listing 1

public interface SpinModel
{
  public int getFieldCount();
  public int getActiveField();
  public void setActiveField(int fieldID);
  public void setNextField();
  public void setPrevField();
  public int[] getFieldIDs();
  public void setFieldIDs(int[] fields);
  public void setRange(int fieldID, SpinRangeModel range);
  public SpinRangeModel getRange(int fieldID);
  public void addChangeListener(ChangeListener listener);
  public void removeChangeListener(ChangeListener listener);
}

Listing 2

public interface SpinRangeModel
{
  public double getValue();
  public double getExtent();
  public double getMinimum();
  public double getMaximum();
  public boolean getWrap();
  public void setValue(double value);
  public void setExtent(double extent);
  public void setMinimum(double min);
  public void setMaximum(double max);
  public void setWrap(boolean wrap);
  public void setValueIsAdjusting(boolean adusting);
  public boolean getValueIsAdjusting();
  public void addChangeListener(ChangeListener listener);
  public void removeChangeListener(ChangeListener listener);
}

Listing 3

public interface SpinRenderer
{
  public Component getSpinCellRendererComponent(
    JSpinnerField spin, Object value, boolean hasFocus,
    Format formatter, int selectedFieldID);
}

Listing 4

public class DefaultSpinModel
  implements SpinModel, ChangeListener
{
  protected int activeField;
  protected Vector fieldIDs = new Vector();
  protected Hashtable table = new Hashtable();
  protected Vector listeners = new Vector();
  
  public DefaultSpinModel() {}
  
  public DefaultSpinModel(double value,
    double extent, double min, double max, boolean wrap)
  {
    SpinRangeModel model = new DefaultSpinRangeModel(
      value, extent, min, max, wrap);
    setRange(NumberFormat.INTEGER_FIELD, model);
    setActiveField(NumberFormat.INTEGER_FIELD);
  }
  
  public DefaultSpinModel(
    double iValue, double iExtent, double iMin, double iMax, boolean iWrap,
    double fValue, double fExtent, double fMin, double fMax, boolean fWrap)
  {
    SpinRangeModel iModel = new DefaultSpinRangeModel(
      iValue, iExtent, iMin, iMax, iWrap);
    setRange(NumberFormat.INTEGER_FIELD, iModel);
      
    SpinRangeModel fModel = new DefaultSpinRangeModel(
      fValue, fExtent, fMin, fMax, fWrap);
    setRange(NumberFormat.FRACTION_FIELD, fModel);
    
    setActiveField(NumberFormat.INTEGER_FIELD);
  }
  
  public int getFieldCount()
  {
    return fieldIDs.size();
  }
  
  public int getActiveField()
  {
    return activeField;
  }

  public void setActiveField(int fieldID)
  {
    activeField = fieldID;
    fireStateChanged();
  }

  public void setNextField()
  {
    int[] array = getFieldIDs();
    for (int i = 0; i < array.length; i++)
    {
      if (array[i] == activeField)// && i != array.length - 1)
      {
        setActiveField(array[Math.min(i + 1, array.length - 1)]);
        return;
      }
    }
  }
  
  public void setPrevField()
  {
    int[] array = getFieldIDs();
    for (int i = 0; i < array.length; i++)
    {
      if (array[i] == activeField)
      {
        setActiveField(array[Math.max(i - 1, 0)]);
        return;
      }
    }
  }
  
  public void setRange(int id, SpinRangeModel range)
  {
    Integer key = new Integer(id);
    if (!table.containsKey(key))
    {
      fieldIDs.addElement(key);
      range.addChangeListener(this);
    }
    table.put(key, range);
  }
  
  public SpinRangeModel getRange(int id)
  {
    Integer key = new Integer(id);
    return (SpinRangeModel)table.get(key);
  }

  public int[] getFieldIDs()
  {
    int size = fieldIDs.size();
    int[] array = new int[size];
    for (int i = 0; i < size; i++)
    {
      array[i] = ((Integer)fieldIDs.elementAt(i)).intValue();
    }
    return array;
  }

  public void setFieldIDs(int[] fields)
  {
    fieldIDs.removeAllElements();
    for (int i = 0; i < fields.length; i++)
    {
      fieldIDs.addElement(new Integer(fields[i]));
    }
  }
  
  public void stateChanged(ChangeEvent event)
  {
    fireStateChanged();
  }
  
  public void addChangeListener(ChangeListener listener)
  {
    listeners.addElement(listener);
  }
    
  public void removeChangeListener(ChangeListener listener)
  {
    listeners.removeElement(listener);
  }

  public void fireStateChanged()
  {
    ChangeListener listener;
    Vector list = (Vector)listeners.clone();
    ChangeEvent event = new ChangeEvent(this);
    for (int i = 0; i < list.size(); i++)
    {
      listener = ((ChangeListener)list.elementAt(i));
      listener.stateChanged(event);
    }
  }
}

Listing 5

public class DefaultSpinRangeModel
  implements SpinRangeModel
{
    protected Vector listeners = new Vector();

    private double value = 0;
    private double extent = 1;
    private double min = 0;
    private double max = 100;
    private boolean wrap = true;
    private boolean isAdjusting = false;

  public DefaultSpinRangeModel() {}

  public DefaultSpinRangeModel(double value, double extent,
    double min, double max, boolean wrap)
  {
    this.value = value;
    this.extent = extent;
    this.min = min;
    this.max = max;
  }

  public double getValue()
  {
    return value; 
  }

  public double getExtent()
  {
    return extent; 
  }

  public double getMinimum()
  {
    return min; 
  }

  public double getMaximum()
  {
    return max; 
  }

  public boolean getWrap()
  {
    return wrap; 
  }

  public void setValue(double value)
  {
    this.value = value;
  }

  public void setExtent(double extent)
  {
    this.extent = extent;
  }

  public void setMinimum(double min)
  {
    this.min = min;
  }

  public void setMaximum(double max)
  {
    this.max = max;
  }

  public void setWrap(boolean wrap)
  {
    this.wrap = wrap;
  }

  public void setValueIsAdjusting(boolean isAdusting)
  {
    this.isAdjusting = isAdjusting;
    if (!isAdjusting) fireStateChanged();
  }

  public boolean getValueIsAdjusting()
  {
    return isAdjusting; 
  }

  public void addChangeListener(ChangeListener listener)
  {
    listeners.addElement(listener);
  }
    
  public void removeChangeListener(ChangeListener listener)
  {
    listeners.removeElement(listener);
  }

  public void fireStateChanged()
  {
    ChangeListener listener;
    Vector list = (Vector)listeners.clone();
    ChangeEvent event = new ChangeEvent(this);
    for (int i = 0; i < list.size(); i++)
    {
      listener = ((ChangeListener)list.elementAt(i));
      listener.stateChanged(event);
    }
  }
  
  public String toString()
  {
    String modelString =
      "value=" + getValue() + ", " +
      "extent=" + getExtent() + ", " +
      "min=" + getMinimum() + ", " +
      "max=" + getMaximum() + ", " +
      "adj=" + getValueIsAdjusting();
    return getClass().getName() + "[" + modelString + "]";
  }
}

Listing 6

public class JSpinner extends JPanel
  implements ActionListener, KeyListener, SwingConstants
{
  protected Vector listeners = new Vector();
  protected BasicArrowButton north, south;
  SpinModel model;

  public JSpinner(SpinModel model)
  {
    this.model = model;
    setLayout(new GridLayout(2, 1));
    setPreferredSize(new Dimension(16, 16));

    north = new BasicArrowButton(BasicArrowButton.NORTH);
    north.addActionListener(this);
    add(north);

    south = new BasicArrowButton(BasicArrowButton.SOUTH);
    south.addActionListener(this);
    add(south);
  }

  public void actionPerformed(ActionEvent event)
  {
    if (event.getSource() == north)
    {
      increment();
    }
    if (event.getSource() == south)
    {
      decrement();
    }
  }

  public void keyTyped(KeyEvent event) {}
  public void keyReleased(KeyEvent event) {}
  public void keyPressed(KeyEvent event)
  {
    int code = event.getKeyCode();
    if (code == KeyEvent.VK_UP)
    {
      increment();
    }
    if (code == KeyEvent.VK_DOWN)
    {
      decrement();
    }
    if (code == KeyEvent.VK_LEFT)
    {
      model.setPrevField();
    }
    if (code == KeyEvent.VK_RIGHT)
    {
      model.setNextField();
    }
  }

  protected void increment()
  {
    int fieldID = model.getActiveField();
    SpinRangeModel range = model.getRange(fieldID);
    range.setValueIsAdjusting(true);
    double value = range.getValue() + range.getExtent();
    if (value > range.getMaximum())
      value = range.getWrap() ?
        range.getMinimum() : range.getMaximum();
    range.setValue(value);
    model.setRange(fieldID, range);
    range.setValueIsAdjusting(false);
  }
  
  public void decrement()
  {
    int fieldID = model.getActiveField();
    SpinRangeModel range = model.getRange(fieldID);
    range.setValueIsAdjusting(true);
    double value = range.getValue() - range.getExtent();
    if (value < range.getMinimum())
      value = range.getWrap() ?
        range.getMaximum() : range.getMinimum();
    range.setValue(value);
    model.setRange(fieldID, range);
    range.setValueIsAdjusting(false);
  }
}

Listing 7

public class JSpinnerField extends JPanel
  implements ChangeListener, MouseListener, FocusListener
{
  protected SpinModel model;
  protected SpinField spinField;
  protected SpinRenderer renderer;
  protected Format formatter;
  protected boolean wrap = true;
  protected boolean hasFocus = false;
  
  public JSpinnerField() {}
  
  public JSpinnerField(int value,
    int extent, int min, int max, boolean wrap)
  {
    init(new DefaultSpinModel(value, extent, min, max, wrap),
      new DefaultSpinRenderer(),
      NumberFormat.getInstance(), wrap);
    refreshSpinView();
  }
  
  public JSpinnerField(SpinModel model,
    SpinRenderer renderer, Format formatter,
    boolean wrap)
  {
    init(model, renderer, formatter, wrap);
    refreshSpinView();
  }
  
  protected void init(SpinModel model,
    SpinRenderer renderer, Format formatter,
    boolean wrap)
  {
    this.model = model;
    this.renderer = renderer;
    this.formatter = formatter;
    this.wrap = wrap;
    spinField = new SpinField(this);
    setLayout(new BorderLayout());
    add(BorderLayout.CENTER, spinField);
    setBorder(spinField.getBorder());
    spinField.setBorder(null);
    JSpinner spinner = new JSpinner(model);
    addKeyListener(spinner);
    addMouseListener(this);
    addFocusListener(this);
    model.addChangeListener(this);
    add(BorderLayout.EAST, spinner);
  }
    
  public void setLocale(Locale locale)
  {
    formatter = NumberFormat.getInstance(locale);
    updateFieldOrder();
  }
  
  public void updateFieldOrder()
  {
    if (spinField.getValue() == null) return;
    int[] fieldIDs = model.getFieldIDs();
    LocaleUtil.sortFieldOrder(formatter, spinField.getValue(), fieldIDs);
    model.setFieldIDs(fieldIDs);
  }
  
  public SpinRenderer getRenderer()
  {
    return renderer;
  }
  
  protected void refreshSpinView()
  {
    int fieldID = model.getActiveField();
    SpinRangeModel range = model.getRange(fieldID);
    spinField.setValue(new Double(range.getValue()));
  }

  public void stateChanged(ChangeEvent event)
  {
    requestFocus();
    refreshSpinView();
    repaint();
  }
  
  public void mouseClicked(MouseEvent event)
  {
    int fieldID = LocaleUtil.findMouseInField(
      getGraphics().getFontMetrics(), event.getX(),
      formatter, spinField.getValue(), model.getFieldIDs());
    model.setActiveField(fieldID);
    requestFocus();
    refreshSpinView();
  }
  public void mousePressed(MouseEvent event) {}
  public void mouseReleased(MouseEvent event) {}
  public void mouseEntered(MouseEvent event) {}
  public void mouseExited(MouseEvent event) {}
  
  public void focusGained(FocusEvent event)
  {
    hasFocus = true;
    repaint();
  }
  
  public void focusLost(FocusEvent event)
  {
    hasFocus = false;
    repaint();
  }
  
  public boolean isFocusTraversable()
  {
    return true;
  }
}

Listing 8

public class LocaleUtil
{
  public static void sortFieldOrder(
    Format formatter, Object obj, int[] fieldIDs)
  {
    int size = fieldIDs.length;
    int[] order = new int[size];
    for (int i = 0; i < size; i++)
    {
      order[i] = getFieldPosition(
        formatter, obj, fieldIDs[i]).getBeginIndex();
    }
    sort(fieldIDs, order);
  }

  public static int findMouseInField(FontMetrics metrics, int x,
    Format formatter, Object obj, int[] fieldIDs)
  {
    String text = formatter.format(obj);
    int size = fieldIDs.length;
    FieldPosition pos;
    for (int i = 0; i < size; i++)
    {
      pos = getFieldPosition(formatter, obj, fieldIDs[i]);
      int left = metrics.stringWidth(
        text.substring(0, pos.getBeginIndex()));
      int right = metrics.stringWidth(
        text.substring(0, pos.getEndIndex()));
      if (x >= left && x <= right)
      {
        return fieldIDs[i];
      }
    }
    return fieldIDs[0];
  }

  public static FieldPosition getFieldPosition(
    Format formatter, Object obj, int field)
  {
    FieldPosition pos = new FieldPosition(field);
    StringBuffer buffer = new StringBuffer();
    formatter.format(obj, buffer, pos);
    return pos;
  }
  
  private static void sort(int[] fieldIDs, int[] order)
  {
    sort(fieldIDs, order, 0, fieldIDs.length - 1);
    }

  private static void sort(int[] fieldIDs, int[] order, int first, int last)
  {
    if (first >= last) return;
    int lo = first, hi = last;
    int mid = order[(first + last) / 2];
    int tmp, temp;
    do
    {
      while (mid > order[lo]) lo++;
      while (mid < order[hi]) hi--;
      if (lo <= hi)
      {
        tmp = order[lo];
        temp = fieldIDs[lo];
        order[lo] = order[hi];
        fieldIDs[lo] = fieldIDs[hi];
        lo++;
        order[hi] = tmp;
        fieldIDs[hi] = temp;
        hi--;
      }
    }
    while (lo <= hi) ;
    sort(fieldIDs, order, first, hi);
    sort(fieldIDs, order, lo, last);
  }

  public static void main(String[] args)
  {
    int[] order = {2, 8, 12, 6, 10, 4};
    int[] fieldIDs = {1, 4, 6, 3, 5, 2};
    sort(fieldIDs, order);
    for (int i = 0; i < fieldIDs.length; i++)
    {
      System.out.print(fieldIDs[i] + "; ");
    }
  }
}

Listing 9

public class SpinField extends JPanel
{
  protected CellRendererPane pane;
  protected JSpinnerField field;
  protected Object value;
  
  public SpinField(JSpinnerField field)
  {
    this.field = field;
    setLayout(new BorderLayout());
    add(BorderLayout.CENTER, pane = new CellRendererPane());
    JComponent renderer = (JComponent)field.getRenderer();
    setBorder(renderer.getBorder());
    renderer.setBorder(null);
  }
  
  public void setValue(Object value)
  {
    this.value = value;
    repaint();
  }
  
  public Object getValue()
  {
    return value;
  }
  
  public void paintComponent(Graphics g)
  {
    int w = getSize().width;
    int h = getSize().height;
    Component comp = field.getRenderer().
      getSpinCellRendererComponent(field, value,
        field.hasFocus, field.formatter,
        field.model.getActiveField());
    pane.paintComponent(g, comp, this, 0, 0, w, h);
  }

  public Dimension getPreferredSize()
  {
    return ((JComponent)field.getRenderer()).getPreferredSize();
  }

  public Dimension getMinimumSize()
  {
    return ((JComponent)field.getRenderer()).getMinimumSize();
  }
}

Listing 10

public class DefaultSpinRenderer extends JTextField
  implements SpinRenderer
{
  private static Color focusColor = new Color(0, 0, 128);
  
  public DefaultSpinRenderer()
  {
    setOpaque(true);
    setEditable(false);
  }
  
  public Component getSpinCellRendererComponent(
    JSpinnerField spin, Object value, boolean hasFocus,
    Format formatter, int selectedFieldID)
  {
    String text = formatter.format(value);
    setText(text);
    FieldPosition pos = LocaleUtil.getFieldPosition(
      formatter, value, selectedFieldID);
    // Make non-selections expand to full selections
    if (pos.getBeginIndex() == pos.getEndIndex())
    {
      pos.setBeginIndex(0);
      pos.setEndIndex(text.length());
    }
    if (hasFocus)
      select(pos.getBeginIndex(), pos.getEndIndex());
    else select(0, 0);
    return this;
  }
}

Listing 11

public class ColorSpinRenderer extends JTextField
  implements SpinRenderer
{
  private static Color focusColor = Color.white;
  private static Border focus = new LineBorder(focusColor, 1);

  public ColorSpinRenderer()
  {
    setOpaque(true);
    setEditable(false);
  }
  
  public Component getSpinCellRendererComponent(
    JSpinnerField spin, Object value, boolean hasFocus,
    Format formatter, int selectedFieldID)
  {
    if (value instanceof Color)
    {
      Color color = (Color)value;
      setBackground(color);
      if (hasFocus) setBorder(focus);
      else setBorder(null);
    }
    return this;
  }
}

Listing 12

public class JSpinnerColor extends JSpinnerField
{
  protected Vector list = new Vector();
  
  public JSpinnerColor(Color[] items, int index, boolean wrap)
  {
    super(
      new DefaultSpinModel(index, 1, 0, items.length - 1, wrap),
      new ColorSpinRenderer(), null, wrap);
    for (int i = 0; i < items.length; i++)
    {
      list.addElement(items[i]);
    }
    refreshSpinView();
  }
  
  protected void refreshSpinView()
  {
    if (list == null) return;
    int fieldID = model.getActiveField();
    SpinRangeModel range = model.getRange(fieldID);
    spinField.setValue(list.elementAt((int)range.getValue()));
  }
  
  public void updateFieldOrder() {}
}

Listing 13

public class JSpinnerList extends JSpinnerField
{
  public JSpinnerList(String[] items, int index, boolean wrap)
  {
    double[] limits = new double[items.length];
    for (int i = 0; i < items.length; i++)
    {
      limits[i] = i;
    }
    init(new DefaultSpinModel(index, 1, 0, items.length - 1, wrap),
      new DefaultSpinRenderer(),
      new ChoiceFormat(limits, items), wrap);
    refreshSpinView();
  }
  
  public void setLocale(Locale locale) {}
}

Listing 14

public class JSpinnerPercent extends JSpinnerField
{
  public JSpinnerPercent()
  {
    init(new DefaultSpinModel(0, 0.01, 0, 1, true),
      new DefaultSpinRenderer(),
      NumberFormat.getPercentInstance(),
      true);
    refreshSpinView();
  }

  public void setLocale(Locale locale)
  {
    formatter = NumberFormat.getPercentInstance(locale);
    updateFieldOrder();
  }
}

Listing 15

public class JSpinnerCurrency extends JSpinnerField
{
  public JSpinnerCurrency()
  {
    init(new DefaultSpinModel(
        0, 1, 0, 10, true,
        0, 0.01, 0, 1, true),
      new DefaultSpinRenderer(),
      NumberFormat.getCurrencyInstance(),
      true);
    refreshSpinView();
  }
  
  public void setLocale(Locale locale)
  {
    formatter = NumberFormat.getCurrencyInstance(locale);
    updateFieldOrder();
  }

  protected void refreshSpinView()
  {
    double integer = model.getRange(
      NumberFormat.INTEGER_FIELD).getValue();
    double fraction = model.getRange(
      NumberFormat.FRACTION_FIELD).getValue();
    spinField.setValue(new Double(integer + fraction));
  }
}

Listing 16

public class TimeSpinModel extends DefaultSpinModel
{
  protected Calendar time = Calendar.getInstance();

  public TimeSpinModel()
  {
    setRange(DateFormat.HOUR1_FIELD,
      new DefaultSpinRangeModel());
    setRange(DateFormat.MINUTE_FIELD,
      new DefaultSpinRangeModel());
    setRange(DateFormat.AM_PM_FIELD,
      new DefaultSpinRangeModel());
    setActiveField(DateFormat.HOUR1_FIELD);
    setTime(time);
  }

  public void setRange(int fieldID, SpinRangeModel range)
  {
    super.setRange(fieldID, range);
    if (fieldID == DateFormat.HOUR1_FIELD)
    {
      time.set(Calendar.HOUR, (int)range.getValue());
    }
    if (fieldID == DateFormat.MINUTE_FIELD)
    {
      time.set(Calendar.MINUTE, (int)range.getValue());
    }
    if (fieldID == DateFormat.AM_PM_FIELD)
    {
      time.set(Calendar.HOUR_OF_DAY,
        time.get(Calendar.HOUR) + 12 * (int)range.getValue());
    }
  }
  
  public SpinRangeModel getRange(int fieldID)
  {
    SpinRangeModel range = super.getRange(fieldID);
    if (fieldID == DateFormat.HOUR1_FIELD)
    {
      range.setExtent(1.0);
      range.setValue(time.get(Calendar.HOUR));
      range.setMinimum(time.getActualMinimum(Calendar.HOUR));
      range.setMaximum(time.getActualMaximum(Calendar.HOUR));
    }
    if (fieldID == DateFormat.MINUTE_FIELD)
    {
      range.setExtent(1.0);
      range.setValue(time.get(Calendar.MINUTE));
      range.setMinimum(time.getActualMinimum(Calendar.MINUTE));
      range.setMaximum(time.getActualMaximum(Calendar.MINUTE));
    }
    if (fieldID == DateFormat.AM_PM_FIELD)
    {
      range.setExtent(1.0);
      range.setValue(time.get(Calendar.AM_PM));
      range.setMinimum(time.getActualMinimum(Calendar.AM_PM));
      range.setMaximum(time.getActualMaximum(Calendar.AM_PM));
    }
    return range;
  }

  public void setTime(Calendar time)
  {
    this.time = time;
    getRange(DateFormat.HOUR1_FIELD);
    getRange(DateFormat.MINUTE_FIELD);
    getRange(DateFormat.AM_PM_FIELD);
    fireStateChanged();
  }
  
  public Calendar getTime()
  {
    return time;
  }

  public static void main(String[] args)
  {
    TimeSpinModel model = new TimeSpinModel();
    int activeField = model.getActiveField();
    System.out.println(model.getRange(activeField));
    model.setNextField();
    activeField = model.getActiveField();
    System.out.println(model.getRange(activeField));
    model.setNextField();
    activeField = model.getActiveField();
    System.out.println(model.getRange(activeField));
    model.setNextField();
    activeField = model.getActiveField();
    System.out.println(model.getRange(activeField));
  }
}

Listing 17

public class DateSpinModel extends DefaultSpinModel
{
  protected Calendar date = Calendar.getInstance();

  public DateSpinModel()
  {
    setRange(DateFormat.MONTH_FIELD,
      new DefaultSpinRangeModel());
    setRange(DateFormat.DATE_FIELD,
      new DefaultSpinRangeModel());
    setRange(DateFormat.YEAR_FIELD,
      new DefaultSpinRangeModel());
    setActiveField(DateFormat.MONTH_FIELD);
  }

  public void setRange(int fieldID, SpinRangeModel range)
  {
    super.setRange(fieldID, range);
    if (fieldID == DateFormat.DATE_FIELD)
    {
      date.set(Calendar.DATE, (int)range.getValue());
    }
    if (fieldID == DateFormat.MONTH_FIELD)
    {
      date.set(Calendar.MONTH, (int)range.getValue());
    }
    if (fieldID == DateFormat.YEAR_FIELD)
    {
      date.set(Calendar.YEAR, (int)range.getValue());
    }
  }
  
  public SpinRangeModel getRange(int fieldID)
  {
    SpinRangeModel range = super.getRange(fieldID);
    if (fieldID == DateFormat.DATE_FIELD)
    {
      range.setExtent(1);
      range.setValue(date.get(Calendar.DAY_OF_MONTH));
      range.setMinimum(date.getActualMinimum(Calendar.DAY_OF_MONTH));
      range.setMaximum(date.getActualMaximum(Calendar.DAY_OF_MONTH));
    }
    if (fieldID == DateFormat.MONTH_FIELD)
    {
      range.setExtent(1);
      range.setValue(date.get(Calendar.MONTH));
      range.setMinimum(date.getActualMinimum(Calendar.MONTH));
      range.setMaximum(date.getActualMaximum(Calendar.MONTH));
    }
    if (fieldID == DateFormat.YEAR_FIELD)
    {
      range.setExtent(1);
      range.setValue(date.get(Calendar.YEAR));
      range.setMinimum(date.getActualMinimum(Calendar.YEAR));
      range.setMaximum(date.getActualMaximum(Calendar.YEAR));
    }
    return range;
  }

  public void setDate(Calendar date)
  {
    this.date = date;
    getRange(DateFormat.DATE_FIELD);
    getRange(DateFormat.MONTH_FIELD);
    getRange(DateFormat.YEAR_FIELD);
  }
  
  public Calendar getDate()
  {
    return date;
  }
}

Listing 18

public class JSpinnerTime extends JSpinnerField
{
  public JSpinnerTime()
  {
    this(Calendar.getInstance());
  }
  
  public JSpinnerTime(Calendar time)
  {
    super(new TimeSpinModel(),
      new DefaultSpinRenderer(),
      DateFormat.getTimeInstance(DateFormat.SHORT),
      true);
    getTimeModel().setTime(time);
    refreshSpinView();
  }
  
  public void setLocale(Locale locale)
  {
    formatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale);
    updateFieldOrder();
  }
  
  private TimeSpinModel getTimeModel()
  {
    return (TimeSpinModel)model;
  }
  
  protected void refreshSpinView()
  {
    spinField.setValue(getTimeModel().getTime().getTime());
  }
}

Listing 19

public class JSpinnerDate extends JSpinnerField
{
  public JSpinnerDate()
  {
    this(Calendar.getInstance());
  }
  
  public JSpinnerDate(Calendar date)
  {
    super(new DateSpinModel(),
      new DefaultSpinRenderer(),
      DateFormat.getDateInstance(DateFormat.MEDIUM),
      true);
    getDateModel().setDate(date);
    refreshSpinView();
  }
  
  public void setLocale(Locale locale)
  {
    formatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale);
    updateFieldOrder();
  }
  
  private DateSpinModel getDateModel()
  {
    return (DateSpinModel)model;
  }
  
  protected void refreshSpinView()
  {
    spinField.setValue(getDateModel().getDate().getTime());
  }
}

Listing 20

public class JSpinnerTest extends JPanel
  implements ActionListener
{
  protected JComboBox localeChoice;
  protected JSpinnerField date, time,
    currency, percent, number, skip, list, color;
  protected String[] localeNames =
  {
    "U.S.", "English", "French", "German"
  };
  protected Locale[] localeTypes =
  {
    Locale.US, Locale.ENGLISH, Locale.FRENCH, Locale.GERMAN
  };
  
  public JSpinnerTest()
  {
    setLayout(new FieldLayout(4, 4));
    add(new JLabel(" Locale: "));
    add(localeChoice = new JComboBox(localeNames));
    localeChoice.addActionListener(this);
    add(new JLabel(" JSpinnerField (1-10/1): "));
    add(number = new JSpinnerField(1, 1, 1, 10, true));
    add(new JLabel(" JSpinnerField (0-8/2): "));
    add(skip = new JSpinnerField(0, 2, 0, 8, true));
    add(new JLabel(" JSpinnerDate (today): "));
    add(date = new JSpinnerDate());
    add(new JLabel(" JSpinnerTime (now): "));
    add(time = new JSpinnerTime());
    add(new JLabel(" JSpinnerPercent: "));
    add(percent = new JSpinnerPercent());
    add(new JLabel(" JSpinnerCurrency: "));
    add(currency = new JSpinnerCurrency());
    add(new JLabel(" JSpinnerList (a,b,c,d,e): "));
    add(list = new JSpinnerList(new String[] {
      "alpha", "beta", "gamma", "delta", "epsilon"}, 0, true));
    add(new JLabel(" JSpinnerColor (r,g,b): "));
    add(color = new JSpinnerColor(new Color[] {
      Color.red, Color.green, Color.blue}, 0, true));
  }
  
  public void actionPerformed(ActionEvent event)
  {
    Locale locale = localeTypes[localeChoice.getSelectedIndex()];
    date.setLocale(locale);
    time.setLocale(locale);
    percent.setLocale(locale);
    currency.setLocale(locale);
    number.setLocale(locale);
    skip.setLocale(locale);
    list.setLocale(locale);
    color.setLocale(locale);
    repaint();
  }
  
  public static void main(String[] args)
  {
    PLAF.setNativeLookAndFeel(true);
    JFrame frame = new JFrame("JSpinner* Test");
    frame.getContentPane().add(new JSpinnerTest());
    frame.setBounds(100, 100, 250, 250);
    frame.show();
  }
}