/*
* Copyright (C) 2010 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.common.layout;
import static com.android.ide.common.layout.LayoutConstants.ANDROID_URI;
import static com.android.ide.common.layout.LayoutConstants.ATTR_ID;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_MARGIN;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_MARGIN_BOTTOM;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_MARGIN_LEFT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_MARGIN_RIGHT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_MARGIN_TOP;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_WIDTH;
import static com.android.ide.common.layout.LayoutConstants.ATTR_TEXT;
import static com.android.ide.common.layout.LayoutConstants.VALUE_FILL_PARENT;
import static com.android.ide.common.layout.LayoutConstants.VALUE_MATCH_PARENT;
import static com.android.ide.common.layout.LayoutConstants.VALUE_WRAP_CONTENT;
import com.android.ide.common.api.DropFeedback;
import com.android.ide.common.api.IAttributeInfo;
import com.android.ide.common.api.IDragElement;
import com.android.ide.common.api.IGraphics;
import com.android.ide.common.api.IMenuCallback;
import com.android.ide.common.api.INode;
import com.android.ide.common.api.INodeHandler;
import com.android.ide.common.api.MenuAction;
import com.android.ide.common.api.Point;
import com.android.ide.common.api.Rect;
import com.android.ide.common.api.IAttributeInfo.Format;
import com.android.ide.common.api.IDragElement.IDragAttribute;
import com.android.ide.common.api.MenuAction.ChoiceProvider;
import com.android.util.Pair;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class BaseLayoutRule extends BaseViewRule {
private static final String ACTION_FILL_WIDTH = "_fillW"; //$NON-NLS-1$
private static final String ACTION_FILL_HEIGHT = "_fillH"; //$NON-NLS-1$
private static final String ACTION_MARGIN = "_margin"; //$NON-NLS-1$
private static final URL ICON_MARGINS =
BaseLayoutRule.class.getResource("margins.png"); //$NON-NLS-1$
private static final URL ICON_GRAVITY =
BaseLayoutRule.class.getResource("gravity.png"); //$NON-NLS-1$
private static final URL ICON_FILL_WIDTH =
BaseLayoutRule.class.getResource("fillwidth.png"); //$NON-NLS-1$
private static final URL ICON_FILL_HEIGHT =
BaseLayoutRule.class.getResource("fillheight.png"); //$NON-NLS-1$
// ==== Layout Actions support ====
// The Margin layout parameters are available for LinearLayout, FrameLayout, RelativeLayout,
// and their subclasses.
protected MenuAction createMarginAction(final INode parentNode,
final List<? extends INode> children) {
final List<? extends INode> targets = children == null || children.size() == 0 ?
Collections.singletonList(parentNode)
: children;
final INode first = targets.get(0);
IMenuCallback actionCallback = new IMenuCallback() {
public void action(MenuAction action, final String valueId, final Boolean newValue) {
parentNode.editXml("Change Margins", new INodeHandler() {
public void handle(INode n) {
String uri = ANDROID_URI;
String all = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN);
String left = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_LEFT);
String right = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_RIGHT);
String top = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_TOP);
String bottom = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_BOTTOM);
String[] margins = mRulesEngine.displayMarginInput(all, left,
right, top, bottom);
if (margins != null) {
assert margins.length == 5;
for (INode child : targets) {
child.setAttribute(uri, ATTR_LAYOUT_MARGIN, margins[0]);
child.setAttribute(uri, ATTR_LAYOUT_MARGIN_LEFT, margins[1]);
child.setAttribute(uri, ATTR_LAYOUT_MARGIN_RIGHT, margins[2]);
child.setAttribute(uri, ATTR_LAYOUT_MARGIN_TOP, margins[3]);
child.setAttribute(uri, ATTR_LAYOUT_MARGIN_BOTTOM, margins[4]);
}
}
}
});
}
};
return MenuAction.createAction(ACTION_MARGIN, "Change Margins...", null, actionCallback,
ICON_MARGINS, 40);
}
// Both LinearLayout and RelativeLayout have a gravity (but RelativeLayout applies it
// to the parent whereas for LinearLayout it's on the children)
protected MenuAction createGravityAction(final List<? extends INode> targets, final
String attributeName) {
if (targets != null && targets.size() > 0) {
final INode first = targets.get(0);
ChoiceProvider provider = new ChoiceProvider() {
public void addChoices(List<String> titles, List<URL> iconUrls,
List<String> ids) {
IAttributeInfo info = first.getAttributeInfo(ANDROID_URI, attributeName);
if (info != null) {
// Generate list of possible gravity value constants
assert IAttributeInfo.Format.FLAG.in(info.getFormats());
for (String name : info.getFlagValues()) {
titles.add(prettyName(name));
ids.add(name);
}
}
}
};
return MenuAction.createChoices("_gravity", "Change Gravity", //$NON-NLS-1$
null,
new PropertyCallback(targets, "Change Gravity", ANDROID_URI,
attributeName),
provider,
first.getStringAttr(ANDROID_URI, attributeName), ICON_GRAVITY,
43);
}
return null;
}
@Override
public void addLayoutActions(List<MenuAction> actions, final INode parentNode,
final List<? extends INode> children) {
super.addLayoutActions(actions, parentNode, children);
final List<? extends INode> targets = children == null || children.size() == 0 ?
Collections.singletonList(parentNode)
: children;
final INode first = targets.get(0);
// Shared action callback
IMenuCallback actionCallback = new IMenuCallback() {
public void action(MenuAction action, final String valueId, final Boolean newValue) {
final String actionId = action.getId();
final String undoLabel;
if (actionId.equals(ACTION_FILL_WIDTH)) {
undoLabel = "Change Width Fill";
} else if (actionId.equals(ACTION_FILL_HEIGHT)) {
undoLabel = "Change Height Fill";
} else {
return;
}
parentNode.editXml(undoLabel, new INodeHandler() {
public void handle(INode n) {
String attribute = actionId.equals(ACTION_FILL_WIDTH)
? ATTR_LAYOUT_WIDTH : ATTR_LAYOUT_HEIGHT;
String value;
if (newValue) {
if (supportsMatchParent()) {
value = VALUE_MATCH_PARENT;
} else {
value = VALUE_FILL_PARENT;
}
} else {
value = VALUE_WRAP_CONTENT;
}
for (INode child : targets) {
child.setAttribute(ANDROID_URI, attribute, value);
}
}
});
}
};
actions.add(MenuAction.createToggle(ACTION_FILL_WIDTH, "Toggle Fill Width",
isFilled(first, ATTR_LAYOUT_WIDTH), actionCallback, ICON_FILL_WIDTH, 10));
actions.add(MenuAction.createToggle(ACTION_FILL_HEIGHT, "Toggle Fill Height",
isFilled(first, ATTR_LAYOUT_HEIGHT), actionCallback, ICON_FILL_HEIGHT, 20));
}
// ==== Paste support ====
/**
* The default behavior for pasting in a layout is to simulate a drop in the
* top-left corner of the view.
* <p/>
* Note that we explicitly do not call super() here -- the BasView.onPaste
* will call onPasteBeforeChild() instead.
* <p/>
* Derived layouts should override this behavior if not appropriate.
*/
@Override
public void onPaste(INode targetNode, IDragElement[] elements) {
DropFeedback feedback = onDropEnter(targetNode, elements);
if (feedback != null) {
Point p = targetNode.getBounds().getTopLeft();
feedback = onDropMove(targetNode, elements, feedback, p);
if (feedback != null) {
onDropLeave(targetNode, elements, feedback);
onDropped(targetNode, elements, feedback, p);
}
}
}
/**
* The default behavior for pasting in a layout with a specific child target
* is to simulate a drop right above the top left of the given child target.
* <p/>
* This method is invoked by BaseView when onPaste() is called --
* views don't generally accept children and instead use the target node as
* a hint to paste "before" it.
*/
public void onPasteBeforeChild(INode parentNode, INode targetNode, IDragElement[] elements) {
DropFeedback feedback = onDropEnter(parentNode, elements);
if (feedback != null) {
Point parentP = parentNode.getBounds().getTopLeft();
Point targetP = targetNode.getBounds().getTopLeft();
if (parentP.y < targetP.y) {
targetP.y -= 1;
}
feedback = onDropMove(parentNode, elements, feedback, targetP);
if (feedback != null) {
onDropLeave(parentNode, elements, feedback);
onDropped(parentNode, elements, feedback, targetP);
}
}
}
// ==== Utility methods used by derived layouts ====
/**
* Draws the bounds of the given elements and all its children elements in
* the canvas with the specified offset.
*/
protected void drawElement(IGraphics gc, IDragElement element, int offsetX, int offsetY) {
Rect b = element.getBounds();
if (b.isValid()) {
b = b.copy().offsetBy(offsetX, offsetY);
gc.drawRect(b);
}
for (IDragElement inner : element.getInnerElements()) {
drawElement(gc, inner, offsetX, offsetY);
}
}
/**
* Collect all the "android:id" IDs from the dropped elements. When moving
* objects within the same canvas, that's all there is to do. However if the
* objects are moved to a different canvas or are copied then set
* createNewIds to true to find the existing IDs under targetNode and create
* a map with new non-conflicting unique IDs as needed. Returns a map String
* old-id => tuple (String new-id, String fqcn) where fqcn is the FQCN of
* the element.
*/
protected static Map<String, Pair<String, String>> getDropIdMap(INode targetNode,
IDragElement[] elements, boolean createNewIds) {
Map<String, Pair<String, String>> idMap = new HashMap<String, Pair<String, String>>();
if (createNewIds) {
collectIds(idMap, elements);
// Need to remap ids if necessary
idMap = remapIds(targetNode, idMap);
}
return idMap;
}
/**
* Fills idMap with a map String id => tuple (String id, String fqcn) where
* fqcn is the FQCN of the element (in case we want to generate new IDs
* based on the element type.)
*
* @see #getDropIdMap
*/
protected static Map<String, Pair<String, String>> collectIds(
Map<String, Pair<String, String>> idMap,
IDragElement[] elements) {
for (IDragElement element : elements) {
IDragAttribute attr = element.getAttribute(ANDROID_URI, ATTR_ID);
if (attr != null) {
String id = attr.getValue();
if (id != null && id.length() > 0) {
idMap.put(id, Pair.of(id, element.getFqcn()));
}
}
collectIds(idMap, element.getInnerElements());
}
return idMap;
}
/**
* Used by #getDropIdMap to find new IDs in case of conflict.
*/
protected static Map<String, Pair<String, String>> remapIds(INode node,
Map<String, Pair<String, String>> idMap) {
// Visit the document to get a list of existing ids
Set<String> existingIdSet = new HashSet<String>();
collectExistingIds(node.getRoot(), existingIdSet);
Map<String, Pair<String, String>> new_map = new HashMap<String, Pair<String, String>>();
for (Map.Entry<String, Pair<String, String>> entry : idMap.entrySet()) {
String key = entry.getKey();
Pair<String, String> value = entry.getValue();
String id = normalizeId(key);
if (!existingIdSet.contains(id)) {
// Not a conflict. Use as-is.
new_map.put(key, value);
if (!key.equals(id)) {
new_map.put(id, value);
}
} else {
// There is a conflict. Get a new id.
String new_id = findNewId(value.getSecond(), existingIdSet);
value = Pair.of(new_id, value.getSecond());
new_map.put(id, value);
new_map.put(id.replaceFirst("@\\+", "@"), value); //$NON-NLS-1$ //$NON-NLS-2$
}
}
return new_map;
}
/**
* Used by #remapIds to find a new ID for a conflicting element.
*/
protected static String findNewId(String fqcn, Set<String> existingIdSet) {
// Get the last component of the FQCN (e.g. "android.view.Button" =>
// "Button")
String name = fqcn.substring(fqcn.lastIndexOf('.') + 1);
for (int i = 1; i < 1000000; i++) {
String id = String.format("@+id/%s%02d", name, i); //$NON-NLS-1$
if (!existingIdSet.contains(id)) {
existingIdSet.add(id);
return id;
}
}
// We'll never reach here.
return null;
}
/**
* Used by #getDropIdMap to find existing IDs recursively.
*/
protected static void collectExistingIds(INode root, Set<String> existingIdSet) {
if (root == null) {
return;
}
String id = root.getStringAttr(ANDROID_URI, ATTR_ID);
if (id != null) {
id = normalizeId(id);
if (!existingIdSet.contains(id)) {
existingIdSet.add(id);
}
}
for (INode child : root.getChildren()) {
collectExistingIds(child, existingIdSet);
}
}
/**
* Transforms @id/name into @+id/name to treat both forms the same way.
*/
protected static String normalizeId(String id) {
if (id.indexOf("@+") == -1) { //$NON-NLS-1$
id = id.replaceFirst("@", "@+"); //$NON-NLS-1$ //$NON-NLS-2$
}
return id;
}
/**
* For use by {@link BaseLayoutRule#addAttributes} A filter should return a
* valid replacement string.
*/
protected static interface AttributeFilter {
String replace(String attributeUri, String attributeName, String attributeValue);
}
private static final String[] EXCLUDED_ATTRIBUTES = new String[] {
// from AbsoluteLayout
"layout_x", //$NON-NLS-1$
"layout_y", //$NON-NLS-1$
// from RelativeLayout
"layout_above", //$NON-NLS-1$
"layout_below", //$NON-NLS-1$
"layout_toLeftOf", //$NON-NLS-1$
"layout_toRightOf", //$NON-NLS-1$
"layout_alignBaseline", //$NON-NLS-1$
"layout_alignTop", //$NON-NLS-1$
"layout_alignBottom", //$NON-NLS-1$
"layout_alignLeft", //$NON-NLS-1$
"layout_alignRight", //$NON-NLS-1$
"layout_alignParentTop", //$NON-NLS-1$
"layout_alignParentBottom", //$NON-NLS-1$
"layout_alignParentLeft", //$NON-NLS-1$
"layout_alignParentRight", //$NON-NLS-1$
"layout_alignWithParentMissing", //$NON-NLS-1$
"layout_centerHorizontal", //$NON-NLS-1$
"layout_centerInParent", //$NON-NLS-1$
"layout_centerVertical", //$NON-NLS-1$
};
/**
* Default attribute filter used by the various layouts to filter out some properties
* we don't want to offer.
*/
public static final AttributeFilter DEFAULT_ATTR_FILTER = new AttributeFilter() {
Set<String> mExcludes;
public String replace(String uri, String name, String value) {
if (!ANDROID_URI.equals(uri)) {
return value;
}
if (mExcludes == null) {
mExcludes = new HashSet<String>(EXCLUDED_ATTRIBUTES.length);
mExcludes.addAll(Arrays.asList(EXCLUDED_ATTRIBUTES));
}
return mExcludes.contains(name) ? null : value;
}
};
/**
* Copies all the attributes from oldElement to newNode. Uses the idMap to
* transform the value of all attributes of Format.REFERENCE. If filter is
* non-null, it's a filter that can rewrite the attribute string.
*/
protected static void addAttributes(INode newNode, IDragElement oldElement,
Map<String, Pair<String, String>> idMap, AttributeFilter filter) {
// A little trick here: when creating new UI widgets by dropping them
// from the palette, we assign them a new id and then set the text
// attribute to that id, so for example a Button will have
// android:text="@+id/Button01".
// Here we detect if such an id is being remapped to a new id and if
// there's a text attribute with exactly the same id name, we update it
// too.
String oldText = null;
String oldId = null;
String newId = null;
for (IDragAttribute attr : oldElement.getAttributes()) {
String uri = attr.getUri();
String name = attr.getName();
String value = attr.getValue();
if (uri.equals(ANDROID_URI)) {
if (name.equals(ATTR_ID)) {
oldId = value;
} else if (name.equals(ATTR_TEXT)) {
oldText = value;
}
}
IAttributeInfo attrInfo = newNode.getAttributeInfo(uri, name);
if (attrInfo != null) {
Format[] formats = attrInfo.getFormats();
if (IAttributeInfo.Format.REFERENCE.in(formats)) {
if (idMap.containsKey(value)) {
value = idMap.get(value).getFirst();
}
}
}
if (filter != null) {
value = filter.replace(uri, name, value);
}
if (value != null && value.length() > 0) {
newNode.setAttribute(uri, name, value);
if (uri.equals(ANDROID_URI) && name.equals(ATTR_ID) &&
oldId != null && !oldId.equals(value)) {
newId = value;
}
}
}
if (newId != null && oldText != null && oldText.equals(oldId)) {
newNode.setAttribute(ANDROID_URI, ATTR_TEXT, newId);
}
}
/**
* Adds all the children elements of oldElement to newNode, recursively.
* Attributes are adjusted by calling addAttributes with idMap as necessary,
* with no closure filter.
*/
protected static void addInnerElements(INode newNode, IDragElement oldElement,
Map<String, Pair<String, String>> idMap) {
for (IDragElement element : oldElement.getInnerElements()) {
String fqcn = element.getFqcn();
INode childNode = newNode.appendChild(fqcn);
addAttributes(childNode, element, idMap, null /* filter */);
addInnerElements(childNode, element, idMap);
}
}
/**
* Insert the given elements into the given node at the given position
*
* @param targetNode the node to insert into
* @param elements the elements to insert
* @param createNewIds if true, generate new ids when there is a conflict
* @param initialInsertPos index among targetnode's children which to insert the
* children
*/
public static void insertAt(final INode targetNode, final IDragElement[] elements,
final boolean createNewIds, final int initialInsertPos) {
// Collect IDs from dropped elements and remap them to new IDs
// if this is a copy or from a different canvas.
final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements,
createNewIds);
targetNode.editXml("Insert Elements", new INodeHandler() {
public void handle(INode node) {
// Now write the new elements.
int insertPos = initialInsertPos;
for (IDragElement element : elements) {
String fqcn = element.getFqcn();
INode newChild = targetNode.insertChildAt(fqcn, insertPos);
// insertPos==-1 means to insert at the end. Otherwise
// increment the insertion position.
if (insertPos >= 0) {
insertPos++;
}
// Copy all the attributes, modifying them as needed.
addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER);
addInnerElements(newChild, element, idMap);
}
}
});
}
}