[go: nahoru, domu]

Added an Evaluate Contrast button.

Evaluate Contrast tests the contrast between text color and background for
Views that may contain text. Sufficient contrast is defined as having a
contrast ratio of 4.5:1 in general, except for: Large text, which can have a
contrast ratio of only 3:1, Inactive components or pure decorations, and
text that is part of a logo or brand name.

Change-Id: Ib8bdb545e8407ff27531b743b2424784d5db52e5
diff --git a/hierarchyviewer2/app/src/main/java/com/android/hierarchyviewer/HierarchyViewerApplication.java b/hierarchyviewer2/app/src/main/java/com/android/hierarchyviewer/HierarchyViewerApplication.java
index 8983f67..ed542ed 100644
--- a/hierarchyviewer2/app/src/main/java/com/android/hierarchyviewer/HierarchyViewerApplication.java
+++ b/hierarchyviewer2/app/src/main/java/com/android/hierarchyviewer/HierarchyViewerApplication.java
@@ -27,6 +27,7 @@
 import com.android.hierarchyviewerlib.actions.CapturePSDAction;
 import com.android.hierarchyviewerlib.actions.DisplayViewAction;
 import com.android.hierarchyviewerlib.actions.DumpDisplayListAction;
+import com.android.hierarchyviewerlib.actions.EvaluateContrastAction;
 import com.android.hierarchyviewerlib.actions.InspectScreenshotAction;
 import com.android.hierarchyviewerlib.actions.InvalidateAction;
 import com.android.hierarchyviewerlib.actions.LoadOverlayAction;
@@ -375,7 +376,7 @@
 
         Composite innerButtonPanel = new Composite(buttonPanel, SWT.NONE);
         innerButtonPanel.setLayoutData(new GridData(GridData.FILL_VERTICAL));
-        GridLayout innerButtonPanelLayout = new GridLayout(8, true);
+        GridLayout innerButtonPanelLayout = new GridLayout(9, true);
         innerButtonPanelLayout.marginWidth = innerButtonPanelLayout.marginHeight = 2;
         innerButtonPanelLayout.horizontalSpacing = innerButtonPanelLayout.verticalSpacing = 2;
         innerButtonPanel.setLayout(innerButtonPanelLayout);
@@ -392,6 +393,10 @@
                 new ActionButton(innerButtonPanel, RefreshViewAction.getAction());
         refreshViewAction.setLayoutData(new GridData(GridData.FILL_BOTH));
 
+        ActionButton evaluateContrast =
+                new ActionButton(innerButtonPanel, EvaluateContrastAction.getAction(getShell()));
+        evaluateContrast.setLayoutData(new GridData(GridData.FILL_BOTH));
+
         ActionButton displayView =
                 new ActionButton(innerButtonPanel, DisplayViewAction.getAction(getShell()));
         displayView.setLayoutData(new GridData(GridData.FILL_BOTH));
diff --git a/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/HierarchyViewerDirector.java b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/HierarchyViewerDirector.java
index 7c0adce..e746634 100644
--- a/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/HierarchyViewerDirector.java
+++ b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/HierarchyViewerDirector.java
@@ -31,6 +31,7 @@
 import com.android.hierarchyviewerlib.models.ViewNode;
 import com.android.hierarchyviewerlib.models.Window;
 import com.android.hierarchyviewerlib.ui.CaptureDisplay;
+import com.android.hierarchyviewerlib.ui.EvaluateContrastDisplay;
 import com.android.hierarchyviewerlib.ui.TreeView;
 import com.android.hierarchyviewerlib.ui.util.DrawableViewNode;
 import com.android.hierarchyviewerlib.ui.util.PsdFile;
@@ -368,6 +369,33 @@
         });
     }
 
+    public void showEvaluateContrast(final Shell shell) {
+        executeInBackground("Capturing node and evaluating contrast", new Runnable() {
+            @Override
+            public void run() {
+                mFilterText = ""; //$NON-NLS-1$
+                Window window = TreeViewModel.getModel().getWindow();
+                IHvDevice hvDevice = window.getHvDevice();
+                final ViewNode viewNode = hvDevice.loadWindowData(window);
+                if (viewNode != null) {
+                    viewNode.setViewCount();
+                    TreeViewModel.getModel().setData(window, viewNode);
+                }
+
+                final Image image = loadCapture(viewNode);
+                if (image != null && viewNode != null) {
+
+                    Display.getDefault().syncExec(new Runnable() {
+                        @Override
+                        public void run() {
+                            EvaluateContrastDisplay.show(shell, viewNode, image);
+                        }
+                    });
+                }
+            }
+        });
+    }
+
     public Image loadCapture(ViewNode viewNode) {
         IHvDevice hvDevice = viewNode.window.getHvDevice();
         final Image image = hvDevice.loadCapture(viewNode.window, viewNode);
diff --git a/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/actions/EvaluateContrastAction.java b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/actions/EvaluateContrastAction.java
new file mode 100644
index 0000000..54c2b58
--- /dev/null
+++ b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/actions/EvaluateContrastAction.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.hierarchyviewerlib.actions;
+
+import com.android.ddmuilib.ImageLoader;
+import com.android.hierarchyviewerlib.HierarchyViewerDirector;
+
+import org.eclipse.jface.action.Action;
+import org.eclipse.jface.resource.ImageDescriptor;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+
+public class EvaluateContrastAction extends Action implements ImageAction {
+
+    private static EvaluateContrastAction sAction;
+
+    private Image mImage;
+
+    private Shell mShell;
+
+    private EvaluateContrastAction(Shell shell) {
+        super("&Evaluate Contrast");
+        mShell = shell;
+        setAccelerator(SWT.MOD1 + 'D');
+        setToolTipText("Evaluate the contrast ratio of this view.");
+        // TODO: Get icon for Button
+    }
+
+    public static EvaluateContrastAction getAction(Shell shell) {
+        if (sAction == null) {
+            sAction = new EvaluateContrastAction(shell);
+        }
+        return sAction;
+    }
+
+    @Override
+    public void run() {
+        HierarchyViewerDirector.getDirector().showEvaluateContrast(mShell);
+    }
+
+    @Override
+    public Image getImage() {
+        return mImage;
+    }
+}
diff --git a/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/models/EvaluateContrastModel.java b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/models/EvaluateContrastModel.java
new file mode 100644
index 0000000..4af8b85
--- /dev/null
+++ b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/models/EvaluateContrastModel.java
@@ -0,0 +1,389 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.hierarchyviewerlib.models;
+
+import com.android.annotations.Nullable;
+import com.google.common.collect.Lists;
+
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+
+import java.awt.Color;
+import java.awt.Rectangle;
+import java.lang.Math;
+import java.lang.Integer;
+import java.lang.String;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map.Entry;
+
+/**
+ * <p>
+ * This class uses the Web Content Accessibility Guidelines (WCAG) 2.0 (http://www.w3.org/TR/WCAG20)
+ * to evaluate the contrast ratio between the text and background colors of a view.
+ * </p>
+ * <p>
+ * Given an image of a view and the bounds of where the view is within the image (x, y, width,
+ * height), this class will extract the luminance values (measure of brightness) of the entire view
+ * to try and determine the text and background color. If known, the constructor accepts text size
+ * and text color to provide a more accurate result.
+ * </p>
+ * <p>
+ * The {@link #calculateLuminance(int)} method calculates the luminance value of an {@code int}
+ * representation of a {@link Color}. We use two of these values, the text and background
+ * luminances, to determine the contrast ratio.</p>
+ * </p>
+ * <p>
+ * "Sufficient contrast" is defined as having a contrast ratio of 4.5:1 in general, except for:
+ * <ul>
+ * <li>Large text (>= 18 points, or >= 14 points and bold),
+ * which can have a contrast ratio of only 3:1.</li>
+ * <li>Inactive components or pure decorations.</li>
+ * <li>Text that is part of a logo or brand name.</li>
+ * </ul>
+ */
+public class EvaluateContrastModel {
+
+    public enum ContrastResult {
+        PASS,
+        FAIL,
+        INDETERMINATE
+    }
+
+    public static final String CONTRAST_RATIO_FORMAT = "%.2f:1";
+    public static final double CONTRAST_RATIO_NORMAL_TEXT = 4.5;
+    public static final double CONTRAST_RATIO_LARGE_TEXT = 3.0;
+    public static final int NORMAL_TEXT_SZ_PTS = 18;
+    public static final int NORMAL_TEXT_BOLD_SZ_PTS = 14;
+
+    public static final String NOT_APPLICABLE = "N/A";
+
+    private static final double MAX_RGB_VALUE = 255.0;
+
+    private ImageData mImageData;
+    /** The bounds of the view within the image */
+    private Rectangle mViewBounds;
+
+    /** Maps an int representation of a {@link Color} to its luminance value. */
+    private HashMap<Integer, Double> mLuminanceMap;
+    /** Keeps track of how many times a luminance value occurs in this view. */
+    private HashMap<Double, Integer> mLuminanceHistogram;
+    private final List<Integer> mBackgroundColors;
+    private final List<Integer> mForegroundColors;
+    private double mBackgroundLuminance;
+    private double mForegroundLuminance;
+
+    private double mContrastRatio;
+    private Integer mTextColor;
+    private Double mTextSize;
+    private boolean mIsBold;
+
+    /**
+     * <p>
+     * Constructs an EvaluateContrastModel to extract and process properties from the image related
+     * to contrast and luminance.
+     * </p>
+     * <p>
+     * NOTE: Invoking this constructor performs image processing tasks, which are relatively
+     * heavywheight.
+     * </p>
+     *
+     * @param image Screenshot of the view.
+     * @param textColor Color of the text. If null, we will try and compute the color of the text.
+     * @param textSize Size of the text. If null, we may have an indeterminate result where it
+     *                 passes only one of the tests.
+     * @param x Starting x-coordinate of the view in the image.
+     * @param y Starting y-coordinate of the view in the image.
+     * @param width The width of the view in the image.
+     * @param height The height of the view in the image.
+     * @param isBold True if we know the text is bold, false otherwise.
+     */
+    public EvaluateContrastModel(Image image, @Nullable Integer textColor,
+            @Nullable Double textSize, int x, int y, int width, int height, boolean isBold) {
+        mImageData = image.getImageData();
+        mTextColor = textColor;
+        mTextSize = textSize;
+        mViewBounds = new Rectangle(x, y, width, height);
+        mIsBold = isBold;
+
+        mBackgroundColors = new LinkedList<Integer>();
+        mForegroundColors = new LinkedList<Integer>();
+        mLuminanceMap = new HashMap<Integer, Double>();
+        mLuminanceHistogram = new HashMap<Double, Integer>();
+
+        processSwatch();
+    }
+
+    /**
+     * Formula derived from http://gmazzocato.altervista.org/colorwheel/algo.php.
+     * More information can be found at http://www.w3.org/TR/WCAG20/relative-luminance.xml.
+     */
+    public static double calculateLuminance(int color) {
+        Color colorObj = new Color(color);
+        float[] sRGB = new float[4];
+        colorObj.getRGBComponents(sRGB);
+
+        final double[] lumRGB = new double[4];
+        for (int i = 0; i < sRGB.length; ++i) {
+            lumRGB[i] = (sRGB[i] <= 0.03928d) ? sRGB[i] / 12.92d
+                    : Math.pow(((sRGB[i] + 0.055d) / 1.055d), 2.4d);
+        }
+
+        return 0.2126d * lumRGB[0] + 0.7152d * lumRGB[1] + 0.0722d * lumRGB[2];
+    }
+
+    public static double calculateContrastRatio(double lum1, double lum2) {
+        if ((lum1 < 0.0d) || (lum2 < 0.0d)) {
+            throw new IllegalArgumentException("Luminance values may not be negative.");
+        }
+
+        return (Math.max(lum1, lum2) + 0.05d) / (Math.min(lum1, lum2) + 0.05d);
+    }
+
+    public static String intToHexString(int color) {
+        return String.format("#%06X", (0xFFFFFF & color));
+    }
+
+    private void processSwatch() {
+        processLuminanceData();
+        extractFgBgData();
+
+        double textLuminance = mTextColor == null ? mForegroundLuminance :
+                calculateLuminance(calculateTextColor(mTextColor, mBackgroundColors.get(0)));
+        // Two-decimal digits of precision for the contrast ratio
+        mContrastRatio = Math.round(calculateContrastRatio(
+                textLuminance, mBackgroundLuminance) * 100.0d) / 100.0d;
+    }
+
+    private void processLuminanceData() {
+        for (int x = mViewBounds.x; x < mViewBounds.width; ++x) {
+            for (int y = mViewBounds.y; y < mViewBounds.height; ++y) {
+                final int color = mImageData.getPixel(x, y);
+                final double luminance = calculateLuminance(color);
+                if (!mLuminanceMap.containsKey(color)) {
+                    mLuminanceMap.put(color, luminance);
+                }
+
+                if (!mLuminanceHistogram.containsKey(luminance)) {
+                    mLuminanceHistogram.put(luminance, 0);
+                }
+
+                mLuminanceHistogram.put(luminance, mLuminanceHistogram.get(luminance) + 1);
+            }
+        }
+    }
+
+    private void extractFgBgData() {
+        if (mLuminanceMap.isEmpty()) {
+            // An empty luminance map indicates we've encountered a 0px area
+            // image. It has no luminance.
+            mBackgroundLuminance = mForegroundLuminance = 0;
+            mBackgroundColors.add(0);
+            mForegroundColors.add(0);
+        } else if (mLuminanceMap.size() == 1) {
+            // Deal with views that only contain a single color
+            mBackgroundLuminance = mForegroundLuminance = mLuminanceHistogram.keySet().iterator()
+                    .next();
+            final int singleColor = mLuminanceMap.keySet().iterator().next();
+            mForegroundColors.add(singleColor);
+            mBackgroundColors.add(singleColor);
+        } else {
+            // Sort all luminance values seen from low to high
+            final ArrayList<Entry<Integer, Double>> colorsByLuminance =
+                    Lists.newArrayList(mLuminanceMap.entrySet());
+            Collections.sort(colorsByLuminance, new Comparator<Entry<Integer, Double>>() {
+                @Override
+                public int compare(Entry<Integer, Double> lhs, Entry<Integer, Double> rhs) {
+                    return Double.compare(lhs.getValue(), rhs.getValue());
+                }
+            });
+
+            // Sort luminance values seen by frequency in the image
+            final ArrayList<Entry<Double, Integer>> luminanceByFrequency =
+                    Lists.newArrayList(mLuminanceHistogram.entrySet());
+            Collections.sort(luminanceByFrequency, new Comparator<Entry<Double, Integer>>() {
+                @Override
+                public int compare(Entry<Double, Integer> lhs, Entry<Double, Integer> rhs) {
+                    return lhs.getValue() - rhs.getValue();
+                }
+            });
+
+            // Find the average luminance value within the set of luminances for
+            // purposes of splitting luminance values into high-luminance and
+            // low-luminance buckets. This is explicitly not a weighted average.
+            double luminanceSum = 0;
+            for (Entry<Double, Integer> luminanceCount : luminanceByFrequency) {
+                luminanceSum += luminanceCount.getKey();
+            }
+
+            final double averageLuminance = luminanceSum / luminanceByFrequency.size();
+
+            // Select the highest and lowest luminance values that contribute to
+            // most number of pixels in the image -- our background and
+            // foreground colors.
+            double lowLuminanceContributor = 0.0d;
+            for (int i = luminanceByFrequency.size() - 1; i >= 0; --i) {
+                final double luminanceValue = luminanceByFrequency.get(i).getKey();
+                if (luminanceValue < averageLuminance) {
+                    lowLuminanceContributor = luminanceValue;
+                    break;
+                }
+            }
+
+            double highLuminanceContributor = 1.0d;
+            for (int i = luminanceByFrequency.size() - 1; i >= 0; --i) {
+                final double luminanceValue = luminanceByFrequency.get(i).getKey();
+                if (luminanceValue >= averageLuminance) {
+                    highLuminanceContributor = luminanceValue;
+                    break;
+                }
+            }
+
+            // Background luminance is that which occurs more frequently
+            if (mLuminanceHistogram.get(highLuminanceContributor)
+                    > mLuminanceHistogram.get(lowLuminanceContributor)) {
+                mBackgroundLuminance = highLuminanceContributor;
+                mForegroundLuminance = lowLuminanceContributor;
+            } else {
+                mBackgroundLuminance = lowLuminanceContributor;
+                mForegroundLuminance = highLuminanceContributor;
+            }
+
+            // Determine the contributing colors for those luminance values
+            // TODO: Optimize (find an alternative to reiterating through whole image)
+            for (Entry<Integer, Double> colorLuminance : mLuminanceMap.entrySet()) {
+                if (colorLuminance.getValue() == mBackgroundLuminance) {
+                    mBackgroundColors.add(colorLuminance.getKey());
+                }
+
+                if (colorLuminance.getValue() == mForegroundLuminance) {
+                    mForegroundColors.add(colorLuminance.getKey());
+                }
+            }
+        }
+    }
+
+    /**
+     * Calculates a more accurate text color for how the text in the view appears by using its alpha
+     * value to determine how much it needs to be blended into its background color.
+     *
+     * @param textColor Text color.
+     * @param backgroundColor Background color.
+     * @return Calculated text color.
+     */
+    private int calculateTextColor(int textColor, int backgroundColor) {
+        Color text = new Color(textColor, true);
+        Color background = new Color(backgroundColor, true);
+
+        int alpha = text.getAlpha();
+        double alphaPercentage = alpha / MAX_RGB_VALUE;
+        double alphaCompliment = 1 - alphaPercentage;
+
+        int red = (int) (alphaPercentage * text.getRed() + alphaCompliment * background.getRed());
+        int green = (int) (alphaPercentage * text.getGreen()
+                + alphaCompliment * background.getGreen());
+        int blue = (int) (alphaPercentage * text.getBlue()
+                + alphaCompliment * background.getBlue());
+
+        Color rgb = new Color(red, green, blue, (int) MAX_RGB_VALUE);
+        mTextColor = rgb.getRGB();
+
+        return mTextColor;
+    }
+
+    public ContrastResult getContrastResult() {
+        ContrastResult normalTest = getContrastResultForNormalText();
+        ContrastResult largeTest = getContrastResultForLargeText();
+
+        if (normalTest == largeTest) {
+            return normalTest;
+        } else if (mTextSize == null) {
+            return ContrastResult.INDETERMINATE;
+        } else if (mTextSize <= NORMAL_TEXT_SZ_PTS) {
+            return normalTest;
+        } else {
+            return largeTest;
+        }
+    }
+
+    public ContrastResult getContrastResultForLargeText() {
+        return mContrastRatio >= CONTRAST_RATIO_LARGE_TEXT ?
+                ContrastResult.PASS : ContrastResult.FAIL;
+    }
+
+    public ContrastResult getContrastResultForNormalText() {
+        if (mIsBold && mTextSize >= NORMAL_TEXT_BOLD_SZ_PTS) {
+            return getContrastResultForLargeText();
+        }
+        return mContrastRatio >= CONTRAST_RATIO_NORMAL_TEXT ?
+                ContrastResult.PASS : ContrastResult.FAIL;
+    }
+
+    public double getContrastRatio() {
+        return mContrastRatio;
+    }
+
+    public double getBackgroundLuminance() {
+        return mBackgroundLuminance;
+    }
+
+    public String getTextSize() {
+        if (mTextSize == null ){
+            return NOT_APPLICABLE;
+        }
+        return Double.toString(mTextSize);
+    }
+
+    public int getTextColor() {
+        Integer textColor;
+
+        if (mTextColor != null) {
+            textColor = mTextColor;
+        } else {
+            // assumes that the foreground color is the luminance value that occurs the least
+            // frequently; which is also the best estimate we have for text color.
+            textColor = mForegroundColors.get(0);
+        }
+
+        return textColor.intValue();
+    }
+
+    public String getTextColorHex() {
+        return intToHexString(getTextColor());
+    }
+
+    public int getBackgroundColor() {
+        return mBackgroundColors.get(0);
+    }
+
+    public String getBackgroundColorHex() {
+        return intToHexString(mBackgroundColors.get(0));
+    }
+
+    public boolean isIndeterminate() {
+        return mTextSize == null && getContrastResult() == ContrastResult.INDETERMINATE;
+    }
+
+    public boolean isBold() {
+        return mIsBold;
+    }
+
+}
diff --git a/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/ui/EvaluateContrastDisplay.java b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/ui/EvaluateContrastDisplay.java
new file mode 100644
index 0000000..521b551
--- /dev/null
+++ b/hierarchyviewer2/hierarchyviewer2lib/src/main/java/com/android/hierarchyviewerlib/ui/EvaluateContrastDisplay.java
@@ -0,0 +1,540 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.hierarchyviewerlib.ui;
+
+import com.android.ddmuilib.ImageLoader;
+import com.android.hierarchyviewerlib.HierarchyViewerDirector;
+import com.android.hierarchyviewerlib.models.EvaluateContrastModel;
+import com.android.hierarchyviewerlib.models.ViewNode;
+import com.android.hierarchyviewerlib.models.EvaluateContrastModel.ContrastResult;
+import com.android.hierarchyviewerlib.models.ViewNode.Property;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.events.PaintEvent;
+import org.eclipse.swt.events.PaintListener;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.events.SelectionListener;
+import org.eclipse.swt.events.ShellAdapter;
+import org.eclipse.swt.events.ShellEvent;
+import org.eclipse.swt.graphics.GC;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.graphics.RGB;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.layout.RowLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Listener;
+import org.eclipse.swt.widgets.ScrollBar;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
+
+import java.lang.Math;
+import java.lang.Override;
+import java.lang.StringBuilder;
+import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+
+public class EvaluateContrastDisplay {
+    private static final int DEFAULT_HEIGHT = 600; // px
+    private static final int MARGIN = 30; // px
+    private static final int PALLETE_IMAGE_SIZE = 16; // px
+    private static final int IMAGE_WIDTH = 800; // px
+    private static final int RESULTS_PANEL_WIDTH = 300; // px
+    private static final int MAX_NUM_CHARACTERS = 35;
+
+    private static final String ABBREVIATE_SUFFIX = "...\"";
+
+    private static Shell sShell;
+    private static Canvas sCanvas;
+    private static Composite sResultsPanel;
+    private static Tree sResultsTree;
+
+    private static Image sImage;
+    private static Point sImageOffset;
+    private static ScrollBar sImageScrollBar;
+    private static int sImageWidth;
+    private static int sImageHeight;
+
+    private static Image sYellowImage;
+    private static Image sRedImage;
+    private static Image sGreenImage;
+
+    private static ViewNode sSelectedNode;
+
+    private static org.eclipse.swt.graphics.Color sBorderColorPass;
+    private static org.eclipse.swt.graphics.Color sBorderColorFail;
+    private static org.eclipse.swt.graphics.Color sBorderColorIndeterminate;
+    private static org.eclipse.swt.graphics.Color sBorderColorCurrentlySelected;
+
+    private static HashMap<ViewNode, Rectangle> sRectangleForViewNode;
+    private static HashMap<ViewNode, org.eclipse.swt.graphics.Color> sBorderColorForViewNode;
+    private static HashMap<ViewNode, EvaluateContrastModel> sViewNodeForModel;
+    private static HashMap<Integer, Image> sImageForColor;
+    private static HashMap<TreeItem, ViewNode> sViewNodeForTreeItem;
+
+    private static double sScaleFactor;
+
+    static {
+        sImageForColor = new HashMap<Integer, Image>();
+        sViewNodeForTreeItem = new HashMap<TreeItem, ViewNode>();
+
+        ImageLoader loader = ImageLoader.getLoader(EvaluateContrastDisplay.class);
+        sYellowImage = loader.loadImage("yellow.png", Display.getDefault());
+        sRedImage = loader.loadImage("red.png", Display.getDefault());
+        sGreenImage = loader.loadImage("green.png", Display.getDefault());
+
+        sRectangleForViewNode = new HashMap<ViewNode, Rectangle>();
+        sBorderColorForViewNode = new HashMap<ViewNode, org.eclipse.swt.graphics.Color>();
+        sViewNodeForModel = new HashMap<ViewNode, EvaluateContrastModel>();
+    }
+
+    private static org.eclipse.swt.graphics.Color getBorderColorPass() {
+        if (sBorderColorPass == null) {
+            sBorderColorPass = /** green */
+                    new org.eclipse.swt.graphics.Color(Display.getDefault(), new RGB(0, 255, 0));
+        }
+        return sBorderColorPass;
+    }
+
+    private static org.eclipse.swt.graphics.Color getBorderColorFail() {
+        if (sBorderColorFail == null) {
+            sBorderColorFail = /** red */
+                    new org.eclipse.swt.graphics.Color(Display.getDefault(), new RGB(255, 0, 0));
+        }
+        return sBorderColorFail;
+    }
+
+    private static org.eclipse.swt.graphics.Color getBorderColorIndeterminate() {
+        if (sBorderColorIndeterminate == null) {
+            sBorderColorIndeterminate = /** yellow */
+                    new org.eclipse.swt.graphics.Color(Display.getDefault(), new RGB(255, 255, 0));
+        }
+        return sBorderColorIndeterminate;
+    }
+
+    private static org.eclipse.swt.graphics.Color getBorderColorCurrentlySelected() {
+        if (sBorderColorCurrentlySelected == null) {
+            sBorderColorCurrentlySelected = /** blue */
+                    new org.eclipse.swt.graphics.Color(Display.getDefault(), new RGB(0, 0, 255));
+        }
+        return sBorderColorCurrentlySelected;
+    }
+
+    private static void clear(boolean shellIsNull) {
+        sRectangleForViewNode.clear();
+        sBorderColorForViewNode.clear();
+        sViewNodeForModel.clear();
+
+        if (!shellIsNull) {
+            sImage.dispose();
+            for (Image image : sImageForColor.values()) {
+                image.dispose();
+            }
+
+            sImageForColor.clear();
+            sViewNodeForTreeItem.clear();
+            for (Control item : sShell.getChildren()) {
+                item.dispose();
+            }
+        }
+
+        if (sBorderColorPass != null) {
+            sBorderColorPass.dispose();
+            sBorderColorPass = null;
+        }
+        if (sBorderColorFail != null) {
+            sBorderColorFail.dispose();
+            sBorderColorFail = null;
+        }
+        if (sBorderColorIndeterminate != null) {
+            sBorderColorIndeterminate.dispose();
+            sBorderColorIndeterminate = null;
+        }
+        if (sBorderColorCurrentlySelected != null) {
+            sBorderColorCurrentlySelected.dispose();
+            sBorderColorCurrentlySelected = null;
+        }
+    }
+
+    private static Image scaleImage(Image image, int width, int height) {
+        Image scaled = new Image(Display.getDefault(), width, height);
+        GC gc = new GC(scaled);
+        gc.setInterpolation(SWT.HIGH);
+        gc.setAntialias(SWT.ON);
+        gc.drawImage(image, 0, 0, image.getBounds().width, image.getBounds().height, 0, 0,
+                width, height);
+        image.dispose();
+        gc.dispose();
+        return scaled;
+    }
+
+    public static void show(Shell parentShell, ViewNode rootNode, Image image) {
+        clear(sShell == null);
+
+        sScaleFactor = Math.min(IMAGE_WIDTH / (double) image.getBounds().width, 1.0);
+        sImage = scaleImage(image, IMAGE_WIDTH,
+                (int) Math.round(image.getBounds().height * sScaleFactor));
+        sImageWidth = sImage.getBounds().width;
+        sImageHeight = sImage.getBounds().height;
+
+        if (sShell == null) {
+            sShell = new Shell(Display.getDefault(), SWT.CLOSE | SWT.TITLE);
+            sShell.setText("Evaluate Contrast");
+            sShell.addShellListener(sShellListener);
+            sShell.setLayout(new GridLayout(2, false));
+        }
+        buildContents(sShell);
+        processEvaluatableChildViews(rootNode);
+
+        sShell.setLocation(parentShell.getBounds().x, parentShell.getBounds().y);
+        sShell.setSize(IMAGE_WIDTH + RESULTS_PANEL_WIDTH + MARGIN, DEFAULT_HEIGHT + (MARGIN * 2));
+        sImageScrollBar.setMaximum(sImage.getBounds().height);
+        sImageScrollBar.setThumb(DEFAULT_HEIGHT);
+        sShell.open();
+        sShell.layout();
+    }
+
+    private static void buildContents(Composite shell) {
+        buildResultsPanel();
+        buildImagePanel(shell);
+    }
+
+    private static void buildResultsPanel() {
+        sResultsPanel = new Composite(sShell, SWT.NONE);
+        sResultsPanel.setLayout(new FillLayout());
+        GridData gridData = new GridData(SWT.BEGINNING, SWT.BEGINNING, false, true);
+        sResultsPanel.setLayoutData(gridData);
+
+        ScrolledComposite scrolledComposite = new ScrolledComposite(sResultsPanel, SWT.VERTICAL);
+        sResultsTree = new Tree(scrolledComposite, SWT.NONE);
+        sResultsTree.setLinesVisible(true);
+        scrolledComposite.setContent(sResultsTree);
+        sResultsTree.setSize(RESULTS_PANEL_WIDTH, DEFAULT_HEIGHT);
+
+        sResultsTree.addListener(SWT.PaintItem, new Listener() {
+            public void handleEvent(Event event) {
+                TreeItem item = (TreeItem) event.item;
+                Image image = (Image) item.getData();
+                if (image != null) {
+                    int x = event.x + event.width;
+                    int itemHeight = sResultsTree.getItemHeight();
+                    int imageHeight = image.getBounds().height;
+                    int y = event.y + (itemHeight - imageHeight) / 2;
+                    event.gc.drawImage(image, x, y);
+                }
+            }
+        });
+
+        Listener listener = new Listener() {
+            public void handleEvent(Event e) {
+                TreeItem treeItem = (TreeItem) e.item;
+                if (treeItem.getItemCount() == 0) {
+                    do {
+                        treeItem = treeItem.getParentItem();
+                    } while (treeItem.getParentItem() != null);
+                }
+
+                ViewNode node = sViewNodeForTreeItem.get(treeItem);
+                if (sSelectedNode != node) {
+                    sSelectedNode = sViewNodeForTreeItem.get(treeItem);
+                    sCanvas.redraw();
+                }
+            }
+        };
+        sResultsTree.addListener(SWT.Selection, listener);
+        sResultsTree.addListener(SWT.DefaultSelection, listener);
+    }
+
+    private static void buildImagePanel(Composite parent) {
+        sImageOffset = new Point(0, 0);
+        sCanvas = new Canvas(parent, SWT.V_SCROLL | SWT.NO_BACKGROUND | SWT.NO_REDRAW_RESIZE);
+        sCanvas.addPaintListener(new PaintListener() {
+
+            @Override
+            public void paintControl(PaintEvent e) {
+                GC gc = e.gc;
+                gc.drawImage(sImage, sImageOffset.x, sImageOffset.y);
+
+                for (ViewNode viewNode : sRectangleForViewNode.keySet()) {
+                    Rectangle rectangle = sRectangleForViewNode.get(viewNode);
+                    if (sSelectedNode == viewNode) {
+                        e.gc.setForeground(getBorderColorCurrentlySelected());
+                    } else {
+                        e.gc.setForeground(sBorderColorForViewNode.get(viewNode));
+                    }
+                    e.gc.drawRectangle(Math.max(0, sImageOffset.x + rectangle.x - 1),
+                            sImageOffset.y + rectangle.y - 1,
+                            rectangle.width - 1,
+                            rectangle.height - 1);
+                }
+
+                Rectangle rect = sImage.getBounds();
+                Rectangle client = sCanvas.getClientArea();
+                int marginWidth = client.width - rect.width;
+                if (marginWidth > 0) {
+                    gc.fillRectangle(rect.width, 0, marginWidth, client.height);
+                }
+                int marginHeight = client.height - rect.height;
+                if (marginHeight > 0) {
+                    gc.fillRectangle(0, rect.height, client.width, marginHeight);
+                }
+            }
+        });
+
+        sImageScrollBar = sCanvas.getVerticalBar();
+        sImageScrollBar.addListener(SWT.Selection, new Listener() {
+            @Override
+            public void handleEvent(Event e) {
+                int offset = sImageScrollBar.getSelection();
+                Rectangle imageBounds = sImage.getBounds();
+                sImageOffset.y = -offset;
+
+                int y = -offset - sImageOffset.y;
+                sCanvas.scroll(0, y, 0, 0, imageBounds.width, imageBounds.height, false);
+                sCanvas.redraw();
+            }
+        });
+
+        GridData gridData = new GridData(SWT.BEGINNING, SWT.BEGINNING, true, true);
+        gridData.widthHint = IMAGE_WIDTH + MARGIN;
+        gridData.heightHint = DEFAULT_HEIGHT;
+        sCanvas.setLayoutData(gridData);
+    }
+
+    private static void processEvaluatableChildViews(ViewNode root){
+        List<ViewNode> children = getEvaluatableChildViews(root);
+
+        for (final ViewNode child : children) {
+            calculateRectangleForViewNode(child);
+            EvaluateContrastModel evaluateContrastModel = evaluateContrastForView(child);
+            if (evaluateContrastModel != null) {
+                calculateBorderColorForViewNode(child, evaluateContrastModel.getContrastResult());
+                buildTreeItem(evaluateContrastModel, child);
+                sViewNodeForModel.put(child, evaluateContrastModel);
+            } else {
+                sRectangleForViewNode.remove(child);
+            }
+        }
+    }
+
+    private static void buildTreeItem(EvaluateContrastModel model, final ViewNode child) {
+        int dotIndex = child.name.lastIndexOf('.');
+        String shortName = (dotIndex == -1) ? child.name : child.name.substring(dotIndex + 1);
+        String text = shortName + ": \"" + child.namedProperties.get("text:mText").value + "\"";
+
+        TreeItem item = new TreeItem(sResultsTree, SWT.NONE);
+        item.setText(transformText(text, MAX_NUM_CHARACTERS));
+        item.setImage(getResultImage(model.getContrastResult()));
+        sViewNodeForTreeItem.put(item, child);
+        buildTreeItemsForModel(model, item);
+    }
+
+    private static Image buildImageForColor(int color) {
+        Image image = sImageForColor.get(color);
+
+        if (image == null) {
+            image = new Image(Display.getDefault(), PALLETE_IMAGE_SIZE, PALLETE_IMAGE_SIZE);
+            GC gc = new GC(image);
+
+            org.eclipse.swt.graphics.Color swtColor = awtColortoSwtColor(new java.awt.Color(color));
+            gc.setBackground(swtColor);
+            swtColor.dispose();
+            gc.fillRectangle(0, 0, PALLETE_IMAGE_SIZE, PALLETE_IMAGE_SIZE);
+
+            swtColor = awtColortoSwtColor(java.awt.Color.BLACK);
+            gc.setForeground(swtColor);
+            swtColor.dispose();
+            gc.drawRectangle(0, 0, PALLETE_IMAGE_SIZE - 1, PALLETE_IMAGE_SIZE - 1);
+            gc.dispose();
+
+            sImageForColor.put(color, image);
+        }
+
+        return image;
+    }
+
+    public static org.eclipse.swt.graphics.Color awtColortoSwtColor(java.awt.Color color) {
+        return new org.eclipse.swt.graphics.Color(Display.getDefault(),
+                color.getRed(), color.getGreen(), color.getBlue());
+    }
+
+    private static void buildTreeItemsForModel(EvaluateContrastModel model, TreeItem parent) {
+        TreeItem item = new TreeItem(parent, SWT.NONE);
+        item.setText("Text color: " + model.getTextColorHex());
+        item.setData(buildImageForColor(model.getTextColor()));
+
+        item = new TreeItem(parent, SWT.NONE);
+        item.setText("Background color: " + model.getBackgroundColorHex());
+        item.setData(buildImageForColor(model.getBackgroundColor()));
+
+        new TreeItem(parent, SWT.NONE).setText("Text size: " + model.getTextSize());
+
+        new TreeItem(parent, SWT.NONE).setText("Contrast ratio: " + String.format(
+                EvaluateContrastModel.CONTRAST_RATIO_FORMAT, model.getContrastRatio()));
+
+        if (!model.isIndeterminate()) {
+            new TreeItem(parent, SWT.NONE).setText("Test: " + model.getContrastResult().name());
+        } else {
+            item = new TreeItem(parent, SWT.NONE);
+            item.setText("Normal Text Test: " + model.getContrastResultForNormalText().name());
+            item.setImage(getResultImage(model.getContrastResultForNormalText()));
+            item = new TreeItem(parent, SWT.NONE);
+            item.setText("Large Text Test: " + model.getContrastResultForLargeText().name());
+            item.setImage(getResultImage(model.getContrastResultForLargeText()));
+        }
+    }
+
+    private static List<ViewNode> getEvaluatableChildViews(ViewNode root) {
+        List<ViewNode> children = new ArrayList<ViewNode>();
+
+        children.add(root);
+        for (int i = 0; i < children.size(); ++i) {
+            ViewNode node = children.get(i);
+            List<ViewNode> temp = node.children;
+            for (ViewNode child: temp) {
+                if (!children.contains(child)) {
+                    children.add(child);
+                }
+            }
+        }
+
+        List<ViewNode> evalutableChildren = new ArrayList<ViewNode>();
+        for (final ViewNode child : children) {
+            if (child.namedProperties.get("text:mText") != null) {
+                evalutableChildren.add(child);
+            }
+        }
+
+        return evalutableChildren;
+    }
+
+    private static void calculateBorderColorForViewNode(ViewNode node, ContrastResult result) {
+        org.eclipse.swt.graphics.Color borderColor;
+
+        switch (result) {
+            case PASS:
+                borderColor = getBorderColorPass();
+                break;
+            case FAIL:
+                borderColor = getBorderColorFail();
+                break;
+            case INDETERMINATE:
+            default:
+                borderColor = getBorderColorIndeterminate();
+        }
+
+        sBorderColorForViewNode.put(node, borderColor);
+    }
+
+    private static Image getResultImage(ContrastResult result) {
+        switch (result) {
+            case PASS:
+                return sGreenImage;
+            case FAIL:
+                return sRedImage;
+            default:
+                return sYellowImage;
+        }
+    }
+
+    private static String transformText(String text, int maxNumCharacters) {
+        if (text.length() == maxNumCharacters) {
+            return text;
+        } else if (text.length() < maxNumCharacters) {
+            char[] filler = new char[maxNumCharacters - text.length()];
+            Arrays.fill(filler,' ');
+            return text + new String(filler);
+        }
+
+        StringBuilder abbreviatedText = new StringBuilder();
+        abbreviatedText.append(text.substring(0, maxNumCharacters - ABBREVIATE_SUFFIX.length()));
+        abbreviatedText.append(ABBREVIATE_SUFFIX);
+        return abbreviatedText.toString();
+    }
+
+    private static void calculateRectangleForViewNode(ViewNode viewNode) {
+          int leftShift = 0;
+          int topShift = 0;
+          int nodeLeft = (int) Math.round(viewNode.left * sScaleFactor);
+          int nodeTop = (int) Math.round(viewNode.top * sScaleFactor);
+          int nodeWidth = (int) Math.round(viewNode.width * sScaleFactor);
+          int nodeHeight = (int) Math.round(viewNode.height * sScaleFactor);
+          ViewNode current = viewNode;
+
+          while (current.parent != null) {
+              leftShift += (int) Math.round(
+                      sScaleFactor * (current.parent.left - current.parent.scrollX));
+              topShift += (int) Math.round(
+                      sScaleFactor * (current.parent.top - current.parent.scrollY));
+              current = current.parent;
+          }
+
+          sRectangleForViewNode.put(viewNode, new Rectangle(leftShift + nodeLeft,
+                  topShift + nodeTop, nodeWidth, nodeHeight));
+    }
+
+    private static EvaluateContrastModel evaluateContrastForView(ViewNode node) {
+        Map<String, Property> namedProperties = node.namedProperties;
+        Property textColorProperty = namedProperties.get("text:mCurTextColor");
+        Integer textColor = textColorProperty == null ? null :
+                Integer.valueOf(textColorProperty.value);
+        Property textSizeProperty = namedProperties.get("text:getScaledTextSize()");
+        Double textSize = textSizeProperty == null ? null : Double.valueOf(textSizeProperty.value);
+        Rectangle rectangle = sRectangleForViewNode.get(node);
+        Property boldProperty = namedProperties.get("text:getTypefaceStyle()");
+        boolean isBold = boldProperty != null && boldProperty.value.equals("BOLD");
+
+        // TODO: also remove views that are covered by other views
+        if (rectangle.x < 0 || rectangle.x > sImageWidth ||
+                rectangle.y < 0 || rectangle.y > sImageHeight ||
+                rectangle.width == 0 || rectangle.height == 0) {
+            // not viewable in screenshot, therefore can't parse background color
+            return null;
+        }
+
+        int x = Math.max(0, rectangle.x);
+        int y = Math.max(0, rectangle.y);
+        int width = Math.min(sImageWidth, rectangle.x + rectangle.width);
+        int height = Math.min(sImageHeight, rectangle.y + rectangle.height);
+
+        return new EvaluateContrastModel(
+                sImage, textColor, textSize, x, y, width, height, isBold);
+    }
+
+    private static ShellAdapter sShellListener = new ShellAdapter() {
+        @Override
+        public void shellClosed(ShellEvent e) {
+            e.doit = false;
+            sShell.setVisible(false);
+            clear(sShell == null);
+        }
+    };
+}