/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Eclipse Public License, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.eclipse.org/org/documents/epl-v10.php * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; import static com.android.SdkConstants.ATTR_BASELINE_ALIGNED; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; import static com.android.SdkConstants.ATTR_ORIENTATION; import static com.android.SdkConstants.EXT_XML; import static com.android.SdkConstants.FQCN_GESTURE_OVERLAY_VIEW; import static com.android.SdkConstants.FQCN_GRID_LAYOUT; import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; import static com.android.SdkConstants.FQCN_RELATIVE_LAYOUT; import static com.android.SdkConstants.FQCN_TABLE_LAYOUT; import static com.android.SdkConstants.GESTURE_OVERLAY_VIEW; import static com.android.SdkConstants.LINEAR_LAYOUT; import static com.android.SdkConstants.TABLE_ROW; import static com.android.SdkConstants.VALUE_FALSE; import static com.android.SdkConstants.VALUE_VERTICAL; import static com.android.SdkConstants.VALUE_WRAP_CONTENT; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.VisibleForTesting; import com.android.ide.eclipse.adt.AdtPlugin; import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatStyle; import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.ViewHierarchy; import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.viewers.ITreeSelection; import org.eclipse.ltk.core.refactoring.Change; import org.eclipse.ltk.core.refactoring.Refactoring; import org.eclipse.ltk.core.refactoring.RefactoringStatus; import org.eclipse.ltk.core.refactoring.TextFileChange; import org.eclipse.text.edits.MalformedTreeException; import org.eclipse.text.edits.MultiTextEdit; import org.eclipse.text.edits.ReplaceEdit; import org.eclipse.text.edits.TextEdit; import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; import org.w3c.dom.Attr; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * Converts the selected layout into a layout of a different type. */ @SuppressWarnings("restriction") // XML model public class ChangeLayoutRefactoring extends VisualRefactoring { private static final String KEY_TYPE = "type"; //$NON-NLS-1$ private static final String KEY_FLATTEN = "flatten"; //$NON-NLS-1$ private String mTypeFqcn; private String mInitializedAttributes; private boolean mFlatten; /** * This constructor is solely used by {@link Descriptor}, * to replay a previous refactoring. * @param arguments argument map created by #createArgumentMap. */ ChangeLayoutRefactoring(Map<String, String> arguments) { super(arguments); mTypeFqcn = arguments.get(KEY_TYPE); mFlatten = Boolean.parseBoolean(arguments.get(KEY_FLATTEN)); } @VisibleForTesting ChangeLayoutRefactoring(List<Element> selectedElements, LayoutEditorDelegate delegate) { super(selectedElements, delegate); } public ChangeLayoutRefactoring( IFile file, LayoutEditorDelegate delegate, ITextSelection selection, ITreeSelection treeSelection) { super(file, delegate, selection, treeSelection); } @Override public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, OperationCanceledException { RefactoringStatus status = new RefactoringStatus(); try { pm.beginTask("Checking preconditions...", 2); if (mSelectionStart == -1 || mSelectionEnd == -1) { status.addFatalError("No selection to convert"); return status; } if (mElements.size() != 1) { status.addFatalError("Select precisely one layout to convert"); return status; } pm.worked(1); return status; } finally { pm.done(); } } @Override protected VisualRefactoringDescriptor createDescriptor() { String comment = getName(); return new Descriptor( mProject.getName(), //project comment, //description comment, //comment createArgumentMap()); } @Override protected Map<String, String> createArgumentMap() { Map<String, String> args = super.createArgumentMap(); args.put(KEY_TYPE, mTypeFqcn); args.put(KEY_FLATTEN, Boolean.toString(mFlatten)); return args; } @Override public String getName() { return "Change Layout"; } void setType(String typeFqcn) { mTypeFqcn = typeFqcn; } void setInitializedAttributes(String initializedAttributes) { mInitializedAttributes = initializedAttributes; } void setFlatten(boolean flatten) { mFlatten = flatten; } @Override protected List<Element> initElements() { List<Element> elements = super.initElements(); // Don't convert a root GestureOverlayView; convert its child. This looks for // gesture overlays, and if found, it generates a new child list where the gesture // overlay children are replaced by their first element children for (Element element : elements) { String tagName = element.getTagName(); if (tagName.equals(GESTURE_OVERLAY_VIEW) || tagName.equals(FQCN_GESTURE_OVERLAY_VIEW)) { List<Element> replacement = new ArrayList<Element>(elements.size()); for (Element e : elements) { tagName = e.getTagName(); if (tagName.equals(GESTURE_OVERLAY_VIEW) || tagName.equals(FQCN_GESTURE_OVERLAY_VIEW)) { NodeList children = e.getChildNodes(); Element first = null; for (int i = 0, n = children.getLength(); i < n; i++) { Node node = children.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) { first = (Element) node; break; } } if (first != null) { e = first; } } replacement.add(e); } return replacement; } } return elements; } @Override protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { String name = getViewClass(mTypeFqcn); IFile file = mDelegate.getEditor().getInputFile(); List<Change> changes = new ArrayList<Change>(); if (file == null) { return changes; } TextFileChange change = new TextFileChange(file.getName(), file); MultiTextEdit rootEdit = new MultiTextEdit(); change.setTextType(EXT_XML); changes.add(change); String text = getText(mSelectionStart, mSelectionEnd); Element layout = getPrimaryElement(); String oldName = layout.getNodeName(); int open = text.indexOf(oldName); int close = text.lastIndexOf(oldName); if (open != -1 && close != -1) { int oldLength = oldName.length(); rootEdit.addChild(new ReplaceEdit(mSelectionStart + open, oldLength, name)); if (close != open) { // Gracefully handle <FooLayout/> rootEdit.addChild(new ReplaceEdit(mSelectionStart + close, oldLength, name)); } } String oldId = getId(layout); String newId = ensureIdMatchesType(layout, mTypeFqcn, rootEdit); // Update any layout references to the old id with the new id if (oldId != null && newId != null) { IStructuredModel model = mDelegate.getEditor().getModelForRead(); try { IStructuredDocument doc = model.getStructuredDocument(); if (doc != null) { List<TextEdit> replaceIds = replaceIds(getAndroidNamespacePrefix(), doc, mSelectionStart, mSelectionEnd, oldId, newId); for (TextEdit edit : replaceIds) { rootEdit.addChild(edit); } } } finally { model.releaseFromRead(); } } String oldType = getOldType(); String newType = mTypeFqcn; if (newType.equals(FQCN_RELATIVE_LAYOUT)) { if (oldType.equals(FQCN_LINEAR_LAYOUT) && !mFlatten) { // Hand-coded conversion specifically tailored for linear to relative, provided // there is no hierarchy flattening // TODO: use the RelativeLayoutConversionHelper for this; it does a better job // analyzing gravities etc. convertLinearToRelative(rootEdit); removeUndefinedAttrs(rootEdit, layout); addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); } else { // Generic conversion to relative - can also flatten the hierarchy convertAnyToRelative(rootEdit, oldType, newType); // This already handles removing undefined layout attributes -- right? //removeUndefinedLayoutAttrs(rootEdit, layout); } } else if (newType.equals(FQCN_GRID_LAYOUT)) { convertAnyToGridLayout(rootEdit); // Layout attributes on children have already been removed as part of conversion // during the flattening removeUndefinedAttrs(rootEdit, layout, false /*removeLayoutAttrs*/); } else if (oldType.equals(FQCN_RELATIVE_LAYOUT) && newType.equals(FQCN_LINEAR_LAYOUT)) { convertRelativeToLinear(rootEdit); removeUndefinedAttrs(rootEdit, layout); addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); } else if (oldType.equals(FQCN_LINEAR_LAYOUT) && newType.equals(FQCN_TABLE_LAYOUT)) { convertLinearToTable(rootEdit); removeUndefinedAttrs(rootEdit, layout); addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); } else { convertGeneric(rootEdit, oldType, newType, layout); } if (mInitializedAttributes != null && mInitializedAttributes.length() > 0) { String namespace = getAndroidNamespacePrefix(); for (String s : mInitializedAttributes.split(",")) { //$NON-NLS-1$ String[] nameValue = s.split("="); //$NON-NLS-1$ String attribute = nameValue[0]; String value = nameValue[1]; String prefix = null; String namespaceUri = null; if (attribute.startsWith(SdkConstants.ANDROID_NS_NAME_PREFIX)) { prefix = namespace; namespaceUri = ANDROID_URI; attribute = attribute.substring(SdkConstants.ANDROID_NS_NAME_PREFIX.length()); } setAttribute(rootEdit, layout, namespaceUri, prefix, attribute, value); } } if (AdtPrefs.getPrefs().getFormatGuiXml()) { MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT); if (formatted != null) { rootEdit = formatted; } } change.setEdit(rootEdit); return changes; } /** Checks whether we need to add any missing attributes on the elements */ private void addMissingWrapContentAttributes(MultiTextEdit rootEdit, Element layout, String oldType, String newType, Set<Element> skip) { if (oldType.equals(FQCN_GRID_LAYOUT) && !newType.equals(FQCN_GRID_LAYOUT)) { String namespace = getAndroidNamespacePrefix(); for (Element child : DomUtilities.getChildren(layout)) { if (skip != null && skip.contains(child)) { continue; } if (!child.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH)) { setAttribute(rootEdit, child, ANDROID_URI, namespace, ATTR_LAYOUT_WIDTH, VALUE_WRAP_CONTENT); } if (!child.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT)) { setAttribute(rootEdit, child, ANDROID_URI, namespace, ATTR_LAYOUT_HEIGHT, VALUE_WRAP_CONTENT); } } } } /** Hand coded conversion from a LinearLayout to a TableLayout */ private void convertLinearToTable(MultiTextEdit rootEdit) { // This is pretty easy; just switch the root tag (already done by the initial generic // conversion) and then convert all the children into <TableRow> elements. // Finally, get rid of the orientation attribute, if any. Element layout = getPrimaryElement(); removeOrientationAttribute(rootEdit, layout); NodeList children = layout.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node node = children.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) { Element child = (Element) node; if (node instanceof IndexedRegion) { IndexedRegion region = (IndexedRegion) node; int start = region.getStartOffset(); int end = region.getEndOffset(); String text = getText(start, end); String oldName = child.getNodeName(); if (oldName.equals(LINEAR_LAYOUT)) { removeOrientationAttribute(rootEdit, child); int open = text.indexOf(oldName); int close = text.lastIndexOf(oldName); if (open != -1 && close != -1) { int oldLength = oldName.length(); rootEdit.addChild(new ReplaceEdit(mSelectionStart + open, oldLength, TABLE_ROW)); if (close != open) { // Gracefully handle <FooLayout/> rootEdit.addChild(new ReplaceEdit(mSelectionStart + close, oldLength, TABLE_ROW)); } } } // else: WRAP in TableLayout! } } } } /** Hand coded conversion from a LinearLayout to a RelativeLayout */ private void convertLinearToRelative(MultiTextEdit rootEdit) { // This can be done accurately. Element layout = getPrimaryElement(); // Horizontal is the default, so if no value is specified it is horizontal. boolean isVertical = VALUE_VERTICAL.equals(layout.getAttributeNS(ANDROID_URI, ATTR_ORIENTATION)); String attributePrefix = getAndroidNamespacePrefix(); // TODO: Consider gravity of each element // TODO: Consider weight of each element // Right now it simply makes a single attachment to keep the order. if (isVertical) { // Align each child to the bottom and left of its parent NodeList children = layout.getChildNodes(); String prevId = null; for (int i = 0, n = children.getLength(); i < n; i++) { Node node = children.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) { Element child = (Element) node; String id = ensureHasId(rootEdit, child, null); if (prevId != null) { setAttribute(rootEdit, child, ANDROID_URI, attributePrefix, ATTR_LAYOUT_BELOW, prevId); } prevId = id; } } } else { // Align each child to the left NodeList children = layout.getChildNodes(); boolean isBaselineAligned = !VALUE_FALSE.equals(layout.getAttributeNS(ANDROID_URI, ATTR_BASELINE_ALIGNED)); String prevId = null; for (int i = 0, n = children.getLength(); i < n; i++) { Node node = children.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) { Element child = (Element) node; String id = ensureHasId(rootEdit, child, null); if (prevId != null) { setAttribute(rootEdit, child, ANDROID_URI, attributePrefix, ATTR_LAYOUT_TO_RIGHT_OF, prevId); if (isBaselineAligned) { setAttribute(rootEdit, child, ANDROID_URI, attributePrefix, ATTR_LAYOUT_ALIGN_BASELINE, prevId); } } prevId = id; } } } } /** Strips out the android:orientation attribute from the given linear layout element */ private void removeOrientationAttribute(MultiTextEdit rootEdit, Element layout) { assert layout.getTagName().equals(LINEAR_LAYOUT); removeAttribute(rootEdit, layout, ANDROID_URI, ATTR_ORIENTATION); } /** * Hand coded conversion from a RelativeLayout to a LinearLayout * * @param rootEdit the root multi text edit to add edits to */ private void convertRelativeToLinear(MultiTextEdit rootEdit) { // This is going to be lossy... // TODO: Attempt to "order" the items based on their visual positions // and insert them in that order in the LinearLayout. // TODO: Possibly use nesting if necessary, by spatial subdivision, // to accomplish roughly the same layout as the relative layout specifies. } /** * Hand coded -generic- conversion from one layout to another. This is not going to be * an accurate layout transformation; instead it simply migrates the layout attributes * that are supported, and adds defaults for any new required layout attributes. In * addition, it attempts to order the children visually based on where they fit in a * rendering. (Unsupported layout attributes will be removed by the caller at the * end.) * <ul> * <li>Try to handle nesting. Converting a *hierarchy* of layouts into a flatter * layout for powerful layouts that support it, like RelativeLayout. * <li>Try to do automatic "inference" about the layout. I can render it and look at * the ViewInfo positions and sizes. I can render it multiple times, at different * sizes, to infer "stretchiness" and "weight" properties of the children. * <li>Try to do indirect transformations. E.g. if I can go from A to B, and B to C, * then an attempt to go from A to C should perform conversions A to B and then B to * C. * </ul> * * @param rootEdit the root multi text edit to add edits to * @param oldType the fully qualified class name of the layout type we are converting * from * @param newType the fully qualified class name of the layout type we are converting * to * @param layout the layout to be converted */ private void convertGeneric(MultiTextEdit rootEdit, String oldType, String newType, Element layout) { // TODO: Add hooks for 3rd party conversions getting registered through the // IViewRule interface. // For now we simply go with the default behavior, which is to just strip the // layout attributes that aren't supported. removeUndefinedAttrs(rootEdit, layout); addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); } /** * Removes all the unavailable attributes after a conversion, both on the * layout element itself as well as the layout attributes of any of the * children */ private void removeUndefinedAttrs(MultiTextEdit rootEdit, Element layout) { removeUndefinedAttrs(rootEdit, layout, true /*removeLayoutAttrs*/); } private void removeUndefinedAttrs(MultiTextEdit rootEdit, Element layout, boolean removeLayoutAttrs) { ViewElementDescriptor descriptor = getElementDescriptor(mTypeFqcn); if (descriptor == null) { return; } if (removeLayoutAttrs) { Set<String> defined = new HashSet<String>(); AttributeDescriptor[] layoutAttributes = descriptor.getLayoutAttributes(); for (AttributeDescriptor attribute : layoutAttributes) { defined.add(attribute.getXmlLocalName()); } NodeList children = layout.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node node = children.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) { Element child = (Element) node; List<Attr> attributes = findLayoutAttributes(child); for (Attr attribute : attributes) { String name = attribute.getLocalName(); if (!defined.contains(name)) { // Remove it try { removeAttribute(rootEdit, child, attribute.getNamespaceURI(), name); } catch (MalformedTreeException mte) { // Sometimes refactoring has modified attribute; not removing // it is non-fatal so just warn instead of letting refactoring // operation abort AdtPlugin.log(IStatus.WARNING, "Could not remove unsupported attribute %1$s; " + //$NON-NLS-1$ "already modified during refactoring?", //$NON-NLS-1$ attribute.getLocalName()); } } } } } } // Also remove the unavailable attributes (not layout attributes) on the // converted element Set<String> defined = new HashSet<String>(); AttributeDescriptor[] attributes = descriptor.getAttributes(); for (AttributeDescriptor attribute : attributes) { defined.add(attribute.getXmlLocalName()); } // Remove undefined attributes on the layout element itself NamedNodeMap attributeMap = layout.getAttributes(); for (int i = 0, n = attributeMap.getLength(); i < n; i++) { Node attributeNode = attributeMap.item(i); String name = attributeNode.getLocalName(); if (!name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) && ANDROID_URI.equals(attributeNode.getNamespaceURI())) { if (!defined.contains(name)) { // Remove it removeAttribute(rootEdit, layout, ANDROID_URI, name); } } } } /** Hand coded conversion from any layout to a RelativeLayout */ private void convertAnyToRelative(MultiTextEdit rootEdit, String oldType, String newType) { // To perform a conversion from any other layout type, including nested conversion, Element layout = getPrimaryElement(); CanvasViewInfo rootView = mRootView; if (rootView == null) { LayoutCanvas canvas = mDelegate.getGraphicalEditor().getCanvasControl(); ViewHierarchy viewHierarchy = canvas.getViewHierarchy(); rootView = viewHierarchy.getRoot(); } RelativeLayoutConversionHelper helper = new RelativeLayoutConversionHelper(this, layout, mFlatten, rootEdit, rootView); helper.convertToRelative(); List<Element> deletedElements = helper.getDeletedElements(); Set<Element> deleted = null; if (deletedElements != null && deletedElements.size() > 0) { deleted = new HashSet<Element>(deletedElements); } addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, deleted); } /** Hand coded conversion from any layout to a GridLayout */ private void convertAnyToGridLayout(MultiTextEdit rootEdit) { // To perform a conversion from any other layout type, including nested conversion, Element layout = getPrimaryElement(); CanvasViewInfo rootView = mRootView; if (rootView == null) { LayoutCanvas canvas = mDelegate.getGraphicalEditor().getCanvasControl(); ViewHierarchy viewHierarchy = canvas.getViewHierarchy(); rootView = viewHierarchy.getRoot(); } GridLayoutConverter converter = new GridLayoutConverter(this, layout, mFlatten, rootEdit, rootView); converter.convertToGridLayout(); } public static class Descriptor extends VisualRefactoringDescriptor { public Descriptor(String project, String description, String comment, Map<String, String> arguments) { super("com.android.ide.eclipse.adt.refactoring.convert", //$NON-NLS-1$ project, description, comment, arguments); } @Override protected Refactoring createRefactoring(Map<String, String> args) { return new ChangeLayoutRefactoring(args); } } String getOldType() { Element primary = getPrimaryElement(); if (primary != null) { String oldType = primary.getTagName(); if (oldType.indexOf('.') == -1) { oldType = ANDROID_WIDGET_PREFIX + oldType; } return oldType; } return null; } @VisibleForTesting protected CanvasViewInfo mRootView; @VisibleForTesting public void setRootView(CanvasViewInfo rootView) { mRootView = rootView; } @Override VisualRefactoringWizard createWizard() { return new ChangeLayoutWizard(this, mDelegate); } }