1 /*
2  * Copyright (C) 2012 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 package com.android.ide.eclipse.adt.internal.editors.layout.refactoring;
17 
18 import static com.android.SdkConstants.ANDROID_URI;
19 import static com.android.SdkConstants.ATTR_DRAWABLE_BOTTOM;
20 import static com.android.SdkConstants.ATTR_DRAWABLE_LEFT;
21 import static com.android.SdkConstants.ATTR_DRAWABLE_PADDING;
22 import static com.android.SdkConstants.ATTR_DRAWABLE_RIGHT;
23 import static com.android.SdkConstants.ATTR_DRAWABLE_TOP;
24 import static com.android.SdkConstants.ATTR_GRAVITY;
25 import static com.android.SdkConstants.ATTR_ID;
26 import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
27 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM;
28 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT;
29 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT;
30 import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP;
31 import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX;
32 import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
33 import static com.android.SdkConstants.ATTR_ORIENTATION;
34 import static com.android.SdkConstants.ATTR_SRC;
35 import static com.android.SdkConstants.EXT_XML;
36 import static com.android.SdkConstants.IMAGE_VIEW;
37 import static com.android.SdkConstants.LINEAR_LAYOUT;
38 import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
39 import static com.android.SdkConstants.TEXT_VIEW;
40 import static com.android.SdkConstants.VALUE_VERTICAL;
41 
42 import com.android.annotations.NonNull;
43 import com.android.annotations.Nullable;
44 import com.android.annotations.VisibleForTesting;
45 import com.android.ide.common.xml.XmlFormatStyle;
46 import com.android.ide.eclipse.adt.AdtUtils;
47 import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlFormatPreferences;
48 import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlPrettyPrinter;
49 import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate;
50 import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo;
51 import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities;
52 import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs;
53 
54 import org.eclipse.core.resources.IFile;
55 import org.eclipse.core.runtime.CoreException;
56 import org.eclipse.core.runtime.IProgressMonitor;
57 import org.eclipse.core.runtime.OperationCanceledException;
58 import org.eclipse.jface.text.ITextSelection;
59 import org.eclipse.jface.viewers.ITreeSelection;
60 import org.eclipse.ltk.core.refactoring.Change;
61 import org.eclipse.ltk.core.refactoring.Refactoring;
62 import org.eclipse.ltk.core.refactoring.RefactoringStatus;
63 import org.eclipse.ltk.core.refactoring.TextFileChange;
64 import org.eclipse.text.edits.MultiTextEdit;
65 import org.eclipse.text.edits.ReplaceEdit;
66 import org.eclipse.text.edits.TextEdit;
67 import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
68 import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion;
69 import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument;
70 import org.w3c.dom.Attr;
71 import org.w3c.dom.Document;
72 import org.w3c.dom.Element;
73 import org.w3c.dom.NamedNodeMap;
74 
75 import java.util.ArrayList;
76 import java.util.List;
77 import java.util.Map;
78 import java.util.regex.Matcher;
79 import java.util.regex.Pattern;
80 
81 /**
82  * Converts a LinearLayout with exactly a TextView child and an ImageView child into
83  * a single TextView with a compound drawable.
84  */
85 @SuppressWarnings("restriction") // XML model
86 public class UseCompoundDrawableRefactoring extends VisualRefactoring {
87     /**
88      * Constructs a new {@link UseCompoundDrawableRefactoring}
89      *
90      * @param file the file to refactor in
91      * @param editor the corresponding editor
92      * @param selection the editor selection, or null
93      * @param treeSelection the canvas selection, or null
94      */
UseCompoundDrawableRefactoring(IFile file, LayoutEditorDelegate editor, ITextSelection selection, ITreeSelection treeSelection)95     public UseCompoundDrawableRefactoring(IFile file, LayoutEditorDelegate editor,
96             ITextSelection selection, ITreeSelection treeSelection) {
97         super(file, editor, selection, treeSelection);
98     }
99 
100     /**
101      * This constructor is solely used by {@link Descriptor}, to replay a
102      * previous refactoring.
103      *
104      * @param arguments argument map created by #createArgumentMap.
105      */
UseCompoundDrawableRefactoring(Map<String, String> arguments)106     private UseCompoundDrawableRefactoring(Map<String, String> arguments) {
107         super(arguments);
108     }
109 
110     @VisibleForTesting
UseCompoundDrawableRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor)111     UseCompoundDrawableRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) {
112         super(selectedElements, editor);
113     }
114 
115     @Override
checkInitialConditions(IProgressMonitor pm)116     public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException,
117             OperationCanceledException {
118         RefactoringStatus status = new RefactoringStatus();
119 
120         try {
121             pm.beginTask("Checking preconditions...", 6);
122 
123             if (mSelectionStart == -1 || mSelectionEnd == -1) {
124                 status.addFatalError("Nothing to convert");
125                 return status;
126             }
127 
128             // Make sure the selection is contiguous
129             if (mTreeSelection != null) {
130                 List<CanvasViewInfo> infos = getSelectedViewInfos();
131                 if (!validateNotEmpty(infos, status)) {
132                     return status;
133                 }
134 
135                 // Enforce that the selection is -contiguous-
136                 if (!validateContiguous(infos, status)) {
137                     return status;
138                 }
139             }
140 
141             // Ensures that we have a valid DOM model:
142             if (mElements.size() == 0) {
143                 status.addFatalError("Nothing to convert");
144                 return status;
145             }
146 
147             // Ensure that we have selected precisely one LinearLayout
148             if (mElements.size() != 1 ||
149                     !(mElements.get(0).getTagName().equals(LINEAR_LAYOUT))) {
150                 status.addFatalError("Must select exactly one LinearLayout");
151                 return status;
152             }
153 
154             Element layout = mElements.get(0);
155             List<Element> children = DomUtilities.getChildren(layout);
156             if (children.size() != 2) {
157                 status.addFatalError("The LinearLayout must have exactly two children");
158                 return status;
159             }
160             Element first = children.get(0);
161             Element second = children.get(1);
162             boolean haveTextView =
163                     first.getTagName().equals(TEXT_VIEW)
164                     || second.getTagName().equals(TEXT_VIEW);
165             boolean haveImageView =
166                     first.getTagName().equals(IMAGE_VIEW)
167                     || second.getTagName().equals(IMAGE_VIEW);
168             if (!(haveTextView && haveImageView)) {
169                 status.addFatalError("The LinearLayout must have exactly one TextView child " +
170                         "and one ImageView child");
171                 return status;
172             }
173 
174             pm.worked(1);
175             return status;
176 
177         } finally {
178             pm.done();
179         }
180     }
181 
182     @Override
createDescriptor()183     protected VisualRefactoringDescriptor createDescriptor() {
184         String comment = getName();
185         return new Descriptor(
186                 mProject.getName(), //project
187                 comment, //description
188                 comment, //comment
189                 createArgumentMap());
190     }
191 
192     @Override
createArgumentMap()193     protected Map<String, String> createArgumentMap() {
194         return super.createArgumentMap();
195     }
196 
197     @Override
getName()198     public String getName() {
199         return "Convert to Compound Drawable";
200     }
201 
202     @Override
computeChanges(IProgressMonitor monitor)203     protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) {
204         String androidNsPrefix = getAndroidNamespacePrefix();
205         IFile file = mDelegate.getEditor().getInputFile();
206         List<Change> changes = new ArrayList<Change>();
207         if (file == null) {
208             return changes;
209         }
210         TextFileChange change = new TextFileChange(file.getName(), file);
211         MultiTextEdit rootEdit = new MultiTextEdit();
212         change.setTextType(EXT_XML);
213 
214         // (1) Build up the contents of the new TextView. This is identical
215         //     to the old contents, but with the addition of a drawableTop/Left/Right/Bottom
216         //     attribute (depending on the orientation and order), as well as any layout
217         //     params from the LinearLayout.
218         // (2) Delete the linear layout and replace with the text view.
219         // (3) Reformat.
220 
221         // checkInitialConditions has already validated that we have exactly a LinearLayout
222         // with an ImageView and a TextView child (in either order)
223         Element layout = mElements.get(0);
224         List<Element> children = DomUtilities.getChildren(layout);
225         Element first = children.get(0);
226         Element second = children.get(1);
227         final Element text;
228         final Element image;
229         if (first.getTagName().equals(TEXT_VIEW)) {
230             text = first;
231             image = second;
232         } else {
233             text = second;
234             image = first;
235         }
236 
237         // Horizontal is the default, so if no value is specified it is horizontal.
238         boolean isVertical = VALUE_VERTICAL.equals(layout.getAttributeNS(ANDROID_URI,
239                 ATTR_ORIENTATION));
240 
241         // The WST DOM implementation doesn't correctly implement cloneNode: this returns
242         // an empty document instead:
243         //   text.getOwnerDocument().cloneNode(false/*deep*/);
244         // Luckily we just need to clone a single element, not a nested structure, so it's
245         // easy enough to do this manually:
246         Document tempDocument = DomUtilities.createEmptyDocument();
247         if (tempDocument == null) {
248             return changes;
249         }
250         Element newTextElement = tempDocument.createElement(text.getTagName());
251         tempDocument.appendChild(newTextElement);
252 
253         NamedNodeMap attributes =  text.getAttributes();
254         for (int i = 0, n = attributes.getLength(); i < n; i++) {
255             Attr attribute = (Attr) attributes.item(i);
256             String name = attribute.getLocalName();
257             if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)
258                     && ANDROID_URI.equals(attribute.getNamespaceURI())
259                     && !(name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT))) {
260                 // Ignore layout params: the parent layout is going away
261             } else {
262                 newTextElement.setAttribute(attribute.getName(), attribute.getValue());
263             }
264         }
265 
266         // Apply all layout params from the parent (except width and height),
267         // as well as android:gravity
268         List<Attr> layoutAttributes = findLayoutAttributes(layout);
269         for (Attr attribute : layoutAttributes) {
270             String name = attribute.getLocalName();
271             if ((name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT))
272                     && ANDROID_URI.equals(attribute.getNamespaceURI())) {
273                 // Already handled specially
274                 continue;
275             }
276             newTextElement.setAttribute(attribute.getName(), attribute.getValue());
277         }
278         String gravity = layout.getAttributeNS(ANDROID_URI, ATTR_GRAVITY);
279         if (gravity.length() > 0) {
280             setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_GRAVITY, gravity);
281         }
282 
283         String src = image.getAttributeNS(ANDROID_URI, ATTR_SRC);
284 
285         // Set the drawable
286         String drawableAttribute;
287         // The space between the image and the text can have margins/padding, both
288         // from the text's perspective and from the image's perspective. We need to
289         // combine these.
290         String padding1 = null;
291         String padding2 = null;
292         if (isVertical) {
293             if (first == image) {
294                 drawableAttribute = ATTR_DRAWABLE_TOP;
295                 padding1 = getPadding(image, ATTR_LAYOUT_MARGIN_BOTTOM);
296                 padding2 = getPadding(text, ATTR_LAYOUT_MARGIN_TOP);
297             } else {
298                 drawableAttribute = ATTR_DRAWABLE_BOTTOM;
299                 padding1 = getPadding(text, ATTR_LAYOUT_MARGIN_BOTTOM);
300                 padding2 = getPadding(image, ATTR_LAYOUT_MARGIN_TOP);
301             }
302         } else {
303             if (first == image) {
304                 drawableAttribute = ATTR_DRAWABLE_LEFT;
305                 padding1 = getPadding(image, ATTR_LAYOUT_MARGIN_RIGHT);
306                 padding2 = getPadding(text, ATTR_LAYOUT_MARGIN_LEFT);
307             } else {
308                 drawableAttribute = ATTR_DRAWABLE_RIGHT;
309                 padding1 = getPadding(text, ATTR_LAYOUT_MARGIN_RIGHT);
310                 padding2 = getPadding(image, ATTR_LAYOUT_MARGIN_LEFT);
311             }
312         }
313 
314         setAndroidAttribute(newTextElement, androidNsPrefix, drawableAttribute, src);
315 
316         String padding = combine(padding1, padding2);
317         if (padding != null) {
318             setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_DRAWABLE_PADDING, padding);
319         }
320 
321         // If the removed LinearLayout is the root container, transfer its namespace
322         // declaration to the TextView
323         if (layout.getParentNode() instanceof Document) {
324             List<Attr> declarations = findNamespaceAttributes(layout);
325             for (Attr attribute : declarations) {
326                 if (attribute instanceof IndexedRegion) {
327                     newTextElement.setAttribute(attribute.getName(), attribute.getValue());
328                 }
329             }
330         }
331 
332         // Update any layout references to the layout to point to the text view
333         String layoutId = getId(layout);
334         if (layoutId.length() > 0) {
335             String id = getId(text);
336             if (id.length() == 0) {
337                 id = ensureHasId(rootEdit, text, null, false);
338                 setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_ID, id);
339             }
340 
341             IStructuredModel model = mDelegate.getEditor().getModelForRead();
342             try {
343                 IStructuredDocument doc = model.getStructuredDocument();
344                 if (doc != null) {
345                     List<TextEdit> replaceIds = replaceIds(androidNsPrefix,
346                             doc, mSelectionStart, mSelectionEnd, layoutId, id);
347                     for (TextEdit edit : replaceIds) {
348                         rootEdit.addChild(edit);
349                     }
350                 }
351             } finally {
352                 model.releaseFromRead();
353             }
354         }
355 
356         String xml = EclipseXmlPrettyPrinter.prettyPrint(
357                 tempDocument.getDocumentElement(),
358                 EclipseXmlFormatPreferences.create(),
359                 XmlFormatStyle.LAYOUT, null, false);
360 
361         TextEdit replace = new ReplaceEdit(mSelectionStart, mSelectionEnd - mSelectionStart, xml);
362         rootEdit.addChild(replace);
363 
364         if (AdtPrefs.getPrefs().getFormatGuiXml()) {
365             MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT);
366             if (formatted != null) {
367                 rootEdit = formatted;
368             }
369         }
370 
371         change.setEdit(rootEdit);
372         changes.add(change);
373         return changes;
374     }
375 
376     @Nullable
getPadding(@onNull Element element, @NonNull String attribute)377     private static String getPadding(@NonNull Element element, @NonNull String attribute) {
378         String padding = element.getAttributeNS(ANDROID_URI, attribute);
379         if (padding != null && padding.isEmpty()) {
380             padding = null;
381         }
382         return padding;
383     }
384 
385     @VisibleForTesting
386     @Nullable
combine(@ullable String dimension1, @Nullable String dimension2)387     static String combine(@Nullable String dimension1, @Nullable String dimension2) {
388         if (dimension1 == null || dimension1.isEmpty()) {
389             if (dimension2 != null && dimension2.isEmpty()) {
390                 return null;
391             }
392             return dimension2;
393         } else if (dimension2 == null || dimension2.isEmpty()) {
394             if (dimension1 != null && dimension1.isEmpty()) {
395                 return null;
396             }
397             return dimension1;
398         } else {
399             // Two dimensions are specified (e.g. marginRight for the left one and marginLeft
400             // for the right one); we have to add these together. We can only do that if
401             // they use the same units, and do not use resources.
402             if (dimension1.startsWith(PREFIX_RESOURCE_REF)
403                     || dimension2.startsWith(PREFIX_RESOURCE_REF)) {
404                 return null;
405             }
406 
407             Pattern p = Pattern.compile("([\\d\\.]+)(.+)"); //$NON-NLS-1$
408             Matcher matcher1 = p.matcher(dimension1);
409             Matcher matcher2 = p.matcher(dimension2);
410             if (matcher1.matches() && matcher2.matches()) {
411                 String unit = matcher1.group(2);
412                 if (unit.equals(matcher2.group(2))) {
413                     float value1 = Float.parseFloat(matcher1.group(1));
414                     float value2 = Float.parseFloat(matcher2.group(1));
415                     return AdtUtils.formatFloatAttribute(value1 + value2) + unit;
416                 }
417             }
418         }
419 
420         return null;
421     }
422 
423     /**
424      * Sets an Android attribute (in the Android namespace) on an element
425      * without a given namespace prefix. This is done when building a new Element
426      * in a temporary document such that the namespace prefix matches when the element is
427      * formatted and replaced in the target document.
428      */
setAndroidAttribute(Element element, String prefix, String name, String value)429     private static void setAndroidAttribute(Element element, String prefix, String name,
430             String value) {
431         element.setAttribute(prefix + ':' + name, value);
432     }
433 
434     @Override
createWizard()435     public VisualRefactoringWizard createWizard() {
436         return new UseCompoundDrawableWizard(this, mDelegate);
437     }
438 
439     @SuppressWarnings("javadoc")
440     public static class Descriptor extends VisualRefactoringDescriptor {
Descriptor(String project, String description, String comment, Map<String, String> arguments)441         public Descriptor(String project, String description, String comment,
442                 Map<String, String> arguments) {
443             super("com.android.ide.eclipse.adt.refactoring.usecompound", //$NON-NLS-1$
444                     project, description, comment, arguments);
445         }
446 
447         @Override
createRefactoring(Map<String, String> args)448         protected Refactoring createRefactoring(Map<String, String> args) {
449             return new UseCompoundDrawableRefactoring(args);
450         }
451     }
452 }
453