1 /*
2  * Copyright (C) 2010 The Android Open Source Project
3  *
4  * Licensed under the Eclipse Public License, Version 1.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.eclipse.org/org/documents/epl-v10.php
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.ide.common.layout;
18 
19 import static com.android.SdkConstants.ANDROID_URI;
20 import static com.android.SdkConstants.ATTR_CLASS;
21 import static com.android.SdkConstants.ATTR_HINT;
22 import static com.android.SdkConstants.ATTR_ID;
23 import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
24 import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX;
25 import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
26 import static com.android.SdkConstants.ATTR_STYLE;
27 import static com.android.SdkConstants.ATTR_TEXT;
28 import static com.android.SdkConstants.DOT_LAYOUT_PARAMS;
29 import static com.android.SdkConstants.ID_PREFIX;
30 import static com.android.SdkConstants.NEW_ID_PREFIX;
31 import static com.android.SdkConstants.VALUE_FALSE;
32 import static com.android.SdkConstants.VALUE_FILL_PARENT;
33 import static com.android.SdkConstants.VALUE_MATCH_PARENT;
34 import static com.android.SdkConstants.VALUE_TRUE;
35 import static com.android.SdkConstants.VALUE_WRAP_CONTENT;
36 import static com.android.SdkConstants.VIEW_FRAGMENT;
37 
38 import com.android.annotations.NonNull;
39 import com.android.annotations.Nullable;
40 import com.android.ide.common.api.AbstractViewRule;
41 import com.android.ide.common.api.IAttributeInfo;
42 import com.android.ide.common.api.IAttributeInfo.Format;
43 import com.android.ide.common.api.IClientRulesEngine;
44 import com.android.ide.common.api.IDragElement;
45 import com.android.ide.common.api.IMenuCallback;
46 import com.android.ide.common.api.INode;
47 import com.android.ide.common.api.IViewMetadata;
48 import com.android.ide.common.api.IViewRule;
49 import com.android.ide.common.api.RuleAction;
50 import com.android.ide.common.api.RuleAction.ActionProvider;
51 import com.android.ide.common.api.RuleAction.ChoiceProvider;
52 import com.android.resources.ResourceType;
53 import com.android.utils.Pair;
54 
55 import java.net.URL;
56 import java.util.ArrayList;
57 import java.util.Arrays;
58 import java.util.Collection;
59 import java.util.Collections;
60 import java.util.Comparator;
61 import java.util.EnumSet;
62 import java.util.HashMap;
63 import java.util.HashSet;
64 import java.util.LinkedList;
65 import java.util.List;
66 import java.util.Locale;
67 import java.util.Map;
68 import java.util.Map.Entry;
69 import java.util.Set;
70 
71 /**
72  * Common IViewRule processing to all view and layout classes.
73  */
74 public class BaseViewRule extends AbstractViewRule {
75     /** List of recently edited properties */
76     private static List<String> sRecent = new LinkedList<String>();
77 
78     /** Maximum number of recent properties to track and list */
79     private final static int MAX_RECENT_COUNT = 12;
80 
81     // Strings used as internal ids, group ids and prefixes for actions
82     private static final String FALSE_ID = "false"; //$NON-NLS-1$
83     private static final String TRUE_ID = "true"; //$NON-NLS-1$
84     private static final String PROP_PREFIX = "@prop@"; //$NON-NLS-1$
85     private static final String CLEAR_ID = "clear"; //$NON-NLS-1$
86     private static final String ZCUSTOM = "zcustom"; //$NON-NLS-1$
87 
88     protected IClientRulesEngine mRulesEngine;
89 
90     // Cache of attributes. Key is FQCN of a node mixed with its view hierarchy
91     // parent. Values are a custom map as needed by getContextMenu.
92     private Map<String, Map<String, Prop>> mAttributesMap =
93         new HashMap<String, Map<String, Prop>>();
94 
95     @Override
onInitialize(@onNull String fqcn, @NonNull IClientRulesEngine engine)96     public boolean onInitialize(@NonNull String fqcn, @NonNull IClientRulesEngine engine) {
97         mRulesEngine = engine;
98 
99         // This base rule can handle any class so we don't need to filter on
100         // FQCN. Derived classes should do so if they can handle some
101         // subclasses.
102 
103         // If onInitialize returns false, it means it can't handle the given
104         // FQCN and will be unloaded.
105 
106         return true;
107     }
108 
109     /**
110      * Returns the {@link IClientRulesEngine} associated with this {@link IViewRule}
111      *
112      * @return the {@link IClientRulesEngine} associated with this {@link IViewRule}
113      */
getRulesEngine()114     public IClientRulesEngine getRulesEngine() {
115         return mRulesEngine;
116     }
117 
118     // === Context Menu ===
119 
120     /**
121      * Generate custom actions for the context menu: <br/>
122      * - Explicit layout_width and layout_height attributes.
123      * - List of all other simple toggle attributes.
124      */
125     @Override
addContextMenuActions(@onNull List<RuleAction> actions, final @NonNull INode selectedNode)126     public void addContextMenuActions(@NonNull List<RuleAction> actions,
127             final @NonNull INode selectedNode) {
128         String width = null;
129         String currentWidth = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH);
130 
131         String fillParent = getFillParentValueName();
132         boolean canMatchParent = supportsMatchParent();
133         if (canMatchParent && VALUE_FILL_PARENT.equals(currentWidth)) {
134             currentWidth = VALUE_MATCH_PARENT;
135         } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentWidth)) {
136             currentWidth = VALUE_FILL_PARENT;
137         } else if (!VALUE_WRAP_CONTENT.equals(currentWidth) && !fillParent.equals(currentWidth)) {
138             width = currentWidth;
139         }
140 
141         String height = null;
142         String currentHeight = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT);
143 
144         if (canMatchParent && VALUE_FILL_PARENT.equals(currentHeight)) {
145             currentHeight = VALUE_MATCH_PARENT;
146         } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentHeight)) {
147             currentHeight = VALUE_FILL_PARENT;
148         } else if (!VALUE_WRAP_CONTENT.equals(currentHeight)
149                 && !fillParent.equals(currentHeight)) {
150             height = currentHeight;
151         }
152         final String newWidth = width;
153         final String newHeight = height;
154 
155         final IMenuCallback onChange = new IMenuCallback() {
156             @Override
157             public void action(
158                     final @NonNull RuleAction action,
159                     final @NonNull List<? extends INode> selectedNodes,
160                     final @Nullable String valueId, final @Nullable Boolean newValue) {
161                 String fullActionId = action.getId();
162                 boolean isProp = fullActionId.startsWith(PROP_PREFIX);
163                 final String actionId = isProp ?
164                         fullActionId.substring(PROP_PREFIX.length()) : fullActionId;
165 
166                 if (fullActionId.equals(ATTR_LAYOUT_WIDTH)) {
167                     final String newAttrValue = getValue(valueId, newWidth);
168                     if (newAttrValue != null) {
169                         for (INode node : selectedNodes) {
170                             node.editXml("Change Attribute " + ATTR_LAYOUT_WIDTH,
171                                     new PropertySettingNodeHandler(ANDROID_URI,
172                                             ATTR_LAYOUT_WIDTH, newAttrValue));
173                         }
174                         editedProperty(ATTR_LAYOUT_WIDTH);
175                     }
176                     return;
177                 } else if (fullActionId.equals(ATTR_LAYOUT_HEIGHT)) {
178                     // Ask the user
179                     final String newAttrValue = getValue(valueId, newHeight);
180                     if (newAttrValue != null) {
181                         for (INode node : selectedNodes) {
182                             node.editXml("Change Attribute " + ATTR_LAYOUT_HEIGHT,
183                                     new PropertySettingNodeHandler(ANDROID_URI,
184                                             ATTR_LAYOUT_HEIGHT, newAttrValue));
185                         }
186                         editedProperty(ATTR_LAYOUT_HEIGHT);
187                     }
188                     return;
189                 } else if (fullActionId.equals(ATTR_ID)) {
190                     // Ids must be set individually so open the id dialog for each
191                     // selected node (though allow cancel to break the loop)
192                     for (INode node : selectedNodes) {
193                         if (!mRulesEngine.rename(node)) {
194                             break;
195                         }
196                     }
197                     editedProperty(ATTR_ID);
198                     return;
199                 } else if (isProp) {
200                     INode firstNode = selectedNodes.get(0);
201                     String key = getPropertyMapKey(selectedNode);
202                     Map<String, Prop> props = mAttributesMap.get(key);
203                     final Prop prop = (props != null) ? props.get(actionId) : null;
204 
205                     if (prop != null) {
206                         editedProperty(actionId);
207 
208                         // For custom values (requiring an input dialog) input the
209                         // value outside the undo-block.
210                         // Input the value as a text, unless we know it's the "text" or
211                         // "style" attributes (where we know we want to ask for specific
212                         // resource types).
213                         String uri = ANDROID_URI;
214                         String v = null;
215                         if (prop.isStringEdit()) {
216                             boolean isStyle = actionId.equals(ATTR_STYLE);
217                             boolean isText = actionId.equals(ATTR_TEXT);
218                             boolean isHint = actionId.equals(ATTR_HINT);
219                             if (isStyle || isText || isHint) {
220                                 String resourceTypeName = isStyle
221                                         ? ResourceType.STYLE.getName()
222                                         : ResourceType.STRING.getName();
223                                 String oldValue = selectedNodes.size() == 1
224                                     ? (isStyle ? firstNode.getStringAttr(ATTR_STYLE, actionId)
225                                             : firstNode.getStringAttr(ANDROID_URI, actionId))
226                                     : ""; //$NON-NLS-1$
227                                 oldValue = ensureValidString(oldValue);
228                                 v = mRulesEngine.displayResourceInput(resourceTypeName, oldValue);
229                                 if (isStyle) {
230                                     uri = null;
231                                 }
232                             } else if (actionId.equals(ATTR_CLASS) && selectedNodes.size() >= 1 &&
233                                     VIEW_FRAGMENT.equals(selectedNodes.get(0).getFqcn())) {
234                                 v = mRulesEngine.displayFragmentSourceInput();
235                                 uri = null;
236                             } else {
237                                 v = inputAttributeValue(firstNode, actionId);
238                             }
239                         }
240                         final String customValue = v;
241 
242                         for (INode n : selectedNodes) {
243                             if (prop.isToggle()) {
244                                 // case of toggle
245                                 String value = "";                  //$NON-NLS-1$
246                                 if (valueId.equals(TRUE_ID)) {
247                                     value = newValue ? "true" : ""; //$NON-NLS-1$ //$NON-NLS-2$
248                                 } else if (valueId.equals(FALSE_ID)) {
249                                     value = newValue ? "false" : "";//$NON-NLS-1$ //$NON-NLS-2$
250                                 }
251                                 n.setAttribute(uri, actionId, value);
252                             } else if (prop.isFlag()) {
253                                 // case of a flag
254                                 String values = "";                 //$NON-NLS-1$
255                                 if (!valueId.equals(CLEAR_ID)) {
256                                     values = n.getStringAttr(ANDROID_URI, actionId);
257                                     Set<String> newValues = new HashSet<String>();
258                                     if (values != null) {
259                                         newValues.addAll(Arrays.asList(
260                                                 values.split("\\|"))); //$NON-NLS-1$
261                                     }
262                                     if (newValue) {
263                                         newValues.add(valueId);
264                                     } else {
265                                         newValues.remove(valueId);
266                                     }
267 
268                                     List<String> sorted = new ArrayList<String>(newValues);
269                                     Collections.sort(sorted);
270                                     values = join('|', sorted);
271 
272                                     // Special case
273                                     if (valueId.equals("normal")) { //$NON-NLS-1$
274                                         // For textStyle for example, if you have "bold|italic"
275                                         // and you select the "normal" property, this should
276                                         // not behave in the normal flag way and "or" itself in;
277                                         // it should replace the other two.
278                                         // This also applies to imeOptions.
279                                         values = valueId;
280                                     }
281                                 }
282                                 n.setAttribute(uri, actionId, values);
283                             } else if (prop.isEnum()) {
284                                 // case of an enum
285                                 String value = "";                   //$NON-NLS-1$
286                                 if (!valueId.equals(CLEAR_ID)) {
287                                     value = newValue ? valueId : ""; //$NON-NLS-1$
288                                 }
289                                 n.setAttribute(uri, actionId, value);
290                             } else {
291                                 assert prop.isStringEdit();
292                                 // We've already received the value outside the undo block
293                                 if (customValue != null) {
294                                     n.setAttribute(uri, actionId, customValue);
295                                 }
296                             }
297                         }
298                     }
299                 }
300             }
301 
302             /**
303              * Input the custom value for the given attribute. This will use the Reference
304              * Chooser if it is a reference value, otherwise a plain text editor.
305              */
306             private String inputAttributeValue(final INode node, final String attribute) {
307                 String oldValue = node.getStringAttr(ANDROID_URI, attribute);
308                 oldValue = ensureValidString(oldValue);
309                 IAttributeInfo attributeInfo = node.getAttributeInfo(ANDROID_URI, attribute);
310                 if (attributeInfo != null
311                         && attributeInfo.getFormats().contains(Format.REFERENCE)) {
312                     return mRulesEngine.displayReferenceInput(oldValue);
313                 } else {
314                     // A single resource type? If so use a resource chooser initialized
315                     // to this specific type
316                     /* This does not work well, because the metadata is a bit misleading:
317                      * for example a Button's "text" property and a Button's "onClick" property
318                      * both claim to be of type [string], but @string/ is NOT valid for
319                      * onClick..
320                     if (attributeInfo != null && attributeInfo.getFormats().length == 1) {
321                         // Resource chooser
322                         Format format = attributeInfo.getFormats()[0];
323                         return mRulesEngine.displayResourceInput(format.name(), oldValue);
324                     }
325                     */
326 
327                     // Fallback: just edit the raw XML string
328                     String message = String.format("New %1$s Value:", attribute);
329                     return mRulesEngine.displayInput(message, oldValue, null);
330                 }
331             }
332 
333             /**
334              * Returns the value (which will ask the user if the value is the special
335              * {@link #ZCUSTOM} marker
336              */
337             private String getValue(String valueId, String defaultValue) {
338                 if (valueId.equals(ZCUSTOM)) {
339                     if (defaultValue == null) {
340                         defaultValue = "";
341                     }
342                     String value = mRulesEngine.displayInput(
343                             "Set custom layout attribute value (example: 50dp)",
344                             defaultValue, null);
345                     if (value != null && value.trim().length() > 0) {
346                         return value.trim();
347                     } else {
348                         return null;
349                     }
350                 }
351 
352                 return valueId;
353             }
354         };
355 
356         IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT);
357         if (textAttribute != null) {
358             actions.add(RuleAction.createAction(PROP_PREFIX + ATTR_TEXT, "Edit Text...", onChange,
359                     null, 10, true));
360         }
361 
362         String editIdLabel = selectedNode.getStringAttr(ANDROID_URI, ATTR_ID) != null ?
363                 "Edit ID..." : "Assign ID...";
364         actions.add(RuleAction.createAction(ATTR_ID, editIdLabel, onChange, null, 20, true));
365 
366         addCommonPropertyActions(actions, selectedNode, onChange, 21);
367 
368         // Create width choice submenu
369         actions.add(RuleAction.createSeparator(32));
370         List<Pair<String, String>> widthChoices = new ArrayList<Pair<String,String>>(4);
371         widthChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content"));
372         if (canMatchParent) {
373             widthChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent"));
374         } else {
375             widthChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent"));
376         }
377         if (width != null) {
378             widthChoices.add(Pair.of(width, width));
379         }
380         widthChoices.add(Pair.of(ZCUSTOM, "Other..."));
381         actions.add(RuleAction.createChoices(
382                 ATTR_LAYOUT_WIDTH, "Layout Width",
383                 onChange,
384                 null /* iconUrls */,
385                 currentWidth,
386                 null, 35,
387                 true, // supportsMultipleNodes
388                 widthChoices));
389 
390         // Create height choice submenu
391         List<Pair<String, String>> heightChoices = new ArrayList<Pair<String,String>>(4);
392         heightChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content"));
393         if (canMatchParent) {
394             heightChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent"));
395         } else {
396             heightChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent"));
397         }
398         if (height != null) {
399             heightChoices.add(Pair.of(height, height));
400         }
401         heightChoices.add(Pair.of(ZCUSTOM, "Other..."));
402         actions.add(RuleAction.createChoices(
403                 ATTR_LAYOUT_HEIGHT, "Layout Height",
404                 onChange,
405                 null /* iconUrls */,
406                 currentHeight,
407                 null, 40,
408                 true,
409                 heightChoices));
410 
411         actions.add(RuleAction.createSeparator(45));
412         RuleAction properties = RuleAction.createChoices("properties", "Other Properties", //$NON-NLS-1$
413                 onChange /*callback*/, null /*icon*/, 50,
414                 true /*supportsMultipleNodes*/, new ActionProvider() {
415             @Override
416             public @NonNull List<RuleAction> getNestedActions(@NonNull INode node) {
417                 List<RuleAction> propertyActionTypes = new ArrayList<RuleAction>();
418                 propertyActionTypes.add(RuleAction.createChoices(
419                         "recent", "Recent", //$NON-NLS-1$
420                         onChange /*callback*/, null /*icon*/, 10,
421                         true /*supportsMultipleNodes*/, new ActionProvider() {
422                             @Override
423                             public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) {
424                                 List<RuleAction> propertyActions = new ArrayList<RuleAction>();
425                                 addRecentPropertyActions(propertyActions, n, onChange);
426                                 return propertyActions;
427                             }
428                 }));
429 
430                 propertyActionTypes.add(RuleAction.createSeparator(20));
431 
432                 addInheritedProperties(propertyActionTypes, node, onChange, 30);
433 
434                 propertyActionTypes.add(RuleAction.createSeparator(50));
435                 propertyActionTypes.add(RuleAction.createChoices(
436                         "layoutparams", "Layout Parameters", //$NON-NLS-1$
437                         onChange /*callback*/, null /*icon*/, 60,
438                         true /*supportsMultipleNodes*/, new ActionProvider() {
439                             @Override
440                             public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) {
441                                 List<RuleAction> propertyActions = new ArrayList<RuleAction>();
442                                 addPropertyActions(propertyActions, n, onChange, null, true);
443                                 return propertyActions;
444                             }
445                 }));
446 
447                 propertyActionTypes.add(RuleAction.createSeparator(70));
448 
449                 propertyActionTypes.add(RuleAction.createChoices(
450                         "allprops", "All By Name", //$NON-NLS-1$
451                         onChange /*callback*/, null /*icon*/, 80,
452                         true /*supportsMultipleNodes*/, new ActionProvider() {
453                             @Override
454                             public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) {
455                                 List<RuleAction> propertyActions = new ArrayList<RuleAction>();
456                                 addPropertyActions(propertyActions, n, onChange, null, false);
457                                 return propertyActions;
458                             }
459                 }));
460 
461                 return propertyActionTypes;
462             }
463         });
464 
465         actions.add(properties);
466     }
467 
468     @Override
469     @Nullable
getDefaultActionId(@onNull final INode selectedNode)470     public String getDefaultActionId(@NonNull final INode selectedNode) {
471         IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT);
472         if (textAttribute != null) {
473             return PROP_PREFIX + ATTR_TEXT;
474         }
475 
476         return null;
477     }
478 
getPropertyMapKey(INode node)479     private static String getPropertyMapKey(INode node) {
480         // Compute the key for mAttributesMap. This depends on the type of this
481         // node and its parent in the view hierarchy.
482         StringBuilder sb = new StringBuilder();
483         sb.append(node.getFqcn());
484         sb.append('_');
485         INode parent = node.getParent();
486         if (parent != null) {
487             sb.append(parent.getFqcn());
488         }
489         return sb.toString();
490     }
491 
492     /**
493      * Adds menu items for the inherited attributes, one pull-right menu for each super class
494      * that defines attributes.
495      *
496      * @param propertyActionTypes the actions list to add into
497      * @param node the node to apply the attributes to
498      * @param onChange the callback to use for setting attributes
499      * @param sortPriority the initial sort attribute for the first menu item
500      */
addInheritedProperties(List<RuleAction> propertyActionTypes, INode node, final IMenuCallback onChange, int sortPriority)501     private void addInheritedProperties(List<RuleAction> propertyActionTypes, INode node,
502             final IMenuCallback onChange, int sortPriority) {
503         List<String> attributeSources = node.getAttributeSources();
504         for (final String definedBy : attributeSources) {
505             String sourceClass = definedBy;
506 
507             // Strip package prefixes when necessary
508             int index = sourceClass.length();
509             if (sourceClass.endsWith(DOT_LAYOUT_PARAMS)) {
510                 index = sourceClass.length() - DOT_LAYOUT_PARAMS.length() - 1;
511             }
512             int lastDot = sourceClass.lastIndexOf('.', index);
513             if (lastDot != -1) {
514                 sourceClass = sourceClass.substring(lastDot + 1);
515             }
516 
517             String label;
518             if (definedBy.equals(node.getFqcn())) {
519                 label = String.format("Defined by %1$s", sourceClass);
520             } else {
521                 label = String.format("Inherited from %1$s", sourceClass);
522             }
523 
524             propertyActionTypes.add(RuleAction.createChoices("def_" + definedBy,
525                     label,
526                     onChange /*callback*/, null /*icon*/, sortPriority++,
527                     true /*supportsMultipleNodes*/, new ActionProvider() {
528                         @Override
529                         public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) {
530                             List<RuleAction> propertyActions = new ArrayList<RuleAction>();
531                             addPropertyActions(propertyActions, n, onChange, definedBy, false);
532                             return propertyActions;
533                         }
534            }));
535         }
536     }
537 
538     /**
539      * Creates a list of properties that are commonly edited for views of the
540      * selected node's type
541      */
addCommonPropertyActions(List<RuleAction> actions, INode selectedNode, IMenuCallback onChange, int sortPriority)542     private void addCommonPropertyActions(List<RuleAction> actions, INode selectedNode,
543             IMenuCallback onChange, int sortPriority) {
544         Map<String, Prop> properties = getPropertyMetadata(selectedNode);
545         IViewMetadata metadata = mRulesEngine.getMetadata(selectedNode.getFqcn());
546         if (metadata != null) {
547             List<String> attributes = metadata.getTopAttributes();
548             if (attributes.size() > 0) {
549                 for (String attribute : attributes) {
550                     // Text and ID are handled manually in the menu construction code because
551                     // we want to place them consistently and customize the action label
552                     if (ATTR_TEXT.equals(attribute) || ATTR_ID.equals(attribute)) {
553                         continue;
554                     }
555 
556                     Prop property = properties.get(attribute);
557                     if (property != null) {
558                         String title = property.getTitle();
559                         if (title.endsWith("...")) {
560                             title = String.format("Edit %1$s", property.getTitle());
561                         }
562                         actions.add(createPropertyAction(property, attribute, title,
563                                 selectedNode, onChange, sortPriority));
564                         sortPriority++;
565                     }
566                 }
567             }
568         }
569     }
570 
571     /**
572      * Record that the given property was just edited; adds it to the front of
573      * the recently edited property list
574      *
575      * @param property the name of the property
576      */
editedProperty(String property)577     static void editedProperty(String property) {
578         if (sRecent.contains(property)) {
579             sRecent.remove(property);
580         } else if (sRecent.size() > MAX_RECENT_COUNT) {
581             sRecent.remove(sRecent.size() - 1);
582         }
583         sRecent.add(0, property);
584     }
585 
586     /**
587      * Creates a list of recently modified properties that apply to the given selected node
588      */
addRecentPropertyActions(List<RuleAction> actions, INode selectedNode, IMenuCallback onChange)589     private void addRecentPropertyActions(List<RuleAction> actions, INode selectedNode,
590             IMenuCallback onChange) {
591         int sortPriority = 10;
592         Map<String, Prop> properties = getPropertyMetadata(selectedNode);
593         for (String attribute : sRecent) {
594             Prop property = properties.get(attribute);
595             if (property != null) {
596                 actions.add(createPropertyAction(property, attribute, property.getTitle(),
597                         selectedNode, onChange, sortPriority));
598                 sortPriority += 10;
599             }
600         }
601     }
602 
603     /**
604      * Creates a list of nested actions representing the property-setting
605      * actions for the given selected node
606      */
addPropertyActions(List<RuleAction> actions, INode selectedNode, IMenuCallback onChange, String definedBy, boolean layoutParamsOnly)607     private void addPropertyActions(List<RuleAction> actions, INode selectedNode,
608             IMenuCallback onChange, String definedBy, boolean layoutParamsOnly) {
609 
610         Map<String, Prop> properties = getPropertyMetadata(selectedNode);
611 
612         int sortPriority = 10;
613         for (Map.Entry<String, Prop> entry : properties.entrySet()) {
614             String id = entry.getKey();
615             Prop property = entry.getValue();
616             if (layoutParamsOnly) {
617                 // If we have definedBy information, that is most accurate; all layout
618                 // params will be defined by a class whose name ends with
619                 // .LayoutParams:
620                 if (definedBy != null) {
621                     if (!definedBy.endsWith(DOT_LAYOUT_PARAMS)) {
622                         continue;
623                     }
624                 } else if (!id.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) {
625                     continue;
626                 }
627             }
628             if (definedBy != null && !definedBy.equals(property.getDefinedBy())) {
629                 continue;
630             }
631             actions.add(createPropertyAction(property, id, property.getTitle(),
632                     selectedNode, onChange, sortPriority));
633             sortPriority += 10;
634         }
635 
636         // The properties are coming out of map key order which isn't right, so sort
637         // alphabetically instead
638         Collections.sort(actions, new Comparator<RuleAction>() {
639             @Override
640             public int compare(RuleAction action1, RuleAction action2) {
641                 return action1.getTitle().compareTo(action2.getTitle());
642             }
643         });
644     }
645 
createPropertyAction(Prop p, String id, String title, INode selectedNode, IMenuCallback onChange, int sortPriority)646     private RuleAction createPropertyAction(Prop p, String id, String title, INode selectedNode,
647             IMenuCallback onChange, int sortPriority) {
648         if (p.isToggle()) {
649             // Toggles are handled as a multiple-choice between true, false
650             // and nothing (clear)
651             String value = selectedNode.getStringAttr(ANDROID_URI, id);
652             if (value != null) {
653                 value = value.toLowerCase(Locale.US);
654             }
655             if (VALUE_TRUE.equals(value)) {
656                 value = TRUE_ID;
657             } else if (VALUE_FALSE.equals(value)) {
658                 value = FALSE_ID;
659             } else {
660                 value = CLEAR_ID;
661             }
662             return RuleAction.createChoices(PROP_PREFIX + id, title,
663                     onChange, BOOLEAN_CHOICE_PROVIDER,
664                     value,
665                     null, sortPriority,
666                     true);
667         } else if (p.getChoices() != null) {
668             // Enum or flags. Their possible values are the multiple-choice
669             // items, with an extra "clear" option to remove everything.
670             String current = selectedNode.getStringAttr(ANDROID_URI, id);
671             if (current == null || current.length() == 0) {
672                 current = CLEAR_ID;
673             }
674             return RuleAction.createChoices(PROP_PREFIX + id, title,
675                     onChange, new EnumPropertyChoiceProvider(p),
676                     current,
677                     null, sortPriority,
678                     true);
679         } else {
680             return RuleAction.createAction(
681                     PROP_PREFIX + id,
682                     title,
683                     onChange,
684                     null, sortPriority,
685                     true);
686         }
687     }
688 
getPropertyMetadata(final INode selectedNode)689     private Map<String, Prop> getPropertyMetadata(final INode selectedNode) {
690         String key = getPropertyMapKey(selectedNode);
691         Map<String, Prop> props = mAttributesMap.get(key);
692         if (props == null) {
693             // Prepare the property map
694             props = new HashMap<String, Prop>();
695             for (IAttributeInfo attrInfo : selectedNode.getDeclaredAttributes()) {
696                 String id = attrInfo != null ? attrInfo.getName() : null;
697                 if (id == null || id.equals(ATTR_LAYOUT_WIDTH) || id.equals(ATTR_LAYOUT_HEIGHT)) {
698                     // Layout width/height are already handled at the root level
699                     continue;
700                 }
701                 if (attrInfo == null) {
702                     continue;
703                 }
704                 EnumSet<Format> formats = attrInfo.getFormats();
705 
706                 String title = getAttributeDisplayName(id);
707 
708                 String definedBy = attrInfo != null ? attrInfo.getDefinedBy() : null;
709                 if (formats.contains(IAttributeInfo.Format.BOOLEAN)) {
710                     props.put(id, new Prop(title, true, definedBy));
711                 } else if (formats.contains(IAttributeInfo.Format.ENUM)) {
712                     // Convert each enum into a map id=>title
713                     Map<String, String> values = new HashMap<String, String>();
714                     if (attrInfo != null) {
715                         for (String e : attrInfo.getEnumValues()) {
716                             values.put(e, getAttributeDisplayName(e));
717                         }
718                     }
719 
720                     props.put(id, new Prop(title, false, false, values, definedBy));
721                 } else if (formats.contains(IAttributeInfo.Format.FLAG)) {
722                     // Convert each flag into a map id=>title
723                     Map<String, String> values = new HashMap<String, String>();
724                     if (attrInfo != null) {
725                         for (String e : attrInfo.getFlagValues()) {
726                             values.put(e, getAttributeDisplayName(e));
727                         }
728                     }
729 
730                     props.put(id, new Prop(title, false, true, values, definedBy));
731                 } else {
732                     props.put(id, new Prop(title + "...", false, definedBy));
733                 }
734             }
735             mAttributesMap.put(key, props);
736         }
737         return props;
738     }
739 
740     /**
741      * A {@link ChoiceProvder} which provides alternatives suitable for choosing
742      * values for a boolean property: true, false, or "default".
743      */
744     private static ChoiceProvider BOOLEAN_CHOICE_PROVIDER = new ChoiceProvider() {
745         @Override
746         public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls,
747                 @NonNull List<String> ids) {
748             titles.add("True");
749             ids.add(TRUE_ID);
750 
751             titles.add("False");
752             ids.add(FALSE_ID);
753 
754             titles.add(RuleAction.SEPARATOR);
755             ids.add(RuleAction.SEPARATOR);
756 
757             titles.add("Default");
758             ids.add(CLEAR_ID);
759         }
760     };
761 
762     /**
763      * A {@link ChoiceProvider} which provides the various available
764      * attribute values available for a given {@link Prop} property descriptor.
765      */
766     private static class EnumPropertyChoiceProvider implements ChoiceProvider {
767         private Prop mProperty;
768 
EnumPropertyChoiceProvider(Prop property)769         public EnumPropertyChoiceProvider(Prop property) {
770             super();
771             mProperty = property;
772         }
773 
774         @Override
addChoices(@onNull List<String> titles, @NonNull List<URL> iconUrls, @NonNull List<String> ids)775         public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls,
776                 @NonNull List<String> ids) {
777             for (Entry<String, String> entry : mProperty.getChoices().entrySet()) {
778                 ids.add(entry.getKey());
779                 titles.add(entry.getValue());
780             }
781 
782             titles.add(RuleAction.SEPARATOR);
783             ids.add(RuleAction.SEPARATOR);
784 
785             titles.add("Default");
786             ids.add(CLEAR_ID);
787         }
788     }
789 
790     /**
791      * Returns true if the given node is "filled" (e.g. has layout width set to match
792      * parent or fill parent
793      */
isFilled(INode node, String attribute)794     protected final boolean isFilled(INode node, String attribute) {
795         String value = node.getStringAttr(ANDROID_URI, attribute);
796         return VALUE_MATCH_PARENT.equals(value) || VALUE_FILL_PARENT.equals(value);
797     }
798 
799     /**
800      * Returns fill_parent or match_parent, depending on whether the minimum supported
801      * platform supports match_parent or not
802      *
803      * @return match_parent or fill_parent depending on which is supported by the project
804      */
getFillParentValueName()805     protected final String getFillParentValueName() {
806         return supportsMatchParent() ? VALUE_MATCH_PARENT : VALUE_FILL_PARENT;
807     }
808 
809     /**
810      * Returns true if the project supports match_parent instead of just fill_parent
811      *
812      * @return true if the project supports match_parent instead of just fill_parent
813      */
supportsMatchParent()814     protected final boolean supportsMatchParent() {
815         // fill_parent was renamed match_parent in API level 8
816         return mRulesEngine.getMinApiLevel() >= 8;
817     }
818 
819     /** Join strings into a single string with the given delimiter */
join(char delimiter, Collection<String> strings)820     static String join(char delimiter, Collection<String> strings) {
821         StringBuilder sb = new StringBuilder(100);
822         for (String s : strings) {
823             if (sb.length() > 0) {
824                 sb.append(delimiter);
825             }
826             sb.append(s);
827         }
828         return sb.toString();
829     }
830 
concatenate(Map<String, String> pre, Map<String, String> post)831     static Map<String, String> concatenate(Map<String, String> pre, Map<String, String> post) {
832         Map<String, String> result = new HashMap<String, String>(pre.size() + post.size());
833         result.putAll(pre);
834         result.putAll(post);
835         return result;
836     }
837 
838     // Quick utility for building up maps declaratively to minimize the diffs
mapify(String... values)839     static Map<String, String> mapify(String... values) {
840         Map<String, String> map = new HashMap<String, String>(values.length / 2);
841         for (int i = 0; i < values.length; i += 2) {
842             String key = values[i];
843             if (key == null) {
844                 continue;
845             }
846             String value = values[i + 1];
847             map.put(key, value);
848         }
849 
850         return map;
851     }
852 
853     /**
854      * Produces a display name for an attribute, usually capitalizing the attribute name
855      * and splitting up underscores into new words
856      *
857      * @param name the attribute name to convert
858      * @return a display name for the attribute name
859      */
getAttributeDisplayName(String name)860     public static String getAttributeDisplayName(String name) {
861         if (name != null && name.length() > 0) {
862             StringBuilder sb = new StringBuilder();
863             boolean capitalizeNext = true;
864             for (int i = 0, n = name.length(); i < n; i++) {
865                 char c = name.charAt(i);
866                 if (capitalizeNext) {
867                     c = Character.toUpperCase(c);
868                 }
869                 capitalizeNext = false;
870                 if (c == '_') {
871                     c = ' ';
872                     capitalizeNext = true;
873                 }
874                 sb.append(c);
875             }
876 
877             return sb.toString();
878         }
879 
880         return name;
881     }
882 
883 
884     // ==== Paste support ====
885 
886     /**
887      * Most views can't accept children so there's nothing to paste on them. In
888      * this case, defer the call to the parent layout and use the target node as
889      * an indication of where to paste.
890      */
891     @Override
onPaste(@onNull INode targetNode, @Nullable Object targetView, @NonNull IDragElement[] elements)892     public void onPaste(@NonNull INode targetNode, @Nullable Object targetView,
893             @NonNull IDragElement[] elements) {
894         //
895         INode parent = targetNode.getParent();
896         if (parent != null) {
897             String parentFqcn = parent.getFqcn();
898             IViewRule parentRule = mRulesEngine.loadRule(parentFqcn);
899 
900             if (parentRule instanceof BaseLayoutRule) {
901                 ((BaseLayoutRule) parentRule).onPasteBeforeChild(parent, targetView, targetNode,
902                         elements);
903             }
904         }
905     }
906 
907     /**
908      * Support class for the context menu code. Stores state about properties in
909      * the context menu.
910      */
911     private static class Prop {
912         private final boolean mToggle;
913         private final boolean mFlag;
914         private final String mTitle;
915         private final Map<String, String> mChoices;
916         private String mDefinedBy;
917 
Prop(String title, boolean isToggle, boolean isFlag, Map<String, String> choices, String definedBy)918         public Prop(String title, boolean isToggle, boolean isFlag, Map<String, String> choices,
919                 String definedBy) {
920             mTitle = title;
921             mToggle = isToggle;
922             mFlag = isFlag;
923             mChoices = choices;
924             mDefinedBy = definedBy;
925         }
926 
getDefinedBy()927         public String getDefinedBy() {
928             return mDefinedBy;
929         }
930 
Prop(String title, boolean isToggle, String definedBy)931         public Prop(String title, boolean isToggle, String definedBy) {
932             this(title, isToggle, false, null, definedBy);
933         }
934 
isToggle()935         private boolean isToggle() {
936             return mToggle;
937         }
938 
isFlag()939         private boolean isFlag() {
940             return mFlag && mChoices != null;
941         }
942 
isEnum()943         private boolean isEnum() {
944             return !mFlag && mChoices != null;
945         }
946 
getTitle()947         private String getTitle() {
948             return mTitle;
949         }
950 
getChoices()951         private Map<String, String> getChoices() {
952             return mChoices;
953         }
954 
isStringEdit()955         private boolean isStringEdit() {
956             return mChoices == null && !mToggle;
957         }
958     }
959 
960     /**
961      * Returns a source attribute value which points to a sample image. This is typically
962      * used to provide an initial image shown on ImageButtons, etc. There is no guarantee
963      * that the source pointed to by this method actually exists.
964      *
965      * @return a source attribute to use for sample images, never null
966      */
getSampleImageSrc()967     protected final String getSampleImageSrc() {
968         // Builtin graphics available since v1:
969         return "@android:drawable/btn_star"; //$NON-NLS-1$
970     }
971 
972     /**
973      * Strips the {@code @+id} or {@code @id} prefix off of the given id
974      *
975      * @param id attribute to be stripped
976      * @return the id name without the {@code @+id} or {@code @id} prefix
977      */
978     @NonNull
stripIdPrefix(@ullable String id)979     public static String stripIdPrefix(@Nullable String id) {
980         if (id == null) {
981             return ""; //$NON-NLS-1$
982         } else if (id.startsWith(NEW_ID_PREFIX)) {
983             return id.substring(NEW_ID_PREFIX.length());
984         } else if (id.startsWith(ID_PREFIX)) {
985             return id.substring(ID_PREFIX.length());
986         }
987         return id;
988     }
989 
ensureValidString(String value)990     private static String ensureValidString(String value) {
991         if (value == null) {
992             value = ""; //$NON-NLS-1$
993         }
994         return value;
995     }
996  }
997