A common misconception is that Mac apps must look like Apple’s own apps. A lot of developers misinterpret Mac users’ high visual standards as a call for plain Cocoa apps only, with no visual innovation or interpretation. What Mac users are really saying, is:

Give me an app that looks at least as good as what Apple produces

In fact, Apple is the first to break from using standard Cocoa (think iTunes). Do users let this slide only because it’s Apple? I don’t think so. Here are a number of applications (including iTunes) that don’t look like standard apps, and are well received by the Mac community.

Lightroom 3 (beta)
light_room
Lightroom 3 has a completely non-standard UI and interaction model, and I love it! The UI is dark and stays out of your way. The “links” in the top right of the window (e.g. Library, Develop etc.) let you quickly adjust your workflow to the task at hand. Overall, the UI is polished, snappy and a pleasure to use.

Coda
coda
Panic’s Coda may look like a Mac app at first, but it was one of the first apps to successfully embrace the one-window paradigm, which was very non-standard for Mac apps when it was released (more recently, Adobe has adopted this concept). Coda also helps you switch workflows by changing the active “tab” (the buttons above the document area). This concept was, and still is, something very unique to Coda. Coda won an Apple Design Award, and is lauded as a truly fantastic Mac app.

Pixelmator
pixelmator
Pixelmator offers a clean, crisp UI for editing photos. They’ve played off Apple’s Heads Up Display (HUD) concept, but pushed it throughout the app to everything including the document window’s chrome. I think their UI is gorgeous and unobtrusive — it makes me want to use it just so I can look at it.

iTunes
itunes
iTunes, though one of Apple’s own products, is consistently different from the core platform. Even though it’s not consistent with other apps, I’m happy with it because it looks good. I enjoy being able to see what UI changes are in the pipeline, as iTunes has been a proving ground for more general user interface changes across the platform.

I could keep going, but I think the point is clear. User interface innovation is widely accepted by Mac users so long as it is an innovation and not sloppiness.

I was playing around with the Mac version of Google Chrome the other day (I found it here) and I stumbled across the “Incognito” mode. When you go incognito, you’re going into private-browsing mode, i.e. you won’t leave traces of where you’ve been.

What I really liked about the user interface for this was the use of different window title bar for the incognito window. In general, modes are not great for users because it’s difficult to convey what mode your in. Google’s custom window chrome is the perfect indication to the user that they are in a special browsing mode.

incognito_window
regular_window

hud_slider_without_tickshud_slider_with_ticks
Someone recently requested I add a HUD style slider to the widget set, which I quickly added and is now available in the latest Mac Widgets for Java developer build. Creating a custom SliderUI delegate is pretty easy, as BasicSliderUI has all the right hooks. You can specify the thumb size, tick size, track bounds, thumb location and much more by overriding corresponding methods. This is a refreshing change from the complicated process of creating a custom scroll bar UI delegate (which I talked about in detail in part 1, part 2, and part 3 of the “Skinning a scroll bar” series).

As I’ve noted in previous HUD style component posts (here and here), you may notice a bit of redundant code between the posts. I’ve done this to keep the blog entries independent, but the actual Mac Widgets for Java code uses a utility class to do most of the HUD painting.

Finally, for this widget to look “correct”, it must be added to a HUD style window, which I talked about here.

public class HudSliderUI extends BasicSliderUI {

    private static final int SLIDER_KNOB_WIDTH = 11;
    private static final int SLIDER_KNOB_HEIGHT_NO_TICKS = 11;
    private static final int SLIDER_KNOB_HEIGHT_WITH_TICKS = 13;
    private static final int TRACK_HEIGHT = 4;

    private static final Color TRACK_BACKGROUND_COLOR = new Color(143, 147, 144, 100);
    private static final Color TRACK_BORDER_COLOR = new Color(255, 255, 255, 200);

    private static final Color TOP_SLIDER_KNOB_COLOR = new Color(0x555555);
    private static final Color BOTTOM_SLIDER_KNOB_COLOR = new Color(0x393939);
    private static final Color TOP_SLIDER_KNOB_PRESSED_COLOR = new Color(0xb0b2b6);
    private static final Color BOTTOM_SLIDER_KNOB_PRESSED_COLOR = new Color(0x86888b);

    public static final Color BORDER_COLOR = new Color(0xc5c8cf);

    private static final Color LIGHT_SHADOW_COLOR = new Color(0, 0, 0, 145);
    private static final Color DARK_SHADOW_COLOR = new Color(0, 0, 0, 50);

    private static final ShapeProvider NO_TICKS_SHAPE_PROVIDER =
            createCircularSliderKnobShapeProvider();

    private static final ShapeProvider TICKS_SHAPE_PROVIDER =
            createPointedSliderKnobShapeProvider();

    public HudSliderUI(JSlider b) {
        super(b);
    }

    @Override
    protected void installDefaults(JSlider slider) {
        super.installDefaults(slider);
        slider.setOpaque(false);
    }

    @Override
    protected Dimension getThumbSize() {
        int sliderKnobHeight = slider.getPaintTicks()
                ? SLIDER_KNOB_HEIGHT_WITH_TICKS : SLIDER_KNOB_HEIGHT_NO_TICKS;
        return new Dimension(SLIDER_KNOB_WIDTH, sliderKnobHeight);
    }

    @Override
    public void paintThumb(Graphics graphics) {
        Paint paint = createSliderKnobButtonPaint(isDragging(), thumbRect.height);
        ShapeProvider shapeProvider = slider.getPaintTicks()
                ? TICKS_SHAPE_PROVIDER : NO_TICKS_SHAPE_PROVIDER;
        paintHudControlBackground((Graphics2D) graphics, thumbRect, shapeProvider,
                paint);
    }

    @Override
    public void paintTrack(Graphics graphics) {
        Graphics2D graphics2d = (Graphics2D) graphics;
        graphics2d.setRenderingHint(
                RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        double trackY = slider.getHeight()/2.0 - TRACK_HEIGHT/2.0;
        RoundRectangle2D track = new RoundRectangle2D.Double(
                0, trackY, slider.getWidth()-1, TRACK_HEIGHT - 1, 4, 2);

        graphics.setColor(TRACK_BACKGROUND_COLOR);
        graphics2d.fill(track);
        graphics2d.setColor(TRACK_BORDER_COLOR);
        graphics2d.draw(track);
    }

    @Override
    protected int getTickLength() {
        return 5;
    }

    @Override
    protected void calculateThumbLocation() {
        super.calculateThumbLocation();

        // if this is a horizontal style slider and we're drawing a pointy style thumb
        // then shift the thumb down three pixels.
        if ( slider.getOrientation() == JSlider.HORIZONTAL
                && slider.getPaintTicks()) {
            thumbRect.y += 3;
        } else  {
            // TODO handle vertical slider.
        }
    }

    @Override
    protected void calculateTickRect() {
        super.calculateTickRect();

        // if this is a horizontal style slider, shift the ticks down one pixel so that
        // they aren't right up against the track.
        if ( slider.getOrientation() == JSlider.HORIZONTAL ) {
            tickRect.y += 1;
        } else  {
            // TODO handle vertical slider.
        }
    }

    @Override
    protected void paintMajorTickForHorizSlider(Graphics g, Rectangle tickBounds, int x) {
        g.setColor(Color.WHITE);
        super.paintMajorTickForHorizSlider(g, tickBounds, x);
    }

    @Override
    public void setThumbLocation(int x, int y) {
        super.setThumbLocation(x, y);
        // repaint the whole slider -- it's easier than trying to figure out
        // whats dirty, especially since the thumb will be drawn outside of the
        // thumbRect (the shadow part).
        slider.repaint();
    }

    @Override
    public void paintFocus(Graphics g) {
        // don't paint focus.
    }

    private static Paint createSliderKnobButtonPaint(boolean isPressed, int height) {
        // grab the top and bottom gradient colors based on the pressed state.
        Color topColor = isPressed
                ? TOP_SLIDER_KNOB_PRESSED_COLOR : TOP_SLIDER_KNOB_COLOR;
        Color bottomColor = isPressed
                ? BOTTOM_SLIDER_KNOB_PRESSED_COLOR : BOTTOM_SLIDER_KNOB_COLOR;
        // compenstate for the two pixel shadow drawn below the slider thumb.
        int bottomY = height - 2;
        return new GradientPaint(0, 0, topColor, 0, bottomY, bottomColor);
    }

    /**
     * Creates a simple circle.
     */
    private static ShapeProvider createCircularSliderKnobShapeProvider() {
        return new ShapeProvider() {
            public Shape createShape(double x, double y, double width, double height) {
                return new Ellipse2D.Double(x, y, width, height);
            }
        };
    }

    /**
     * Cerates a pointy slider thumb shape that looks roughly like this:
     *     +----+
     *    /      \
     *    +      +
     *    |      |
     *    +      +
     *      \  /
     *       \/
     */
    private static ShapeProvider createPointedSliderKnobShapeProvider() {
        return new ShapeProvider() {
            public Shape createShape(double x, double y, double width, double height) {
                float xFloat = (float) x;
                float yFloat = (float) y;
                float widthFloat = (float) width;
                float heightFloat = (float) height;

                // draw the thumb shape based on the given height and width.
                GeneralPath path = new GeneralPath();
                // move in two pixels so that we can curve down to the next point.
                path.moveTo(xFloat + 2.0f, yFloat);
                // curve down to the second point.
                path.curveTo(xFloat + 0.25f, yFloat + 0.25f, xFloat - 0.25f,
                        yFloat + 2.0f, xFloat, yFloat + 2.0f);
                // move straight down to the next point.
                path.lineTo(xFloat, yFloat + heightFloat/1.60f);
                // move down and right to form the left half of the pointy section.
                path.lineTo(xFloat + widthFloat/2, yFloat + heightFloat);
                // move up and right to form the right half of the pointy section.
                path.lineTo(xFloat + widthFloat, yFloat + heightFloat/1.60f);
                // move straight up to the point to curve from.
                path.lineTo(xFloat + widthFloat, yFloat + 2.0f);
                // curve up and right to the top of the thumb.
                path.curveTo(xFloat + widthFloat - 0.25f, yFloat + 2.0f,
                        xFloat + widthFloat - 0.25f, yFloat + 0.25f,
                        xFloat + widthFloat - 2.0f, yFloat);
                path.closePath();

                return path;
            }
        };
    }

    /**
     * Paints a HUD style background in the given shape. This includes a drop shadow
     * which will be drawn under the shape to be painted. The shadow will be draw
     * outside the given bounds.
     * @param graphics the {@code Graphics2D} context to draw in.
     * @param bounds the bounds to paint in.
     * @param shapeProvider the delegate to request the {@link Shape} from.
     * @param paint the {@link Paint} to use to fill the {@code Shape}.
     */
    public static void paintHudControlBackground(
            Graphics2D graphics, Rectangle bounds, ShapeProvider shapeProvider,
            Paint paint) {

        graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);

        int x = bounds.x;
        int y = bounds.y;
        int width = bounds.width;
        int height = bounds.height;

        // paint the first (further away) part of the drop shadow.
        graphics.setColor(LIGHT_SHADOW_COLOR);
        graphics.draw(shapeProvider.createShape(x, y, width - 1, height));
        // paint the second (closer) part of the drop shadow.
        graphics.setColor(DARK_SHADOW_COLOR);
        graphics.draw(shapeProvider.createShape(x, y, width - 1, height + 1));

        // fill the HUD shape.
        graphics.setPaint(paint);
        graphics.fill(shapeProvider.createShape(x, y + 1, width, height - 1));
        // stroke the HUD shape.
        graphics.setColor(BORDER_COLOR);
        graphics.draw(shapeProvider.createShape(x, y, width - 1, height - 1));
    }

    /**
     * An interface for specifying a shape to paint and draw a drop shadown under.
     */
    public interface ShapeProvider {
        Shape createShape(double x, double y, double width, double height);
    }
}

new_client_properties_focusednew_client_properties_unfocused
There are a few new client properties on the Mac, that haven’t yet been documented (though I’ve been assured that they’re safe to use). The great thing about the way Apple has been using client properties on the Mac, is that they make it easy for you to get closer to being a great Mac app, while not breaking your fidelity on other platforms. They’re really great if you want to fit in on your target platform, whereas in Mac Widgets for Java, I’m aiming for always looking like a Mac app.

The screenshots above show the new client properties that give you access to SourceList style selection painters (demo’d in the source code below). Note that these painters accurately pick up whether the user is using Aqua or Graphite — that’s a big bonus.

Here’s a listing of the new client properties:

List.sourceListBackgroundPainter
List.sourceListSelectionBackgroundPainter
List.sourceListFocusedSelectionBackgroundPainter
List.evenRowBackgroundPainter
List.oddRowBackgroundPainter

Here’s a little bit of code that puts a few of the new SourceList client properties to work (seen above):

public class NewClientProperties {

    /**
     * Create a SourceList style JList.
     */
    private static JList createMacSourceList() {
        JList list = new SourceList();
        // install a custom renderer that wraps the already installed renderer.
        list.setCellRenderer(new CustomListCellRenderer(list.getCellRenderer()));
        return list;
    }

    /**
     * A custom JList that renders like a Mac SourceList.
     */
    public static class SourceList extends JList {

        public SourceList() {
            // make the component non-opaque so that we can paint the background in
            // paintComponent.
            setOpaque(false);
        }

        @Override
        protected void paintComponent(Graphics g) {
            // paint the background of the component using the special Mac border
            // painter.
            Border backgroundPainter =
                    UIManager.getBorder("List.sourceListBackgroundPainter");
            backgroundPainter.paintBorder(this, g, 0, 0, getWidth(), getHeight());
            super.paintComponent(g);
        }
    }

    /**
     * A custom ListCellRenderrer that wraps a delegate renderer.
     */
    public static class CustomListCellRenderer extends JPanel
            implements ListCellRenderer {

        private ListCellRenderer fDelegate;
        private boolean fIsSelected;
        private boolean fIsFocused;

        public CustomListCellRenderer(ListCellRenderer delegate) {
            this.setOpaque(false);
            this.setLayout(new BorderLayout());
            this.setBorder(BorderFactory.createEmptyBorder(1,5,1,5));
            fDelegate = delegate;
        }

        public Component getListCellRendererComponent(
                JList list, Object value, int index,boolean isSelected,
                boolean cellHasFocus) {

            this.removeAll();
            // remember the isSelected and cellHasFocus state so that we can use those
            // values in the paintComponent method.
            fIsSelected = isSelected;
            fIsFocused = cellHasFocus;
            // call the delegate renderer
            JComponent component = (JComponent) fDelegate.getListCellRendererComponent(
                    list, value, index, isSelected, false);
            // make the delegate rendere non-opqaue so that the background shows through.
            component.setOpaque(false);
            this.add(component, BorderLayout.CENTER);

            return this;
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            // if the item was selected, then paint the custom Mac selection background.
            if (fIsSelected) {
                Border backgroundPainter = fIsFocused
                        ? UIManager.getBorder("List.sourceListFocusedSelectionBackgroundPainter")
                        : UIManager.getBorder("List.sourceListSelectionBackgroundPainter");
                backgroundPainter.paintBorder(this, g, 0, 0, getWidth(), getHeight());
            }
        }
    }

    public static void main(String[] args) {

        JList list = createMacSourceList();
        list.setListData(new String[]{
                "BMW", "Chevy", "Dodge", "Infiniti", "Nissan", "Porsche"});

        JScrollPane scrollPane = new JScrollPane(list);
        scrollPane.setBorder(BorderFactory.createEmptyBorder());

        JFrame frame = new JFrame();
        frame.add(scrollPane);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(200,200);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
}

mighty_mouse
Mighty Mouses have a single debilitating flaw. Over time, that little tiny scroll ball, (reminicent of the ball that used to be underneath the mouse) stops working. I don’t know about you, but I might as well pack it up when this happens, because I am not effective without my scroll ball!

It turns out that there’s an easy fix for this, which I just sought out after being crippled with a mouse that wouldn’t scroll down. Here’s what you do:

  1. Lay a piece of paper on a desk
  2. Flip your Mighty Mouse upside down
  3. Press the little ball down firmly on the piece of paper and roll the ball around
  4. Do this a couple of times, flipping rightside up every so often to blow of the gunk that comes out

It’s that easy!

I thought Safari 4 beta was headed in the right direction with it’s tab location. The Safari design team had moved the tabs to the the top of the window and thus made them up-right like this:
safari_4_beta
Now I realize there was a bit of a usability problem, as the tabs were now doubling as the mechanism to move the window, but I liked the direction the UI was moving in. I greatly disliked the upside down tabs of Safari 3, because they make no visual sense — the tabs are completely disconnected from the content they are tabbing.

As you can imagine, I was dismayed when I installed the final version of Safari 4 only to see the tabs revert to their version 3 visuals:
safari_4
I’m not sure why the Safari visual design team chose this broken tab metaphor to begin with as it’s never made any sense, and it’s clear that they realize this. So why keep around this broken metaphor and make us suffer for another full version?

This is not a hard visual design problem to solve. Google Chrome has a great solution that meets the visual demands of the Mac platform:
chrome_beta
Come on Apple — lets get this fixed.

JTable looks a little rough around the edges straight out of the box. With a couple of tweaks, though, we can make it fit in better on the Mac (and Windows too).

Here’s a screen shot of a JTable that uses no auto-resize (the default on most platforms) with no customization:
jtable_bad

So what’s the most glaring problem here? Well for one, the table header doesn’t extend to the scrollbar. Second, there isn’t a scroll pane corner set on the upper right corner (in a perfect world, the scroll pane corner would merge into the table header, but we’ll let that go for now!).

Below, I’ve set the empty table header space to the right of the right-most column (if there is any) to paint the table header background. I’ve also installed a scroll pane corner and added row striping for effect. Here’s what it looks like:

jtable_good

Not bad.

Below is the implementation. Note that I’ve done the striping in a custom JViewport because it’s much easier that way. Also, the selection intentionally doesn’t span the entire width of the viewport, as the native table selection doesn’t do this.

public class BetterJTable extends JTable {

    private static final Color EVEN_ROW_COLOR = new Color(241, 245, 250);
    private static final Color TABLE_GRID_COLOR = new Color(0xd9d9d9);

    private static final CellRendererPane CELL_RENDER_PANE = new CellRendererPane();

    public BetterJTable(TableModel dm) {
        super(dm);
        init();
    }

    private void init() {
        setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
        setTableHeader(createTableHeader());
        getTableHeader().setReorderingAllowed(false);
        setOpaque(false);
        setGridColor(TABLE_GRID_COLOR);
        setIntercellSpacing(new Dimension(0, 0));
        // turn off grid painting as we'll handle this manually in order to paint
        // grid lines over the entire viewport.
        setShowGrid(false);
    }

    /**
     * Creates a JTableHeader that paints the table header background to the right
     * of the right-most column if neccesasry.
     */
    private JTableHeader createTableHeader() {
        return new JTableHeader(getColumnModel()) {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g);
                // if this JTableHEader is parented in a JViewport, then paint the
                // table header background to the right of the last column if
                // neccessary.
                JViewport viewport = (JViewport) table.getParent();
                if (viewport != null && table.getWidth() < viewport.getWidth()) {
                    int x = table.getWidth();
                    int width = viewport.getWidth() - table.getWidth();
                    paintHeader(g, getTable(), x, width);
                }
            }
        };
    }

    /**
     * Paints the given JTable's table default header background at given
     * x for the given width.
     */
    private static void paintHeader(Graphics g, JTable table, int x, int width) {
        TableCellRenderer renderer = table.getTableHeader().getDefaultRenderer();
        Component component = renderer.getTableCellRendererComponent(
                table, "", false, false, -1, 2);

        component.setBounds(0,0,width, table.getTableHeader().getHeight());

        ((JComponent)component).setOpaque(false);
        CELL_RENDER_PANE.paintComponent(g, component, null, x, 0,
                width, table.getTableHeader().getHeight(), true);
    }

    @Override
    public Component prepareRenderer(TableCellRenderer renderer, int row,
                                     int column) {
        Component component = super.prepareRenderer(renderer, row, column);
        // if the rendere is a JComponent and the given row isn't part of a
        // selection, make the renderer non-opaque so that striped rows show
        // through.
        if (component instanceof JComponent) {
            ((JComponent)component).setOpaque(getSelectionModel().isSelectedIndex(row));
        }
        return component;
    }

    // Stripe painting Viewport. //////////////////////////////////////////////

    /**
     * Creates a JViewport that draws a striped backgroud corresponding to the
     * row positions of the given JTable.
     */
    private static class StripedViewport extends JViewport {

        private final JTable fTable;

        public StripedViewport(JTable table) {
            fTable = table;
            setOpaque(false);
            initListeners();
        }

        private void initListeners() {
            // install a listener to cause the whole table to repaint when
            // a column is resized. we do this because the extended grid
            // lines may need to be repainted. this could be cleaned up,
            // but for now, it works fine.
            PropertyChangeListener listener = createTableColumnWidthListener();
            for (int i=0; i<fTable.getColumnModel().getColumnCount(); i++) {
                fTable.getColumnModel().getColumn(i).addPropertyChangeListener(listener);
            }
        }

        private PropertyChangeListener createTableColumnWidthListener() {
            return new PropertyChangeListener() {
                public void propertyChange(PropertyChangeEvent evt) {
                    repaint();
                }
            };
        }

        @Override
        protected void paintComponent(Graphics g) {
            paintStripedBackground(g);
            paintVerticalGridLines(g);
            super.paintComponent(g);
        }

        private void paintStripedBackground(Graphics g) {
            // get the row index at the top of the clip bounds (the first row
            // to paint).
            int rowAtPoint = fTable.rowAtPoint(g.getClipBounds().getLocation());
            // get the y coordinate of the first row to paint. if there are no
            // rows in the table, start painting at the top of the supplied
            // clipping bounds.
            int topY = rowAtPoint < 0
                    ? g.getClipBounds().y : fTable.getCellRect(rowAtPoint,0,true).y;

            // create a counter variable to hold the current row. if there are no
            // rows in the table, start the counter at 0.
            int currentRow = rowAtPoint < 0 ? 0 : rowAtPoint;
            while (topY < g.getClipBounds().y + g.getClipBounds().height) {
                int bottomY = topY + fTable.getRowHeight();
                g.setColor(getRowColor(currentRow));
                g.fillRect(g.getClipBounds().x, topY, g.getClipBounds().width, bottomY);
                topY = bottomY;
                currentRow ++;
            }
        }

        private Color getRowColor(int row) {
            return row % 2 == 0 ? EVEN_ROW_COLOR : getBackground();
        }

        private void paintVerticalGridLines(Graphics g) {
            // paint the column grid dividers for the non-existent rows.
            int x = 0;
            for (int i = 0; i < fTable.getColumnCount(); i++) {
                TableColumn column = fTable.getColumnModel().getColumn(i);
                // increase the x position by the width of the current column.
                x += column.getWidth();
                g.setColor(TABLE_GRID_COLOR);
                // draw the grid line (not sure what the -1 is for, but BasicTableUI
                // also does it.
                g.drawLine(x - 1, g.getClipBounds().y, x - 1, getHeight());
            }
        }
    }

    public static JScrollPane createStripedJScrollPane(JTable table) {
        JScrollPane scrollPane =  new JScrollPane(table);
        scrollPane.setViewport(new StripedViewport(table));
        scrollPane.getViewport().setView(table);
        scrollPane.setBorder(BorderFactory.createEmptyBorder());
        scrollPane.setCorner(JScrollPane.UPPER_RIGHT_CORNER,
                createCornerComponent(table));
        return scrollPane;
    }

    /**
     * Creates a component that paints the header background for use in a
     * JScrollPane corner.
     */
    private static JComponent createCornerComponent(final JTable table) {
        return new JComponent() {
            @Override
            protected void paintComponent(Graphics g) {
                paintHeader(g, table, 0, getWidth());
            }
        };
    }
}

Swing can be oh so sweet

March 23, 2009

If you had any doubts that Swing could deliver truly beautiful apps, then check out Dirk Lemmermann’s latest endeavor below, which is using Mac Widgets for Java. Dirk is working on a product to enable collaborative enterprise project and issue management, called “Planner’s Workbench”, which he’s aiming to release this summer. He’s really helped refine and improve the Mac Widgets for Java API, as he’s really using this stuff.

By the way, if Dirk’s name sounds familiar, that’s probably because your familiar with his powerful FlexGantt UI Framework (another really nice piece of work).

bild-6
bild-3
bild-3-1
bild-2
bild-4

hud_combo_box1

Creating a custom combo box is by far the most complicated of the custom components we’ve discussed thus far. BasicComboBoxUI encapsulates a large amount of functionality, including building and showing a popup, which it delegates to a ComboPopup.

The ComboPopup is responsible for figuring out where to place the popup on the screen and then showing it. BasicComboPopup is used by BasicComboBoxUI, and puts the popup directly below the comb box. We want the popup to show over the popup such that the selected item lines up with the combo box.

One other important note about ComboPopup is that it wants the items in the popup to be contained in a JList. This is not good if we’re on Mac OS X and want the blue gradient menu selection, as JList, by default, has a flat blue selection color. This is another reason we’ll need to create a custom implementation of ComboPopup, namely so we can fill it with JMenuItems instead of a JList.

Listed first below is an extension of BasicComboBoxUI, which takes responsibility for drawing the combo box itself. It does this by creating a simple HUD style button (see this post for the HudButtonUI code), with extra margin space on the right. Up/down arrows are then painted in the extra space on the right.

The second listing is a custom implementation of ComboPopup. This implementation uses JMenuItems instead of a JList. It also figures out where to place the popup on the screen. Note that the given implementation won’t handle cases where the popup is to large to fit on the screen.

HudComboBoxUI:

public class HudComboBoxUI extends BasicComboBoxUI {

    /* Font constants. */
    public static final float FONT_SIZE = 11.0f;
    public static final Color FONT_COLOR = Color.WHITE;

    private HudButtonUI fArrowButtonUI;

    private static final int LEFT_MARGIN = 7;
    private static final int RIGHT_MARGIN = 19;
    private static final int DEFAULT_WIDTH = 100;

    /**
     * Creates a HUD style {@link javax.swing.plaf.ComboBoxUI}.
     */
    public HudComboBoxUI() {
        fArrowButtonUI = new HudButtonUI(Roundedness.COMBO_BUTTON);
    }

    @Override
    protected void installDefaults() {
        super.installDefaults();

        comboBox.setFont(getHudFont());
        comboBox.setForeground(FONT_COLOR);
        comboBox.setOpaque(false);
        comboBox.addActionListener(createComboBoxListener());
    }

    @Override
    protected void uninstallDefaults() {
        super.uninstallDefaults();
        // TODO implement this.
    }

    @Override
    protected void installComponents() {
        super.installComponents();
        updateDisplayedItem();
    }

    /**
     * Updates the value displayed to match that of
     * {@link javax.swing.JComboBox#getSelectedItem()}.
     */
    private void updateDisplayedItem() {
        // TODO make the calculation of the display string more robust
        // TODO (i.e. use TextProvider interface).
        String displayValue = comboBox.getSelectedItem() == null
                ? "" : comboBox.getSelectedItem().toString();
        arrowButton.setText(displayValue);
    }

    /**
     * Creates a {@link EPComboPopup.ComboBoxVerticalCenterProvider}
     * that returns a vertical center value that takes HudComboBoxUI's drop
     * shadow. The visual center is calculated as if the drop shadow did not exist.
     */
    private EPComboPopup.ComboBoxVerticalCenterProvider createComboBoxVerticalCenterProvider() {
        return new EPComboPopup.ComboBoxVerticalCenterProvider() {
            public int provideCenter(JComboBox comboBox) {
                return calculateArrowButtonVisualVerticalCenter();
            }
        };
    }

    /**
     * Creates an {@link java.awt.event.ActionListener} that updates
     * the displayed item when the {@link JComboBox}'s currently selected
     * item changes.
     */
    private ActionListener createComboBoxListener() {
        return new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                updateDisplayedItem();
            }
        };
    }

    @Override
    protected JButton createArrowButton() {
        JButton arrowButton = new JButton("");
        arrowButton.setUI(fArrowButtonUI);
        Insets currentInsets = arrowButton.getInsets();
        arrowButton.setBorder(BorderFactory.createEmptyBorder(
                currentInsets.top, LEFT_MARGIN, currentInsets.bottom, RIGHT_MARGIN));
        arrowButton.setHorizontalAlignment(SwingConstants.LEFT);

        return arrowButton;
    }

    @Override
    protected ListCellRenderer createRenderer() {
        return new JComboBox().getRenderer();
    }

    @Override
    protected ComboPopup createPopup() {
        EPComboPopup popup = new EPComboPopup(comboBox);
        popup.setFont(getHudFont().deriveFont(Font.PLAIN));
        // install a custom ComboBoxVerticalCenterProvider that takes into
        // account the size of the drop shadow.
        popup.setVerticalComponentCenterProvider(createComboBoxVerticalCenterProvider());
        return popup;
    }

    @Override
    public Dimension getMinimumSize(JComponent c) {
        int width = getDisplaySize().width;
        int height = arrowButton.getPreferredSize().height;
        return new Dimension(width, height);
    }

    @Override
    protected Dimension getDefaultSize() {
        JButton button = new JButton("Button");
        button.setUI(new HudButtonUI());
        return new Dimension(DEFAULT_WIDTH, button.getPreferredSize().height);
    }

    @Override
    public void paint(Graphics g, JComponent c) {
        super.paint(g, c);

        Graphics2D graphics = (Graphics2D) g.create();
        paintUpDownArrowsIcon(graphics);
        graphics.dispose();
    }

    @Override
    public void paintCurrentValue(Graphics g, Rectangle bounds, boolean hasFocus) {
        // no painting necessary - the arrowButton handles painting of the currently selected value.
    }

    @Override
    protected Dimension getDisplaySize() {
        int maxWidth;
        if (comboBox.getPrototypeDisplayValue() != null) {
            maxWidth = getDisplayWidth(comboBox.getPrototypeDisplayValue());
        } else if (comboBox.getItemCount() > 0) {
            maxWidth = getMaxComboBoxModelDisplayWidth();
        } else {
            maxWidth = getDefaultSize().width;
        }

        Insets arrowButtonInsets = arrowButton.getInsets();
        maxWidth += arrowButtonInsets.left + arrowButtonInsets.right;

        return new Dimension(maxWidth, arrowButton.getPreferredSize().height);
    }

    /**
     * Gets the max display width in pixels of all entries in the {@link JComboBox}'s
     * {@link javax.swing.ComboBoxModel}.
     */
    private int getMaxComboBoxModelDisplayWidth() {
        int maxWidth = 0;
        for (int i = 0; i < comboBox.getModel().getSize(); i++) {
            int itemWidth = getDisplayWidth(comboBox.getModel().getElementAt(i));
            maxWidth = Math.max(maxWidth, itemWidth);
        }
        return maxWidth;
    }

    /**
     * Calculates the display width in pixels of the given object.
     */
    private int getDisplayWidth(Object object) {
        assert object != null : "The given object cannot be null";
        // TODO refactor this logic into utility class that looks for TextProvider.
        FontMetrics fontMetrics = comboBox.getFontMetrics(comboBox.getFont());
        return fontMetrics.stringWidth(object.toString());
    }

    @Override
    protected LayoutManager createLayoutManager() {
        return new LayoutManager() {
            public void addLayoutComponent(String name, Component comp) {
                throw new UnsupportedOperationException("This operation is not supported.");
            }

            public void removeLayoutComponent(Component comp) {
                throw new UnsupportedOperationException("This operation is not supported.");
            }

            public Dimension preferredLayoutSize(Container parent) {
                // the combo box's preferred size is the preferred width of the parent and the
                // preferred height of the arrowButton.
                return new Dimension(parent.getPreferredSize().width,
                        arrowButton.getPreferredSize().height);
            }

            public Dimension minimumLayoutSize(Container parent) {
                return parent.getMinimumSize();
            }

            public void layoutContainer(Container parent) {
                // make the arrowButton fill the width, and center itself in the available height.
                int buttonHeight = arrowButton.getPreferredSize().height;
                int y = parent.getHeight() / 2 - buttonHeight / 2;
                arrowButton.setBounds(0, y, parent.getWidth(), buttonHeight);
            }
        };
    }

    /**
     * Calculates the visual vertical center of this component. The visual center is what the user
     * would interpret as the center, thus we adjust the actual center to take into account the size
     * of the drop shadow.
     */
    private int calculateArrowButtonVisualVerticalCenter() {
        int arrowButtonShadowHeight = getHudControlShadowSize();
        return (arrowButton.getHeight() - arrowButtonShadowHeight) / 2;
    }

    /**
     * Paints the up and down arrows on the right side of the combo box.
     */
    private void paintUpDownArrowsIcon(Graphics2D graphics) {
        Insets arrowButtonInsets = arrowButton.getInsets();
        int arrowButtonHeight = arrowButton.getHeight();

        // calculate the exact center of where both arrows will be drawn relative to.
        int centerX = arrowButton.getWidth() - arrowButtonInsets.right / 2;
        int centerY = calculateArrowButtonVisualVerticalCenter();

        // calculate how many pixels there should be between the arrows as well as how long each
        // side of the arrow should be.
        int verticalDistanceBetweenArrows = (int) (arrowButtonHeight * 0.125);
        int arrowSideLength = verticalDistanceBetweenArrows * 2;

        // calculate the upper left position of the up arrow.
        int upArrowX = centerX - arrowSideLength / 2;
        int upArrowY = centerY - verticalDistanceBetweenArrows / 2;

        graphics.setColor(FONT_COLOR);
        graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        // translate the graphics to the upper left of each arrow and draw that arrow. each arrow
        // assumes that it is being drawn at 0,0.
        graphics.translate(upArrowX, upArrowY);
        graphics.fill(createUpArrow(arrowSideLength));
        graphics.translate(0, verticalDistanceBetweenArrows);
        graphics.fill(createDownArrow(arrowSideLength));
    }

    /**
     * Creates a path representing an up arrow, based at 0,0.
     */
    private static GeneralPath createUpArrow(int arrowSideLength) {
        GeneralPath path = new GeneralPath();
        path.moveTo(0, 0);
        path.lineTo(arrowSideLength, 0);
        path.lineTo(arrowSideLength / 2, -arrowSideLength);
        path.closePath();

        return path;
    }

    /**
     * Creates a path representing a down arrow, based at 0,0.
     */
    private static GeneralPath createDownArrow(int arrowSideLength) {
        GeneralPath path = new GeneralPath();
        path.moveTo(0, 0);
        path.lineTo(arrowSideLength, 0);
        path.lineTo(arrowSideLength / 2, arrowSideLength);
        path.closePath();

        return path;
    }

    /**
     * Gets the number of pixels that a HUD style widget's shadow takes up. HUD
     * button's have a shadow directly below them, that is, there is no top, left
     * or right component to the shadow.
     * @return the number of pixels that a HUD style widget's shadow takes up.
     */
    private static int getHudControlShadowSize() {
        // this is hardcoded at two pixels for now, but ideally it would be
        // calculated.
        return 2;
    }

    /**
     * Gets the font used by HUD style widgets.
     * @return the font used by HUD style widgets.
     */
    private static Font getHudFont() {
        return UIManager.getFont("Button.font").deriveFont(Font.BOLD, FONT_SIZE);
    }

    /**
     * An enumeration representing the roundness styles of HUD buttons. Using this
     * enumeration will make it easier to transition this code to support more
     * HUD controls, like check boxes and combo buttons.
     */
    public enum Roundedness {
        /**
         * A roundedness of 95%, equates to almost a half-circle as the button
         * edge shape.
         */
        ROUNDED_BUTTON(.95),
        /**
         * A roundedness of 95%, equates to almost a half-circle as the button
         * edge shape.
         */
        COMBO_BUTTON(.45);

        private final double fRoundedPercentage;

        private Roundedness(double roundedPercentage) {
            fRoundedPercentage = roundedPercentage;
        }

        public int getRoundedDiameter(int controlHeight) {
            int roundedDiameter = (int) (controlHeight * fRoundedPercentage);
            // force the rounded diameter value to be even - odd values look lumpy.
            int makeItEven = roundedDiameter % 2;
            return roundedDiameter - makeItEven;
        }
    }
}

EPComboPopup:

public class EPComboPopup implements ComboPopup {

    private final JComboBox fComboBox;
    private JPopupMenu fPopupMenu = new JPopupMenu();
    private Font fFont;
    private ComboBoxVerticalCenterProvider fComboBoxVerticalCenterProvider =
            new DefaultVerticalCenterProvider();

    private static final int LEFT_SHIFT = 5;

    public EPComboPopup(JComboBox comboBox) {
        fComboBox = comboBox;
        fFont = comboBox.getFont();
    }

    public void setFont(Font font) {
        fFont = font;
    }

    public void setVerticalComponentCenterProvider(
            ComboBoxVerticalCenterProvider comboBoxVerticalCenterProvider) {
        if (comboBoxVerticalCenterProvider == null) {
            throw new IllegalArgumentException(
                    "The given CompnonentCenterProvider cannot be null.");
        }
        fComboBoxVerticalCenterProvider = comboBoxVerticalCenterProvider;
    }

    private void togglePopup() {
        if (isVisible()) {
            hide();
        } else {
            show();
        }
    }

    private void clearAndFillMenu() {
        fPopupMenu.removeAll();

        ButtonGroup buttonGroup = new ButtonGroup();

        // add the given items to the popup menu.
        for (int i = 0; i < fComboBox.getModel().getSize(); i++) {
            Object item = fComboBox.getModel().getElementAt(i);
            JMenuItem menuItem = new JCheckBoxMenuItem(item.toString());
            menuItem.setFont(fFont);
            menuItem.addActionListener(createMenuItemListener(item));
            buttonGroup.add(menuItem);
            fPopupMenu.add(menuItem);

            // if the current item is selected, make the menu item reflect that.
            if (item.equals(fComboBox.getModel().getSelectedItem())) {
                menuItem.setSelected(true);
                fPopupMenu.setSelected(menuItem);
            }
        }

        fPopupMenu.pack();
        int popupWidth = fComboBox.getWidth() + 5;
        int popupHeight = fPopupMenu.getPreferredSize().height;
        fPopupMenu.setPreferredSize(new Dimension(popupWidth, popupHeight));
    }

    private ActionListener createMenuItemListener(final Object comboBoxItem) {
        return new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                fComboBox.setSelectedItem(comboBoxItem);
            }
        };
    }

    private void forceCorrectPopupSelection() {
        assert fPopupMenu.isShowing() :
                "The popup must be showing for this method to work properly.";

        // force the correct item to be shown as selected. this is a
        // work around for Java bug 4740942, which has been fixed by
        // Sun, but not by Apple.
        int index = fPopupMenu.getSelectionModel().getSelectedIndex();
        MenuElement[] menuPath = new MenuElement[2];
        menuPath[0] = fPopupMenu;
        menuPath[1] = fPopupMenu.getSubElements()[index];
        MenuSelectionManager.defaultManager().setSelectedPath(menuPath);
    }

    // ComboPopup implementation. ///////////////////////////////////////////

    public void show() {
        clearAndFillMenu();
        Rectangle popupBounds = calculateInitialPopupBounds();

        fPopupMenu.show(fComboBox, popupBounds.x, popupBounds.y);
        forceCorrectPopupSelection();
    }

    public void hide() {
        fPopupMenu.setVisible(false);
    }

    public boolean isVisible() {
        return fPopupMenu.isVisible();
    }

    /**
     * This method is not implemented and would throw an
     * {@link UnsupportedOperationException} if
     * {@link javax.swing.plaf.basic.BasicComboBoxUI} didn't call it. Thus,
     * this method should not be used, as it always returns null.
     *
     * @return null.
     */
    public JList getList() {
        return null;
    }

    public MouseListener getMouseListener() {
        return new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                // TODO add more detailed logic.
                togglePopup();
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                MenuSelectionManager.defaultManager().processMouseEvent(e);
            }
        };
    }

    public MouseMotionListener getMouseMotionListener() {
        return new MouseMotionAdapter() {
            @Override
            public void mouseDragged(MouseEvent e) {
                MenuSelectionManager.defaultManager().processMouseEvent(e);
            }
        };
    }

    public KeyListener getKeyListener() {
        return null;
    }

    public void uninstallingUI() {
        // TODO implement, if necessary.
    }

    /**
     * An interface to allow a third-party to provide the center of a given
     * compoennt.
     */
    public interface ComboBoxVerticalCenterProvider {
        int provideCenter(JComboBox comboBox);
    }

    // A default implementation of ComboBoxVerticalCenterProvider. ///////////

    private static class DefaultVerticalCenterProvider
            implements ComboBoxVerticalCenterProvider {
        public int provideCenter(JComboBox comboBox) {
            return comboBox.getHeight() / 2;
        }
    }

    // Utility methods. //////////////////////////////////////////////////////

    private Rectangle calculateInitialPopupBounds() {
        // grab the right most location of the button.
        int comboBoxRightEdge = fComboBox.getWidth();

        // figure out how the height of a menu item.
        Insets insets = fPopupMenu.getInsets();

        // calculate the x and y value at which to place the popup menu. by
        // default, this will place the selected menu item in the popup item
        // directly over the button.
        int x = comboBoxRightEdge - fPopupMenu.getPreferredSize().width - LEFT_SHIFT;
        int selectedItemIndex =
                fPopupMenu.getSelectionModel().getSelectedIndex();
        int componentCenter =
                fComboBoxVerticalCenterProvider.provideCenter(fComboBox);
        int menuItemHeight =
                fPopupMenu.getComponent(selectedItemIndex).getPreferredSize().height;
        int menuItemCenter =
                insets.top + (selectedItemIndex * menuItemHeight) + menuItemHeight / 2;
        int y = componentCenter - menuItemCenter;

        // TODO this method doesn't robustly handle multiple monitors.

        return new Rectangle(new Point(x,y), fPopupMenu.getPreferredSize());
    }
}

hudcheckboxui

The check box is the next widget we’ll add to our HUD style component toolkit. Most of the painting happens in the custom icon I’ve implemented, which handles the rendering of the actual check and box. There are a bunch of hard-coded values (like 12 for the size of the checkbox) – I’ll leave it as an exercise to the reader to make the code more robust.

Also, you’ll probably notice a bit of redundant code from the post on creating a HUD style button. I’ve done this to keep the blog entries independent, but the code that will be incorporated into Mac Widgets for Java 0.9.4 will use a utility class to do most of the HUD painting.

Finally, as I noted in the HUD button post, for this widget to look “correct”, it must be added to a HUD style window, which I talked about here. The button is quite transparent, so it ends up looking a lot like what it’s added to.

public class HudCheckBoxUI extends BasicCheckBoxUI {

    /* Font constants. */
    public static final float FONT_SIZE = 11.0f;
    public static final Color FONT_COLOR = Color.WHITE;

    /* Color constants. */
    private static final Color TOP_COLOR = new Color(170,170,170,50);
    private static final Color BOTTOM_COLOR = new Color(17,17,17,50);
    private static final Color TOP_PRESSED_COLOR = new Color(249,249,249,153);
    private static final Color BOTTOM_PRESSED_COLOR = new Color(176,176,176,153);
    private static final Color LIGHT_SHADOW_COLOR = new Color(0,0,0,145);
    private static final Color DARK_SHADOW_COLOR = new Color(0,0,0,50);

    /* Border constants. */
    private static final Color BORDER_COLOR = new Color(0xc5c8cf);
    private static final int BORDER_WIDTH = 1;

    /* Margin constants. */
    private static final int TOP_AND_BOTTOM_MARGIN = 2;
    private static final int LEFT_AND_RIGHT_MARGIN = 16;

    private static final Color PRESSED_CHECK_MARK_COLOR = new Color(0, 0, 0, 225);

    @Override
    protected void installDefaults(final AbstractButton b) {
        super.installDefaults(b);

        b.setIconTextGap((int) (FONT_SIZE / 2));
        icon = new CheckIcon();

        // TODO save original values.

        b.setFont(getHudFont());
        b.setForeground(FONT_COLOR);
        b.setOpaque(false);
        // add space for the drop shadow underneath the button.
        int bottomMargin = TOP_AND_BOTTOM_MARGIN + getHudControlShadowSize();
        b.setBorder(BorderFactory.createEmptyBorder(TOP_AND_BOTTOM_MARGIN,
                LEFT_AND_RIGHT_MARGIN,bottomMargin, LEFT_AND_RIGHT_MARGIN));
    }

    // Check icon implementation. ////////////////////////////////////////////

    private static class CheckIcon implements Icon {

        private final int CHECK_BOX_SIZE = 12;

        public void paintIcon(Component c, Graphics g, int x, int y) {

            AbstractButton button = (AbstractButton) c;

            Graphics2D graphics = (Graphics2D) g.create();
            // translate the graphics context so that 0,0 is the top/left of
            // the check box. this allows us to then delegate the painting to
            // the HudPaintingUtils method, which assumes 0,0.
            graphics.translate(x, y);
            paintHudControlBackground(graphics, button, CHECK_BOX_SIZE,
                    CHECK_BOX_SIZE, Roundedness.CHECK_BOX);
            drawCheckMarkIfNecessary(graphics, button.getModel());
            graphics.dispose();
        }

        /**
         * Draws the check mark if {@link javax.swing.ButtonModel#isSelected}
         * returns true.
         */
        private void drawCheckMarkIfNecessary(Graphics2D graphics,
                                              ButtonModel model) {
            if (model.isSelected()) {
                drawCheckMark(graphics, model);
            }
        }

        /**
         * Draws the check in the check box using the appropriate color based on
         * the {@link ButtonModel#isPressed} state. Note that part of the check
         * will be drawn outside it's bounds. Because this icon is actually being
         * drawn inside a larger component (a {@link javax.swing.JCheckBox}), this
         * shouldn't be an issue.
         */
        private void drawCheckMark(Graphics2D graphics, ButtonModel model) {
            int x1 = CHECK_BOX_SIZE / 4;
            int y1 = CHECK_BOX_SIZE / 3;
            int x2 = x1 + CHECK_BOX_SIZE / 6;
            int y2 = y1 + CHECK_BOX_SIZE / 4;
            int x3 = CHECK_BOX_SIZE - 2;
            int y3 = -1;

            Color color = model.isPressed() ?
                    PRESSED_CHECK_MARK_COLOR : FONT_COLOR;

            graphics.setStroke(new BasicStroke(1.65f, BasicStroke.CAP_SQUARE,
                    BasicStroke.JOIN_ROUND));
            graphics.setColor(color);
            graphics.drawLine(x1, y1, x2, y2);
            graphics.drawLine(x2, y2, x3, y3);
        }

        public int getIconWidth() {
            return CHECK_BOX_SIZE;
        }

        public int getIconHeight() {
            return CHECK_BOX_SIZE;
        }

    }

    // Utility methods. ///////////////////////////////////////////////////////

    /**
     * Paints a HUD style button background onto the given {@link Graphics2D}
     * context. The background will be painted from 0,0 to width/height.
     * @param graphics the graphics context to paint onto.
     * @param button the button being painted.
     * @param width the width of the area to paint.
     * @param height the height of the area to paint.
     * @param roundedness the roundedness to use when painting the background.
     */
    private static void paintHudControlBackground(
            Graphics2D graphics, AbstractButton button, int width, int height,
            Roundedness roundedness) {
        graphics.setRenderingHint(
                RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        // TODO replace with real drop shadow painting. see Romain Guy's article
        // TODO for more info on real drop shadows:
        // TODO   http://www.jroller.com/gfx/entry/fast_or_good_drop_shadows

        // paint a light shadow line further away from the button.
        graphics.setColor(LIGHT_SHADOW_COLOR);
        int arcDiameter = roundedness.getRoundedDiameter(height);
        graphics.drawRoundRect(0,0,width-1,height,arcDiameter,arcDiameter);

        // paint a dark shadow line closer to the button.
        graphics.setColor(DARK_SHADOW_COLOR);
        int smallerShadowArcDiameter = height-1;
        graphics.drawRoundRect(0,0,width-1,height+1,smallerShadowArcDiameter,
                smallerShadowArcDiameter);

        // fill the button with the gradient paint.
        graphics.setPaint(createButtonPaint(button, BORDER_WIDTH));
        graphics.fillRoundRect(0,1,width,height-1,arcDiameter,arcDiameter);

        // draw the border around the button.
        graphics.setColor(BORDER_COLOR);
        graphics.drawRoundRect(0,0,width-1,height-1,arcDiameter,arcDiameter);
    }

    /**
     * Creates a HUD style gradient paint for the given button offset from the top
     * and bottom of the button by the given line border size.
     */
    private static Paint createButtonPaint(AbstractButton button,
                                           int lineBorderWidth) {
        boolean isPressed = button.getModel().isPressed();
        Color topColor = isPressed ? TOP_PRESSED_COLOR : TOP_COLOR;
        Color bottomColor = isPressed ? BOTTOM_PRESSED_COLOR : BOTTOM_COLOR;
        int bottomY = button.getHeight()-lineBorderWidth*2;
        return new GradientPaint(0,lineBorderWidth,topColor,0,bottomY,bottomColor);
    }

    /**
     * Gets the number of pixels that a HUD style widget's shadow takes up. HUD
     * button's have a shadow directly below them, that is, there is no top, left
     * or right component to the shadow.
     * @return the number of pixels that a HUD style widget's shadow takes up.
     */
    private static int getHudControlShadowSize() {
        // this is hardcoded at two pixels for now, but ideally it would be
        // calculated.
        return 2;
    }

    /**
     * Gets the font used by HUD style widgets.
     * @return the font used by HUD style widgets.
     */
    private static Font getHudFont() {
        return UIManager.getFont("Button.font").deriveFont(Font.BOLD, FONT_SIZE);
    }

    /**
     * An enumeration representing the roundness styles of HUD buttons. Using this
     * enumeration will make it easier to transition this code to support more
     * HUD controls, like check boxes and combo buttons.
     */
    public enum Roundedness {
        /**
         * A roundedness of 40%, equates to slightly rounded edge.
         */
        CHECK_BOX(.4);

        private final double fRoundedPercentage;

        private Roundedness(double roundedPercentage) {
            fRoundedPercentage = roundedPercentage;
        }

        private int getRoundedDiameter(int controlHeight) {
            int roundedDiameter = (int) (controlHeight * fRoundedPercentage);
            // force the rounded diameter value to be even - odd values look lumpy.
            int makeItEven = roundedDiameter % 2;
            return roundedDiameter - makeItEven;
        }
    }
}