blob: 1b5dae93caf4ada96e03fef348c4774129ab4780 [file] [log] [blame]
/*
* Copyright (C) 2013 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.tools.idea.rendering;
import com.android.annotations.Nullable;
import com.android.ide.common.rendering.api.AdapterBinding;
import com.android.ide.common.rendering.api.DataBindingItem;
import com.android.ide.common.rendering.api.ResourceReference;
import com.google.common.collect.Lists;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.Result;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Computable;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiManager;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.xml.XmlAttribute;
import com.intellij.psi.xml.XmlFile;
import com.intellij.psi.xml.XmlTag;
import org.jetbrains.android.inspections.lint.SuppressLintIntentionAction;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xmlpull.v1.XmlPullParser;
import java.util.List;
import java.util.Map;
import static com.android.SdkConstants.*;
import static com.android.tools.lint.detector.api.LintUtils.stripIdPrefix;
/**
* Design-time metadata lookup for layouts, such as fragment and AdapterView bindings.
*/
public class LayoutMetadata {
/**
* The default layout to use for list items in expandable list views
*/
public static final String DEFAULT_EXPANDABLE_LIST_ITEM = "simple_expandable_list_item_2"; //$NON-NLS-1$
/**
* The default layout to use for list items in plain list views
*/
public static final String DEFAULT_LIST_ITEM = "simple_list_item_2"; //$NON-NLS-1$
/**
* The default layout to use for list items in spinners
*/
public static final String DEFAULT_SPINNER_ITEM = "simple_spinner_item"; //$NON-NLS-1$
/**
* The property key, included in comments, which references a list item layout
*/
public static final String KEY_LV_ITEM = "listitem"; //$NON-NLS-1$
/**
* The property key, included in comments, which references a list header layout
*/
public static final String KEY_LV_HEADER = "listheader"; //$NON-NLS-1$
/**
* The property key, included in comments, which references a list footer layout
*/
public static final String KEY_LV_FOOTER = "listfooter"; //$NON-NLS-1$
/**
* The property key, included in comments, which references a fragment layout to show
*/
public static final String KEY_FRAGMENT_LAYOUT = "layout"; //$NON-NLS-1$
// NOTE: If you add additional keys related to resources, make sure you update the
// ResourceRenameParticipant
/**
* Utility class, do not create instances
*/
private LayoutMetadata() {
}
/**
* Returns the given property specified in the <b>current</b> element being
* processed by the given pull parser.
*
* @param parser the pull parser, which must be in the middle of processing
* the target element
* @param name the property name to look up
* @return the property value, or null if not defined
*/
@Nullable
public static String getProperty(@NotNull XmlPullParser parser, @NotNull String name) {
String value = parser.getAttributeValue(TOOLS_URI, name);
if (value != null && value.isEmpty()) {
value = null;
}
return value;
}
/**
* Returns the given property of the given DOM node, or null
*
* @param node the XML node to associate metadata with
* @param name the name of the property to look up
* @return the value stored with the given node and name, or null
*/
@Nullable
public static String getProperty(@NotNull Node node, @NotNull String name) {
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element)node;
String value = element.getAttributeNS(TOOLS_URI, name);
if (value != null && value.isEmpty()) {
value = null;
}
return value;
}
return null;
}
/**
* Returns the given property of the given DOM node, or null
*
* @param node the XML node to associate metadata with
* @param name the name of the property to look up
* @return the value stored with the given node and name, or null
*/
@Nullable
public static String getProperty(@NotNull XmlTag node, @NotNull String name) {
String value = node.getAttributeValue(name, TOOLS_URI);
if (value != null && value.isEmpty()) {
value = null;
}
return value;
}
/**
* Strips out @layout/ or @android:layout/ from the given layout reference
*/
private static String stripLayoutPrefix(String layout) {
if (layout.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX)) {
layout = layout.substring(ANDROID_LAYOUT_RESOURCE_PREFIX.length());
}
else if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) {
layout = layout.substring(LAYOUT_RESOURCE_PREFIX.length());
}
return layout;
}
/**
* Creates an {@link com.android.ide.common.rendering.api.AdapterBinding} for the given view object, or null if the user
* has not yet chosen a target layout to use for the given AdapterView.
*
* @param viewObject the view object to create an adapter binding for
* @param map a map containing tools attribute metadata
* @return a binding, or null
*/
@Nullable
public static AdapterBinding getNodeBinding(@Nullable Object viewObject, @NotNull Map<String, String> map) {
String header = map.get(KEY_LV_HEADER);
String footer = map.get(KEY_LV_FOOTER);
String layout = map.get(KEY_LV_ITEM);
if (layout != null || header != null || footer != null) {
int count = 12;
return getNodeBinding(viewObject, header, footer, layout, count);
}
return null;
}
/**
* Creates an {@link com.android.ide.common.rendering.api.AdapterBinding} for the given view object, or null if the user
* has not yet chosen a target layout to use for the given AdapterView.
*
* @param viewObject the view object to create an adapter binding for
* @param xmlNode the ui node corresponding to the view object
* @return a binding, or null
*/
@Nullable
public static AdapterBinding getNodeBinding(@Nullable Object viewObject, @NotNull XmlTag xmlNode) {
String header = getProperty(xmlNode, KEY_LV_HEADER);
String footer = getProperty(xmlNode, KEY_LV_FOOTER);
String layout = getProperty(xmlNode, KEY_LV_ITEM);
if (layout != null || header != null || footer != null) {
int count = 12;
// If we're dealing with a grid view, multiply the list item count
// by the number of columns to ensure we have enough items
if (xmlNode instanceof Element && xmlNode.getName().endsWith(GRID_VIEW)) {
Element element = (Element)xmlNode;
String columns = element.getAttributeNS(ANDROID_URI, ATTR_NUM_COLUMNS);
int multiplier = 2;
if (columns != null && columns.length() > 0 &&
!columns.equals(VALUE_AUTO_FIT)) {
try {
int c = Integer.parseInt(columns);
if (c >= 1 && c <= 10) {
multiplier = c;
}
}
catch (NumberFormatException nufe) {
// some unexpected numColumns value: just stick with 2 columns for
// preview purposes
}
}
count *= multiplier;
}
return getNodeBinding(viewObject, header, footer, layout, count);
}
return null;
}
@Nullable
private static AdapterBinding getNodeBinding(@Nullable Object viewObject,
@Nullable String header,
@Nullable String footer,
@Nullable String layout,
int count) {
if (layout != null || header != null || footer != null) {
AdapterBinding binding = new AdapterBinding(count);
if (header != null) {
boolean isFramework = header.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX);
binding.addHeader(new ResourceReference(stripLayoutPrefix(header), isFramework));
}
if (footer != null) {
boolean isFramework = footer.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX);
binding.addFooter(new ResourceReference(stripLayoutPrefix(footer), isFramework));
}
if (layout != null) {
boolean isFramework = layout.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX);
if (isFramework) {
layout = layout.substring(ANDROID_LAYOUT_RESOURCE_PREFIX.length());
}
else if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) {
layout = layout.substring(LAYOUT_RESOURCE_PREFIX.length());
}
binding.addItem(new DataBindingItem(layout, isFramework, 1));
}
else if (viewObject != null) {
String listFqcn = LayoutlibCallbackImpl.getListAdapterViewFqcn(viewObject.getClass());
if (listFqcn != null) {
if (listFqcn.endsWith(EXPANDABLE_LIST_VIEW)) {
binding.addItem(new DataBindingItem(DEFAULT_EXPANDABLE_LIST_ITEM, true /* isFramework */, 1));
}
else {
binding.addItem(new DataBindingItem(DEFAULT_LIST_ITEM, true /* isFramework */, 1));
}
}
}
else {
binding.addItem(new DataBindingItem(DEFAULT_LIST_ITEM, true /* isFramework */, 1));
}
return binding;
}
return null;
}
/**
* Sets the given property of the given DOM node to a given value, or if null clears
* the property.
*/
public static void setProperty(@NotNull final Project project,
@Nullable String title,
@NotNull final XmlFile file,
@NotNull final XmlTag element,
@NotNull final String name,
@Nullable final String namespace,
@Nullable final String value) {
String capitalizedName = StringUtil.capitalize(name);
if (title == null) {
title = value != null ? String.format("Set %1$s", capitalizedName) : String.format("Clear %1$s", capitalizedName);
}
WriteCommandAction<Void> action = new WriteCommandAction<Void>(project, title, file) {
@Override
protected void run(Result<Void> result) throws Throwable {
if (value == null) {
// Clear attribute
XmlAttribute attribute;
if (namespace != null) {
attribute = element.getAttribute(name, namespace);
} else {
attribute = element.getAttribute(name);
}
if (attribute != null) {
attribute.delete();
}
} else {
if (namespace != null) {
SuppressLintIntentionAction.ensureNamespaceImported(project, file, namespace);
element.setAttribute(name, namespace, value);
} else {
element.setAttribute(name, value);
}
}
}
};
action.execute();
// Also set the values on the same elements in any resource variations
// of the same layout
// TODO: This should be done after a brief delay, say 50ms
final List<XmlTag> list = ApplicationManager.getApplication().runReadAction(new Computable<List<XmlTag>>() {
@Override
@Nullable
public List<XmlTag> compute() {
// Look up the id of the element, if any
String id = stripIdPrefix(element.getAttributeValue(ATTR_ID, ANDROID_URI));
if (id.isEmpty()) {
return null;
}
VirtualFile layoutFile = file.getVirtualFile();
if (layoutFile != null) {
final List<VirtualFile> variations = ResourceHelper.getResourceVariations(layoutFile, false);
if (variations.isEmpty()) {
return null;
}
PsiManager manager = PsiManager.getInstance(project);
List<XmlTag> list = Lists.newArrayList();
for (VirtualFile file : variations) {
PsiFile psiFile = manager.findFile(file);
if (psiFile == null) {
continue;
}
for (XmlTag tag : PsiTreeUtil.findChildrenOfType(psiFile, XmlTag.class)) {
XmlAttribute attribute = tag.getAttribute(ATTR_ID, ANDROID_URI);
if (attribute == null) {
continue;
}
if (attribute.getValue().endsWith(id) && id.equals(stripIdPrefix(attribute.getValue()))) {
list.add(tag);
break;
}
}
}
return list;
}
return null;
}
});
if (list != null && !list.isEmpty()) {
List<PsiFile> affectedFiles = Lists.newArrayList();
for (XmlTag tag : list) {
PsiFile psiFile = tag.getContainingFile();
if (psiFile != null) {
affectedFiles.add(psiFile);
}
}
action = new WriteCommandAction<Void>(project, title, affectedFiles.toArray(new PsiFile[affectedFiles.size()])) {
@Override
protected void run(Result<Void> result) throws Throwable {
for (XmlTag tag : list) {
if (value == null) {
// Clear attribute
XmlAttribute attribute;
if (namespace != null) {
attribute = tag.getAttribute(name, namespace);
} else {
attribute = tag.getAttribute(name);
}
if (attribute != null) {
attribute.delete();
}
} else {
if (namespace != null) {
SuppressLintIntentionAction.ensureNamespaceImported(project, file, namespace);
tag.setAttribute(name, namespace, value);
} else {
tag.setAttribute(name, value);
}
}
}
}
};
action.execute();
}
}
}