blob: 5661b2919ee4fcb3debc9938bd17c0c3a0e9847e [file] [log] [blame]
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Eclipse Public License, Version 1.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.eclipse.org/org/documents/epl-v10.php
*
* 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.ide.eclipse.adt.internal.editors.layout.gle2;
import static com.android.SdkConstants.DOT_PNG;
import static com.android.SdkConstants.FQCN_DATE_PICKER;
import static com.android.SdkConstants.FQCN_EXPANDABLE_LIST_VIEW;
import static com.android.SdkConstants.FQCN_LIST_VIEW;
import static com.android.SdkConstants.FQCN_TIME_PICKER;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.rendering.LayoutLibrary;
import com.android.ide.common.rendering.api.Capability;
import com.android.ide.common.rendering.api.RenderSession;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.ide.common.rendering.api.SessionParams.RenderingMode;
import com.android.ide.common.rendering.api.StyleResourceValue;
import com.android.ide.common.rendering.api.ViewInfo;
import com.android.ide.common.resources.ResourceResolver;
import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor;
import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor;
import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate;
import com.android.ide.eclipse.adt.internal.editors.layout.gre.PaletteMetadataDescriptor;
import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository;
import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository.RenderMode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode;
import com.android.ide.eclipse.adt.internal.resources.ResourceHelper;
import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData;
import com.android.sdklib.IAndroidTarget;
import com.android.utils.Pair;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.jface.resource.ImageDescriptor;
import org.eclipse.swt.graphics.RGB;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import javax.imageio.ImageIO;
/**
* Factory which can provide preview icons for android views of a particular SDK and
* editor's configuration chooser
*/
public class PreviewIconFactory {
private PaletteControl mPalette;
private RGB mBackground;
private RGB mForeground;
private File mImageDir;
private static final String PREVIEW_INFO_FILE = "preview.properties"; //$NON-NLS-1$
public PreviewIconFactory(PaletteControl palette) {
mPalette = palette;
}
/**
* Resets the state in the preview icon factory such that it will re-fetch information
* like the theme and SDK (the icons themselves are cached in a directory across IDE
* session though)
*/
public void reset() {
mImageDir = null;
mBackground = null;
mForeground = null;
}
/**
* Deletes all the persistent state for the current settings such that it will be regenerated
*/
public void refresh() {
File imageDir = getImageDir(false);
if (imageDir != null && imageDir.exists()) {
File[] files = imageDir.listFiles();
for (File file : files) {
file.delete();
}
imageDir.delete();
reset();
}
}
/**
* Returns an image descriptor for the given element descriptor, or null if no image
* could be computed. The rendering parameters (SDK, theme etc) correspond to those
* stored in the associated palette.
*
* @param desc the element descriptor to get an image for
* @return an image descriptor, or null if no image could be rendered
*/
public ImageDescriptor getImageDescriptor(ElementDescriptor desc) {
File imageDir = getImageDir(false);
if (!imageDir.exists()) {
render();
}
File file = new File(imageDir, getFileName(desc));
if (file.exists()) {
try {
return ImageDescriptor.createFromURL(file.toURI().toURL());
} catch (MalformedURLException e) {
AdtPlugin.log(e, "Could not create image descriptor for %s", file);
}
}
return null;
}
/**
* Partition the elements in the document according to their rendering preferences;
* elements that should be skipped are removed, elements that should be rendered alone
* are placed in their own list, etc
*
* @param document the document containing render fragments for the various elements
* @return
*/
private List<List<Element>> partitionRenderElements(Document document) {
List<List<Element>> elements = new ArrayList<List<Element>>();
List<Element> shared = new ArrayList<Element>();
Element root = document.getDocumentElement();
elements.add(shared);
ViewMetadataRepository repository = ViewMetadataRepository.get();
NodeList children = root.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String fqn = repository.getFullClassName(element);
assert fqn.length() > 0 : element.getNodeName();
RenderMode renderMode = repository.getRenderMode(fqn);
// Temporary special cases
if (fqn.equals(FQCN_LIST_VIEW) || fqn.equals(FQCN_EXPANDABLE_LIST_VIEW)) {
if (!mPalette.getEditor().renderingSupports(Capability.ADAPTER_BINDING)) {
renderMode = RenderMode.SKIP;
}
} else if (fqn.equals(FQCN_DATE_PICKER) || fqn.equals(FQCN_TIME_PICKER)) {
IAndroidTarget renderingTarget = mPalette.getEditor().getRenderingTarget();
// In Honeycomb, these widgets only render properly in the Holo themes.
int apiLevel = renderingTarget.getVersion().getApiLevel();
if (apiLevel == 11) {
String themeName = mPalette.getCurrentTheme();
if (themeName == null || !themeName.startsWith("Theme.Holo")) { //$NON-NLS-1$
// Note - it's possible that the the theme is some other theme
// such as a user theme which inherits from Theme.Holo and that
// the render -would- have worked, but it's harder to detect that
// scenario, so we err on the side of caution and just show an
// icon + name for the time widgets.
renderMode = RenderMode.SKIP;
}
} else if (apiLevel >= 12) {
// Currently broken, even for Holo.
renderMode = RenderMode.SKIP;
} // apiLevel <= 10 is fine
}
if (renderMode == RenderMode.ALONE) {
elements.add(Collections.singletonList(element));
} else if (renderMode == RenderMode.NORMAL) {
shared.add(element);
} else {
assert renderMode == RenderMode.SKIP;
}
}
}
return elements;
}
/**
* Renders ALL the widgets and then extracts image data for each view and saves it on
* disk
*/
private boolean render() {
File imageDir = getImageDir(true);
GraphicalEditorPart editor = mPalette.getEditor();
LayoutEditorDelegate layoutEditorDelegate = editor.getEditorDelegate();
LayoutLibrary layoutLibrary = editor.getLayoutLibrary();
Integer overrideBgColor = null;
if (layoutLibrary != null) {
if (layoutLibrary.supports(Capability.CUSTOM_BACKGROUND_COLOR)) {
Pair<RGB, RGB> themeColors = getColorsFromTheme();
RGB bg = themeColors.getFirst();
RGB fg = themeColors.getSecond();
if (bg != null) {
storeBackground(imageDir, bg, fg);
overrideBgColor = Integer.valueOf(ImageUtils.rgbToInt(bg, 0xFF));
}
}
}
ViewMetadataRepository repository = ViewMetadataRepository.get();
Document document = repository.getRenderingConfigDoc();
if (document == null) {
return false;
}
// Construct UI model from XML
AndroidTargetData data = layoutEditorDelegate.getEditor().getTargetData();
DocumentDescriptor documentDescriptor;
if (data == null) {
documentDescriptor = new DocumentDescriptor("temp", null/*children*/);//$NON-NLS-1$
} else {
documentDescriptor = data.getLayoutDescriptors().getDescriptor();
}
UiDocumentNode model = (UiDocumentNode) documentDescriptor.createUiNode();
model.setEditor(layoutEditorDelegate.getEditor());
model.setUnknownDescriptorProvider(editor.getModel().getUnknownDescriptorProvider());
Element documentElement = document.getDocumentElement();
List<List<Element>> elements = partitionRenderElements(document);
for (List<Element> elementGroup : elements) {
// Replace the document elements with the current element group
while (documentElement.getFirstChild() != null) {
documentElement.removeChild(documentElement.getFirstChild());
}
for (Element element : elementGroup) {
documentElement.appendChild(element);
}
model.loadFromXmlNode(document);
RenderSession session = null;
NodeList childNodes = documentElement.getChildNodes();
try {
// Important to get these sizes large enough for clients that don't support
// RenderMode.FULL_EXPAND such as 1.6
int width = 200;
int height = childNodes.getLength() == 1 ? 400 : 1600;
session = RenderService.create(editor)
.setModel(model)
.setOverrideRenderSize(width, height)
.setRenderingMode(RenderingMode.FULL_EXPAND)
.setLog(editor.createRenderLogger("palette"))
.setOverrideBgColor(overrideBgColor)
.setDecorations(false)
.createRenderSession();
} catch (Throwable t) {
// If there are internal errors previewing the components just revert to plain
// icons and labels
continue;
}
if (session != null) {
if (session.getResult().isSuccess()) {
BufferedImage image = session.getImage();
if (image != null && image.getWidth() > 0 && image.getHeight() > 0) {
// Fallback for older platforms where we couldn't do background rendering
// at the beginning of this method
if (mBackground == null) {
Pair<RGB, RGB> themeColors = getColorsFromTheme();
RGB bg = themeColors.getFirst();
RGB fg = themeColors.getSecond();
if (bg == null) {
// Just use a pixel from the rendering instead.
int p = image.getRGB(image.getWidth() - 1, image.getHeight() - 1);
// However, in this case we don't trust the foreground color
// even if one was found in the themes; pick one that is guaranteed
// to contrast with the background
bg = ImageUtils.intToRgb(p);
if (ImageUtils.getBrightness(ImageUtils.rgbToInt(bg, 255)) < 128) {
fg = new RGB(255, 255, 255);
} else {
fg = new RGB(0, 0, 0);
}
}
storeBackground(imageDir, bg, fg);
assert mBackground != null;
}
List<ViewInfo> viewInfoList = session.getRootViews();
if (viewInfoList != null && viewInfoList.size() > 0) {
// We don't render previews under a <merge> so there should
// only be one root.
ViewInfo firstRoot = viewInfoList.get(0);
int parentX = firstRoot.getLeft();
int parentY = firstRoot.getTop();
List<ViewInfo> infos = firstRoot.getChildren();
for (ViewInfo info : infos) {
Object cookie = info.getCookie();
if (!(cookie instanceof UiElementNode)) {
continue;
}
UiElementNode node = (UiElementNode) cookie;
String fileName = getFileName(node);
File file = new File(imageDir, fileName);
if (file.exists()) {
// On Windows, perhaps we need to rename instead?
file.delete();
}
int x1 = parentX + info.getLeft();
int y1 = parentY + info.getTop();
int x2 = parentX + info.getRight();
int y2 = parentY + info.getBottom();
if (x1 != x2 && y1 != y2) {
savePreview(file, image, x1, y1, x2, y2);
}
}
}
}
} else {
StringBuilder sb = new StringBuilder();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node node = childNodes.item(i);
if (node instanceof Element) {
Element e = (Element) node;
String fqn = repository.getFullClassName(e);
fqn = fqn.substring(fqn.lastIndexOf('.') + 1);
if (sb.length() > 0) {
sb.append(", "); //$NON-NLS-1$
}
sb.append(fqn);
}
}
AdtPlugin.log(IStatus.WARNING, "Failed to render set of icons for %1$s",
sb.toString());
if (session.getResult().getException() != null) {
AdtPlugin.log(session.getResult().getException(),
session.getResult().getErrorMessage());
} else if (session.getResult().getErrorMessage() != null) {
AdtPlugin.log(IStatus.WARNING, session.getResult().getErrorMessage());
}
}
session.dispose();
}
}
mPalette.getEditor().recomputeLayout();
return true;
}
/**
* Look up the background and foreground colors from the theme. May not find either
* the background or foreground or both, but will always return a pair of possibly
* null colors.
*
* @return a pair of possibly null color descriptions
*/
@NonNull
private Pair<RGB, RGB> getColorsFromTheme() {
RGB background = null;
RGB foreground = null;
ResourceResolver resources = mPalette.getEditor().getResourceResolver();
if (resources == null) {
return Pair.of(background, foreground);
}
StyleResourceValue theme = resources.getCurrentTheme();
if (theme != null) {
background = resolveThemeColor(resources, "windowBackground"); //$NON-NLS-1$
if (background == null) {
background = renderDrawableResource("windowBackground"); //$NON-NLS-1$
// This causes some harm with some themes: We'll find a color, say black,
// that isn't actually rendered in the theme. Better to use null here,
// which will cause the caller to pick a pixel from the observed background
// instead.
//if (background == null) {
// background = resolveThemeColor(resources, "colorBackground"); //$NON-NLS-1$
//}
}
foreground = resolveThemeColor(resources, "textColorPrimary"); //$NON-NLS-1$
}
// Ensure that the foreground color is suitably distinct from the background color
if (background != null) {
int bgRgb = ImageUtils.rgbToInt(background, 0xFF);
int backgroundBrightness = ImageUtils.getBrightness(bgRgb);
if (foreground == null) {
if (backgroundBrightness < 128) {
foreground = new RGB(255, 255, 255);
} else {
foreground = new RGB(0, 0, 0);
}
} else {
int fgRgb = ImageUtils.rgbToInt(foreground, 0xFF);
int foregroundBrightness = ImageUtils.getBrightness(fgRgb);
if (Math.abs(backgroundBrightness - foregroundBrightness) < 64) {
if (backgroundBrightness < 128) {
foreground = new RGB(255, 255, 255);
} else {
foreground = new RGB(0, 0, 0);
}
}
}
}
return Pair.of(background, foreground);
}
/**
* Renders the given resource which should refer to a drawable and returns a
* representative color value for the drawable (such as the color in the center)
*
* @param themeItemName the item in the theme to be looked up and rendered
* @return a color representing a typical color in the drawable
*/
private RGB renderDrawableResource(String themeItemName) {
GraphicalEditorPart editor = mPalette.getEditor();
ResourceResolver resources = editor.getResourceResolver();
ResourceValue resourceValue = resources.findItemInTheme(themeItemName);
BufferedImage image = RenderService.create(editor)
.setOverrideRenderSize(100, 100)
.renderDrawable(resourceValue);
if (image != null) {
// Use the middle pixel as the color since that works better for gradients;
// solid colors work too.
int rgb = image.getRGB(image.getWidth() / 2, image.getHeight() / 2);
return ImageUtils.intToRgb(rgb);
}
return null;
}
private static RGB resolveThemeColor(ResourceResolver resources, String resourceName) {
ResourceValue textColor = resources.findItemInTheme(resourceName);
return ResourceHelper.resolveColor(resources, textColor);
}
private String getFileName(ElementDescriptor descriptor) {
if (descriptor instanceof PaletteMetadataDescriptor) {
PaletteMetadataDescriptor pmd = (PaletteMetadataDescriptor) descriptor;
StringBuilder sb = new StringBuilder();
String name = pmd.getUiName();
// Strip out whitespace, parentheses, etc.
for (int i = 0, n = name.length(); i < n; i++) {
char c = name.charAt(i);
if (Character.isLetter(c)) {
sb.append(c);
}
}
return sb.toString() + DOT_PNG;
}
return descriptor.getUiName() + DOT_PNG;
}
private String getFileName(UiElementNode node) {
ViewMetadataRepository repository = ViewMetadataRepository.get();
String fqn = repository.getFullClassName((Element) node.getXmlNode());
return fqn.substring(fqn.lastIndexOf('.') + 1) + DOT_PNG;
}
/**
* Cleans up a name by removing punctuation and whitespace etc to make
* it a better filename
* @param name the name to clean
* @return a cleaned up name
*/
@NonNull
private static String cleanup(@Nullable String name) {
if (name == null) {
return "";
}
// Extract just the characters (no whitespace, parentheses, punctuation etc)
// to ensure that the filename is pretty portable
StringBuilder sb = new StringBuilder(name.length());
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (Character.isJavaIdentifierPart(c)) {
sb.append(Character.toLowerCase(c));
}
}
return sb.toString();
}
/** Returns the location of a directory containing image previews (which may not exist) */
private File getImageDir(boolean create) {
if (mImageDir == null) {
// Location for plugin-related state data
IPath pluginState = AdtPlugin.getDefault().getStateLocation();
// We have multiple directories - one for each combination of SDK, theme and device
// (and later, possibly other qualifiers).
// These are created -lazily-.
String targetName = mPalette.getCurrentTarget().hashString();
String androidTargetNamePrefix = "android-";
String themeNamePrefix = "Theme.";
if (targetName.startsWith(androidTargetNamePrefix)) {
targetName = targetName.substring(androidTargetNamePrefix.length());
}
String themeName = mPalette.getCurrentTheme();
if (themeName == null) {
themeName = "Theme"; //$NON-NLS-1$
}
if (themeName.startsWith(themeNamePrefix)) {
themeName = themeName.substring(themeNamePrefix.length());
}
targetName = cleanup(targetName);
themeName = cleanup(themeName);
String deviceName = cleanup(mPalette.getCurrentDevice());
String dirName = String.format("palette-preview-r16b-%s-%s-%s", targetName,
themeName, deviceName);
IPath dirPath = pluginState.append(dirName);
mImageDir = new File(dirPath.toOSString());
}
if (create && !mImageDir.exists()) {
mImageDir.mkdirs();
}
return mImageDir;
}
private void savePreview(File output, BufferedImage image,
int left, int top, int right, int bottom) {
try {
BufferedImage im = ImageUtils.subImage(image, left, top, right, bottom);
ImageIO.write(im, "PNG", output); //$NON-NLS-1$
} catch (IOException e) {
AdtPlugin.log(e, "Failed writing palette file");
}
}
private void storeBackground(File imageDir, RGB bg, RGB fg) {
mBackground = bg;
mForeground = fg;
File file = new File(imageDir, PREVIEW_INFO_FILE);
String colors = String.format(
"background=#%02x%02x%02x\nforeground=#%02x%02x%02x\n", //$NON-NLS-1$
bg.red, bg.green, bg.blue,
fg.red, fg.green, fg.blue);
AdtPlugin.writeFile(file, colors);
}
public RGB getBackgroundColor() {
if (mBackground == null) {
initColors();
}
return mBackground;
}
public RGB getForegroundColor() {
if (mForeground == null) {
initColors();
}
return mForeground;
}
public void initColors() {
try {
// Already initialized? Foreground can be null which would call
// initColors again and again, but background is never null after
// initialization so we use it as the have-initialized flag.
if (mBackground != null) {
return;
}
File imageDir = getImageDir(false);
if (!imageDir.exists()) {
render();
// Initialized as part of the render
if (mBackground != null) {
return;
}
}
File file = new File(imageDir, PREVIEW_INFO_FILE);
if (file.exists()) {
Properties properties = new Properties();
InputStream is = null;
try {
is = new BufferedInputStream(new FileInputStream(file));
properties.load(is);
} catch (IOException e) {
AdtPlugin.log(e, "Can't read preview properties");
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
// Nothing useful can be done.
}
}
}
String colorString = (String) properties.get("background"); //$NON-NLS-1$
if (colorString != null) {
int rgb = ImageUtils.getColor(colorString.trim());
mBackground = ImageUtils.intToRgb(rgb);
}
colorString = (String) properties.get("foreground"); //$NON-NLS-1$
if (colorString != null) {
int rgb = ImageUtils.getColor(colorString.trim());
mForeground = ImageUtils.intToRgb(rgb);
}
}
if (mBackground == null) {
mBackground = new RGB(0, 0, 0);
}
// mForeground is allowed to be null.
} catch (Throwable t) {
AdtPlugin.log(t, "Cannot initialize preview color settings");
}
}
}