1 /*******************************************************************************
2  * Copyright (c) 2006, 2017 IBM Corporation and others.
3  *
4  * This program and the accompanying materials
5  * are made available under the terms of the Eclipse Public License 2.0
6  * which accompanies this distribution, and is available at
7  * https://www.eclipse.org/legal/epl-2.0/
8  *
9  * SPDX-License-Identifier: EPL-2.0
10  *
11  * Contributors:
12  *     Tom Schindl <tom.schindl@bestsolution.at> - initial API and implementation
13  *                                                 bugfix in: 195137, 198089, 225190
14  *******************************************************************************/
15 
16 package org.eclipse.jface.window;
17 
18 import java.util.HashMap;
19 
20 import org.eclipse.jface.viewers.ColumnViewer;
21 import org.eclipse.jface.viewers.ViewerCell;
22 import org.eclipse.swt.SWT;
23 import org.eclipse.swt.graphics.Point;
24 import org.eclipse.swt.graphics.Rectangle;
25 import org.eclipse.swt.layout.FillLayout;
26 import org.eclipse.swt.widgets.Composite;
27 import org.eclipse.swt.widgets.Control;
28 import org.eclipse.swt.widgets.Event;
29 import org.eclipse.swt.widgets.Listener;
30 import org.eclipse.swt.widgets.Monitor;
31 import org.eclipse.swt.widgets.Shell;
32 
33 /**
34  * This class gives implementors to provide customized tooltips for any control.
35  *
36  * @since 3.3
37  */
38 public abstract class ToolTip {
39 	private Control control;
40 
41 	private int xShift = 3;
42 
43 	private int yShift = 0;
44 
45 	private int popupDelay = 0;
46 
47 	private int hideDelay = 0;
48 
49 	private ToolTipOwnerControlListener listener;
50 
51 	private HashMap<String, Object> data;
52 
53 	// Ensure that only one tooltip is active in time
54 	private static Shell CURRENT_TOOLTIP;
55 
56 	/**
57 	 * Recreate the tooltip on every mouse move
58 	 */
59 	public static final int RECREATE = 1;
60 
61 	/**
62 	 * Don't recreate the tooltip as long the mouse doesn't leave the area
63 	 * triggering the tooltip creation
64 	 */
65 	public static final int NO_RECREATE = 1 << 1;
66 
67 	private TooltipHideListener hideListener = new TooltipHideListener();
68 
69 	private Listener shellListener;
70 
71 	private boolean hideOnMouseDown = true;
72 
73 	private boolean respectDisplayBounds = true;
74 
75 	private boolean respectMonitorBounds = true;
76 
77 	private int style;
78 
79 	private Object currentArea;
80 
81 	/**
82 	 * Create new instance which add TooltipSupport to the widget
83 	 *
84 	 * @param control
85 	 *            the control on whose action the tooltip is shown
86 	 */
ToolTip(Control control)87 	public ToolTip(Control control) {
88 		this(control, RECREATE, false);
89 	}
90 
91 	/**
92 	 * @param control
93 	 *            the control to which the tooltip is bound
94 	 * @param style
95 	 *            style passed to control tooltip behavior
96 	 *
97 	 * @param manualActivation
98 	 *            <code>true</code> if the activation is done manually using
99 	 *            {@link #show(Point)}
100 	 * @see #RECREATE
101 	 * @see #NO_RECREATE
102 	 */
ToolTip(Control control, int style, boolean manualActivation)103 	public ToolTip(Control control, int style, boolean manualActivation) {
104 		this.control = control;
105 		this.style = style;
106 		this.listener = new ToolTipOwnerControlListener();
107 		this.shellListener = event -> {
108 			if (ToolTip.this.control != null
109 					&& !ToolTip.this.control.isDisposed()) {
110 				ToolTip.this.control.getDisplay().asyncExec(() -> {
111 					// Check if the new active shell is the tooltip
112 					// itself
113 					if (ToolTip.this.control != null && !ToolTip.this.control.isDisposed()
114 							&& ToolTip.this.control.getDisplay().getActiveShell() != CURRENT_TOOLTIP) {
115 						toolTipHide(CURRENT_TOOLTIP, event);
116 					}
117 				});
118 			}
119 		};
120 
121 		if (!manualActivation) {
122 			activate();
123 		}
124 	}
125 
126 	/**
127 	 * Restore arbitrary data under the given key
128 	 *
129 	 * @param key
130 	 *            the key
131 	 * @param value
132 	 *            the value
133 	 */
setData(String key, Object value)134 	public void setData(String key, Object value) {
135 		if (data == null) {
136 			data = new HashMap<>();
137 		}
138 		data.put(key, value);
139 	}
140 
141 	/**
142 	 * Get the data restored under the key
143 	 *
144 	 * @param key
145 	 *            the key
146 	 * @return data or <code>null</code> if no entry is restored under the key
147 	 */
getData(String key)148 	public Object getData(String key) {
149 		if (data != null) {
150 			return data.get(key);
151 		}
152 		return null;
153 	}
154 
155 	/**
156 	 * Set the shift (from the mouse position triggered the event) used to
157 	 * display the tooltip.
158 	 * <p>
159 	 * By default the tooltip is shifted 3 pixels to the right.
160 	 * </p>
161 	 *
162 	 * @param p
163 	 *            the new shift
164 	 */
setShift(Point p)165 	public void setShift(Point p) {
166 		xShift = p.x;
167 		yShift = p.y;
168 	}
169 
170 	/**
171 	 * Activate tooltip support for this control
172 	 */
activate()173 	public void activate() {
174 		deactivate();
175 		control.addListener(SWT.Dispose, listener);
176 		control.addListener(SWT.MouseHover, listener);
177 		control.addListener(SWT.MouseMove, listener);
178 		control.addListener(SWT.MouseExit, listener);
179 		control.addListener(SWT.MouseDown, listener);
180 		control.addListener(SWT.MouseWheel, listener);
181 	}
182 
183 	/**
184 	 * Deactivate tooltip support for the underlying control
185 	 */
deactivate()186 	public void deactivate() {
187 		control.removeListener(SWT.Dispose, listener);
188 		control.removeListener(SWT.MouseHover, listener);
189 		control.removeListener(SWT.MouseMove, listener);
190 		control.removeListener(SWT.MouseExit, listener);
191 		control.removeListener(SWT.MouseDown, listener);
192 		control.removeListener(SWT.MouseWheel, listener);
193 	}
194 
195 	/**
196 	 * Return whether the tooltip respects bounds of the display.
197 	 *
198 	 * @return <code>true</code> if the tooltip respects bounds of the display
199 	 */
isRespectDisplayBounds()200 	public boolean isRespectDisplayBounds() {
201 		return respectDisplayBounds;
202 	}
203 
204 	/**
205 	 * Set to <code>false</code> if display bounds should not be respected or to
206 	 * <code>true</code> if the tooltip is should repositioned to not overlap the
207 	 * display bounds.
208 	 * <p>
209 	 * Default is <code>true</code>
210 	 * </p>
211 	 *
212 	 * @param respectDisplayBounds <code>false</code> if tooltip is allowed to
213 	 *                             overlap display bounds
214 	 */
setRespectDisplayBounds(boolean respectDisplayBounds)215 	public void setRespectDisplayBounds(boolean respectDisplayBounds) {
216 		this.respectDisplayBounds = respectDisplayBounds;
217 	}
218 
219 	/**
220 	 * Return whether the tooltip respects bounds of the monitor.
221 	 *
222 	 * @return <code>true</code> if tooltip respects the bounds of the monitor
223 	 */
isRespectMonitorBounds()224 	public boolean isRespectMonitorBounds() {
225 		return respectMonitorBounds;
226 	}
227 
228 	/**
229 	 * Set to <code>false</code> if monitor bounds should not be respected or to
230 	 * <code>true</code> if the tooltip is should repositioned to not overlap the
231 	 * monitors bounds. The monitor the tooltip belongs to is the same is control's
232 	 * monitor the tooltip is shown for.
233 	 * <p>
234 	 * Default is <code>true</code>
235 	 * </p>
236 	 *
237 	 * @param respectMonitorBounds <code>false</code> if tooltip is allowed to
238 	 *                             overlap monitor bounds
239 	 */
setRespectMonitorBounds(boolean respectMonitorBounds)240 	public void setRespectMonitorBounds(boolean respectMonitorBounds) {
241 		this.respectMonitorBounds = respectMonitorBounds;
242 	}
243 
244 	/**
245 	 * Should the tooltip displayed because of the given event.
246 	 * <p>
247 	 * <b>Subclasses may overwrite this to get custom behavior</b>
248 	 * </p>
249 	 *
250 	 * @param event
251 	 *            the event
252 	 * @return <code>true</code> if tooltip should be displayed
253 	 */
shouldCreateToolTip(Event event)254 	protected boolean shouldCreateToolTip(Event event) {
255 		if ((style & NO_RECREATE) != 0) {
256 			Object tmp = getToolTipArea(event);
257 
258 			// No new area close the current tooltip
259 			if (tmp == null) {
260 				hide();
261 				return false;
262 			}
263 
264 			return !tmp.equals(currentArea);
265 		}
266 
267 		return true;
268 	}
269 
270 	/**
271 	 * This method is called before the tooltip is hidden
272 	 *
273 	 * @param event
274 	 *            the event trying to hide the tooltip
275 	 * @return <code>true</code> if the tooltip should be hidden
276 	 */
shouldHideToolTip(Event event)277 	private boolean shouldHideToolTip(Event event) {
278 		if (event != null && event.type == SWT.MouseMove
279 				&& (style & NO_RECREATE) != 0) {
280 			Object tmp = getToolTipArea(event);
281 
282 			// No new area close the current tooltip
283 			if (tmp == null) {
284 				hide();
285 				return false;
286 			}
287 
288 			return !tmp.equals(currentArea);
289 		}
290 
291 		return true;
292 	}
293 
294 	/**
295 	 * This method is called to check for which area the tooltip is
296 	 * created/hidden for. In case of {@link #NO_RECREATE} this is used to
297 	 * decide if the tooltip is hidden recreated.
298 	 *
299 	 * <code>By the default it is the widget the tooltip is created for but could be any object. To decide if
300 	 * the area changed the {@link Object#equals(Object)} method is used.</code>
301 	 *
302 	 * @param event
303 	 *            the event
304 	 * @return the area responsible for the tooltip creation or
305 	 *         <code>null</code> this could be any object describing the area
306 	 *         (e.g. the {@link Control} onto which the tooltip is bound to, a
307 	 *         part of this area e.g. for {@link ColumnViewer} this could be a
308 	 *         {@link ViewerCell})
309 	 */
getToolTipArea(Event event)310 	protected Object getToolTipArea(Event event) {
311 		return control;
312 	}
313 
314 	/**
315 	 * Start up the tooltip programmatically
316 	 *
317 	 * @param location
318 	 *            the location relative to the control the tooltip is shown
319 	 */
show(Point location)320 	public void show(Point location) {
321 		Event event = new Event();
322 		event.x = location.x;
323 		event.y = location.y;
324 		event.widget = control;
325 		toolTipCreate(event);
326 	}
327 
toolTipCreate(final Event event)328 	private Shell toolTipCreate(final Event event) {
329 		if (shouldCreateToolTip(event)) {
330 			Shell shell = new Shell(control.getShell(), SWT.ON_TOP | SWT.TOOL
331 					| SWT.NO_FOCUS);
332 			shell.setLayout(new FillLayout());
333 
334 			toolTipOpen(shell, event);
335 
336 			return shell;
337 		}
338 
339 		return null;
340 	}
341 
toolTipShow(Shell tip, Event event)342 	private void toolTipShow(Shell tip, Event event) {
343 		if (!tip.isDisposed()) {
344 			currentArea = getToolTipArea(event);
345 			createToolTipContentArea(event, tip);
346 			if (isHideOnMouseDown()) {
347 				toolTipHookBothRecursively(tip);
348 			} else {
349 				toolTipHookByTypeRecursively(tip, true, SWT.MouseExit);
350 			}
351 
352 			tip.pack();
353 			Point size = tip.getSize();
354 			Point location = fixupDisplayBounds(size, getLocation(size, event));
355 
356 			// Need to adjust a bit more if the mouse cursor.y == tip.y and
357 			// the cursor.x is inside the tip
358 			Point cursorLocation = tip.getDisplay().getCursorLocation();
359 
360 			if (cursorLocation.y == location.y && location.x < cursorLocation.x
361 					&& location.x + size.x > cursorLocation.x) {
362 				location.y -= 2;
363 			}
364 
365 			tip.setLocation(location);
366 			tip.setVisible(true);
367 		}
368 	}
369 
fixupDisplayBounds(Point tipSize, Point location)370 	private Point fixupDisplayBounds(Point tipSize, Point location) {
371 		if (respectDisplayBounds || respectMonitorBounds) {
372 			Rectangle bounds;
373 			Point rightBounds = new Point(tipSize.x + location.x, tipSize.y
374 					+ location.y);
375 
376 			Monitor[] ms = control.getDisplay().getMonitors();
377 
378 			if (respectMonitorBounds && ms.length > 1) {
379 				// By default present in the monitor of the control
380 				bounds = control.getMonitor().getBounds();
381 				Point p = new Point(location.x, location.y);
382 
383 				// Search on which monitor the event occurred
384 				Rectangle tmp;
385 				for (Monitor element : ms) {
386 					tmp = element.getBounds();
387 					if (tmp.contains(p)) {
388 						bounds = tmp;
389 						break;
390 					}
391 				}
392 
393 			} else {
394 				bounds = control.getDisplay().getBounds();
395 			}
396 
397 			if (!(bounds.contains(location) && bounds.contains(rightBounds))) {
398 				if (rightBounds.x > bounds.x + bounds.width) {
399 					location.x -= rightBounds.x - (bounds.x + bounds.width);
400 				}
401 
402 				if (rightBounds.y > bounds.y + bounds.height) {
403 					location.y -= rightBounds.y - (bounds.y + bounds.height);
404 				}
405 
406 				if (location.x < bounds.x) {
407 					location.x = bounds.x;
408 				}
409 
410 				if (location.y < bounds.y) {
411 					location.y = bounds.y;
412 				}
413 			}
414 		}
415 
416 		return location;
417 	}
418 
419 	/**
420 	 * Get the display relative location where the tooltip is displayed.
421 	 * Subclasses may overwrite to implement custom positioning.
422 	 *
423 	 * @param tipSize
424 	 *            the size of the tooltip to be shown
425 	 * @param event
426 	 *            the event triggered showing the tooltip
427 	 * @return the absolute position on the display
428 	 */
getLocation(Point tipSize, Event event)429 	public Point getLocation(Point tipSize, Event event) {
430 		return control.toDisplay(event.x + xShift, event.y + yShift);
431 	}
432 
toolTipHide(Shell tip, Event event)433 	private void toolTipHide(Shell tip, Event event) {
434 		if (tip != null && !tip.isDisposed() && shouldHideToolTip(event)) {
435 			control.getShell().removeListener(SWT.Deactivate, shellListener);
436 			currentArea = null;
437 			passOnEvent(tip, event);
438 			tip.dispose();
439 			CURRENT_TOOLTIP = null;
440 			afterHideToolTip(event);
441 		}
442 		if (event != null && event.type == SWT.Dispose) {
443 			deactivate();
444 			data = null;
445 		}
446 	}
447 
passOnEvent(Shell tip, Event event)448 	private void passOnEvent(Shell tip, Event event) {
449 		if (control != null && !control.isDisposed() && event != null
450 				&& event.widget != control && event.type == SWT.MouseDown) {
451 			// the following was left in order to fix bug 298770 with minimal change. In 3.7, the complete method should be removed.
452 			tip.close();
453 		}
454 	}
455 
toolTipOpen(final Shell shell, final Event event)456 	private void toolTipOpen(final Shell shell, final Event event) {
457 		// Ensure that only one Tooltip is shown in time
458 		if (CURRENT_TOOLTIP != null) {
459 			toolTipHide(CURRENT_TOOLTIP, null);
460 		}
461 
462 		CURRENT_TOOLTIP = shell;
463 
464 		control.getShell().addListener(SWT.Deactivate, shellListener);
465 
466 		if (popupDelay > 0) {
467 			control.getDisplay().timerExec(popupDelay, () -> toolTipShow(shell, event));
468 		} else {
469 			toolTipShow(CURRENT_TOOLTIP, event);
470 		}
471 
472 		if (hideDelay > 0) {
473 			control.getDisplay().timerExec(popupDelay + hideDelay,
474 					() -> toolTipHide(shell, null));
475 		}
476 	}
477 
toolTipHookByTypeRecursively(Control c, boolean add, int type)478 	private void toolTipHookByTypeRecursively(Control c, boolean add, int type) {
479 		if (add) {
480 			c.addListener(type, hideListener);
481 		} else {
482 			c.removeListener(type, hideListener);
483 		}
484 
485 		if (c instanceof Composite) {
486 			Control[] children = ((Composite) c).getChildren();
487 			for (Control element : children) {
488 				toolTipHookByTypeRecursively(element, add, type);
489 			}
490 		}
491 	}
492 
toolTipHookBothRecursively(Control c)493 	private void toolTipHookBothRecursively(Control c) {
494 		c.addListener(SWT.MouseDown, hideListener);
495 		c.addListener(SWT.MouseExit, hideListener);
496 
497 		if (c instanceof Composite) {
498 			Control[] children = ((Composite) c).getChildren();
499 			for (Control element : children) {
500 				toolTipHookBothRecursively(element);
501 			}
502 		}
503 	}
504 
505 	/**
506 	 * Creates the content area of the the tooltip.
507 	 *
508 	 * @param event
509 	 *            the event that triggered the activation of the tooltip
510 	 * @param parent
511 	 *            the parent of the content area
512 	 * @return the content area created
513 	 */
createToolTipContentArea(Event event, Composite parent)514 	protected abstract Composite createToolTipContentArea(Event event,
515 			Composite parent);
516 
517 	/**
518 	 * This method is called after a tooltip is hidden.
519 	 * <p>
520 	 * <b>Subclasses may override to clean up requested system resources</b>
521 	 * </p>
522 	 *
523 	 * @param event
524 	 *            event triggered the hiding action (may be <code>null</code>
525 	 *            if event wasn't triggered by user actions directly)
526 	 */
afterHideToolTip(Event event)527 	protected void afterHideToolTip(Event event) {
528 
529 	}
530 
531 	/**
532 	 * Set the hide delay.
533 	 *
534 	 * @param hideDelay
535 	 *            the delay before the tooltip is hidden. If <code>0</code>
536 	 *            the tooltip is shown until user moves to other item
537 	 */
setHideDelay(int hideDelay)538 	public void setHideDelay(int hideDelay) {
539 		this.hideDelay = hideDelay;
540 	}
541 
542 	/**
543 	 * Set the popup delay.
544 	 *
545 	 * @param popupDelay
546 	 *            the delay before the tooltip is shown to the user. If
547 	 *            <code>0</code> the tooltip is shown immediately
548 	 */
setPopupDelay(int popupDelay)549 	public void setPopupDelay(int popupDelay) {
550 		this.popupDelay = popupDelay;
551 	}
552 
553 	/**
554 	 * Return if hiding on mouse down is set.
555 	 *
556 	 * @return <code>true</code> if hiding on mouse down in the tool tip is on
557 	 */
isHideOnMouseDown()558 	public boolean isHideOnMouseDown() {
559 		return hideOnMouseDown;
560 	}
561 
562 	/**
563 	 * If you don't want the tool tip to be hidden when the user clicks inside
564 	 * the tool tip set this to <code>false</code>. You maybe also need to
565 	 * hide the tool tip yourself depending on what you do after clicking in the
566 	 * tooltip (e.g. if you open a new {@link Shell})
567 	 *
568 	 * @param hideOnMouseDown
569 	 *            flag to indicate of tooltip is hidden automatically on mouse
570 	 *            down inside the tool tip
571 	 */
setHideOnMouseDown(final boolean hideOnMouseDown)572 	public void setHideOnMouseDown(final boolean hideOnMouseDown) {
573 		// Only needed if there's currently a tooltip active
574 		if (CURRENT_TOOLTIP != null && !CURRENT_TOOLTIP.isDisposed()) {
575 			// Only change if value really changed
576 			if (hideOnMouseDown != this.hideOnMouseDown) {
577 				control.getDisplay().syncExec(() -> {
578 					if (CURRENT_TOOLTIP != null
579 							&& CURRENT_TOOLTIP.isDisposed()) {
580 						toolTipHookByTypeRecursively(CURRENT_TOOLTIP,
581 								hideOnMouseDown, SWT.MouseDown);
582 					}
583 				});
584 			}
585 		}
586 
587 		this.hideOnMouseDown = hideOnMouseDown;
588 	}
589 
590 	/**
591 	 * Hide the currently active tool tip
592 	 */
hide()593 	public void hide() {
594 		toolTipHide(CURRENT_TOOLTIP, null);
595 	}
596 
597 	private class ToolTipOwnerControlListener implements Listener {
598 		@Override
handleEvent(Event event)599 		public void handleEvent(Event event) {
600 			switch (event.type) {
601 			case SWT.Dispose:
602 			case SWT.KeyDown:
603 			case SWT.MouseDown:
604 			case SWT.MouseMove:
605 			case SWT.MouseWheel:
606 				toolTipHide(CURRENT_TOOLTIP, event);
607 				break;
608 			case SWT.MouseHover:
609 				toolTipCreate(event);
610 				break;
611 			case SWT.MouseExit:
612 				/*
613 				 * Check if the mouse exit happened because we move over the
614 				 * tooltip
615 				 */
616 				if (CURRENT_TOOLTIP != null && !CURRENT_TOOLTIP.isDisposed()) {
617 					if (CURRENT_TOOLTIP.getBounds().contains(
618 							control.toDisplay(event.x, event.y))) {
619 						break;
620 					}
621 				}
622 
623 				toolTipHide(CURRENT_TOOLTIP, event);
624 				break;
625 			}
626 		}
627 	}
628 
629 	private class TooltipHideListener implements Listener {
630 		@Override
handleEvent(Event event)631 		public void handleEvent(Event event) {
632 			if (event.widget instanceof Control) {
633 
634 				Control c = (Control) event.widget;
635 				Shell shell = c.getShell();
636 
637 				switch (event.type) {
638 				case SWT.MouseDown:
639 					if (isHideOnMouseDown()) {
640 						toolTipHide(shell, event);
641 					}
642 					break;
643 				case SWT.MouseExit:
644 					Rectangle rect = shell.getBounds();
645 					if (!rect.contains(c.getDisplay().getCursorLocation())) {
646 						toolTipHide(shell, event);
647 					}
648 
649 					break;
650 				}
651 			}
652 		}
653 	}
654 }
655