/* * Copyright 2000-2010 JetBrains s.r.o. * * 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 org.jetbrains.android; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.VisibleForTesting; import com.android.ide.common.rendering.api.ResourceValue; import com.android.ide.common.resources.ResourceItem; import com.android.ide.common.resources.ResourceRepository; import com.android.ide.common.resources.ResourceResolver; import com.android.ide.common.resources.configuration.DensityQualifier; import com.android.ide.common.resources.configuration.FolderConfiguration; import com.android.resources.Density; import com.android.resources.ResourceType; import com.android.tools.idea.AndroidPsiUtils; import com.android.tools.idea.configurations.Configuration; import com.android.tools.idea.rendering.AppResourceRepository; import com.android.tools.idea.rendering.LocalResourceRepository; import com.android.tools.idea.rendering.ResourceHelper; import com.intellij.lang.annotation.Annotation; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.lang.annotation.Annotator; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.actionSystem.CommonDataKeys; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.markup.GutterIconRenderer; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.module.Module; import com.intellij.openapi.module.ModuleUtilCore; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiReferenceExpression; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.psi.xml.XmlAttribute; import com.intellij.psi.xml.XmlAttributeValue; import com.intellij.psi.xml.XmlTag; import com.intellij.psi.xml.XmlTagValue; import com.intellij.ui.ColorChooser; import com.intellij.util.ui.ColorIcon; import com.intellij.util.ui.EmptyIcon; import com.intellij.util.xml.DomElement; import com.intellij.util.xml.DomManager; import org.jetbrains.android.dom.resources.ResourceElement; import org.jetbrains.android.facet.AndroidFacet; import org.jetbrains.android.uipreview.ColorPicker; import org.jetbrains.android.util.AndroidBundle; import org.jetbrains.android.util.AndroidResourceUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.swing.*; import java.awt.*; import java.io.File; import static com.android.SdkConstants.*; import static com.android.tools.idea.AndroidPsiUtils.ResourceReferenceType; /** * Annotator which puts colors in the editor gutter for both color files, as well * as any XML resource that references a color attribute (\@color) or color literal (#AARRGGBBB), * or references it from Java code (R.color.name). It also previews small icons. * <p> * TODO: Use {@link com.android.ide.common.resources.ResourceItemResolver} when possible! * */ public class AndroidColorAnnotator implements Annotator { private static final int ICON_SIZE = 8; private static final String ANDROID_COLOR_RESOURCE_PREFIX = "@android:color/"; private static final int MAX_ICON_SIZE = 5000; @Override public void annotate(@NotNull PsiElement element, @NotNull AnnotationHolder holder) { if (ApplicationManager.getApplication().isUnitTestMode()) { // disable it in test mode temporary, because of failing AndroidLayoutDomTest#testAttrReferences1() return; } if (element instanceof XmlTag) { XmlTag tag = (XmlTag)element; String tagName = tag.getName(); if ((ResourceType.COLOR.getName().equals(tagName) || ResourceType.DRAWABLE.getName().equals(tagName))) { DomElement domElement = DomManager.getDomManager(element.getProject()).getDomElement(tag); if (domElement instanceof ResourceElement) { String value = tag.getValue().getText().trim(); annotateXml(element, holder, value); } } else if (SdkConstants.TAG_ITEM.equals(tagName)) { XmlTagValue value = tag.getValue(); String text = value.getText(); annotateXml(element, holder, text); } } else if (element instanceof XmlAttributeValue) { XmlAttributeValue v = (XmlAttributeValue)element; String value = v.getValue(); if (value == null || value.isEmpty()) { return; } annotateXml(element, holder, value); } else if (element instanceof PsiReferenceExpression) { ResourceReferenceType referenceType = AndroidPsiUtils.getResourceReferenceType(element); if (referenceType != ResourceReferenceType.NONE) { // (isResourceReference will return true for both "R.drawable.foo" and the foo literal leaf in the // same expression, which would result in both elements getting annotated and the icon showing up // in the gutter twice. Instead we only count the outer one. ResourceType type = AndroidPsiUtils.getResourceType(element); if (type == ResourceType.COLOR || type == ResourceType.DRAWABLE) { String name = AndroidPsiUtils.getResourceName(element); annotateResourceReference(type, holder, element, name, referenceType == ResourceReferenceType.FRAMEWORK); } } } } private static void annotateXml(PsiElement element, AnnotationHolder holder, String value) { if (value.startsWith("#")) { final PsiFile file = element.getContainingFile(); if (file != null && AndroidResourceUtil.isInResourceSubdirectory(file, null)) { if (element instanceof XmlTag) { Annotation annotation = holder.createInfoAnnotation(element, null); annotation.setGutterIconRenderer(new MyRenderer(element, null)); } else { assert element instanceof XmlAttributeValue; Color color = ResourceHelper.parseColor(value); if (color != null) { Annotation annotation = holder.createInfoAnnotation(element, null); annotation.setGutterIconRenderer(new MyRenderer(element, null)); } } } } else if (value.startsWith(COLOR_RESOURCE_PREFIX)) { annotateResourceReference(ResourceType.COLOR, holder, element, value.substring(COLOR_RESOURCE_PREFIX.length()), false); } else if (value.startsWith(ANDROID_COLOR_RESOURCE_PREFIX)) { annotateResourceReference(ResourceType.COLOR, holder, element, value.substring(ANDROID_COLOR_RESOURCE_PREFIX.length()), true); } else if (value.startsWith(DRAWABLE_PREFIX)) { annotateResourceReference(ResourceType.DRAWABLE, holder, element, value.substring(DRAWABLE_PREFIX.length()), false); } else if (value.startsWith(ANDROID_DRAWABLE_PREFIX)) { annotateResourceReference(ResourceType.DRAWABLE, holder, element, value.substring(ANDROID_DRAWABLE_PREFIX.length()), true); } } /** * When annotating Java files, we need to find an associated layout file to pick the resource * resolver from (e.g. to for example have a theme association which will drive how colors are * resolved). This file picks one of the open layout files, and if not found, the first layout * file found in the resources (if any). * */ @Nullable public static VirtualFile pickLayoutFile(@NotNull Module module, @NotNull AndroidFacet facet) { VirtualFile layout = null; VirtualFile[] openFiles = FileEditorManager.getInstance(module.getProject()).getOpenFiles(); for (VirtualFile file : openFiles) { if (file.getName().endsWith(DOT_XML) && file.getParent() != null && file.getParent().getName().startsWith(FD_RES_LAYOUT)) { layout = file; break; } } if (layout == null) { // Pick among actual files in the project for (VirtualFile resourceDir : facet.getAllResourceDirectories()) { for (VirtualFile folder : resourceDir.getChildren()) { if (folder.getName().startsWith(FD_RES_LAYOUT) && folder.isDirectory()) { for (VirtualFile file : folder.getChildren()) { if (file.getName().endsWith(DOT_XML) && file.getParent() != null && file.getParent().getName().startsWith(FD_RES_LAYOUT)) { layout = file; break; } } } } } } return layout; } private static void annotateResourceReference(@NotNull ResourceType type, @NotNull AnnotationHolder holder, @NotNull PsiElement element, @NotNull String name, boolean isFramework) { Module module = ModuleUtilCore.findModuleForPsiElement(element); if (module == null) { return; } AndroidFacet facet = AndroidFacet.getInstance(module); if (facet == null) { return; } PsiFile file = PsiTreeUtil.getParentOfType(element, PsiFile.class); if (file == null) { return; } Configuration configuration = pickConfiguration(facet, module, file); if (configuration == null) { return; } ResourceValue value = findResourceValue(type, name, isFramework, module, configuration); if (value != null) { // TODO: Use a *shared* fallback resolver for this? ResourceResolver resourceResolver = configuration.getResourceResolver(); if (resourceResolver != null) { annotateResourceValue(type, holder, element, value, resourceResolver); } } } /** Picks a suitable configuration to use for resource resolution */ @Nullable private static Configuration pickConfiguration(AndroidFacet facet, Module module, PsiFile file) { VirtualFile virtualFile = file.getVirtualFile(); if (virtualFile == null) { return null; } VirtualFile layout; String parentName = virtualFile.getParent().getName(); if (!parentName.startsWith(FD_RES_LAYOUT)) { layout = pickLayoutFile(module, facet); if (layout == null) { return null; } } else { layout = virtualFile; } return facet.getConfigurationManager().getConfiguration(layout); } /** Annotates the given element with the resolved value of the given {@link ResourceValue} */ private static void annotateResourceValue(@NotNull ResourceType type, @NotNull AnnotationHolder holder, @NotNull PsiElement element, @NotNull ResourceValue value, @NotNull ResourceResolver resourceResolver) { if (type == ResourceType.COLOR) { Color color = ResourceHelper.resolveColor(resourceResolver, value); if (color != null) { Annotation annotation = holder.createInfoAnnotation(element, null); annotation.setGutterIconRenderer(new MyRenderer(element, color)); } } else { assert type == ResourceType.DRAWABLE; File iconFile = pickBestBitmap(ResourceHelper.resolveDrawable(resourceResolver, value)); if (iconFile != null) { Annotation annotation = holder.createInfoAnnotation(element, null); annotation.setGutterIconRenderer(new com.android.tools.idea.rendering.GutterIconRenderer(element, iconFile)); } } } @Nullable private static File pickBestBitmap(@Nullable File bitmap) { if (bitmap != null && bitmap.exists()) { // Pick the smallest resolution, if possible! E.g. if the theme resolver located // drawable-hdpi/foo.png, and drawable-mdpi/foo.png pick that one instead (and ditto // for -ldpi etc) File smallest = findSmallestDpiVersion(bitmap); if (smallest != null) { return smallest; } long length = bitmap.length(); if (length < MAX_ICON_SIZE) { return bitmap; } } return null; } @Nullable private static File findSmallestDpiVersion(@NonNull File bitmap) { File parentFile = bitmap.getParentFile(); if (parentFile == null) { return null; } File resFolder = parentFile.getParentFile(); if (resFolder == null) { return null; } String parentName = parentFile.getName(); FolderConfiguration config = FolderConfiguration.getConfigForFolder(parentName); if (config == null) { return null; } DensityQualifier qualifier = config.getDensityQualifier(); if (qualifier == null) { return null; } Density density = qualifier.getValue(); if (density != null && density != Density.NODPI) { String fileName = bitmap.getName(); Density[] densities = Density.values(); // Iterate in reverse, since the Density enum is in descending order for (int i = densities.length - 1; i >= 0; i--) { Density d = densities[i]; if (d != Density.NODPI) { String folder = parentName.replace(density.getResourceValue(), d.getResourceValue()); bitmap = new File(resFolder, folder + File.separator + fileName); if (bitmap.exists()) { if (bitmap.length() > MAX_ICON_SIZE) { // No point continuing the loop; the other densities will be too big too return null; } return bitmap; } } } } return null; } /** Looks up the resource item of the given type and name for the given configuration, if any */ @Nullable private static ResourceValue findResourceValue(ResourceType type, String name, boolean isFramework, Module module, Configuration configuration) { if (isFramework) { ResourceRepository frameworkResources = configuration.getFrameworkResources(); if (frameworkResources == null) { return null; } if (!frameworkResources.hasResourceItem(type, name)) { return null; } ResourceItem item = frameworkResources.getResourceItem(type, name); return item.getResourceValue(type, configuration.getFullConfig(), false); } else { LocalResourceRepository appResources = AppResourceRepository.getAppResources(module, true); if (appResources == null) { return null; } if (!appResources.hasResourceItem(type, name)) { return null; } return appResources.getConfiguredValue(type, name, configuration.getFullConfig()); } } @VisibleForTesting static String colorToString(Color color) { long longColor = ((long)color.getAlpha() << 24) | (color.getRed() << 16) | (color.getGreen() << 8) | color.getBlue(); return '#' + Long.toHexString(longColor); } private static class MyRenderer extends GutterIconRenderer { private final PsiElement myElement; private final Color myColor; private MyRenderer(@NotNull PsiElement element, @Nullable Color color) { myElement = element; myColor = color; } @NotNull @Override public Icon getIcon() { final Color color = getCurrentColor(); return color == null ? EmptyIcon.create(ICON_SIZE) : new ColorIcon(ICON_SIZE, color); } @Nullable private Color getCurrentColor() { if (myColor != null) { return myColor; } else if (myElement instanceof XmlTag) { return ResourceHelper.parseColor(((XmlTag)myElement).getValue().getText()); } else if (myElement instanceof XmlAttributeValue) { return ResourceHelper.parseColor(((XmlAttributeValue)myElement).getValue()); } else { return null; } } @Override public AnAction getClickAction() { if (myColor != null) { // Cannot set colors that were derived return null; } return new AnAction() { @Override public void actionPerformed(AnActionEvent e) { final Editor editor = CommonDataKeys.EDITOR.getData(e.getDataContext()); if (editor != null) { // Need ARGB support in platform color chooser; see // http://youtrack.jetbrains.com/issue/IDEA-123498 //final Color color = // ColorChooser.chooseColor(editor.getComponent(), AndroidBundle.message("android.choose.color"), getCurrentColor()); final Color color = ColorPicker.showDialog(editor.getComponent(), "Choose Color", getCurrentColor(), true, null, false); if (color != null) { ApplicationManager.getApplication().runWriteAction(new Runnable() { @Override public void run() { if (myElement instanceof XmlTag) { ((XmlTag)myElement).getValue().setText(colorToString(color)); } else if (myElement instanceof XmlAttributeValue) { XmlAttribute attribute = PsiTreeUtil.getParentOfType(myElement, XmlAttribute.class); if (attribute != null) { attribute.setValue(colorToString(color)); } } } }); } } } }; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MyRenderer that = (MyRenderer)o; // TODO: Compare with modification count in app resources (if not framework) if (myColor != null ? !myColor.equals(that.myColor) : that.myColor != null) return false; if (!myElement.equals(that.myElement)) return false; return true; } @Override public int hashCode() { int result = myElement.hashCode(); result = 31 * result + (myColor != null ? myColor.hashCode() : 0); return result; } } }