2006-05-22

Making JTable look more at home on Mac OS and GNOME

Back in 2004 I wrote Alternating row colors in JTable which dealt with, as the title suggests, getting alternating row colors in a JTable, in a subclass called ETable. Last week, Daniel Lopez mailed me Creating an iTunes-like JTable where he fixes a clip-rectangle related bug in ETable, adds a tool tips, and gets rid of the border Swing puts on the selected cell.

The latest version of ETable, available from the salma-hayek link on this page, already had a better tool tip implementation. ETable automatically measures the text in a table cell and sets the tool tip to the full text if the cell is too narrow to show it all. So the "..." acts as a visual cue that there's something extra to see, and there's no otherwise-invisible information that only appears in a tool tip (where you can't control how long it stays visible or copy it to the clipboard).

Daniel suggested that it would be even better if it behaved like MS Outlook, which places the tool tip over the cell (rather than below and to the right, which is more typical tool tip behavior) so it looks like the tool tip is simply extending the truncated cell's text. I've always liked the effect (and its guarantee that the tool tip doesn't bob back and forth horizontally as you move your mouse up and down a column), so I now override JComponent.getToolTipLocation.

There are two slightly interesting aspects of the implementation. The first is the use of JComponent.getToolTipText(MouseEvent) rather than the more usual JComponent.getToolTipText(). The latter would just give us the table's global default tool tip, rather than delegating to the appropriate cell's renderer. The other is that even if you've correctly set a null tool tip, unless getToolTipLocation also returns null, Swing will display an empty tool tip if the tool tip is already up and you move over a cell that doesn't need a tool tip. (Hovering over the cell when there isn't already a tool tip up won't however cause a tool tip to appear.) I think there's a bug in ToolTipManager.checkForTipChange in that it should say newText != null && newPreferredLocation != null instead of newText != null || newPreferredLocation != null, but I can't be bothered to report it. (If you can, send me the bug number and I'll add a link.)

Daniel's method of getting rid of the border on the selected cell was interesting, though. Setting a custom border on a cell renderer is a technique I'd used before in SCM to render a list where items are in groups with dotted horizontal lines between the groups, but I hadn't thought of using it to solve the problem with JTable that it (in contrast to most native toolkits) thinks that the selected table cell should be marked with a focus rectangle.

Daniel wanted an effect where the whole row (rather than just the clicked-on cell) gets the border. He used a different border on the first cell in a row, the last cell in the row, and a third border for all the cells in the middle. This let him produce a Windows-like effect where the whole selected row has a focus rectangle.

If you look at iTunes, you'll see that on Mac OS there are no focus rectangles at all. There is a line at the bottom of each selected row, though. And there are those vertical lines that Apple's LAF doesn't draw. It's easy to adapt Daniel's technique to produce this effect on Mac OS, and to simply remove the focus rectangle on GNOME.

There are other Mac OS problems that have been annoying me. The upper right corner of an enclosing JScrollPane doesn't look like a table header, for one thing. And a native table's selection changes color when it loses the focus.

There were also various GNOME problems. The most problematic for me being that the default cell renderer for boolean columns (a JCheckBox) was messing up the alternate row coloring.

Anyway, I spent this afternoon polishing ETable, and now it looks a lot closer on both Mac OS and GNOME. Here's how it looks on Mac OS now:



It's obvious that the JCheckBox is too large, but I haven't had any success working around that yet, and I'm hoping Apple fix it before I get round to wasting more time on it.

And here's the ETable source at the time of writing. As always, the latest version (and the accompanying helper classes) is in the salma-hayek source download.

package e.gui;

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import javax.swing.border.*;
import javax.swing.table.*;
import e.util.*;

/**
* A better-looking table than JTable. In particular, on Mac OS this looks
* more like a Cocoa table than the default Aqua LAF manages.
*
* @author Elliott Hughes
*/
public class ETable extends JTable {
private static final Color MAC_FOCUSED_SELECTED_CELL_HORIZONTAL_LINE_COLOR = new Color(0x7daaea);
private static final Color MAC_UNFOCUSED_SELECTED_CELL_HORIZONTAL_LINE_COLOR = new Color(0xe0e0e0);

private static final Color MAC_UNFOCUSED_SELECTED_CELL_BACKGROUND_COLOR = new Color(0xc0c0c0);

private static final Color MAC_FOCUSED_UNSELECTED_VERTICAL_LINE_COLOR = new Color(0xd9d9d9);
private static final Color MAC_FOCUSED_SELECTED_VERTICAL_LINE_COLOR = new Color(0x346dbe);
private static final Color MAC_UNFOCUSED_UNSELECTED_VERTICAL_LINE_COLOR = new Color(0xd9d9d9);
private static final Color MAC_UNFOCUSED_SELECTED_VERTICAL_LINE_COLOR = new Color(0xacacac);

public ETable() {
// Although it's the JTable default, most systems' tables don't draw a grid by default.
// Worse, it's not easy (or possible?) for us to take over grid painting ourselves for those LAFs (Metal, for example) that do paint grids.
// The Aqua and GTK LAFs ignore the grid settings anyway, so this causes no change there.
setShowGrid(false);

// Tighten the cells up, and enable the manual painting of the vertical grid lines.
setIntercellSpacing(new Dimension());

// Table column re-ordering is too badly implemented to enable.
getTableHeader().setReorderingAllowed(false);

if (GuiUtilities.isMacOs()) {
// Work-around for Apple 4352937.
JLabel.class.cast(getTableHeader().getDefaultRenderer()).setHorizontalAlignment(SwingConstants.LEADING);

// Use an iTunes-style vertical-only "grid".
setShowHorizontalLines(false);
setShowVerticalLines(true);
}
}

/**
* Paints empty rows too, after letting the UI delegate do
* its painting.
*/
public void paint(Graphics g) {
super.paint(g);
paintEmptyRows(g);
}

/**
* Paints the backgrounds of the implied empty rows when the
* table model is insufficient to fill all the visible area
* available to us. We don't involve cell renderers, because
* we have no data.
*/
protected void paintEmptyRows(Graphics g) {
final int rowCount = getRowCount();
final Rectangle clip = g.getClipBounds();
final int height = clip.y + clip.height;
if (rowCount * rowHeight < height) {
for (int i = rowCount; i <= height/rowHeight; ++i) {
g.setColor(colorForRow(i));
g.fillRect(clip.x, i * rowHeight, clip.width, rowHeight);
}

// Mac OS' Aqua LAF never draws vertical grid lines, so we have to draw them ourselves.
if (GuiUtilities.isMacOs() && getShowVerticalLines()) {
g.setColor(MAC_UNFOCUSED_UNSELECTED_VERTICAL_LINE_COLOR);
TableColumnModel columnModel = getColumnModel();
int x = 0;
for (int i = 0; i < columnModel.getColumnCount(); ++i) {
TableColumn column = columnModel.getColumn(i);
x += column.getWidth();
g.drawLine(x - 1, rowCount * rowHeight, x - 1, height);
}
}
}
}

/**
* Changes the behavior of a table in a JScrollPane to be more like
* the behavior of JList, which expands to fill the available space.
* JTable normally restricts its size to just what's needed by its
* model.
*/
public boolean getScrollableTracksViewportHeight() {
if (getParent() instanceof JViewport) {
JViewport parent = (JViewport) getParent();
return (parent.getHeight() > getPreferredSize().height);
}
return false;
}

/**
* Returns the appropriate background color for the given row.
*/
protected Color colorForRow(int row) {
return (row % 2 == 0) ? alternateRowColor() : getBackground();
}

private Color alternateRowColor() {
return GuiUtilities.isGtk() ? Color.WHITE : GuiUtilities.ALTERNATE_ROW_COLOR;
}

/**
* Shades alternate rows in different colors.
*/
public Component prepareRenderer(TableCellRenderer renderer, int row, int column) {
Component c = super.prepareRenderer(renderer, row, column);
boolean focused = hasFocus();
boolean selected = isCellSelected(row, column);
if (selected) {
if (GuiUtilities.isMacOs() && focused == false) {
// Native Mac OS renders the selection differently if the table doesn't have the focus.
// The Mac OS LAF doesn't imitate this for us.
c. setBackground(MAC_UNFOCUSED_SELECTED_CELL_BACKGROUND_COLOR);
c.setForeground(UIManager.getColor("Table.foreground"));
} else {
c.setBackground(UIManager.getColor("Table.selectionBackground"));
c.setForeground(UIManager.getColor("Table.selectionForeground"));
}
} else {
// Outside of selected rows, we want to alternate the background color.
c.setBackground(colorForRow(row));
c.setForeground(UIManager.getColor("Table.foreground"));
}

if (c instanceof JComponent) {
JComponent jc = (JComponent) c;

// The Java 6 GTK LAF JCheckBox doesn't paint its background by default.
// Sun 5043225 says this is the intended behavior, though presumably not when it's being used as a table cell renderer.
if (GuiUtilities.isGtk() && c instanceof JCheckBox) {
jc.setOpaque(true);
}

if (getCellSelectionEnabled() == false && isEditing() == false) {
if (GuiUtilities.isMacOs()) {
// Native Mac OS doesn't draw a border on the selected cell.
// It does however draw a horizontal line under the whole row, and a vertical line separating each column.
fixMacOsCellRendererBorder(jc, selected, focused);
} else {
// FIXME: doesn't Windows have row-wide selection focus?
// Hide the cell focus.
jc.setBorder(null);
}
}

initToolTip(jc, row, column);
}

return c;
}

private void fixMacOsCellRendererBorder(JComponent renderer, boolean selected, boolean focused) {
Border border;
if (selected) {
border = BorderFactory.createMatteBorder(0, 0, 1, 0, focused ? MAC_FOCUSED_SELECTED_CELL_HORIZONTAL_LINE_COLOR : MAC_UNFOCUSED_SELECTED_CELL_HORIZONTAL_LINE_COLOR);
} else {
border = BorderFactory.createEmptyBorder(0, 0, 1, 0);
}

// Mac OS' Aqua LAF never draws vertical grid lines, so we have to draw them ourselves.
if (getShowVerticalLines()) {
Color verticalLineColor;
if (focused) {
verticalLineColor = selected ? MAC_FOCUSED_SELECTED_VERTICAL_LINE_COLOR : MAC_FOCUSED_UNSELECTED_VERTICAL_LINE_COLOR;
} else {
verticalLineColor = selected ? MAC_UNFOCUSED_SELECTED_VERTICAL_LINE_COLOR : MAC_UNFOCUSED_UNSELECTED_VERTICAL_LINE_COLOR;
}
Border verticalBorder = BorderFactory.createMatteBorder(0, 0, 0, 1, verticalLineColor);
border = BorderFactory.createCompoundBorder(border, verticalBorder);
}

renderer.setBorder(border);
}

/**
* Sets the component's tool tip if the component is being rendered smaller than its preferred size.
* This means that all users automatically get tool tips on truncated text fields that show them the full value.
*/
private void initToolTip(JComponent c, int row, int column) {
String toolTipText = null;
if (c.getPreferredSize().width > getCellRect(row, column, false).width) {
toolTipText = getValueAt(row, column).toString();
}
c.setToolTipText(toolTipText);
}

/**
* Places tool tips over the cell they correspond to. MS Outlook does this, and it works rather well.
* Swing will automatically override our suggested location if it would cause the tool tip to go off the display.
*/
@Override
public Point getToolTipLocation(MouseEvent e) {
// After a tool tip has been displayed for a cell that has a tool tip, cells without tool tips will show an empty tool tip until the tool tip mode times out (or the table has a global default tool tip).
// (ToolTipManager.checkForTipChange considers a non-null result from getToolTipText *or* a non-null result from getToolTipLocation as implying that the tool tip should be displayed. This seems like a bug, but that's the way it is.)
if (getToolTipText(e) == null) {
return null;
}
final int row = rowAtPoint(e.getPoint());
final int column = columnAtPoint(e.getPoint());
if (row == -1 || column == -1) {
return null;
}
return getCellRect(row, column, false).getLocation();
}

/**
* Improve the appearance of of a table in a JScrollPane on Mac OS, where there's otherwise an unsightly hole.
*/
@Override
protected void configureEnclosingScrollPane() {
super.configureEnclosingScrollPane();

if (GuiUtilities.isMacOs() == false) {
return;
}

Container p = getParent();
if (p instanceof JViewport) {
Container gp = p.getParent();
if (gp instanceof JScrollPane) {
JScrollPane scrollPane = (JScrollPane)gp;
// Make certain we are the viewPort's view and not, for
// example, the rowHeaderView of the scrollPane -
// an implementor of fixed columns might do this.
JViewport viewport = scrollPane.getViewport();
if (viewport == null || viewport.getView() != this) {
return;
}

// JTable copy & paste above this point; our code below.

// Put a dummy header in the upper-right corner.
final Component renderer = new JTableHeader().getDefaultRenderer().getTableCellRendererComponent(null, "", false, false, -1, 0);
JPanel panel = new JPanel(new BorderLayout());
panel.add(renderer, BorderLayout.CENTER);
scrollPane.setCorner(JScrollPane.UPPER_RIGHT_CORNER, panel);
}
}
}
}

Hopefully as time passes, more of this will move into JTable and its UI delegates, but for now, we're stuck with work-arounds like this.