/** * Copyright (C) 2001-2017 by RapidMiner and the contributors * * Complete list of developers available at our web site: * * http://rapidminer.com * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Affero General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This program 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 * Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see http://www.gnu.org/licenses/. */ package com.rapidminer.gui.tools.components; import java.awt.AWTEvent; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Dialog; import java.awt.Dialog.ModalExclusionType; import java.awt.Dimension; import java.awt.Font; import java.awt.GraphicsDevice; import java.awt.GraphicsEnvironment; import java.awt.Insets; import java.awt.MouseInfo; import java.awt.Point; import java.awt.Rectangle; import java.awt.Toolkit; import java.awt.event.AWTEventListener; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.event.WindowFocusListener; import java.util.concurrent.atomic.AtomicBoolean; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkListener; import javax.swing.text.html.HTMLEditorKit; import javax.swing.text.html.StyleSheet; import com.rapidminer.gui.ApplicationFrame; import com.rapidminer.gui.MainFrame; import com.rapidminer.gui.tools.ExtendedHTMLJEditorPane; import com.rapidminer.gui.tools.ProgressThread; import com.rapidminer.gui.tools.ResourceLabel; import com.rapidminer.gui.tools.SwingTools; import com.rapidminer.repository.Entry; import com.rapidminer.repository.IOObjectEntry; import com.rapidminer.repository.RepositoryLocation; import com.rapidminer.tools.RMUrlHandler; /** * This class manages dynamic largish tool tips for JComponents. In order to use this class, * implement a {@link TipProvider} that generates tool tip texts depending on the a mouse position * relative to a component and pass this component and the tip provider to the constructor of this * class. * * This class will listen to mouse events of the specified component and will display an undecorated * scrollable dialog whenever the mouse does not move for a certain time. The user can focus (an * then resize) the dialog by pressing F3. * * It is also possible to specify the location relative to the mouse cursor. See * {@link TooltipLocation} which can be supplied via constructor. * * @author Simon Fischer, Marco Boeck * */ public class ToolTipWindow { /** * Used to specify the location of the popup in relation to the mouse cursor. * * @since 6.0.004 */ public static enum TooltipLocation { /** popup will open below the mouse; behavior as before this was implemented */ BELOW, /** popup will open slightly to the right of the mouse */ RIGHT; } public interface TipProvider { /** Returns the actual tip belonging to this point. Called after {@link #getIdUnder(Point)}. */ public String getTip(Object id); /** Returns an additional tooltip component to be added below the text field. */ public Component getCustomComponent(Object id); /** * Returns an ID of the object under the given mouse position. This is only used to * determine whether the mouse has left the area corresponding to the current tool tip. We * could have called {@link #getTip(Object)} directly, however this may be a too time * consuming operation. Note: IDs are compared by == ! */ public Object getIdUnder(Point point); } private enum State { IDLE, SHOWING_TIP, IN_FOCUS, DISPOSED } /** Component observed by this object. */ private final JComponent parent; private final TipProvider tipProvider; private State state = State.IDLE; /** the location relative to the cursor where the tooltip should appear */ private final TooltipLocation location; /** Point at which the tip was last displayed. Relative to {@link #parent}. */ private Point lastPoint; /** Mouse position when it was last moved. Relative to {@link #parent}. */ private Point lastMousePosition; /** Position of the mouse at the point of time when the tip was displayed. */ private Point mousePositionAtPopup; private Object currentId; /** Panel containing the {@link #tipScrollPane} and a short label (F3 to focus). */ private final JPanel mainPanel = new JPanel(new BorderLayout()); /** Pane containing the help text. */ private final ExtendedHTMLJEditorPane tipPane = new ExtendedHTMLJEditorPane("text/html", "<html></html>"); private Component customComponent; /** Contains the {@link #tipPane}. */ private final JScrollPane tipScrollPane; /** If set, the tooltip is shown on pressing the mouse */ private boolean reactOnMousePressed = false; /** * Current (decorated or undecorated) dialog containing the main panel. May be null if state is * IDLE. */ private JDialog currentDialog; /** Shows a tip after 500ms. */ private final Timer showTipTimer = new Timer(500, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { showTip(); } }); /** * We use this approach for tracking the mouse exited event from the dialog. * http://weblogs.java.net/blog/alexfromsun/archive/2006/09/a_wellbehaved_g.html The method * described in the Java Tutorial on using glasspanes described here: * http://java.sun.com/docs/books/tutorial/uiswing/components/rootpane.html fails for various * reasons. Most importantly, it makes the scroll bars unusable. */ private final AWTEventListener hideTipOnExitListener = new AWTEventListener() { @Override public void eventDispatched(AWTEvent event) { if (event instanceof MouseEvent) { MouseEvent me = (MouseEvent) event; if (me.getID() != MouseEvent.MOUSE_EXITED || state != State.SHOWING_TIP || !SwingUtilities.isDescendingFrom(me.getComponent(), currentDialog)) { return; } Point origin = currentDialog.getLocationOnScreen(); Point mep = me.getLocationOnScreen(); if (mep.getX() < origin.getX() || mep.getY() < origin.getY() || mep.getX() > origin.getX() + currentDialog.getWidth() || mep.getY() > origin.getY() + currentDialog.getHeight()) { hideTip(false); } } } }; /** Decorates the tip dialog if the user presses F3. */ private final Action FOCUS_TIP_ACTION = new AbstractAction() { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { focusTip(); } }; private final ResourceLabel f3Label; private final Dialog owner; private boolean mouseOnParentIsDown = false; private boolean onlyWhenFocussed = true; /** * Registers the tooltip provider on the specified parent component. The tooltip will appear * below the cursor. * * @param tipProvider * Generates tool tip texts whenever needed * @param parent * The component to observe */ public ToolTipWindow(TipProvider tipProvider, JComponent parent) { this(tipProvider, parent, TooltipLocation.BELOW); } /** * Registers the tooltip provider on the specified parent component. The tooltip will appear at * the specified location. * * @param tipProvider * Generates tool tip texts whenever needed * @param parent * The component to observe * @param tooltipLocation * The location relative to the mouse cursor */ public ToolTipWindow(TipProvider tipProvider, JComponent parent, TooltipLocation tooltipLocation) { this(null, tipProvider, parent, tooltipLocation); } /** * Registers the tooltip provider on the specified parent component. The tooltip will appear * below the cursor. * * @param owner * The owner of the tool tip dialog. If null, the {@link MainFrame} will be used. If * this tool tip is for a component in a dialog, but the owner is not set, the tool * tip will be displayed behind the dialog. * @param tipProvider * Generates tool tip texts whenever needed * @param parent * The component to observe */ public ToolTipWindow(Dialog owner, TipProvider tipProvider, final JComponent parent) { this(owner, tipProvider, parent, TooltipLocation.BELOW); } /** * Registers the tooltip provider on the specified parent component. The tooltip will appear at * the specified location. * * @param owner * The owner of the tool tip dialog. If null, the {@link MainFrame} will be used. If * this tool tip is for a component in a dialog, but the owner is not set, the tool * tip will be displayed behind the dialog. * @param tipProvider * Generates tool tip texts whenever needed * @param parent * The component to observe * @param location * The location relative to the mouse cursor */ public ToolTipWindow(Dialog owner, TipProvider tipProvider, final JComponent parent, TooltipLocation location) { // TODO: Is there a way to find the owner elegantly? Travers ancestors? this.owner = owner; this.tipProvider = tipProvider; this.parent = parent; this.location = location; showTipTimer.setRepeats(false); tipPane.setFont(new Font("Sans-serif", Font.PLAIN, 9)); tipPane.setMargin(new Insets(4, 4, 4, 4)); tipPane.setEditable(false); tipPane.addHyperlinkListener(new HyperlinkListener() { @Override public void hyperlinkUpdate(HyperlinkEvent e) { if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { if (e.getDescription().startsWith("loadMetaData?")) { final String loc = e.getDescription().substring("loadMetaData?".length()); final Object idAtTimeOfDownload = currentId; tipPane.setText("<p>Please stand by...</p>"); final AtomicBoolean tipWasClosed = new AtomicBoolean(false); currentDialog.addWindowListener(new WindowAdapter() { @Override public void windowClosed(WindowEvent e) { tipWasClosed.set(true); } }); new ProgressThread("download_md_from_repository") { @Override public void run() { getProgressListener().setTotal(100); getProgressListener().setCompleted(10); try { Entry entry = new RepositoryLocation(loc).locateEntry(); if (entry instanceof IOObjectEntry) { ((IOObjectEntry) entry).retrieveMetaData(); } } catch (Exception e) { SwingTools.showSimpleErrorMessage("error_downloading_metadata", e, loc, e.getMessage()); } finally { getProgressListener().complete(); } SwingUtilities.invokeLater(new Runnable() { @Override public void run() { if (!tipWasClosed.get()) { refreshDialogContents(idAtTimeOfDownload); autoAdjustDialogSize(state == State.IN_FOCUS); currentDialog.pack(); } } }); } }.start(); } else { RMUrlHandler.handleUrl(e.getDescription()); } } } }); tipPane.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); StyleSheet css = ((HTMLEditorKit) tipPane.getEditorKit()).getStyleSheet(); css.addRule("body {font-family:Sans;font-size:12pt}"); css.addRule("h3 {margin:0; padding:0}"); css.addRule("h4 {margin-bottom:0; margin-top:1ex; padding:0}"); css.addRule("p {margin-top:0; margin-bottom:1ex; padding:0}"); css.addRule("ul {margin-top:0; margin-bottom:1ex; list-style-image: url(" + getClass().getResource("/com/rapidminer/resources/icons/modern/help/circle.png") + ")}"); css.addRule("ul li {padding-bottom: 2px}"); css.addRule("li.outPorts {padding-bottom: 0px}"); css.addRule("ul li ul {margin-top:0; margin-bottom:1ex; list-style-image: url(" + getClass().getResource("/com/rapidminer/resources/icons/modern/help/line.png") + ")"); css.addRule("li ul li {padding-bottom:0}"); tipScrollPane = new JScrollPane(tipPane); tipScrollPane.setBorder(null); mainPanel.add(tipScrollPane, BorderLayout.CENTER); f3Label = new ResourceLabel("F3_for_focus"); f3Label.setBorder(BorderFactory.createCompoundBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, Color.LIGHT_GRAY), BorderFactory.createEmptyBorder(0, 4, 0, 0))); f3Label.setOpaque(true); f3Label.setBackground(Color.WHITE); mainPanel.add(f3Label, BorderLayout.SOUTH); int focusCondition = JComponent.WHEN_FOCUSED; parent.getInputMap(focusCondition).put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), "focusTip"); parent.getActionMap().put("focusTip", FOCUS_TIP_ACTION); mainPanel.getInputMap(focusCondition).put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), "focusTip"); mainPanel.getActionMap().put("focusTip", FOCUS_TIP_ACTION); tipPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), "focusTip"); mainPanel.getActionMap().put("focusTip", FOCUS_TIP_ACTION); parent.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { mouseOnParentIsDown = true; showTipTimer.stop(); if (state == State.SHOWING_TIP) { hideTip(true); } else if (reactOnMousePressed && SwingUtilities.isLeftMouseButton(e)) { showTip(); } } @Override public void mouseReleased(MouseEvent e) { mouseOnParentIsDown = false; if (state == State.IDLE) { showTipTimer.start(); } } @Override public void mouseExited(MouseEvent e) { // only hide if we did not move the mouse over the tooltip and while we are in not // focus only mode if (!SwingTools.isMouseEventExitedToChildComponents(parent, e) && !isOnlyWhenFocussed()) { hideTip(false); } mouseOnParentIsDown = false; showTipTimer.stop(); } @Override public void mouseEntered(MouseEvent e) { mouseOnParentIsDown = false; if (state == State.IDLE) { showTipTimer.start(); } } }); parent.addMouseMotionListener(new MouseMotionListener() { @Override public void mouseMoved(MouseEvent e) { parentIsActive(e); } @Override public void mouseDragged(MouseEvent e) { parentIsActive(e); } }); } private void parentIsActive(MouseEvent e) { lastMousePosition = e.getPoint(); switch (state) { case IDLE: if (!mouseOnParentIsDown) { showTipTimer.restart(); } break; case SHOWING_TIP: Object id = ToolTipWindow.this.tipProvider.getIdUnder(e.getPoint()); if (id == currentId) { return; } else { double dx = e.getX() - mousePositionAtPopup.getX(); double dy = e.getY() - mousePositionAtPopup.getY(); double dist = dx * dx + dy * dy; if (dist > 100) { hideTip(false); } } break; case DISPOSED: default: state = State.IDLE; break; } } private void makeDialog(boolean undecorated, Point point) { currentDialog = new JDialog(owner != null ? owner : ApplicationFrame.getApplicationFrame()); if (undecorated) { currentDialog.setUndecorated(true); f3Label.setVisible(true); currentDialog.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke(KeyEvent.VK_F3, 0), "focusTip"); currentDialog.getRootPane().getActionMap().put("focusTip", FOCUS_TIP_ACTION); } else { f3Label.setVisible(false); currentDialog.setModalExclusionType(ModalExclusionType.APPLICATION_EXCLUDE); } currentDialog.setAlwaysOnTop(true); currentDialog.getRootPane().setBorder(BorderFactory.createLineBorder(Color.BLACK)); currentDialog.getContentPane().setLayout(new BorderLayout()); currentDialog.getContentPane().add(mainPanel); SwingTools.setDialogIcon(currentDialog); // dispose focused if focus lost if (state == State.IN_FOCUS) { currentDialog.addWindowFocusListener(new WindowFocusListener() { @Override public void windowGainedFocus(WindowEvent e) {} @Override public void windowLostFocus(WindowEvent e) { hideTip(true); } }); } currentDialog.addWindowListener(new WindowAdapter() { @Override public void windowOpened(WindowEvent e) { Toolkit.getDefaultToolkit().addAWTEventListener(hideTipOnExitListener, AWTEvent.MOUSE_MOTION_EVENT_MASK | AWTEvent.MOUSE_EVENT_MASK); if (state == State.SHOWING_TIP) { tipScrollPane.getVerticalScrollBar().setValue(0); } } @Override public void windowClosed(WindowEvent e) { Toolkit.getDefaultToolkit().removeAWTEventListener(hideTipOnExitListener); state = State.IDLE; } }); autoAdjustDialogSize(undecorated); currentDialog.pack(); if (undecorated) { currentDialog.setLocation(new Point((int) (parent.getLocationOnScreen().getX() + point.getX()), (int) (parent .getLocationOnScreen().getY() + point.getY()))); } else { Rectangle innerBounds = currentDialog.getComponent(0).getBounds(); currentDialog.setLocation(new Point((int) (parent.getLocationOnScreen().getX() + point.getX() - innerBounds.x), (int) (parent.getLocationOnScreen().getY() + point.getY() - innerBounds.y))); int dx = currentDialog.getSize().width - tipScrollPane.getSize().width; int dy = currentDialog.getSize().height - tipScrollPane.getSize().height; currentDialog.setPreferredSize(new Dimension(tipScrollPane.getPreferredSize().width + dx, tipScrollPane .getPreferredSize().height + dy)); } GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); Rectangle boundsOfCurrentScreen = ge.getDefaultScreenDevice().getDefaultConfiguration().getBounds(); for (GraphicsDevice gd : ge.getScreenDevices()) { Rectangle screenbounds = gd.getDefaultConfiguration().getBounds(); if (screenbounds.contains(currentDialog.getLocation())) { boundsOfCurrentScreen = screenbounds; break; } } Rectangle bounds = currentDialog.getBounds(); if (bounds.getMaxX() > boundsOfCurrentScreen.getMaxX()) { currentDialog.setLocation(new Point((int) (boundsOfCurrentScreen.getMaxX() - bounds.getWidth()), (int) bounds .getY())); bounds = currentDialog.getBounds(); } if (bounds.getMaxY() > boundsOfCurrentScreen.getMaxY()) { currentDialog.setLocation(new Point((int) bounds.getX(), (int) (boundsOfCurrentScreen.getMaxY() - bounds .getHeight()))); } currentDialog.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0, false), "CLOSE"); currentDialog.getRootPane().getActionMap().put("CLOSE", new AbstractAction() { private static final long serialVersionUID = 1373293026453738733L; @Override public void actionPerformed(ActionEvent e) { currentDialog.dispose(); } }); currentDialog.setVisible(true); } private void autoAdjustDialogSize(boolean undecorated) { int tipPaneHeight = (int) tipPane.getPreferredSize().getHeight(); int tipPaneWidth = (int) tipPane.getPreferredSize().getWidth(); if (tipPaneHeight > 300) { tipScrollPane.setPreferredSize(new Dimension(tipPaneWidth + 50, 300 + (undecorated ? 0 : f3Label.getHeight()))); } else { tipScrollPane.setPreferredSize(new Dimension(tipPaneWidth + 50, tipPaneHeight + 30 + (undecorated ? 0 : f3Label.getHeight()))); } } private void showTip() { if (state == State.IDLE) { // if parent does not have focus we don't want to show tool tip! if (isOnlyWhenFocussed() && !parent.hasFocus()) { return; } // check if we are really under the mouse. Necessary since mouse exited events // might have been lost when dragging. if (!parent.isDisplayable()) { return; } Rectangle parentBounds = new Rectangle(parent.getLocationOnScreen(), new Dimension(parent.getWidth(), parent.getHeight())); if (!parentBounds.contains(MouseInfo.getPointerInfo().getLocation())) { return; } if (lastMousePosition == null) { return; } currentId = tipProvider.getIdUnder(lastMousePosition); if (currentId == null) { return; } refreshDialogContents(currentId); state = State.SHOWING_TIP; showTipTimer.stop(); if (location == TooltipLocation.RIGHT) { lastPoint = new Point((int) lastMousePosition.getX() + 20, (int) lastMousePosition.getY()); } else if (location == TooltipLocation.BELOW) { // behavior of previous versions lastPoint = new Point((int) lastMousePosition.getX() - 50, (int) lastMousePosition.getY() + 20); } mousePositionAtPopup = lastMousePosition; makeDialog(true, lastPoint); parent.requestFocus(); } else { state = State.IDLE; } } private void focusTip() { if (state == State.SHOWING_TIP) { state = State.IN_FOCUS; currentDialog.dispose(); currentDialog = null; makeDialog(false, lastPoint); } } /** * Hides the tooltip dialog if it is not decorated. * * @param forceHide * if <code>true</code>, even a decorated tooltip will be hidden */ private void hideTip(boolean forceHide) { if (currentDialog != null) { // only hide if user did NOT press F3 (making it decorated) if (forceHide || currentDialog.isUndecorated()) { currentDialog.dispose(); state = State.DISPOSED; } } } private void refreshDialogContents(Object objectId) { String tipText = tipProvider.getTip(objectId); if (tipText == null || tipText.length() == 0) { return; } if (customComponent != null) { mainPanel.remove(customComponent); } mainPanel.remove(tipScrollPane); mainPanel.remove(tipPane); tipPane.setText("<html><body><div style=\"width:300px\">" + tipText + "</div></body></html>"); if (customComponent != null) { mainPanel.remove(customComponent); } mainPanel.remove(tipPane); mainPanel.remove(tipScrollPane); customComponent = tipProvider.getCustomComponent(objectId); if (customComponent != null) { mainPanel.add(tipPane, BorderLayout.NORTH); mainPanel.add(customComponent, BorderLayout.CENTER); } else { tipScrollPane.setViewportView(tipPane); mainPanel.add(tipScrollPane, BorderLayout.CENTER); } } /** * Returns whether the tooltip will only be shown when the parent is focused. Default is * <code>true</code>. * * @return */ public boolean isOnlyWhenFocussed() { return onlyWhenFocussed; } /** * Controls whether the tooltip is shown only when the parent is focused or not. Default is * <code>true</code>. * * @param onlyWhenFocussed * * @return The {@link ToolTipWindow} instance for method chaining */ public ToolTipWindow setOnlyWhenFocussed(boolean onlyWhenFocussed) { this.onlyWhenFocussed = onlyWhenFocussed; return this; } /** * Controls whether the tooltip is shown, if the mouse is pressed. * * @return The {@link ToolTipWindow} instance for method chaining */ public ToolTipWindow setReactOnMousePressed(boolean reactOnMousePressed) { this.reactOnMousePressed = reactOnMousePressed; return this; } /** * Controls the delay until a tooltip is shown when hovering over the parent component. * * @param delayInMS * the delay of the tooltip */ public ToolTipWindow setToolTipDelay(int delayInMS) { showTipTimer.setInitialDelay(delayInMS); return this; } }