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_ID;
21 import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE;
22 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE;
23 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM;
24 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT;
25 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM;
26 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT;
27 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT;
28 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP;
29 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT;
30 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP;
31 import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING;
32 import static com.android.SdkConstants.ATTR_LAYOUT_BELOW;
33 import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL;
34 import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT;
35 import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL;
36 import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN;
37 import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN_SPAN;
38 import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY;
39 import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
40 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN;
41 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM;
42 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT;
43 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT;
44 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP;
45 import static com.android.SdkConstants.ATTR_LAYOUT_ROW;
46 import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN;
47 import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF;
48 import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF;
49 import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
50 import static com.android.SdkConstants.ATTR_LAYOUT_X;
51 import static com.android.SdkConstants.ATTR_LAYOUT_Y;
52 import static com.android.SdkConstants.VALUE_FILL_PARENT;
53 import static com.android.SdkConstants.VALUE_MATCH_PARENT;
54 import static com.android.SdkConstants.VALUE_WRAP_CONTENT;
55 
56 import com.android.SdkConstants;
57 import com.android.annotations.NonNull;
58 import com.android.annotations.Nullable;
59 import com.android.ide.common.api.DrawingStyle;
60 import com.android.ide.common.api.DropFeedback;
61 import com.android.ide.common.api.IAttributeInfo;
62 import com.android.ide.common.api.IClientRulesEngine;
63 import com.android.ide.common.api.IDragElement;
64 import com.android.ide.common.api.IDragElement.IDragAttribute;
65 import com.android.ide.common.api.IFeedbackPainter;
66 import com.android.ide.common.api.IGraphics;
67 import com.android.ide.common.api.IMenuCallback;
68 import com.android.ide.common.api.INode;
69 import com.android.ide.common.api.INodeHandler;
70 import com.android.ide.common.api.IViewRule;
71 import com.android.ide.common.api.MarginType;
72 import com.android.ide.common.api.Point;
73 import com.android.ide.common.api.Rect;
74 import com.android.ide.common.api.RuleAction;
75 import com.android.ide.common.api.RuleAction.ChoiceProvider;
76 import com.android.ide.common.api.Segment;
77 import com.android.ide.common.api.SegmentType;
78 import com.android.utils.Pair;
79 
80 import java.net.URL;
81 import java.util.Arrays;
82 import java.util.Collections;
83 import java.util.HashMap;
84 import java.util.HashSet;
85 import java.util.List;
86 import java.util.Map;
87 import java.util.Set;
88 
89 /**
90  * A {@link IViewRule} for all layouts.
91  */
92 public class BaseLayoutRule extends BaseViewRule {
93     private static final String ACTION_FILL_WIDTH = "_fillW";  //$NON-NLS-1$
94     private static final String ACTION_FILL_HEIGHT = "_fillH"; //$NON-NLS-1$
95     private static final String ACTION_MARGIN = "_margin";     //$NON-NLS-1$
96     private static final URL ICON_MARGINS =
97         BaseLayoutRule.class.getResource("margins.png"); //$NON-NLS-1$
98     private static final URL ICON_GRAVITY =
99         BaseLayoutRule.class.getResource("gravity.png"); //$NON-NLS-1$
100     private static final URL ICON_FILL_WIDTH =
101         BaseLayoutRule.class.getResource("fillwidth.png"); //$NON-NLS-1$
102     private static final URL ICON_FILL_HEIGHT =
103         BaseLayoutRule.class.getResource("fillheight.png"); //$NON-NLS-1$
104 
105     // ==== Layout Actions support ====
106 
107     // The Margin layout parameters are available for LinearLayout, FrameLayout, RelativeLayout,
108     // and their subclasses.
createMarginAction(final INode parentNode, final List<? extends INode> children)109     protected final RuleAction createMarginAction(final INode parentNode,
110             final List<? extends INode> children) {
111 
112         final List<? extends INode> targets = children == null || children.size() == 0 ?
113                 Collections.singletonList(parentNode)
114                 : children;
115         final INode first = targets.get(0);
116 
117         IMenuCallback actionCallback = new IMenuCallback() {
118             @Override
119             public void action(@NonNull RuleAction action,
120                     @NonNull List<? extends INode> selectedNodes,
121                     final @Nullable String valueId,
122                     final @Nullable Boolean newValue) {
123                 parentNode.editXml("Change Margins", new INodeHandler() {
124                     @Override
125                     public void handle(@NonNull INode n) {
126                         String uri = ANDROID_URI;
127                         String all = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN);
128                         String left = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_LEFT);
129                         String right = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_RIGHT);
130                         String top = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_TOP);
131                         String bottom = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_BOTTOM);
132                         String[] margins = mRulesEngine.displayMarginInput(all, left,
133                                 right, top, bottom);
134                         if (margins != null) {
135                             assert margins.length == 5;
136                             for (INode child : targets) {
137                                 child.setAttribute(uri, ATTR_LAYOUT_MARGIN, margins[0]);
138                                 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_LEFT, margins[1]);
139                                 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_RIGHT, margins[2]);
140                                 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_TOP, margins[3]);
141                                 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_BOTTOM, margins[4]);
142                             }
143                         }
144                     }
145                 });
146             }
147         };
148 
149         return RuleAction.createAction(ACTION_MARGIN, "Change Margins...", actionCallback,
150                 ICON_MARGINS, 40, false);
151     }
152 
153     // Both LinearLayout and RelativeLayout have a gravity (but RelativeLayout applies it
154     // to the parent whereas for LinearLayout it's on the children)
createGravityAction(final List<? extends INode> targets, final String attributeName)155     protected final RuleAction createGravityAction(final List<? extends INode> targets, final
156             String attributeName) {
157         if (targets != null && targets.size() > 0) {
158             final INode first = targets.get(0);
159             ChoiceProvider provider = new ChoiceProvider() {
160                 @Override
161                 public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls,
162                         @NonNull List<String> ids) {
163                     IAttributeInfo info = first.getAttributeInfo(ANDROID_URI, attributeName);
164                     if (info != null) {
165                         // Generate list of possible gravity value constants
166                         assert info.getFormats().contains(IAttributeInfo.Format.FLAG);
167                         for (String name : info.getFlagValues()) {
168                             titles.add(getAttributeDisplayName(name));
169                             ids.add(name);
170                         }
171                     }
172                 }
173             };
174 
175             return RuleAction.createChoices("_gravity", "Change Gravity", //$NON-NLS-1$
176                     new PropertyCallback(targets, "Change Gravity", ANDROID_URI,
177                             attributeName),
178                     provider,
179                     first.getStringAttr(ANDROID_URI, attributeName), ICON_GRAVITY,
180                     43, false);
181         }
182 
183         return null;
184     }
185 
186     @Override
addLayoutActions( @onNull List<RuleAction> actions, final @NonNull INode parentNode, final @NonNull List<? extends INode> children)187     public void addLayoutActions(
188             @NonNull List<RuleAction> actions,
189             final @NonNull INode parentNode,
190             final @NonNull List<? extends INode> children) {
191         super.addLayoutActions(actions, parentNode, children);
192 
193         final List<? extends INode> targets = children == null || children.size() == 0 ?
194                 Collections.singletonList(parentNode)
195                 : children;
196         final INode first = targets.get(0);
197 
198         // Shared action callback
199         IMenuCallback actionCallback = new IMenuCallback() {
200             @Override
201             public void action(
202                     @NonNull RuleAction action,
203                     @NonNull List<? extends INode> selectedNodes,
204                     final @Nullable String valueId,
205                     final @Nullable Boolean newValue) {
206                 final String actionId = action.getId();
207                 final String undoLabel;
208                 if (actionId.equals(ACTION_FILL_WIDTH)) {
209                     undoLabel = "Change Width Fill";
210                 } else if (actionId.equals(ACTION_FILL_HEIGHT)) {
211                     undoLabel = "Change Height Fill";
212                 } else {
213                     return;
214                 }
215                 parentNode.editXml(undoLabel, new INodeHandler() {
216                     @Override
217                     public void handle(@NonNull INode n) {
218                         String attribute = actionId.equals(ACTION_FILL_WIDTH)
219                                 ? ATTR_LAYOUT_WIDTH : ATTR_LAYOUT_HEIGHT;
220                         String value;
221                         if (newValue) {
222                             if (supportsMatchParent()) {
223                                 value = VALUE_MATCH_PARENT;
224                             } else {
225                                 value = VALUE_FILL_PARENT;
226                             }
227                         } else {
228                             value = VALUE_WRAP_CONTENT;
229                         }
230                         for (INode child : targets) {
231                             child.setAttribute(ANDROID_URI, attribute, value);
232                         }
233                     }
234                 });
235             }
236         };
237 
238         actions.add(RuleAction.createToggle(ACTION_FILL_WIDTH, "Toggle Fill Width",
239                 isFilled(first, ATTR_LAYOUT_WIDTH), actionCallback, ICON_FILL_WIDTH, 10, false));
240         actions.add(RuleAction.createToggle(ACTION_FILL_HEIGHT, "Toggle Fill Height",
241                 isFilled(first, ATTR_LAYOUT_HEIGHT), actionCallback, ICON_FILL_HEIGHT, 20, false));
242     }
243 
244     // ==== Paste support ====
245 
246     /**
247      * The default behavior for pasting in a layout is to simulate a drop in the
248      * top-left corner of the view.
249      * <p/>
250      * Note that we explicitly do not call super() here -- the BaseViewRule.onPaste handler
251      * will call onPasteBeforeChild() instead.
252      * <p/>
253      * Derived layouts should override this behavior if not appropriate.
254      */
255     @Override
onPaste(@onNull INode targetNode, @Nullable Object targetView, @NonNull IDragElement[] elements)256     public void onPaste(@NonNull INode targetNode, @Nullable Object targetView,
257             @NonNull IDragElement[] elements) {
258         DropFeedback feedback = onDropEnter(targetNode, targetView, elements);
259         if (feedback != null) {
260             Point p = targetNode.getBounds().getTopLeft();
261             feedback = onDropMove(targetNode, elements, feedback, p);
262             if (feedback != null) {
263                 onDropLeave(targetNode, elements, feedback);
264                 onDropped(targetNode, elements, feedback, p);
265             }
266         }
267     }
268 
269     /**
270      * The default behavior for pasting in a layout with a specific child target
271      * is to simulate a drop right above the top left of the given child target.
272      * <p/>
273      * This method is invoked by BaseView when onPaste() is called --
274      * views don't generally accept children and instead use the target node as
275      * a hint to paste "before" it.
276      *
277      * @param parentNode the parent node we're pasting into
278      * @param parentView the view object for the parent layout, or null
279      * @param targetNode the first selected node
280      * @param elements the elements being pasted
281      */
onPasteBeforeChild(INode parentNode, Object parentView, INode targetNode, IDragElement[] elements)282     public void onPasteBeforeChild(INode parentNode, Object parentView, INode targetNode,
283             IDragElement[] elements) {
284         DropFeedback feedback = onDropEnter(parentNode, parentView, elements);
285         if (feedback != null) {
286             Point parentP = parentNode.getBounds().getTopLeft();
287             Point targetP = targetNode.getBounds().getTopLeft();
288             if (parentP.y < targetP.y) {
289                 targetP.y -= 1;
290             }
291 
292             feedback = onDropMove(parentNode, elements, feedback, targetP);
293             if (feedback != null) {
294                 onDropLeave(parentNode, elements, feedback);
295                 onDropped(parentNode, elements, feedback, targetP);
296             }
297         }
298     }
299 
300     // ==== Utility methods used by derived layouts ====
301 
302     /**
303      * Draws the bounds of the given elements and all its children elements in the canvas
304      * with the specified offset.
305      *
306      * @param gc the graphics context
307      * @param element the element to be drawn
308      * @param offsetX a horizontal delta to add to the current bounds of the element when
309      *            drawing it
310      * @param offsetY a vertical delta to add to the current bounds of the element when
311      *            drawing it
312      */
drawElement(IGraphics gc, IDragElement element, int offsetX, int offsetY)313     public void drawElement(IGraphics gc, IDragElement element, int offsetX, int offsetY) {
314         Rect b = element.getBounds();
315         if (b.isValid()) {
316             gc.drawRect(b.x + offsetX, b.y + offsetY, b.x + offsetX + b.w, b.y + offsetY + b.h);
317         }
318 
319         for (IDragElement inner : element.getInnerElements()) {
320             drawElement(gc, inner, offsetX, offsetY);
321         }
322     }
323 
324     /**
325      * Collect all the "android:id" IDs from the dropped elements. When moving
326      * objects within the same canvas, that's all there is to do. However if the
327      * objects are moved to a different canvas or are copied then set
328      * createNewIds to true to find the existing IDs under targetNode and create
329      * a map with new non-conflicting unique IDs as needed. Returns a map String
330      * old-id => tuple (String new-id, String fqcn) where fqcn is the FQCN of
331      * the element.
332      */
getDropIdMap(INode targetNode, IDragElement[] elements, boolean createNewIds)333     protected static Map<String, Pair<String, String>> getDropIdMap(INode targetNode,
334             IDragElement[] elements, boolean createNewIds) {
335         Map<String, Pair<String, String>> idMap = new HashMap<String, Pair<String, String>>();
336 
337         if (createNewIds) {
338             collectIds(idMap, elements);
339             // Need to remap ids if necessary
340             idMap = remapIds(targetNode, idMap);
341         }
342 
343         return idMap;
344     }
345 
346     /**
347      * Fills idMap with a map String id => tuple (String id, String fqcn) where
348      * fqcn is the FQCN of the element (in case we want to generate new IDs
349      * based on the element type.)
350      *
351      * @see #getDropIdMap
352      */
collectIds( Map<String, Pair<String, String>> idMap, IDragElement[] elements)353     protected static Map<String, Pair<String, String>> collectIds(
354             Map<String, Pair<String, String>> idMap,
355             IDragElement[] elements) {
356         for (IDragElement element : elements) {
357             IDragAttribute attr = element.getAttribute(ANDROID_URI, ATTR_ID);
358             if (attr != null) {
359                 String id = attr.getValue();
360                 if (id != null && id.length() > 0) {
361                     idMap.put(id, Pair.of(id, element.getFqcn()));
362                 }
363             }
364 
365             collectIds(idMap, element.getInnerElements());
366         }
367 
368         return idMap;
369     }
370 
371     /**
372      * Used by #getDropIdMap to find new IDs in case of conflict.
373      */
remapIds(INode node, Map<String, Pair<String, String>> idMap)374     protected static Map<String, Pair<String, String>> remapIds(INode node,
375             Map<String, Pair<String, String>> idMap) {
376         // Visit the document to get a list of existing ids
377         Set<String> existingIdSet = new HashSet<String>();
378         collectExistingIds(node.getRoot(), existingIdSet);
379 
380         Map<String, Pair<String, String>> new_map = new HashMap<String, Pair<String, String>>();
381         for (Map.Entry<String, Pair<String, String>> entry : idMap.entrySet()) {
382             String key = entry.getKey();
383             Pair<String, String> value = entry.getValue();
384 
385             String id = normalizeId(key);
386 
387             if (!existingIdSet.contains(id)) {
388                 // Not a conflict. Use as-is.
389                 new_map.put(key, value);
390                 if (!key.equals(id)) {
391                     new_map.put(id, value);
392                 }
393             } else {
394                 // There is a conflict. Get a new id.
395                 String new_id = findNewId(value.getSecond(), existingIdSet);
396                 value = Pair.of(new_id, value.getSecond());
397                 new_map.put(id, value);
398                 new_map.put(id.replaceFirst("@\\+", "@"), value); //$NON-NLS-1$ //$NON-NLS-2$
399             }
400         }
401 
402         return new_map;
403     }
404 
405     /**
406      * Used by #remapIds to find a new ID for a conflicting element.
407      */
findNewId(String fqcn, Set<String> existingIdSet)408     protected static String findNewId(String fqcn, Set<String> existingIdSet) {
409         // Get the last component of the FQCN (e.g. "android.view.Button" =>
410         // "Button")
411         String name = fqcn.substring(fqcn.lastIndexOf('.') + 1);
412 
413         for (int i = 1; i < 1000000; i++) {
414             String id = String.format("@+id/%s%02d", name, i); //$NON-NLS-1$
415             if (!existingIdSet.contains(id)) {
416                 existingIdSet.add(id);
417                 return id;
418             }
419         }
420 
421         // We'll never reach here.
422         return null;
423     }
424 
425     /**
426      * Used by #getDropIdMap to find existing IDs recursively.
427      */
collectExistingIds(INode root, Set<String> existingIdSet)428     protected static void collectExistingIds(INode root, Set<String> existingIdSet) {
429         if (root == null) {
430             return;
431         }
432 
433         String id = root.getStringAttr(ANDROID_URI, ATTR_ID);
434         if (id != null) {
435             id = normalizeId(id);
436 
437             if (!existingIdSet.contains(id)) {
438                 existingIdSet.add(id);
439             }
440         }
441 
442         for (INode child : root.getChildren()) {
443             collectExistingIds(child, existingIdSet);
444         }
445     }
446 
447     /**
448      * Transforms @id/name into @+id/name to treat both forms the same way.
449      */
normalizeId(String id)450     protected static String normalizeId(String id) {
451         if (id.indexOf("@+") == -1) { //$NON-NLS-1$
452             id = id.replaceFirst("@", "@+"); //$NON-NLS-1$ //$NON-NLS-2$
453         }
454         return id;
455     }
456 
457     /**
458      * For use by {@link BaseLayoutRule#addAttributes} A filter should return a
459      * valid replacement string.
460      */
461     protected static interface AttributeFilter {
replace(String attributeUri, String attributeName, String attributeValue)462         String replace(String attributeUri, String attributeName, String attributeValue);
463     }
464 
465     private static final String[] EXCLUDED_ATTRIBUTES = new String[] {
466         // Common
467         ATTR_LAYOUT_GRAVITY,
468 
469         // from AbsoluteLayout
470         ATTR_LAYOUT_X,
471         ATTR_LAYOUT_Y,
472 
473         // from RelativeLayout
474         ATTR_LAYOUT_ABOVE,
475         ATTR_LAYOUT_BELOW,
476         ATTR_LAYOUT_TO_LEFT_OF,
477         ATTR_LAYOUT_TO_RIGHT_OF,
478         ATTR_LAYOUT_ALIGN_BASELINE,
479         ATTR_LAYOUT_ALIGN_TOP,
480         ATTR_LAYOUT_ALIGN_BOTTOM,
481         ATTR_LAYOUT_ALIGN_LEFT,
482         ATTR_LAYOUT_ALIGN_RIGHT,
483         ATTR_LAYOUT_ALIGN_PARENT_TOP,
484         ATTR_LAYOUT_ALIGN_PARENT_BOTTOM,
485         ATTR_LAYOUT_ALIGN_PARENT_LEFT,
486         ATTR_LAYOUT_ALIGN_PARENT_RIGHT,
487         ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING,
488         ATTR_LAYOUT_CENTER_HORIZONTAL,
489         ATTR_LAYOUT_CENTER_IN_PARENT,
490         ATTR_LAYOUT_CENTER_VERTICAL,
491 
492         // From GridLayout
493         ATTR_LAYOUT_ROW,
494         ATTR_LAYOUT_ROW_SPAN,
495         ATTR_LAYOUT_COLUMN,
496         ATTR_LAYOUT_COLUMN_SPAN
497     };
498 
499     /**
500      * Default attribute filter used by the various layouts to filter out some properties
501      * we don't want to offer.
502      */
503     public static final AttributeFilter DEFAULT_ATTR_FILTER = new AttributeFilter() {
504         Set<String> mExcludes;
505 
506         @Override
507         public String replace(String uri, String name, String value) {
508             if (!ANDROID_URI.equals(uri)) {
509                 return value;
510             }
511 
512             if (mExcludes == null) {
513                 mExcludes = new HashSet<String>(EXCLUDED_ATTRIBUTES.length);
514                 mExcludes.addAll(Arrays.asList(EXCLUDED_ATTRIBUTES));
515             }
516 
517             return mExcludes.contains(name) ? null : value;
518         }
519     };
520 
521     /**
522      * Copies all the attributes from oldElement to newNode. Uses the idMap to
523      * transform the value of all attributes of Format.REFERENCE. If filter is
524      * non-null, it's a filter that can rewrite the attribute string.
525      */
addAttributes(INode newNode, IDragElement oldElement, Map<String, Pair<String, String>> idMap, AttributeFilter filter)526     protected static void addAttributes(INode newNode, IDragElement oldElement,
527             Map<String, Pair<String, String>> idMap, AttributeFilter filter) {
528 
529         for (IDragAttribute attr : oldElement.getAttributes()) {
530             String uri = attr.getUri();
531             String name = attr.getName();
532             String value = attr.getValue();
533 
534             IAttributeInfo attrInfo = newNode.getAttributeInfo(uri, name);
535             if (attrInfo != null) {
536                 if (attrInfo.getFormats().contains(IAttributeInfo.Format.REFERENCE)) {
537                     if (idMap.containsKey(value)) {
538                         value = idMap.get(value).getFirst();
539                     }
540                 }
541             }
542 
543             if (filter != null) {
544                 value = filter.replace(uri, name, value);
545             }
546             if (value != null && value.length() > 0) {
547                 newNode.setAttribute(uri, name, value);
548             }
549         }
550     }
551 
552     /**
553      * Adds all the children elements of oldElement to newNode, recursively.
554      * Attributes are adjusted by calling addAttributes with idMap as necessary,
555      * with no closure filter.
556      */
addInnerElements(INode newNode, IDragElement oldElement, Map<String, Pair<String, String>> idMap)557     protected static void addInnerElements(INode newNode, IDragElement oldElement,
558             Map<String, Pair<String, String>> idMap) {
559 
560         for (IDragElement element : oldElement.getInnerElements()) {
561             String fqcn = element.getFqcn();
562             INode childNode = newNode.appendChild(fqcn);
563 
564             addAttributes(childNode, element, idMap, null /* filter */);
565             addInnerElements(childNode, element, idMap);
566         }
567     }
568 
569     /**
570      * Insert the given elements into the given node at the given position
571      *
572      * @param targetNode the node to insert into
573      * @param elements the elements to insert
574      * @param createNewIds if true, generate new ids when there is a conflict
575      * @param initialInsertPos index among targetnode's children which to insert the
576      *            children
577      */
insertAt(final INode targetNode, final IDragElement[] elements, final boolean createNewIds, final int initialInsertPos)578     public static void insertAt(final INode targetNode, final IDragElement[] elements,
579             final boolean createNewIds, final int initialInsertPos) {
580 
581         // Collect IDs from dropped elements and remap them to new IDs
582         // if this is a copy or from a different canvas.
583         final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements,
584                 createNewIds);
585 
586         targetNode.editXml("Insert Elements", new INodeHandler() {
587 
588             @Override
589             public void handle(@NonNull INode node) {
590                 // Now write the new elements.
591                 int insertPos = initialInsertPos;
592                 for (IDragElement element : elements) {
593                     String fqcn = element.getFqcn();
594 
595                     INode newChild = targetNode.insertChildAt(fqcn, insertPos);
596 
597                     // insertPos==-1 means to insert at the end. Otherwise
598                     // increment the insertion position.
599                     if (insertPos >= 0) {
600                         insertPos++;
601                     }
602 
603                     // Copy all the attributes, modifying them as needed.
604                     addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER);
605                     addInnerElements(newChild, element, idMap);
606                 }
607             }
608         });
609     }
610 
611     // ---- Resizing ----
612 
613     /** Creates a new {@link ResizeState} object to track resize state */
createResizeState(INode layout, Object layoutView, INode node)614     protected ResizeState createResizeState(INode layout, Object layoutView, INode node) {
615         return new ResizeState(this, layout, layoutView, node);
616     }
617 
618     @Override
onResizeBegin(@onNull INode child, @NonNull INode parent, @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge, @Nullable Object childView, @Nullable Object parentView)619     public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent,
620             @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge,
621             @Nullable Object childView, @Nullable Object parentView) {
622         ResizeState state = createResizeState(parent, parentView, child);
623         state.horizontalEdgeType = horizontalEdge;
624         state.verticalEdgeType = verticalEdge;
625 
626         // Compute preferred (wrap_content) size such that we can offer guidelines to
627         // snap to the preferred size
628         Map<INode, Rect> sizes = mRulesEngine.measureChildren(parent,
629                 new IClientRulesEngine.AttributeFilter() {
630                     @Override
631                     public String getAttribute(@NonNull INode node, @Nullable String namespace,
632                             @NonNull String localName) {
633                         // Change attributes to wrap_content
634                         if (ATTR_LAYOUT_WIDTH.equals(localName)
635                                 && SdkConstants.NS_RESOURCES.equals(namespace)) {
636                             return VALUE_WRAP_CONTENT;
637                         }
638                         if (ATTR_LAYOUT_HEIGHT.equals(localName)
639                                 && SdkConstants.NS_RESOURCES.equals(namespace)) {
640                             return VALUE_WRAP_CONTENT;
641                         }
642 
643                         return null;
644                     }
645                 });
646         if (sizes != null) {
647             state.wrapBounds = sizes.get(child);
648         }
649 
650         return new DropFeedback(state, new IFeedbackPainter() {
651             @Override
652             public void paint(@NonNull IGraphics gc, @NonNull INode node,
653                     @NonNull DropFeedback feedback) {
654                 ResizeState resizeState = (ResizeState) feedback.userData;
655                 if (resizeState != null && resizeState.bounds != null) {
656                     paintResizeFeedback(gc, node, resizeState);
657                 }
658             }
659         });
660     }
661 
662     protected void paintResizeFeedback(IGraphics gc, INode node, ResizeState resizeState) {
663         gc.useStyle(DrawingStyle.RESIZE_PREVIEW);
664         Rect b = resizeState.bounds;
665         gc.drawRect(b);
666 
667         if (resizeState.horizontalFillSegment != null) {
668             gc.useStyle(DrawingStyle.GUIDELINE);
669             Segment s = resizeState.horizontalFillSegment;
670             gc.drawLine(s.from, s.at, s.to, s.at);
671         }
672         if (resizeState.verticalFillSegment != null) {
673             gc.useStyle(DrawingStyle.GUIDELINE);
674             Segment s = resizeState.verticalFillSegment;
675             gc.drawLine(s.at, s.from, s.at, s.to);
676         }
677 
678         if (resizeState.wrapBounds != null) {
679             gc.useStyle(DrawingStyle.GUIDELINE);
680             int wrapWidth = resizeState.wrapBounds.w;
681             int wrapHeight = resizeState.wrapBounds.h;
682 
683             // Show the "wrap_content" guideline.
684             // If we are showing both the wrap_width and wrap_height lines
685             // then we show at most the rectangle formed by the two lines;
686             // otherwise we show the entire width of the line
687             if (resizeState.horizontalEdgeType != null) {
688                 int y = -1;
689                 switch (resizeState.horizontalEdgeType) {
690                     case TOP:
691                         y = b.y + b.h - wrapHeight;
692                         break;
693                     case BOTTOM:
694                         y = b.y + wrapHeight;
695                         break;
696                     default: assert false : resizeState.horizontalEdgeType;
697                 }
698                 if (resizeState.verticalEdgeType != null) {
699                     switch (resizeState.verticalEdgeType) {
700                         case LEFT:
701                             gc.drawLine(b.x + b.w - wrapWidth, y, b.x + b.w, y);
702                             break;
703                         case RIGHT:
704                             gc.drawLine(b.x, y, b.x + wrapWidth, y);
705                             break;
706                         default: assert false : resizeState.verticalEdgeType;
707                     }
708                 } else {
709                     gc.drawLine(b.x, y, b.x + b.w, y);
710                 }
711             }
712             if (resizeState.verticalEdgeType != null) {
713                 int x = -1;
714                 switch (resizeState.verticalEdgeType) {
715                     case LEFT:
716                         x = b.x + b.w - wrapWidth;
717                         break;
718                     case RIGHT:
719                         x = b.x + wrapWidth;
720                         break;
721                     default: assert false : resizeState.verticalEdgeType;
722                 }
723                 if (resizeState.horizontalEdgeType != null) {
724                     switch (resizeState.horizontalEdgeType) {
725                         case TOP:
726                             gc.drawLine(x, b.y + b.h - wrapHeight, x, b.y + b.h);
727                             break;
728                         case BOTTOM:
729                             gc.drawLine(x, b.y, x, b.y + wrapHeight);
730                             break;
731                         default: assert false : resizeState.horizontalEdgeType;
732                     }
733                 } else {
734                     gc.drawLine(x, b.y, x, b.y + b.h);
735                 }
736             }
737         }
738     }
739 
740     /**
741      * Returns the maximum number of pixels will be considered a "match" when snapping
742      * resize or move positions to edges or other constraints
743      *
744      * @return the maximum number of pixels to consider for snapping
745      */
746     public static final int getMaxMatchDistance() {
747         // TODO - make constant once we're happy with the feel
748         return 20;
749     }
750 
751     @Override
752     public void onResizeUpdate(@Nullable DropFeedback feedback, @NonNull INode child,
753             @NonNull INode parent, @NonNull Rect newBounds, int modifierMask) {
754         ResizeState state = (ResizeState) feedback.userData;
755         state.bounds = newBounds;
756         state.modifierMask = modifierMask;
757 
758         // Match on wrap bounds
759         state.wrapWidth = state.wrapHeight = false;
760         if (state.wrapBounds != null) {
761             Rect b = state.wrapBounds;
762             int maxMatchDistance = getMaxMatchDistance();
763             if (state.horizontalEdgeType != null) {
764                 if (Math.abs(newBounds.h - b.h) < maxMatchDistance) {
765                     state.wrapHeight = true;
766                     if (state.horizontalEdgeType == SegmentType.TOP) {
767                         newBounds.y += newBounds.h - b.h;
768                     }
769                     newBounds.h = b.h;
770                 }
771             }
772             if (state.verticalEdgeType != null) {
773                 if (Math.abs(newBounds.w - b.w) < maxMatchDistance) {
774                     state.wrapWidth = true;
775                     if (state.verticalEdgeType == SegmentType.LEFT) {
776                         newBounds.x += newBounds.w - b.w;
777                     }
778                     newBounds.w = b.w;
779                 }
780             }
781         }
782 
783         // Match on fill bounds
784         state.horizontalFillSegment = null;
785         state.fillHeight = false;
786         if (state.horizontalEdgeType == SegmentType.BOTTOM && !state.wrapHeight) {
787             Rect parentBounds = parent.getBounds();
788             state.horizontalFillSegment = new Segment(parentBounds.y2(), newBounds.x,
789                 newBounds.x2(),
790                 null /*node*/, null /*id*/, SegmentType.BOTTOM, MarginType.NO_MARGIN);
791             if (Math.abs(newBounds.y2() - parentBounds.y2()) < getMaxMatchDistance()) {
792                 state.fillHeight = true;
793                 newBounds.h = parentBounds.y2() - newBounds.y;
794             }
795         }
796         state.verticalFillSegment = null;
797         state.fillWidth = false;
798         if (state.verticalEdgeType == SegmentType.RIGHT && !state.wrapWidth) {
799             Rect parentBounds = parent.getBounds();
800             state.verticalFillSegment = new Segment(parentBounds.x2(), newBounds.y,
801                 newBounds.y2(),
802                 null /*node*/, null /*id*/, SegmentType.RIGHT, MarginType.NO_MARGIN);
803             if (Math.abs(newBounds.x2() - parentBounds.x2()) < getMaxMatchDistance()) {
804                 state.fillWidth = true;
805                 newBounds.w = parentBounds.x2() - newBounds.x;
806             }
807         }
808 
809         feedback.tooltip = getResizeUpdateMessage(state, child, parent,
810                 newBounds, state.horizontalEdgeType, state.verticalEdgeType);
811     }
812 
813     @Override
814     public void onResizeEnd(@Nullable DropFeedback feedback, @NonNull INode child,
815             final @NonNull INode parent, final @NonNull Rect newBounds) {
816         final Rect oldBounds = child.getBounds();
817         if (oldBounds.w != newBounds.w || oldBounds.h != newBounds.h) {
818             final ResizeState state = (ResizeState) feedback.userData;
819             child.editXml("Resize", new INodeHandler() {
820                 @Override
821                 public void handle(@NonNull INode n) {
822                     setNewSizeBounds(state, n, parent, oldBounds, newBounds,
823                             state.horizontalEdgeType, state.verticalEdgeType);
824                 }
825             });
826         }
827     }
828 
829     /**
830      * Returns the message to display to the user during the resize operation
831      *
832      * @param resizeState the current resize state
833      * @param child the child node being resized
834      * @param parent the parent of the resized node
835      * @param newBounds the new bounds to resize the child to, in pixels
836      * @param horizontalEdge the horizontal edge being resized
837      * @param verticalEdge the vertical edge being resized
838      * @return the message to display for the current resize bounds
839      */
840     protected String getResizeUpdateMessage(ResizeState resizeState, INode child, INode parent,
841             Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) {
842         String width = resizeState.getWidthAttribute();
843         String height = resizeState.getHeightAttribute();
844 
845         if (horizontalEdge == null) {
846             return width;
847         } else if (verticalEdge == null) {
848             return height;
849         } else {
850             // U+00D7: Unicode for multiplication sign
851             return String.format("%s \u00D7 %s", width, height);
852         }
853     }
854 
855     /**
856      * Performs the edit on the node to complete a resizing operation. The actual edit
857      * part is pulled out such that subclasses can change/add to the edits and be part of
858      * the same undo event
859      *
860      * @param resizeState the current resize state
861      * @param node the child node being resized
862      * @param layout the parent of the resized node
863      * @param newBounds the new bounds to resize the child to, in pixels
864      * @param horizontalEdge the horizontal edge being resized
865      * @param verticalEdge the vertical edge being resized
866      */
867     protected void setNewSizeBounds(ResizeState resizeState, INode node, INode layout,
868             Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) {
869         if (verticalEdge != null
870             && (newBounds.w != oldBounds.w || resizeState.wrapWidth || resizeState.fillWidth)) {
871             node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, resizeState.getWidthAttribute());
872         }
873         if (horizontalEdge != null
874             && (newBounds.h != oldBounds.h || resizeState.wrapHeight || resizeState.fillHeight)) {
875             node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, resizeState.getHeightAttribute());
876         }
877     }
878 }
879