| /* |
| * 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.SdkConstants.ANDROID_URI; |
| import static com.android.SdkConstants.ATTR_CLASS; |
| import static com.android.SdkConstants.ATTR_HINT; |
| import static com.android.SdkConstants.ATTR_ID; |
| import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; |
| import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; |
| import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; |
| import static com.android.SdkConstants.ATTR_STYLE; |
| import static com.android.SdkConstants.ATTR_TEXT; |
| import static com.android.SdkConstants.DOT_LAYOUT_PARAMS; |
| import static com.android.SdkConstants.ID_PREFIX; |
| import static com.android.SdkConstants.NEW_ID_PREFIX; |
| import static com.android.SdkConstants.VALUE_FALSE; |
| import static com.android.SdkConstants.VALUE_FILL_PARENT; |
| import static com.android.SdkConstants.VALUE_MATCH_PARENT; |
| import static com.android.SdkConstants.VALUE_TRUE; |
| import static com.android.SdkConstants.VALUE_WRAP_CONTENT; |
| import static com.android.SdkConstants.VIEW_FRAGMENT; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.ide.common.api.AbstractViewRule; |
| import com.android.ide.common.api.IAttributeInfo; |
| import com.android.ide.common.api.IAttributeInfo.Format; |
| 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.IViewMetadata; |
| import com.android.ide.common.api.IViewRule; |
| import com.android.ide.common.api.RuleAction; |
| import com.android.ide.common.api.RuleAction.ActionProvider; |
| import com.android.ide.common.api.RuleAction.ChoiceProvider; |
| import com.android.resources.ResourceType; |
| import com.android.utils.Pair; |
| |
| import java.net.URL; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.EnumSet; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Map.Entry; |
| import java.util.Set; |
| |
| /** |
| * Common IViewRule processing to all view and layout classes. |
| */ |
| public class BaseViewRule extends AbstractViewRule { |
| /** List of recently edited properties */ |
| private static List<String> sRecent = new LinkedList<String>(); |
| |
| /** Maximum number of recent properties to track and list */ |
| private final static int MAX_RECENT_COUNT = 12; |
| |
| // Strings used as internal ids, group ids and prefixes for actions |
| private static final String FALSE_ID = "false"; //$NON-NLS-1$ |
| private static final String TRUE_ID = "true"; //$NON-NLS-1$ |
| private static final String PROP_PREFIX = "@prop@"; //$NON-NLS-1$ |
| private static final String CLEAR_ID = "clear"; //$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>>(); |
| |
| @Override |
| public boolean onInitialize(@NonNull String fqcn, @NonNull IClientRulesEngine engine) { |
| 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; |
| } |
| |
| /** |
| * Returns the {@link IClientRulesEngine} associated with this {@link IViewRule} |
| * |
| * @return the {@link IClientRulesEngine} associated with this {@link IViewRule} |
| */ |
| public IClientRulesEngine getRulesEngine() { |
| return mRulesEngine; |
| } |
| |
| // === Context Menu === |
| |
| /** |
| * Generate custom actions for the context menu: <br/> |
| * - Explicit layout_width and layout_height attributes. |
| * - List of all other simple toggle attributes. |
| */ |
| @Override |
| public void addContextMenuActions(@NonNull List<RuleAction> actions, |
| final @NonNull INode selectedNode) { |
| String width = null; |
| String currentWidth = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH); |
| |
| String fillParent = getFillParentValueName(); |
| boolean canMatchParent = supportsMatchParent(); |
| if (canMatchParent && VALUE_FILL_PARENT.equals(currentWidth)) { |
| currentWidth = VALUE_MATCH_PARENT; |
| } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentWidth)) { |
| currentWidth = VALUE_FILL_PARENT; |
| } else if (!VALUE_WRAP_CONTENT.equals(currentWidth) && !fillParent.equals(currentWidth)) { |
| width = currentWidth; |
| } |
| |
| String height = null; |
| String currentHeight = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT); |
| |
| if (canMatchParent && VALUE_FILL_PARENT.equals(currentHeight)) { |
| currentHeight = VALUE_MATCH_PARENT; |
| } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentHeight)) { |
| currentHeight = VALUE_FILL_PARENT; |
| } else if (!VALUE_WRAP_CONTENT.equals(currentHeight) |
| && !fillParent.equals(currentHeight)) { |
| height = currentHeight; |
| } |
| final String newWidth = width; |
| final String newHeight = height; |
| |
| final IMenuCallback onChange = new IMenuCallback() { |
| @Override |
| public void action( |
| final @NonNull RuleAction action, |
| final @NonNull List<? extends INode> selectedNodes, |
| final @Nullable String valueId, final @Nullable Boolean newValue) { |
| String fullActionId = action.getId(); |
| boolean isProp = fullActionId.startsWith(PROP_PREFIX); |
| final String actionId = isProp ? |
| fullActionId.substring(PROP_PREFIX.length()) : fullActionId; |
| |
| if (fullActionId.equals(ATTR_LAYOUT_WIDTH)) { |
| final String newAttrValue = getValue(valueId, newWidth); |
| if (newAttrValue != null) { |
| for (INode node : selectedNodes) { |
| node.editXml("Change Attribute " + ATTR_LAYOUT_WIDTH, |
| new PropertySettingNodeHandler(ANDROID_URI, |
| ATTR_LAYOUT_WIDTH, newAttrValue)); |
| } |
| editedProperty(ATTR_LAYOUT_WIDTH); |
| } |
| return; |
| } else if (fullActionId.equals(ATTR_LAYOUT_HEIGHT)) { |
| // Ask the user |
| final String newAttrValue = getValue(valueId, newHeight); |
| if (newAttrValue != null) { |
| for (INode node : selectedNodes) { |
| node.editXml("Change Attribute " + ATTR_LAYOUT_HEIGHT, |
| new PropertySettingNodeHandler(ANDROID_URI, |
| ATTR_LAYOUT_HEIGHT, newAttrValue)); |
| } |
| editedProperty(ATTR_LAYOUT_HEIGHT); |
| } |
| return; |
| } else if (fullActionId.equals(ATTR_ID)) { |
| // Ids must be set individually so open the id dialog for each |
| // selected node (though allow cancel to break the loop) |
| for (INode node : selectedNodes) { |
| if (!mRulesEngine.rename(node)) { |
| break; |
| } |
| } |
| editedProperty(ATTR_ID); |
| return; |
| } else if (isProp) { |
| INode firstNode = selectedNodes.get(0); |
| String key = getPropertyMapKey(selectedNode); |
| Map<String, Prop> props = mAttributesMap.get(key); |
| final Prop prop = (props != null) ? props.get(actionId) : null; |
| |
| if (prop != null) { |
| editedProperty(actionId); |
| |
| // For custom values (requiring an input dialog) input the |
| // value outside the undo-block. |
| // Input the value as a text, unless we know it's the "text" or |
| // "style" attributes (where we know we want to ask for specific |
| // resource types). |
| String uri = ANDROID_URI; |
| String v = null; |
| if (prop.isStringEdit()) { |
| boolean isStyle = actionId.equals(ATTR_STYLE); |
| boolean isText = actionId.equals(ATTR_TEXT); |
| boolean isHint = actionId.equals(ATTR_HINT); |
| if (isStyle || isText || isHint) { |
| String resourceTypeName = isStyle |
| ? ResourceType.STYLE.getName() |
| : ResourceType.STRING.getName(); |
| String oldValue = selectedNodes.size() == 1 |
| ? (isStyle ? firstNode.getStringAttr(ATTR_STYLE, actionId) |
| : firstNode.getStringAttr(ANDROID_URI, actionId)) |
| : ""; //$NON-NLS-1$ |
| oldValue = ensureValidString(oldValue); |
| v = mRulesEngine.displayResourceInput(resourceTypeName, oldValue); |
| if (isStyle) { |
| uri = null; |
| } |
| } else if (actionId.equals(ATTR_CLASS) && selectedNodes.size() >= 1 && |
| VIEW_FRAGMENT.equals(selectedNodes.get(0).getFqcn())) { |
| v = mRulesEngine.displayFragmentSourceInput(); |
| uri = null; |
| } else { |
| v = inputAttributeValue(firstNode, actionId); |
| } |
| } |
| final String customValue = v; |
| |
| for (INode n : selectedNodes) { |
| 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(uri, actionId, value); |
| } else if (prop.isFlag()) { |
| // case of a flag |
| String values = ""; //$NON-NLS-1$ |
| if (!valueId.equals(CLEAR_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); |
| } |
| |
| List<String> sorted = new ArrayList<String>(newValues); |
| Collections.sort(sorted); |
| values = join('|', sorted); |
| |
| // Special case |
| if (valueId.equals("normal")) { //$NON-NLS-1$ |
| // For textStyle for example, if you have "bold|italic" |
| // and you select the "normal" property, this should |
| // not behave in the normal flag way and "or" itself in; |
| // it should replace the other two. |
| // This also applies to imeOptions. |
| values = valueId; |
| } |
| } |
| n.setAttribute(uri, actionId, values); |
| } else if (prop.isEnum()) { |
| // case of an enum |
| String value = ""; //$NON-NLS-1$ |
| if (!valueId.equals(CLEAR_ID)) { |
| value = newValue ? valueId : ""; //$NON-NLS-1$ |
| } |
| n.setAttribute(uri, actionId, value); |
| } else { |
| assert prop.isStringEdit(); |
| // We've already received the value outside the undo block |
| if (customValue != null) { |
| n.setAttribute(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 |
| && attributeInfo.getFormats().contains(Format.REFERENCE)) { |
| 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: 50dp)", |
| defaultValue, null); |
| if (value != null && value.trim().length() > 0) { |
| return value.trim(); |
| } else { |
| return null; |
| } |
| } |
| |
| return valueId; |
| } |
| }; |
| |
| IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); |
| if (textAttribute != null) { |
| actions.add(RuleAction.createAction(PROP_PREFIX + ATTR_TEXT, "Edit Text...", onChange, |
| null, 10, true)); |
| } |
| |
| String editIdLabel = selectedNode.getStringAttr(ANDROID_URI, ATTR_ID) != null ? |
| "Edit ID..." : "Assign ID..."; |
| actions.add(RuleAction.createAction(ATTR_ID, editIdLabel, onChange, null, 20, true)); |
| |
| addCommonPropertyActions(actions, selectedNode, onChange, 21); |
| |
| // Create width choice submenu |
| actions.add(RuleAction.createSeparator(32)); |
| List<Pair<String, String>> widthChoices = new ArrayList<Pair<String,String>>(4); |
| widthChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content")); |
| if (canMatchParent) { |
| widthChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent")); |
| } else { |
| widthChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent")); |
| } |
| if (width != null) { |
| widthChoices.add(Pair.of(width, width)); |
| } |
| widthChoices.add(Pair.of(ZCUSTOM, "Other...")); |
| actions.add(RuleAction.createChoices( |
| ATTR_LAYOUT_WIDTH, "Layout Width", |
| onChange, |
| null /* iconUrls */, |
| currentWidth, |
| null, 35, |
| true, // supportsMultipleNodes |
| widthChoices)); |
| |
| // Create height choice submenu |
| List<Pair<String, String>> heightChoices = new ArrayList<Pair<String,String>>(4); |
| heightChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content")); |
| if (canMatchParent) { |
| heightChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent")); |
| } else { |
| heightChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent")); |
| } |
| if (height != null) { |
| heightChoices.add(Pair.of(height, height)); |
| } |
| heightChoices.add(Pair.of(ZCUSTOM, "Other...")); |
| actions.add(RuleAction.createChoices( |
| ATTR_LAYOUT_HEIGHT, "Layout Height", |
| onChange, |
| null /* iconUrls */, |
| currentHeight, |
| null, 40, |
| true, |
| heightChoices)); |
| |
| actions.add(RuleAction.createSeparator(45)); |
| RuleAction properties = RuleAction.createChoices("properties", "Other Properties", //$NON-NLS-1$ |
| onChange /*callback*/, null /*icon*/, 50, |
| true /*supportsMultipleNodes*/, new ActionProvider() { |
| @Override |
| public @NonNull List<RuleAction> getNestedActions(@NonNull INode node) { |
| List<RuleAction> propertyActionTypes = new ArrayList<RuleAction>(); |
| propertyActionTypes.add(RuleAction.createChoices( |
| "recent", "Recent", //$NON-NLS-1$ |
| onChange /*callback*/, null /*icon*/, 10, |
| true /*supportsMultipleNodes*/, new ActionProvider() { |
| @Override |
| public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { |
| List<RuleAction> propertyActions = new ArrayList<RuleAction>(); |
| addRecentPropertyActions(propertyActions, n, onChange); |
| return propertyActions; |
| } |
| })); |
| |
| propertyActionTypes.add(RuleAction.createSeparator(20)); |
| |
| addInheritedProperties(propertyActionTypes, node, onChange, 30); |
| |
| propertyActionTypes.add(RuleAction.createSeparator(50)); |
| propertyActionTypes.add(RuleAction.createChoices( |
| "layoutparams", "Layout Parameters", //$NON-NLS-1$ |
| onChange /*callback*/, null /*icon*/, 60, |
| true /*supportsMultipleNodes*/, new ActionProvider() { |
| @Override |
| public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { |
| List<RuleAction> propertyActions = new ArrayList<RuleAction>(); |
| addPropertyActions(propertyActions, n, onChange, null, true); |
| return propertyActions; |
| } |
| })); |
| |
| propertyActionTypes.add(RuleAction.createSeparator(70)); |
| |
| propertyActionTypes.add(RuleAction.createChoices( |
| "allprops", "All By Name", //$NON-NLS-1$ |
| onChange /*callback*/, null /*icon*/, 80, |
| true /*supportsMultipleNodes*/, new ActionProvider() { |
| @Override |
| public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { |
| List<RuleAction> propertyActions = new ArrayList<RuleAction>(); |
| addPropertyActions(propertyActions, n, onChange, null, false); |
| return propertyActions; |
| } |
| })); |
| |
| return propertyActionTypes; |
| } |
| }); |
| |
| actions.add(properties); |
| } |
| |
| @Override |
| @Nullable |
| public String getDefaultActionId(@NonNull final INode selectedNode) { |
| IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); |
| if (textAttribute != null) { |
| return PROP_PREFIX + ATTR_TEXT; |
| } |
| |
| return null; |
| } |
| |
| private static String getPropertyMapKey(INode node) { |
| // Compute the key for mAttributesMap. This depends on the type of this |
| // node and its parent in the view hierarchy. |
| StringBuilder sb = new StringBuilder(); |
| sb.append(node.getFqcn()); |
| sb.append('_'); |
| INode parent = node.getParent(); |
| if (parent != null) { |
| sb.append(parent.getFqcn()); |
| } |
| return sb.toString(); |
| } |
| |
| /** |
| * Adds menu items for the inherited attributes, one pull-right menu for each super class |
| * that defines attributes. |
| * |
| * @param propertyActionTypes the actions list to add into |
| * @param node the node to apply the attributes to |
| * @param onChange the callback to use for setting attributes |
| * @param sortPriority the initial sort attribute for the first menu item |
| */ |
| private void addInheritedProperties(List<RuleAction> propertyActionTypes, INode node, |
| final IMenuCallback onChange, int sortPriority) { |
| List<String> attributeSources = node.getAttributeSources(); |
| for (final String definedBy : attributeSources) { |
| String sourceClass = definedBy; |
| |
| // Strip package prefixes when necessary |
| int index = sourceClass.length(); |
| if (sourceClass.endsWith(DOT_LAYOUT_PARAMS)) { |
| index = sourceClass.length() - DOT_LAYOUT_PARAMS.length() - 1; |
| } |
| int lastDot = sourceClass.lastIndexOf('.', index); |
| if (lastDot != -1) { |
| sourceClass = sourceClass.substring(lastDot + 1); |
| } |
| |
| String label; |
| if (definedBy.equals(node.getFqcn())) { |
| label = String.format("Defined by %1$s", sourceClass); |
| } else { |
| label = String.format("Inherited from %1$s", sourceClass); |
| } |
| |
| propertyActionTypes.add(RuleAction.createChoices("def_" + definedBy, |
| label, |
| onChange /*callback*/, null /*icon*/, sortPriority++, |
| true /*supportsMultipleNodes*/, new ActionProvider() { |
| @Override |
| public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { |
| List<RuleAction> propertyActions = new ArrayList<RuleAction>(); |
| addPropertyActions(propertyActions, n, onChange, definedBy, false); |
| return propertyActions; |
| } |
| })); |
| } |
| } |
| |
| /** |
| * Creates a list of properties that are commonly edited for views of the |
| * selected node's type |
| */ |
| private void addCommonPropertyActions(List<RuleAction> actions, INode selectedNode, |
| IMenuCallback onChange, int sortPriority) { |
| Map<String, Prop> properties = getPropertyMetadata(selectedNode); |
| IViewMetadata metadata = mRulesEngine.getMetadata(selectedNode.getFqcn()); |
| if (metadata != null) { |
| List<String> attributes = metadata.getTopAttributes(); |
| if (attributes.size() > 0) { |
| for (String attribute : attributes) { |
| // Text and ID are handled manually in the menu construction code because |
| // we want to place them consistently and customize the action label |
| if (ATTR_TEXT.equals(attribute) || ATTR_ID.equals(attribute)) { |
| continue; |
| } |
| |
| Prop property = properties.get(attribute); |
| if (property != null) { |
| String title = property.getTitle(); |
| if (title.endsWith("...")) { |
| title = String.format("Edit %1$s", property.getTitle()); |
| } |
| actions.add(createPropertyAction(property, attribute, title, |
| selectedNode, onChange, sortPriority)); |
| sortPriority++; |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * Record that the given property was just edited; adds it to the front of |
| * the recently edited property list |
| * |
| * @param property the name of the property |
| */ |
| static void editedProperty(String property) { |
| if (sRecent.contains(property)) { |
| sRecent.remove(property); |
| } else if (sRecent.size() > MAX_RECENT_COUNT) { |
| sRecent.remove(sRecent.size() - 1); |
| } |
| sRecent.add(0, property); |
| } |
| |
| /** |
| * Creates a list of recently modified properties that apply to the given selected node |
| */ |
| private void addRecentPropertyActions(List<RuleAction> actions, INode selectedNode, |
| IMenuCallback onChange) { |
| int sortPriority = 10; |
| Map<String, Prop> properties = getPropertyMetadata(selectedNode); |
| for (String attribute : sRecent) { |
| Prop property = properties.get(attribute); |
| if (property != null) { |
| actions.add(createPropertyAction(property, attribute, property.getTitle(), |
| selectedNode, onChange, sortPriority)); |
| sortPriority += 10; |
| } |
| } |
| } |
| |
| /** |
| * Creates a list of nested actions representing the property-setting |
| * actions for the given selected node |
| */ |
| private void addPropertyActions(List<RuleAction> actions, INode selectedNode, |
| IMenuCallback onChange, String definedBy, boolean layoutParamsOnly) { |
| |
| Map<String, Prop> properties = getPropertyMetadata(selectedNode); |
| |
| int sortPriority = 10; |
| for (Map.Entry<String, Prop> entry : properties.entrySet()) { |
| String id = entry.getKey(); |
| Prop property = entry.getValue(); |
| if (layoutParamsOnly) { |
| // If we have definedBy information, that is most accurate; all layout |
| // params will be defined by a class whose name ends with |
| // .LayoutParams: |
| if (definedBy != null) { |
| if (!definedBy.endsWith(DOT_LAYOUT_PARAMS)) { |
| continue; |
| } |
| } else if (!id.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { |
| continue; |
| } |
| } |
| if (definedBy != null && !definedBy.equals(property.getDefinedBy())) { |
| continue; |
| } |
| actions.add(createPropertyAction(property, id, property.getTitle(), |
| selectedNode, onChange, sortPriority)); |
| sortPriority += 10; |
| } |
| |
| // The properties are coming out of map key order which isn't right, so sort |
| // alphabetically instead |
| Collections.sort(actions, new Comparator<RuleAction>() { |
| @Override |
| public int compare(RuleAction action1, RuleAction action2) { |
| return action1.getTitle().compareTo(action2.getTitle()); |
| } |
| }); |
| } |
| |
| private RuleAction createPropertyAction(Prop p, String id, String title, INode selectedNode, |
| IMenuCallback onChange, int sortPriority) { |
| 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(Locale.US); |
| } |
| if (VALUE_TRUE.equals(value)) { |
| value = TRUE_ID; |
| } else if (VALUE_FALSE.equals(value)) { |
| value = FALSE_ID; |
| } else { |
| value = CLEAR_ID; |
| } |
| return RuleAction.createChoices(PROP_PREFIX + id, title, |
| onChange, BOOLEAN_CHOICE_PROVIDER, |
| value, |
| null, sortPriority, |
| true); |
| } 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 = CLEAR_ID; |
| } |
| return RuleAction.createChoices(PROP_PREFIX + id, title, |
| onChange, new EnumPropertyChoiceProvider(p), |
| current, |
| null, sortPriority, |
| true); |
| } else { |
| return RuleAction.createAction( |
| PROP_PREFIX + id, |
| title, |
| onChange, |
| null, sortPriority, |
| true); |
| } |
| } |
| |
| private Map<String, Prop> getPropertyMetadata(final INode selectedNode) { |
| String key = getPropertyMapKey(selectedNode); |
| 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; |
| } |
| if (attrInfo == null) { |
| continue; |
| } |
| EnumSet<Format> formats = attrInfo.getFormats(); |
| |
| String title = getAttributeDisplayName(id); |
| |
| String definedBy = attrInfo != null ? attrInfo.getDefinedBy() : null; |
| if (formats.contains(IAttributeInfo.Format.BOOLEAN)) { |
| props.put(id, new Prop(title, true, definedBy)); |
| } else if (formats.contains(IAttributeInfo.Format.ENUM)) { |
| // 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, getAttributeDisplayName(e)); |
| } |
| } |
| |
| props.put(id, new Prop(title, false, false, values, definedBy)); |
| } else if (formats.contains(IAttributeInfo.Format.FLAG)) { |
| // 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, getAttributeDisplayName(e)); |
| } |
| } |
| |
| props.put(id, new Prop(title, false, true, values, definedBy)); |
| } else { |
| props.put(id, new Prop(title + "...", false, definedBy)); |
| } |
| } |
| mAttributesMap.put(key, props); |
| } |
| return props; |
| } |
| |
| /** |
| * A {@link ChoiceProvder} which provides alternatives suitable for choosing |
| * values for a boolean property: true, false, or "default". |
| */ |
| private static ChoiceProvider BOOLEAN_CHOICE_PROVIDER = new ChoiceProvider() { |
| @Override |
| public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, |
| @NonNull List<String> ids) { |
| titles.add("True"); |
| ids.add(TRUE_ID); |
| |
| titles.add("False"); |
| ids.add(FALSE_ID); |
| |
| titles.add(RuleAction.SEPARATOR); |
| ids.add(RuleAction.SEPARATOR); |
| |
| titles.add("Default"); |
| ids.add(CLEAR_ID); |
| } |
| }; |
| |
| /** |
| * A {@link ChoiceProvider} which provides the various available |
| * attribute values available for a given {@link Prop} property descriptor. |
| */ |
| private static class EnumPropertyChoiceProvider implements ChoiceProvider { |
| private Prop mProperty; |
| |
| public EnumPropertyChoiceProvider(Prop property) { |
| super(); |
| mProperty = property; |
| } |
| |
| @Override |
| public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, |
| @NonNull List<String> ids) { |
| for (Entry<String, String> entry : mProperty.getChoices().entrySet()) { |
| ids.add(entry.getKey()); |
| titles.add(entry.getValue()); |
| } |
| |
| titles.add(RuleAction.SEPARATOR); |
| ids.add(RuleAction.SEPARATOR); |
| |
| titles.add("Default"); |
| ids.add(CLEAR_ID); |
| } |
| } |
| |
| /** |
| * Returns true if the given node is "filled" (e.g. has layout width set to match |
| * parent or fill parent |
| */ |
| protected final 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 final 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 final 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(); |
| } |
| |
| 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; |
| } |
| |
| /** |
| * Produces a display name for an attribute, usually capitalizing the attribute name |
| * and splitting up underscores into new words |
| * |
| * @param name the attribute name to convert |
| * @return a display name for the attribute name |
| */ |
| public static String getAttributeDisplayName(String name) { |
| if (name != null && name.length() > 0) { |
| StringBuilder sb = new StringBuilder(); |
| boolean capitalizeNext = true; |
| for (int i = 0, n = name.length(); i < n; i++) { |
| char c = name.charAt(i); |
| if (capitalizeNext) { |
| c = Character.toUpperCase(c); |
| } |
| capitalizeNext = false; |
| if (c == '_') { |
| c = ' '; |
| capitalizeNext = true; |
| } |
| sb.append(c); |
| } |
| |
| return sb.toString(); |
| } |
| |
| return name; |
| } |
| |
| |
| // ==== 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. |
| */ |
| @Override |
| public void onPaste(@NonNull INode targetNode, @Nullable Object targetView, |
| @NonNull 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, targetView, 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; |
| private String mDefinedBy; |
| |
| public Prop(String title, boolean isToggle, boolean isFlag, Map<String, String> choices, |
| String definedBy) { |
| mTitle = title; |
| mToggle = isToggle; |
| mFlag = isFlag; |
| mChoices = choices; |
| mDefinedBy = definedBy; |
| } |
| |
| public String getDefinedBy() { |
| return mDefinedBy; |
| } |
| |
| public Prop(String title, boolean isToggle, String definedBy) { |
| this(title, isToggle, false, null, definedBy); |
| } |
| |
| 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 final String getSampleImageSrc() { |
| // Builtin graphics available since v1: |
| return "@android:drawable/btn_star"; //$NON-NLS-1$ |
| } |
| |
| /** |
| * Strips the {@code @+id} or {@code @id} prefix off of the given id |
| * |
| * @param id attribute to be stripped |
| * @return the id name without the {@code @+id} or {@code @id} prefix |
| */ |
| @NonNull |
| public static String stripIdPrefix(@Nullable String id) { |
| if (id == null) { |
| return ""; //$NON-NLS-1$ |
| } else 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 id; |
| } |
| |
| private static String ensureValidString(String value) { |
| if (value == null) { |
| value = ""; //$NON-NLS-1$ |
| } |
| return value; |
| } |
| } |