/***************************************************************** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. ****************************************************************/ package org.apache.cayenne.modeler.util; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Dimension; import java.awt.GridLayout; import java.awt.Rectangle; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import javax.swing.AbstractListModel; import javax.swing.DefaultListCellRenderer; import javax.swing.ImageIcon; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JViewport; import javax.swing.ListCellRenderer; import javax.swing.ListSelectionModel; import javax.swing.border.Border; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.event.TreeSelectionEvent; import javax.swing.event.TreeSelectionListener; import javax.swing.tree.TreeModel; import javax.swing.tree.TreePath; /** * A simple non-editable tree browser with multiple columns for display and * navigation of a tree structure. This type of browser is ideal for showing * deeply (or infinitely) nested trees/graphs. The most famous example of its * use is Mac OS X Finder column view. * * <p> * MultiColumnBrowser starts at the root of the tree and automatically expands * to the right as navigation goes deeper. MultiColumnBrowser uses the same * TreeModel as a regular JTree for its navigation model. * </p> * * <p> * Users are notified of selection changes via a TreeSelectionEvents. * </p> * * @since 1.1 */ public class MultiColumnBrowser extends JPanel { protected static final ImageIcon rightArrow = ModelerUtil.buildIcon("icon-arrow-closed.png"); public static final int DEFAULT_MIN_COLUMNS_COUNT = 3; protected int minColumns; protected ListCellRenderer renderer; protected TreeModel model; protected Object[] selectionPath; protected Dimension preferredColumnSize; protected List<BrowserPanel> columns; protected ListSelectionListener browserSelector; protected List<TreeSelectionListener> treeSelectionListeners; /** * Whether firing of TreeSelectionListeners is disabled now */ private boolean fireDisabled; public MultiColumnBrowser() { this(DEFAULT_MIN_COLUMNS_COUNT); } public MultiColumnBrowser(int minColumns) { if (minColumns < DEFAULT_MIN_COLUMNS_COUNT) { throw new IllegalArgumentException("Expected " + DEFAULT_MIN_COLUMNS_COUNT + " or more columns, got: " + minColumns); } this.minColumns = minColumns; this.browserSelector = new PanelController(); this.treeSelectionListeners = new ArrayList<TreeSelectionListener>(); initView(); } public void addTreeSelectionListener(TreeSelectionListener listener) { if (listener != null && !treeSelectionListeners.contains(listener)) { treeSelectionListeners.add(listener); } } public void removeTreeSelectionListener(TreeSelectionListener listener) { treeSelectionListeners.remove(listener); } /** * Notifies listeners of a tree selection change. */ protected void fireTreeSelectionEvent(Object[] selectionPath) { if (fireDisabled) { return; } TreeSelectionEvent e = new TreeSelectionEvent(this, new TreePath(selectionPath), false, null, null); for (TreeSelectionListener listener : treeSelectionListeners) { listener.valueChanged(e); } } /** * Returns current selection path or null if no selection is made. */ public TreePath getSelectionPath() { return new TreePath(selectionPath); } /** * Sets new selection path and fires an event */ public void setSelectionPath(TreePath path) { try { fireDisabled = true; for (int i = 0; i < path.getPathCount(); i++) { selectRow(path.getPathComponent(i), i, path); } } finally { fireDisabled = false; } } /** * Selects one path component */ protected void selectRow(Object row, int index, TreePath path) { if (index > 0 && columns.get(index - 1).getSelectedValue() != row) { columns.get(index - 1).setSelectedValue(row, true); } else { // update updateFromModel(row, index - 1); } } /** * Returns the minumum number of displayed columns. */ public int getMinColumns() { return minColumns; } /** * Sets the minumum number of displayed columns. */ public void setMinColumns(int minColumns) { this.minColumns = minColumns; } /** * Returns prefrred size of a single browser column. */ public Dimension getPreferredColumnSize() { return preferredColumnSize; } public void setPreferredColumnSize(Dimension preferredColumnSize) { this.preferredColumnSize = preferredColumnSize; refreshPreferredSize(); } /** * Resets currently used renderer to default one that will use the "name" * property of objects and display a small arrow to the right of all * non-leaf nodes. */ public void setDefaultRenderer() { if (!(renderer instanceof MultiColumnBrowserRenderer)) { setRenderer(new MultiColumnBrowserRenderer()); } } /** * Returns ListCellRenderer for individual elements of each column. */ public ListCellRenderer getRenderer() { return renderer; } /** * Initializes the renderer of column cells. */ public void setRenderer(ListCellRenderer renderer) { if (this.renderer != renderer) { this.renderer = renderer; // update existing browser if (columns != null && columns.size() > 0) { for (JList column : columns) column.setCellRenderer(renderer); } } } /** * Initializes browser model. */ public void setModel(TreeModel model) { if (model == null) { throw new NullPointerException("Null tree model."); } if (this.model != model) { this.model = model; // display first column updateFromModel(model.getRoot(), -1); } } /** * Returns browser model. */ public TreeModel getModel() { return model; } /** * Returns a current number of columns. */ public int getColumnsCount() { return columns.size(); } // ==================================================== // Internal private methods // ==================================================== private void initView() { columns = new ArrayList<BrowserPanel>(minColumns); adjustViewColumns(minColumns); } /** * Expands or contracts the view by <code>delta</code> columns. Never * contracts the view below <code>minColumns</code>. */ private void adjustViewColumns(int delta) { if (delta == 0) { return; } setLayout(new GridLayout(1, columns.size() + delta, 3, 3)); if (delta > 0) { for (int i = 0; i < delta; i++) { appendColumn(); } } else { for (int i = -delta; i > 0 && columns.size() > minColumns; i--) { removeLastColumn(); } } refreshPreferredSize(); revalidate(); } private BrowserPanel appendColumn() { BrowserPanel panel = new BrowserPanel(); installColumn(panel); columns.add(panel); JScrollPane scroller = new JScrollPane(panel, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); // note - it is important to set prefrred size on scroller, // not on the component itself... otherwise resizing // will be very ugly... if (preferredColumnSize != null) { scroller.setPreferredSize(preferredColumnSize); } add(scroller); return panel; } /** * Installs all needed columns and renderers to a new column */ protected void installColumn(BrowserPanel panel) { panel.addListSelectionListener(browserSelector); panel.setCellRenderer(renderer); } private BrowserPanel removeLastColumn() { if (columns.size() == 0) { return null; } int index = columns.size() - 1; BrowserPanel panel = columns.remove(index); uninstallColumn(panel); // remove ansestor of the column (JScrollPane) remove(index); return panel; } /** * Clears selection in browser and removes all unnessesary columns */ public void clearSelection() { updateFromModel(model.getRoot(), -1); } /** * Removes all local listeners from the column */ protected void uninstallColumn(BrowserPanel panel) { panel.removeListSelectionListener(browserSelector); } /** * Refreshes preferred size of the browser to reflect current number of * columns. */ private void refreshPreferredSize() { if (preferredColumnSize != null) { int w = getColumnsCount() * (preferredColumnSize.width + 3) + 3; int h = preferredColumnSize.height + 6; setPreferredSize(new Dimension(w, h)); } } /** * Makes numbered column visible if the browser parent allows scrolling. */ private void scrollToColumn(int column) { if (getParent() instanceof JViewport) { JViewport viewport = (JViewport) getParent(); // find a rectangle in the middle of the browser // and scroll it... double x = getWidth() * column / ((double) getMinColumns()); double y = getHeight() / 2; if (preferredColumnSize != null) { x -= preferredColumnSize.width / 2; if (x < 0) { x = 0; } } Rectangle rectangle = new Rectangle((int) x, (int) y, 1, 1); // Scroll the area into view. viewport.scrollRectToVisible(rectangle); } } /** * Rebuilds view for the new object selection. */ protected void updateFromModel(Object selectedNode, int panelIndex) { updateFromModel(selectedNode, panelIndex, true); } /** * Rebuilds view for the new object selection. * * @param load * Whether children are loaded automatically */ protected void updateFromModel(Object selectedNode, int panelIndex, boolean load) { if (selectionPath == null) { selectionPath = new Object[0]; } // clean up extra columns int lastIndex = selectionPath.length; // check array range to handle race conditions for (int i = panelIndex + 1; i <= lastIndex && i >= 0 && i < columns.size(); i++) { BrowserPanel column = columns.get(i); column.getSelectionModel().clearSelection(); column.setRootNode(null); } // build path to selected node this.selectionPath = rebuildPath(selectionPath, selectedNode, panelIndex); if (load) { // a selectedNode is contained in "panelIndex" column, // but its children are in the next column. panelIndex++; } // expand/contract columns as needed adjustViewColumns(panelIndex + 1 - columns.size()); if (load) { // selectedNode becomes the root of columns[panelIndex] if (!model.isLeaf(selectedNode)) { BrowserPanel lastPanel = columns.get(panelIndex); lastPanel.setRootNode(selectedNode); scrollToColumn(panelIndex); } } fireTreeSelectionEvent(selectionPath); } /** * Builds a TreePath to the new node, that is known to be a peer or a child * of one of the path components. As the method walks the current path * backwards, it cleans columns that are not common with the new path. */ private Object[] rebuildPath(Object[] path, Object node, int panelIndex) { Object[] newPath = new Object[panelIndex + 2]; System.arraycopy(path, 0, newPath, 0, panelIndex + 1); newPath[panelIndex + 1] = node; return newPath; } // ==================================================== // Helper classes // ==================================================== // ==================================================== // Represents a browser column list model. This is an // adapter on top of the TreeModel node, showing the branch // containing node children // ==================================================== final class ColumnListModel extends AbstractListModel { Object treeNode; int children; void setTreeNode(Object treeNode) { int oldChildren = children; this.treeNode = treeNode; this.children = (treeNode != null) ? model.getChildCount(treeNode) : 0; // must fire an event to refresh the view super.fireContentsChanged(MultiColumnBrowser.this, 0, Math.max(oldChildren, children)); } public Object getElementAt(int index) { return model.getChild(treeNode, index); } public int getSize() { return children; } } // ==================================================== // Represents a single browser column // ==================================================== protected final class BrowserPanel extends JList { BrowserPanel() { BrowserPanel.this.setModel(new ColumnListModel()); BrowserPanel.this.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); } void setRootNode(Object node) { ((ColumnListModel) BrowserPanel.this.getModel()).setTreeNode(node); } Object getTreeNode() { return ((ColumnListModel) BrowserPanel.this.getModel()).treeNode; } } // ==================================================== // Processes selection events in each column // ==================================================== final class PanelController implements ListSelectionListener { public void valueChanged(ListSelectionEvent e) { // ignore "adjusting" if (!e.getValueIsAdjusting()) { BrowserPanel panel = (BrowserPanel) e.getSource(); Object selectedNode = panel.getSelectedValue(); // ignore unselected if (selectedNode != null) { updateFromModel(selectedNode, columns.indexOf(panel)); } } } } // ==================================================== // Default renderer that shows non-leaf nodes with a // small right arrow. Unfortunately we can't subclass // DefaultListCellRenerer since it extends JLabel that // does not allow the layout that we need. // ==================================================== final class MultiColumnBrowserRenderer implements ListCellRenderer, Serializable { ListCellRenderer leafRenderer; JPanel nonLeafPanel; ListCellRenderer nonLeafTextRenderer; MultiColumnBrowserRenderer() { leafRenderer = CellRenderers.listRenderer(); nonLeafTextRenderer = new DefaultListCellRenderer() { @Override public Border getBorder() { return null; } }; nonLeafPanel = new JPanel(); nonLeafPanel.setLayout(new BorderLayout()); nonLeafPanel.add(new JLabel(rightArrow), BorderLayout.EAST); nonLeafPanel.add((Component) nonLeafTextRenderer, BorderLayout.WEST); } public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { if (getModel().isLeaf(value)) { return leafRenderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); } Object renderedValue = ModelerUtil.getObjectName(value); if (renderedValue == null) { // render NULL as empty string renderedValue = " "; } Component text = nonLeafTextRenderer.getListCellRendererComponent(list, renderedValue, index, isSelected, cellHasFocus); nonLeafPanel.setComponentOrientation(text.getComponentOrientation()); nonLeafPanel.setBackground(text.getBackground()); nonLeafPanel.setForeground(text.getForeground()); nonLeafPanel.setEnabled(text.isEnabled()); return nonLeafPanel; } } }