blob: 0e56bdf4d93c3bee362535cad0a9a4958b9af1ce [file] [log] [blame]
/*
* Copyright (C) 2012 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.ATTR_DRAWABLE_BOTTOM;
import static com.android.SdkConstants.ATTR_DRAWABLE_LEFT;
import static com.android.SdkConstants.ATTR_DRAWABLE_PADDING;
import static com.android.SdkConstants.ATTR_DRAWABLE_RIGHT;
import static com.android.SdkConstants.ATTR_DRAWABLE_TOP;
import static com.android.SdkConstants.ATTR_GRAVITY;
import static com.android.SdkConstants.ATTR_ID;
import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM;
import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT;
import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT;
import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP;
import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX;
import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
import static com.android.SdkConstants.ATTR_ORIENTATION;
import static com.android.SdkConstants.ATTR_SRC;
import static com.android.SdkConstants.EXT_XML;
import static com.android.SdkConstants.IMAGE_VIEW;
import static com.android.SdkConstants.LINEAR_LAYOUT;
import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
import static com.android.SdkConstants.TEXT_VIEW;
import static com.android.SdkConstants.VALUE_VERTICAL;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.xml.XmlFormatStyle;
import com.android.ide.eclipse.adt.AdtUtils;
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.gle2.CanvasViewInfo;
import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities;
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.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.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.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Converts a LinearLayout with exactly a TextView child and an ImageView child into
* a single TextView with a compound drawable.
*/
@SuppressWarnings("restriction") // XML model
public class UseCompoundDrawableRefactoring extends VisualRefactoring {
/**
* Constructs a new {@link UseCompoundDrawableRefactoring}
*
* @param file the file to refactor in
* @param editor the corresponding editor
* @param selection the editor selection, or null
* @param treeSelection the canvas selection, or null
*/
public UseCompoundDrawableRefactoring(IFile file, LayoutEditorDelegate editor,
ITextSelection selection, ITreeSelection treeSelection) {
super(file, editor, selection, treeSelection);
}
/**
* This constructor is solely used by {@link Descriptor}, to replay a
* previous refactoring.
*
* @param arguments argument map created by #createArgumentMap.
*/
private UseCompoundDrawableRefactoring(Map<String, String> arguments) {
super(arguments);
}
@VisibleForTesting
UseCompoundDrawableRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) {
super(selectedElements, editor);
}
@Override
public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException,
OperationCanceledException {
RefactoringStatus status = new RefactoringStatus();
try {
pm.beginTask("Checking preconditions...", 6);
if (mSelectionStart == -1 || mSelectionEnd == -1) {
status.addFatalError("Nothing to convert");
return status;
}
// Make sure the selection is contiguous
if (mTreeSelection != null) {
List<CanvasViewInfo> infos = getSelectedViewInfos();
if (!validateNotEmpty(infos, status)) {
return status;
}
// Enforce that the selection is -contiguous-
if (!validateContiguous(infos, status)) {
return status;
}
}
// Ensures that we have a valid DOM model:
if (mElements.size() == 0) {
status.addFatalError("Nothing to convert");
return status;
}
// Ensure that we have selected precisely one LinearLayout
if (mElements.size() != 1 ||
!(mElements.get(0).getTagName().equals(LINEAR_LAYOUT))) {
status.addFatalError("Must select exactly one LinearLayout");
return status;
}
Element layout = mElements.get(0);
List<Element> children = DomUtilities.getChildren(layout);
if (children.size() != 2) {
status.addFatalError("The LinearLayout must have exactly two children");
return status;
}
Element first = children.get(0);
Element second = children.get(1);
boolean haveTextView =
first.getTagName().equals(TEXT_VIEW)
|| second.getTagName().equals(TEXT_VIEW);
boolean haveImageView =
first.getTagName().equals(IMAGE_VIEW)
|| second.getTagName().equals(IMAGE_VIEW);
if (!(haveTextView && haveImageView)) {
status.addFatalError("The LinearLayout must have exactly one TextView child " +
"and one ImageView child");
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() {
return super.createArgumentMap();
}
@Override
public String getName() {
return "Convert to Compound Drawable";
}
@Override
protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) {
String androidNsPrefix = getAndroidNamespacePrefix();
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);
// (1) Build up the contents of the new TextView. This is identical
// to the old contents, but with the addition of a drawableTop/Left/Right/Bottom
// attribute (depending on the orientation and order), as well as any layout
// params from the LinearLayout.
// (2) Delete the linear layout and replace with the text view.
// (3) Reformat.
// checkInitialConditions has already validated that we have exactly a LinearLayout
// with an ImageView and a TextView child (in either order)
Element layout = mElements.get(0);
List<Element> children = DomUtilities.getChildren(layout);
Element first = children.get(0);
Element second = children.get(1);
final Element text;
final Element image;
if (first.getTagName().equals(TEXT_VIEW)) {
text = first;
image = second;
} else {
text = second;
image = first;
}
// Horizontal is the default, so if no value is specified it is horizontal.
boolean isVertical = VALUE_VERTICAL.equals(layout.getAttributeNS(ANDROID_URI,
ATTR_ORIENTATION));
// The WST DOM implementation doesn't correctly implement cloneNode: this returns
// an empty document instead:
// text.getOwnerDocument().cloneNode(false/*deep*/);
// Luckily we just need to clone a single element, not a nested structure, so it's
// easy enough to do this manually:
Document tempDocument = DomUtilities.createEmptyDocument();
if (tempDocument == null) {
return changes;
}
Element newTextElement = tempDocument.createElement(text.getTagName());
tempDocument.appendChild(newTextElement);
NamedNodeMap attributes = text.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attribute = (Attr) attributes.item(i);
String name = attribute.getLocalName();
if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)
&& ANDROID_URI.equals(attribute.getNamespaceURI())
&& !(name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT))) {
// Ignore layout params: the parent layout is going away
} else {
newTextElement.setAttribute(attribute.getName(), attribute.getValue());
}
}
// Apply all layout params from the parent (except width and height),
// as well as android:gravity
List<Attr> layoutAttributes = findLayoutAttributes(layout);
for (Attr attribute : layoutAttributes) {
String name = attribute.getLocalName();
if ((name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT))
&& ANDROID_URI.equals(attribute.getNamespaceURI())) {
// Already handled specially
continue;
}
newTextElement.setAttribute(attribute.getName(), attribute.getValue());
}
String gravity = layout.getAttributeNS(ANDROID_URI, ATTR_GRAVITY);
if (gravity.length() > 0) {
setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_GRAVITY, gravity);
}
String src = image.getAttributeNS(ANDROID_URI, ATTR_SRC);
// Set the drawable
String drawableAttribute;
// The space between the image and the text can have margins/padding, both
// from the text's perspective and from the image's perspective. We need to
// combine these.
String padding1 = null;
String padding2 = null;
if (isVertical) {
if (first == image) {
drawableAttribute = ATTR_DRAWABLE_TOP;
padding1 = getPadding(image, ATTR_LAYOUT_MARGIN_BOTTOM);
padding2 = getPadding(text, ATTR_LAYOUT_MARGIN_TOP);
} else {
drawableAttribute = ATTR_DRAWABLE_BOTTOM;
padding1 = getPadding(text, ATTR_LAYOUT_MARGIN_BOTTOM);
padding2 = getPadding(image, ATTR_LAYOUT_MARGIN_TOP);
}
} else {
if (first == image) {
drawableAttribute = ATTR_DRAWABLE_LEFT;
padding1 = getPadding(image, ATTR_LAYOUT_MARGIN_RIGHT);
padding2 = getPadding(text, ATTR_LAYOUT_MARGIN_LEFT);
} else {
drawableAttribute = ATTR_DRAWABLE_RIGHT;
padding1 = getPadding(text, ATTR_LAYOUT_MARGIN_RIGHT);
padding2 = getPadding(image, ATTR_LAYOUT_MARGIN_LEFT);
}
}
setAndroidAttribute(newTextElement, androidNsPrefix, drawableAttribute, src);
String padding = combine(padding1, padding2);
if (padding != null) {
setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_DRAWABLE_PADDING, padding);
}
// If the removed LinearLayout is the root container, transfer its namespace
// declaration to the TextView
if (layout.getParentNode() instanceof Document) {
List<Attr> declarations = findNamespaceAttributes(layout);
for (Attr attribute : declarations) {
if (attribute instanceof IndexedRegion) {
newTextElement.setAttribute(attribute.getName(), attribute.getValue());
}
}
}
// Update any layout references to the layout to point to the text view
String layoutId = getId(layout);
if (layoutId.length() > 0) {
String id = getId(text);
if (id.length() == 0) {
id = ensureHasId(rootEdit, text, null, false);
setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_ID, id);
}
IStructuredModel model = mDelegate.getEditor().getModelForRead();
try {
IStructuredDocument doc = model.getStructuredDocument();
if (doc != null) {
List<TextEdit> replaceIds = replaceIds(androidNsPrefix,
doc, mSelectionStart, mSelectionEnd, layoutId, id);
for (TextEdit edit : replaceIds) {
rootEdit.addChild(edit);
}
}
} finally {
model.releaseFromRead();
}
}
String xml = EclipseXmlPrettyPrinter.prettyPrint(
tempDocument.getDocumentElement(),
EclipseXmlFormatPreferences.create(),
XmlFormatStyle.LAYOUT, null, false);
TextEdit replace = new ReplaceEdit(mSelectionStart, mSelectionEnd - mSelectionStart, xml);
rootEdit.addChild(replace);
if (AdtPrefs.getPrefs().getFormatGuiXml()) {
MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT);
if (formatted != null) {
rootEdit = formatted;
}
}
change.setEdit(rootEdit);
changes.add(change);
return changes;
}
@Nullable
private static String getPadding(@NonNull Element element, @NonNull String attribute) {
String padding = element.getAttributeNS(ANDROID_URI, attribute);
if (padding != null && padding.isEmpty()) {
padding = null;
}
return padding;
}
@VisibleForTesting
@Nullable
static String combine(@Nullable String dimension1, @Nullable String dimension2) {
if (dimension1 == null || dimension1.isEmpty()) {
if (dimension2 != null && dimension2.isEmpty()) {
return null;
}
return dimension2;
} else if (dimension2 == null || dimension2.isEmpty()) {
if (dimension1 != null && dimension1.isEmpty()) {
return null;
}
return dimension1;
} else {
// Two dimensions are specified (e.g. marginRight for the left one and marginLeft
// for the right one); we have to add these together. We can only do that if
// they use the same units, and do not use resources.
if (dimension1.startsWith(PREFIX_RESOURCE_REF)
|| dimension2.startsWith(PREFIX_RESOURCE_REF)) {
return null;
}
Pattern p = Pattern.compile("([\\d\\.]+)(.+)"); //$NON-NLS-1$
Matcher matcher1 = p.matcher(dimension1);
Matcher matcher2 = p.matcher(dimension2);
if (matcher1.matches() && matcher2.matches()) {
String unit = matcher1.group(2);
if (unit.equals(matcher2.group(2))) {
float value1 = Float.parseFloat(matcher1.group(1));
float value2 = Float.parseFloat(matcher2.group(1));
return AdtUtils.formatFloatAttribute(value1 + value2) + unit;
}
}
}
return null;
}
/**
* Sets an Android attribute (in the Android namespace) on an element
* without a given namespace prefix. This is done when building a new Element
* in a temporary document such that the namespace prefix matches when the element is
* formatted and replaced in the target document.
*/
private static void setAndroidAttribute(Element element, String prefix, String name,
String value) {
element.setAttribute(prefix + ':' + name, value);
}
@Override
public VisualRefactoringWizard createWizard() {
return new UseCompoundDrawableWizard(this, mDelegate);
}
@SuppressWarnings("javadoc")
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.usecompound", //$NON-NLS-1$
project, description, comment, arguments);
}
@Override
protected Refactoring createRefactoring(Map<String, String> args) {
return new UseCompoundDrawableRefactoring(args);
}
}
}