Search code examples
javaswinglook-and-feelswingxjxtreetable

JXTreeTable, row striping and proper repainting


I'm using a look and feel which does row striping of tables by default. When I put in a JXTreeTable, I noticed that for some reason it didn't get the row striping automatically.

So I put in a workaround using Highlighter, but it looks like I get a repainting glitch:

screenshot showing the painting glitch

It seems like JXTreeTable is specifically repainting only the boundaries of the text instead of the whole cell. I have been trying to catch this in the debugger to figure out why, but every time I switch between programs, the whole window repaints, so it's nearly impossible to catch this kind of thing.

JTable and JTree both behave sanely. This look and feel is one which paints the whole row of a JTree (like Quaqua and Synth), so maybe that has something to do with it too. Perhaps JXTreeTable has some kind of assumption that the look and feel won't paint the rows of the tree? If so, is there a way to work around that? It won't just be this look and feel which has the issue.

The code:

import org.jdesktop.swingx.JXTreeTable;
import org.jdesktop.swingx.decorator.AbstractHighlighter;
import org.jdesktop.swingx.decorator.ComponentAdapter;
import org.jdesktop.swingx.decorator.Highlighter;
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode;
import org.jdesktop.swingx.treetable.DefaultTreeTableModel;
import org.jdesktop.swingx.treetable.TreeTableModel;
import org.trypticon.haqua.HaquaLookAndFeel;

import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.WindowConstants;
import java.awt.BorderLayout;
import java.awt.Component;
import java.util.Arrays;

public class TreeTableDemo2 implements Runnable {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(new TreeTableDemo2());
    }

    @Override
    public void run() {
        try {
            UIManager.setLookAndFeel(new HaquaLookAndFeel());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        JFrame frame = new JFrame("Tree Table Demo");
        frame.setLayout(new BorderLayout());
        frame.add(createPanel(), BorderLayout.CENTER);
        frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
        frame.pack();
        frame.setVisible(true);
    }

    public JPanel createPanel() {
        JPanel panel = new JPanel(new BorderLayout());

        TreeTableModel treeTableModel = new DummyTreeTableModel();
        JXTreeTable treeTable = new FixedTreeTable(treeTableModel);
        JScrollPane treeTableScroll = new JScrollPane(treeTable);

        panel.add(treeTableScroll, BorderLayout.CENTER);
        return panel;
    }

    private static class FixedTreeTable extends JXTreeTable {
        private static final Highlighter oddRowHighlighter = new AbstractHighlighter() {
            @Override
            protected Component doHighlight(Component component, ComponentAdapter componentAdapter) {
                if (componentAdapter.row % 2 != 0 &&
                        !componentAdapter.isSelected()) {
                    component.setBackground(UIManager.getColor("Table.alternateRowColor"));
                }
                return component;
            }
        };

        public FixedTreeTable(TreeTableModel treeModel) {
            super(treeModel);

            // This hack makes it paint correctly after releasing the mouse, which is not quite good enough.
//            getSelectionModel().addListSelectionListener(new ListSelectionListener() {
//                @Override
//                public void valueChanged(ListSelectionEvent e) {
//                    Rectangle repaintRange = getCellRect(e.getFirstIndex(), 0, true);
//                    repaintRange.add(getCellRect(e.getLastIndex(), 0, true));
//                    repaint(repaintRange);
//                }
//            });
        }

        @Override
        public void updateUI() {
            removeHighlighter(oddRowHighlighter);

            super.updateUI();

            // JTable does this striping automatically but JXTable's default renderer
            // seems to ignore it, so JXTreeTable inherits this broken behaviour.
            if (UIManager.get("Table.alternateRowColor") != null) {
                addHighlighter(oddRowHighlighter);
            }
        }
    }

    private static class DummyTreeTableNode extends DefaultMutableTreeTableNode {
        private final Object[] values;

        private DummyTreeTableNode(String name) {
            super(name);
            values = new Object[5];
            values[0] = name;
        }

        private DummyTreeTableNode(Object... values) {
            super(values[0]);
            this.values = values;
        }

        @Override
        public Object getValueAt(int column) {
            return values[column];
        }
    }

    private static class DummyTreeTableModel extends DefaultTreeTableModel {
        private static DefaultMutableTreeTableNode rootNode = new DefaultMutableTreeTableNode();
        static {
            DefaultMutableTreeTableNode blue = new DefaultMutableTreeTableNode("Blue");
            blue.add(new DummyTreeTableNode("Orionis C",          33000,  30000.0,    18.0,   5.90));
            rootNode.add(blue);

            DefaultMutableTreeTableNode bluish = new DefaultMutableTreeTableNode("Bluish");
            bluish.add(new DummyTreeTableNode("Becrux",             30000,  16000.0,    16.0,   5.70));
            bluish.add(new DummyTreeTableNode("Spica",              22000,  8300.0,     10.5,   5.10));
            bluish.add(new DummyTreeTableNode("Achernar",           15000,  750.0,      5.40,   3.70));
            bluish.add(new DummyTreeTableNode("Rigel",              12500,  130.0,      3.50,   2.70));
            rootNode.add(bluish);

            DefaultMutableTreeTableNode blueWhite = new DefaultMutableTreeTableNode("Blue-White");
            blueWhite.add(new DummyTreeTableNode("Sirius A",           9500,   63.0,       2.60,   2.30));
            blueWhite.add(new DummyTreeTableNode("Fomalhaut",          9000,   40.0,       2.20,   2.00));
            blueWhite.add(new DummyTreeTableNode("Altair",             8700,   24.0,       1.90,   1.80));
            rootNode.add(blueWhite);

            DefaultMutableTreeTableNode white = new DefaultMutableTreeTableNode("White");
            white.add(new DummyTreeTableNode("Polaris A",          7400,   9.0,        1.60,   1.50));
            white.add(new DummyTreeTableNode("Eta Scorpii",        7100,   6.3,        1.50,   1.30));
            white.add(new DummyTreeTableNode("Procyon A",          6400,   4.0,        1.35,   1.20));
            rootNode.add(white);

            DefaultMutableTreeTableNode yellowWhite = new DefaultMutableTreeTableNode("Yellow-White");
            yellowWhite.add(new DummyTreeTableNode("Alpha Centauri A",   5900,   1.45,       1.08,   1.05));
            yellowWhite.add(new DummyTreeTableNode("The Sun",            5800,   100.0,      1.00,   1.00));
            yellowWhite.add(new DummyTreeTableNode("Mu Cassiopeiae",     5600,   0.70,       0.95,   0.91));
            yellowWhite.add(new DummyTreeTableNode("Tau Ceti",           5300,   0.44,       0.85,   0.87));
            rootNode.add(yellowWhite);

            DefaultMutableTreeTableNode orange = new DefaultMutableTreeTableNode("Orange");
            orange.add(new DummyTreeTableNode("Pollux",             5100,   0.36,       0.83,   0.83));
            orange.add(new DummyTreeTableNode("Epsilon Eridani",    4830,   0.28,       0.78,   0.79));
            orange.add(new DummyTreeTableNode("Alpha Centauri B",   4370,   0.18,       0.68,   0.74));
            rootNode.add(orange);

            DefaultMutableTreeTableNode red = new DefaultMutableTreeTableNode("Red");
            red.add(new DummyTreeTableNode("Lalande 21185",      3400,   0.03,       0.33,   0.36));
            red.add(new DummyTreeTableNode("Ross 128",           3200,   0.0005,     0.20,   0.21));
            red.add(new DummyTreeTableNode("Wolf 359",           3000,   0.0002,     0.10,   0.12));
            rootNode.add(red);
        }

        private static final Object[] columnNames = {
                "Star", "Temperature (K)", "Luminosity", "Mass", "Radius"
        };

        public DummyTreeTableModel() {
            super(rootNode, Arrays.asList(columnNames));
        }

        @Override
        public Class<?> getColumnClass(int columnIndex) {
            if (columnIndex == 0) {
                return String.class;
            } else {
                return Double.class;
            }
        }

        @Override
        public boolean isCellEditable(Object node, int column) {
            return false;
        }
    }
}

Further investigation round 1:

I finally managed to trap the condition in the debugger by watching a paint method in JXTreeTable. What I see is that it has something called ClippedTreeCellRenderer which has some fields which look like they correspond to the suspicious behaviour:

iconRect = {java.awt.Rectangle@2946}"java.awt.Rectangle[x=20,y=92,width=16,height=16]"
textRect = {java.awt.Rectangle@2947}"java.awt.Rectangle[x=20,y=-17,width=62,height=15]"
itemRect = {java.awt.Rectangle@2948}"java.awt.Rectangle[x=20,y=36,width=103,height=18]"

I haven't yet confirmed that it is definitely using this value to paint the rectangle, but textRect is exactly the size of the small window it does repaint. So now the question is, where on earth is JXTreeTable pulling these values from and why is it using them?

My instinct tells me that JXTreeTable's renderer is somehow using the tree cell renderer to render the cell directly instead of just telling the tree itself to paint. The logic for painting the row backgrounds and the expand/collapse icons is in the tree, not in the cells, so if it's doing that, it would make sense that it isn't consistently painting the tree.

Further investigation round 2:

I think I was on the wrong path entirely. It looks like the whole row is being painted, but tree.isPathSelected(path) returns false for the newly-selected blue rows and returns true for the just-deselected row.

I can confirm by breakpoints in DefaultTreeSelectionModel that the tree selection is only updated after you let go of the mouse, which is why it eventually returns to being rendered correctly.

I'll have to dig further into JXTreeTable to see how it's keeping those in sync.


Solution

  • I found the bug. It looks like some well-meaning code in JXTreeTable.java which is trying to reduce updates:

        /**
         * Class responsible for calling updateSelectedPathsFromSelectedRows
         * when the selection of the list changse.
         */
        class ListSelectionHandler implements ListSelectionListener {
            @Override
            public void valueChanged(ListSelectionEvent e) {
                if (!e.getValueIsAdjusting()) {
                    updateSelectedPathsFromSelectedRows();
                }
            }
        }
    

    If you remove that if check, everything works correctly. I'll just have to make some local changes to SwingX I guess, since the project is essentially dead. :(