1 /* 2 * Copyright (C) 2010 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 17 package com.android.ide.common.layout; 18 19 import static com.android.SdkConstants.ANDROID_URI; 20 import static com.android.SdkConstants.ATTR_CLASS; 21 import static com.android.SdkConstants.ATTR_HINT; 22 import static com.android.SdkConstants.ATTR_ID; 23 import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; 24 import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; 25 import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; 26 import static com.android.SdkConstants.ATTR_STYLE; 27 import static com.android.SdkConstants.ATTR_TEXT; 28 import static com.android.SdkConstants.DOT_LAYOUT_PARAMS; 29 import static com.android.SdkConstants.ID_PREFIX; 30 import static com.android.SdkConstants.NEW_ID_PREFIX; 31 import static com.android.SdkConstants.VALUE_FALSE; 32 import static com.android.SdkConstants.VALUE_FILL_PARENT; 33 import static com.android.SdkConstants.VALUE_MATCH_PARENT; 34 import static com.android.SdkConstants.VALUE_TRUE; 35 import static com.android.SdkConstants.VALUE_WRAP_CONTENT; 36 import static com.android.SdkConstants.VIEW_FRAGMENT; 37 38 import com.android.annotations.NonNull; 39 import com.android.annotations.Nullable; 40 import com.android.ide.common.api.AbstractViewRule; 41 import com.android.ide.common.api.IAttributeInfo; 42 import com.android.ide.common.api.IAttributeInfo.Format; 43 import com.android.ide.common.api.IClientRulesEngine; 44 import com.android.ide.common.api.IDragElement; 45 import com.android.ide.common.api.IMenuCallback; 46 import com.android.ide.common.api.INode; 47 import com.android.ide.common.api.IViewMetadata; 48 import com.android.ide.common.api.IViewRule; 49 import com.android.ide.common.api.RuleAction; 50 import com.android.ide.common.api.RuleAction.ActionProvider; 51 import com.android.ide.common.api.RuleAction.ChoiceProvider; 52 import com.android.resources.ResourceType; 53 import com.android.utils.Pair; 54 55 import java.net.URL; 56 import java.util.ArrayList; 57 import java.util.Arrays; 58 import java.util.Collection; 59 import java.util.Collections; 60 import java.util.Comparator; 61 import java.util.EnumSet; 62 import java.util.HashMap; 63 import java.util.HashSet; 64 import java.util.LinkedList; 65 import java.util.List; 66 import java.util.Locale; 67 import java.util.Map; 68 import java.util.Map.Entry; 69 import java.util.Set; 70 71 /** 72 * Common IViewRule processing to all view and layout classes. 73 */ 74 public class BaseViewRule extends AbstractViewRule { 75 /** List of recently edited properties */ 76 private static List<String> sRecent = new LinkedList<String>(); 77 78 /** Maximum number of recent properties to track and list */ 79 private final static int MAX_RECENT_COUNT = 12; 80 81 // Strings used as internal ids, group ids and prefixes for actions 82 private static final String FALSE_ID = "false"; //$NON-NLS-1$ 83 private static final String TRUE_ID = "true"; //$NON-NLS-1$ 84 private static final String PROP_PREFIX = "@prop@"; //$NON-NLS-1$ 85 private static final String CLEAR_ID = "clear"; //$NON-NLS-1$ 86 private static final String ZCUSTOM = "zcustom"; //$NON-NLS-1$ 87 88 protected IClientRulesEngine mRulesEngine; 89 90 // Cache of attributes. Key is FQCN of a node mixed with its view hierarchy 91 // parent. Values are a custom map as needed by getContextMenu. 92 private Map<String, Map<String, Prop>> mAttributesMap = 93 new HashMap<String, Map<String, Prop>>(); 94 95 @Override 96 public boolean onInitialize(@NonNull String fqcn, @NonNull IClientRulesEngine engine) { 97 mRulesEngine = engine; 98 99 // This base rule can handle any class so we don't need to filter on 100 // FQCN. Derived classes should do so if they can handle some 101 // subclasses. 102 103 // If onInitialize returns false, it means it can't handle the given 104 // FQCN and will be unloaded. 105 106 return true; 107 } 108 109 /** 110 * Returns the {@link IClientRulesEngine} associated with this {@link IViewRule} 111 * 112 * @return the {@link IClientRulesEngine} associated with this {@link IViewRule} 113 */ 114 public IClientRulesEngine getRulesEngine() { 115 return mRulesEngine; 116 } 117 118 // === Context Menu === 119 120 /** 121 * Generate custom actions for the context menu: <br/> 122 * - Explicit layout_width and layout_height attributes. 123 * - List of all other simple toggle attributes. 124 */ 125 @Override 126 public void addContextMenuActions(@NonNull List<RuleAction> actions, 127 final @NonNull INode selectedNode) { 128 String width = null; 129 String currentWidth = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH); 130 131 String fillParent = getFillParentValueName(); 132 boolean canMatchParent = supportsMatchParent(); 133 if (canMatchParent && VALUE_FILL_PARENT.equals(currentWidth)) { 134 currentWidth = VALUE_MATCH_PARENT; 135 } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentWidth)) { 136 currentWidth = VALUE_FILL_PARENT; 137 } else if (!VALUE_WRAP_CONTENT.equals(currentWidth) && !fillParent.equals(currentWidth)) { 138 width = currentWidth; 139 } 140 141 String height = null; 142 String currentHeight = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT); 143 144 if (canMatchParent && VALUE_FILL_PARENT.equals(currentHeight)) { 145 currentHeight = VALUE_MATCH_PARENT; 146 } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentHeight)) { 147 currentHeight = VALUE_FILL_PARENT; 148 } else if (!VALUE_WRAP_CONTENT.equals(currentHeight) 149 && !fillParent.equals(currentHeight)) { 150 height = currentHeight; 151 } 152 final String newWidth = width; 153 final String newHeight = height; 154 155 final IMenuCallback onChange = new IMenuCallback() { 156 @Override 157 public void action( 158 final @NonNull RuleAction action, 159 final @NonNull List<? extends INode> selectedNodes, 160 final @Nullable String valueId, final @Nullable Boolean newValue) { 161 String fullActionId = action.getId(); 162 boolean isProp = fullActionId.startsWith(PROP_PREFIX); 163 final String actionId = isProp ? 164 fullActionId.substring(PROP_PREFIX.length()) : fullActionId; 165 166 if (fullActionId.equals(ATTR_LAYOUT_WIDTH)) { 167 final String newAttrValue = getValue(valueId, newWidth); 168 if (newAttrValue != null) { 169 for (INode node : selectedNodes) { 170 node.editXml("Change Attribute " + ATTR_LAYOUT_WIDTH, 171 new PropertySettingNodeHandler(ANDROID_URI, 172 ATTR_LAYOUT_WIDTH, newAttrValue)); 173 } 174 editedProperty(ATTR_LAYOUT_WIDTH); 175 } 176 return; 177 } else if (fullActionId.equals(ATTR_LAYOUT_HEIGHT)) { 178 // Ask the user 179 final String newAttrValue = getValue(valueId, newHeight); 180 if (newAttrValue != null) { 181 for (INode node : selectedNodes) { 182 node.editXml("Change Attribute " + ATTR_LAYOUT_HEIGHT, 183 new PropertySettingNodeHandler(ANDROID_URI, 184 ATTR_LAYOUT_HEIGHT, newAttrValue)); 185 } 186 editedProperty(ATTR_LAYOUT_HEIGHT); 187 } 188 return; 189 } else if (fullActionId.equals(ATTR_ID)) { 190 // Ids must be set individually so open the id dialog for each 191 // selected node (though allow cancel to break the loop) 192 for (INode node : selectedNodes) { 193 if (!mRulesEngine.rename(node)) { 194 break; 195 } 196 } 197 editedProperty(ATTR_ID); 198 return; 199 } else if (isProp) { 200 INode firstNode = selectedNodes.get(0); 201 String key = getPropertyMapKey(selectedNode); 202 Map<String, Prop> props = mAttributesMap.get(key); 203 final Prop prop = (props != null) ? props.get(actionId) : null; 204 205 if (prop != null) { 206 editedProperty(actionId); 207 208 // For custom values (requiring an input dialog) input the 209 // value outside the undo-block. 210 // Input the value as a text, unless we know it's the "text" or 211 // "style" attributes (where we know we want to ask for specific 212 // resource types). 213 String uri = ANDROID_URI; 214 String v = null; 215 if (prop.isStringEdit()) { 216 boolean isStyle = actionId.equals(ATTR_STYLE); 217 boolean isText = actionId.equals(ATTR_TEXT); 218 boolean isHint = actionId.equals(ATTR_HINT); 219 if (isStyle || isText || isHint) { 220 String resourceTypeName = isStyle 221 ? ResourceType.STYLE.getName() 222 : ResourceType.STRING.getName(); 223 String oldValue = selectedNodes.size() == 1 224 ? (isStyle ? firstNode.getStringAttr(ATTR_STYLE, actionId) 225 : firstNode.getStringAttr(ANDROID_URI, actionId)) 226 : ""; //$NON-NLS-1$ 227 oldValue = ensureValidString(oldValue); 228 v = mRulesEngine.displayResourceInput(resourceTypeName, oldValue); 229 if (isStyle) { 230 uri = null; 231 } 232 } else if (actionId.equals(ATTR_CLASS) && selectedNodes.size() >= 1 && 233 VIEW_FRAGMENT.equals(selectedNodes.get(0).getFqcn())) { 234 v = mRulesEngine.displayFragmentSourceInput(); 235 uri = null; 236 } else { 237 v = inputAttributeValue(firstNode, actionId); 238 } 239 } 240 final String customValue = v; 241 242 for (INode n : selectedNodes) { 243 if (prop.isToggle()) { 244 // case of toggle 245 String value = ""; //$NON-NLS-1$ 246 if (valueId.equals(TRUE_ID)) { 247 value = newValue ? "true" : ""; //$NON-NLS-1$ //$NON-NLS-2$ 248 } else if (valueId.equals(FALSE_ID)) { 249 value = newValue ? "false" : "";//$NON-NLS-1$ //$NON-NLS-2$ 250 } 251 n.setAttribute(uri, actionId, value); 252 } else if (prop.isFlag()) { 253 // case of a flag 254 String values = ""; //$NON-NLS-1$ 255 if (!valueId.equals(CLEAR_ID)) { 256 values = n.getStringAttr(ANDROID_URI, actionId); 257 Set<String> newValues = new HashSet<String>(); 258 if (values != null) { 259 newValues.addAll(Arrays.asList( 260 values.split("\\|"))); //$NON-NLS-1$ 261 } 262 if (newValue) { 263 newValues.add(valueId); 264 } else { 265 newValues.remove(valueId); 266 } 267 268 List<String> sorted = new ArrayList<String>(newValues); 269 Collections.sort(sorted); 270 values = join('|', sorted); 271 272 // Special case 273 if (valueId.equals("normal")) { //$NON-NLS-1$ 274 // For textStyle for example, if you have "bold|italic" 275 // and you select the "normal" property, this should 276 // not behave in the normal flag way and "or" itself in; 277 // it should replace the other two. 278 // This also applies to imeOptions. 279 values = valueId; 280 } 281 } 282 n.setAttribute(uri, actionId, values); 283 } else if (prop.isEnum()) { 284 // case of an enum 285 String value = ""; //$NON-NLS-1$ 286 if (!valueId.equals(CLEAR_ID)) { 287 value = newValue ? valueId : ""; //$NON-NLS-1$ 288 } 289 n.setAttribute(uri, actionId, value); 290 } else { 291 assert prop.isStringEdit(); 292 // We've already received the value outside the undo block 293 if (customValue != null) { 294 n.setAttribute(uri, actionId, customValue); 295 } 296 } 297 } 298 } 299 } 300 } 301 302 /** 303 * Input the custom value for the given attribute. This will use the Reference 304 * Chooser if it is a reference value, otherwise a plain text editor. 305 */ 306 private String inputAttributeValue(final INode node, final String attribute) { 307 String oldValue = node.getStringAttr(ANDROID_URI, attribute); 308 oldValue = ensureValidString(oldValue); 309 IAttributeInfo attributeInfo = node.getAttributeInfo(ANDROID_URI, attribute); 310 if (attributeInfo != null 311 && attributeInfo.getFormats().contains(Format.REFERENCE)) { 312 return mRulesEngine.displayReferenceInput(oldValue); 313 } else { 314 // A single resource type? If so use a resource chooser initialized 315 // to this specific type 316 /* This does not work well, because the metadata is a bit misleading: 317 * for example a Button's "text" property and a Button's "onClick" property 318 * both claim to be of type [string], but @string/ is NOT valid for 319 * onClick.. 320 if (attributeInfo != null && attributeInfo.getFormats().length == 1) { 321 // Resource chooser 322 Format format = attributeInfo.getFormats()[0]; 323 return mRulesEngine.displayResourceInput(format.name(), oldValue); 324 } 325 */ 326 327 // Fallback: just edit the raw XML string 328 String message = String.format("New %1$s Value:", attribute); 329 return mRulesEngine.displayInput(message, oldValue, null); 330 } 331 } 332 333 /** 334 * Returns the value (which will ask the user if the value is the special 335 * {@link #ZCUSTOM} marker 336 */ 337 private String getValue(String valueId, String defaultValue) { 338 if (valueId.equals(ZCUSTOM)) { 339 if (defaultValue == null) { 340 defaultValue = ""; 341 } 342 String value = mRulesEngine.displayInput( 343 "Set custom layout attribute value (example: 50dp)", 344 defaultValue, null); 345 if (value != null && value.trim().length() > 0) { 346 return value.trim(); 347 } else { 348 return null; 349 } 350 } 351 352 return valueId; 353 } 354 }; 355 356 IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); 357 if (textAttribute != null) { 358 actions.add(RuleAction.createAction(PROP_PREFIX + ATTR_TEXT, "Edit Text...", onChange, 359 null, 10, true)); 360 } 361 362 String editIdLabel = selectedNode.getStringAttr(ANDROID_URI, ATTR_ID) != null ? 363 "Edit ID..." : "Assign ID..."; 364 actions.add(RuleAction.createAction(ATTR_ID, editIdLabel, onChange, null, 20, true)); 365 366 addCommonPropertyActions(actions, selectedNode, onChange, 21); 367 368 // Create width choice submenu 369 actions.add(RuleAction.createSeparator(32)); 370 List<Pair<String, String>> widthChoices = new ArrayList<Pair<String,String>>(4); 371 widthChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content")); 372 if (canMatchParent) { 373 widthChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent")); 374 } else { 375 widthChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent")); 376 } 377 if (width != null) { 378 widthChoices.add(Pair.of(width, width)); 379 } 380 widthChoices.add(Pair.of(ZCUSTOM, "Other...")); 381 actions.add(RuleAction.createChoices( 382 ATTR_LAYOUT_WIDTH, "Layout Width", 383 onChange, 384 null /* iconUrls */, 385 currentWidth, 386 null, 35, 387 true, // supportsMultipleNodes 388 widthChoices)); 389 390 // Create height choice submenu 391 List<Pair<String, String>> heightChoices = new ArrayList<Pair<String,String>>(4); 392 heightChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content")); 393 if (canMatchParent) { 394 heightChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent")); 395 } else { 396 heightChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent")); 397 } 398 if (height != null) { 399 heightChoices.add(Pair.of(height, height)); 400 } 401 heightChoices.add(Pair.of(ZCUSTOM, "Other...")); 402 actions.add(RuleAction.createChoices( 403 ATTR_LAYOUT_HEIGHT, "Layout Height", 404 onChange, 405 null /* iconUrls */, 406 currentHeight, 407 null, 40, 408 true, 409 heightChoices)); 410 411 actions.add(RuleAction.createSeparator(45)); 412 RuleAction properties = RuleAction.createChoices("properties", "Other Properties", //$NON-NLS-1$ 413 onChange /*callback*/, null /*icon*/, 50, 414 true /*supportsMultipleNodes*/, new ActionProvider() { 415 @Override 416 public @NonNull List<RuleAction> getNestedActions(@NonNull INode node) { 417 List<RuleAction> propertyActionTypes = new ArrayList<RuleAction>(); 418 propertyActionTypes.add(RuleAction.createChoices( 419 "recent", "Recent", //$NON-NLS-1$ 420 onChange /*callback*/, null /*icon*/, 10, 421 true /*supportsMultipleNodes*/, new ActionProvider() { 422 @Override 423 public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { 424 List<RuleAction> propertyActions = new ArrayList<RuleAction>(); 425 addRecentPropertyActions(propertyActions, n, onChange); 426 return propertyActions; 427 } 428 })); 429 430 propertyActionTypes.add(RuleAction.createSeparator(20)); 431 432 addInheritedProperties(propertyActionTypes, node, onChange, 30); 433 434 propertyActionTypes.add(RuleAction.createSeparator(50)); 435 propertyActionTypes.add(RuleAction.createChoices( 436 "layoutparams", "Layout Parameters", //$NON-NLS-1$ 437 onChange /*callback*/, null /*icon*/, 60, 438 true /*supportsMultipleNodes*/, new ActionProvider() { 439 @Override 440 public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { 441 List<RuleAction> propertyActions = new ArrayList<RuleAction>(); 442 addPropertyActions(propertyActions, n, onChange, null, true); 443 return propertyActions; 444 } 445 })); 446 447 propertyActionTypes.add(RuleAction.createSeparator(70)); 448 449 propertyActionTypes.add(RuleAction.createChoices( 450 "allprops", "All By Name", //$NON-NLS-1$ 451 onChange /*callback*/, null /*icon*/, 80, 452 true /*supportsMultipleNodes*/, new ActionProvider() { 453 @Override 454 public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { 455 List<RuleAction> propertyActions = new ArrayList<RuleAction>(); 456 addPropertyActions(propertyActions, n, onChange, null, false); 457 return propertyActions; 458 } 459 })); 460 461 return propertyActionTypes; 462 } 463 }); 464 465 actions.add(properties); 466 } 467 468 @Override 469 @Nullable 470 public String getDefaultActionId(@NonNull final INode selectedNode) { 471 IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); 472 if (textAttribute != null) { 473 return PROP_PREFIX + ATTR_TEXT; 474 } 475 476 return null; 477 } 478 479 private static String getPropertyMapKey(INode node) { 480 // Compute the key for mAttributesMap. This depends on the type of this 481 // node and its parent in the view hierarchy. 482 StringBuilder sb = new StringBuilder(); 483 sb.append(node.getFqcn()); 484 sb.append('_'); 485 INode parent = node.getParent(); 486 if (parent != null) { 487 sb.append(parent.getFqcn()); 488 } 489 return sb.toString(); 490 } 491 492 /** 493 * Adds menu items for the inherited attributes, one pull-right menu for each super class 494 * that defines attributes. 495 * 496 * @param propertyActionTypes the actions list to add into 497 * @param node the node to apply the attributes to 498 * @param onChange the callback to use for setting attributes 499 * @param sortPriority the initial sort attribute for the first menu item 500 */ 501 private void addInheritedProperties(List<RuleAction> propertyActionTypes, INode node, 502 final IMenuCallback onChange, int sortPriority) { 503 List<String> attributeSources = node.getAttributeSources(); 504 for (final String definedBy : attributeSources) { 505 String sourceClass = definedBy; 506 507 // Strip package prefixes when necessary 508 int index = sourceClass.length(); 509 if (sourceClass.endsWith(DOT_LAYOUT_PARAMS)) { 510 index = sourceClass.length() - DOT_LAYOUT_PARAMS.length() - 1; 511 } 512 int lastDot = sourceClass.lastIndexOf('.', index); 513 if (lastDot != -1) { 514 sourceClass = sourceClass.substring(lastDot + 1); 515 } 516 517 String label; 518 if (definedBy.equals(node.getFqcn())) { 519 label = String.format("Defined by %1$s", sourceClass); 520 } else { 521 label = String.format("Inherited from %1$s", sourceClass); 522 } 523 524 propertyActionTypes.add(RuleAction.createChoices("def_" + definedBy, 525 label, 526 onChange /*callback*/, null /*icon*/, sortPriority++, 527 true /*supportsMultipleNodes*/, new ActionProvider() { 528 @Override 529 public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { 530 List<RuleAction> propertyActions = new ArrayList<RuleAction>(); 531 addPropertyActions(propertyActions, n, onChange, definedBy, false); 532 return propertyActions; 533 } 534 })); 535 } 536 } 537 538 /** 539 * Creates a list of properties that are commonly edited for views of the 540 * selected node's type 541 */ 542 private void addCommonPropertyActions(List<RuleAction> actions, INode selectedNode, 543 IMenuCallback onChange, int sortPriority) { 544 Map<String, Prop> properties = getPropertyMetadata(selectedNode); 545 IViewMetadata metadata = mRulesEngine.getMetadata(selectedNode.getFqcn()); 546 if (metadata != null) { 547 List<String> attributes = metadata.getTopAttributes(); 548 if (attributes.size() > 0) { 549 for (String attribute : attributes) { 550 // Text and ID are handled manually in the menu construction code because 551 // we want to place them consistently and customize the action label 552 if (ATTR_TEXT.equals(attribute) || ATTR_ID.equals(attribute)) { 553 continue; 554 } 555 556 Prop property = properties.get(attribute); 557 if (property != null) { 558 String title = property.getTitle(); 559 if (title.endsWith("...")) { 560 title = String.format("Edit %1$s", property.getTitle()); 561 } 562 actions.add(createPropertyAction(property, attribute, title, 563 selectedNode, onChange, sortPriority)); 564 sortPriority++; 565 } 566 } 567 } 568 } 569 } 570 571 /** 572 * Record that the given property was just edited; adds it to the front of 573 * the recently edited property list 574 * 575 * @param property the name of the property 576 */ 577 static void editedProperty(String property) { 578 if (sRecent.contains(property)) { 579 sRecent.remove(property); 580 } else if (sRecent.size() > MAX_RECENT_COUNT) { 581 sRecent.remove(sRecent.size() - 1); 582 } 583 sRecent.add(0, property); 584 } 585 586 /** 587 * Creates a list of recently modified properties that apply to the given selected node 588 */ 589 private void addRecentPropertyActions(List<RuleAction> actions, INode selectedNode, 590 IMenuCallback onChange) { 591 int sortPriority = 10; 592 Map<String, Prop> properties = getPropertyMetadata(selectedNode); 593 for (String attribute : sRecent) { 594 Prop property = properties.get(attribute); 595 if (property != null) { 596 actions.add(createPropertyAction(property, attribute, property.getTitle(), 597 selectedNode, onChange, sortPriority)); 598 sortPriority += 10; 599 } 600 } 601 } 602 603 /** 604 * Creates a list of nested actions representing the property-setting 605 * actions for the given selected node 606 */ 607 private void addPropertyActions(List<RuleAction> actions, INode selectedNode, 608 IMenuCallback onChange, String definedBy, boolean layoutParamsOnly) { 609 610 Map<String, Prop> properties = getPropertyMetadata(selectedNode); 611 612 int sortPriority = 10; 613 for (Map.Entry<String, Prop> entry : properties.entrySet()) { 614 String id = entry.getKey(); 615 Prop property = entry.getValue(); 616 if (layoutParamsOnly) { 617 // If we have definedBy information, that is most accurate; all layout 618 // params will be defined by a class whose name ends with 619 // .LayoutParams: 620 if (definedBy != null) { 621 if (!definedBy.endsWith(DOT_LAYOUT_PARAMS)) { 622 continue; 623 } 624 } else if (!id.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { 625 continue; 626 } 627 } 628 if (definedBy != null && !definedBy.equals(property.getDefinedBy())) { 629 continue; 630 } 631 actions.add(createPropertyAction(property, id, property.getTitle(), 632 selectedNode, onChange, sortPriority)); 633 sortPriority += 10; 634 } 635 636 // The properties are coming out of map key order which isn't right, so sort 637 // alphabetically instead 638 Collections.sort(actions, new Comparator<RuleAction>() { 639 @Override 640 public int compare(RuleAction action1, RuleAction action2) { 641 return action1.getTitle().compareTo(action2.getTitle()); 642 } 643 }); 644 } 645 646 private RuleAction createPropertyAction(Prop p, String id, String title, INode selectedNode, 647 IMenuCallback onChange, int sortPriority) { 648 if (p.isToggle()) { 649 // Toggles are handled as a multiple-choice between true, false 650 // and nothing (clear) 651 String value = selectedNode.getStringAttr(ANDROID_URI, id); 652 if (value != null) { 653 value = value.toLowerCase(Locale.US); 654 } 655 if (VALUE_TRUE.equals(value)) { 656 value = TRUE_ID; 657 } else if (VALUE_FALSE.equals(value)) { 658 value = FALSE_ID; 659 } else { 660 value = CLEAR_ID; 661 } 662 return RuleAction.createChoices(PROP_PREFIX + id, title, 663 onChange, BOOLEAN_CHOICE_PROVIDER, 664 value, 665 null, sortPriority, 666 true); 667 } else if (p.getChoices() != null) { 668 // Enum or flags. Their possible values are the multiple-choice 669 // items, with an extra "clear" option to remove everything. 670 String current = selectedNode.getStringAttr(ANDROID_URI, id); 671 if (current == null || current.length() == 0) { 672 current = CLEAR_ID; 673 } 674 return RuleAction.createChoices(PROP_PREFIX + id, title, 675 onChange, new EnumPropertyChoiceProvider(p), 676 current, 677 null, sortPriority, 678 true); 679 } else { 680 return RuleAction.createAction( 681 PROP_PREFIX + id, 682 title, 683 onChange, 684 null, sortPriority, 685 true); 686 } 687 } 688 689 private Map<String, Prop> getPropertyMetadata(final INode selectedNode) { 690 String key = getPropertyMapKey(selectedNode); 691 Map<String, Prop> props = mAttributesMap.get(key); 692 if (props == null) { 693 // Prepare the property map 694 props = new HashMap<String, Prop>(); 695 for (IAttributeInfo attrInfo : selectedNode.getDeclaredAttributes()) { 696 String id = attrInfo != null ? attrInfo.getName() : null; 697 if (id == null || id.equals(ATTR_LAYOUT_WIDTH) || id.equals(ATTR_LAYOUT_HEIGHT)) { 698 // Layout width/height are already handled at the root level 699 continue; 700 } 701 if (attrInfo == null) { 702 continue; 703 } 704 EnumSet<Format> formats = attrInfo.getFormats(); 705 706 String title = getAttributeDisplayName(id); 707 708 String definedBy = attrInfo != null ? attrInfo.getDefinedBy() : null; 709 if (formats.contains(IAttributeInfo.Format.BOOLEAN)) { 710 props.put(id, new Prop(title, true, definedBy)); 711 } else if (formats.contains(IAttributeInfo.Format.ENUM)) { 712 // Convert each enum into a map id=>title 713 Map<String, String> values = new HashMap<String, String>(); 714 if (attrInfo != null) { 715 for (String e : attrInfo.getEnumValues()) { 716 values.put(e, getAttributeDisplayName(e)); 717 } 718 } 719 720 props.put(id, new Prop(title, false, false, values, definedBy)); 721 } else if (formats.contains(IAttributeInfo.Format.FLAG)) { 722 // Convert each flag into a map id=>title 723 Map<String, String> values = new HashMap<String, String>(); 724 if (attrInfo != null) { 725 for (String e : attrInfo.getFlagValues()) { 726 values.put(e, getAttributeDisplayName(e)); 727 } 728 } 729 730 props.put(id, new Prop(title, false, true, values, definedBy)); 731 } else { 732 props.put(id, new Prop(title + "...", false, definedBy)); 733 } 734 } 735 mAttributesMap.put(key, props); 736 } 737 return props; 738 } 739 740 /** 741 * A {@link ChoiceProvder} which provides alternatives suitable for choosing 742 * values for a boolean property: true, false, or "default". 743 */ 744 private static ChoiceProvider BOOLEAN_CHOICE_PROVIDER = new ChoiceProvider() { 745 @Override 746 public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, 747 @NonNull List<String> ids) { 748 titles.add("True"); 749 ids.add(TRUE_ID); 750 751 titles.add("False"); 752 ids.add(FALSE_ID); 753 754 titles.add(RuleAction.SEPARATOR); 755 ids.add(RuleAction.SEPARATOR); 756 757 titles.add("Default"); 758 ids.add(CLEAR_ID); 759 } 760 }; 761 762 /** 763 * A {@link ChoiceProvider} which provides the various available 764 * attribute values available for a given {@link Prop} property descriptor. 765 */ 766 private static class EnumPropertyChoiceProvider implements ChoiceProvider { 767 private Prop mProperty; 768 769 public EnumPropertyChoiceProvider(Prop property) { 770 super(); 771 mProperty = property; 772 } 773 774 @Override 775 public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, 776 @NonNull List<String> ids) { 777 for (Entry<String, String> entry : mProperty.getChoices().entrySet()) { 778 ids.add(entry.getKey()); 779 titles.add(entry.getValue()); 780 } 781 782 titles.add(RuleAction.SEPARATOR); 783 ids.add(RuleAction.SEPARATOR); 784 785 titles.add("Default"); 786 ids.add(CLEAR_ID); 787 } 788 } 789 790 /** 791 * Returns true if the given node is "filled" (e.g. has layout width set to match 792 * parent or fill parent 793 */ 794 protected final boolean isFilled(INode node, String attribute) { 795 String value = node.getStringAttr(ANDROID_URI, attribute); 796 return VALUE_MATCH_PARENT.equals(value) || VALUE_FILL_PARENT.equals(value); 797 } 798 799 /** 800 * Returns fill_parent or match_parent, depending on whether the minimum supported 801 * platform supports match_parent or not 802 * 803 * @return match_parent or fill_parent depending on which is supported by the project 804 */ 805 protected final String getFillParentValueName() { 806 return supportsMatchParent() ? VALUE_MATCH_PARENT : VALUE_FILL_PARENT; 807 } 808 809 /** 810 * Returns true if the project supports match_parent instead of just fill_parent 811 * 812 * @return true if the project supports match_parent instead of just fill_parent 813 */ 814 protected final boolean supportsMatchParent() { 815 // fill_parent was renamed match_parent in API level 8 816 return mRulesEngine.getMinApiLevel() >= 8; 817 } 818 819 /** Join strings into a single string with the given delimiter */ 820 static String join(char delimiter, Collection<String> strings) { 821 StringBuilder sb = new StringBuilder(100); 822 for (String s : strings) { 823 if (sb.length() > 0) { 824 sb.append(delimiter); 825 } 826 sb.append(s); 827 } 828 return sb.toString(); 829 } 830 831 static Map<String, String> concatenate(Map<String, String> pre, Map<String, String> post) { 832 Map<String, String> result = new HashMap<String, String>(pre.size() + post.size()); 833 result.putAll(pre); 834 result.putAll(post); 835 return result; 836 } 837 838 // Quick utility for building up maps declaratively to minimize the diffs 839 static Map<String, String> mapify(String... values) { 840 Map<String, String> map = new HashMap<String, String>(values.length / 2); 841 for (int i = 0; i < values.length; i += 2) { 842 String key = values[i]; 843 if (key == null) { 844 continue; 845 } 846 String value = values[i + 1]; 847 map.put(key, value); 848 } 849 850 return map; 851 } 852 853 /** 854 * Produces a display name for an attribute, usually capitalizing the attribute name 855 * and splitting up underscores into new words 856 * 857 * @param name the attribute name to convert 858 * @return a display name for the attribute name 859 */ 860 public static String getAttributeDisplayName(String name) { 861 if (name != null && name.length() > 0) { 862 StringBuilder sb = new StringBuilder(); 863 boolean capitalizeNext = true; 864 for (int i = 0, n = name.length(); i < n; i++) { 865 char c = name.charAt(i); 866 if (capitalizeNext) { 867 c = Character.toUpperCase(c); 868 } 869 capitalizeNext = false; 870 if (c == '_') { 871 c = ' '; 872 capitalizeNext = true; 873 } 874 sb.append(c); 875 } 876 877 return sb.toString(); 878 } 879 880 return name; 881 } 882 883 884 // ==== Paste support ==== 885 886 /** 887 * Most views can't accept children so there's nothing to paste on them. In 888 * this case, defer the call to the parent layout and use the target node as 889 * an indication of where to paste. 890 */ 891 @Override 892 public void onPaste(@NonNull INode targetNode, @Nullable Object targetView, 893 @NonNull IDragElement[] elements) { 894 // 895 INode parent = targetNode.getParent(); 896 if (parent != null) { 897 String parentFqcn = parent.getFqcn(); 898 IViewRule parentRule = mRulesEngine.loadRule(parentFqcn); 899 900 if (parentRule instanceof BaseLayoutRule) { 901 ((BaseLayoutRule) parentRule).onPasteBeforeChild(parent, targetView, targetNode, 902 elements); 903 } 904 } 905 } 906 907 /** 908 * Support class for the context menu code. Stores state about properties in 909 * the context menu. 910 */ 911 private static class Prop { 912 private final boolean mToggle; 913 private final boolean mFlag; 914 private final String mTitle; 915 private final Map<String, String> mChoices; 916 private String mDefinedBy; 917 918 public Prop(String title, boolean isToggle, boolean isFlag, Map<String, String> choices, 919 String definedBy) { 920 mTitle = title; 921 mToggle = isToggle; 922 mFlag = isFlag; 923 mChoices = choices; 924 mDefinedBy = definedBy; 925 } 926 927 public String getDefinedBy() { 928 return mDefinedBy; 929 } 930 931 public Prop(String title, boolean isToggle, String definedBy) { 932 this(title, isToggle, false, null, definedBy); 933 } 934 935 private boolean isToggle() { 936 return mToggle; 937 } 938 939 private boolean isFlag() { 940 return mFlag && mChoices != null; 941 } 942 943 private boolean isEnum() { 944 return !mFlag && mChoices != null; 945 } 946 947 private String getTitle() { 948 return mTitle; 949 } 950 951 private Map<String, String> getChoices() { 952 return mChoices; 953 } 954 955 private boolean isStringEdit() { 956 return mChoices == null && !mToggle; 957 } 958 } 959 960 /** 961 * Returns a source attribute value which points to a sample image. This is typically 962 * used to provide an initial image shown on ImageButtons, etc. There is no guarantee 963 * that the source pointed to by this method actually exists. 964 * 965 * @return a source attribute to use for sample images, never null 966 */ 967 protected final String getSampleImageSrc() { 968 // Builtin graphics available since v1: 969 return "@android:drawable/btn_star"; //$NON-NLS-1$ 970 } 971 972 /** 973 * Strips the {@code @+id} or {@code @id} prefix off of the given id 974 * 975 * @param id attribute to be stripped 976 * @return the id name without the {@code @+id} or {@code @id} prefix 977 */ 978 @NonNull 979 public static String stripIdPrefix(@Nullable String id) { 980 if (id == null) { 981 return ""; //$NON-NLS-1$ 982 } else if (id.startsWith(NEW_ID_PREFIX)) { 983 return id.substring(NEW_ID_PREFIX.length()); 984 } else if (id.startsWith(ID_PREFIX)) { 985 return id.substring(ID_PREFIX.length()); 986 } 987 return id; 988 } 989 990 private static String ensureValidString(String value) { 991 if (value == null) { 992 value = ""; //$NON-NLS-1$ 993 } 994 return value; 995 } 996 } 997