diff options
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java')
-rw-r--r-- | eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java | 1403 |
1 files changed, 1403 insertions, 0 deletions
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java new file mode 100644 index 000000000..904a3a084 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java @@ -0,0 +1,1403 @@ +/* + * 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_NS_NAME; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.XMLNS; +import static com.android.SdkConstants.XMLNS_PREFIX; + +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlFormatPreferences; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlPrettyPrinter; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +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.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.core.runtime.Path; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.ChangeDescriptor; +import org.eclipse.ltk.core.refactoring.CompositeChange; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringChangeDescriptor; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.text.edits.DeleteEdit; +import org.eclipse.text.edits.InsertEdit; +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.ui.IEditorPart; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.ide.IDE; +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.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList; +import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Parent class for the various visual refactoring operations; contains shared + * implementations needed by most of them + */ +@SuppressWarnings("restriction") // XML model +public abstract class VisualRefactoring extends Refactoring { + private static final String KEY_FILE = "file"; //$NON-NLS-1$ + private static final String KEY_PROJECT = "proj"; //$NON-NLS-1$ + private static final String KEY_SEL_START = "sel-start"; //$NON-NLS-1$ + private static final String KEY_SEL_END = "sel-end"; //$NON-NLS-1$ + + protected final IFile mFile; + protected final LayoutEditorDelegate mDelegate; + protected final IProject mProject; + protected int mSelectionStart = -1; + protected int mSelectionEnd = -1; + protected final List<Element> mElements; + protected final ITreeSelection mTreeSelection; + protected final ITextSelection mSelection; + /** Same as {@link #mSelectionStart} but not adjusted to element edges */ + protected int mOriginalSelectionStart = -1; + /** Same as {@link #mSelectionEnd} but not adjusted to element edges */ + protected int mOriginalSelectionEnd = -1; + + protected final Map<Element, String> mGeneratedIdMap = new HashMap<Element, String>(); + protected final Set<String> mGeneratedIds = new HashSet<String>(); + + protected List<Change> mChanges; + private String mAndroidNamespacePrefix; + + /** + * This constructor is solely used by {@link VisualRefactoringDescriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + VisualRefactoring(Map<String, String> arguments) { + IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT)); + mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path); + path = Path.fromPortableString(arguments.get(KEY_FILE)); + mFile = (IFile) ResourcesPlugin.getWorkspace().getRoot().findMember(path); + mSelectionStart = Integer.parseInt(arguments.get(KEY_SEL_START)); + mSelectionEnd = Integer.parseInt(arguments.get(KEY_SEL_END)); + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + mDelegate = null; + mElements = null; + mSelection = null; + mTreeSelection = null; + } + + @VisibleForTesting + VisualRefactoring(List<Element> elements, LayoutEditorDelegate delegate) { + mElements = elements; + mDelegate = delegate; + + mFile = delegate != null ? delegate.getEditor().getInputFile() : null; + mProject = delegate != null ? delegate.getEditor().getProject() : null; + mSelectionStart = 0; + mSelectionEnd = 0; + mOriginalSelectionStart = 0; + mOriginalSelectionEnd = 0; + mSelection = null; + mTreeSelection = null; + + int end = Integer.MIN_VALUE; + int start = Integer.MAX_VALUE; + for (Element element : elements) { + if (element instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) element; + start = Math.min(start, region.getStartOffset()); + end = Math.max(end, region.getEndOffset()); + } + } + if (start >= 0) { + mSelectionStart = start; + mSelectionEnd = end; + mOriginalSelectionStart = start; + mOriginalSelectionEnd = end; + } + } + + public VisualRefactoring(IFile file, LayoutEditorDelegate editor, ITextSelection selection, + ITreeSelection treeSelection) { + mFile = file; + mDelegate = editor; + mProject = file.getProject(); + mSelection = selection; + mTreeSelection = treeSelection; + + // Initialize mSelectionStart and mSelectionEnd based on the selection context, which + // is either a treeSelection (when invoked from the layout editor or the outline), or + // a selection (when invoked from an XML editor) + if (treeSelection != null) { + int end = Integer.MIN_VALUE; + int start = Integer.MAX_VALUE; + for (TreePath path : treeSelection.getPaths()) { + Object lastSegment = path.getLastSegment(); + if (lastSegment instanceof CanvasViewInfo) { + CanvasViewInfo viewInfo = (CanvasViewInfo) lastSegment; + UiViewElementNode uiNode = viewInfo.getUiViewNode(); + if (uiNode == null) { + continue; + } + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) xmlNode; + + start = Math.min(start, region.getStartOffset()); + end = Math.max(end, region.getEndOffset()); + } + } + } + if (start >= 0) { + mSelectionStart = start; + mSelectionEnd = end; + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + } + if (selection != null) { + mOriginalSelectionStart = selection.getOffset(); + mOriginalSelectionEnd = mOriginalSelectionStart + selection.getLength(); + } + } else if (selection != null) { + // TODO: update selection to boundaries! + mSelectionStart = selection.getOffset(); + mSelectionEnd = mSelectionStart + selection.getLength(); + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + } + + mElements = initElements(); + } + + @NonNull + protected abstract List<Change> computeChanges(IProgressMonitor monitor); + + @Override + public RefactoringStatus checkFinalConditions(IProgressMonitor monitor) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + mChanges = new ArrayList<Change>(); + try { + monitor.beginTask("Checking post-conditions...", 5); + + // Reset state for each computeChanges call, in case the user goes back + // and forth in the refactoring wizard + mGeneratedIdMap.clear(); + mGeneratedIds.clear(); + List<Change> changes = computeChanges(monitor); + mChanges.addAll(changes); + + monitor.worked(1); + } finally { + monitor.done(); + } + + return status; + } + + @Override + public Change createChange(IProgressMonitor monitor) throws CoreException, + OperationCanceledException { + try { + monitor.beginTask("Applying changes...", 1); + + CompositeChange change = new CompositeChange( + getName(), + mChanges.toArray(new Change[mChanges.size()])) { + @Override + public ChangeDescriptor getDescriptor() { + VisualRefactoringDescriptor desc = createDescriptor(); + return new RefactoringChangeDescriptor(desc); + } + }; + + monitor.worked(1); + return change; + + } finally { + monitor.done(); + } + } + + protected abstract VisualRefactoringDescriptor createDescriptor(); + + protected Map<String, String> createArgumentMap() { + HashMap<String, String> args = new HashMap<String, String>(); + args.put(KEY_PROJECT, mProject.getFullPath().toPortableString()); + args.put(KEY_FILE, mFile.getFullPath().toPortableString()); + args.put(KEY_SEL_START, Integer.toString(mSelectionStart)); + args.put(KEY_SEL_END, Integer.toString(mSelectionEnd)); + + return args; + } + + IFile getFile() { + return mFile; + } + + // ---- Shared functionality ---- + + + protected void openFile(IFile file) { + GraphicalEditorPart graphicalEditor = mDelegate.getGraphicalEditor(); + IFile leavingFile = graphicalEditor.getEditedFile(); + + try { + // Duplicate the current state into the newly created file + String state = ConfigurationDescription.getDescription(leavingFile); + + // TODO: Look for a ".NoTitleBar.Fullscreen" theme version of the current + // theme to show. + + file.setSessionProperty(GraphicalEditorPart.NAME_INITIAL_STATE, state); + } catch (CoreException e) { + // pass + } + + /* TBD: "Show Included In" if supported. + * Not sure if this is a good idea. + if (graphicalEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) { + try { + Reference include = Reference.create(graphicalEditor.getEditedFile()); + file.setSessionProperty(GraphicalEditorPart.NAME_INCLUDE, include); + } catch (CoreException e) { + // pass - worst that can happen is that we don't start with inclusion + } + } + */ + + try { + IEditorPart part = + IDE.openEditor(mDelegate.getEditor().getEditorSite().getPage(), file); + if (part instanceof AndroidXmlEditor && AdtPrefs.getPrefs().getFormatGuiXml()) { + AndroidXmlEditor newEditor = (AndroidXmlEditor) part; + newEditor.reformatDocument(); + } + } catch (PartInitException e) { + AdtPlugin.log(e, "Can't open new included layout"); + } + } + + + /** Produce a list of edits to replace references to the given id with the given new id */ + protected static List<TextEdit> replaceIds(String androidNamePrefix, + IStructuredDocument doc, int skipStart, int skipEnd, + String rootId, String referenceId) { + if (rootId == null) { + return Collections.emptyList(); + } + + // We need to search for either @+id/ or @id/ + String match1 = rootId; + String match2; + if (match1.startsWith(ID_PREFIX)) { + match2 = '"' + NEW_ID_PREFIX + match1.substring(ID_PREFIX.length()) + '"'; + match1 = '"' + match1 + '"'; + } else if (match1.startsWith(NEW_ID_PREFIX)) { + match2 = '"' + ID_PREFIX + match1.substring(NEW_ID_PREFIX.length()) + '"'; + match1 = '"' + match1 + '"'; + } else { + return Collections.emptyList(); + } + + String namePrefix = androidNamePrefix + ':' + ATTR_LAYOUT_RESOURCE_PREFIX; + List<TextEdit> edits = new ArrayList<TextEdit>(); + + IStructuredDocumentRegion region = doc.getFirstStructuredDocumentRegion(); + for (; region != null; region = region.getNext()) { + ITextRegionList list = region.getRegions(); + int regionStart = region.getStart(); + + // Look at all attribute values and look for an id reference match + String attributeName = ""; //$NON-NLS-1$ + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) { + attributeName = region.getText(subRegion); + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) { + // Only replace references in layout attributes + if (!attributeName.startsWith(namePrefix)) { + continue; + } + // Skip occurrences in the given skip range + int subRegionStart = regionStart + subRegion.getStart(); + if (subRegionStart >= skipStart && subRegionStart <= skipEnd) { + continue; + } + + String attributeValue = region.getText(subRegion); + if (attributeValue.equals(match1) || attributeValue.equals(match2)) { + int start = subRegionStart + 1; // skip quote + int end = start + rootId.length(); + + edits.add(new ReplaceEdit(start, end - start, referenceId)); + } + } + } + } + + return edits; + } + + /** Get the id of the root selected element, if any */ + protected String getRootId() { + Element primary = getPrimaryElement(); + if (primary != null) { + String oldId = primary.getAttributeNS(ANDROID_URI, ATTR_ID); + // id null check for https://bugs.eclipse.org/bugs/show_bug.cgi?id=272378 + if (oldId != null && oldId.length() > 0) { + return oldId; + } + } + + return null; + } + + protected String getAndroidNamespacePrefix() { + if (mAndroidNamespacePrefix == null) { + List<Attr> attributeNodes = findNamespaceAttributes(); + for (Node attributeNode : attributeNodes) { + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + String name = attributeNode.getNodeName(); + String value = attributeNode.getNodeValue(); + if (value.equals(ANDROID_URI)) { + mAndroidNamespacePrefix = name; + if (mAndroidNamespacePrefix.startsWith(XMLNS_PREFIX)) { + mAndroidNamespacePrefix = + mAndroidNamespacePrefix.substring(XMLNS_PREFIX.length()); + } + } + } + } + + if (mAndroidNamespacePrefix == null) { + mAndroidNamespacePrefix = ANDROID_NS_NAME; + } + } + + return mAndroidNamespacePrefix; + } + + protected static String getAndroidNamespacePrefix(Document document) { + String nsPrefix = null; + List<Attr> attributeNodes = findNamespaceAttributes(document); + for (Node attributeNode : attributeNodes) { + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + String name = attributeNode.getNodeName(); + String value = attributeNode.getNodeValue(); + if (value.equals(ANDROID_URI)) { + nsPrefix = name; + if (nsPrefix.startsWith(XMLNS_PREFIX)) { + nsPrefix = + nsPrefix.substring(XMLNS_PREFIX.length()); + } + } + } + } + + if (nsPrefix == null) { + nsPrefix = ANDROID_NS_NAME; + } + + return nsPrefix; + } + + protected List<Attr> findNamespaceAttributes() { + Document document = getDomDocument(); + return findNamespaceAttributes(document); + } + + protected static List<Attr> findNamespaceAttributes(Document document) { + if (document != null) { + Element root = document.getDocumentElement(); + return findNamespaceAttributes(root); + } + + return Collections.emptyList(); + } + + protected static List<Attr> findNamespaceAttributes(Node root) { + List<Attr> result = new ArrayList<Attr>(); + NamedNodeMap attributes = root.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attributeNode = attributes.item(i); + + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + result.add((Attr) attributeNode); + } + } + + return result; + } + + protected List<Attr> findLayoutAttributes(Node root) { + List<Attr> result = new ArrayList<Attr>(); + NamedNodeMap attributes = root.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attributeNode = attributes.item(i); + + String name = attributeNode.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attributeNode.getNamespaceURI())) { + result.add((Attr) attributeNode); + } + } + + return result; + } + + protected String insertNamespace(String xmlText, String namespaceDeclarations) { + // Insert namespace declarations into the extracted XML fragment + int firstSpace = xmlText.indexOf(' '); + int elementEnd = xmlText.indexOf('>'); + int insertAt; + if (firstSpace != -1 && firstSpace < elementEnd) { + insertAt = firstSpace; + } else { + insertAt = elementEnd; + } + xmlText = xmlText.substring(0, insertAt) + namespaceDeclarations + + xmlText.substring(insertAt); + + return xmlText; + } + + /** Remove sections of the document that correspond to top level layout attributes; + * these are placed on the include element instead */ + protected String stripTopLayoutAttributes(Element primary, int start, String xml) { + if (primary != null) { + // List of attributes to remove + List<IndexedRegion> skip = new ArrayList<IndexedRegion>(); + NamedNodeMap attributes = primary.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attr = attributes.item(i); + String name = attr.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attr.getNamespaceURI())) { + if (name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) { + // These are special and are left in + continue; + } + + if (attr instanceof IndexedRegion) { + skip.add((IndexedRegion) attr); + } + } + } + if (skip.size() > 0) { + Collections.sort(skip, new Comparator<IndexedRegion>() { + // Sort in start order + @Override + public int compare(IndexedRegion r1, IndexedRegion r2) { + return r1.getStartOffset() - r2.getStartOffset(); + } + }); + + // Successively cut out the various layout attributes + // TODO remove adjacent whitespace too (but not newlines, unless they + // are newly adjacent) + StringBuilder sb = new StringBuilder(xml.length()); + int nextStart = 0; + + // Copy out all the sections except the skip sections + for (IndexedRegion r : skip) { + int regionStart = r.getStartOffset(); + // Adjust to string offsets since we've copied the string out of + // the document + regionStart -= start; + + sb.append(xml.substring(nextStart, regionStart)); + + nextStart = regionStart + r.getLength(); + } + if (nextStart < xml.length()) { + sb.append(xml.substring(nextStart)); + } + + return sb.toString(); + } + } + + return xml; + } + + protected static String getIndent(String line, int max) { + int i = 0; + int n = Math.min(max, line.length()); + for (; i < n; i++) { + char c = line.charAt(i); + if (!Character.isWhitespace(c)) { + return line.substring(0, i); + } + } + + if (n < line.length()) { + return line.substring(0, n); + } else { + return line; + } + } + + protected static String dedent(String xml) { + String[] lines = xml.split("\n"); //$NON-NLS-1$ + if (lines.length < 2) { + // The first line never has any indentation since we copy it out from the + // element start index + return xml; + } + + String indentPrefix = getIndent(lines[1], lines[1].length()); + for (int i = 2, n = lines.length; i < n; i++) { + String line = lines[i]; + + // Ignore blank lines + if (line.trim().length() == 0) { + continue; + } + + indentPrefix = getIndent(line, indentPrefix.length()); + + if (indentPrefix.length() == 0) { + return xml; + } + } + + StringBuilder sb = new StringBuilder(); + for (String line : lines) { + if (line.startsWith(indentPrefix)) { + sb.append(line.substring(indentPrefix.length())); + } else { + sb.append(line); + } + sb.append('\n'); + } + return sb.toString(); + } + + protected String getText(int start, int end) { + try { + IStructuredDocument document = mDelegate.getEditor().getStructuredDocument(); + return document.get(start, end - start); + } catch (BadLocationException e) { + // the region offset was invalid. ignore. + return null; + } + } + + protected List<Element> getElements() { + return mElements; + } + + protected List<Element> initElements() { + List<Element> nodes = new ArrayList<Element>(); + + assert mTreeSelection == null || mSelection == null : + "treeSel= " + mTreeSelection + ", sel=" + mSelection; + + // Initialize mSelectionStart and mSelectionEnd based on the selection context, which + // is either a treeSelection (when invoked from the layout editor or the outline), or + // a selection (when invoked from an XML editor) + if (mTreeSelection != null) { + int end = Integer.MIN_VALUE; + int start = Integer.MAX_VALUE; + for (TreePath path : mTreeSelection.getPaths()) { + Object lastSegment = path.getLastSegment(); + if (lastSegment instanceof CanvasViewInfo) { + CanvasViewInfo viewInfo = (CanvasViewInfo) lastSegment; + UiViewElementNode uiNode = viewInfo.getUiViewNode(); + if (uiNode == null) { + continue; + } + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode instanceof Element) { + Element element = (Element) xmlNode; + nodes.add(element); + IndexedRegion region = getRegion(element); + start = Math.min(start, region.getStartOffset()); + end = Math.max(end, region.getEndOffset()); + } + } + } + if (start >= 0) { + mSelectionStart = start; + mSelectionEnd = end; + } + } else if (mSelection != null) { + mSelectionStart = mSelection.getOffset(); + mSelectionEnd = mSelectionStart + mSelection.getLength(); + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + + // Figure out the range of selected nodes from the document offsets + IStructuredDocument doc = mDelegate.getEditor().getStructuredDocument(); + Pair<Element, Element> range = DomUtilities.getElementRange(doc, + mSelectionStart, mSelectionEnd); + if (range != null) { + Element first = range.getFirst(); + Element last = range.getSecond(); + + // Adjust offsets to get rid of surrounding text nodes (if you happened + // to select a text range and included whitespace on either end etc) + mSelectionStart = getRegion(first).getStartOffset(); + mSelectionEnd = getRegion(last).getEndOffset(); + + if (mSelectionStart > mSelectionEnd) { + int tmp = mSelectionStart; + mSelectionStart = mSelectionEnd; + mSelectionEnd = tmp; + } + + if (first == last) { + nodes.add(first); + } else if (first.getParentNode() == last.getParentNode()) { + // Add the range + Node node = first; + while (node != null) { + if (node instanceof Element) { + nodes.add((Element) node); + } + if (node == last) { + break; + } + node = node.getNextSibling(); + } + } else { + // Different parents: this means we have an uneven selection, selecting + // elements from different levels. We can't extract ranges like that. + } + } + } else { + assert false; + } + + // Make sure that the list of elements is unique + //Set<Element> seen = new HashSet<Element>(); + //for (Element element : nodes) { + // assert !seen.contains(element) : element; + // seen.add(element); + //} + + return nodes; + } + + protected Element getPrimaryElement() { + List<Element> elements = getElements(); + if (elements != null && elements.size() == 1) { + return elements.get(0); + } + + return null; + } + + protected Document getDomDocument() { + if (mDelegate.getUiRootNode() != null) { + return mDelegate.getUiRootNode().getXmlDocument(); + } else { + return getElements().get(0).getOwnerDocument(); + } + } + + protected List<CanvasViewInfo> getSelectedViewInfos() { + List<CanvasViewInfo> infos = new ArrayList<CanvasViewInfo>(); + if (mTreeSelection != null) { + for (TreePath path : mTreeSelection.getPaths()) { + Object lastSegment = path.getLastSegment(); + if (lastSegment instanceof CanvasViewInfo) { + infos.add((CanvasViewInfo) lastSegment); + } + } + } + return infos; + } + + protected boolean validateNotEmpty(List<CanvasViewInfo> infos, RefactoringStatus status) { + if (infos.size() == 0) { + status.addFatalError("No selection to extract"); + return false; + } + + return true; + } + + protected boolean validateNotRoot(List<CanvasViewInfo> infos, RefactoringStatus status) { + for (CanvasViewInfo info : infos) { + if (info.isRoot()) { + status.addFatalError("Cannot refactor the root"); + return false; + } + } + + return true; + } + + protected boolean validateContiguous(List<CanvasViewInfo> infos, RefactoringStatus status) { + if (infos.size() > 1) { + // All elements must be siblings (e.g. same parent) + List<UiViewElementNode> nodes = new ArrayList<UiViewElementNode>(infos + .size()); + for (CanvasViewInfo info : infos) { + UiViewElementNode node = info.getUiViewNode(); + if (node != null) { + nodes.add(node); + } + } + if (nodes.size() == 0) { + status.addFatalError("No selected views"); + return false; + } + + UiElementNode parent = nodes.get(0).getUiParent(); + for (UiViewElementNode node : nodes) { + if (parent != node.getUiParent()) { + status.addFatalError("The selected elements must be adjacent"); + return false; + } + } + // Ensure that the siblings are contiguous; no gaps. + // If we've selected all the children of the parent then we don't need + // to look. + List<UiElementNode> siblings = parent.getUiChildren(); + if (siblings.size() != nodes.size()) { + Set<UiViewElementNode> nodeSet = new HashSet<UiViewElementNode>(nodes); + boolean inRange = false; + int remaining = nodes.size(); + for (UiElementNode node : siblings) { + boolean in = nodeSet.contains(node); + if (in) { + remaining--; + if (remaining == 0) { + break; + } + inRange = true; + } else if (inRange) { + status.addFatalError("The selected elements must be adjacent"); + return false; + } + } + } + } + + return true; + } + + /** + * Updates the given element with a new name if the current id reflects the old + * element type. If the name was changed, it will return the new name. + */ + protected String ensureIdMatchesType(Element element, String newType, MultiTextEdit rootEdit) { + String oldType = element.getTagName(); + if (oldType.indexOf('.') == -1) { + oldType = ANDROID_WIDGET_PREFIX + oldType; + } + String oldTypeBase = oldType.substring(oldType.lastIndexOf('.') + 1); + String id = getId(element); + if (id == null || id.length() == 0 + || id.toLowerCase(Locale.US).contains(oldTypeBase.toLowerCase(Locale.US))) { + String newTypeBase = newType.substring(newType.lastIndexOf('.') + 1); + return ensureHasId(rootEdit, element, newTypeBase); + } + + return null; + } + + /** + * Returns the {@link IndexedRegion} for the given node + * + * @param node the node to look up the region for + * @return the corresponding region, or null + */ + public static IndexedRegion getRegion(Node node) { + if (node instanceof IndexedRegion) { + return (IndexedRegion) node; + } + + return null; + } + + protected String ensureHasId(MultiTextEdit rootEdit, Element element, String prefix) { + return ensureHasId(rootEdit, element, prefix, true); + } + + protected String ensureHasId(MultiTextEdit rootEdit, Element element, String prefix, + boolean apply) { + String id = mGeneratedIdMap.get(element); + if (id != null) { + return NEW_ID_PREFIX + id; + } + + if (!element.hasAttributeNS(ANDROID_URI, ATTR_ID) + || (prefix != null && !getId(element).startsWith(prefix))) { + id = DomUtilities.getFreeWidgetId(element, mGeneratedIds, prefix); + // Make sure we don't use this one again + mGeneratedIds.add(id); + mGeneratedIdMap.put(element, id); + id = NEW_ID_PREFIX + id; + if (apply) { + setAttribute(rootEdit, element, + ANDROID_URI, getAndroidNamespacePrefix(), ATTR_ID, id); + } + return id; + } + + return getId(element); + } + + protected int getFirstAttributeOffset(Element element) { + IndexedRegion region = getRegion(element); + if (region != null) { + int startOffset = region.getStartOffset(); + int endOffset = region.getEndOffset(); + String text = getText(startOffset, endOffset); + String name = element.getLocalName(); + int nameOffset = text.indexOf(name); + if (nameOffset != -1) { + return startOffset + nameOffset + name.length(); + } + } + + return -1; + } + + /** + * Returns the id of the given element + * + * @param element the element to look up the id for + * @return the corresponding id, or an empty string (should not be null + * according to the DOM API, but has been observed to be null on + * some versions of Eclipse) + */ + public static String getId(Element element) { + return element.getAttributeNS(ANDROID_URI, ATTR_ID); + } + + protected String ensureNewId(String id) { + if (id != null && id.length() > 0) { + if (id.startsWith(ID_PREFIX)) { + id = NEW_ID_PREFIX + id.substring(ID_PREFIX.length()); + } else if (!id.startsWith(NEW_ID_PREFIX)) { + id = NEW_ID_PREFIX + id; + } + } else { + id = null; + } + + return id; + } + + protected String getViewClass(String fqcn) { + // Don't include android.widget. as a package prefix in layout files + if (fqcn.startsWith(ANDROID_WIDGET_PREFIX)) { + fqcn = fqcn.substring(ANDROID_WIDGET_PREFIX.length()); + } + + return fqcn; + } + + protected void setAttribute(MultiTextEdit rootEdit, Element element, + String attributeUri, + String attributePrefix, String attributeName, String attributeValue) { + int offset = getFirstAttributeOffset(element); + if (offset != -1) { + if (element.hasAttributeNS(attributeUri, attributeName)) { + replaceAttributeDeclaration(rootEdit, offset, element, attributePrefix, + attributeUri, attributeName, attributeValue); + } else { + addAttributeDeclaration(rootEdit, offset, attributePrefix, attributeName, + attributeValue); + } + } + } + + private void addAttributeDeclaration(MultiTextEdit rootEdit, int offset, + String attributePrefix, String attributeName, String attributeValue) { + StringBuilder sb = new StringBuilder(); + sb.append(' '); + + if (attributePrefix != null) { + sb.append(attributePrefix).append(':'); + } + sb.append(attributeName).append('=').append('"'); + sb.append(attributeValue).append('"'); + + InsertEdit setAttribute = new InsertEdit(offset, sb.toString()); + rootEdit.addChild(setAttribute); + } + + /** Replaces the value declaration of the given attribute */ + private void replaceAttributeDeclaration(MultiTextEdit rootEdit, int offset, + Element element, String attributePrefix, String attributeUri, + String attributeName, String attributeValue) { + // Find attribute value and replace it + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + IStructuredDocument doc = model.getStructuredDocument(); + + IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(offset); + ITextRegionList list = region.getRegions(); + int regionStart = region.getStart(); + + int valueStart = -1; + boolean useNextValue = false; + String targetName = attributePrefix != null + ? attributePrefix + ':' + attributeName : attributeName; + + // Look at all attribute values and look for an id reference match + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) { + // What about prefix? + if (targetName.equals(region.getText(subRegion))) { + useNextValue = true; + } + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) { + if (useNextValue) { + valueStart = regionStart + subRegion.getStart(); + break; + } + } + } + + if (valueStart != -1) { + String oldValue = element.getAttributeNS(attributeUri, attributeName); + int start = valueStart + 1; // Skip opening " + ReplaceEdit setAttribute = new ReplaceEdit(start, oldValue.length(), + attributeValue); + try { + rootEdit.addChild(setAttribute); + } catch (MalformedTreeException mte) { + AdtPlugin.log(mte, "Could not replace attribute %1$s with %2$s", + attributeName, attributeValue); + throw mte; + } + } + } finally { + model.releaseFromRead(); + } + } + + /** Strips out the given attribute, if defined */ + protected void removeAttribute(MultiTextEdit rootEdit, Element element, String uri, + String attributeName) { + if (element.hasAttributeNS(uri, attributeName)) { + Attr attribute = element.getAttributeNodeNS(uri, attributeName); + removeAttribute(rootEdit, attribute); + } + } + + /** Strips out the given attribute, if defined */ + protected void removeAttribute(MultiTextEdit rootEdit, Attr attribute) { + IndexedRegion region = getRegion(attribute); + if (region != null) { + int startOffset = region.getStartOffset(); + int endOffset = region.getEndOffset(); + DeleteEdit deletion = new DeleteEdit(startOffset, endOffset - startOffset); + rootEdit.addChild(deletion); + } + } + + + /** + * Removes the given element's opening and closing tags (including all of its + * attributes) but leaves any children alone + * + * @param rootEdit the multi edit to add the removal operation to + * @param element the element to delete the open and closing tags for + * @param skip a list of elements that should not be modified (for example because they + * are targeted for deletion) + * + * TODO: Rename this to "unwrap" ? And allow for handling nested deletions. + */ + protected void removeElementTags(MultiTextEdit rootEdit, Element element, List<Element> skip, + boolean changeIndentation) { + IndexedRegion elementRegion = getRegion(element); + if (elementRegion == null) { + return; + } + + // Look for the opening tag + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + int startLineInclusive = -1; + int endLineInclusive = -1; + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null) { + int start = elementRegion.getStartOffset(); + IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(start); + ITextRegionList list = region.getRegions(); + int regionStart = region.getStart(); + int startOffset = regionStart; + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_OPEN.equals(type)) { + startOffset = regionStart + subRegion.getStart(); + } else if (DOMRegionContext.XML_TAG_CLOSE.equals(type)) { + int endOffset = regionStart + subRegion.getStart() + subRegion.getLength(); + + DeleteEdit deletion = createDeletion(doc, startOffset, endOffset); + rootEdit.addChild(deletion); + startLineInclusive = doc.getLineOfOffset(endOffset) + 1; + break; + } + } + + // Find the close tag + // Look at all attribute values and look for an id reference match + region = doc.getRegionAtCharacterOffset(elementRegion.getEndOffset() + - element.getTagName().length() - 1); + list = region.getRegions(); + regionStart = region.getStartOffset(); + startOffset = -1; + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) { + startOffset = regionStart + subRegion.getStart(); + } else if (DOMRegionContext.XML_TAG_CLOSE.equals(type)) { + int endOffset = regionStart + subRegion.getStart() + subRegion.getLength(); + if (startOffset != -1) { + DeleteEdit deletion = createDeletion(doc, startOffset, endOffset); + rootEdit.addChild(deletion); + endLineInclusive = doc.getLineOfOffset(startOffset) - 1; + } + break; + } + } + } + + // Dedent the contents + if (changeIndentation && startLineInclusive != -1 && endLineInclusive != -1) { + String indent = AndroidXmlEditor.getIndentAtOffset(doc, getRegion(element) + .getStartOffset()); + setIndentation(rootEdit, indent, doc, startLineInclusive, endLineInclusive, + element, skip); + } + } finally { + model.releaseFromRead(); + } + } + + protected void removeIndentation(MultiTextEdit rootEdit, String removeIndent, + IStructuredDocument doc, int startLineInclusive, int endLineInclusive, + Element element, List<Element> skip) { + if (startLineInclusive > endLineInclusive) { + return; + } + int indentLength = removeIndent.length(); + if (indentLength == 0) { + return; + } + + try { + for (int line = startLineInclusive; line <= endLineInclusive; line++) { + IRegion info = doc.getLineInformation(line); + int lineStart = info.getOffset(); + int lineLength = info.getLength(); + int lineEnd = lineStart + lineLength; + if (overlaps(lineStart, lineEnd, element, skip)) { + continue; + } + String lineText = getText(lineStart, + lineStart + Math.min(lineLength, indentLength)); + if (lineText.startsWith(removeIndent)) { + rootEdit.addChild(new DeleteEdit(lineStart, indentLength)); + } + } + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + } + + protected void setIndentation(MultiTextEdit rootEdit, String indent, + IStructuredDocument doc, int startLineInclusive, int endLineInclusive, + Element element, List<Element> skip) { + if (startLineInclusive > endLineInclusive) { + return; + } + int indentLength = indent.length(); + if (indentLength == 0) { + return; + } + + try { + for (int line = startLineInclusive; line <= endLineInclusive; line++) { + IRegion info = doc.getLineInformation(line); + int lineStart = info.getOffset(); + int lineLength = info.getLength(); + int lineEnd = lineStart + lineLength; + if (overlaps(lineStart, lineEnd, element, skip)) { + continue; + } + String lineText = getText(lineStart, lineStart + lineLength); + int indentEnd = getFirstNonSpace(lineText); + rootEdit.addChild(new ReplaceEdit(lineStart, indentEnd, indent)); + } + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + } + + private int getFirstNonSpace(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isWhitespace(s.charAt(i))) { + return i; + } + } + + return s.length(); + } + + /** Returns true if the given line overlaps any of the given elements */ + private static boolean overlaps(int startOffset, int endOffset, + Element element, List<Element> overlaps) { + for (Element e : overlaps) { + if (e == element) { + continue; + } + + IndexedRegion region = getRegion(e); + if (region.getEndOffset() >= startOffset && region.getStartOffset() <= endOffset) { + return true; + } + } + return false; + } + + protected DeleteEdit createDeletion(IStructuredDocument doc, int startOffset, int endOffset) { + // Expand to delete the whole line? + try { + IRegion info = doc.getLineInformationOfOffset(startOffset); + int lineBegin = info.getOffset(); + // Is the text on the line leading up to the deletion region, + // and the text following it, all whitespace? + boolean deleteLine = true; + if (lineBegin < startOffset) { + String prefix = getText(lineBegin, startOffset); + if (prefix.trim().length() > 0) { + deleteLine = false; + } + } + info = doc.getLineInformationOfOffset(endOffset); + int lineEnd = info.getOffset() + info.getLength(); + if (lineEnd > endOffset) { + String suffix = getText(endOffset, lineEnd); + if (suffix.trim().length() > 0) { + deleteLine = false; + } + } + if (deleteLine) { + startOffset = lineBegin; + endOffset = Math.min(doc.getLength(), lineEnd + 1); + } + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + + + return new DeleteEdit(startOffset, endOffset - startOffset); + } + + /** + * Rewrite the edits in the given {@link MultiTextEdit} such that same edits are + * applied, but the resulting range is also formatted + */ + protected MultiTextEdit reformat(MultiTextEdit edit, XmlFormatStyle style) { + String xml = mDelegate.getEditor().getStructuredDocument().get(); + return reformat(xml, edit, style); + } + + /** + * Rewrite the edits in the given {@link MultiTextEdit} such that same edits are + * applied, but the resulting range is also formatted + * + * @param oldContents the original contents that should be edited by a + * {@link MultiTextEdit} + * @param edit the {@link MultiTextEdit} to be applied to some string + * @param style the formatting style to use + * @return a new {@link MultiTextEdit} which performs the same edits as the input edit + * but also reformats the text + */ + public static MultiTextEdit reformat(String oldContents, MultiTextEdit edit, + XmlFormatStyle style) { + IDocument document = new org.eclipse.jface.text.Document(); + document.set(oldContents); + + try { + edit.apply(document); + } catch (MalformedTreeException e) { + AdtPlugin.log(e, null); + return null; // Abort formatting + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + return null; // Abort formatting + } + + String actual = document.get(); + + // TODO: Try to format only the affected portion of the document. + // To do that we need to find out what the affected offsets are; we know + // the MultiTextEdit's affected range, but that is referring to offsets + // in the old document. Use that to compute offsets in the new document. + //int distanceFromEnd = actual.length() - edit.getExclusiveEnd(); + //IStructuredModel model = DomUtilities.createStructuredModel(actual); + //int start = edit.getOffset(); + //int end = actual.length() - distanceFromEnd; + //int length = end - start; + //TextEdit format = AndroidXmlFormattingStrategy.format(model, start, length); + EclipseXmlFormatPreferences formatPrefs = EclipseXmlFormatPreferences.create(); + String formatted = EclipseXmlPrettyPrinter.prettyPrint(actual, formatPrefs, style, + null /*lineSeparator*/); + + + // Figure out how much of the before and after strings are identical and narrow + // the replacement scope + boolean foundDifference = false; + int firstDifference = 0; + int lastDifference = formatted.length(); + int start = 0; + int end = oldContents.length(); + + for (int i = 0, j = start; i < formatted.length() && j < end; i++, j++) { + if (formatted.charAt(i) != oldContents.charAt(j)) { + firstDifference = i; + foundDifference = true; + break; + } + } + + if (!foundDifference) { + // No differences - the document is already formatted, nothing to do + return null; + } + + lastDifference = firstDifference + 1; + for (int i = formatted.length() - 1, j = end - 1; + i > firstDifference && j > start; + i--, j--) { + if (formatted.charAt(i) != oldContents.charAt(j)) { + lastDifference = i + 1; + break; + } + } + + start += firstDifference; + end -= (formatted.length() - lastDifference); + end = Math.max(start, end); + formatted = formatted.substring(firstDifference, lastDifference); + + ReplaceEdit format = new ReplaceEdit(start, end - start, + formatted); + + MultiTextEdit newEdit = new MultiTextEdit(); + newEdit.addChild(format); + + return newEdit; + } + + protected ViewElementDescriptor getElementDescriptor(String fqcn) { + AndroidTargetData data = mDelegate.getEditor().getTargetData(); + if (data != null) { + return data.getLayoutDescriptors().findDescriptorByClass(fqcn); + } + + return null; + } + + /** Create a wizard for this refactoring */ + abstract VisualRefactoringWizard createWizard(); + + public abstract static class VisualRefactoringDescriptor extends RefactoringDescriptor { + private final Map<String, String> mArguments; + + public VisualRefactoringDescriptor( + String id, String project, String description, String comment, + Map<String, String> arguments) { + super(id, project, description, comment, STRUCTURAL_CHANGE | MULTI_CHANGE); + mArguments = arguments; + } + + public Map<String, String> getArguments() { + return mArguments; + } + + protected abstract Refactoring createRefactoring(Map<String, String> args); + + @Override + public Refactoring createRefactoring(RefactoringStatus status) throws CoreException { + try { + return createRefactoring(mArguments); + } catch (NullPointerException e) { + status.addFatalError("Failed to recreate refactoring from descriptor"); + return null; + } + } + } +} |