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