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);
+ }
+ };
+}