/* * 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_WIDTH; import static com.android.ide.common.layout.LayoutConstants.ATTR_TEXT; import static com.android.ide.common.layout.LayoutConstants.ID_PREFIX; import static com.android.ide.common.layout.LayoutConstants.NEW_ID_PREFIX; 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.IClientRulesEngine; import com.android.ide.common.api.IDragElement; 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.IValidator; import com.android.ide.common.api.IViewRule; import com.android.ide.common.api.InsertType; import com.android.ide.common.api.MenuAction; import com.android.ide.common.api.Point; import com.android.ide.common.api.IAttributeInfo.Format; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * Common IViewRule processing to all view and layout classes. */ public class BaseViewRule implements IViewRule { // Strings used as internal ids, group ids and prefixes for actions private static final String FALSE_ID = "2f"; //$NON-NLS-1$ private static final String TRUE_ID = "1t"; //$NON-NLS-1$ private static final String PROP_PREFIX = "@prop@"; //$NON-NLS-1$ private static final String SEPARATOR_ID = "~1sep"; //$NON-NLS-1$ private static final String DEFAULT_ID = "~2clr"; //$NON-NLS-1$ private static final String PROPERTIES_ID = "properties"; //$NON-NLS-1$ private static final String EDIT_TEXT_ID = "edittext"; //$NON-NLS-1$ private static final String EDIT_ID_ID = "editid"; //$NON-NLS-1$ private static final String WIDTH_ID = "layout_1width"; //$NON-NLS-1$ private static final String HEIGHT_ID = "layout_2height"; //$NON-NLS-1$ private static final String ZCUSTOM = "zcustom"; //$NON-NLS-1$ protected IClientRulesEngine mRulesEngine; // Cache of attributes. Key is FQCN of a node mixed with its view hierarchy // parent. Values are a custom map as needed by getContextMenu. private Map<String, Map<String, Prop>> mAttributesMap = new HashMap<String, Map<String, Prop>>(); public boolean onInitialize(String fqcn, IClientRulesEngine engine) { this.mRulesEngine = engine; // This base rule can handle any class so we don't need to filter on // FQCN. Derived classes should do so if they can handle some // subclasses. // If onInitialize returns false, it means it can't handle the given // FQCN and will be unloaded. return true; } public void onDispose() { // Nothing to dispose. } public String getDisplayName() { // Default is to not override the selection display name. return null; } // === Context Menu === /** * Generate custom actions for the context menu: <br/> * - Explicit layout_width and layout_height attributes. * - List of all other simple toggle attributes. */ public List<MenuAction> getContextMenu(final INode selectedNode) { // Compute the key for mAttributesMap. This depends on the type of this // node and its parent in the view hierarchy. StringBuilder keySb = new StringBuilder(); keySb.append(selectedNode.getFqcn()); keySb.append('_'); INode parent = selectedNode.getParent(); if (parent != null) { keySb.append(parent.getFqcn()); } final String key = keySb.toString(); String custom_w = null; String curr_w = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH); String fillParent = getFillParentValueName(); boolean canMatchParent = supportsMatchParent(); if (canMatchParent && VALUE_FILL_PARENT.equals(curr_w)) { curr_w = VALUE_MATCH_PARENT; } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(curr_w)) { curr_w = VALUE_FILL_PARENT; } else if (!VALUE_WRAP_CONTENT.equals(curr_w) && !fillParent.equals(curr_w)) { custom_w = curr_w; } String custom_h = null; String curr_h = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT); if (canMatchParent && VALUE_FILL_PARENT.equals(curr_h)) { curr_h = VALUE_MATCH_PARENT; } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(curr_h)) { curr_h = VALUE_FILL_PARENT; } else if (!VALUE_WRAP_CONTENT.equals(curr_h) && !fillParent.equals(curr_h)) { custom_h = curr_h; } final String customWidth = custom_w; final String customHeight = custom_h; IMenuCallback onChange = new IMenuCallback() { public void action( final MenuAction action, final String valueId, final Boolean newValue) { String fullActionId = action.getId(); boolean isProp = fullActionId.startsWith(PROP_PREFIX); final String actionId = isProp ? fullActionId.substring(PROP_PREFIX.length()) : fullActionId; final INode node = selectedNode; if (fullActionId.equals(WIDTH_ID)) { final String newAttrValue = getValue(valueId, customWidth); if (newAttrValue != null) { node.editXml("Change Attribute " + ATTR_LAYOUT_WIDTH, new PropertySettingNodeHandler(ANDROID_URI, ATTR_LAYOUT_WIDTH, newAttrValue)); } return; } else if (fullActionId.equals(HEIGHT_ID)) { // Ask the user final String newAttrValue = getValue(valueId, customHeight); if (newAttrValue != null) { node.editXml("Change Attribute " + ATTR_LAYOUT_HEIGHT, new PropertySettingNodeHandler(ANDROID_URI, ATTR_LAYOUT_HEIGHT, newAttrValue)); } return; } else if (fullActionId.equals(EDIT_ID_ID)) { // Strip off the @id prefix stuff String oldId = node.getStringAttr(ANDROID_URI, ATTR_ID); oldId = stripIdPrefix(ensureValidString(oldId)); IValidator validator = mRulesEngine.getResourceValidator(); String newId = mRulesEngine.displayInput("New Id:", oldId, validator); if (newId != null && newId.trim().length() > 0) { if (!newId.startsWith(NEW_ID_PREFIX)) { newId = NEW_ID_PREFIX + stripIdPrefix(newId); } node.editXml("Change ID", new PropertySettingNodeHandler(ANDROID_URI, ATTR_ID, newId)); } } else if (fullActionId.equals(EDIT_TEXT_ID)) { String oldText = node.getStringAttr(ANDROID_URI, ATTR_TEXT); oldText = ensureValidString(oldText); String newText = mRulesEngine.displayResourceInput("string", oldText); //$NON-NLS-1$ if (newText != null) { node.editXml("Change Text", new PropertySettingNodeHandler(ANDROID_URI, ATTR_TEXT, newText)); } } if (isProp) { Map<String, Prop> props = mAttributesMap.get(key); final Prop prop = (props != null) ? props.get(actionId) : null; if (prop != null) { // For custom values (requiring an input dialog) input the // value outside the undo-block final String customValue = prop.isStringEdit() ? inputAttributeValue(node, actionId) : null; node.editXml("Change Attribute " + actionId, new INodeHandler() { public void handle(INode n) { if (prop.isToggle()) { // case of toggle String value = ""; //$NON-NLS-1$ if (valueId.equals(TRUE_ID)) { value = newValue ? "true" : ""; //$NON-NLS-1$ //$NON-NLS-2$ } else if (valueId.equals(FALSE_ID)) { value = newValue ? "false" : "";//$NON-NLS-1$ //$NON-NLS-2$ } n.setAttribute(ANDROID_URI, actionId, value); } else if (prop.isFlag()) { // case of a flag String values = ""; //$NON-NLS-1$ if (!valueId.equals(DEFAULT_ID)) { values = n.getStringAttr(ANDROID_URI, actionId); Set<String> newValues = new HashSet<String>(); if (values != null) { newValues.addAll(Arrays.asList( values.split("\\|"))); //$NON-NLS-1$ } if (newValue) { newValues.add(valueId); } else { newValues.remove(valueId); } values = join('|', newValues); } n.setAttribute(ANDROID_URI, actionId, values); } else if (prop.isEnum()) { // case of an enum String value = ""; //$NON-NLS-1$ if (!valueId.equals(DEFAULT_ID)) { value = newValue ? valueId : ""; //$NON-NLS-1$ } n.setAttribute(ANDROID_URI, actionId, value); } else { assert prop.isStringEdit(); // We've already received the value outside the undo block if (customValue != null) { n.setAttribute(ANDROID_URI, actionId, customValue); } } } }); } } } /** * Input the custom value for the given attribute. This will use the Reference * Chooser if it is a reference value, otherwise a plain text editor. */ private String inputAttributeValue(final INode node, final String attribute) { String oldValue = node.getStringAttr(ANDROID_URI, attribute); oldValue = ensureValidString(oldValue); IAttributeInfo attributeInfo = node.getAttributeInfo(ANDROID_URI, attribute); if (attributeInfo != null && IAttributeInfo.Format.REFERENCE.in(attributeInfo.getFormats())) { return mRulesEngine.displayReferenceInput(oldValue); } else { // A single resource type? If so use a resource chooser initialized // to this specific type /* This does not work well, because the metadata is a bit misleading: * for example a Button's "text" property and a Button's "onClick" property * both claim to be of type [string], but @string/ is NOT valid for * onClick.. if (attributeInfo != null && attributeInfo.getFormats().length == 1) { // Resource chooser Format format = attributeInfo.getFormats()[0]; return mRulesEngine.displayResourceInput(format.name(), oldValue); } */ // Fallback: just edit the raw XML string String message = String.format("New %1$s Value:", attribute); return mRulesEngine.displayInput(message, oldValue, null); } } /** * Returns the value (which will ask the user if the value is the special * {@link #ZCUSTOM} marker */ private String getValue(String valueId, String defaultValue) { if (valueId.equals(ZCUSTOM)) { if (defaultValue == null) { defaultValue = ""; } String value = mRulesEngine.displayInput( "Set custom layout attribute value (example: 50dip)", defaultValue, null); if (value != null && value.trim().length() > 0) { return value.trim(); } else { return null; } } return valueId; } }; MenuAction.Action editText = null; IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); if (textAttribute != null) { editText = new MenuAction.Action(EDIT_TEXT_ID, "Edit Text...", null, onChange); } List<MenuAction> list1 = Arrays.asList(new MenuAction[] { editText, // could be null - will be ignored by menu creation code new MenuAction.Action(EDIT_ID_ID, "Edit ID...", null, onChange), new MenuAction.Choices(WIDTH_ID, "Layout Width", mapify( VALUE_WRAP_CONTENT, "Wrap Content", canMatchParent ? VALUE_MATCH_PARENT : VALUE_FILL_PARENT, canMatchParent ? "Match Parent" : "Fill Parent", custom_w, custom_w, ZCUSTOM, "Other..." ), curr_w, onChange ), new MenuAction.Choices(HEIGHT_ID, "Layout Height", mapify( VALUE_WRAP_CONTENT, "Wrap Content", canMatchParent ? VALUE_MATCH_PARENT : VALUE_FILL_PARENT, canMatchParent ? "Match Parent" : "Fill Parent", custom_h, custom_h, ZCUSTOM, "Other..." ), curr_h, onChange ), new MenuAction.Group(PROPERTIES_ID, "Properties") }); // Prepare a list of all simple properties. Map<String, Prop> props = mAttributesMap.get(key); if (props == null) { // Prepare the property map props = new HashMap<String, Prop>(); for (IAttributeInfo attrInfo : selectedNode.getDeclaredAttributes()) { String id = attrInfo != null ? attrInfo.getName() : null; if (id == null || id.equals(ATTR_LAYOUT_WIDTH) || id.equals(ATTR_LAYOUT_HEIGHT)) { // Layout width/height are already handled at the root level continue; } Format[] formats = attrInfo != null ? attrInfo.getFormats() : null; if (formats == null) { continue; } String title = prettyName(id); if (IAttributeInfo.Format.BOOLEAN.in(formats)) { props.put(id, new Prop(title, true)); } else if (IAttributeInfo.Format.ENUM.in(formats)) { // Convert each enum into a map id=>title Map<String, String> values = new HashMap<String, String>(); if (attrInfo != null) { for (String e : attrInfo.getEnumValues()) { values.put(e, prettyName(e)); } } props.put(id, new Prop(title, false, false, values)); } else if (IAttributeInfo.Format.FLAG.in(formats)) { // Convert each flag into a map id=>title Map<String, String> values = new HashMap<String, String>(); if (attrInfo != null) { for (String e : attrInfo.getFlagValues()) { values.put(e, prettyName(e)); } } props.put(id, new Prop(title, false, true, values)); } else { props.put(id, new Prop(title + "...", false)); } } mAttributesMap.put(key, props); } List<MenuAction> list2 = new ArrayList<MenuAction>(); for (Map.Entry<String, Prop> entry : props.entrySet()) { String id = entry.getKey(); Prop p = entry.getValue(); MenuAction a = null; if (p.isToggle()) { // Toggles are handled as a multiple-choice between true, false // and nothing (clear) String value = selectedNode.getStringAttr(ANDROID_URI, id); if (value != null) value = value.toLowerCase(); if ("true".equals(value)) { //$NON-NLS-1$ value = TRUE_ID; } else if ("false".equals(value)) { //$NON-NLS-1$ value = FALSE_ID; } else { value = "4clr"; //$NON-NLS-1$ } a = new MenuAction.Choices( PROP_PREFIX + id, p.getTitle(), mapify( TRUE_ID, "True", FALSE_ID, "False", "3sep", MenuAction.Choices.SEPARATOR, //$NON-NLS-1$ "4clr", "Default"), //$NON-NLS-1$ value, PROPERTIES_ID, onChange); } else if (p.getChoices() != null) { // Enum or flags. Their possible values are the multiple-choice // items, with an extra "clear" option to remove everything. String current = selectedNode.getStringAttr(ANDROID_URI, id); if (current == null || current.length() == 0) { current = DEFAULT_ID; } a = new MenuAction.Choices( PROP_PREFIX + id, p.getTitle(), concatenate( p.getChoices(), mapify( SEPARATOR_ID, MenuAction.Choices.SEPARATOR, DEFAULT_ID, "Default" ) ), current, PROPERTIES_ID, onChange); } else { a = new MenuAction.Action( PROP_PREFIX + id, p.getTitle(), PROPERTIES_ID, onChange); } list2.add(a); } return concatenate(list1, list2); } /** * Returns true if the given node is "filled" (e.g. has layout width set to match * parent or fill parent */ protected boolean isFilled(INode node, String attribute) { String value = node.getStringAttr(ANDROID_URI, attribute); return VALUE_MATCH_PARENT.equals(value) || VALUE_FILL_PARENT.equals(value); } /** * Returns fill_parent or match_parent, depending on whether the minimum supported * platform supports match_parent or not * * @return match_parent or fill_parent depending on which is supported by the project */ protected String getFillParentValueName() { return supportsMatchParent() ? VALUE_MATCH_PARENT : VALUE_FILL_PARENT; } /** * Returns true if the project supports match_parent instead of just fill_parent * * @return true if the project supports match_parent instead of just fill_parent */ protected boolean supportsMatchParent() { // fill_parent was renamed match_parent in API level 8 return mRulesEngine.getMinApiLevel() >= 8; } /** Join strings into a single string with the given delimiter */ static String join(char delimiter, Collection<String> strings) { StringBuilder sb = new StringBuilder(100); for (String s : strings) { if (sb.length() > 0) { sb.append(delimiter); } sb.append(s); } return sb.toString(); } // Concatenate two menu action lists. Move these utilities into MenuAction static List<MenuAction> concatenate(List<MenuAction> pre, List<MenuAction> post) { List<MenuAction> result = new ArrayList<MenuAction>(pre.size() + post.size()); result.addAll(pre); result.addAll(post); return result; } static List<MenuAction> concatenate(List<MenuAction> pre, MenuAction post) { List<MenuAction> result = new ArrayList<MenuAction>(pre.size() + 1); result.addAll(pre); result.add(post); return result; } static Map<String, String> concatenate(Map<String, String> pre, Map<String, String> post) { Map<String, String> result = new HashMap<String, String>(pre.size() + post.size()); result.putAll(pre); result.putAll(post); return result; } // Quick utility for building up maps declaratively to minimize the diffs static Map<String, String> mapify(String... values) { Map<String, String> map = new HashMap<String, String>(values.length / 2); for (int i = 0; i < values.length; i += 2) { String key = values[i]; if (key == null) { continue; } String value = values[i + 1]; map.put(key, value); } return map; } public static String prettyName(String name) { if (name != null && name.length() > 0) { name = Character.toUpperCase(name.charAt(0)) + name.substring(1).replace('_', ' '); } return name; } // ==== Selection ==== public List<String> getSelectionHint(INode parentNode, INode childNode) { return null; } public void addLayoutActions(List<MenuAction> actions, INode parentNode, List<? extends INode> children) { } // ==== Drag'n'drop support ==== // By default Views do not accept drag'n'drop. public DropFeedback onDropEnter(INode targetNode, IDragElement[] elements) { return null; } public DropFeedback onDropMove(INode targetNode, IDragElement[] elements, DropFeedback feedback, Point p) { return null; } public void onDropLeave(INode targetNode, IDragElement[] elements, DropFeedback feedback) { // ignore } public void onDropped( INode targetNode, IDragElement[] elements, DropFeedback feedback, Point p) { // ignore } // ==== Paste support ==== /** * Most views can't accept children so there's nothing to paste on them. In * this case, defer the call to the parent layout and use the target node as * an indication of where to paste. */ public void onPaste(INode targetNode, IDragElement[] elements) { // INode parent = targetNode.getParent(); if (parent != null) { String parentFqcn = parent.getFqcn(); IViewRule parentRule = mRulesEngine.loadRule(parentFqcn); if (parentRule instanceof BaseLayoutRule) { ((BaseLayoutRule) parentRule).onPasteBeforeChild(parent, targetNode, elements); } } } /** * Support class for the context menu code. Stores state about properties in * the context menu. */ private static class Prop { private final boolean mToggle; private final boolean mFlag; private final String mTitle; private final Map<String, String> mChoices; public Prop(String title, boolean isToggle, boolean isFlag, Map<String, String> choices) { this.mTitle = title; this.mToggle = isToggle; this.mFlag = isFlag; this.mChoices = choices; } public Prop(String title, boolean isToggle) { this(title, isToggle, false, null); } private boolean isToggle() { return mToggle; } private boolean isFlag() { return mFlag && mChoices != null; } private boolean isEnum() { return !mFlag && mChoices != null; } private String getTitle() { return mTitle; } private Map<String, String> getChoices() { return mChoices; } private boolean isStringEdit() { return mChoices == null && !mToggle; } } /** * Returns a source attribute value which points to a sample image. This is typically * used to provide an initial image shown on ImageButtons, etc. There is no guarantee * that the source pointed to by this method actually exists. * * @return a source attribute to use for sample images, never null */ protected String getSampleImageSrc() { // For now, we point to the sample icon which is written into new Android projects // created in ADT. We could alternatively look into the project resources folder // and try to pick something else, or even return some builtin image resource // in the @android namespace. return "@drawable/icon"; //$NON-NLS-1$ } public void onCreate(INode node, INode parent, InsertType insertType) { } public void onChildInserted(INode node, INode parent, InsertType insertType) { } private static String stripIdPrefix(String id) { if (id.startsWith(NEW_ID_PREFIX)) { id = id.substring(NEW_ID_PREFIX.length()); } else if (id.startsWith(ID_PREFIX)) { id = id.substring(ID_PREFIX.length()); } return id; } private static String ensureValidString(String value) { if (value == null) { value = ""; //$NON-NLS-1$ } return value; } private static class PropertySettingNodeHandler implements INodeHandler { private final String mNamespaceUri; private final String mAttribute; private final String mValue; public PropertySettingNodeHandler(String namespaceUri, String attribute, String value) { super(); mNamespaceUri = namespaceUri; mAttribute = attribute; mValue = value; } public void handle(INode node) { node.setAttribute(mNamespaceUri, mAttribute, mValue); } } }