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