/*
* Copyright 2008-2013 Andre Pfeiler
*
* This file is part of FindBugs-IDEA.
*
* FindBugs-IDEA is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* FindBugs-IDEA is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with FindBugs-IDEA. If not, see <http://www.gnu.org/licenses/>.
*/
package org.twodividedbyzero.idea.findbugs.gui.tree.view;
import com.intellij.ui.JBColor;
import com.intellij.util.ui.UIUtil;
import javax.swing.JComponent;
import javax.swing.JList;
import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.ListCellRenderer;
import javax.swing.Popup;
import javax.swing.PopupFactory;
import javax.swing.SwingUtilities;
import javax.swing.border.Border;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.event.TreeModelEvent;
import javax.swing.event.TreeModelListener;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.tree.TreePath;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.HierarchyBoundsListener;
import java.awt.event.HierarchyEvent;
import java.awt.event.HierarchyListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionListener;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
/**
* Displays pseudo-tooltips for tree and list views which don't have enough
* space. This class is not NB specific, and can be used with any
* JTree or JList.
*
* @author Tim Boudreau
* @author Andre Pfeiler - made some tiny modification on positioning the buffered image rect
*/
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings({"RI_REDUNDANT_INTERFACES"})
@SuppressWarnings({"AssignmentToNull", "RedundantInterfaceDeclaration"})
public final class ViewTooltips extends MouseAdapter implements MouseMotionListener {
/** The default instance, reference counted */
private static ViewTooltips INSTANCE;
/** A reference count for number of comps listened to */
private int refcount;
/** The last known component we were invoked against, nulled on hide() */
private JComponent inner;
/** The last row we were invoked against */
private int row = -1;
/** An array of currently visible popups */
private final Popup[] popups = new Popup[2];
/** A component we'll reuse to paint into the popups */
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(
value = "SE_TRANSIENT_FIELD_NOT_RESTORED",
justification = "because I know better")
private final ImgComp painter = new ImgComp();
/** Nobody should instantiate this */
private ViewTooltips() {
}
/**
* Register a child of a JScrollPane (only JList or JTree supported
* for now) which should show helper tooltips. Should be called
* from the component's addNotify() method.
*
* @param comp
*/
public static void register(final JComponent comp) {
if (INSTANCE == null) {
INSTANCE = new ViewTooltips();
}
INSTANCE.attachTo(comp);
}
/**
* Unregister a child of a JScrollPane (only JList or JTree supported
* for now) which should show helper tooltips. Should be called
* from the component's removeNotify() method.
*
* @param comp
*/
public static void unregister(final JComponent comp) {
assert INSTANCE != null : "Unregister asymmetrically called";
if (INSTANCE.detachFrom(comp) == 0) {
INSTANCE.hide();
INSTANCE = null;
}
}
/**
* Start listening to mouse motion on the passed component
*
* @param comp
*/
private void attachTo(final JComponent comp) {
assert comp instanceof JTree || comp instanceof JList;
comp.addMouseListener(this);
comp.addMouseMotionListener(this);
refcount++;
}
/**
* Stop listening to mouse motion on the passed component
*
* @param comp
* @return
*/
private int detachFrom(final JComponent comp) {
assert comp instanceof JTree || comp instanceof JList;
comp.removeMouseMotionListener(this);
comp.removeMouseListener(this);
return refcount--;
}
//@Override
public void mouseMoved(final MouseEvent e) {
Point p = e.getPoint();
final JComponent comp = (JComponent) e.getSource();
final JScrollPane jsp = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, comp);
if (jsp != null) {
p = SwingUtilities.convertPoint(comp, p, jsp);
show(jsp, p);
}
}
//@Override
public void mouseDragged(final MouseEvent e) {
hide();
}
@Override
public void mouseEntered(final MouseEvent e) {
hide();
}
@Override
public void mouseExited(final MouseEvent e) {
hide();
}
/**
* Shows the appropriate popups given the state of the scroll pane and
* its view.
*
* @param view The scroll pane owning the component the event happened on
* @param pt The point at which the mouse event happened, in the coordinate
* space of the scroll pane.
*/
void show(final JScrollPane view, final Point pt) {
if (view.getViewport().getView() instanceof JTree) {
showJTree(view, pt);
} else if (view.getViewport().getView() instanceof JList) {
showJList(view, pt);
} else {
assert false : "Bad component type registered: " + view.getViewport().getView();
}
}
private void showJList(final JScrollPane view, final Point pt) {
final JList list = (JList) view.getViewport().getView();
final Point p = SwingUtilities.convertPoint(view, pt.x, pt.y, list);
final int row = list.locationToIndex(p);
if (row == -1) {
hide();
return;
}
final Rectangle bds = list.getCellBounds(row, row);
//GetCellBounds returns a width that is the
//full component width; we want only what
//the renderer really needs.
final ListCellRenderer ren = list.getCellRenderer();
final Dimension rendererSize = ren.getListCellRendererComponent(list, list.getModel().getElementAt(row), row, false, false).getPreferredSize();
bds.width = rendererSize.width;
if (!bds.contains(p)) {
hide();
return;
}
if (setCompAndRow(list, row)) {
final Rectangle visible = getShowingRect(view);
final Rectangle[] rects = getRects(bds, visible);
if (rects.length > 0) {
ensureOldPopupsHidden();
painter.configure(list.getModel().getElementAt(row), view, list, row);
showPopups(rects, bds, visible, list, view);
} else {
hide();
}
}
}
private void showJTree(final JScrollPane view, final Point pt) {
final JTree tree = (JTree) view.getViewport().getView();
final Point p = SwingUtilities.convertPoint(view, pt.x, pt.y, tree);
final int row = tree.getClosestRowForLocation(p.x, p.y);
final TreePath path = tree.getClosestPathForLocation(p.x, p.y);
final Rectangle bds = tree.getPathBounds(path);
if (bds == null || !bds.contains(p)) {
hide();
return;
}
if (setCompAndRow(tree, row)) {
final Rectangle visible = getShowingRect(view);
final Rectangle[] rects = getRects(bds, visible);
if (rects.length > 0) {
ensureOldPopupsHidden();
painter.configure(path.getLastPathComponent(), view, tree, path, row);
showPopups(rects, bds, visible, tree, view);
} else {
hide();
}
}
}
/**
* Set the currently shown component and row, returning true if they are
* not the same as the last known values.
*
* @param inner
* @param row
* @return
*/
private boolean setCompAndRow(final JComponent inner, final int row) {
final boolean rowChanged = row != this.row;
final boolean compChanged = inner != this.inner;
this.inner = inner;
this.row = row;
return rowChanged || compChanged;
}
/**
* Hide all popups and discard any references to the components the
* popups were showing for.
*/
void hide() {
ensureOldPopupsHidden();
if (painter != null) {
painter.clear();
}
setHideComponent(null, null);
inner = null;
row = -1;
}
private void ensureOldPopupsHidden() {
for (int i = 0; i < popups.length; i++) {
if (popups[i] != null) {
popups[i].hide();
popups[i] = null;
}
}
}
/**
* Gets the sub-rectangle of a JScrollPane's area that
* is actually showing the view
*
* @param pane
* @return
*/
private Rectangle getShowingRect(final JScrollPane pane) {
final Insets ins1 = pane.getViewport().getInsets();
final Border inner = pane.getViewportBorder();
final Insets ins2;
if (inner != null) {
ins2 = inner.getBorderInsets(pane);
} else {
ins2 = new Insets(0, 0, 0, 0);
}
Insets ins3 = new Insets(0, 0, 0, 0);
if (pane.getBorder() != null) {
ins3 = pane.getBorder().getBorderInsets(pane);
}
Rectangle r = pane.getViewportBorderBounds();
r.translate(-r.x, -r.y);
r.width -= ins1.left + ins1.right;
r.width -= ins2.left + ins2.right;
r.height -= ins1.top + ins1.bottom;
r.height -= ins2.top + ins2.bottom;
r.x -= ins2.left;
r.x -= ins3.left;
final Point p = pane.getViewport().getViewPosition();
r.translate(p.x, p.y);
r = SwingUtilities.convertRectangle(pane.getViewport(), r, pane);
return r;
}
/**
* Fetches an array or rectangles representing the non-overlapping
* portions of a cell rect against the visible portion of the component.
*
* @param bds
* @param vis
* @return
* @bds The cell's bounds, in the coordinate space of the tree or list
* @vis The visible area of the tree or list, in the tree or list's coordinate space
*/
private static Rectangle[] getRects(final Rectangle bds, final Rectangle vis) {
final Rectangle[] result;
if (vis.contains(bds)) {
result = new Rectangle[0];
} else {
if (bds.x < vis.x && bds.x + bds.width > vis.x + vis.width) {
final Rectangle a = new Rectangle(bds.x, bds.y, vis.x - bds.x, bds.height);
final Rectangle b = new Rectangle(vis.x + vis.width, bds.y, bds.x + bds.width - (vis.x + vis.width), bds.height);
result = new Rectangle[] {a, b};
} else if (bds.x < vis.x) {
result = new Rectangle[] {new Rectangle(bds.x, bds.y, vis.x - bds.x, bds.height)};
} else if (bds.x + bds.width > vis.x + vis.width) {
result = new Rectangle[] {new Rectangle(vis.x + vis.width, bds.y, bds.x + bds.width - (vis.x + vis.width), bds.height)};
} else {
result = new Rectangle[0];
}
}
return result;
}
/**
* Show popups for each rectangle, using the now configured painter.
*
* @param rects
* @param bds
* @param visible
* @param comp
* @param view
*/
private void showPopups(final Rectangle[] rects, final Rectangle bds, final Rectangle visible, final JComponent comp, final JScrollPane view) {
boolean shown = false;
for (int i = 0; i < rects.length; i++) {
final Rectangle sect = rects[i];
sect.translate(-bds.x, -bds.y);
final ImgComp part = painter.getPartial(sect, bds.x + rects[i].x < visible.x);
final Point pos = new Point(bds.x + rects[i].x, bds.y + rects[i].y);
SwingUtilities.convertPointToScreen(pos, comp);
if (comp instanceof JList) {
//XXX off by one somewhere, only with JLists - where?
pos.y--;
}
if (pos.x > 0) { //Mac OS will reposition off-screen popups to x=0,
//so don't try to show them
popups[i] = getPopupFactory().getPopup(view, part, pos.x, pos.y);
popups[i].show();
shown = true;
}
}
if (shown) {
setHideComponent(comp, view);
} else {
setHideComponent(null, null); //clear references
}
}
private static PopupFactory getPopupFactory() {
/*if (Utilities.isMac()) {
// See ide/applemenu/src/org/netbeans/modules/applemenu/ApplePopupFactory
// We have a custom PopupFactory that will consistently use
// lightweight popups on Mac OS, since HW popups get a drop
// shadow. By default, popups returned when a heavyweight popup
// is needed (SDI mode) are no-op popups, since some hacks
// are necessary to make it really work.
// To enable heavyweight popups which have no drop shadow
// *most* of the time on mac os, run with
// -J-Dnb.explorer.hw.completions=true
// To enable heavyweight popups which have no drop shadow
// *ever* on mac os, you need to put the cocoa classes on the
// classpath - modify netbeans.conf to add
// System/Library/Java on the bootclasspath. *Then*
// run with the above line switch and
// -J-Dnb.explorer.hw.cocoahack=true
PopupFactory result = (PopupFactory) Lookup.getDefault().lookup (
PopupFactory.class);
return result == null ? PopupFactory.getSharedInstance() : result;
} else {*/
return PopupFactory.getSharedInstance();
//}
}
private Hider hider;
/**
* Set a component (JList or JTree) which should be listened to, such that if
* a model, selection or scroll event occurs, all currently open popups
* should be hidden.
*
* @param comp
* @param parent
*/
private void setHideComponent(final JComponent comp, final JScrollPane parent) {
if (hider != null) {
if (hider.isListeningTo(comp)) {
return;
}
}
if (hider != null) {
hider.detach();
}
if (comp != null) {
hider = new Hider(comp, parent);
} else {
hider = null;
}
}
/**
* A JComponent which creates a BufferedImage of a cell renderer and can
* produce clones of itself that display subrectangles of that cell
* renderer.
*/
@SuppressWarnings("SimplifiableConditionalExpression")
private static final class ImgComp extends JComponent {
private transient BufferedImage img;
private Dimension d;
private Color bg = JBColor.WHITE;
private JScrollPane comp;
private Object node;
private AffineTransform at = AffineTransform.getTranslateInstance(0d, 0d);
boolean isRight;
ImgComp() {
}
/**
* Create a clone with a specified backing image
*
* @param img
* @param off
* @param right
*/
ImgComp(final BufferedImage img, final Rectangle off, final boolean right) {
this.img = img;
at = AffineTransform.getTranslateInstance(-off.x, 0);
d = new Dimension(off.width, off.height);
isRight = right;
}
public ImgComp getPartial(final Rectangle bds, final boolean right) {
assert img != null;
return new ImgComp(img, bds, right);
}
/**
* Configures a tree cell renderer and sets up sizing and the
* backing image from it
*
* @param nd
* @param tv
* @param tree
* @param path
* @param row
* @return
*/
public boolean configure(final Object nd, final JScrollPane tv, final JTree tree, final TreePath path, final int row) {
//final boolean sameVn = setLastRendereredObject(nd);
//final boolean sameComp = setLastRenderedScrollPane(tv);
bg = tree.getBackground();
final boolean sel = tree.isSelectionEmpty() ? false : tree.getSelectionModel().isPathSelected(path);
final boolean exp = tree.isExpanded(path);
final boolean leaf = !exp && tree.getModel().isLeaf(nd);
final boolean lead = path.equals(tree.getSelectionModel().getLeadSelectionPath());
final Component renderer = tree.getCellRenderer().getTreeCellRendererComponent(tree, nd, sel, exp, leaf, row, lead);
if (renderer != null) {
setComponent(renderer);
}
return true;
}
/**
* Configures a list cell renderer and sets up sizing and the
* backing image from it
*
* @param nd
* @param tv
* @param list
* @param row
* @return
*/
public boolean configure(final Object nd, final JScrollPane tv, final JList list, final int row) {
//final boolean sameVn = setLastRendereredObject(nd);
//final boolean sameComp = setLastRenderedScrollPane(tv);
bg = list.getBackground();
final boolean sel = list.isSelectionEmpty() ? false : list.getSelectionModel().isSelectedIndex(row);
final Component renderer = list.getCellRenderer().getListCellRendererComponent(list, nd, row, sel, false);
if (renderer != null) {
setComponent(renderer);
}
return true;
}
void clear() {
comp = null;
node = null;
}
/**
* Set the cell renderer we will proxy.
*
* @param jc
*/
public void setComponent(final Component jc) {
final Dimension d = jc.getPreferredSize();
final BufferedImage nue = UIUtil.createImage(d.width, d.height, BufferedImage.TYPE_INT_ARGB_PRE);// was height +2
SwingUtilities.paintComponent(nue.getGraphics(), jc, this, 0, 0, d.width, d.height); // was height +2
setImage(nue);
}
@Override
public Rectangle getBounds() {
final Dimension dd = getPreferredSize();
return new Rectangle(0, 0, dd.width, dd.height);
}
private void setImage(final BufferedImage img) {
this.img = img;
d = null;
}
@Override
public Dimension getPreferredSize() {
if (d == null) {
d = new Dimension(img.getWidth(), img.getHeight());
}
return d;
}
@Override
public Dimension getSize() {
return getPreferredSize();
}
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(
value = "BC_UNCONFIRMED_CAST",
justification = "")
@Override
public void paint(final Graphics g) {
g.setColor(bg);
g.fillRect(0, 0, d.width, d.height);
final Graphics2D g2d = (Graphics2D) g;
g2d.drawRenderedImage(img, at);
g.setColor(JBColor.BLACK);
g.drawLine(0, 0, d.width, 0);
g.drawLine(0, d.height - 1, d.width, d.height - 1);
if (isRight) {
g.drawLine(0, 0, 0, d.height - 1);
} else {
g.drawLine(d.width - 1, 0, d.width - 1, d.height - 1);
}
}
@Override
public void firePropertyChange(final String s, final Object a, final Object b) {
}
@Override
public void invalidate() {
}
@Override
public void validate() {
}
@Override
public void revalidate() {
}
}
/**
* A listener that listens to just about everything in the known universe
* and hides all currently displayed popups if anything happens.
*/
private static final class Hider implements ChangeListener, PropertyChangeListener, TreeModelListener, TreeSelectionListener, HierarchyListener, HierarchyBoundsListener, ListSelectionListener, ListDataListener, ComponentListener {
private final JTree tree;
private final JScrollPane pane;
private final JList list;
@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(
value = "BC_UNCONFIRMED_CAST",
justification = "")
private Hider(final JComponent comp, final JScrollPane pane) {
if (comp instanceof JTree) {
tree = (JTree) comp;
list = null;
} else {
list = (JList) comp;
tree = null;
}
assert tree != null || list != null;
this.pane = pane;
attach();
}
private boolean isListeningTo(final JComponent comp) {
return !detached && (comp == list || comp == tree);
}
private void attach() {
if (tree != null) {
tree.getModel().addTreeModelListener(this);
tree.getSelectionModel().addTreeSelectionListener(this);
tree.addHierarchyBoundsListener(this);
tree.addHierarchyListener(this);
tree.addComponentListener(this);
} else {
list.getSelectionModel().addListSelectionListener(this);
list.getModel().addListDataListener(this);
list.addHierarchyBoundsListener(this);
list.addHierarchyListener(this);
list.addComponentListener(this);
}
pane.getHorizontalScrollBar().getModel().addChangeListener(this);
pane.getVerticalScrollBar().getModel().addChangeListener(this);
KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener(this);
}
private boolean detached;
private void detach() {
KeyboardFocusManager.getCurrentKeyboardFocusManager().removePropertyChangeListener(this);
if (tree != null) {
tree.getSelectionModel().removeTreeSelectionListener(this);
tree.getModel().removeTreeModelListener(this);
tree.removeHierarchyBoundsListener(this);
tree.removeHierarchyListener(this);
tree.removeComponentListener(this);
} else {
list.getSelectionModel().removeListSelectionListener(this);
list.getModel().removeListDataListener(this);
list.removeHierarchyBoundsListener(this);
list.removeHierarchyListener(this);
list.removeComponentListener(this);
}
pane.getHorizontalScrollBar().getModel().removeChangeListener(this);
pane.getVerticalScrollBar().getModel().removeChangeListener(this);
detached = true;
}
private void change() {
if (ViewTooltips.INSTANCE != null) {
ViewTooltips.INSTANCE.hide();
}
detach();
}
public void propertyChange(final PropertyChangeEvent evt) {
change();
}
public void treeNodesChanged(final TreeModelEvent e) {
change();
}
public void treeNodesInserted(final TreeModelEvent e) {
change();
}
public void treeNodesRemoved(final TreeModelEvent e) {
change();
}
public void treeStructureChanged(final TreeModelEvent e) {
change();
}
public void hierarchyChanged(final HierarchyEvent e) {
change();
}
public void valueChanged(final TreeSelectionEvent e) {
change();
}
public void ancestorMoved(final HierarchyEvent e) {
change();
}
public void ancestorResized(final HierarchyEvent e) {
change();
}
public void stateChanged(final ChangeEvent e) {
change();
}
public void valueChanged(final ListSelectionEvent e) {
change();
}
public void intervalAdded(final ListDataEvent e) {
change();
}
public void intervalRemoved(final ListDataEvent e) {
change();
}
public void contentsChanged(final ListDataEvent e) {
change();
}
public void componentResized(final ComponentEvent e) {
change();
}
public void componentMoved(final ComponentEvent e) {
change();
}
public void componentShown(final ComponentEvent e) {
change();
}
public void componentHidden(final ComponentEvent e) {
change();
}
}
}