Bug 498030 - Implement an SWT layout spy

Change-Id: Iaea155b7fe07bcabc79a977e28d8d77d320105fc
Signed-off-by: Stefan Xenos <sxenos@gmail.com>
diff --git a/ui/org.eclipse.pde.runtime/.classpath b/ui/org.eclipse.pde.runtime/.classpath
index 4c62a80..4f83b23 100644
--- a/ui/org.eclipse.pde.runtime/.classpath
+++ b/ui/org.eclipse.pde.runtime/.classpath
@@ -2,6 +2,6 @@
 <classpath>
 	<classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/>
 	<classpathentry kind="src" path="src"/>
-	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.6"/>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8"/>
 	<classpathentry kind="output" path="bin"/>
 </classpath>
diff --git a/ui/org.eclipse.pde.runtime/.settings/org.eclipse.jdt.core.prefs b/ui/org.eclipse.pde.runtime/.settings/org.eclipse.jdt.core.prefs
index 62ec106..519f9d0 100644
--- a/ui/org.eclipse.pde.runtime/.settings/org.eclipse.jdt.core.prefs
+++ b/ui/org.eclipse.pde.runtime/.settings/org.eclipse.jdt.core.prefs
@@ -8,9 +8,10 @@
 org.eclipse.jdt.core.classpath.exclusionPatterns=enabled

 org.eclipse.jdt.core.classpath.multipleOutputLocations=enabled

 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled

-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6

+org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate

+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8

 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve

-org.eclipse.jdt.core.compiler.compliance=1.6

+org.eclipse.jdt.core.compiler.compliance=1.8

 org.eclipse.jdt.core.compiler.debug.lineNumber=generate

 org.eclipse.jdt.core.compiler.debug.localVariable=generate

 org.eclipse.jdt.core.compiler.debug.sourceFile=generate

@@ -103,7 +104,7 @@
 org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=error

 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning

 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning

-org.eclipse.jdt.core.compiler.source=1.6

+org.eclipse.jdt.core.compiler.source=1.8

 org.eclipse.jdt.core.formatter.align_type_members_on_columns=false

 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression=16

 org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation=0

diff --git a/ui/org.eclipse.pde.runtime/META-INF/MANIFEST.MF b/ui/org.eclipse.pde.runtime/META-INF/MANIFEST.MF
index 9847de0..778f97c 100644
--- a/ui/org.eclipse.pde.runtime/META-INF/MANIFEST.MF
+++ b/ui/org.eclipse.pde.runtime/META-INF/MANIFEST.MF
@@ -2,14 +2,19 @@
 Bundle-ManifestVersion: 2
 Bundle-Name: %name
 Bundle-SymbolicName: org.eclipse.pde.runtime; singleton:=true
-Bundle-Version: 3.5.100.qualifier
+Bundle-Version: 3.6.0.qualifier
 Bundle-Activator: org.eclipse.pde.internal.runtime.PDERuntimePlugin
 Bundle-Vendor: %provider-name
 Bundle-Localization: plugin
-Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.11.0,4.0.0)",
+Require-Bundle: org.eclipse.core.databinding;bundle-version="1.6.0",
+ org.eclipse.core.databinding.observable;bundle-version="1.6.0",
+ org.eclipse.core.databinding.property;bundle-version="1.6.0",
+ org.eclipse.core.runtime;bundle-version="[3.11.0,4.0.0)",
  org.eclipse.ui;bundle-version="[3.3.0,4.0.0)",
  org.eclipse.ui.forms;bundle-version="[3.3.0,4.0.0)",
+ org.eclipse.jdt.annotation;bundle-version="2.1.0",
  org.eclipse.jdt.core;bundle-version="[3.3.0,4.0.0)";resolution:=optional,
+ org.eclipse.jface.databinding;bundle-version="1.8.0",
  org.eclipse.core.resources;bundle-version="[3.3.0,4.0.0)";resolution:=optional,
  org.eclipse.jdt.ui;bundle-version="[3.3.0,4.0.0)";resolution:=optional,
  org.eclipse.pde.ui;bundle-version="[3.3.0,4.0.0)";resolution:=optional,
@@ -22,5 +27,5 @@
  org.eclipse.pde.internal.runtime.spy.dialogs;x-internal:=true,
  org.eclipse.pde.internal.runtime.spy.handlers;x-internal:=true,
  org.eclipse.pde.internal.runtime.spy.sections;x-internal:=true
-Bundle-RequiredExecutionEnvironment: JavaSE-1.6
+Bundle-RequiredExecutionEnvironment: JavaSE-1.8
 Bundle-ActivationPolicy: lazy
diff --git a/ui/org.eclipse.pde.runtime/icons/elcl16/up_nav.png b/ui/org.eclipse.pde.runtime/icons/elcl16/up_nav.png
new file mode 100644
index 0000000..a9830bc
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/icons/elcl16/up_nav.png
Binary files differ
diff --git a/ui/org.eclipse.pde.runtime/icons/elcl16/up_nav@2x.png b/ui/org.eclipse.pde.runtime/icons/elcl16/up_nav@2x.png
new file mode 100644
index 0000000..adaef6d
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/icons/elcl16/up_nav@2x.png
Binary files differ
diff --git a/ui/org.eclipse.pde.runtime/icons/obj16/layoutspy_obj.png b/ui/org.eclipse.pde.runtime/icons/obj16/layoutspy_obj.png
new file mode 100644
index 0000000..c729f06
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/icons/obj16/layoutspy_obj.png
Binary files differ
diff --git a/ui/org.eclipse.pde.runtime/plugin.properties b/ui/org.eclipse.pde.runtime/plugin.properties
index 1439826..82eeec5 100644
--- a/ui/org.eclipse.pde.runtime/plugin.properties
+++ b/ui/org.eclipse.pde.runtime/plugin.properties
@@ -21,5 +21,7 @@
 spy-category.name = Spy
 spy-command.name = Plug-in Selection Spy
 spy-command.description = Show the Plug-in Spy
+spy-layout-command.name = Layout Spy
+spy-layout-command.description = Show the Layout Spy
 spy-menu-command.name = Plug-in Menu Spy
 
diff --git a/ui/org.eclipse.pde.runtime/plugin.xml b/ui/org.eclipse.pde.runtime/plugin.xml
index 0824cb2..605627a 100644
--- a/ui/org.eclipse.pde.runtime/plugin.xml
+++ b/ui/org.eclipse.pde.runtime/plugin.xml
@@ -46,6 +46,12 @@
             id="org.eclipse.pde.runtime.spy.commands.menuSpyCommand"
             name="%spy-menu-command.name">
       </command>
+      <command
+            categoryId="org.eclipse.pde.runtime.spy.commands.category"
+            description="%spy-layout-command.description"
+            id="org.eclipse.pde.runtime.spy.commands.layoutSpyCommand"
+            name="%spy-layout-command.name">
+      </command>
    </extension>
    <extension
          point="org.eclipse.ui.commandImages">
@@ -57,6 +63,10 @@
             commandId="org.eclipse.pde.runtime.spy.commands.menuSpyCommand"
             icon="$nl$/icons/obj16/menuspy_obj.gif">
       </image>
+      <image
+            commandId="org.eclipse.pde.runtime.spy.commands.layoutSpyCommand"
+            icon="$nl$/icons/obj16/layoutspy_obj.png">
+      </image>
    </extension>
    <extension
          point="org.eclipse.ui.bindings">
@@ -87,6 +97,12 @@
             schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
             sequence="M3+M2+F2">
       </key>
+      <key
+            commandId="org.eclipse.pde.runtime.spy.commands.layoutSpyCommand"
+            contextId="org.eclipse.ui.contexts.dialogAndWindow"
+            schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
+            sequence="M1+M2+M3+F9">
+      </key>
    </extension>
    <extension
          point="org.eclipse.ui.handlers">
@@ -98,6 +114,10 @@
             class="org.eclipse.pde.internal.runtime.spy.handlers.MenuSpyHandler"
             commandId="org.eclipse.pde.runtime.spy.commands.menuSpyCommand">
       </handler>
+      <handler
+            class="org.eclipse.pde.internal.runtime.spy.handlers.LayoutSpyHandler"
+            commandId="org.eclipse.pde.runtime.spy.commands.layoutSpyCommand">
+      </handler>
    </extension>
 
 </plugin>
diff --git a/ui/org.eclipse.pde.runtime/pom.xml b/ui/org.eclipse.pde.runtime/pom.xml
index 0c15e27..a0aa311 100644
--- a/ui/org.eclipse.pde.runtime/pom.xml
+++ b/ui/org.eclipse.pde.runtime/pom.xml
@@ -20,7 +20,7 @@
   </parent>
   <groupId>org.eclipse.pde</groupId>
   <artifactId>org.eclipse.pde.runtime</artifactId>
-  <version>3.5.100-SNAPSHOT</version>
+  <version>3.6.0-SNAPSHOT</version>
   <packaging>eclipse-plugin</packaging>
 
   <properties>
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/PDERuntimePluginImages.java b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/PDERuntimePluginImages.java
index 1b83d09..a12c0b3 100644
--- a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/PDERuntimePluginImages.java
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/PDERuntimePluginImages.java
@@ -39,6 +39,7 @@
 	public static final String IMG_CONTEXTID_OBJ = "contextid_obj.gif"; //$NON-NLS-1$
 	public static final String IMG_SAVE_IMAGE_AS_OBJ = "save_image_as_obj.gif"; //$NON-NLS-1$
 	public static final String IMG_COPY_QNAME = "cpyqual_menu.gif"; //$NON-NLS-1$
+	public static final String IMG_UP_NAV = "up_nav.png"; //$NON-NLS-1$
 
 	public static final ImageDescriptor CLASS_OBJ = create(PATH_OBJ, IMG_CLASS_OBJ);
 	public static final ImageDescriptor INTERFACE_OBJ = create(PATH_OBJ, IMG_INTERFACE_OBJ);
@@ -50,6 +51,7 @@
 	public static final ImageDescriptor CONTEXTID_OBJ = create(PATH_OBJ, IMG_CONTEXTID_OBJ);
 	public static final ImageDescriptor SAVE_IMAGE_AS_OBJ = create(PATH_OBJ, IMG_SAVE_IMAGE_AS_OBJ);
 	public static final ImageDescriptor COPY_QNAME = create(PATH_LCL, IMG_COPY_QNAME);
+	public static final ImageDescriptor UP_NAV = create(PATH_LCL, IMG_UP_NAV);
 
 	public static final ImageDescriptor DESC_REFRESH_DISABLED = create(PATH_DCL, "refresh.gif"); //$NON-NLS-1$
 	public static final ImageDescriptor DESC_REFRESH = create(PATH_LCL, "refresh.gif"); //$NON-NLS-1$
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/ControlSelector.java b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/ControlSelector.java
new file mode 100644
index 0000000..e508ec6
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/ControlSelector.java
@@ -0,0 +1,197 @@
+/*******************************************************************************
+ * Copyright (c) 2016 Google Inc and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     Stefan Xenos (Google) - initial API and implementation
+ *
+ *******************************************************************************/
+package org.eclipse.pde.internal.runtime.spy.dialogs;
+
+import java.util.ArrayList;
+import java.util.function.Consumer;
+import org.eclipse.core.databinding.observable.value.WritableValue;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jface.databinding.swt.WidgetSideEffects;
+import org.eclipse.jface.resource.JFaceResources;
+import org.eclipse.jface.resource.LocalResourceManager;
+import org.eclipse.jface.util.Geometry;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.DisposeEvent;
+import org.eclipse.swt.events.PaintEvent;
+import org.eclipse.swt.graphics.*;
+import org.eclipse.swt.widgets.*;
+
+/**
+ * Creates an overlay that allows the user to select any SWT control by moving
+ * the mouse and clicking. Once the user selects the control or presses ESC, the
+ * overlay is closed.
+ */
+public class ControlSelector {
+	private static final int EDGE_SIZE = 4;
+
+	private Shell overlay;
+	private LocalResourceManager resources;
+	private Color selectionRectangleColor;
+	private Display display;
+	private WritableValue<@Nullable Control> currentSelection = new WritableValue<>(null, null);
+	private Region region;
+	private Consumer<@Nullable Control> callback;
+	private Listener moveFilter = this::mouseMove;
+	private Listener selectFilter = this::select;
+
+	/**
+	 * Instantiates and opens the control selector.
+	 *
+	 * @param resultCallback
+	 *            callback that will be invoked when the overlay is closed. If
+	 *            the user selected a control, that control is passed to the
+	 *            callback. If the user cancelled the overlay, null is passed.
+	 */
+	public ControlSelector(Consumer<@Nullable Control> resultCallback) {
+		this.callback = resultCallback;
+		display = Display.getCurrent();
+		overlay = new Shell(SWT.ON_TOP | SWT.NO_TRIM);
+		overlay.addPaintListener(this::paint);
+		resources = new LocalResourceManager(JFaceResources.getResources(), overlay);
+		selectionRectangleColor = resources.createColor(new RGB(255, 255, 0));
+		display.addFilter(SWT.MouseMove, moveFilter);
+		display.addFilter(SWT.MouseDown, selectFilter);
+		overlay.addDisposeListener(this::disposed);
+		region = new Region();
+		WidgetSideEffects.createFactory(overlay).create(currentSelection::getValue, this::updateRegion);
+	}
+
+	private void updateRegion(@Nullable Control newControl) {
+		if (newControl == null) {
+			overlay.setVisible(false);
+			return;
+		}
+		overlay.setBounds(newControl.getMonitor().getClientArea());
+		Rectangle parentBoundsWrtDisplay = GeometryUtil.getDisplayBounds(newControl);
+		Rectangle parentBoundsWrtOverlay = Geometry.toControl(overlay, parentBoundsWrtDisplay);
+		Rectangle innerBoundsWrtOverlay = Geometry.copy(parentBoundsWrtOverlay);
+		Geometry.expand(innerBoundsWrtOverlay, -EDGE_SIZE, -EDGE_SIZE, -EDGE_SIZE, -EDGE_SIZE);
+		region.dispose();
+		region = new Region();
+		region.add(parentBoundsWrtOverlay);
+		region.subtract(innerBoundsWrtOverlay);
+		overlay.setRegion(region);
+		overlay.setVisible(true);
+	}
+
+	private void disposed(DisposeEvent e) {
+		currentSelection.dispose();
+		region.dispose();
+		display.removeFilter(SWT.MouseMove, moveFilter);
+		display.removeFilter(SWT.MouseDown, selectFilter);
+	}
+
+	private void select(Event e) {
+		closeWithResult(this.currentSelection.getValue());
+		e.doit = false;
+	}
+
+	private void closeWithResult(@Nullable Control result) {
+		display.removeFilter(SWT.MouseMove, moveFilter);
+		display.removeFilter(SWT.MouseDown, selectFilter);
+		overlay.dispose();
+		callback.accept(result);
+	}
+
+	/**
+	 * Finds and returns the most specific SWT control at the given location.
+	 * (Note: this does a DFS on the SWT widget hierarchy, which is slow).
+	 *
+	 * @param displayToSearch
+	 * @param locationToFind
+	 * @return the most specific SWT control at the given location
+	 */
+	public static Control findControl(Display displayToSearch, Shell toIgnore, Point locationToFind) {
+		Shell[] shells = displayToSearch.getShells();
+
+		ArrayList<Shell> shellList = new ArrayList<>();
+		for (Shell next : shells) {
+			if (next == toIgnore) {
+				continue;
+			}
+			shellList.add(next);
+		}
+
+		shells = shellList.toArray(new Shell[shellList.size()]);
+
+		return findControl(shells, locationToFind);
+	}
+
+	/**
+	 * Finds the control at the given location.
+	 *
+	 * @param toSearch
+	 * @param locationToFind
+	 *            location (in display coordinates)
+	 * @return the control at the given location
+	 */
+	public static Control findControl(Composite toSearch, Point locationToFind) {
+		Control[] children = toSearch.getChildren();
+
+		return findControl(children, locationToFind);
+	}
+
+	/**
+	 * Searches the given list of controls for a control containing the given
+	 * point. If the array contains any composites, those composites will be
+	 * recursively searched to find the most specific child that contains the
+	 * point.
+	 *
+	 * @param toSearch
+	 *            an array of composites
+	 * @param locationToFind
+	 *            a point (in display coordinates)
+	 * @return the most specific Control that overlaps the given point, or null
+	 *         if none
+	 */
+	public static Control findControl(Control[] toSearch, Point locationToFind) {
+		for (int idx = toSearch.length - 1; idx >= 0; idx--) {
+			Control next = toSearch[idx];
+
+			if (!next.isDisposed() && next.isVisible()) {
+
+				Rectangle bounds = GeometryUtil.getDisplayBounds(next);
+
+				if (bounds.contains(locationToFind)) {
+					if (next instanceof Composite) {
+						Control result = findControl((Composite) next, locationToFind);
+
+						if (result != null) {
+							return result;
+						}
+					}
+
+					return next;
+				}
+			}
+		}
+
+		return null;
+	}
+
+	private void mouseMove(Event e) {
+		Point globalPoint = new Point(e.x, e.y);
+		if (e.widget instanceof Control) {
+			Control control = (Control) e.widget;
+
+			globalPoint = control.toDisplay(globalPoint);
+		}
+		Control control = findControl(Display.getCurrent(), overlay, globalPoint);
+		currentSelection.setValue(control);
+	}
+
+	protected void paint(PaintEvent e) {
+		e.gc.setBackground(selectionRectangleColor);
+		e.gc.fillRectangle(overlay.getClientArea());
+	}
+
+}
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/GeometryUtil.java b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/GeometryUtil.java
new file mode 100644
index 0000000..57d5ae8
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/GeometryUtil.java
@@ -0,0 +1,43 @@
+/*******************************************************************************
+ * Copyright (c) 2016 Google Inc and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     Stefan Xenos (Google) - initial API and implementation
+ *
+ *******************************************************************************/
+package org.eclipse.pde.internal.runtime.spy.dialogs;
+
+import org.eclipse.jface.util.Geometry;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+
+public class GeometryUtil {
+	public static Rectangle getDisplayBounds(Control boundsControl) {
+		Control parent = boundsControl.getParent();
+		if (parent == null || boundsControl instanceof Shell) {
+			return boundsControl.getBounds();
+		}
+	
+		return Geometry.toDisplay(parent, boundsControl.getBounds());
+	}
+
+	public static Rectangle extrudeEdge(Rectangle innerBoundsWrtOverlay, int distanceToTop, int side) {
+		if (distanceToTop <= 0) {
+			return new Rectangle(0, 0, 0, 0);
+		}
+		return Geometry.getExtrudedEdge(innerBoundsWrtOverlay, distanceToTop, side);
+	}
+
+	public static int getBottom(Rectangle rect) {
+		return rect.y + rect.height;
+	}
+
+	public static int getRight(Rectangle rect) {
+		return rect.x + rect.width;
+	}
+}
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/LayoutSpyDialog.java b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/LayoutSpyDialog.java
new file mode 100644
index 0000000..17252c3
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/LayoutSpyDialog.java
@@ -0,0 +1,735 @@
+/*******************************************************************************
+ * Copyright (c) 2016 Google Inc and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     Stefan Xenos (Google) - initial API and implementation
+ *
+ *******************************************************************************/
+package org.eclipse.pde.internal.runtime.spy.dialogs;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.List;
+import org.eclipse.core.databinding.observable.list.ComputedList;
+import org.eclipse.core.databinding.observable.sideeffect.ISideEffectFactory;
+import org.eclipse.core.databinding.observable.value.WritableValue;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jface.databinding.swt.*;
+import org.eclipse.jface.databinding.viewers.*;
+import org.eclipse.jface.layout.*;
+import org.eclipse.jface.resource.*;
+import org.eclipse.jface.util.Geometry;
+import org.eclipse.jface.viewers.TableViewer;
+import org.eclipse.osgi.util.NLS;
+import org.eclipse.pde.internal.runtime.PDERuntimePluginImages;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.PaintEvent;
+import org.eclipse.swt.graphics.*;
+import org.eclipse.swt.layout.*;
+import org.eclipse.swt.widgets.*;
+
+/**
+ * Implementation of the "layout spy" dialog, a diagnostic tool for fixing bugs
+ * related to control positioning and the implementation of SWT {@link Control}s
+ * and {@link Layout}s.
+ */
+public class LayoutSpyDialog {
+	private static final int EDGE_SIZE = 4;
+	private static final RGB SELECTED_PARENT_OVERLAY_COLOR = new RGB(255, 0, 0);
+	private static final RGB SELECTED_CHILD_OVERLAY_COLOR = new RGB(255, 255, 0);
+
+	/**
+	 * Value used to indicate an unknown hint value
+	 */
+	private static final int UNKNOWN = -2;
+	private Shell shell;
+
+	// Controls
+	private TableViewer childList;
+	private Text details;
+	private Button selectWidgetButton;
+	private Button goUpButton;
+	private Button goDownButton;
+	private Shell overlay;
+
+	// Model
+	private WritableValue<@Nullable Composite> parentControl = new WritableValue<>(null, null);
+	private WritableValue<Boolean> controlSelectorOpen = new WritableValue<>(Boolean.FALSE, null);
+	private ComputedList<Control> listContents;
+	private IViewerObservableValue selectedChild;
+	private Color parentRectangleColor;
+	private Color childRectangleColor;
+	private ResourceManager resources;
+	private Region region;
+	private ISWTObservableValue overlayEnabled;
+	private Image upImage;
+	private Text diagnostics;
+
+	/**
+	 * Creates the dialog but does not make it visible.
+	 * 
+	 * @param parentShell
+	 *            the parent shell
+	 */
+	public LayoutSpyDialog(Shell parentShell) {
+		overlay = new Shell(SWT.ON_TOP | SWT.NO_TRIM);
+		{
+			overlay.addPaintListener(this::paintOverlay);
+			region = new Region();
+			overlay.addDisposeListener((DisposeEvent) -> {
+				region.dispose();
+			});
+			overlay.setRegion(region);
+		}
+
+		shell = new Shell(parentShell, SWT.SHELL_TRIM);
+
+		resources = new LocalResourceManager(JFaceResources.getResources(), shell);
+		parentRectangleColor = resources.createColor(SELECTED_PARENT_OVERLAY_COLOR);
+		childRectangleColor = resources.createColor(SELECTED_CHILD_OVERLAY_COLOR);
+		upImage = resources.createImage(PDERuntimePluginImages.UP_NAV);
+
+		Composite infoRegion = new Composite(shell, SWT.NONE);
+		{
+			Composite headerRegion = new Composite(infoRegion, SWT.NONE);
+			{
+				Button upButton = new Button(headerRegion, SWT.PUSH | SWT.CENTER);
+				upButton.setImage(upImage);
+				upButton.addListener(SWT.Selection, event -> goUp());
+				GridDataFactory.fillDefaults().applyTo(upButton);
+
+				Label childrenLabel = new Label(headerRegion, SWT.NONE);
+				childrenLabel.setText(Messages.LayoutSpyDialog_label_children);
+			}
+			GridLayoutFactory.fillDefaults().numColumns(2).generateLayout(headerRegion);
+
+			Label detailsLabel = new Label(infoRegion, SWT.NONE);
+			detailsLabel.setText(Messages.LayoutSpyDialog_label_layout);
+
+			childList = new TableViewer(infoRegion);
+			details = new Text(infoRegion, SWT.READ_ONLY | SWT.MULTI | SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL);
+			GridDataFactory.fillDefaults().hint(300, 300).grab(true, true).applyTo(details);
+
+			GridLayoutFactory.fillDefaults().numColumns(2).equalWidth(true).generateLayout(infoRegion);
+		}
+
+		diagnostics = new Text(shell, SWT.READ_ONLY | SWT.MULTI | SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL);
+		GridDataFactory.fillDefaults().hint(300, 300).grab(true, true).applyTo(diagnostics);
+
+		Button showOverlayButton = new Button(shell, SWT.CHECK);
+		showOverlayButton.setText(Messages.LayoutSpyDialog_button_show_overlay);
+
+		Composite buttonBar = new Composite(shell, SWT.NONE);
+		{
+			selectWidgetButton = new Button(buttonBar, SWT.PUSH);
+			selectWidgetButton.setText(Messages.LayoutSpyDialog_button_select_control);
+			goUpButton = new Button(buttonBar, SWT.PUSH);
+			goUpButton.setText(Messages.LayoutSpyDialog_button_open_parent);
+			goDownButton = new Button(buttonBar, SWT.PUSH);
+			goDownButton.setText(Messages.LayoutSpyDialog_button_open_child);
+
+			GridLayoutFactory.fillDefaults().numColumns(3).generateLayout(buttonBar);
+		}
+		GridDataFactory.fillDefaults().align(SWT.CENTER, SWT.CENTER).applyTo(buttonBar);
+
+		GridLayoutFactory.fillDefaults().margins(LayoutConstants.getMargins()).generateLayout(shell);
+
+		// Attach listeners
+		shell.addDisposeListener(event -> disposed());
+		selectWidgetButton.addListener(SWT.Selection, event -> selectControl());
+		goUpButton.addListener(SWT.Selection, event -> goUp());
+		goDownButton.addListener(SWT.Selection, event -> goDown());
+		childList.addOpenListener(event -> {
+			goDown();
+		});
+
+		// Set up the model
+		selectedChild = ViewerProperties.singleSelection().observe(childList);
+		overlayEnabled = WidgetProperties.selection().observe(showOverlayButton);
+		childList.setContentProvider(new ObservableListContentProvider());
+		listContents = new ComputedList<Control>() {
+			@Override
+			protected List<Control> calculate() {
+				Composite control = parentControl.getValue();
+				if (control == null) {
+					return Arrays.asList(Display.getCurrent().getShells());
+				}
+				return Arrays.asList(control.getChildren());
+			}
+		};
+		childList.setInput(listContents);
+		ISideEffectFactory sideEffectFactory = WidgetSideEffects.createFactory(shell);
+		sideEffectFactory.create(this::computeParentInfo, details::setText);
+		sideEffectFactory.create(this::computeChildInfo, diagnostics::setText);
+		sideEffectFactory.create(this::updateOverlay);
+
+		openComposite(parentShell);
+	}
+
+	/**
+	 * Opens the dialog box, revealing it to the user.
+	 */
+	public void open() {
+		this.shell.pack();
+		this.shell.open();
+	}
+
+	/**
+	 * Disposes the dialog box.
+	 */
+	public void close() {
+		this.shell.dispose();
+	}
+
+	/**
+	 * Invoked as a callback when the main shell is disposed.
+	 */
+	private void disposed() {
+		listContents.dispose();
+		selectedChild.dispose();
+		parentControl.dispose();
+
+		overlay.dispose();
+	}
+
+	private void openComposite(Composite composite) {
+		parentControl.setValue(composite);
+	}
+
+	/**
+	 * Returns the currently-selected child control or null if none.
+	 */
+	private @Nullable Control getSelectedChild() {
+		return (@Nullable Control) selectedChild.getValue();
+	}
+
+	/**
+	 * Opens the given control in the layout spy.
+	 */
+	@SuppressWarnings("unchecked")
+	private void openControl(Control control) {
+		Composite parent = control.getParent();
+
+		if (parent == null) {
+			if (control instanceof Composite) {
+				parentControl.setValue((Composite) control);
+			}
+		} else {
+			parentControl.setValue(parent);
+			selectedChild.setValue(control);
+		}
+	}
+
+	// Overlay management
+	// -----------------------------------------------------------------
+
+	/**
+	 * This callback is used to update the bounds and visible region for the
+	 * overlay shell. It is used as part of a side-effect, so if it makes use of
+	 * any tracked getters, it will automatically be invoked again whenever one
+	 * of those tracked getters changes state.
+	 *
+	 * @TrackedGetter
+	 */
+	public void updateOverlay() {
+		@Nullable
+		Composite parent = parentControl.getValue();
+
+		boolean enabled = Boolean.TRUE.equals(overlayEnabled.getValue());
+
+		overlay.setVisible(parent != null && !controlSelectorOpen.getValue() && enabled);
+		if (parent == null) {
+			return;
+		}
+		Shell shell = parent.getShell();
+		Rectangle outerBounds = Geometry.copy(shell.getBounds());
+		overlay.setBounds(outerBounds);
+		Rectangle parentBoundsWrtDisplay = GeometryUtil.getDisplayBounds(parent);
+
+		Rectangle parentBoundsWrtOverlay = Geometry.toControl(overlay, parentBoundsWrtDisplay);
+		Rectangle innerBoundsWrtOverlay = Geometry.copy(parentBoundsWrtOverlay);
+		Geometry.expand(innerBoundsWrtOverlay, -EDGE_SIZE, -EDGE_SIZE, -EDGE_SIZE, -EDGE_SIZE);
+		region.dispose();
+		region = new Region();
+		@Nullable
+		Control child = (@Nullable Control) selectedChild.getValue();
+		if (child != null) {
+			Rectangle childBoundsWrtOverlay = Geometry.toControl(overlay, GeometryUtil.getDisplayBounds(child));
+			Rectangle childInnerBoundsWrtOverlay = Geometry.copy(childBoundsWrtOverlay);
+			Geometry.expand(childInnerBoundsWrtOverlay, -EDGE_SIZE, -EDGE_SIZE, -EDGE_SIZE, -EDGE_SIZE);
+			region.add(parentBoundsWrtOverlay);
+			int distanceToTop = childBoundsWrtOverlay.y - innerBoundsWrtOverlay.y;
+			subtractRect(region, GeometryUtil.extrudeEdge(innerBoundsWrtOverlay, distanceToTop, SWT.TOP));
+			int distanceToLeft = childBoundsWrtOverlay.x - innerBoundsWrtOverlay.x;
+			subtractRect(region, GeometryUtil.extrudeEdge(innerBoundsWrtOverlay, distanceToLeft, SWT.LEFT));
+			int distanceToRight = GeometryUtil.getRight(innerBoundsWrtOverlay) - GeometryUtil.getRight(childBoundsWrtOverlay);
+			subtractRect(region, GeometryUtil.extrudeEdge(innerBoundsWrtOverlay, distanceToRight, SWT.RIGHT));
+			int distanceToBottom = GeometryUtil.getBottom(innerBoundsWrtOverlay) - GeometryUtil.getBottom(childBoundsWrtOverlay);
+			subtractRect(region, GeometryUtil.extrudeEdge(innerBoundsWrtOverlay, distanceToBottom, SWT.BOTTOM));
+
+			subtractRect(region, childInnerBoundsWrtOverlay);
+		} else {
+			region.add(parentBoundsWrtOverlay);
+			region.subtract(innerBoundsWrtOverlay);
+		}
+
+		overlay.redraw();
+		overlay.setRegion(region);
+	}
+
+	/**
+	 * Paint callback for the overlay shell. This draws rectangles around the
+	 * selected layout and the selected child.
+	 */
+	protected void paintOverlay(PaintEvent e) {
+		@Nullable
+		Composite parent = parentControl.getValue();
+		if (parent == null) {
+			return;
+		}
+		int halfSize = EDGE_SIZE / 2;
+		Rectangle parentDisplayBounds = GeometryUtil.getDisplayBounds(parent);
+		Rectangle parentBoundsWrtOverlay = Geometry.toControl(overlay, parentDisplayBounds);
+		Geometry.expand(parentBoundsWrtOverlay, -halfSize, -halfSize, -halfSize, -halfSize);
+
+		@Nullable
+		Control child = (@Nullable Control) selectedChild.getValue();
+		e.gc.setLineWidth(EDGE_SIZE);
+		e.gc.setForeground(parentRectangleColor);
+		e.gc.drawRectangle(parentBoundsWrtOverlay.x, parentBoundsWrtOverlay.y, parentBoundsWrtOverlay.width,
+				parentBoundsWrtOverlay.height);
+
+		if (child != null) {
+			Rectangle childBoundsWrtOverlay = Geometry.toControl(overlay, GeometryUtil.getDisplayBounds(child));
+			Geometry.expand(childBoundsWrtOverlay, -halfSize, -halfSize, -halfSize, -halfSize);
+			e.gc.setForeground(childRectangleColor);
+			e.gc.drawRectangle(childBoundsWrtOverlay.x, childBoundsWrtOverlay.y, childBoundsWrtOverlay.width,
+					childBoundsWrtOverlay.height);
+		}
+	}
+
+	// User gesture callbacks
+	// -----------------------------------------------------------
+
+	/**
+	 * Invoked when the user clicks the "select control" button. It opens some
+	 * UI that allows the user to select a new input control for the layout spy.
+	 */
+	private void selectControl() {
+		this.controlSelectorOpen.setValue(true);
+		this.shell.setVisible(false);
+		new ControlSelector((@Nullable Control control) -> {
+			if (control != null) {
+				openControl(control);
+			}
+			this.controlSelectorOpen.setValue(false);
+			this.shell.setVisible(true);
+		});
+	}
+
+	/**
+	 * Invoked when the user clicks the "go up" button, which opens the parent.
+	 */
+	@SuppressWarnings("unchecked")
+	private void goUp() {
+		@Nullable
+		Composite parent = parentControl.getValue();
+		if (parent == null) {
+			return;
+		}
+		Composite ancestor = parent.getParent();
+		openComposite(ancestor);
+		this.selectedChild.setValue(parent);
+	}
+
+	/**
+	 * Invoked when the user clicks the "go down" button, which opens the
+	 * selected child.
+	 */
+	private void goDown() {
+		Control child = getSelectedChild();
+		if (child instanceof Composite) {
+			Composite composite = (Composite) child;
+			openComposite(composite);
+		}
+	}
+
+	// Utility functions -----------------------------------------------------
+
+	/**
+	 * Subtracts the given rectangle from the given region unless the rectangle
+	 * is empty.
+	 */
+	private static void subtractRect(Region region, Rectangle rect) {
+		if (rect.isEmpty()) {
+			return;
+		}
+		region.subtract(rect);
+	}
+
+	private String getWarningMessage(String string) {
+		return NLS.bind(Messages.LayoutSpyDialog_warning_prefix, string);
+	}
+
+	private static String printHint(int hint) {
+		if (hint == SWT.DEFAULT) {
+			return "SWT.DEFAULT"; //$NON-NLS-1$
+		}
+		return Integer.toString(hint);
+	}
+
+	private static String printPoint(Point toPrint) {
+		return NLS.bind("({0}, {1})", new Object[] { toPrint.x, toPrint.y }); //$NON-NLS-1$
+	}
+
+	// Control classification ------------------------------------------------
+
+	private static boolean isHorizontallyScrollable(Control child) {
+		return (child.getStyle() & SWT.H_SCROLL) != 0;
+	}
+
+	private static boolean isVerticallyScrollable(Control child) {
+		return (child.getStyle() & SWT.V_SCROLL) != 0;
+	}
+
+	/**
+	 * Computes the values that should be subtracted off the width and height
+	 * hints from computeSize on the given control.
+	 */
+	private static Point computeHintAdjustment(Control control) {
+		int widthAdjustment;
+		int heightAdjustment;
+		if (control instanceof Scrollable) {
+			// For composites, subtract off the trim size
+			Scrollable composite = (Scrollable) control;
+			Rectangle trim = composite.computeTrim(0, 0, 0, 0);
+
+			widthAdjustment = trim.width;
+			heightAdjustment = trim.height;
+		} else {
+			// For non-composites, subtract off 2 * the border size
+			widthAdjustment = control.getBorderWidth() * 2;
+			heightAdjustment = widthAdjustment;
+		}
+
+		return new Point(widthAdjustment, heightAdjustment);
+	}
+
+	/**
+	 * Returns true if the given control is a composite which can expand in the
+	 * given dimension. Returns false if the control either cannot expand in the
+	 * given dimension or if its growable characteristics cannot be computed in
+	 * that dimension.
+	 */
+	private static boolean isGrowableLayout(Control control, boolean horizontal) {
+		if (control instanceof Composite) {
+			Composite composite = (Composite) control;
+
+			Layout theLayout = composite.getLayout();
+			if (theLayout instanceof GridLayout) {
+				Control[] children = composite.getChildren();
+				for (int i = 0; i < children.length; i++) {
+					Control child = children[i];
+
+					GridData data = (GridData) child.getLayoutData();
+
+					if (data != null) {
+						if (horizontal) {
+							if (data.grabExcessHorizontalSpace) {
+								return true;
+							}
+						} else {
+							if (data.grabExcessVerticalSpace) {
+								return true;
+							}
+						}
+					}
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Returns true iff another visible widget in the same shell overlaps the
+	 * given control.
+	 */
+	private static boolean overlapsSibling(Control toFind) {
+		Composite parent = toFind.getParent();
+		Control current = toFind;
+		Rectangle displayBounds = GeometryUtil.getDisplayBounds(toFind);
+
+		while (parent != null && !(parent instanceof Shell)) {
+			for (Control nextSibling : parent.getChildren()) {
+				if (nextSibling == current) {
+					continue;
+				}
+				if (!nextSibling.isVisible()) {
+					continue;
+				}
+				Rectangle nextSiblingBounds = GeometryUtil.getDisplayBounds(nextSibling);
+				if (nextSiblingBounds.intersects(displayBounds)) {
+					return true;
+				}
+			}
+			current = parent;
+			parent = parent.getParent();
+		}
+		return false;
+	}
+
+	/**
+	 * Computes the string that will be shown in the text box which displays
+	 * information about the selected child. This is a tracked getter -- if it
+	 * reads from a databinding observable, the text box will automatically
+	 * refresh in response to changes in that observable.
+	 * 
+	 * @TrackedGetter
+	 */
+	private String computeChildInfo() {
+		StringBuilder builder = new StringBuilder();
+		Control child = getSelectedChild();
+
+		if (child != null) {
+			builder.append(child.getClass().getName());
+			builder.append("\n\n"); //$NON-NLS-1$
+
+			int widthHintFromLayoutData = UNKNOWN;
+			int heightHintFromLayoutData = UNKNOWN;
+			Object layoutData = child.getLayoutData();
+			if (layoutData == null) {
+				builder.append("getLayoutData() == null\n"); //$NON-NLS-1$
+			} else if (layoutData instanceof GridData) {
+				GridData grid = (GridData) layoutData;
+				builder.append(GridDataFactory.createFrom(grid));
+				widthHintFromLayoutData = grid.widthHint;
+				heightHintFromLayoutData = grid.heightHint;
+
+				if (!grid.grabExcessHorizontalSpace) {
+					if (isHorizontallyScrollable(child) || isGrowableLayout(child, true)) {
+						builder.append(getWarningMessage(
+								Messages.LayoutSpyDialog_warning_grab_horizontally_scrolling));
+					}
+				}
+				if (!grid.grabExcessVerticalSpace) {
+					if (isVerticallyScrollable(child) || isGrowableLayout(child, false)) {
+						builder.append(getWarningMessage(
+								Messages.LayoutSpyDialog_warning_grab_vertical_scrolling));
+					}
+				}
+			} else if (layoutData instanceof FormData) {
+				FormData data = (FormData) layoutData;
+
+				widthHintFromLayoutData = data.width;
+				heightHintFromLayoutData = data.height;
+			} else {
+				describeObject(builder, "data", layoutData); //$NON-NLS-1$
+			}
+
+			if (isHorizontallyScrollable(child)) {
+				if (widthHintFromLayoutData == SWT.DEFAULT) {
+					builder.append(getWarningMessage(Messages.LayoutSpyDialog_warning_hint_for_horizontally_scrollable));
+				}
+			}
+
+			if (isVerticallyScrollable(child)) {
+				if (heightHintFromLayoutData == SWT.DEFAULT) {
+					builder.append(getWarningMessage(Messages.LayoutSpyDialog_warning_hint_for_vertically_scrollable));
+				}
+			}
+
+			builder.append("\n"); //$NON-NLS-1$
+
+			// Print the current dimensions
+			Rectangle bounds = child.getBounds();
+			builder.append(NLS.bind("getBounds() = {0}", bounds.toString())); //$NON-NLS-1$
+			builder.append("\n"); //$NON-NLS-1$
+
+			Point adjustment = computeHintAdjustment(child);
+
+			int widthHint = Math.max(0, bounds.width - adjustment.x);
+			int heightHint = Math.max(0, bounds.height - adjustment.y);
+
+			builder.append(NLS.bind("widthAdjustment = {0}, heightAdjustment = {1}", //$NON-NLS-1$
+					new Object[] { adjustment.x, adjustment.y }));
+			builder.append("\n\n"); //$NON-NLS-1$
+
+			// Print the default size
+			Point defaultSize = child.computeSize(SWT.DEFAULT, SWT.DEFAULT, false);
+			builder.append(NLS.bind("computeSize(SWT.DEFAULT, SWT.DEFAULT, false) = {0}", printPoint(defaultSize))); //$NON-NLS-1$
+			builder.append("\n"); //$NON-NLS-1$
+
+			// Print the preferred horizontally-wrapped size:
+			Point hWrappedSize = child.computeSize(widthHint, SWT.DEFAULT, false);
+			builder.append(NLS.bind("computeSize({0} - widthAdjustment, SWT.DEFAULT, false) = {1}", //$NON-NLS-1$
+					new Object[] { widthHint, printPoint(hWrappedSize) }));
+			builder.append("\n"); //$NON-NLS-1$
+
+			// Print the preferred vertically-wrapped size:
+			Point vWrappedSize = child.computeSize(SWT.DEFAULT, heightHint, false);
+			builder.append(NLS.bind("computeSize(SWT.DEFAULT, {0} - heightAdjustment, false) = {1}", //$NON-NLS-1$
+					new Object[] { heightHint, printPoint(vWrappedSize) }));
+			builder.append("\n"); //$NON-NLS-1$
+
+			// Check for warnings
+			Point noOpSize = child.computeSize(widthHint, heightHint, false);
+			if (noOpSize.x != bounds.width || noOpSize.y != bounds.height) {
+				builder.append(getWarningMessage(NLS.bind(Messages.LayoutSpyDialog_warning_unexpected_compute_size,
+						new Object[] { printHint(widthHint), printHint(heightHint), printPoint(noOpSize) })));
+			}
+
+			if (bounds.height < hWrappedSize.y) {
+				builder.append(
+						getWarningMessage(Messages.LayoutSpyDialog_warning_shorter_than_preferred_size));
+			}
+
+			printReasonControlIsInvisible(builder, child);
+		}
+		return builder.toString();
+	}
+
+	/**
+	 * If the control cannot be seen by the user, this method adds a warning
+	 * message to the given builder explaining the reason why the control cannot
+	 * be seen.
+	 */
+	private void printReasonControlIsInvisible(StringBuilder builder, Control control) {
+		if (!control.isVisible()) {
+			builder.append(getWarningMessage("isVisible() == false")); //$NON-NLS-1$
+			return;
+		}
+
+		Rectangle bounds = control.getBounds();
+		if (bounds.isEmpty()) {
+			builder.append(getWarningMessage(Messages.LayoutSpyDialog_warning_zero_size));
+			return;
+		}
+
+		Rectangle displayBounds = GeometryUtil.getDisplayBounds(control);
+
+		Composite parent = control.getParent();
+		if (parent != null) {
+			Rectangle parentDisplayBounds = GeometryUtil.getDisplayBounds(parent);
+
+			Rectangle intersection = displayBounds.intersection(parentDisplayBounds);
+			if (intersection.isEmpty()) {
+				builder.append(getWarningMessage(Messages.LayoutSpyDialog_warning_bounds_outside_parent));
+				return;
+			}
+
+			if (intersection.width < bounds.width || intersection.height < bounds.height) {
+				builder.append(getWarningMessage(Messages.LayoutSpyDialog_warning_control_partially_clipped));
+				return;
+			}
+
+			if (overlapsSibling(control)) {
+				builder.append(getWarningMessage(Messages.LayoutSpyDialog_warning_control_overlaps_siblings));
+				return;
+			}
+		}
+	}
+
+	/**
+	 * Computes the string that will be shown in the text box which displays
+	 * information about the selected layout. This is a tracked getter: if it
+	 * reads from an observable, the text box will update automatically when the
+	 * observable changes.
+	 * 
+	 * @TrackedGetter
+	 */
+	private String computeParentInfo() {
+		StringBuilder builder = new StringBuilder();
+		@Nullable
+		Composite parent = parentControl.getValue();
+
+		if (parent != null) {
+			builder.append(parent.getClass().getName());
+			builder.append("\n\n"); //$NON-NLS-1$
+
+			Rectangle parentBounds = GeometryUtil.getDisplayBounds(parent);
+			Layout layout = parent.getLayout();
+
+			if (layout != null) {
+				if (layout instanceof GridLayout) {
+					GridLayout grid = (GridLayout) layout;
+					builder.append(GridLayoutFactory.createFrom(grid));
+
+					boolean hasVerticallyTruncadeControls = false;
+					boolean hasHorizontallyTruncadeControls = false;
+
+					boolean hasHorizontalGrab = false;
+					boolean hasVerticalGrab = false;
+					for (Control next : parent.getChildren()) {
+						@Nullable
+						GridData data = (GridData) next.getLayoutData();
+						if (data == null) {
+							continue;
+						}
+
+						Rectangle childBounds = GeometryUtil.getDisplayBounds(parent);
+						Rectangle intersection = childBounds.intersection(parentBounds);
+
+						if (intersection.width < childBounds.width) {
+							hasHorizontallyTruncadeControls = true;
+						}
+
+						if (intersection.height < childBounds.height) {
+							hasVerticallyTruncadeControls = true;
+						}
+
+						hasHorizontalGrab = hasHorizontalGrab || data.grabExcessHorizontalSpace;
+						hasVerticalGrab = hasVerticalGrab || data.grabExcessVerticalSpace;
+					}
+
+					if (hasHorizontallyTruncadeControls && !hasHorizontalGrab) {
+						builder.append(getWarningMessage(
+								Messages.LayoutSpyDialog_warning_not_grabbing_horizontally));
+					}
+
+					if (hasVerticallyTruncadeControls && !hasVerticalGrab) {
+						builder.append(getWarningMessage(
+								Messages.LayoutSpyDialog_warning_not_grabbing_vertically));
+					}
+				} else {
+					describeObject(builder, "layout", layout); //$NON-NLS-1$
+				}
+			}
+		} else {
+			builder.append(Messages.LayoutSpyDialog_label_no_parent_control_selected);
+		}
+
+		return builder.toString();
+	}
+
+	/**
+	 * Uses reflection to print the values of the given object's public fields.
+	 */
+	void describeObject(StringBuilder result, String variableName, Object toDescribe) {
+		@SuppressWarnings("rawtypes")
+		Class clazz = toDescribe.getClass();
+		result.append(clazz.getName());
+		result.append(" "); //$NON-NLS-1$
+		result.append(variableName);
+		result.append(";\n"); //$NON-NLS-1$
+		Field[] fields = clazz.getFields();
+
+		for (Field nextField : fields) {
+			int modifiers = nextField.getModifiers();
+			if (!Modifier.isPublic(modifiers)) {
+				continue;
+			}
+			try {
+				String next = variableName + "." + nextField.getName() + " = " + nextField.get(toDescribe) + ";"; //$NON-NLS-1$//$NON-NLS-2$ //$NON-NLS-3$
+				result.append(next);
+				result.append("\n"); //$NON-NLS-1$
+			} catch (IllegalArgumentException | IllegalAccessException e) {
+				// Don't care
+			}
+		}
+	}
+}
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/Messages.java b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/Messages.java
new file mode 100644
index 0000000..145b1e2
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/Messages.java
@@ -0,0 +1,45 @@
+/*******************************************************************************
+ * Copyright (c) 2016 Google Inc and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     Stefan Xenos (Google) - initial API and implementation
+ *
+ *******************************************************************************/
+package org.eclipse.pde.internal.runtime.spy.dialogs;
+
+import org.eclipse.osgi.util.NLS;
+
+public class Messages extends NLS {
+	private static final String BUNDLE_NAME = "org.eclipse.pde.internal.runtime.spy.dialogs.messages"; //$NON-NLS-1$
+	public static String LayoutSpyDialog_button_open_child;
+	public static String LayoutSpyDialog_button_open_parent;
+	public static String LayoutSpyDialog_button_select_control;
+	public static String LayoutSpyDialog_button_show_overlay;
+	public static String LayoutSpyDialog_label_children;
+	public static String LayoutSpyDialog_label_layout;
+	public static String LayoutSpyDialog_label_no_parent_control_selected;
+	public static String LayoutSpyDialog_warning_bounds_outside_parent;
+	public static String LayoutSpyDialog_warning_control_overlaps_siblings;
+	public static String LayoutSpyDialog_warning_control_partially_clipped;
+	public static String LayoutSpyDialog_warning_grab_horizontally_scrolling;
+	public static String LayoutSpyDialog_warning_grab_vertical_scrolling;
+	public static String LayoutSpyDialog_warning_hint_for_horizontally_scrollable;
+	public static String LayoutSpyDialog_warning_hint_for_vertically_scrollable;
+	public static String LayoutSpyDialog_warning_not_grabbing_horizontally;
+	public static String LayoutSpyDialog_warning_not_grabbing_vertically;
+	public static String LayoutSpyDialog_warning_prefix;
+	public static String LayoutSpyDialog_warning_shorter_than_preferred_size;
+	public static String LayoutSpyDialog_warning_unexpected_compute_size;
+	public static String LayoutSpyDialog_warning_zero_size;
+	static {
+		// initialize resource bundle
+		NLS.initializeMessages(BUNDLE_NAME, Messages.class);
+	}
+
+	private Messages() {
+	}
+}
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/messages.properties b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/messages.properties
new file mode 100644
index 0000000..55cbd25
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/dialogs/messages.properties
@@ -0,0 +1,20 @@
+LayoutSpyDialog_button_open_child=Open &Child
+LayoutSpyDialog_button_open_parent=Open &Parent
+LayoutSpyDialog_button_select_control=Select &Control
+LayoutSpyDialog_button_show_overlay=Show &overlay
+LayoutSpyDialog_label_children=Children:
+LayoutSpyDialog_label_layout=Layout:
+LayoutSpyDialog_label_no_parent_control_selected=No parent control selected
+LayoutSpyDialog_warning_bounds_outside_parent=Control is located outside the bounds of its parent
+LayoutSpyDialog_warning_control_overlaps_siblings=Control overlaps one of its visible siblings
+LayoutSpyDialog_warning_control_partially_clipped=Control is being partially clipped by its parent
+LayoutSpyDialog_warning_grab_horizontally_scrolling=The grab horizontal flag is recommended for horizontally resizable controls
+LayoutSpyDialog_warning_grab_vertical_scrolling=The grab vertical flag is recommended for vertically resizable controls
+LayoutSpyDialog_warning_hint_for_horizontally_scrollable=Horizontally scrollable controls should have a width hint
+LayoutSpyDialog_warning_hint_for_vertically_scrollable=Vertically scrollable controls should have a height hint
+LayoutSpyDialog_warning_not_grabbing_horizontally=None of the controls use the horizontal grab flag, which may be the reason why some of the controls in the layout are truncated horizontally.
+LayoutSpyDialog_warning_not_grabbing_vertically=None of the controls use the vertical grab flag, which may be the reason why some of the controls in the layout are truncated vertically.
+LayoutSpyDialog_warning_prefix=WARNING: {0}\n
+LayoutSpyDialog_warning_shorter_than_preferred_size=The vertical size of this widget is shorter than its preferred size.
+LayoutSpyDialog_warning_unexpected_compute_size=computeSize({0}, {1}) returned unexpected size of {2}
+LayoutSpyDialog_warning_zero_size=Control has 0 size
diff --git a/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/handlers/LayoutSpyHandler.java b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/handlers/LayoutSpyHandler.java
new file mode 100644
index 0000000..556fa48
--- /dev/null
+++ b/ui/org.eclipse.pde.runtime/src/org/eclipse/pde/internal/runtime/spy/handlers/LayoutSpyHandler.java
@@ -0,0 +1,36 @@
+/*******************************************************************************
+ * Copyright (c) 2016 Google Inc and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ *     Stefan Xenos (Google) - initial API and implementation
+ *
+ *******************************************************************************/
+package org.eclipse.pde.internal.runtime.spy.handlers;
+
+import org.eclipse.core.commands.*;
+import org.eclipse.pde.internal.runtime.spy.dialogs.LayoutSpyDialog;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.ui.handlers.HandlerUtil;
+
+public class LayoutSpyHandler extends AbstractHandler {
+	private LayoutSpyDialog popupDialog;
+
+	@Override
+	public Object execute(ExecutionEvent event) throws ExecutionException {
+		if (popupDialog != null) {
+			popupDialog.close();
+		}
+
+		Shell shell = HandlerUtil.getActiveShell(event);
+		if (shell != null) {
+			popupDialog = new LayoutSpyDialog(shell);
+			popupDialog.open();
+		}
+		return null;
+	}
+
+}