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 */
16package com.android.ide.eclipse.adt.internal.editors.layout.refactoring;
17
18import static com.android.SdkConstants.ANDROID_URI;
19import static com.android.SdkConstants.ATTR_DRAWABLE_BOTTOM;
20import static com.android.SdkConstants.ATTR_DRAWABLE_LEFT;
21import static com.android.SdkConstants.ATTR_DRAWABLE_PADDING;
22import static com.android.SdkConstants.ATTR_DRAWABLE_RIGHT;
23import static com.android.SdkConstants.ATTR_DRAWABLE_TOP;
24import static com.android.SdkConstants.ATTR_GRAVITY;
25import static com.android.SdkConstants.ATTR_ID;
26import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
27import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM;
28import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT;
29import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT;
30import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP;
31import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX;
32import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
33import static com.android.SdkConstants.ATTR_ORIENTATION;
34import static com.android.SdkConstants.ATTR_SRC;
35import static com.android.SdkConstants.EXT_XML;
36import static com.android.SdkConstants.IMAGE_VIEW;
37import static com.android.SdkConstants.LINEAR_LAYOUT;
38import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
39import static com.android.SdkConstants.TEXT_VIEW;
40import static com.android.SdkConstants.VALUE_VERTICAL;
41
42import com.android.annotations.NonNull;
43import com.android.annotations.Nullable;
44import com.android.annotations.VisibleForTesting;
45import com.android.ide.eclipse.adt.AdtUtils;
46import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatPreferences;
47import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatStyle;
48import com.android.ide.eclipse.adt.internal.editors.formatting.XmlPrettyPrinter;
49import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate;
50import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo;
51import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities;
52import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs;
53
54import org.eclipse.core.resources.IFile;
55import org.eclipse.core.runtime.CoreException;
56import org.eclipse.core.runtime.IProgressMonitor;
57import org.eclipse.core.runtime.OperationCanceledException;
58import org.eclipse.jface.text.ITextSelection;
59import org.eclipse.jface.viewers.ITreeSelection;
60import org.eclipse.ltk.core.refactoring.Change;
61import org.eclipse.ltk.core.refactoring.Refactoring;
62import org.eclipse.ltk.core.refactoring.RefactoringStatus;
63import org.eclipse.ltk.core.refactoring.TextFileChange;
64import org.eclipse.text.edits.MultiTextEdit;
65import org.eclipse.text.edits.ReplaceEdit;
66import org.eclipse.text.edits.TextEdit;
67import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
68import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion;
69import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument;
70import org.w3c.dom.Attr;
71import org.w3c.dom.Document;
72import org.w3c.dom.Element;
73import org.w3c.dom.NamedNodeMap;
74
75import java.util.ArrayList;
76import java.util.List;
77import java.util.Map;
78import java.util.regex.Matcher;
79import 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
86public 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     */
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     */
106    private UseCompoundDrawableRefactoring(Map<String, String> arguments) {
107        super(arguments);
108    }
109
110    @VisibleForTesting
111    UseCompoundDrawableRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) {
112        super(selectedElements, editor);
113    }
114
115    @Override
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
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
193    protected Map<String, String> createArgumentMap() {
194        return super.createArgumentMap();
195    }
196
197    @Override
198    public String getName() {
199        return "Convert to Compound Drawable";
200    }
201
202    @Override
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        XmlFormatPreferences formatPrefs = XmlFormatPreferences.create();
357        XmlPrettyPrinter printer = new XmlPrettyPrinter(formatPrefs, XmlFormatStyle.LAYOUT,
358                null /*lineSeparator*/);
359        StringBuilder sb = new StringBuilder(300);
360        printer.prettyPrint(-1, tempDocument, null, null, sb, false /*openTagOnly*/);
361        String xml = sb.toString();
362
363
364        TextEdit replace = new ReplaceEdit(mSelectionStart, mSelectionEnd - mSelectionStart, xml);
365        rootEdit.addChild(replace);
366
367        if (AdtPrefs.getPrefs().getFormatGuiXml()) {
368            MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT);
369            if (formatted != null) {
370                rootEdit = formatted;
371            }
372        }
373
374        change.setEdit(rootEdit);
375        changes.add(change);
376        return changes;
377    }
378
379    @Nullable
380    private static String getPadding(@NonNull Element element, @NonNull String attribute) {
381        String padding = element.getAttributeNS(ANDROID_URI, attribute);
382        if (padding != null && padding.isEmpty()) {
383            padding = null;
384        }
385        return padding;
386    }
387
388    @VisibleForTesting
389    @Nullable
390    static String combine(@Nullable String dimension1, @Nullable String dimension2) {
391        if (dimension1 == null || dimension1.isEmpty()) {
392            if (dimension2 != null && dimension2.isEmpty()) {
393                return null;
394            }
395            return dimension2;
396        } else if (dimension2 == null || dimension2.isEmpty()) {
397            if (dimension1 != null && dimension1.isEmpty()) {
398                return null;
399            }
400            return dimension1;
401        } else {
402            // Two dimensions are specified (e.g. marginRight for the left one and marginLeft
403            // for the right one); we have to add these together. We can only do that if
404            // they use the same units, and do not use resources.
405            if (dimension1.startsWith(PREFIX_RESOURCE_REF)
406                    || dimension2.startsWith(PREFIX_RESOURCE_REF)) {
407                return null;
408            }
409
410            Pattern p = Pattern.compile("([\\d\\.]+)(.+)"); //$NON-NLS-1$
411            Matcher matcher1 = p.matcher(dimension1);
412            Matcher matcher2 = p.matcher(dimension2);
413            if (matcher1.matches() && matcher2.matches()) {
414                String unit = matcher1.group(2);
415                if (unit.equals(matcher2.group(2))) {
416                    float value1 = Float.parseFloat(matcher1.group(1));
417                    float value2 = Float.parseFloat(matcher2.group(1));
418                    return AdtUtils.formatFloatAttribute(value1 + value2) + unit;
419                }
420            }
421        }
422
423        return null;
424    }
425
426    /**
427     * Sets an Android attribute (in the Android namespace) on an element
428     * without a given namespace prefix. This is done when building a new Element
429     * in a temporary document such that the namespace prefix matches when the element is
430     * formatted and replaced in the target document.
431     */
432    private static void setAndroidAttribute(Element element, String prefix, String name,
433            String value) {
434        element.setAttribute(prefix + ':' + name, value);
435    }
436
437    @Override
438    public VisualRefactoringWizard createWizard() {
439        return new UseCompoundDrawableWizard(this, mDelegate);
440    }
441
442    @SuppressWarnings("javadoc")
443    public static class Descriptor extends VisualRefactoringDescriptor {
444        public Descriptor(String project, String description, String comment,
445                Map<String, String> arguments) {
446            super("com.android.ide.eclipse.adt.refactoring.usecompound", //$NON-NLS-1$
447                    project, description, comment, arguments);
448        }
449
450        @Override
451        protected Refactoring createRefactoring(Map<String, String> args) {
452            return new UseCompoundDrawableRefactoring(args);
453        }
454    }
455}
456