blob: 61b3173af8f76a7e2af4b94c1320dbcb85d52bd2 [file] [log] [blame]
/*
* Copyright (C) 2015 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.uibuilder.model;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.rendering.api.ViewInfo;
import com.android.resources.ResourceType;
import com.android.tools.idea.AndroidPsiUtils;
import com.android.tools.idea.rendering.AppResourceRepository;
import com.android.tools.idea.rendering.AttributeSnapshot;
import com.android.tools.idea.rendering.ResourceHelper;
import com.android.tools.idea.rendering.TagSnapshot;
import com.android.tools.idea.uibuilder.api.InsertType;
import com.android.tools.idea.uibuilder.api.ViewEditor;
import com.android.tools.idea.uibuilder.api.ViewGroupHandler;
import com.android.tools.idea.uibuilder.api.ViewHandler;
import com.android.tools.idea.uibuilder.handlers.ViewEditorImpl;
import com.android.tools.idea.uibuilder.handlers.ViewHandlerManager;
import com.google.common.base.Objects;
import com.google.common.collect.Lists;
import com.intellij.lang.LanguageNamesValidation;
import com.intellij.lang.java.JavaLanguage;
import com.intellij.lang.refactoring.NamesValidator;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Computable;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.psi.xml.XmlTag;
import org.jetbrains.android.facet.AndroidFacet;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static com.android.SdkConstants.*;
/**
* Represents a component editable in the UI builder. A component has properties,
* if visual it has bounds, etc.
*/
public class NlComponent {
@Nullable public List<NlComponent> children;
@Nullable public ViewInfo viewInfo;
@AndroidCoordinate public int x;
@AndroidCoordinate public int y;
@AndroidCoordinate public int w;
@AndroidCoordinate public int h;
private NlComponent myParent;
@NonNull private final NlModel myModel;
@NonNull private XmlTag myTag;
@NonNull private String myTagName; // for non-read lock access elsewhere
@Nullable private TagSnapshot snapshot;
public NlComponent(@NonNull NlModel model, @NonNull XmlTag tag) {
myModel = model;
myTag = tag;
myTagName = tag.getName();
}
@NonNull
public XmlTag getTag() {
return myTag;
}
@NonNull
public NlModel getModel() {
return myModel;
}
public void setTag(@NonNull XmlTag tag) {
myTag = tag;
myTagName = tag.getName();
}
public void setSnapshot(@Nullable TagSnapshot snapshot) {
this.snapshot = snapshot;
}
public void setBounds(@AndroidCoordinate int x, @AndroidCoordinate int y, @AndroidCoordinate int w, @AndroidCoordinate int h) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
}
public void addChild(@NonNull NlComponent component) {
addChild(component, null);
}
public void addChild(@NonNull NlComponent component, @Nullable NlComponent before) {
if (children == null) {
children = Lists.newArrayList();
}
int index = before != null ? children.indexOf(before) : -1;
if (index != -1) {
children.add(index, component);
} else {
children.add(component);
}
component.setParent(this);
}
public void delete() {
NlComponent parent = getParent();
if (parent != null) {
parent.removeChild(this);
}
myTag.delete();
}
public void removeChild(@NonNull NlComponent component) {
if (children != null) {
children.remove(component);
}
component.setParent(null);
}
@NonNull
public Iterable<NlComponent> getChildren() {
return children != null ? children : Collections.<NlComponent>emptyList();
}
public int getChildCount() {
return children != null ? children.size() : 0;
}
@Nullable
public NlComponent getChild(int index) {
return children != null && index >= 0 && index < children.size() ? children.get(index) : null;
}
@Nullable
public NlComponent getNextSibling() {
if (myParent == null) {
return null;
}
for (int index = 0; index < myParent.getChildCount(); index++) {
if (myParent.getChild(index) == this) {
return myParent.getChild(index + 1);
}
}
return null;
}
@Nullable
public NlComponent findViewByTag(@NonNull XmlTag tag) {
if (myTag == tag) {
return this;
}
if (children != null) {
for (NlComponent child : children) {
NlComponent result = child.findViewByTag(tag);
if (result != null) {
return result;
}
}
}
return null;
}
@Nullable
public List<NlComponent> findViewsByTag(@NonNull XmlTag tag) {
List<NlComponent> result = null;
if (children != null) {
for (NlComponent child : children) {
List<NlComponent> matches = child.findViewsByTag(tag);
if (matches != null) {
if (result != null) {
result.addAll(matches);
} else {
result = matches;
}
}
}
}
if (myTag == tag) {
if (result == null) {
return Lists.newArrayList(this);
}
result.add(this);
}
return result;
}
@Nullable
public NlComponent findLeafAt(@AndroidCoordinate int px, @AndroidCoordinate int py) {
if (children != null) {
// Search BACKWARDS such that if the children are painted on top of each
// other (as is the case in a FrameLayout) I pick the last one which will
// be topmost!
for (int i = children.size() - 1; i >= 0; i--) {
NlComponent child = children.get(i);
NlComponent result = child.findLeafAt(px, py);
if (result != null) {
return result;
}
}
}
return (x <= px && y <= py && x + w >= px && y + h >= py) ? this : null;
}
public boolean isRoot() {
return !(myTag.getParent() instanceof XmlTag);
}
public NlComponent getRoot() {
NlComponent component = this;
while (component != null && !component.isRoot()) {
component = component.getParent();
}
return component;
}
public static String toTree(@NonNull List<NlComponent> roots) {
StringBuilder sb = new StringBuilder(200);
for (NlComponent root : roots) {
describe(sb, root, 0);
}
return sb.toString().trim();
}
private static void describe(@NonNull StringBuilder sb, @NonNull NlComponent component, int depth) {
for (int i = 0; i < depth; i++) {
sb.append(" ");
}
sb.append(describe(component));
sb.append('\n');
for (NlComponent child : component.getChildren()) {
describe(sb, child, depth + 1);
}
}
private static String describe(@NonNull NlComponent root) {
return Objects.toStringHelper(root).omitNullValues()
.add("tag", describe(root.myTag))
.add("bounds", "[" + root.x + "," + root.y + ":" + root.w + "x" + root.h)
.toString();
}
private static String describe(@Nullable XmlTag tag) {
if (tag == null) {
return "";
} else {
return '<' + tag.getName() + '>';
}
}
/** Returns the ID of this component */
@Nullable
public String getId() {
String id = getAttribute(ANDROID_URI, ATTR_ID);
if (id != null) {
if (id.startsWith(NEW_ID_PREFIX)) {
return id.substring(NEW_ID_PREFIX.length());
} else if (id.startsWith(ID_PREFIX)) {
return id.substring(ID_PREFIX.length());
}
}
return null;
}
/**
* Determines whether the given new component should have an id attribute.
* This is generally false for layouts, and generally true for other views,
* not including the {@code <include>} and {@code <merge>} tags. Note that
* {@code <fragment>} tags <b>should</b> specify an id.
*
* @return true if the component should have a default id
*/
public boolean needsDefaultId() {
if (myTagName.equals(VIEW_INCLUDE) || myTagName.equals(VIEW_MERGE) || myTagName.equals(SPACE) || myTagName.equals(REQUEST_FOCUS) ||
// Handle <Space> in the compatibility library package
(myTagName.endsWith(SPACE) && myTagName.length() > SPACE.length() && myTagName.charAt(myTagName.length() - SPACE.length()) == '.')) {
return false;
}
// Assign id's to ViewGroups like ListViews, but not to views like LinearLayout
ViewHandler viewHandler = getViewHandler();
if (viewHandler == null) {
if (myTagName.endsWith("Layout")) {
return false;
}
} else if (viewHandler instanceof ViewGroupHandler) {
return false;
}
return true;
}
/** Returns the ID, but also assigns a default id if the component does not already have an id (even if the component does
* not need one according to {@link #needsDefaultId()} */
public String ensureId() {
String id = getId();
if (id != null) {
return id;
}
return assignId();
}
public String assignId() {
Collection<String> idList = getIds(myModel.getFacet());
return assignId(this, idList);
}
public static String assignId(@NonNull NlComponent component, @NonNull Collection<String> idList) {
String idValue = StringUtil.decapitalize(component.getTagName());
Module module = component.getModel().getModule();
Project project = module.getProject();
idValue = ResourceHelper.prependResourcePrefix(module, idValue);
String nextIdValue = idValue;
int index = 0;
// Ensure that we don't create something like "switch" as an id, which won't compile when used
// in the R class
NamesValidator validator = LanguageNamesValidation.INSTANCE.forLanguage(JavaLanguage.INSTANCE);
while (idList.contains(nextIdValue) || validator != null && validator.isKeyword(nextIdValue, project)) {
++index;
if (index == 1 && (validator == null || !validator.isKeyword(nextIdValue, project))) {
nextIdValue = idValue;
} else {
nextIdValue = idValue + Integer.toString(index);
}
}
String newId = idValue + (index == 0 ? "" : Integer.toString(index));
component.setAttribute(ANDROID_URI, ATTR_ID, NEW_ID_PREFIX + newId);
return newId;
}
/** Looks up the existing set of id's reachable from the given module */
private static Collection<String> getIds(@NonNull AndroidFacet facet) {
AppResourceRepository resources = AppResourceRepository.getAppResources(facet, true);
return resources.getItemsOfType(ResourceType.ID);
}
public int getBaseline() {
try {
if (viewInfo != null) {
Object viewObject = viewInfo.getViewObject();
return (Integer)viewObject.getClass().getMethod("getBaseline").invoke(viewObject);
}
}
catch (Throwable ignore) {
}
return -1;
}
private Insets myMargins;
private Insets myPadding;
private static int fixDefault(int value) {
return value == Integer.MIN_VALUE ? 0 : value;
}
@NonNull
public Insets getMargins() {
if (myMargins == null) {
if (viewInfo == null) {
return Insets.NONE;
}
try {
Object layoutParams = viewInfo.getLayoutParamsObject();
Class<?> layoutClass = layoutParams.getClass();
int left = fixDefault(layoutClass.getField("leftMargin").getInt(layoutParams));
int top = fixDefault(layoutClass.getField("topMargin").getInt(layoutParams));
int right = fixDefault(layoutClass.getField("rightMargin").getInt(layoutParams));
int bottom = fixDefault(layoutClass.getField("bottomMargin").getInt(layoutParams));
// Doesn't look like we need to read startMargin and endMargin here;
// ViewGroup.MarginLayoutParams#doResolveMargins resolves and assigns values to the others
if (left == 0 && top == 0 && right == 0 && bottom == 0) {
myMargins = Insets.NONE;
} else {
myMargins = new Insets(left, top, right, bottom);
}
}
catch (Throwable e) {
myMargins = Insets.NONE;
}
}
return myMargins;
}
@NonNull
public Insets getPadding() {
if (myPadding == null) {
if (viewInfo == null) {
return Insets.NONE;
}
try {
Object layoutParams = viewInfo.getViewObject();
Class<?> layoutClass = layoutParams.getClass();
int left = fixDefault((Integer)layoutClass.getMethod("getPaddingLeft").invoke(layoutParams)); // TODO: getPaddingStart!
int top = fixDefault((Integer)layoutClass.getMethod("getPaddingTop").invoke(layoutParams));
int right = fixDefault((Integer)layoutClass.getMethod("getPaddingRight").invoke(layoutParams));
int bottom = fixDefault((Integer)layoutClass.getMethod("getPaddingBottom").invoke(layoutParams));
if (left == 0 && top == 0 && right == 0 && bottom == 0) {
myPadding = Insets.NONE;
} else {
myPadding = new Insets(left, top, right, bottom);
}
}
catch (Throwable e) {
myPadding = Insets.NONE;
}
}
return myPadding;
}
@Nullable
public NlComponent getParent() {
return myParent;
}
public void setParent(@Nullable NlComponent parent) {
myParent = parent;
}
@NonNull
public String getTagName() {
return myTagName;
}
@Override
public String toString() {
return describe(this);
}
/** Convenience wrapper for now; this should be replaced with property lookup */
public void setAttribute(@Nullable String namespace, @NonNull String attribute, @Nullable String value) {
// Handle validity
myTag.setAttribute(attribute, namespace, value);
if (snapshot != null) {
snapshot.setAttribute(attribute, namespace, null, value);
}
}
@Nullable
public String getAttribute(@Nullable String namespace, @NonNull String attribute) {
if (snapshot != null) {
return snapshot.getAttribute(attribute, namespace);
} else if (myTag.isValid()) {
return AndroidPsiUtils.getAttributeSafely(myTag, namespace, attribute);
} else {
// Newly created components for example
return null;
}
}
@NonNull
public List<AttributeSnapshot> getAttributes() {
if (snapshot != null) {
return snapshot.attributes;
}
if (myTag.isValid()) {
if (!ApplicationManager.getApplication().isReadAccessAllowed()) {
return ApplicationManager.getApplication().runReadAction(new Computable<List<AttributeSnapshot>>() {
@Override
public List<AttributeSnapshot> compute() {
return AttributeSnapshot.createAttributesForTag(myTag);
}
});
}
return AttributeSnapshot.createAttributesForTag(myTag);
}
return Collections.emptyList();
}
public String ensureNamespace(@NonNull String prefix, @NonNull String namespace) {
//todo: Merge with functionality in {@link SuppressLintIntentionAction#ensureNamespaceImported}
assert isRoot();
// Handle validity
String existingPrefix = myTag.getPrefixByNamespace(namespace);
if (existingPrefix != null) {
return existingPrefix;
}
if (myTag.getAttribute(XMLNS_PREFIX + prefix) != null) {
String base = prefix;
for (int i = 2; ; i++) {
prefix = base + Integer.toString(i);
if (myTag.getAttribute(XMLNS_PREFIX + prefix) == null) {
break;
}
}
}
myTag.setAttribute(XMLNS_PREFIX + prefix, namespace);
return prefix;
}
public boolean isShowing() {
return snapshot != null;
}
@Nullable
public ViewHandler getViewHandler() {
return ViewHandlerManager.get(myTag.getProject()).getHandler(this);
}
/**
* Creates a new child of the given type, and inserts it before the given sibling (or null to append at the end).
* Note: This operation can only be called when the caller is already holding a write lock. This will be the
* case from {@link ViewHandler} callbacks such as {@link ViewHandler#onCreate(ViewEditor, NlComponent, NlComponent, InsertType)}
* and {@link com.android.tools.idea.uibuilder.api.DragHandler#commit(int, int, int)}.
*
* @param editor The editor showing the component
* @param fqcn The fully qualified name of the widget to insert, such as {@code android.widget.LinearLayout}
* You can also pass XML tags here (this is typically the same as the fully qualified class name
* of the custom view, but for Android framework views in the android.view or android.widget packages,
* you can omit the package.)
* @param before The sibling to insert immediately before, or null to append
* @param insertType The type of insertion
*/
public NlComponent createChild(@NonNull ViewEditor editor,
@NonNull String fqcn,
@Nullable NlComponent before,
@NonNull InsertType insertType) {
return myModel.createComponent(((ViewEditorImpl)editor).getScreenView(), fqcn, this, before, insertType);
}
/**
* Returns true if views with the given fully qualified class name need to include
* their package in the layout XML tag
*
* @param fqcn the fully qualified class name, such as android.widget.Button
* @return true if the full package path should be included in the layout XML element
* tag
*/
private static boolean viewNeedsPackage(String fqcn) {
return !(fqcn.startsWith(ANDROID_WIDGET_PREFIX)
|| fqcn.startsWith(ANDROID_VIEW_PKG)
|| fqcn.startsWith(ANDROID_WEBKIT_PKG));
}
/**
* Maps a custom view class to the corresponding layout tag;
* e.g. {@code android.widget.LinearLayout} maps to just {@code LinearLayout}, but
* {@code android.support.v4.widget.DrawerLayout} maps to
* {@code android.support.v4.widget.DrawerLayout}.
*
* @param fqcn fully qualified class name
* @return the corresponding view tag
*/
@NonNull
public static String viewClassToTag(@NonNull String fqcn) {
if (!viewNeedsPackage(fqcn)) {
return fqcn.substring(fqcn.lastIndexOf('.') + 1);
}
return fqcn;
}
}