diff options
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common')
81 files changed, 15163 insertions, 0 deletions
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AbsListViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AbsListViewRule.java new file mode 100644 index 000000000..cd1b0fcae --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AbsListViewRule.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.ide.common.api.IViewRule; + +/** + * An {@link IViewRule} for android.widget.AbsListViewRule + */ +public class AbsListViewRule extends IgnoredLayoutRule { + + // GridViews and ListViews are not configurable via XML + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AbsoluteLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AbsoluteLayoutRule.java new file mode 100644 index 000000000..3ec3b5f1a --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AbsoluteLayoutRule.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_X; +import static com.android.SdkConstants.ATTR_LAYOUT_Y; +import static com.android.SdkConstants.VALUE_N_DP; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.SegmentType; +import com.android.utils.Pair; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * An {@link IViewRule} for android.widget.AbsoluteLayout and all its derived + * classes. + */ +public class AbsoluteLayoutRule extends BaseLayoutRule { + + @Override + public List<String> getSelectionHint(@NonNull INode parentNode, @NonNull INode childNode) { + List<String> infos = new ArrayList<String>(2); + infos.add("AbsoluteLayout is deprecated."); + infos.add("Use other layouts instead."); + return infos; + } + + // ==== Drag'n'drop support ==== + // The AbsoluteLayout accepts any drag'n'drop anywhere on its surface. + + @Override + public DropFeedback onDropEnter(@NonNull INode targetNode, @Nullable Object targetView, + final @Nullable IDragElement[] elements) { + + if (elements.length == 0) { + return null; + } + + DropFeedback df = new DropFeedback(null, new IFeedbackPainter() { + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, + @NonNull DropFeedback feedback) { + // Paint callback for the AbsoluteLayout. + // This is called by the canvas when a draw is needed. + drawFeedback(gc, node, elements, feedback); + } + }); + df.errorMessage = "AbsoluteLayout is deprecated."; + return df; + } + + void drawFeedback( + IGraphics gc, + INode targetNode, + IDragElement[] elements, + DropFeedback feedback) { + Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return; + } + + // Highlight the receiver + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + + // Get the drop point + Point p = (Point) feedback.userData; + + if (p == null) { + return; + } + + int x = p.x; + int y = p.y; + + Rect be = elements[0].getBounds(); + + if (be.isValid()) { + // At least the first element has a bound. Draw rectangles + // for all dropped elements with valid bounds, offset at + // the drop point. + int offsetX = x - be.x + (feedback.dragBounds != null ? feedback.dragBounds.x : 0); + int offsetY = y - be.y + (feedback.dragBounds != null ? feedback.dragBounds.y : 0); + gc.useStyle(DrawingStyle.DROP_PREVIEW); + for (IDragElement element : elements) { + drawElement(gc, element, offsetX, offsetY); + } + } else { + // We don't have bounds for new elements. In this case + // just draw cross hairs to the drop point. + gc.useStyle(DrawingStyle.GUIDELINE); + gc.drawLine(x, b.y, x, b.y + b.h); + gc.drawLine(b.x, y, b.x + b.w, y); + + // Use preview lines to indicate the bottom quadrant as well (to + // indicate that you are looking at the top left position of the + // drop, not the center for example) + gc.useStyle(DrawingStyle.DROP_PREVIEW); + gc.drawLine(x, y, b.x + b.w, y); + gc.drawLine(x, y, x, b.y + b.h); + } + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + // Update the data used by the DropFeedback.paintCallback above. + feedback.userData = p; + feedback.requestPaint = true; + + return feedback; + } + + @Override + public void onDropLeave(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback) { + // Nothing to do. + } + + @Override + public void onDropped(final @NonNull INode targetNode, final @NonNull IDragElement[] elements, + final @Nullable DropFeedback feedback, final @NonNull Point p) { + + final Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return; + } + + // Collect IDs from dropped elements and remap them to new IDs + // if this is a copy or from a different canvas. + final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, + feedback.isCopy || !feedback.sameCanvas); + + targetNode.editXml("Add elements to AbsoluteLayout", new INodeHandler() { + @Override + public void handle(@NonNull INode node) { + boolean first = true; + Point offset = null; + + // Now write the new elements. + for (IDragElement element : elements) { + String fqcn = element.getFqcn(); + Rect be = element.getBounds(); + + INode newChild = targetNode.appendChild(fqcn); + + // Copy all the attributes, modifying them as needed. + addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); + + int deltaX = (feedback.dragBounds != null ? feedback.dragBounds.x : 0); + int deltaY = (feedback.dragBounds != null ? feedback.dragBounds.y : 0); + + int x = p.x - b.x + deltaX; + int y = p.y - b.y + deltaY; + + if (first) { + first = false; + if (be.isValid()) { + offset = new Point(x - be.x, y - be.y); + } + } else if (offset != null && be.isValid()) { + x = offset.x + be.x; + y = offset.y + be.y; + } else { + x += 10; + y += be.isValid() ? be.h : 10; + } + + double scale = feedback.dipScale; + if (scale != 1.0) { + x *= scale; + y *= scale; + } + + newChild.setAttribute(ANDROID_URI, ATTR_LAYOUT_X, + String.format(VALUE_N_DP, x)); + newChild.setAttribute(ANDROID_URI, ATTR_LAYOUT_Y, + String.format(VALUE_N_DP, y)); + + addInnerElements(newChild, element, idMap); + } + } + }); + } + + /** + * {@inheritDoc} + * <p> + * Overridden in this layout in order to let the top left coordinate be affected by + * the resize operation too. In other words, dragging the top left corner to resize a + * widget will not only change the size of the widget, it will also move it (though in + * this case, the bottom right corner will stay fixed). + */ + @Override + protected void setNewSizeBounds(ResizeState resizeState, INode node, INode layout, + Rect previousBounds, Rect newBounds, SegmentType horizontalEdge, + SegmentType verticalEdge) { + super.setNewSizeBounds(resizeState, node, layout, previousBounds, newBounds, + horizontalEdge, verticalEdge); + if (verticalEdge != null && newBounds.x != previousBounds.x) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_X, + String.format(VALUE_N_DP, + mRulesEngine.pxToDp(newBounds.x - node.getParent().getBounds().x))); + } + if (horizontalEdge != null && newBounds.y != previousBounds.y) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_Y, + String.format(VALUE_N_DP, + mRulesEngine.pxToDp(newBounds.y - node.getParent().getBounds().y))); + } + } + + @Override + protected String getResizeUpdateMessage(ResizeState resizeState, INode child, INode parent, + Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { + Rect parentBounds = parent.getBounds(); + if (horizontalEdge == SegmentType.BOTTOM && verticalEdge == SegmentType.RIGHT) { + return super.getResizeUpdateMessage(resizeState, child, parent, newBounds, + horizontalEdge, verticalEdge); + } + return String.format("x=%d, y=%d\nwidth=%s, height=%s", + mRulesEngine.pxToDp(newBounds.x - parentBounds.x), + mRulesEngine.pxToDp(newBounds.y - parentBounds.y), + resizeState.getWidthAttribute(), resizeState.getHeightAttribute()); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AdapterViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AdapterViewRule.java new file mode 100644 index 000000000..28f5fc95e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/AdapterViewRule.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; + +/** Rule for AdapterView subclasses that don't have more specific rules */ +public class AdapterViewRule extends BaseLayoutRule { + @Override + public DropFeedback onDropEnter(@NonNull INode targetNode, @Nullable Object targetView, + @Nullable IDragElement[] elements) { + // You are not allowed to insert children into AdapterViews; you must + // use the dedicated addView methods etc dynamically + DropFeedback dropFeedback = new DropFeedback(null, new IFeedbackPainter() { + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, + @NonNull DropFeedback feedback) { + Rect b = node.getBounds(); + if (b.isValid()) { + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + } + } + }); + String fqcn = targetNode.getFqcn(); + String name = fqcn.substring(fqcn.lastIndexOf('.') +1); + dropFeedback.errorMessage = String.format( + "%s cannot be configured via XML; add content to the AdapterView using Java code", + name); + dropFeedback.invalidTarget = true; + return dropFeedback; + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + feedback.invalidTarget = true; + return feedback; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java new file mode 100644 index 000000000..df2c8f473 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java @@ -0,0 +1,878 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING; +import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN_SPAN; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN; +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_ROW; +import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_LAYOUT_X; +import static com.android.SdkConstants.ATTR_LAYOUT_Y; +import static com.android.SdkConstants.VALUE_FILL_PARENT; +import static com.android.SdkConstants.VALUE_MATCH_PARENT; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IDragElement.IDragAttribute; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.MarginType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.RuleAction.ChoiceProvider; +import com.android.ide.common.api.Segment; +import com.android.ide.common.api.SegmentType; +import com.android.utils.Pair; + +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * A {@link IViewRule} for all layouts. + */ +public class BaseLayoutRule extends BaseViewRule { + private static final String ACTION_FILL_WIDTH = "_fillW"; //$NON-NLS-1$ + private static final String ACTION_FILL_HEIGHT = "_fillH"; //$NON-NLS-1$ + private static final String ACTION_MARGIN = "_margin"; //$NON-NLS-1$ + private static final URL ICON_MARGINS = + BaseLayoutRule.class.getResource("margins.png"); //$NON-NLS-1$ + private static final URL ICON_GRAVITY = + BaseLayoutRule.class.getResource("gravity.png"); //$NON-NLS-1$ + private static final URL ICON_FILL_WIDTH = + BaseLayoutRule.class.getResource("fillwidth.png"); //$NON-NLS-1$ + private static final URL ICON_FILL_HEIGHT = + BaseLayoutRule.class.getResource("fillheight.png"); //$NON-NLS-1$ + + // ==== Layout Actions support ==== + + // The Margin layout parameters are available for LinearLayout, FrameLayout, RelativeLayout, + // and their subclasses. + protected final RuleAction createMarginAction(final INode parentNode, + final List<? extends INode> children) { + + final List<? extends INode> targets = children == null || children.size() == 0 ? + Collections.singletonList(parentNode) + : children; + final INode first = targets.get(0); + + IMenuCallback actionCallback = new IMenuCallback() { + @Override + public void action(@NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + final @Nullable Boolean newValue) { + parentNode.editXml("Change Margins", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + String uri = ANDROID_URI; + String all = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN); + String left = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_LEFT); + String right = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_RIGHT); + String top = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_TOP); + String bottom = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_BOTTOM); + String[] margins = mRulesEngine.displayMarginInput(all, left, + right, top, bottom); + if (margins != null) { + assert margins.length == 5; + for (INode child : targets) { + child.setAttribute(uri, ATTR_LAYOUT_MARGIN, margins[0]); + child.setAttribute(uri, ATTR_LAYOUT_MARGIN_LEFT, margins[1]); + child.setAttribute(uri, ATTR_LAYOUT_MARGIN_RIGHT, margins[2]); + child.setAttribute(uri, ATTR_LAYOUT_MARGIN_TOP, margins[3]); + child.setAttribute(uri, ATTR_LAYOUT_MARGIN_BOTTOM, margins[4]); + } + } + } + }); + } + }; + + return RuleAction.createAction(ACTION_MARGIN, "Change Margins...", actionCallback, + ICON_MARGINS, 40, false); + } + + // Both LinearLayout and RelativeLayout have a gravity (but RelativeLayout applies it + // to the parent whereas for LinearLayout it's on the children) + protected final RuleAction createGravityAction(final List<? extends INode> targets, final + String attributeName) { + if (targets != null && targets.size() > 0) { + final INode first = targets.get(0); + ChoiceProvider provider = new ChoiceProvider() { + @Override + public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, + @NonNull List<String> ids) { + IAttributeInfo info = first.getAttributeInfo(ANDROID_URI, attributeName); + if (info != null) { + // Generate list of possible gravity value constants + assert info.getFormats().contains(IAttributeInfo.Format.FLAG); + for (String name : info.getFlagValues()) { + titles.add(getAttributeDisplayName(name)); + ids.add(name); + } + } + } + }; + + return RuleAction.createChoices("_gravity", "Change Gravity", //$NON-NLS-1$ + new PropertyCallback(targets, "Change Gravity", ANDROID_URI, + attributeName), + provider, + first.getStringAttr(ANDROID_URI, attributeName), ICON_GRAVITY, + 43, false); + } + + return null; + } + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + + final List<? extends INode> targets = children == null || children.size() == 0 ? + Collections.singletonList(parentNode) + : children; + final INode first = targets.get(0); + + // Shared action callback + IMenuCallback actionCallback = new IMenuCallback() { + @Override + public void action( + @NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + final @Nullable Boolean newValue) { + final String actionId = action.getId(); + final String undoLabel; + if (actionId.equals(ACTION_FILL_WIDTH)) { + undoLabel = "Change Width Fill"; + } else if (actionId.equals(ACTION_FILL_HEIGHT)) { + undoLabel = "Change Height Fill"; + } else { + return; + } + parentNode.editXml(undoLabel, new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + String attribute = actionId.equals(ACTION_FILL_WIDTH) + ? ATTR_LAYOUT_WIDTH : ATTR_LAYOUT_HEIGHT; + String value; + if (newValue) { + if (supportsMatchParent()) { + value = VALUE_MATCH_PARENT; + } else { + value = VALUE_FILL_PARENT; + } + } else { + value = VALUE_WRAP_CONTENT; + } + for (INode child : targets) { + child.setAttribute(ANDROID_URI, attribute, value); + } + } + }); + } + }; + + actions.add(RuleAction.createToggle(ACTION_FILL_WIDTH, "Toggle Fill Width", + isFilled(first, ATTR_LAYOUT_WIDTH), actionCallback, ICON_FILL_WIDTH, 10, false)); + actions.add(RuleAction.createToggle(ACTION_FILL_HEIGHT, "Toggle Fill Height", + isFilled(first, ATTR_LAYOUT_HEIGHT), actionCallback, ICON_FILL_HEIGHT, 20, false)); + } + + // ==== Paste support ==== + + /** + * The default behavior for pasting in a layout is to simulate a drop in the + * top-left corner of the view. + * <p/> + * Note that we explicitly do not call super() here -- the BaseViewRule.onPaste handler + * will call onPasteBeforeChild() instead. + * <p/> + * Derived layouts should override this behavior if not appropriate. + */ + @Override + public void onPaste(@NonNull INode targetNode, @Nullable Object targetView, + @NonNull IDragElement[] elements) { + DropFeedback feedback = onDropEnter(targetNode, targetView, elements); + if (feedback != null) { + Point p = targetNode.getBounds().getTopLeft(); + feedback = onDropMove(targetNode, elements, feedback, p); + if (feedback != null) { + onDropLeave(targetNode, elements, feedback); + onDropped(targetNode, elements, feedback, p); + } + } + } + + /** + * The default behavior for pasting in a layout with a specific child target + * is to simulate a drop right above the top left of the given child target. + * <p/> + * This method is invoked by BaseView when onPaste() is called -- + * views don't generally accept children and instead use the target node as + * a hint to paste "before" it. + * + * @param parentNode the parent node we're pasting into + * @param parentView the view object for the parent layout, or null + * @param targetNode the first selected node + * @param elements the elements being pasted + */ + public void onPasteBeforeChild(INode parentNode, Object parentView, INode targetNode, + IDragElement[] elements) { + DropFeedback feedback = onDropEnter(parentNode, parentView, elements); + if (feedback != null) { + Point parentP = parentNode.getBounds().getTopLeft(); + Point targetP = targetNode.getBounds().getTopLeft(); + if (parentP.y < targetP.y) { + targetP.y -= 1; + } + + feedback = onDropMove(parentNode, elements, feedback, targetP); + if (feedback != null) { + onDropLeave(parentNode, elements, feedback); + onDropped(parentNode, elements, feedback, targetP); + } + } + } + + // ==== Utility methods used by derived layouts ==== + + /** + * Draws the bounds of the given elements and all its children elements in the canvas + * with the specified offset. + * + * @param gc the graphics context + * @param element the element to be drawn + * @param offsetX a horizontal delta to add to the current bounds of the element when + * drawing it + * @param offsetY a vertical delta to add to the current bounds of the element when + * drawing it + */ + public void drawElement(IGraphics gc, IDragElement element, int offsetX, int offsetY) { + Rect b = element.getBounds(); + if (b.isValid()) { + gc.drawRect(b.x + offsetX, b.y + offsetY, b.x + offsetX + b.w, b.y + offsetY + b.h); + } + + for (IDragElement inner : element.getInnerElements()) { + drawElement(gc, inner, offsetX, offsetY); + } + } + + /** + * Collect all the "android:id" IDs from the dropped elements. When moving + * objects within the same canvas, that's all there is to do. However if the + * objects are moved to a different canvas or are copied then set + * createNewIds to true to find the existing IDs under targetNode and create + * a map with new non-conflicting unique IDs as needed. Returns a map String + * old-id => tuple (String new-id, String fqcn) where fqcn is the FQCN of + * the element. + */ + protected static Map<String, Pair<String, String>> getDropIdMap(INode targetNode, + IDragElement[] elements, boolean createNewIds) { + Map<String, Pair<String, String>> idMap = new HashMap<String, Pair<String, String>>(); + + if (createNewIds) { + collectIds(idMap, elements); + // Need to remap ids if necessary + idMap = remapIds(targetNode, idMap); + } + + return idMap; + } + + /** + * Fills idMap with a map String id => tuple (String id, String fqcn) where + * fqcn is the FQCN of the element (in case we want to generate new IDs + * based on the element type.) + * + * @see #getDropIdMap + */ + protected static Map<String, Pair<String, String>> collectIds( + Map<String, Pair<String, String>> idMap, + IDragElement[] elements) { + for (IDragElement element : elements) { + IDragAttribute attr = element.getAttribute(ANDROID_URI, ATTR_ID); + if (attr != null) { + String id = attr.getValue(); + if (id != null && id.length() > 0) { + idMap.put(id, Pair.of(id, element.getFqcn())); + } + } + + collectIds(idMap, element.getInnerElements()); + } + + return idMap; + } + + /** + * Used by #getDropIdMap to find new IDs in case of conflict. + */ + protected static Map<String, Pair<String, String>> remapIds(INode node, + Map<String, Pair<String, String>> idMap) { + // Visit the document to get a list of existing ids + Set<String> existingIdSet = new HashSet<String>(); + collectExistingIds(node.getRoot(), existingIdSet); + + Map<String, Pair<String, String>> new_map = new HashMap<String, Pair<String, String>>(); + for (Map.Entry<String, Pair<String, String>> entry : idMap.entrySet()) { + String key = entry.getKey(); + Pair<String, String> value = entry.getValue(); + + String id = normalizeId(key); + + if (!existingIdSet.contains(id)) { + // Not a conflict. Use as-is. + new_map.put(key, value); + if (!key.equals(id)) { + new_map.put(id, value); + } + } else { + // There is a conflict. Get a new id. + String new_id = findNewId(value.getSecond(), existingIdSet); + value = Pair.of(new_id, value.getSecond()); + new_map.put(id, value); + new_map.put(id.replaceFirst("@\\+", "@"), value); //$NON-NLS-1$ //$NON-NLS-2$ + } + } + + return new_map; + } + + /** + * Used by #remapIds to find a new ID for a conflicting element. + */ + protected static String findNewId(String fqcn, Set<String> existingIdSet) { + // Get the last component of the FQCN (e.g. "android.view.Button" => + // "Button") + String name = fqcn.substring(fqcn.lastIndexOf('.') + 1); + + for (int i = 1; i < 1000000; i++) { + String id = String.format("@+id/%s%02d", name, i); //$NON-NLS-1$ + if (!existingIdSet.contains(id)) { + existingIdSet.add(id); + return id; + } + } + + // We'll never reach here. + return null; + } + + /** + * Used by #getDropIdMap to find existing IDs recursively. + */ + protected static void collectExistingIds(INode root, Set<String> existingIdSet) { + if (root == null) { + return; + } + + String id = root.getStringAttr(ANDROID_URI, ATTR_ID); + if (id != null) { + id = normalizeId(id); + + if (!existingIdSet.contains(id)) { + existingIdSet.add(id); + } + } + + for (INode child : root.getChildren()) { + collectExistingIds(child, existingIdSet); + } + } + + /** + * Transforms @id/name into @+id/name to treat both forms the same way. + */ + protected static String normalizeId(String id) { + if (id.indexOf("@+") == -1) { //$NON-NLS-1$ + id = id.replaceFirst("@", "@+"); //$NON-NLS-1$ //$NON-NLS-2$ + } + return id; + } + + /** + * For use by {@link BaseLayoutRule#addAttributes} A filter should return a + * valid replacement string. + */ + protected static interface AttributeFilter { + String replace(String attributeUri, String attributeName, String attributeValue); + } + + private static final String[] EXCLUDED_ATTRIBUTES = new String[] { + // Common + ATTR_LAYOUT_GRAVITY, + + // from AbsoluteLayout + ATTR_LAYOUT_X, + ATTR_LAYOUT_Y, + + // from RelativeLayout + ATTR_LAYOUT_ABOVE, + ATTR_LAYOUT_BELOW, + ATTR_LAYOUT_TO_LEFT_OF, + ATTR_LAYOUT_TO_RIGHT_OF, + ATTR_LAYOUT_ALIGN_BASELINE, + ATTR_LAYOUT_ALIGN_TOP, + ATTR_LAYOUT_ALIGN_BOTTOM, + ATTR_LAYOUT_ALIGN_LEFT, + ATTR_LAYOUT_ALIGN_RIGHT, + ATTR_LAYOUT_ALIGN_PARENT_TOP, + ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, + ATTR_LAYOUT_ALIGN_PARENT_LEFT, + ATTR_LAYOUT_ALIGN_PARENT_RIGHT, + ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING, + ATTR_LAYOUT_CENTER_HORIZONTAL, + ATTR_LAYOUT_CENTER_IN_PARENT, + ATTR_LAYOUT_CENTER_VERTICAL, + + // From GridLayout + ATTR_LAYOUT_ROW, + ATTR_LAYOUT_ROW_SPAN, + ATTR_LAYOUT_COLUMN, + ATTR_LAYOUT_COLUMN_SPAN + }; + + /** + * Default attribute filter used by the various layouts to filter out some properties + * we don't want to offer. + */ + public static final AttributeFilter DEFAULT_ATTR_FILTER = new AttributeFilter() { + Set<String> mExcludes; + + @Override + public String replace(String uri, String name, String value) { + if (!ANDROID_URI.equals(uri)) { + return value; + } + + if (mExcludes == null) { + mExcludes = new HashSet<String>(EXCLUDED_ATTRIBUTES.length); + mExcludes.addAll(Arrays.asList(EXCLUDED_ATTRIBUTES)); + } + + return mExcludes.contains(name) ? null : value; + } + }; + + /** + * Copies all the attributes from oldElement to newNode. Uses the idMap to + * transform the value of all attributes of Format.REFERENCE. If filter is + * non-null, it's a filter that can rewrite the attribute string. + */ + protected static void addAttributes(INode newNode, IDragElement oldElement, + Map<String, Pair<String, String>> idMap, AttributeFilter filter) { + + for (IDragAttribute attr : oldElement.getAttributes()) { + String uri = attr.getUri(); + String name = attr.getName(); + String value = attr.getValue(); + + IAttributeInfo attrInfo = newNode.getAttributeInfo(uri, name); + if (attrInfo != null) { + if (attrInfo.getFormats().contains(IAttributeInfo.Format.REFERENCE)) { + if (idMap.containsKey(value)) { + value = idMap.get(value).getFirst(); + } + } + } + + if (filter != null) { + value = filter.replace(uri, name, value); + } + if (value != null && value.length() > 0) { + newNode.setAttribute(uri, name, value); + } + } + } + + /** + * Adds all the children elements of oldElement to newNode, recursively. + * Attributes are adjusted by calling addAttributes with idMap as necessary, + * with no closure filter. + */ + protected static void addInnerElements(INode newNode, IDragElement oldElement, + Map<String, Pair<String, String>> idMap) { + + for (IDragElement element : oldElement.getInnerElements()) { + String fqcn = element.getFqcn(); + INode childNode = newNode.appendChild(fqcn); + + addAttributes(childNode, element, idMap, null /* filter */); + addInnerElements(childNode, element, idMap); + } + } + + /** + * Insert the given elements into the given node at the given position + * + * @param targetNode the node to insert into + * @param elements the elements to insert + * @param createNewIds if true, generate new ids when there is a conflict + * @param initialInsertPos index among targetnode's children which to insert the + * children + */ + public static void insertAt(final INode targetNode, final IDragElement[] elements, + final boolean createNewIds, final int initialInsertPos) { + + // Collect IDs from dropped elements and remap them to new IDs + // if this is a copy or from a different canvas. + final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, + createNewIds); + + targetNode.editXml("Insert Elements", new INodeHandler() { + + @Override + public void handle(@NonNull INode node) { + // Now write the new elements. + int insertPos = initialInsertPos; + for (IDragElement element : elements) { + String fqcn = element.getFqcn(); + + INode newChild = targetNode.insertChildAt(fqcn, insertPos); + + // insertPos==-1 means to insert at the end. Otherwise + // increment the insertion position. + if (insertPos >= 0) { + insertPos++; + } + + // Copy all the attributes, modifying them as needed. + addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); + addInnerElements(newChild, element, idMap); + } + } + }); + } + + // ---- Resizing ---- + + /** Creates a new {@link ResizeState} object to track resize state */ + protected ResizeState createResizeState(INode layout, Object layoutView, INode node) { + return new ResizeState(this, layout, layoutView, node); + } + + @Override + public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent, + @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge, + @Nullable Object childView, @Nullable Object parentView) { + ResizeState state = createResizeState(parent, parentView, child); + state.horizontalEdgeType = horizontalEdge; + state.verticalEdgeType = verticalEdge; + + // Compute preferred (wrap_content) size such that we can offer guidelines to + // snap to the preferred size + Map<INode, Rect> sizes = mRulesEngine.measureChildren(parent, + new IClientRulesEngine.AttributeFilter() { + @Override + public String getAttribute(@NonNull INode node, @Nullable String namespace, + @NonNull String localName) { + // Change attributes to wrap_content + if (ATTR_LAYOUT_WIDTH.equals(localName) + && SdkConstants.NS_RESOURCES.equals(namespace)) { + return VALUE_WRAP_CONTENT; + } + if (ATTR_LAYOUT_HEIGHT.equals(localName) + && SdkConstants.NS_RESOURCES.equals(namespace)) { + return VALUE_WRAP_CONTENT; + } + + return null; + } + }); + if (sizes != null) { + state.wrapBounds = sizes.get(child); + } + + return new DropFeedback(state, new IFeedbackPainter() { + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, + @NonNull DropFeedback feedback) { + ResizeState resizeState = (ResizeState) feedback.userData; + if (resizeState != null && resizeState.bounds != null) { + paintResizeFeedback(gc, node, resizeState); + } + } + }); + } + + protected void paintResizeFeedback(IGraphics gc, INode node, ResizeState resizeState) { + gc.useStyle(DrawingStyle.RESIZE_PREVIEW); + Rect b = resizeState.bounds; + gc.drawRect(b); + + if (resizeState.horizontalFillSegment != null) { + gc.useStyle(DrawingStyle.GUIDELINE); + Segment s = resizeState.horizontalFillSegment; + gc.drawLine(s.from, s.at, s.to, s.at); + } + if (resizeState.verticalFillSegment != null) { + gc.useStyle(DrawingStyle.GUIDELINE); + Segment s = resizeState.verticalFillSegment; + gc.drawLine(s.at, s.from, s.at, s.to); + } + + if (resizeState.wrapBounds != null) { + gc.useStyle(DrawingStyle.GUIDELINE); + int wrapWidth = resizeState.wrapBounds.w; + int wrapHeight = resizeState.wrapBounds.h; + + // Show the "wrap_content" guideline. + // If we are showing both the wrap_width and wrap_height lines + // then we show at most the rectangle formed by the two lines; + // otherwise we show the entire width of the line + if (resizeState.horizontalEdgeType != null) { + int y = -1; + switch (resizeState.horizontalEdgeType) { + case TOP: + y = b.y + b.h - wrapHeight; + break; + case BOTTOM: + y = b.y + wrapHeight; + break; + default: assert false : resizeState.horizontalEdgeType; + } + if (resizeState.verticalEdgeType != null) { + switch (resizeState.verticalEdgeType) { + case LEFT: + gc.drawLine(b.x + b.w - wrapWidth, y, b.x + b.w, y); + break; + case RIGHT: + gc.drawLine(b.x, y, b.x + wrapWidth, y); + break; + default: assert false : resizeState.verticalEdgeType; + } + } else { + gc.drawLine(b.x, y, b.x + b.w, y); + } + } + if (resizeState.verticalEdgeType != null) { + int x = -1; + switch (resizeState.verticalEdgeType) { + case LEFT: + x = b.x + b.w - wrapWidth; + break; + case RIGHT: + x = b.x + wrapWidth; + break; + default: assert false : resizeState.verticalEdgeType; + } + if (resizeState.horizontalEdgeType != null) { + switch (resizeState.horizontalEdgeType) { + case TOP: + gc.drawLine(x, b.y + b.h - wrapHeight, x, b.y + b.h); + break; + case BOTTOM: + gc.drawLine(x, b.y, x, b.y + wrapHeight); + break; + default: assert false : resizeState.horizontalEdgeType; + } + } else { + gc.drawLine(x, b.y, x, b.y + b.h); + } + } + } + } + + /** + * Returns the maximum number of pixels will be considered a "match" when snapping + * resize or move positions to edges or other constraints + * + * @return the maximum number of pixels to consider for snapping + */ + public static final int getMaxMatchDistance() { + // TODO - make constant once we're happy with the feel + return 20; + } + + @Override + public void onResizeUpdate(@Nullable DropFeedback feedback, @NonNull INode child, + @NonNull INode parent, @NonNull Rect newBounds, int modifierMask) { + ResizeState state = (ResizeState) feedback.userData; + state.bounds = newBounds; + state.modifierMask = modifierMask; + + // Match on wrap bounds + state.wrapWidth = state.wrapHeight = false; + if (state.wrapBounds != null) { + Rect b = state.wrapBounds; + int maxMatchDistance = getMaxMatchDistance(); + if (state.horizontalEdgeType != null) { + if (Math.abs(newBounds.h - b.h) < maxMatchDistance) { + state.wrapHeight = true; + if (state.horizontalEdgeType == SegmentType.TOP) { + newBounds.y += newBounds.h - b.h; + } + newBounds.h = b.h; + } + } + if (state.verticalEdgeType != null) { + if (Math.abs(newBounds.w - b.w) < maxMatchDistance) { + state.wrapWidth = true; + if (state.verticalEdgeType == SegmentType.LEFT) { + newBounds.x += newBounds.w - b.w; + } + newBounds.w = b.w; + } + } + } + + // Match on fill bounds + state.horizontalFillSegment = null; + state.fillHeight = false; + if (state.horizontalEdgeType == SegmentType.BOTTOM && !state.wrapHeight) { + Rect parentBounds = parent.getBounds(); + state.horizontalFillSegment = new Segment(parentBounds.y2(), newBounds.x, + newBounds.x2(), + null /*node*/, null /*id*/, SegmentType.BOTTOM, MarginType.NO_MARGIN); + if (Math.abs(newBounds.y2() - parentBounds.y2()) < getMaxMatchDistance()) { + state.fillHeight = true; + newBounds.h = parentBounds.y2() - newBounds.y; + } + } + state.verticalFillSegment = null; + state.fillWidth = false; + if (state.verticalEdgeType == SegmentType.RIGHT && !state.wrapWidth) { + Rect parentBounds = parent.getBounds(); + state.verticalFillSegment = new Segment(parentBounds.x2(), newBounds.y, + newBounds.y2(), + null /*node*/, null /*id*/, SegmentType.RIGHT, MarginType.NO_MARGIN); + if (Math.abs(newBounds.x2() - parentBounds.x2()) < getMaxMatchDistance()) { + state.fillWidth = true; + newBounds.w = parentBounds.x2() - newBounds.x; + } + } + + feedback.tooltip = getResizeUpdateMessage(state, child, parent, + newBounds, state.horizontalEdgeType, state.verticalEdgeType); + } + + @Override + public void onResizeEnd(@Nullable DropFeedback feedback, @NonNull INode child, + final @NonNull INode parent, final @NonNull Rect newBounds) { + final Rect oldBounds = child.getBounds(); + if (oldBounds.w != newBounds.w || oldBounds.h != newBounds.h) { + final ResizeState state = (ResizeState) feedback.userData; + child.editXml("Resize", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + setNewSizeBounds(state, n, parent, oldBounds, newBounds, + state.horizontalEdgeType, state.verticalEdgeType); + } + }); + } + } + + /** + * Returns the message to display to the user during the resize operation + * + * @param resizeState the current resize state + * @param child the child node being resized + * @param parent the parent of the resized node + * @param newBounds the new bounds to resize the child to, in pixels + * @param horizontalEdge the horizontal edge being resized + * @param verticalEdge the vertical edge being resized + * @return the message to display for the current resize bounds + */ + protected String getResizeUpdateMessage(ResizeState resizeState, INode child, INode parent, + Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { + String width = resizeState.getWidthAttribute(); + String height = resizeState.getHeightAttribute(); + + if (horizontalEdge == null) { + return width; + } else if (verticalEdge == null) { + return height; + } else { + // U+00D7: Unicode for multiplication sign + return String.format("%s \u00D7 %s", width, height); + } + } + + /** + * Performs the edit on the node to complete a resizing operation. The actual edit + * part is pulled out such that subclasses can change/add to the edits and be part of + * the same undo event + * + * @param resizeState the current resize state + * @param node the child node being resized + * @param layout the parent of the resized node + * @param newBounds the new bounds to resize the child to, in pixels + * @param horizontalEdge the horizontal edge being resized + * @param verticalEdge the vertical edge being resized + */ + protected void setNewSizeBounds(ResizeState resizeState, INode node, INode layout, + Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { + if (verticalEdge != null + && (newBounds.w != oldBounds.w || resizeState.wrapWidth || resizeState.fillWidth)) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, resizeState.getWidthAttribute()); + } + if (horizontalEdge != null + && (newBounds.h != oldBounds.h || resizeState.wrapHeight || resizeState.fillHeight)) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, resizeState.getHeightAttribute()); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseViewRule.java new file mode 100644 index 000000000..83ce9ef8f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseViewRule.java @@ -0,0 +1,996 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_CLASS; +import static com.android.SdkConstants.ATTR_HINT; +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.ATTR_STYLE; +import static com.android.SdkConstants.ATTR_TEXT; +import static com.android.SdkConstants.DOT_LAYOUT_PARAMS; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.VALUE_FALSE; +import static com.android.SdkConstants.VALUE_FILL_PARENT; +import static com.android.SdkConstants.VALUE_MATCH_PARENT; +import static com.android.SdkConstants.VALUE_TRUE; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; +import static com.android.SdkConstants.VIEW_FRAGMENT; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.AbstractViewRule; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.common.api.IAttributeInfo.Format; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.RuleAction.ActionProvider; +import com.android.ide.common.api.RuleAction.ChoiceProvider; +import com.android.resources.ResourceType; +import com.android.utils.Pair; + +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** + * Common IViewRule processing to all view and layout classes. + */ +public class BaseViewRule extends AbstractViewRule { + /** List of recently edited properties */ + private static List<String> sRecent = new LinkedList<String>(); + + /** Maximum number of recent properties to track and list */ + private final static int MAX_RECENT_COUNT = 12; + + // Strings used as internal ids, group ids and prefixes for actions + private static final String FALSE_ID = "false"; //$NON-NLS-1$ + private static final String TRUE_ID = "true"; //$NON-NLS-1$ + private static final String PROP_PREFIX = "@prop@"; //$NON-NLS-1$ + private static final String CLEAR_ID = "clear"; //$NON-NLS-1$ + private static final String ZCUSTOM = "zcustom"; //$NON-NLS-1$ + + protected IClientRulesEngine mRulesEngine; + + // Cache of attributes. Key is FQCN of a node mixed with its view hierarchy + // parent. Values are a custom map as needed by getContextMenu. + private Map<String, Map<String, Prop>> mAttributesMap = + new HashMap<String, Map<String, Prop>>(); + + @Override + public boolean onInitialize(@NonNull String fqcn, @NonNull IClientRulesEngine engine) { + mRulesEngine = engine; + + // This base rule can handle any class so we don't need to filter on + // FQCN. Derived classes should do so if they can handle some + // subclasses. + + // If onInitialize returns false, it means it can't handle the given + // FQCN and will be unloaded. + + return true; + } + + /** + * Returns the {@link IClientRulesEngine} associated with this {@link IViewRule} + * + * @return the {@link IClientRulesEngine} associated with this {@link IViewRule} + */ + public IClientRulesEngine getRulesEngine() { + return mRulesEngine; + } + + // === Context Menu === + + /** + * Generate custom actions for the context menu: <br/> + * - Explicit layout_width and layout_height attributes. + * - List of all other simple toggle attributes. + */ + @Override + public void addContextMenuActions(@NonNull List<RuleAction> actions, + final @NonNull INode selectedNode) { + String width = null; + String currentWidth = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH); + + String fillParent = getFillParentValueName(); + boolean canMatchParent = supportsMatchParent(); + if (canMatchParent && VALUE_FILL_PARENT.equals(currentWidth)) { + currentWidth = VALUE_MATCH_PARENT; + } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentWidth)) { + currentWidth = VALUE_FILL_PARENT; + } else if (!VALUE_WRAP_CONTENT.equals(currentWidth) && !fillParent.equals(currentWidth)) { + width = currentWidth; + } + + String height = null; + String currentHeight = selectedNode.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + + if (canMatchParent && VALUE_FILL_PARENT.equals(currentHeight)) { + currentHeight = VALUE_MATCH_PARENT; + } else if (!canMatchParent && VALUE_MATCH_PARENT.equals(currentHeight)) { + currentHeight = VALUE_FILL_PARENT; + } else if (!VALUE_WRAP_CONTENT.equals(currentHeight) + && !fillParent.equals(currentHeight)) { + height = currentHeight; + } + final String newWidth = width; + final String newHeight = height; + + final IMenuCallback onChange = new IMenuCallback() { + @Override + public void action( + final @NonNull RuleAction action, + final @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, final @Nullable Boolean newValue) { + String fullActionId = action.getId(); + boolean isProp = fullActionId.startsWith(PROP_PREFIX); + final String actionId = isProp ? + fullActionId.substring(PROP_PREFIX.length()) : fullActionId; + + if (fullActionId.equals(ATTR_LAYOUT_WIDTH)) { + final String newAttrValue = getValue(valueId, newWidth); + if (newAttrValue != null) { + for (INode node : selectedNodes) { + node.editXml("Change Attribute " + ATTR_LAYOUT_WIDTH, + new PropertySettingNodeHandler(ANDROID_URI, + ATTR_LAYOUT_WIDTH, newAttrValue)); + } + editedProperty(ATTR_LAYOUT_WIDTH); + } + return; + } else if (fullActionId.equals(ATTR_LAYOUT_HEIGHT)) { + // Ask the user + final String newAttrValue = getValue(valueId, newHeight); + if (newAttrValue != null) { + for (INode node : selectedNodes) { + node.editXml("Change Attribute " + ATTR_LAYOUT_HEIGHT, + new PropertySettingNodeHandler(ANDROID_URI, + ATTR_LAYOUT_HEIGHT, newAttrValue)); + } + editedProperty(ATTR_LAYOUT_HEIGHT); + } + return; + } else if (fullActionId.equals(ATTR_ID)) { + // Ids must be set individually so open the id dialog for each + // selected node (though allow cancel to break the loop) + for (INode node : selectedNodes) { + if (!mRulesEngine.rename(node)) { + break; + } + } + editedProperty(ATTR_ID); + return; + } else if (isProp) { + INode firstNode = selectedNodes.get(0); + String key = getPropertyMapKey(selectedNode); + Map<String, Prop> props = mAttributesMap.get(key); + final Prop prop = (props != null) ? props.get(actionId) : null; + + if (prop != null) { + editedProperty(actionId); + + // For custom values (requiring an input dialog) input the + // value outside the undo-block. + // Input the value as a text, unless we know it's the "text" or + // "style" attributes (where we know we want to ask for specific + // resource types). + String uri = ANDROID_URI; + String v = null; + if (prop.isStringEdit()) { + boolean isStyle = actionId.equals(ATTR_STYLE); + boolean isText = actionId.equals(ATTR_TEXT); + boolean isHint = actionId.equals(ATTR_HINT); + if (isStyle || isText || isHint) { + String resourceTypeName = isStyle + ? ResourceType.STYLE.getName() + : ResourceType.STRING.getName(); + String oldValue = selectedNodes.size() == 1 + ? (isStyle ? firstNode.getStringAttr(ATTR_STYLE, actionId) + : firstNode.getStringAttr(ANDROID_URI, actionId)) + : ""; //$NON-NLS-1$ + oldValue = ensureValidString(oldValue); + v = mRulesEngine.displayResourceInput(resourceTypeName, oldValue); + if (isStyle) { + uri = null; + } + } else if (actionId.equals(ATTR_CLASS) && selectedNodes.size() >= 1 && + VIEW_FRAGMENT.equals(selectedNodes.get(0).getFqcn())) { + v = mRulesEngine.displayFragmentSourceInput(); + uri = null; + } else { + v = inputAttributeValue(firstNode, actionId); + } + } + final String customValue = v; + + for (INode n : selectedNodes) { + if (prop.isToggle()) { + // case of toggle + String value = ""; //$NON-NLS-1$ + if (valueId.equals(TRUE_ID)) { + value = newValue ? "true" : ""; //$NON-NLS-1$ //$NON-NLS-2$ + } else if (valueId.equals(FALSE_ID)) { + value = newValue ? "false" : "";//$NON-NLS-1$ //$NON-NLS-2$ + } + n.setAttribute(uri, actionId, value); + } else if (prop.isFlag()) { + // case of a flag + String values = ""; //$NON-NLS-1$ + if (!valueId.equals(CLEAR_ID)) { + values = n.getStringAttr(ANDROID_URI, actionId); + Set<String> newValues = new HashSet<String>(); + if (values != null) { + newValues.addAll(Arrays.asList( + values.split("\\|"))); //$NON-NLS-1$ + } + if (newValue) { + newValues.add(valueId); + } else { + newValues.remove(valueId); + } + + List<String> sorted = new ArrayList<String>(newValues); + Collections.sort(sorted); + values = join('|', sorted); + + // Special case + if (valueId.equals("normal")) { //$NON-NLS-1$ + // For textStyle for example, if you have "bold|italic" + // and you select the "normal" property, this should + // not behave in the normal flag way and "or" itself in; + // it should replace the other two. + // This also applies to imeOptions. + values = valueId; + } + } + n.setAttribute(uri, actionId, values); + } else if (prop.isEnum()) { + // case of an enum + String value = ""; //$NON-NLS-1$ + if (!valueId.equals(CLEAR_ID)) { + value = newValue ? valueId : ""; //$NON-NLS-1$ + } + n.setAttribute(uri, actionId, value); + } else { + assert prop.isStringEdit(); + // We've already received the value outside the undo block + if (customValue != null) { + n.setAttribute(uri, actionId, customValue); + } + } + } + } + } + } + + /** + * Input the custom value for the given attribute. This will use the Reference + * Chooser if it is a reference value, otherwise a plain text editor. + */ + private String inputAttributeValue(final INode node, final String attribute) { + String oldValue = node.getStringAttr(ANDROID_URI, attribute); + oldValue = ensureValidString(oldValue); + IAttributeInfo attributeInfo = node.getAttributeInfo(ANDROID_URI, attribute); + if (attributeInfo != null + && attributeInfo.getFormats().contains(Format.REFERENCE)) { + return mRulesEngine.displayReferenceInput(oldValue); + } else { + // A single resource type? If so use a resource chooser initialized + // to this specific type + /* This does not work well, because the metadata is a bit misleading: + * for example a Button's "text" property and a Button's "onClick" property + * both claim to be of type [string], but @string/ is NOT valid for + * onClick.. + if (attributeInfo != null && attributeInfo.getFormats().length == 1) { + // Resource chooser + Format format = attributeInfo.getFormats()[0]; + return mRulesEngine.displayResourceInput(format.name(), oldValue); + } + */ + + // Fallback: just edit the raw XML string + String message = String.format("New %1$s Value:", attribute); + return mRulesEngine.displayInput(message, oldValue, null); + } + } + + /** + * Returns the value (which will ask the user if the value is the special + * {@link #ZCUSTOM} marker + */ + private String getValue(String valueId, String defaultValue) { + if (valueId.equals(ZCUSTOM)) { + if (defaultValue == null) { + defaultValue = ""; + } + String value = mRulesEngine.displayInput( + "Set custom layout attribute value (example: 50dp)", + defaultValue, null); + if (value != null && value.trim().length() > 0) { + return value.trim(); + } else { + return null; + } + } + + return valueId; + } + }; + + IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); + if (textAttribute != null) { + actions.add(RuleAction.createAction(PROP_PREFIX + ATTR_TEXT, "Edit Text...", onChange, + null, 10, true)); + } + + String editIdLabel = selectedNode.getStringAttr(ANDROID_URI, ATTR_ID) != null ? + "Edit ID..." : "Assign ID..."; + actions.add(RuleAction.createAction(ATTR_ID, editIdLabel, onChange, null, 20, true)); + + addCommonPropertyActions(actions, selectedNode, onChange, 21); + + // Create width choice submenu + actions.add(RuleAction.createSeparator(32)); + List<Pair<String, String>> widthChoices = new ArrayList<Pair<String,String>>(4); + widthChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content")); + if (canMatchParent) { + widthChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent")); + } else { + widthChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent")); + } + if (width != null) { + widthChoices.add(Pair.of(width, width)); + } + widthChoices.add(Pair.of(ZCUSTOM, "Other...")); + actions.add(RuleAction.createChoices( + ATTR_LAYOUT_WIDTH, "Layout Width", + onChange, + null /* iconUrls */, + currentWidth, + null, 35, + true, // supportsMultipleNodes + widthChoices)); + + // Create height choice submenu + List<Pair<String, String>> heightChoices = new ArrayList<Pair<String,String>>(4); + heightChoices.add(Pair.of(VALUE_WRAP_CONTENT, "Wrap Content")); + if (canMatchParent) { + heightChoices.add(Pair.of(VALUE_MATCH_PARENT, "Match Parent")); + } else { + heightChoices.add(Pair.of(VALUE_FILL_PARENT, "Fill Parent")); + } + if (height != null) { + heightChoices.add(Pair.of(height, height)); + } + heightChoices.add(Pair.of(ZCUSTOM, "Other...")); + actions.add(RuleAction.createChoices( + ATTR_LAYOUT_HEIGHT, "Layout Height", + onChange, + null /* iconUrls */, + currentHeight, + null, 40, + true, + heightChoices)); + + actions.add(RuleAction.createSeparator(45)); + RuleAction properties = RuleAction.createChoices("properties", "Other Properties", //$NON-NLS-1$ + onChange /*callback*/, null /*icon*/, 50, + true /*supportsMultipleNodes*/, new ActionProvider() { + @Override + public @NonNull List<RuleAction> getNestedActions(@NonNull INode node) { + List<RuleAction> propertyActionTypes = new ArrayList<RuleAction>(); + propertyActionTypes.add(RuleAction.createChoices( + "recent", "Recent", //$NON-NLS-1$ + onChange /*callback*/, null /*icon*/, 10, + true /*supportsMultipleNodes*/, new ActionProvider() { + @Override + public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { + List<RuleAction> propertyActions = new ArrayList<RuleAction>(); + addRecentPropertyActions(propertyActions, n, onChange); + return propertyActions; + } + })); + + propertyActionTypes.add(RuleAction.createSeparator(20)); + + addInheritedProperties(propertyActionTypes, node, onChange, 30); + + propertyActionTypes.add(RuleAction.createSeparator(50)); + propertyActionTypes.add(RuleAction.createChoices( + "layoutparams", "Layout Parameters", //$NON-NLS-1$ + onChange /*callback*/, null /*icon*/, 60, + true /*supportsMultipleNodes*/, new ActionProvider() { + @Override + public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { + List<RuleAction> propertyActions = new ArrayList<RuleAction>(); + addPropertyActions(propertyActions, n, onChange, null, true); + return propertyActions; + } + })); + + propertyActionTypes.add(RuleAction.createSeparator(70)); + + propertyActionTypes.add(RuleAction.createChoices( + "allprops", "All By Name", //$NON-NLS-1$ + onChange /*callback*/, null /*icon*/, 80, + true /*supportsMultipleNodes*/, new ActionProvider() { + @Override + public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { + List<RuleAction> propertyActions = new ArrayList<RuleAction>(); + addPropertyActions(propertyActions, n, onChange, null, false); + return propertyActions; + } + })); + + return propertyActionTypes; + } + }); + + actions.add(properties); + } + + @Override + @Nullable + public String getDefaultActionId(@NonNull final INode selectedNode) { + IAttributeInfo textAttribute = selectedNode.getAttributeInfo(ANDROID_URI, ATTR_TEXT); + if (textAttribute != null) { + return PROP_PREFIX + ATTR_TEXT; + } + + return null; + } + + private static String getPropertyMapKey(INode node) { + // Compute the key for mAttributesMap. This depends on the type of this + // node and its parent in the view hierarchy. + StringBuilder sb = new StringBuilder(); + sb.append(node.getFqcn()); + sb.append('_'); + INode parent = node.getParent(); + if (parent != null) { + sb.append(parent.getFqcn()); + } + return sb.toString(); + } + + /** + * Adds menu items for the inherited attributes, one pull-right menu for each super class + * that defines attributes. + * + * @param propertyActionTypes the actions list to add into + * @param node the node to apply the attributes to + * @param onChange the callback to use for setting attributes + * @param sortPriority the initial sort attribute for the first menu item + */ + private void addInheritedProperties(List<RuleAction> propertyActionTypes, INode node, + final IMenuCallback onChange, int sortPriority) { + List<String> attributeSources = node.getAttributeSources(); + for (final String definedBy : attributeSources) { + String sourceClass = definedBy; + + // Strip package prefixes when necessary + int index = sourceClass.length(); + if (sourceClass.endsWith(DOT_LAYOUT_PARAMS)) { + index = sourceClass.length() - DOT_LAYOUT_PARAMS.length() - 1; + } + int lastDot = sourceClass.lastIndexOf('.', index); + if (lastDot != -1) { + sourceClass = sourceClass.substring(lastDot + 1); + } + + String label; + if (definedBy.equals(node.getFqcn())) { + label = String.format("Defined by %1$s", sourceClass); + } else { + label = String.format("Inherited from %1$s", sourceClass); + } + + propertyActionTypes.add(RuleAction.createChoices("def_" + definedBy, + label, + onChange /*callback*/, null /*icon*/, sortPriority++, + true /*supportsMultipleNodes*/, new ActionProvider() { + @Override + public @NonNull List<RuleAction> getNestedActions(@NonNull INode n) { + List<RuleAction> propertyActions = new ArrayList<RuleAction>(); + addPropertyActions(propertyActions, n, onChange, definedBy, false); + return propertyActions; + } + })); + } + } + + /** + * Creates a list of properties that are commonly edited for views of the + * selected node's type + */ + private void addCommonPropertyActions(List<RuleAction> actions, INode selectedNode, + IMenuCallback onChange, int sortPriority) { + Map<String, Prop> properties = getPropertyMetadata(selectedNode); + IViewMetadata metadata = mRulesEngine.getMetadata(selectedNode.getFqcn()); + if (metadata != null) { + List<String> attributes = metadata.getTopAttributes(); + if (attributes.size() > 0) { + for (String attribute : attributes) { + // Text and ID are handled manually in the menu construction code because + // we want to place them consistently and customize the action label + if (ATTR_TEXT.equals(attribute) || ATTR_ID.equals(attribute)) { + continue; + } + + Prop property = properties.get(attribute); + if (property != null) { + String title = property.getTitle(); + if (title.endsWith("...")) { + title = String.format("Edit %1$s", property.getTitle()); + } + actions.add(createPropertyAction(property, attribute, title, + selectedNode, onChange, sortPriority)); + sortPriority++; + } + } + } + } + } + + /** + * Record that the given property was just edited; adds it to the front of + * the recently edited property list + * + * @param property the name of the property + */ + static void editedProperty(String property) { + if (sRecent.contains(property)) { + sRecent.remove(property); + } else if (sRecent.size() > MAX_RECENT_COUNT) { + sRecent.remove(sRecent.size() - 1); + } + sRecent.add(0, property); + } + + /** + * Creates a list of recently modified properties that apply to the given selected node + */ + private void addRecentPropertyActions(List<RuleAction> actions, INode selectedNode, + IMenuCallback onChange) { + int sortPriority = 10; + Map<String, Prop> properties = getPropertyMetadata(selectedNode); + for (String attribute : sRecent) { + Prop property = properties.get(attribute); + if (property != null) { + actions.add(createPropertyAction(property, attribute, property.getTitle(), + selectedNode, onChange, sortPriority)); + sortPriority += 10; + } + } + } + + /** + * Creates a list of nested actions representing the property-setting + * actions for the given selected node + */ + private void addPropertyActions(List<RuleAction> actions, INode selectedNode, + IMenuCallback onChange, String definedBy, boolean layoutParamsOnly) { + + Map<String, Prop> properties = getPropertyMetadata(selectedNode); + + int sortPriority = 10; + for (Map.Entry<String, Prop> entry : properties.entrySet()) { + String id = entry.getKey(); + Prop property = entry.getValue(); + if (layoutParamsOnly) { + // If we have definedBy information, that is most accurate; all layout + // params will be defined by a class whose name ends with + // .LayoutParams: + if (definedBy != null) { + if (!definedBy.endsWith(DOT_LAYOUT_PARAMS)) { + continue; + } + } else if (!id.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + continue; + } + } + if (definedBy != null && !definedBy.equals(property.getDefinedBy())) { + continue; + } + actions.add(createPropertyAction(property, id, property.getTitle(), + selectedNode, onChange, sortPriority)); + sortPriority += 10; + } + + // The properties are coming out of map key order which isn't right, so sort + // alphabetically instead + Collections.sort(actions, new Comparator<RuleAction>() { + @Override + public int compare(RuleAction action1, RuleAction action2) { + return action1.getTitle().compareTo(action2.getTitle()); + } + }); + } + + private RuleAction createPropertyAction(Prop p, String id, String title, INode selectedNode, + IMenuCallback onChange, int sortPriority) { + if (p.isToggle()) { + // Toggles are handled as a multiple-choice between true, false + // and nothing (clear) + String value = selectedNode.getStringAttr(ANDROID_URI, id); + if (value != null) { + value = value.toLowerCase(Locale.US); + } + if (VALUE_TRUE.equals(value)) { + value = TRUE_ID; + } else if (VALUE_FALSE.equals(value)) { + value = FALSE_ID; + } else { + value = CLEAR_ID; + } + return RuleAction.createChoices(PROP_PREFIX + id, title, + onChange, BOOLEAN_CHOICE_PROVIDER, + value, + null, sortPriority, + true); + } else if (p.getChoices() != null) { + // Enum or flags. Their possible values are the multiple-choice + // items, with an extra "clear" option to remove everything. + String current = selectedNode.getStringAttr(ANDROID_URI, id); + if (current == null || current.length() == 0) { + current = CLEAR_ID; + } + return RuleAction.createChoices(PROP_PREFIX + id, title, + onChange, new EnumPropertyChoiceProvider(p), + current, + null, sortPriority, + true); + } else { + return RuleAction.createAction( + PROP_PREFIX + id, + title, + onChange, + null, sortPriority, + true); + } + } + + private Map<String, Prop> getPropertyMetadata(final INode selectedNode) { + String key = getPropertyMapKey(selectedNode); + Map<String, Prop> props = mAttributesMap.get(key); + if (props == null) { + // Prepare the property map + props = new HashMap<String, Prop>(); + for (IAttributeInfo attrInfo : selectedNode.getDeclaredAttributes()) { + String id = attrInfo != null ? attrInfo.getName() : null; + if (id == null || id.equals(ATTR_LAYOUT_WIDTH) || id.equals(ATTR_LAYOUT_HEIGHT)) { + // Layout width/height are already handled at the root level + continue; + } + if (attrInfo == null) { + continue; + } + EnumSet<Format> formats = attrInfo.getFormats(); + + String title = getAttributeDisplayName(id); + + String definedBy = attrInfo != null ? attrInfo.getDefinedBy() : null; + if (formats.contains(IAttributeInfo.Format.BOOLEAN)) { + props.put(id, new Prop(title, true, definedBy)); + } else if (formats.contains(IAttributeInfo.Format.ENUM)) { + // Convert each enum into a map id=>title + Map<String, String> values = new HashMap<String, String>(); + if (attrInfo != null) { + for (String e : attrInfo.getEnumValues()) { + values.put(e, getAttributeDisplayName(e)); + } + } + + props.put(id, new Prop(title, false, false, values, definedBy)); + } else if (formats.contains(IAttributeInfo.Format.FLAG)) { + // Convert each flag into a map id=>title + Map<String, String> values = new HashMap<String, String>(); + if (attrInfo != null) { + for (String e : attrInfo.getFlagValues()) { + values.put(e, getAttributeDisplayName(e)); + } + } + + props.put(id, new Prop(title, false, true, values, definedBy)); + } else { + props.put(id, new Prop(title + "...", false, definedBy)); + } + } + mAttributesMap.put(key, props); + } + return props; + } + + /** + * A {@link ChoiceProvder} which provides alternatives suitable for choosing + * values for a boolean property: true, false, or "default". + */ + private static ChoiceProvider BOOLEAN_CHOICE_PROVIDER = new ChoiceProvider() { + @Override + public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, + @NonNull List<String> ids) { + titles.add("True"); + ids.add(TRUE_ID); + + titles.add("False"); + ids.add(FALSE_ID); + + titles.add(RuleAction.SEPARATOR); + ids.add(RuleAction.SEPARATOR); + + titles.add("Default"); + ids.add(CLEAR_ID); + } + }; + + /** + * A {@link ChoiceProvider} which provides the various available + * attribute values available for a given {@link Prop} property descriptor. + */ + private static class EnumPropertyChoiceProvider implements ChoiceProvider { + private Prop mProperty; + + public EnumPropertyChoiceProvider(Prop property) { + super(); + mProperty = property; + } + + @Override + public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, + @NonNull List<String> ids) { + for (Entry<String, String> entry : mProperty.getChoices().entrySet()) { + ids.add(entry.getKey()); + titles.add(entry.getValue()); + } + + titles.add(RuleAction.SEPARATOR); + ids.add(RuleAction.SEPARATOR); + + titles.add("Default"); + ids.add(CLEAR_ID); + } + } + + /** + * Returns true if the given node is "filled" (e.g. has layout width set to match + * parent or fill parent + */ + protected final boolean isFilled(INode node, String attribute) { + String value = node.getStringAttr(ANDROID_URI, attribute); + return VALUE_MATCH_PARENT.equals(value) || VALUE_FILL_PARENT.equals(value); + } + + /** + * Returns fill_parent or match_parent, depending on whether the minimum supported + * platform supports match_parent or not + * + * @return match_parent or fill_parent depending on which is supported by the project + */ + protected final String getFillParentValueName() { + return supportsMatchParent() ? VALUE_MATCH_PARENT : VALUE_FILL_PARENT; + } + + /** + * Returns true if the project supports match_parent instead of just fill_parent + * + * @return true if the project supports match_parent instead of just fill_parent + */ + protected final boolean supportsMatchParent() { + // fill_parent was renamed match_parent in API level 8 + return mRulesEngine.getMinApiLevel() >= 8; + } + + /** Join strings into a single string with the given delimiter */ + static String join(char delimiter, Collection<String> strings) { + StringBuilder sb = new StringBuilder(100); + for (String s : strings) { + if (sb.length() > 0) { + sb.append(delimiter); + } + sb.append(s); + } + return sb.toString(); + } + + static Map<String, String> concatenate(Map<String, String> pre, Map<String, String> post) { + Map<String, String> result = new HashMap<String, String>(pre.size() + post.size()); + result.putAll(pre); + result.putAll(post); + return result; + } + + // Quick utility for building up maps declaratively to minimize the diffs + static Map<String, String> mapify(String... values) { + Map<String, String> map = new HashMap<String, String>(values.length / 2); + for (int i = 0; i < values.length; i += 2) { + String key = values[i]; + if (key == null) { + continue; + } + String value = values[i + 1]; + map.put(key, value); + } + + return map; + } + + /** + * Produces a display name for an attribute, usually capitalizing the attribute name + * and splitting up underscores into new words + * + * @param name the attribute name to convert + * @return a display name for the attribute name + */ + public static String getAttributeDisplayName(String name) { + if (name != null && name.length() > 0) { + StringBuilder sb = new StringBuilder(); + boolean capitalizeNext = true; + for (int i = 0, n = name.length(); i < n; i++) { + char c = name.charAt(i); + if (capitalizeNext) { + c = Character.toUpperCase(c); + } + capitalizeNext = false; + if (c == '_') { + c = ' '; + capitalizeNext = true; + } + sb.append(c); + } + + return sb.toString(); + } + + return name; + } + + + // ==== Paste support ==== + + /** + * Most views can't accept children so there's nothing to paste on them. In + * this case, defer the call to the parent layout and use the target node as + * an indication of where to paste. + */ + @Override + public void onPaste(@NonNull INode targetNode, @Nullable Object targetView, + @NonNull IDragElement[] elements) { + // + INode parent = targetNode.getParent(); + if (parent != null) { + String parentFqcn = parent.getFqcn(); + IViewRule parentRule = mRulesEngine.loadRule(parentFqcn); + + if (parentRule instanceof BaseLayoutRule) { + ((BaseLayoutRule) parentRule).onPasteBeforeChild(parent, targetView, targetNode, + elements); + } + } + } + + /** + * Support class for the context menu code. Stores state about properties in + * the context menu. + */ + private static class Prop { + private final boolean mToggle; + private final boolean mFlag; + private final String mTitle; + private final Map<String, String> mChoices; + private String mDefinedBy; + + public Prop(String title, boolean isToggle, boolean isFlag, Map<String, String> choices, + String definedBy) { + mTitle = title; + mToggle = isToggle; + mFlag = isFlag; + mChoices = choices; + mDefinedBy = definedBy; + } + + public String getDefinedBy() { + return mDefinedBy; + } + + public Prop(String title, boolean isToggle, String definedBy) { + this(title, isToggle, false, null, definedBy); + } + + private boolean isToggle() { + return mToggle; + } + + private boolean isFlag() { + return mFlag && mChoices != null; + } + + private boolean isEnum() { + return !mFlag && mChoices != null; + } + + private String getTitle() { + return mTitle; + } + + private Map<String, String> getChoices() { + return mChoices; + } + + private boolean isStringEdit() { + return mChoices == null && !mToggle; + } + } + + /** + * Returns a source attribute value which points to a sample image. This is typically + * used to provide an initial image shown on ImageButtons, etc. There is no guarantee + * that the source pointed to by this method actually exists. + * + * @return a source attribute to use for sample images, never null + */ + protected final String getSampleImageSrc() { + // Builtin graphics available since v1: + return "@android:drawable/btn_star"; //$NON-NLS-1$ + } + + /** + * Strips the {@code @+id} or {@code @id} prefix off of the given id + * + * @param id attribute to be stripped + * @return the id name without the {@code @+id} or {@code @id} prefix + */ + @NonNull + public static String stripIdPrefix(@Nullable String id) { + if (id == null) { + return ""; //$NON-NLS-1$ + } else if (id.startsWith(NEW_ID_PREFIX)) { + return id.substring(NEW_ID_PREFIX.length()); + } else if (id.startsWith(ID_PREFIX)) { + return id.substring(ID_PREFIX.length()); + } + return id; + } + + private static String ensureValidString(String value) { + if (value == null) { + value = ""; //$NON-NLS-1$ + } + return value; + } + } diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/CalendarViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/CalendarViewRule.java new file mode 100644 index 000000000..91684e2c5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/CalendarViewRule.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.CalendarView. + */ +public class CalendarViewRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + // CalendarViews need a lot of space, and the wrapping doesn't seem to work + // well anyway; it reports a much-to-small size than actually accommodates its + // content. + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, getFillParentValueName()); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, getFillParentValueName()); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/DatePickerRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/DatePickerRule.java new file mode 100644 index 000000000..a635a9ad6 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/DatePickerRule.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2010 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.common.layout; + +public class DatePickerRule extends IgnoredLayoutRule { + // A DatePicker inherits from FrameLayout but is not a general purpose + // FrameLayout +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/DialerFilterRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/DialerFilterRule.java new file mode 100644 index 000000000..606bbd86c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/DialerFilterRule.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_TEXT; +import static com.android.SdkConstants.FQCN_EDIT_TEXT; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.DialerFilterRule. + */ +public class DialerFilterRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + // A DialerFilter requires a couple of nested EditTexts with fixed ids: + if (insertType.isCreate()) { + String fillParent = getFillParentValueName(); + INode hint = node.appendChild(FQCN_EDIT_TEXT); + hint.setAttribute(ANDROID_URI, ATTR_TEXT, "Hint"); + hint.setAttribute(ANDROID_URI, ATTR_ID, "@android:id/hint"); //$NON-NLS-1$ + hint.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + + INode primary = node.appendChild(FQCN_EDIT_TEXT); + primary.setAttribute(ANDROID_URI, ATTR_TEXT, "Primary"); + primary.setAttribute(ANDROID_URI, ATTR_ID, "@android:id/primary"); //$NON-NLS-1$ + primary.setAttribute(ANDROID_URI, ATTR_LAYOUT_BELOW, + "@android:id/hint"); //$NON-NLS-1$ + primary.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + + + // What do we initialize the icon to? + //INode icon = node.appendChild("android.widget.ImageView"); //$NON-NLS-1$ + //icon.setAttribute(ANDROID_URI, ATTR_ID, "@android:id/icon"); //$NON-NLS-1$ + } + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/EditTextRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/EditTextRule.java new file mode 100644 index 000000000..03a5bc04e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/EditTextRule.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_EMS; +import static com.android.SdkConstants.REQUEST_FOCUS; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.RuleAction; + +import java.util.List; + +/** + * An {@link IViewRule} for android.widget.EditText. + */ +public class EditTextRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (parent != null) { + INode focus = findFocus(findRoot(parent)); + if (focus == null) { + // Add <requestFocus> + node.appendChild(REQUEST_FOCUS); + } + + if (parent.getBounds().w >= 320) { + node.setAttribute(ANDROID_URI, ATTR_EMS, "10"); //$NON-NLS-1$ + } + } + } + + /** + * {@inheritDoc} + * <p> + * Adds a "Request Focus" menu item. + */ + @Override + public void addContextMenuActions(@NonNull List<RuleAction> actions, + final @NonNull INode selectedNode) { + super.addContextMenuActions(actions, selectedNode); + + final boolean hasFocus = hasFocus(selectedNode); + final String label = hasFocus ? "Clear Focus" : "Request Focus"; + + IMenuCallback onChange = new IMenuCallback() { + @Override + public void action( + @NonNull RuleAction menuAction, + @NonNull List<? extends INode> selectedNodes, + @Nullable String valueId, + @Nullable Boolean newValue) { + selectedNode.editXml(label, new INodeHandler() { + @Override + public void handle(@NonNull INode node) { + INode focus = findFocus(findRoot(node)); + if (focus != null && focus.getParent() != null) { + focus.getParent().removeChild(focus); + } + if (!hasFocus) { + node.appendChild(REQUEST_FOCUS); + } + } + }); + } + }; + + actions.add(RuleAction.createAction("_setfocus", label, onChange, //$NON-NLS-1$ + null, 5, false /*supportsMultipleNodes*/)); + actions.add(RuleAction.createSeparator(7)); + } + + /** Returns true if the given node currently has focus */ + private static boolean hasFocus(INode node) { + INode focus = findFocus(node); + if (focus != null) { + return focus.getParent() == node; + } + + return false; + } + + /** Returns the root/top level node in the view hierarchy that contains the given node */ + private static INode findRoot(INode node) { + // First find the parent + INode root = node; + while (root != null) { + INode parent = root.getParent(); + if (parent == null) { + break; + } else { + root = parent; + } + } + + return root; + } + + /** Finds the focus node (not the node containing focus, but the actual request focus node + * under a given node */ + private static INode findFocus(INode node) { + if (node.getFqcn().equals(REQUEST_FOCUS)) { + return node; + } + + for (INode child : node.getChildren()) { + INode focus = findFocus(child); + if (focus != null) { + return focus; + } + } + return null; + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/FragmentRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/FragmentRule.java new file mode 100644 index 000000000..f99cf0ceb --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/FragmentRule.java @@ -0,0 +1,46 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_NAME; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for the special XML {@code <fragment>} tag. + */ +public class FragmentRule extends BaseViewRule { + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + // When dropping a fragment tag, ask the user which class to use. + if (insertType == InsertType.CREATE) { // NOT InsertType.CREATE_PREVIEW + String fqcn = mRulesEngine.displayFragmentSourceInput(); + if (fqcn != null) { + node.editXml("Add Fragment", + new PropertySettingNodeHandler(ANDROID_URI, ATTR_NAME, + fqcn.length() > 0 ? fqcn : null)); + } else { + // Remove the view; the insertion was canceled + parent.removeChild(node); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/FrameLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/FrameLayoutRule.java new file mode 100644 index 000000000..0f9096294 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/FrameLayoutRule.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.IViewMetadata.FillPreference; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.RuleAction; +import com.android.utils.Pair; + +import java.util.List; +import java.util.Map; + +/** + * An {@link IViewRule} for android.widget.FrameLayout and all its derived + * classes. + */ +public class FrameLayoutRule extends BaseLayoutRule { + + // ==== Drag'n'drop support ==== + // The FrameLayout accepts any drag'n'drop anywhere on its surface. + + @Override + public DropFeedback onDropEnter(@NonNull INode targetNode, @Nullable Object targetView, + final @Nullable IDragElement[] elements) { + if (elements.length == 0) { + return null; + } + + return new DropFeedback(null, new IFeedbackPainter() { + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, + @NonNull DropFeedback feedback) { + drawFeedback(gc, node, elements, feedback); + } + }); + } + + protected void drawFeedback( + IGraphics gc, + INode targetNode, + IDragElement[] elements, + DropFeedback feedback) { + Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return; + } + + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + + // Get the drop point + Point p = (Point) feedback.userData; + + if (p == null) { + return; + } + + Rect be = elements[0].getBounds(); + + gc.useStyle(DrawingStyle.DROP_PREVIEW); + if (be.isValid()) { + // At least the first element has a bound. Draw rectangles + // for all dropped elements with valid bounds, offset at + // (0,0) + for (IDragElement it : elements) { + Rect currBounds = it.getBounds(); + if (currBounds.isValid()) { + int offsetX = b.x - currBounds.x; + int offsetY = b.y - currBounds.y; + drawElement(gc, it, offsetX, offsetY); + } + } + } else { + // We don't have bounds for new elements. In this case + // just draw insert lines indicating the top left corner where + // the item will be placed + + // +1: Place lines fully within the view (the stroke width is 2) to + // make + // it even more visually obvious + gc.drawLine(b.x + 1, b.y, b.x + 1, b.y + b.h); + gc.drawLine(b.x, b.y + 1, b.x + b.w, b.y + 1); + } + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + feedback.userData = p; + feedback.requestPaint = true; + return feedback; + } + + @Override + public void onDropLeave(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback) { + // ignore + } + + @Override + public void onDropped(final @NonNull INode targetNode, final @NonNull IDragElement[] elements, + final @Nullable DropFeedback feedback, final @NonNull Point p) { + Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return; + } + + // Collect IDs from dropped elements and remap them to new IDs + // if this is a copy or from a different canvas. + final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, + feedback.isCopy || !feedback.sameCanvas); + + targetNode.editXml("Add elements to FrameLayout", new INodeHandler() { + + @Override + public void handle(@NonNull INode node) { + + // Now write the new elements. + for (IDragElement element : elements) { + String fqcn = element.getFqcn(); + + INode newChild = targetNode.appendChild(fqcn); + + // Copy all the attributes, modifying them as needed. + addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); + + addInnerElements(newChild, element, idMap); + } + } + }); + } + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + actions.add(RuleAction.createSeparator(25)); + actions.add(createMarginAction(parentNode, children)); + if (children != null && children.size() > 0) { + actions.add(createGravityAction(children, ATTR_LAYOUT_GRAVITY)); + } + } + + @Override + public void onChildInserted(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + // Look at the fill preferences and fill embedded layouts etc + String fqcn = node.getFqcn(); + IViewMetadata metadata = mRulesEngine.getMetadata(fqcn); + if (metadata != null) { + FillPreference fill = metadata.getFillPreference(); + String fillParent = getFillParentValueName(); + if (fill.fillHorizontally(true)) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + } + if (fill.fillVertically(false)) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GravityHelper.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GravityHelper.java new file mode 100644 index 000000000..b9aabad3f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GravityHelper.java @@ -0,0 +1,233 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.GRAVITY_VALUE_BOTTOM; +import static com.android.SdkConstants.GRAVITY_VALUE_CENTER; +import static com.android.SdkConstants.GRAVITY_VALUE_CENTER_HORIZONTAL; +import static com.android.SdkConstants.GRAVITY_VALUE_CENTER_VERTICAL; +import static com.android.SdkConstants.GRAVITY_VALUE_FILL; +import static com.android.SdkConstants.GRAVITY_VALUE_FILL_HORIZONTAL; +import static com.android.SdkConstants.GRAVITY_VALUE_FILL_VERTICAL; +import static com.android.SdkConstants.GRAVITY_VALUE_LEFT; +import static com.android.SdkConstants.GRAVITY_VALUE_RIGHT; +import static com.android.SdkConstants.GRAVITY_VALUE_TOP; + +import org.w3c.dom.Element; + +/** Helper class for looking up the gravity masks of gravity attributes */ +public class GravityHelper { + // From SDK constants; temporary + public static final String GRAVITY_VALUE_START = "start"; //$NON-NLS-1$ + public static final String GRAVITY_VALUE_END = "end"; //$NON-NLS-1$ + + /** Bitmask for a gravity which includes left */ + @SuppressWarnings("PointlessBitwiseExpression") // for symmetry with other fields + public static final int GRAVITY_LEFT = 1 << 0; + + /** Bitmask for a gravity which includes right */ + public static final int GRAVITY_RIGHT = 1 << 1; + + /** Bitmask for a gravity which includes center horizontal */ + public static final int GRAVITY_CENTER_HORIZ = 1 << 2; + + /** Bitmask for a gravity which includes fill horizontal */ + public static final int GRAVITY_FILL_HORIZ = 1 << 3; + + /** Bitmask for a gravity which includes center vertical */ + public static final int GRAVITY_CENTER_VERT = 1 << 4; + + /** Bitmask for a gravity which includes fill vertical */ + public static final int GRAVITY_FILL_VERT = 1 << 5; + + /** Bitmask for a gravity which includes top */ + public static final int GRAVITY_TOP = 1 << 6; + + /** Bitmask for a gravity which includes bottom */ + public static final int GRAVITY_BOTTOM = 1 << 7; + + /** Bitmask for a gravity which includes start */ + public static final int GRAVITY_START = 1 << 8; + + /** Bitmask for a gravity which includes end */ + public static final int GRAVITY_END = 1 << 9; + + /** Bitmask for a gravity which includes any horizontal constraint */ + public static final int GRAVITY_HORIZ_MASK = GRAVITY_CENTER_HORIZ | GRAVITY_FILL_HORIZ + | GRAVITY_LEFT | GRAVITY_RIGHT | GRAVITY_START | GRAVITY_END; + + /** Bitmask for a gravity which any vertical constraint */ + public static final int GRAVITY_VERT_MASK = GRAVITY_CENTER_VERT | GRAVITY_FILL_VERT + | GRAVITY_TOP | GRAVITY_BOTTOM; + + /** + * Returns the gravity of the given element + * + * @param element the element to look up the gravity for + * @return a bit mask corresponding to the selected gravities + */ + public static int getGravity(Element element) { + String gravityString = element.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_GRAVITY); + return getGravity(gravityString, GRAVITY_LEFT | GRAVITY_TOP); + } + + /** + * Returns the gravity bitmask for the given gravity string description + * + * @param gravityString the gravity string description + * @param defaultMask the default/initial bitmask to start with + * @return a bitmask corresponding to the gravity description + */ + public static int getGravity(String gravityString, int defaultMask) { + int gravity = defaultMask; + if (gravityString != null && !gravityString.isEmpty()) { + String[] anchors = gravityString.split("\\|"); //$NON-NLS-1$ + for (String anchor : anchors) { + if (GRAVITY_VALUE_CENTER.equals(anchor)) { + gravity = GRAVITY_CENTER_HORIZ | GRAVITY_CENTER_VERT; + } else if (GRAVITY_VALUE_FILL.equals(anchor)) { + gravity = GRAVITY_FILL_HORIZ | GRAVITY_FILL_VERT; + } else if (GRAVITY_VALUE_CENTER_VERTICAL.equals(anchor)) { + gravity = (gravity & GRAVITY_HORIZ_MASK) | GRAVITY_CENTER_VERT; + } else if (GRAVITY_VALUE_CENTER_HORIZONTAL.equals(anchor)) { + gravity = (gravity & GRAVITY_VERT_MASK) | GRAVITY_CENTER_HORIZ; + } else if (GRAVITY_VALUE_FILL_VERTICAL.equals(anchor)) { + gravity = (gravity & GRAVITY_HORIZ_MASK) | GRAVITY_FILL_VERT; + } else if (GRAVITY_VALUE_FILL_HORIZONTAL.equals(anchor)) { + gravity = (gravity & GRAVITY_VERT_MASK) | GRAVITY_FILL_HORIZ; + } else if (GRAVITY_VALUE_TOP.equals(anchor)) { + gravity = (gravity & GRAVITY_HORIZ_MASK) | GRAVITY_TOP; + } else if (GRAVITY_VALUE_BOTTOM.equals(anchor)) { + gravity = (gravity & GRAVITY_HORIZ_MASK) | GRAVITY_BOTTOM; + } else if (GRAVITY_VALUE_LEFT.equals(anchor)) { + gravity = (gravity & (GRAVITY_VERT_MASK|GRAVITY_START)) | GRAVITY_LEFT; + } else if (GRAVITY_VALUE_RIGHT.equals(anchor)) { + gravity = (gravity & (GRAVITY_VERT_MASK|GRAVITY_END)) | GRAVITY_RIGHT; + } else if (GRAVITY_VALUE_START.equals(anchor)) { + gravity = (gravity & (GRAVITY_VERT_MASK|GRAVITY_LEFT)) | GRAVITY_START; + } else if (GRAVITY_VALUE_END.equals(anchor)) { + gravity = (gravity & (GRAVITY_VERT_MASK|GRAVITY_RIGHT)) | GRAVITY_END; + } // else: "clip" not supported + } + } + + return gravity; + } + + /** + * Returns true if the given gravity bitmask is constrained horizontally + * + * @param gravity the gravity bitmask + * @return true if the given gravity bitmask is constrained horizontally + */ + public static boolean isConstrainedHorizontally(int gravity) { + return (gravity & GRAVITY_HORIZ_MASK) != 0; + } + + /** + * Returns true if the given gravity bitmask is constrained vertically + * + * @param gravity the gravity bitmask + * @return true if the given gravity bitmask is constrained vertically + */ + public static boolean isConstrainedVertically(int gravity) { + return (gravity & GRAVITY_VERT_MASK) != 0; + } + + /** + * Returns true if the given gravity bitmask is left aligned + * + * @param gravity the gravity bitmask + * @return true if the given gravity bitmask is left aligned + */ + public static boolean isLeftAligned(int gravity) { + return (gravity & (GRAVITY_LEFT|GRAVITY_START)) != 0; + } + + /** + * Returns true if the given gravity bitmask is top aligned + * + * @param gravity the gravity bitmask + * @return true if the given gravity bitmask is aligned + */ + public static boolean isTopAligned(int gravity) { + return (gravity & GRAVITY_TOP) != 0; + } + + /** Returns a gravity value string from the given gravity bitmask + * + * @param gravity the gravity bitmask + * @return the corresponding gravity string suitable as an XML attribute value + */ + public static String getGravity(int gravity) { + if (gravity == 0) { + return ""; + } + + if ((gravity & (GRAVITY_CENTER_HORIZ | GRAVITY_CENTER_VERT)) == + (GRAVITY_CENTER_HORIZ | GRAVITY_CENTER_VERT)) { + return GRAVITY_VALUE_CENTER; + } + + StringBuilder sb = new StringBuilder(30); + int horizontal = gravity & GRAVITY_HORIZ_MASK; + int vertical = gravity & GRAVITY_VERT_MASK; + + if ((horizontal & (GRAVITY_LEFT|GRAVITY_START)) != 0) { + if ((horizontal & GRAVITY_LEFT) != 0) { + sb.append(GRAVITY_VALUE_LEFT); + } + if ((horizontal & GRAVITY_START) != 0) { + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(GRAVITY_VALUE_START); + } + } else if ((horizontal & (GRAVITY_RIGHT|GRAVITY_END)) != 0) { + if ((horizontal & GRAVITY_RIGHT) != 0) { + sb.append(GRAVITY_VALUE_RIGHT); + } + if ((horizontal & GRAVITY_END) != 0) { + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(GRAVITY_VALUE_END); + } + } else if ((horizontal & GRAVITY_CENTER_HORIZ) != 0) { + sb.append(GRAVITY_VALUE_CENTER_HORIZONTAL); + } else if ((horizontal & GRAVITY_FILL_HORIZ) != 0) { + sb.append(GRAVITY_VALUE_FILL_HORIZONTAL); + } + + if (sb.length() > 0 && vertical != 0) { + sb.append('|'); + } + + if ((vertical & GRAVITY_TOP) != 0) { + sb.append(GRAVITY_VALUE_TOP); + } else if ((vertical & GRAVITY_BOTTOM) != 0) { + sb.append(GRAVITY_VALUE_BOTTOM); + } else if ((vertical & GRAVITY_CENTER_VERT) != 0) { + sb.append(GRAVITY_VALUE_CENTER_VERTICAL); + } else if ((vertical & GRAVITY_FILL_VERT) != 0) { + sb.append(GRAVITY_VALUE_FILL_VERTICAL); + } + + return sb.toString(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GridLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GridLayoutRule.java new file mode 100644 index 000000000..80a23c6db --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GridLayoutRule.java @@ -0,0 +1,676 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_ROW; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.FQCN_GRID_LAYOUT; +import static com.android.SdkConstants.FQCN_SPACE; +import static com.android.SdkConstants.FQCN_SPACE_V7; +import static com.android.SdkConstants.GRAVITY_VALUE_FILL; +import static com.android.SdkConstants.GRAVITY_VALUE_FILL_HORIZONTAL; +import static com.android.SdkConstants.GRAVITY_VALUE_FILL_VERTICAL; +import static com.android.SdkConstants.GRAVITY_VALUE_LEFT; +import static com.android.SdkConstants.GRID_LAYOUT; +import static com.android.SdkConstants.VALUE_HORIZONTAL; +import static com.android.SdkConstants.VALUE_TRUE; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.IViewMetadata.FillPreference; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.RuleAction.Choices; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.grid.GridDropHandler; +import com.android.ide.common.layout.grid.GridLayoutPainter; +import com.android.ide.common.layout.grid.GridModel; +import com.android.ide.common.layout.grid.GridModel.ViewData; +import com.android.utils.Pair; + +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * An {@link IViewRule} for android.widget.GridLayout which provides designtime + * interaction with GridLayouts. + * <p> + * TODO: + * <ul> + * <li>Handle multi-drag: preserving relative positions and alignments among dragged + * views. + * <li>Handle GridLayouts that have been configured in a vertical orientation. + * <li>Handle free-form editing GridLayouts that have been manually edited rather than + * built up using free-form editing (e.g. they might not follow the same spacing + * convention, might use weights etc) + * <li>Avoid setting row and column numbers on the actual elements if they can be skipped + * to make the XML leaner. + * </ul> + */ +public class GridLayoutRule extends BaseLayoutRule { + /** + * The size of the visual regular grid that we snap to (if {@link #sSnapToGrid} is set + */ + public static final int GRID_SIZE = 16; + + /** Standard gap between views */ + public static final int SHORT_GAP_DP = 16; + + /** + * The preferred margin size, in pixels + */ + public static final int MARGIN_SIZE = 32; + + /** + * Size in screen pixels in the IDE of the gutter shown for new rows and columns (in + * grid mode) + */ + private static final int NEW_CELL_WIDTH = 10; + + /** + * Maximum size of a widget relative to a cell which is allowed to fit into a cell + * (and thereby enlarge it) before it is spread with row or column spans. + */ + public static final double MAX_CELL_DIFFERENCE = 1.2; + + /** Whether debugging diagnostics is available in the toolbar */ + private static final boolean CAN_DEBUG = + VALUE_TRUE.equals(System.getenv("ADT_DEBUG_GRIDLAYOUT")); //$NON-NLS-1$ + + private static final String ACTION_ADD_ROW = "_addrow"; //$NON-NLS-1$ + private static final String ACTION_REMOVE_ROW = "_removerow"; //$NON-NLS-1$ + private static final String ACTION_ADD_COL = "_addcol"; //$NON-NLS-1$ + private static final String ACTION_REMOVE_COL = "_removecol"; //$NON-NLS-1$ + private static final String ACTION_ORIENTATION = "_orientation"; //$NON-NLS-1$ + private static final String ACTION_SHOW_STRUCTURE = "_structure"; //$NON-NLS-1$ + private static final String ACTION_GRID_MODE = "_gridmode"; //$NON-NLS-1$ + private static final String ACTION_SNAP = "_snap"; //$NON-NLS-1$ + private static final String ACTION_DEBUG = "_debug"; //$NON-NLS-1$ + + private static final URL ICON_HORIZONTAL = GridLayoutRule.class.getResource("hlinear.png"); //$NON-NLS-1$ + private static final URL ICON_VERTICAL = GridLayoutRule.class.getResource("vlinear.png"); //$NON-NLS-1$ + private static final URL ICON_ADD_ROW = GridLayoutRule.class.getResource("addrow.png"); //$NON-NLS-1$ + private static final URL ICON_REMOVE_ROW = GridLayoutRule.class.getResource("removerow.png"); //$NON-NLS-1$ + private static final URL ICON_ADD_COL = GridLayoutRule.class.getResource("addcol.png"); //$NON-NLS-1$ + private static final URL ICON_REMOVE_COL = GridLayoutRule.class.getResource("removecol.png"); //$NON-NLS-1$ + private static final URL ICON_SHOW_STRUCT = GridLayoutRule.class.getResource("showgrid.png"); //$NON-NLS-1$ + private static final URL ICON_GRID_MODE = GridLayoutRule.class.getResource("gridmode.png"); //$NON-NLS-1$ + private static final URL ICON_SNAP = GridLayoutRule.class.getResource("snap.png"); //$NON-NLS-1$ + + /** + * Whether the IDE should show diagnostics for debugging the grid layout - including + * spacers visibly in the outline, showing row and column numbers, and so on + */ + public static boolean sDebugGridLayout = CAN_DEBUG; + + /** Whether the structure (grid model) should be displayed persistently to the user */ + public static boolean sShowStructure = false; + + /** Whether the drop positions should snap to a regular grid */ + public static boolean sSnapToGrid = false; + + /** + * Whether the grid is edited in "grid mode" where the operations are row/column based + * rather than free-form + */ + public static boolean sGridMode = true; + + /** Constructs a new {@link GridLayoutRule} */ + public GridLayoutRule() { + } + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + + String namespace = getNamespace(parentNode); + Choices orientationAction = RuleAction.createChoices( + ACTION_ORIENTATION, + "Orientation", //$NON-NLS-1$ + new PropertyCallback(Collections.singletonList(parentNode), + "Change LinearLayout Orientation", namespace, ATTR_ORIENTATION), Arrays + .<String> asList("Set Horizontal Orientation", "Set Vertical Orientation"), + Arrays.<URL> asList(ICON_HORIZONTAL, ICON_VERTICAL), Arrays.<String> asList( + "horizontal", "vertical"), getCurrentOrientation(parentNode), + null /* icon */, -10, false); + orientationAction.setRadio(true); + actions.add(orientationAction); + + // Gravity and margins + if (children != null && children.size() > 0) { + actions.add(RuleAction.createSeparator(35)); + actions.add(createMarginAction(parentNode, children)); + actions.add(createGravityAction(children, ATTR_LAYOUT_GRAVITY)); + } + + IMenuCallback actionCallback = new IMenuCallback() { + @Override + public void action( + final @NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + final @Nullable Boolean newValue) { + parentNode.editXml("Add/Remove Row/Column", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + String id = action.getId(); + if (id.equals(ACTION_SHOW_STRUCTURE)) { + sShowStructure = !sShowStructure; + mRulesEngine.redraw(); + return; + } else if (id.equals(ACTION_GRID_MODE)) { + sGridMode = !sGridMode; + mRulesEngine.redraw(); + return; + } else if (id.equals(ACTION_SNAP)) { + sSnapToGrid = !sSnapToGrid; + mRulesEngine.redraw(); + return; + } else if (id.equals(ACTION_DEBUG)) { + sDebugGridLayout = !sDebugGridLayout; + mRulesEngine.layout(); + return; + } + + GridModel grid = GridModel.get(mRulesEngine, parentNode, null); + if (id.equals(ACTION_ADD_ROW)) { + grid.addRow(children); + } else if (id.equals(ACTION_REMOVE_ROW)) { + grid.removeRows(children); + } else if (id.equals(ACTION_ADD_COL)) { + grid.addColumn(children); + } else if (id.equals(ACTION_REMOVE_COL)) { + grid.removeColumns(children); + } + } + + }); + } + }; + + actions.add(RuleAction.createSeparator(142)); + + actions.add(RuleAction.createToggle(ACTION_GRID_MODE, "Grid Model Mode", + sGridMode, actionCallback, ICON_GRID_MODE, 145, false)); + + // Add and Remove Column actions only apply in Grid Mode + if (sGridMode) { + actions.add(RuleAction.createToggle(ACTION_SHOW_STRUCTURE, "Show Structure", + sShowStructure, actionCallback, ICON_SHOW_STRUCT, 147, false)); + + // Add Row and Add Column + actions.add(RuleAction.createSeparator(150)); + actions.add(RuleAction.createAction(ACTION_ADD_COL, "Add Column", actionCallback, + ICON_ADD_COL, 160, false /* supportsMultipleNodes */)); + actions.add(RuleAction.createAction(ACTION_ADD_ROW, "Add Row", actionCallback, + ICON_ADD_ROW, 165, false)); + + // Remove Row and Remove Column (if something is selected) + if (children != null && children.size() > 0) { + // TODO: Add "Merge Columns" and "Merge Rows" ? + + actions.add(RuleAction.createAction(ACTION_REMOVE_COL, "Remove Column", + actionCallback, ICON_REMOVE_COL, 170, false)); + actions.add(RuleAction.createAction(ACTION_REMOVE_ROW, "Remove Row", + actionCallback, ICON_REMOVE_ROW, 175, false)); + } + + actions.add(RuleAction.createSeparator(185)); + } else { + actions.add(RuleAction.createToggle(ACTION_SHOW_STRUCTURE, "Show Structure", + sShowStructure, actionCallback, ICON_SHOW_STRUCT, 190, false)); + + // Snap to Grid and Show Structure are only relevant in free form mode + actions.add(RuleAction.createToggle(ACTION_SNAP, "Snap to Grid", + sSnapToGrid, actionCallback, ICON_SNAP, 200, false)); + } + + // Temporary: Diagnostics for GridLayout + if (CAN_DEBUG) { + actions.add(RuleAction.createToggle(ACTION_DEBUG, "Debug", + sDebugGridLayout, actionCallback, null, 210, false)); + } + } + + /** + * Returns the orientation attribute value currently used by the node (even if not + * defined, in which case the default horizontal value is returned) + */ + private String getCurrentOrientation(final INode node) { + String orientation = node.getStringAttr(getNamespace(node), ATTR_ORIENTATION); + if (orientation == null || orientation.length() == 0) { + orientation = VALUE_HORIZONTAL; + } + return orientation; + } + + @Override + public DropFeedback onDropEnter(@NonNull INode targetNode, @Nullable Object targetView, + @Nullable IDragElement[] elements) { + GridDropHandler userData = new GridDropHandler(this, targetNode, targetView); + IFeedbackPainter painter = GridLayoutPainter.createDropFeedbackPainter(this, elements); + return new DropFeedback(userData, painter); + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + if (feedback == null) { + return null; + } + feedback.requestPaint = true; + + GridDropHandler handler = (GridDropHandler) feedback.userData; + handler.computeMatches(feedback, p); + + return feedback; + } + + @Override + public void onDropped(final @NonNull INode targetNode, final @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + if (feedback == null) { + return; + } + + Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return; + } + + GridDropHandler dropHandler = (GridDropHandler) feedback.userData; + if (dropHandler.getRowMatch() == null || dropHandler.getColumnMatch() == null) { + return; + } + + // Collect IDs from dropped elements and remap them to new IDs + // if this is a copy or from a different canvas. + Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, + feedback.isCopy || !feedback.sameCanvas); + + for (IDragElement element : elements) { + INode newChild; + if (!sGridMode) { + newChild = dropHandler.handleFreeFormDrop(targetNode, element); + } else { + newChild = dropHandler.handleGridModeDrop(targetNode, element); + } + + // Copy all the attributes, modifying them as needed. + addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); + + addInnerElements(newChild, element, idMap); + } + } + + @Override + public void onChildInserted(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + if (insertType == InsertType.MOVE_WITHIN) { + // Don't adjust widths/heights/weights when just moving within a single layout + return; + } + + if (GridModel.isSpace(node.getFqcn())) { + return; + } + + // Attempt to set "fill" properties on newly added views such that for example + // a text field will stretch horizontally. + String fqcn = node.getFqcn(); + IViewMetadata metadata = mRulesEngine.getMetadata(fqcn); + FillPreference fill = metadata.getFillPreference(); + String gravity = computeDefaultGravity(fill); + if (gravity != null) { + node.setAttribute(getNamespace(parent), ATTR_LAYOUT_GRAVITY, gravity); + } + } + + /** + * Returns the namespace URI to use for GridLayout-specific attributes, such + * as columnCount, layout_column, layout_column_span, layout_gravity etc. + * + * @param layout the GridLayout instance to look up the namespace for + * @return the namespace, never null + */ + public String getNamespace(INode layout) { + String namespace = ANDROID_URI; + + String fqcn = layout.getFqcn(); + if (!fqcn.equals(GRID_LAYOUT) && !fqcn.equals(FQCN_GRID_LAYOUT)) { + namespace = mRulesEngine.getAppNameSpace(); + } + + return namespace; + } + + /** + * Computes the default gravity to be used for a widget of the given fill + * preference when added to a grid layout + * + * @param fill the fill preference for the widget + * @return the gravity value, or null, to be set on the widget + */ + public static String computeDefaultGravity(FillPreference fill) { + String horizontal = GRAVITY_VALUE_LEFT; + String vertical = null; + if (fill.fillHorizontally(true /*verticalContext*/)) { + horizontal = GRAVITY_VALUE_FILL_HORIZONTAL; + } + if (fill.fillVertically(true /*verticalContext*/)) { + vertical = GRAVITY_VALUE_FILL_VERTICAL; + } + String gravity; + if (horizontal == GRAVITY_VALUE_FILL_HORIZONTAL + && vertical == GRAVITY_VALUE_FILL_VERTICAL) { + gravity = GRAVITY_VALUE_FILL; + } else if (vertical != null) { + gravity = horizontal + '|' + vertical; + } else { + gravity = horizontal; + } + + return gravity; + } + + @Override + public void onRemovingChildren(@NonNull List<INode> deleted, @NonNull INode parent, + boolean moved) { + super.onRemovingChildren(deleted, parent, moved); + + if (!sGridMode) { + // Attempt to clean up spacer objects for any newly-empty rows or columns + // as the result of this deletion + GridModel grid = GridModel.get(mRulesEngine, parent, null); + grid.onDeleted(deleted); + } + } + + @Override + protected void paintResizeFeedback(IGraphics gc, INode node, ResizeState state) { + if (!sGridMode) { + GridModel grid = getGrid(state); + GridLayoutPainter.paintResizeFeedback(gc, state.layout, grid); + } + + if (resizingWidget(state)) { + super.paintResizeFeedback(gc, node, state); + } else { + GridModel grid = getGrid(state); + int startColumn = grid.getColumn(state.bounds.x); + int endColumn = grid.getColumn(state.bounds.x2()); + int columnSpan = endColumn - startColumn + 1; + + int startRow = grid.getRow(state.bounds.y); + int endRow = grid.getRow(state.bounds.y2()); + int rowSpan = endRow - startRow + 1; + + Rect cellBounds = grid.getCellBounds(startRow, startColumn, rowSpan, columnSpan); + gc.useStyle(DrawingStyle.RESIZE_PREVIEW); + gc.drawRect(cellBounds); + } + } + + /** Returns the grid size cached on the given {@link ResizeState} object */ + private GridModel getGrid(ResizeState resizeState) { + GridModel grid = (GridModel) resizeState.clientData; + if (grid == null) { + grid = GridModel.get(mRulesEngine, resizeState.layout, resizeState.layoutView); + resizeState.clientData = grid; + } + + return grid; + } + + @Override + protected void setNewSizeBounds(ResizeState state, INode node, INode layout, + Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { + + if (resizingWidget(state)) { + if (state.fillWidth || state.fillHeight || state.wrapWidth || state.wrapHeight) { + GridModel grid = getGrid(state); + ViewData view = grid.getView(node); + if (view != null) { + String gravityString = grid.getGridAttribute(view.node, ATTR_LAYOUT_GRAVITY); + int gravity = GravityHelper.getGravity(gravityString, 0); + if (view.column > 0 && verticalEdge != null && state.fillWidth) { + state.fillWidth = false; + state.wrapWidth = true; + gravity &= ~GravityHelper.GRAVITY_HORIZ_MASK; + gravity |= GravityHelper.GRAVITY_FILL_HORIZ; + } else if (verticalEdge != null && state.wrapWidth) { + gravity &= ~GravityHelper.GRAVITY_HORIZ_MASK; + gravity |= GravityHelper.GRAVITY_LEFT; + } + if (view.row > 0 && horizontalEdge != null && state.fillHeight) { + state.fillHeight = false; + state.wrapHeight = true; + gravity &= ~GravityHelper.GRAVITY_VERT_MASK; + gravity |= GravityHelper.GRAVITY_FILL_VERT; + } else if (horizontalEdge != null && state.wrapHeight) { + gravity &= ~GravityHelper.GRAVITY_VERT_MASK; + gravity |= GravityHelper.GRAVITY_TOP; + } + gravityString = GravityHelper.getGravity(gravity); + grid.setGridAttribute(view.node, ATTR_LAYOUT_GRAVITY, gravityString); + // Fall through and set layout_width and/or layout_height to wrap_content + } + } + super.setNewSizeBounds(state, node, layout, oldBounds, newBounds, horizontalEdge, + verticalEdge); + } else { + Pair<Integer, Integer> spans = computeResizeSpans(state); + int rowSpan = spans.getFirst(); + int columnSpan = spans.getSecond(); + GridModel grid = getGrid(state); + grid.setColumnSpanAttribute(node, columnSpan); + grid.setRowSpanAttribute(node, rowSpan); + + ViewData view = grid.getView(node); + if (view != null) { + String gravityString = grid.getGridAttribute(view.node, ATTR_LAYOUT_GRAVITY); + int gravity = GravityHelper.getGravity(gravityString, 0); + if (verticalEdge != null && columnSpan > 1) { + gravity &= ~GravityHelper.GRAVITY_HORIZ_MASK; + gravity |= GravityHelper.GRAVITY_FILL_HORIZ; + } + if (horizontalEdge != null && rowSpan > 1) { + gravity &= ~GravityHelper.GRAVITY_VERT_MASK; + gravity |= GravityHelper.GRAVITY_FILL_VERT; + } + gravityString = GravityHelper.getGravity(gravity); + grid.setGridAttribute(view.node, ATTR_LAYOUT_GRAVITY, gravityString); + } + } + } + + @Override + protected String getResizeUpdateMessage(ResizeState state, INode child, INode parent, + Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { + Pair<Integer, Integer> spans = computeResizeSpans(state); + if (resizingWidget(state)) { + String width = state.getWidthAttribute(); + String height = state.getHeightAttribute(); + + String message; + if (horizontalEdge == null) { + message = width; + } else if (verticalEdge == null) { + message = height; + } else { + // U+00D7: Unicode for multiplication sign + message = String.format("%s \u00D7 %s", width, height); + } + + // Tack on a tip about using the Shift modifier key + return String.format("%s\n(Press Shift to resize row/column spans)", message); + } else { + int rowSpan = spans.getFirst(); + int columnSpan = spans.getSecond(); + return String.format("ColumnSpan=%d, RowSpan=%d\n(Release Shift to resize widget itself)", + columnSpan, rowSpan); + } + } + + /** + * Returns true if we're resizing the widget, and false if we're resizing the cell + * spans + */ + private static boolean resizingWidget(ResizeState state) { + return (state.modifierMask & DropFeedback.MODIFIER2) == 0; + } + + /** + * Computes the new column and row spans as the result of the current resizing + * operation + */ + private Pair<Integer, Integer> computeResizeSpans(ResizeState state) { + GridModel grid = getGrid(state); + + int startColumn = grid.getColumn(state.bounds.x); + int endColumn = grid.getColumn(state.bounds.x2()); + int columnSpan = endColumn - startColumn + 1; + + int startRow = grid.getRow(state.bounds.y); + int endRow = grid.getRow(state.bounds.y2()); + int rowSpan = endRow - startRow + 1; + + return Pair.of(rowSpan, columnSpan); + } + + /** + * Returns the size of the new cell gutter in layout coordinates + * + * @return the size of the new cell gutter in layout coordinates + */ + public int getNewCellSize() { + return mRulesEngine.screenToLayout(NEW_CELL_WIDTH / 2); + } + + @Override + public void paintSelectionFeedback(@NonNull IGraphics graphics, @NonNull INode parentNode, + @NonNull List<? extends INode> childNodes, @Nullable Object view) { + super.paintSelectionFeedback(graphics, parentNode, childNodes, view); + + if (sShowStructure) { + // TODO: Cache the grid + if (view != null) { + if (GridLayoutPainter.paintStructure(view, DrawingStyle.GUIDELINE_DASHED, + parentNode, graphics)) { + return; + } + } + GridLayoutPainter.paintStructure(DrawingStyle.GUIDELINE_DASHED, + parentNode, graphics, GridModel.get(mRulesEngine, parentNode, view)); + } else if (sDebugGridLayout) { + GridLayoutPainter.paintStructure(DrawingStyle.GRID, + parentNode, graphics, GridModel.get(mRulesEngine, parentNode, view)); + } + + // TBD: Highlight the cells around the selection, and display easy controls + // for for example tweaking the rowspan/colspan of a cell? (but only in grid mode) + } + + /** + * Paste into a GridLayout. We have several possible behaviors (and many + * more than are listed here): + * <ol> + * <li> Preserve the current positions of the elements (if pasted from another + * canvas, not just XML markup copied from say a web site) and apply those + * into the current grid. This might mean "overwriting" (sitting on top of) + * existing elements. + * <li> Fill available "holes" in the grid. + * <li> Lay them out consecutively, row by row, like text. + * <li> Some hybrid approach, where I attempt to preserve the <b>relative</b> + * relationships (columns/wrapping, spacing between the pasted views etc) + * but I append them to the bottom of the layout on one or more new rows. + * <li> Try to paste at the current mouse position, if known, preserving the + * relative distances between the existing elements there. + * </ol> + * Attempting to preserve the current position isn't possible right now, + * because the clipboard data contains only the textual representation of + * the markup. (We'd need to stash position information from a previous + * layout render along with the clipboard data). + * <p> + * Currently, this implementation simply lays out the elements row by row, + * approach #3 above. + */ + @Override + public void onPaste( + @NonNull INode targetNode, + @Nullable Object targetView, + @NonNull IDragElement[] elements) { + DropFeedback feedback = onDropEnter(targetNode, targetView, elements); + if (feedback != null) { + Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return; + } + + Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, + true /* remap id's */); + + for (IDragElement element : elements) { + // Skip <Space> elements and only insert the real elements being + // copied + if (elements.length > 1 && (FQCN_SPACE.equals(element.getFqcn()) + || FQCN_SPACE_V7.equals(element.getFqcn()))) { + continue; + } + + String fqcn = element.getFqcn(); + INode newChild = targetNode.appendChild(fqcn); + addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); + + // Ensure that we reset any potential row/column attributes from a different + // grid layout being copied from + GridDropHandler handler = (GridDropHandler) feedback.userData; + GridModel grid = handler.getGrid(); + grid.setGridAttribute(newChild, ATTR_LAYOUT_COLUMN, null); + grid.setGridAttribute(newChild, ATTR_LAYOUT_ROW, null); + + // TODO: Set columnSpans to avoid making these widgets completely + // break the layout + // Alternatively, I could just lay them all out on subsequent lines + // with a column span of columnSpan5 + + addInnerElements(newChild, element, idMap); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GridViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GridViewRule.java new file mode 100644 index 000000000..b82f391b4 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/GridViewRule.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_NUM_COLUMNS; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.GridView + */ +public class GridViewRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, getFillParentValueName()); + node.setAttribute(ANDROID_URI, ATTR_NUM_COLUMNS, "3"); //$NON-NLS-1$ + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/HorizontalScrollViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/HorizontalScrollViewRule.java new file mode 100644 index 000000000..722949051 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/HorizontalScrollViewRule.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; +import static com.android.SdkConstants.VALUE_HORIZONTAL; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; + +/** + * An {@link IViewRule} for android.widget.HorizontalScrollView. + */ +public class HorizontalScrollViewRule extends FrameLayoutRule { + + @Override + public void onChildInserted(@NonNull INode child, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onChildInserted(child, parent, insertType); + + // The child of the ScrollView should fill in both directions + String fillParent = getFillParentValueName(); + child.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + child.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + } + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + // Insert a horizontal linear layout which is commonly used with horizontal scrollbars + // as described by the documentation for HorizontalScrollbars. + INode linearLayout = node.appendChild(FQCN_LINEAR_LAYOUT); + linearLayout.setAttribute(ANDROID_URI, ATTR_ORIENTATION, + VALUE_HORIZONTAL); + } + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + DropFeedback f = super.onDropMove(targetNode, elements, feedback, p); + + // HorizontalScrollViews only allow a single child + if (targetNode.getChildren().length > 0) { + f.invalidTarget = true; + } + return f; + } + + @Override + protected void drawFeedback( + IGraphics gc, + INode targetNode, + IDragElement[] elements, + DropFeedback feedback) { + if (targetNode.getChildren().length > 0) { + Rect b = targetNode.getBounds(); + if (b.isValid()) { + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + } + } else { + super.drawFeedback(gc, targetNode, elements, feedback); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/IgnoredLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/IgnoredLayoutRule.java new file mode 100644 index 000000000..3a65a8601 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/IgnoredLayoutRule.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.INode; + +/** + * An ignored layout is a layout that should not be treated as a layout by the + * visual editor (usually because the widget extends a layout class we recognize + * and support, but where the widget is more restrictive in how it manages its + * children so we don't want to expose the normal configuration options). + * <p> + * For example, the ZoomControls widget is not user-configurable as a + * LinearLayout even though it extends it. Our ZoomControls rule is therefore a + * subclass of this {@link IgnoredLayoutRule} class. + */ +public abstract class IgnoredLayoutRule extends BaseLayoutRule { + @Override + public DropFeedback onDropEnter(@NonNull INode targetNode, @Nullable Object targetView, + @Nullable IDragElement[] elements) { + // Do nothing; this layout rule corresponds to a layout that + // should not be handled as a layout by the visual editor - usually + // because some widget is extending a layout for implementation purposes + // but does not want to expose configurability of the base layout in the + // editor. + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ImageButtonRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ImageButtonRule.java new file mode 100644 index 000000000..990795091 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ImageButtonRule.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_SRC; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.ImageButton. + */ +public class ImageButtonRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + // When dropping an include tag, ask the user which layout to include. + if (insertType == InsertType.CREATE) { // NOT InsertType.CREATE_PREVIEW + String src = mRulesEngine.displayResourceInput("drawable", ""); //$NON-NLS-1$ //$NON-NLS-2$ + if (src != null) { + node.editXml("Set Image", + new PropertySettingNodeHandler(ANDROID_URI, ATTR_SRC, + src.length() > 0 ? src : null)); + return; + } + } + + // Fallback if dismissed or during previews etc + if (insertType.isCreate()) { + node.setAttribute(ANDROID_URI, ATTR_SRC, getSampleImageSrc()); + } + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ImageViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ImageViewRule.java new file mode 100644 index 000000000..bc0184c4f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ImageViewRule.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_SRC; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.ImageViewRule. + */ +public class ImageViewRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + // When dropping an include tag, ask the user which layout to include. + if (insertType == InsertType.CREATE) { // NOT InsertType.CREATE_PREVIEW + String src = mRulesEngine.displayResourceInput("drawable", ""); //$NON-NLS-1$ //$NON-NLS-2$ + if (src != null) { + node.editXml("Set Image", + new PropertySettingNodeHandler(ANDROID_URI, ATTR_SRC, + src.length() > 0 ? src : null)); + return; + } else { + // Remove the view; the insertion was canceled + parent.removeChild(node); + } + } + + // Fallback if dismissed or during previews etc + if (insertType.isCreate()) { + node.setAttribute(ANDROID_URI, ATTR_SRC, getSampleImageSrc()); + } + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/IncludeRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/IncludeRule.java new file mode 100644 index 000000000..fcb1a6dac --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/IncludeRule.java @@ -0,0 +1,46 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ATTR_LAYOUT; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for the special XML {@code <include>} tag. + */ +public class IncludeRule extends BaseViewRule { + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + // When dropping an include tag, ask the user which layout to include. + if (insertType == InsertType.CREATE) { // NOT InsertType.CREATE_PREVIEW + String include = mRulesEngine.displayIncludeSourceInput(); + if (include != null) { + node.editXml("Include Layout", + // Note -- the layout attribute is NOT in the Android namespace! + new PropertySettingNodeHandler(null, ATTR_LAYOUT, + include.length() > 0 ? include : null)); + } else { + // Remove the view; the insertion was canceled + parent.removeChild(node); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/LinearLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/LinearLayoutRule.java new file mode 100644 index 000000000..610fe5d8b --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/LinearLayoutRule.java @@ -0,0 +1,1092 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_BASELINE_ALIGNED; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.ATTR_WEIGHT_SUM; +import static com.android.SdkConstants.VALUE_1; +import static com.android.SdkConstants.VALUE_HORIZONTAL; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; +import static com.android.SdkConstants.VALUE_ZERO_DP; +import static com.android.ide.eclipse.adt.AdtUtils.formatFloatAttribute; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.IViewMetadata.FillPreference; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.RuleAction.Choices; +import com.android.ide.common.api.SegmentType; +import com.android.ide.eclipse.adt.AdtPlugin; + +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * An {@link IViewRule} for android.widget.LinearLayout and all its derived + * classes. + */ +public class LinearLayoutRule extends BaseLayoutRule { + private static final String ACTION_ORIENTATION = "_orientation"; //$NON-NLS-1$ + private static final String ACTION_WEIGHT = "_weight"; //$NON-NLS-1$ + private static final String ACTION_DISTRIBUTE = "_distribute"; //$NON-NLS-1$ + private static final String ACTION_BASELINE = "_baseline"; //$NON-NLS-1$ + private static final String ACTION_CLEAR = "_clear"; //$NON-NLS-1$ + private static final String ACTION_DOMINATE = "_dominate"; //$NON-NLS-1$ + + private static final URL ICON_HORIZONTAL = + LinearLayoutRule.class.getResource("hlinear.png"); //$NON-NLS-1$ + private static final URL ICON_VERTICAL = + LinearLayoutRule.class.getResource("vlinear.png"); //$NON-NLS-1$ + private static final URL ICON_WEIGHTS = + LinearLayoutRule.class.getResource("weights.png"); //$NON-NLS-1$ + private static final URL ICON_DISTRIBUTE = + LinearLayoutRule.class.getResource("distribute.png"); //$NON-NLS-1$ + private static final URL ICON_BASELINE = + LinearLayoutRule.class.getResource("baseline.png"); //$NON-NLS-1$ + private static final URL ICON_CLEAR_WEIGHTS = + LinearLayoutRule.class.getResource("clearweights.png"); //$NON-NLS-1$ + private static final URL ICON_DOMINATE = + LinearLayoutRule.class.getResource("allweight.png"); //$NON-NLS-1$ + + /** + * Returns the current orientation, regardless of whether it has been defined in XML + * + * @param node The LinearLayout to look up the orientation for + * @return "horizontal" or "vertical" depending on the current orientation of the + * linear layout + */ + private String getCurrentOrientation(final INode node) { + String orientation = node.getStringAttr(ANDROID_URI, ATTR_ORIENTATION); + if (orientation == null || orientation.length() == 0) { + orientation = VALUE_HORIZONTAL; + } + return orientation; + } + + /** + * Returns true if the given node represents a vertical linear layout. + * @param node the node to check layout orientation for + * @return true if the layout is in vertical mode, otherwise false + */ + protected boolean isVertical(INode node) { + // Horizontal is the default, so if no value is specified it is horizontal. + return VALUE_VERTICAL.equals(node.getStringAttr(ANDROID_URI, + ATTR_ORIENTATION)); + } + + /** + * Returns true if this LinearLayout supports switching orientation. + * + * @return true if this layout supports orientations + */ + protected boolean supportsOrientation() { + return true; + } + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + if (supportsOrientation()) { + Choices action = RuleAction.createChoices( + ACTION_ORIENTATION, "Orientation", //$NON-NLS-1$ + new PropertyCallback(Collections.singletonList(parentNode), + "Change LinearLayout Orientation", + ANDROID_URI, ATTR_ORIENTATION), + Arrays.<String>asList("Set Horizontal Orientation","Set Vertical Orientation"), + Arrays.<URL>asList(ICON_HORIZONTAL, ICON_VERTICAL), + Arrays.<String>asList("horizontal", "vertical"), + getCurrentOrientation(parentNode), + null /* icon */, + -10, + false /* supportsMultipleNodes */ + ); + action.setRadio(true); + actions.add(action); + } + if (!isVertical(parentNode)) { + String current = parentNode.getStringAttr(ANDROID_URI, ATTR_BASELINE_ALIGNED); + boolean isAligned = current == null || Boolean.valueOf(current); + actions.add(RuleAction.createToggle(ACTION_BASELINE, "Toggle Baseline Alignment", + isAligned, + new PropertyCallback(Collections.singletonList(parentNode), + "Change Baseline Alignment", + ANDROID_URI, ATTR_BASELINE_ALIGNED), // TODO: Also set index? + ICON_BASELINE, 38, false)); + } + + // Gravity + if (children != null && children.size() > 0) { + actions.add(RuleAction.createSeparator(35)); + + // Margins + actions.add(createMarginAction(parentNode, children)); + + // Gravity + actions.add(createGravityAction(children, ATTR_LAYOUT_GRAVITY)); + + // Weights + IMenuCallback actionCallback = new IMenuCallback() { + @Override + public void action( + final @NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + final @Nullable Boolean newValue) { + parentNode.editXml("Change Weight", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + String id = action.getId(); + if (id.equals(ACTION_WEIGHT)) { + String weight = + children.get(0).getStringAttr(ANDROID_URI, ATTR_LAYOUT_WEIGHT); + if (weight == null || weight.length() == 0) { + weight = "0.0"; //$NON-NLS-1$ + } + weight = mRulesEngine.displayInput("Enter Weight Value:", weight, + null); + if (weight != null) { + if (weight.isEmpty()) { + weight = null; // remove attribute + } + for (INode child : children) { + child.setAttribute(ANDROID_URI, + ATTR_LAYOUT_WEIGHT, weight); + } + } + } else if (id.equals(ACTION_DISTRIBUTE)) { + distributeWeights(parentNode, parentNode.getChildren()); + } else if (id.equals(ACTION_CLEAR)) { + clearWeights(parentNode); + } else if (id.equals(ACTION_CLEAR) || id.equals(ACTION_DOMINATE)) { + clearWeights(parentNode); + distributeWeights(parentNode, + children.toArray(new INode[children.size()])); + } else { + assert id.equals(ACTION_BASELINE); + } + } + }); + } + }; + actions.add(RuleAction.createSeparator(50)); + actions.add(RuleAction.createAction(ACTION_DISTRIBUTE, "Distribute Weights Evenly", + actionCallback, ICON_DISTRIBUTE, 60, false /*supportsMultipleNodes*/)); + actions.add(RuleAction.createAction(ACTION_DOMINATE, "Assign All Weight", + actionCallback, ICON_DOMINATE, 70, false)); + actions.add(RuleAction.createAction(ACTION_WEIGHT, "Change Layout Weight", + actionCallback, ICON_WEIGHTS, 80, false)); + actions.add(RuleAction.createAction(ACTION_CLEAR, "Clear All Weights", + actionCallback, ICON_CLEAR_WEIGHTS, 90, false)); + } + } + + private void distributeWeights(INode parentNode, INode[] targets) { + // Any XML to get weight sum? + String weightSum = parentNode.getStringAttr(ANDROID_URI, + ATTR_WEIGHT_SUM); + double sum = -1.0; + if (weightSum != null) { + // Distribute + try { + sum = Double.parseDouble(weightSum); + } catch (NumberFormatException nfe) { + // Just keep using the default + } + } + int numTargets = targets.length; + double share; + if (sum <= 0.0) { + // The sum will be computed from the children, so just + // use arbitrary amount + share = 1.0; + } else { + share = sum / numTargets; + } + String value = formatFloatAttribute((float) share); + String sizeAttribute = isVertical(parentNode) ? + ATTR_LAYOUT_HEIGHT : ATTR_LAYOUT_WIDTH; + for (INode target : targets) { + target.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, value); + // Also set the width/height to 0dp to ensure actual equal + // size (without this, only the remaining space is + // distributed) + if (VALUE_WRAP_CONTENT.equals(target.getStringAttr(ANDROID_URI, sizeAttribute))) { + target.setAttribute(ANDROID_URI, sizeAttribute, VALUE_ZERO_DP); + } + } + } + + private void clearWeights(INode parentNode) { + // Clear attributes + String sizeAttribute = isVertical(parentNode) + ? ATTR_LAYOUT_HEIGHT : ATTR_LAYOUT_WIDTH; + for (INode target : parentNode.getChildren()) { + target.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, null); + String size = target.getStringAttr(ANDROID_URI, sizeAttribute); + if (size != null && size.startsWith("0")) { //$NON-NLS-1$ + target.setAttribute(ANDROID_URI, sizeAttribute, VALUE_WRAP_CONTENT); + } + } + } + + // ==== Drag'n'drop support ==== + + @Override + public DropFeedback onDropEnter(final @NonNull INode targetNode, @Nullable Object targetView, + final @Nullable IDragElement[] elements) { + + if (elements.length == 0) { + return null; + } + + Rect bn = targetNode.getBounds(); + if (!bn.isValid()) { + return null; + } + + boolean isVertical = isVertical(targetNode); + + // Prepare a list of insertion points: X coords for horizontal, Y for + // vertical. + List<MatchPos> indexes = new ArrayList<MatchPos>(); + + int last = isVertical ? bn.y : bn.x; + int pos = 0; + boolean lastDragged = false; + int selfPos = -1; + for (INode it : targetNode.getChildren()) { + Rect bc = it.getBounds(); + if (bc.isValid()) { + // First see if this node looks like it's the same as one of the + // *dragged* bounds + boolean isDragged = false; + for (IDragElement element : elements) { + // This tries to determine if an INode corresponds to an + // IDragElement, by comparing their bounds. + if (element.isSame(it)) { + isDragged = true; + break; + } + } + + // We don't want to insert drag positions before or after the + // element that is itself being dragged. However, we -do- want + // to insert a match position here, at the center, such that + // when you drag near its current position we show a match right + // where it's already positioned. + if (isDragged) { + int v = isVertical ? bc.y + (bc.h / 2) : bc.x + (bc.w / 2); + selfPos = pos; + indexes.add(new MatchPos(v, pos++)); + } else if (lastDragged) { + // Even though we don't want to insert a match below, we + // need to increment the index counter such that subsequent + // lines know their correct index in the child list. + pos++; + } else { + // Add an insertion point between the last point and the + // start of this child + int v = isVertical ? bc.y : bc.x; + v = (last + v) / 2; + indexes.add(new MatchPos(v, pos++)); + } + + last = isVertical ? (bc.y + bc.h) : (bc.x + bc.w); + lastDragged = isDragged; + } else { + // We still have to count this position even if it has no bounds, or + // subsequent children will be inserted at the wrong place + pos++; + } + } + + // Finally add an insert position after all the children - unless of + // course we happened to be dragging the last element + if (!lastDragged) { + int v = last + 1; + indexes.add(new MatchPos(v, pos)); + } + + int posCount = targetNode.getChildren().length + 1; + return new DropFeedback(new LinearDropData(indexes, posCount, isVertical, selfPos), + new IFeedbackPainter() { + + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, + @NonNull DropFeedback feedback) { + // Paint callback for the LinearLayout. This is called + // by the canvas when a draw is needed. + drawFeedback(gc, node, elements, feedback); + } + }); + } + + void drawFeedback(IGraphics gc, INode node, IDragElement[] elements, DropFeedback feedback) { + Rect b = node.getBounds(); + if (!b.isValid()) { + return; + } + + // Highlight the receiver + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + + gc.useStyle(DrawingStyle.DROP_ZONE); + + LinearDropData data = (LinearDropData) feedback.userData; + boolean isVertical = data.isVertical(); + int selfPos = data.getSelfPos(); + + for (MatchPos it : data.getIndexes()) { + int i = it.getDistance(); + int pos = it.getPosition(); + // Don't show insert drop zones for "self"-index since that one goes + // right through the center of the widget rather than in a sibling + // position + if (pos != selfPos) { + if (isVertical) { + // draw horizontal lines + gc.drawLine(b.x, i, b.x + b.w, i); + } else { + // draw vertical lines + gc.drawLine(i, b.y, i, b.y + b.h); + } + } + } + + Integer currX = data.getCurrX(); + Integer currY = data.getCurrY(); + + if (currX != null && currY != null) { + gc.useStyle(DrawingStyle.DROP_ZONE_ACTIVE); + + int x = currX; + int y = currY; + + Rect be = elements[0].getBounds(); + + // Draw a clear line at the closest drop zone (unless we're over the + // dragged element itself) + if (data.getInsertPos() != selfPos || selfPos == -1) { + gc.useStyle(DrawingStyle.DROP_PREVIEW); + if (data.getWidth() != null) { + int width = data.getWidth(); + int fromX = x - width / 2; + int toX = x + width / 2; + gc.drawLine(fromX, y, toX, y); + } else if (data.getHeight() != null) { + int height = data.getHeight(); + int fromY = y - height / 2; + int toY = y + height / 2; + gc.drawLine(x, fromY, x, toY); + } + } + + if (be.isValid()) { + boolean isLast = data.isLastPosition(); + + // At least the first element has a bound. Draw rectangles for + // all dropped elements with valid bounds, offset at the drop + // point. + int offsetX; + int offsetY; + if (isVertical) { + offsetX = b.x - be.x; + offsetY = currY - be.y - (isLast ? 0 : (be.h / 2)); + + } else { + offsetX = currX - be.x - (isLast ? 0 : (be.w / 2)); + offsetY = b.y - be.y; + } + + gc.useStyle(DrawingStyle.DROP_PREVIEW); + for (IDragElement element : elements) { + Rect bounds = element.getBounds(); + if (bounds.isValid() && (bounds.w > b.w || bounds.h > b.h) && + node.getChildren().length == 0) { + // The bounds of the child does not fully fit inside the target. + // Limit the bounds to the layout bounds (but only when there + // are no children, since otherwise positioning around the existing + // children gets difficult) + final int px, py, pw, ph; + if (bounds.w > b.w) { + px = b.x; + pw = b.w; + } else { + px = bounds.x + offsetX; + pw = bounds.w; + } + if (bounds.h > b.h) { + py = b.y; + ph = b.h; + } else { + py = bounds.y + offsetY; + ph = bounds.h; + } + Rect within = new Rect(px, py, pw, ph); + gc.drawRect(within); + } else { + drawElement(gc, element, offsetX, offsetY); + } + } + } + } + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + Rect b = targetNode.getBounds(); + if (!b.isValid()) { + return feedback; + } + + LinearDropData data = (LinearDropData) feedback.userData; + boolean isVertical = data.isVertical(); + + int bestDist = Integer.MAX_VALUE; + int bestIndex = Integer.MIN_VALUE; + Integer bestPos = null; + + for (MatchPos index : data.getIndexes()) { + int i = index.getDistance(); + int pos = index.getPosition(); + int dist = (isVertical ? p.y : p.x) - i; + if (dist < 0) + dist = -dist; + if (dist < bestDist) { + bestDist = dist; + bestIndex = i; + bestPos = pos; + if (bestDist <= 0) + break; + } + } + + if (bestIndex != Integer.MIN_VALUE) { + Integer oldX = data.getCurrX(); + Integer oldY = data.getCurrY(); + + if (isVertical) { + data.setCurrX(b.x + b.w / 2); + data.setCurrY(bestIndex); + data.setWidth(b.w); + data.setHeight(null); + } else { + data.setCurrX(bestIndex); + data.setCurrY(b.y + b.h / 2); + data.setWidth(null); + data.setHeight(b.h); + } + + data.setInsertPos(bestPos); + + feedback.requestPaint = !equals(oldX, data.getCurrX()) + || !equals(oldY, data.getCurrY()); + } + + return feedback; + } + + private static boolean equals(Integer i1, Integer i2) { + if (i1 == i2) { + return true; + } else if (i1 != null) { + return i1.equals(i2); + } else { + // We know i2 != null + return i2.equals(i1); + } + } + + @Override + public void onDropLeave(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback) { + // ignore + } + + @Override + public void onDropped(final @NonNull INode targetNode, final @NonNull IDragElement[] elements, + final @Nullable DropFeedback feedback, final @NonNull Point p) { + + LinearDropData data = (LinearDropData) feedback.userData; + final int initialInsertPos = data.getInsertPos(); + insertAt(targetNode, elements, feedback.isCopy || !feedback.sameCanvas, initialInsertPos); + } + + @Override + public void onChildInserted(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + if (insertType == InsertType.MOVE_WITHIN) { + // Don't adjust widths/heights/weights when just moving within a single + // LinearLayout + return; + } + + // Attempt to set fill-properties on newly added views such that for example, + // in a vertical layout, a text field defaults to filling horizontally, but not + // vertically. + String fqcn = node.getFqcn(); + IViewMetadata metadata = mRulesEngine.getMetadata(fqcn); + if (metadata != null) { + boolean vertical = isVertical(parent); + FillPreference fill = metadata.getFillPreference(); + String fillParent = getFillParentValueName(); + if (fill.fillHorizontally(vertical)) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + } else if (!vertical && fill == FillPreference.WIDTH_IN_VERTICAL) { + // In a horizontal layout, make views that would fill horizontally in a + // vertical layout have a non-zero weight instead. This will make the item + // fill but only enough to allow other views to be shown as well. + // (However, for drags within the same layout we do not touch + // the weight, since it might already have been tweaked to a particular + // value) + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, VALUE_1); + } + if (fill.fillVertically(vertical)) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + } + } + + // If you insert into a layout that already is using layout weights, + // and all the layout weights are the same (nonzero) value, then use + // the same weight for this new layout as well. Also duplicate the 0dip/0px/0dp + // sizes, if used. + boolean duplicateWeight = true; + boolean duplicate0dip = true; + String sameWeight = null; + String sizeAttribute = isVertical(parent) ? ATTR_LAYOUT_HEIGHT : ATTR_LAYOUT_WIDTH; + for (INode target : parent.getChildren()) { + if (target == node) { + continue; + } + String weight = target.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WEIGHT); + if (weight == null || weight.length() == 0) { + duplicateWeight = false; + break; + } else if (sameWeight != null && !sameWeight.equals(weight)) { + duplicateWeight = false; + } else { + sameWeight = weight; + } + String size = target.getStringAttr(ANDROID_URI, sizeAttribute); + if (size != null && !size.startsWith("0")) { //$NON-NLS-1$ + duplicate0dip = false; + break; + } + } + if (duplicateWeight && sameWeight != null) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, sameWeight); + if (duplicate0dip) { + node.setAttribute(ANDROID_URI, sizeAttribute, VALUE_ZERO_DP); + } + } + } + + /** A possible match position */ + private static class MatchPos { + /** The pixel distance */ + private int mDistance; + /** The position among siblings */ + private int mPosition; + + public MatchPos(int distance, int position) { + mDistance = distance; + mPosition = position; + } + + @Override + public String toString() { + return "MatchPos [distance=" + mDistance //$NON-NLS-1$ + + ", position=" + mPosition //$NON-NLS-1$ + + "]"; //$NON-NLS-1$ + } + + private int getDistance() { + return mDistance; + } + + private int getPosition() { + return mPosition; + } + } + + private static class LinearDropData { + /** Vertical layout? */ + private final boolean mVertical; + + /** Insert points (pixels + index) */ + private final List<MatchPos> mIndexes; + + /** Number of insert positions in the target node */ + private final int mNumPositions; + + /** Current marker X position */ + private Integer mCurrX; + + /** Current marker Y position */ + private Integer mCurrY; + + /** Position of the dragged element in this layout (or + -1 if the dragged element is from elsewhere) */ + private final int mSelfPos; + + /** Current drop insert index (-1 for "at the end") */ + private int mInsertPos = -1; + + /** width of match line if it's a horizontal one */ + private Integer mWidth; + + /** height of match line if it's a vertical one */ + private Integer mHeight; + + public LinearDropData(List<MatchPos> indexes, int numPositions, + boolean isVertical, int selfPos) { + mIndexes = indexes; + mNumPositions = numPositions; + mVertical = isVertical; + mSelfPos = selfPos; + } + + @Override + public String toString() { + return "LinearDropData [currX=" + mCurrX //$NON-NLS-1$ + + ", currY=" + mCurrY //$NON-NLS-1$ + + ", height=" + mHeight //$NON-NLS-1$ + + ", indexes=" + mIndexes //$NON-NLS-1$ + + ", insertPos=" + mInsertPos //$NON-NLS-1$ + + ", isVertical=" + mVertical //$NON-NLS-1$ + + ", selfPos=" + mSelfPos //$NON-NLS-1$ + + ", width=" + mWidth //$NON-NLS-1$ + + "]"; //$NON-NLS-1$ + } + + private boolean isVertical() { + return mVertical; + } + + private void setCurrX(Integer currX) { + mCurrX = currX; + } + + private Integer getCurrX() { + return mCurrX; + } + + private void setCurrY(Integer currY) { + mCurrY = currY; + } + + private Integer getCurrY() { + return mCurrY; + } + + private int getSelfPos() { + return mSelfPos; + } + + private void setInsertPos(int insertPos) { + mInsertPos = insertPos; + } + + private int getInsertPos() { + return mInsertPos; + } + + private List<MatchPos> getIndexes() { + return mIndexes; + } + + private void setWidth(Integer width) { + mWidth = width; + } + + private Integer getWidth() { + return mWidth; + } + + private void setHeight(Integer height) { + mHeight = height; + } + + private Integer getHeight() { + return mHeight; + } + + /** + * Returns true if we are inserting into the last position + * + * @return true if we are inserting into the last position + */ + public boolean isLastPosition() { + return mInsertPos == mNumPositions - 1; + } + } + + /** Custom resize state used during linear layout resizing */ + private class LinearResizeState extends ResizeState { + /** Whether the node should be assigned a new weight */ + public boolean useWeight; + /** Weight sum to be applied to the parent */ + private float mNewWeightSum; + /** The weight to be set on the node (provided {@link #useWeight} is true) */ + private float mWeight; + /** Map from nodes to preferred bounds of nodes where the weights have been cleared */ + public final Map<INode, Rect> unweightedSizes; + /** Total required size required by the siblings <b>without</b> weights */ + public int totalLength; + /** List of nodes which should have their weights cleared */ + public List<INode> mClearWeights; + + private LinearResizeState(BaseLayoutRule rule, INode layout, Object layoutView, + INode node) { + super(rule, layout, layoutView, node); + + unweightedSizes = mRulesEngine.measureChildren(layout, + new IClientRulesEngine.AttributeFilter() { + @Override + public String getAttribute(@NonNull INode n, @Nullable String namespace, + @NonNull String localName) { + // Clear out layout weights; we need to measure the unweighted sizes + // of the children + if (ATTR_LAYOUT_WEIGHT.equals(localName) + && SdkConstants.NS_RESOURCES.equals(namespace)) { + return ""; //$NON-NLS-1$ + } + + return null; + } + }); + + // Compute total required size required by the siblings *without* weights + totalLength = 0; + final boolean isVertical = isVertical(layout); + for (Map.Entry<INode, Rect> entry : unweightedSizes.entrySet()) { + Rect preferredSize = entry.getValue(); + if (isVertical) { + totalLength += preferredSize.h; + } else { + totalLength += preferredSize.w; + } + } + } + + /** Resets the computed state */ + void reset() { + mNewWeightSum = -1; + useWeight = false; + mClearWeights = null; + } + + /** Sets a weight to be applied to the node */ + void setWeight(float weight) { + useWeight = true; + mWeight = weight; + } + + /** Sets a weight sum to be applied to the parent layout */ + void setWeightSum(float weightSum) { + mNewWeightSum = weightSum; + } + + /** Marks that the given node should be cleared when applying the new size */ + void clearWeight(INode n) { + if (mClearWeights == null) { + mClearWeights = new ArrayList<INode>(); + } + mClearWeights.add(n); + } + + /** Applies the state to the nodes */ + public void apply() { + assert useWeight; + + String value = mWeight > 0 ? formatFloatAttribute(mWeight) : null; + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, value); + + if (mClearWeights != null) { + for (INode n : mClearWeights) { + if (getWeight(n) > 0.0f) { + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, null); + } + } + } + + if (mNewWeightSum > 0.0) { + layout.setAttribute(ANDROID_URI, ATTR_WEIGHT_SUM, + formatFloatAttribute(mNewWeightSum)); + } + } + } + + @Override + protected ResizeState createResizeState(INode layout, Object layoutView, INode node) { + return new LinearResizeState(this, layout, layoutView, node); + } + + protected void updateResizeState(LinearResizeState resizeState, final INode node, INode layout, + Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, + SegmentType verticalEdge) { + // Update the resize state. + // This method attempts to compute a new layout weight to be used in the direction + // of the linear layout. If the superclass has already determined that we can snap to + // a wrap_content or match_parent boundary, we prefer that. Otherwise, we attempt to + // compute a layout weight - which can fail if the size is too big (not enough room), + // or if the size is too small (smaller than the natural width of the node), and so on. + // In that case this method just aborts, which will leave the resize state object + // in such a state that it will call the superclass to resize instead, which will fall + // back to device independent pixel sizing. + resizeState.reset(); + + if (oldBounds.equals(newBounds)) { + return; + } + + // If we're setting the width/height to wrap_content/match_parent in the dimension of the + // linear layout, then just apply wrap_content and clear weights. + boolean isVertical = isVertical(layout); + if (!isVertical && verticalEdge != null) { + if (resizeState.wrapWidth || resizeState.fillWidth) { + resizeState.clearWeight(node); + return; + } + if (newBounds.w == oldBounds.w) { + return; + } + } + + if (isVertical && horizontalEdge != null) { + if (resizeState.wrapHeight || resizeState.fillHeight) { + resizeState.clearWeight(node); + return; + } + if (newBounds.h == oldBounds.h) { + return; + } + } + + // Compute weight sum + float sum = getWeightSum(layout); + if (sum <= 0.0f) { + sum = 1.0f; + resizeState.setWeightSum(sum); + } + + // If the new size of the node is smaller than its preferred/wrap_content size, + // then we cannot use weights to size it; switch to pixel-based sizing instead + Map<INode, Rect> sizes = resizeState.unweightedSizes; + Rect nodePreferredSize = sizes.get(node); + if (nodePreferredSize != null) { + if (horizontalEdge != null && newBounds.h < nodePreferredSize.h || + verticalEdge != null && newBounds.w < nodePreferredSize.w) { + return; + } + } + + Rect layoutBounds = layout.getBounds(); + int remaining = (isVertical ? layoutBounds.h : layoutBounds.w) - resizeState.totalLength; + Rect nodeBounds = sizes.get(node); + if (nodeBounds == null) { + return; + } + + if (remaining > 0) { + int missing = 0; + if (isVertical) { + if (newBounds.h > nodeBounds.h) { + missing = newBounds.h - nodeBounds.h; + } else if (newBounds.h > resizeState.wrapBounds.h) { + // The weights concern how much space to ADD to the view. + // What if we have resized it to a size *smaller* than its current + // size without the weight delta? This can happen if you for example + // have set a hardcoded size, such as 500dp, and then size it to some + // smaller size. + missing = newBounds.h - resizeState.wrapBounds.h; + remaining += nodeBounds.h - resizeState.wrapBounds.h; + resizeState.wrapHeight = true; + } + } else { + if (newBounds.w > nodeBounds.w) { + missing = newBounds.w - nodeBounds.w; + } else if (newBounds.w > resizeState.wrapBounds.w) { + missing = newBounds.w - resizeState.wrapBounds.w; + remaining += nodeBounds.w - resizeState.wrapBounds.w; + resizeState.wrapWidth = true; + } + } + if (missing > 0) { + // (weight / weightSum) * remaining = missing, so + // weight = missing * weightSum / remaining + float weight = missing * sum / remaining; + resizeState.setWeight(weight); + } + } + } + + /** + * {@inheritDoc} + * <p> + * Overridden in this layout in order to make resizing affect the layout_weight + * attribute instead of the layout_width (for horizontal LinearLayouts) or + * layout_height (for vertical LinearLayouts). + */ + @Override + protected void setNewSizeBounds(ResizeState state, final INode node, INode layout, + Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, + SegmentType verticalEdge) { + LinearResizeState resizeState = (LinearResizeState) state; + updateResizeState(resizeState, node, layout, oldBounds, newBounds, + horizontalEdge, verticalEdge); + + if (resizeState.useWeight) { + resizeState.apply(); + + // Handle resizing in the opposite dimension of the layout + final boolean isVertical = isVertical(layout); + if (!isVertical && horizontalEdge != null) { + if (newBounds.h != oldBounds.h || resizeState.wrapHeight + || resizeState.fillHeight) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, + resizeState.getHeightAttribute()); + } + } + if (isVertical && verticalEdge != null) { + if (newBounds.w != oldBounds.w || resizeState.wrapWidth || resizeState.fillWidth) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, + resizeState.getWidthAttribute()); + } + } + } else { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, null); + super.setNewSizeBounds(resizeState, node, layout, oldBounds, newBounds, + horizontalEdge, verticalEdge); + } + } + + @Override + protected String getResizeUpdateMessage(ResizeState state, INode child, INode parent, + Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { + LinearResizeState resizeState = (LinearResizeState) state; + updateResizeState(resizeState, child, parent, child.getBounds(), newBounds, + horizontalEdge, verticalEdge); + + if (resizeState.useWeight) { + String weight = formatFloatAttribute(resizeState.mWeight); + String dimension = String.format("weight %1$s", weight); + + String width; + String height; + if (isVertical(parent)) { + width = resizeState.getWidthAttribute(); + height = dimension; + } else { + width = dimension; + height = resizeState.getHeightAttribute(); + } + + if (horizontalEdge == null) { + return width; + } else if (verticalEdge == null) { + return height; + } else { + // U+00D7: Unicode for multiplication sign + return String.format("%s \u00D7 %s", width, height); + } + } else { + return super.getResizeUpdateMessage(state, child, parent, newBounds, + horizontalEdge, verticalEdge); + } + } + + /** + * Returns the layout weight of of the given child of a LinearLayout, or 0.0 if it + * does not define a weight + */ + private static float getWeight(INode linearLayoutChild) { + String weight = linearLayoutChild.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WEIGHT); + if (weight != null && weight.length() > 0) { + try { + return Float.parseFloat(weight); + } catch (NumberFormatException nfe) { + AdtPlugin.log(nfe, "Invalid weight %1$s", weight); + } + } + + return 0.0f; + } + + /** + * Returns the sum of all the layout weights of the children in the given LinearLayout + * + * @param linearLayout the layout to compute the total sum for + * @return the total sum of all the layout weights in the given layout + */ + private static float getWeightSum(INode linearLayout) { + String weightSum = linearLayout.getStringAttr(ANDROID_URI, + ATTR_WEIGHT_SUM); + float sum = -1.0f; + if (weightSum != null) { + // Distribute + try { + sum = Float.parseFloat(weightSum); + return sum; + } catch (NumberFormatException nfe) { + // Just keep using the default + } + } + + return getSumOfWeights(linearLayout); + } + + private static float getSumOfWeights(INode linearLayout) { + float sum = 0.0f; + for (INode child : linearLayout.getChildren()) { + sum += getWeight(child); + } + + return sum; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ListViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ListViewRule.java new file mode 100644 index 000000000..70728c81d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ListViewRule.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.ListView and all its derived classes such + * as ExpandableListView. + * This is the "root" rule, that is used whenever there is not more specific + * rule to apply. + */ +public class ListViewRule extends AdapterViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, getFillParentValueName()); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/MapViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/MapViewRule.java new file mode 100644 index 000000000..006661e57 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/MapViewRule.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for com.google.android.maps.MapView. + * <p> + * TODO: This class should be pulled out of the ADT and bundled with the add ons + * (not the core jar but an optional tool jar) + */ +public class MapViewRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + node.setAttribute(ANDROID_URI, "android:apiKey", //$NON-NLS-1$ + "Your API key: see " + //$NON-NLS-1$ + "http://code.google.com/android/add-ons/google-apis/mapkey.html"); //$NON-NLS-1$ + } + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/MergeRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/MergeRule.java new file mode 100644 index 000000000..9cef9c4b3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/MergeRule.java @@ -0,0 +1,38 @@ +/* + * 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.common.layout; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.RuleAction; + +import java.util.List; + +/** + * Drop handler for the {@code <merge>} tag + */ +public class MergeRule extends FrameLayoutRule { + // The <merge> tag behaves a lot like the FrameLayout; all children are added + // on top of each other at (0,0) + + @Override + public void addContextMenuActions(@NonNull List<RuleAction> actions, + final @NonNull INode selectedNode) { + // Deliberately ignore super.getContextMenu(); we don't want to attempt to list + // properties for the <merge> tag + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/PropertyCallback.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/PropertyCallback.java new file mode 100644 index 000000000..da2614eef --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/PropertyCallback.java @@ -0,0 +1,82 @@ +/* + * 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.common.layout; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.RuleAction; + +import java.util.List; + +/** + * Convenience implementation of {@link IMenuCallback} which can be used to set a + * particular property to the new valueId or newValue passed from the {@link IMenuCallback} + */ +public class PropertyCallback implements IMenuCallback { + private final List<? extends INode> mTargetNodes; + private final String mUndoLabel; + private final String mUri; + private final String mAttribute; + + /** + * Creates a new property callback. + * + * @param targetNodes the nodes to apply the property to, or null to use the + * nodes pass into the + * {@link #action(RuleAction, List, String, Boolean)} method. + * @param undoLabel the label to use for the undo action + * @param uri the attribute URI to apply + * @param attribute the attribute name to apply + */ + public PropertyCallback(List<? extends INode> targetNodes, String undoLabel, + String uri, String attribute) { + super(); + mTargetNodes = targetNodes; + mUndoLabel = undoLabel; + mUri = uri; + mAttribute = attribute; + } + + // ---- Implements IMenuCallback ---- + @Override + public void action(@NonNull RuleAction action, @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, final @Nullable Boolean newValue) { + if (mTargetNodes != null && mTargetNodes.size() > 0) { + selectedNodes = mTargetNodes; + } + if (selectedNodes == null || selectedNodes.size() == 0) { + return; + } + final List<? extends INode> nodes = selectedNodes; + selectedNodes.get(0).editXml(mUndoLabel, new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + for (INode targetNode : nodes) { + if (valueId != null) { + targetNode.setAttribute(mUri, mAttribute, valueId); + } else { + assert newValue != null; + targetNode.setAttribute(mUri, mAttribute, Boolean.toString(newValue)); + } + } + } + }); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/PropertySettingNodeHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/PropertySettingNodeHandler.java new file mode 100644 index 000000000..13c8842ed --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/PropertySettingNodeHandler.java @@ -0,0 +1,42 @@ +/* + * 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.common.layout; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; + +/** + * A convenience implementation of {@link INodeHandler} for setting a given attribute to a + * given value on a particular node. + */ +class PropertySettingNodeHandler implements INodeHandler { + private final String mNamespaceUri; + private final String mAttribute; + private final String mValue; + + PropertySettingNodeHandler(String namespaceUri, String attribute, String value) { + super(); + mNamespaceUri = namespaceUri; + mAttribute = attribute; + mValue = value; + } + + @Override + public void handle(@NonNull INode node) { + node.setAttribute(mNamespaceUri, mAttribute, mValue); + } +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/QuickContactBadgeRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/QuickContactBadgeRule.java new file mode 100644 index 000000000..0164794d3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/QuickContactBadgeRule.java @@ -0,0 +1,34 @@ +/* + * 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.common.layout; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.QuickContactBadgeRule. + */ +public class QuickContactBadgeRule extends ImageViewRule { + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + // Deliberately override onCreate such that we don't populate a default + // image; at design time layoutlib will supply the system default contacts + // image. + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/RadioGroupRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/RadioGroupRule.java new file mode 100644 index 000000000..c9aa20768 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/RadioGroupRule.java @@ -0,0 +1,50 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ATTR_CHECKED; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.VALUE_TRUE; + + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.RadioGroup which initializes the radio group + * with some radio buttons + */ +public class RadioGroupRule extends LinearLayoutRule { + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + for (int i = 0; i < 3; i++) { + INode handle = node.appendChild(SdkConstants.FQCN_RADIO_BUTTON); + handle.setAttribute(ANDROID_URI, ATTR_ID, String.format("@+id/radio%d", i)); + if (i == 0) { + handle.setAttribute(ANDROID_URI, ATTR_CHECKED, VALUE_TRUE); + } + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/RelativeLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/RelativeLayoutRule.java new file mode 100644 index 000000000..b4bc86978 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/RelativeLayoutRule.java @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING; +import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; +import static com.android.SdkConstants.VALUE_TRUE; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.relative.ConstraintPainter; +import com.android.ide.common.layout.relative.DeletionHandler; +import com.android.ide.common.layout.relative.GuidelinePainter; +import com.android.ide.common.layout.relative.MoveHandler; +import com.android.ide.common.layout.relative.ResizeHandler; +import com.android.utils.Pair; + +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * An {@link IViewRule} for android.widget.RelativeLayout and all its derived + * classes. + */ +public class RelativeLayoutRule extends BaseLayoutRule { + private static final String ACTION_SHOW_STRUCTURE = "_structure"; //$NON-NLS-1$ + private static final String ACTION_SHOW_CONSTRAINTS = "_constraints"; //$NON-NLS-1$ + private static final String ACTION_CENTER_VERTICAL = "_centerVert"; //$NON-NLS-1$ + private static final String ACTION_CENTER_HORIZONTAL = "_centerHoriz"; //$NON-NLS-1$ + private static final URL ICON_CENTER_VERTICALLY = + RelativeLayoutRule.class.getResource("centerVertically.png"); //$NON-NLS-1$ + private static final URL ICON_CENTER_HORIZONTALLY = + RelativeLayoutRule.class.getResource("centerHorizontally.png"); //$NON-NLS-1$ + private static final URL ICON_SHOW_STRUCTURE = + BaseLayoutRule.class.getResource("structure.png"); //$NON-NLS-1$ + private static final URL ICON_SHOW_CONSTRAINTS = + BaseLayoutRule.class.getResource("constraints.png"); //$NON-NLS-1$ + + public static boolean sShowStructure = false; + public static boolean sShowConstraints = true; + + // ==== Selection ==== + + @Override + public List<String> getSelectionHint(@NonNull INode parentNode, @NonNull INode childNode) { + List<String> infos = new ArrayList<String>(18); + addAttr(ATTR_LAYOUT_ABOVE, childNode, infos); + addAttr(ATTR_LAYOUT_BELOW, childNode, infos); + addAttr(ATTR_LAYOUT_TO_LEFT_OF, childNode, infos); + addAttr(ATTR_LAYOUT_TO_RIGHT_OF, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_BASELINE, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_TOP, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_BOTTOM, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_LEFT, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_RIGHT, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_PARENT_TOP, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_PARENT_LEFT, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_PARENT_RIGHT, childNode, infos); + addAttr(ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING, childNode, infos); + addAttr(ATTR_LAYOUT_CENTER_HORIZONTAL, childNode, infos); + addAttr(ATTR_LAYOUT_CENTER_IN_PARENT, childNode, infos); + addAttr(ATTR_LAYOUT_CENTER_VERTICAL, childNode, infos); + + return infos; + } + + private void addAttr(String propertyName, INode childNode, List<String> infos) { + String a = childNode.getStringAttr(ANDROID_URI, propertyName); + if (a != null && a.length() > 0) { + // Display the layout parameters without the leading layout_ prefix + // and id references without the @+id/ prefix + if (propertyName.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + propertyName = propertyName.substring(ATTR_LAYOUT_RESOURCE_PREFIX.length()); + } + a = stripIdPrefix(a); + String s = propertyName + ": " + a; + infos.add(s); + } + } + + @Override + public void paintSelectionFeedback(@NonNull IGraphics graphics, @NonNull INode parentNode, + @NonNull List<? extends INode> childNodes, @Nullable Object view) { + super.paintSelectionFeedback(graphics, parentNode, childNodes, view); + + boolean showDependents = true; + if (sShowStructure) { + childNodes = Arrays.asList(parentNode.getChildren()); + // Avoid painting twice - both as incoming and outgoing + showDependents = false; + } else if (!sShowConstraints) { + return; + } + + ConstraintPainter.paintSelectionFeedback(graphics, parentNode, childNodes, showDependents); + } + + // ==== Drag'n'drop support ==== + + @Override + public DropFeedback onDropEnter(@NonNull INode targetNode, @Nullable Object targetView, + @Nullable IDragElement[] elements) { + return new DropFeedback(new MoveHandler(targetNode, elements, mRulesEngine), + new GuidelinePainter()); + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + if (elements == null || elements.length == 0 || feedback == null) { + return null; + } + + MoveHandler state = (MoveHandler) feedback.userData; + int offsetX = p.x + (feedback.dragBounds != null ? feedback.dragBounds.x : 0); + int offsetY = p.y + (feedback.dragBounds != null ? feedback.dragBounds.y : 0); + state.updateMove(feedback, elements, offsetX, offsetY, feedback.modifierMask); + + // Or maybe only do this if the results changed... + feedback.requestPaint = true; + + return feedback; + } + + @Override + public void onDropLeave(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback) { + } + + @Override + public void onDropped(final @NonNull INode targetNode, final @NonNull IDragElement[] elements, + final @Nullable DropFeedback feedback, final @NonNull Point p) { + if (feedback == null) { + return; + } + + final MoveHandler state = (MoveHandler) feedback.userData; + + final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, + feedback.isCopy || !feedback.sameCanvas); + + targetNode.editXml("Dropped", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + int index = -1; + + // Remove cycles + state.removeCycles(); + + // Now write the new elements. + INode previous = null; + for (IDragElement element : elements) { + String fqcn = element.getFqcn(); + + // index==-1 means to insert at the end. + // Otherwise increment the insertion position. + if (index >= 0) { + index++; + } + + INode newChild = targetNode.insertChildAt(fqcn, index); + + // Copy all the attributes, modifying them as needed. + addAttributes(newChild, element, idMap, BaseLayoutRule.DEFAULT_ATTR_FILTER); + addInnerElements(newChild, element, idMap); + + if (previous == null) { + state.applyConstraints(newChild); + previous = newChild; + } else { + // Arrange the nodes next to each other, depending on which + // edge we are attaching to. For example, if attaching to the + // top edge, arrange the subsequent nodes in a column below it. + // + // TODO: Try to do something smarter here where we detect + // constraints between the dragged edges, and we preserve these. + // We have to do this carefully though because if the + // constraints go through some other nodes not part of the + // selection, this doesn't work right, and you might be + // dragging several connected components, which we'd then + // need to stitch together such that they are all visible. + + state.attachPrevious(previous, newChild); + previous = newChild; + } + } + } + }); + } + + @Override + public void onChildInserted(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + // TODO: Handle more generically some way to ensure that widgets with no + // intrinsic size get some minimum size until they are attached on multiple + // opposing sides. + //String fqcn = node.getFqcn(); + //if (fqcn.equals(FQCN_EDIT_TEXT)) { + // node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, "100dp"); //$NON-NLS-1$ + //} + } + + @Override + public void onRemovingChildren(@NonNull List<INode> deleted, @NonNull INode parent, + boolean moved) { + super.onRemovingChildren(deleted, parent, moved); + + if (!moved) { + DeletionHandler handler = new DeletionHandler(deleted, Collections.<INode>emptyList(), + parent); + handler.updateConstraints(); + } + } + + // ==== Resize Support ==== + + @Override + public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent, + @Nullable SegmentType horizontalEdgeType, @Nullable SegmentType verticalEdgeType, + @Nullable Object childView, @Nullable Object parentView) { + ResizeHandler state = new ResizeHandler(parent, child, mRulesEngine, + horizontalEdgeType, verticalEdgeType); + return new DropFeedback(state, new GuidelinePainter()); + } + + @Override + public void onResizeUpdate(@Nullable DropFeedback feedback, @NonNull INode child, + @NonNull INode parent, @NonNull Rect newBounds, + int modifierMask) { + if (feedback == null) { + return; + } + + ResizeHandler state = (ResizeHandler) feedback.userData; + state.updateResize(feedback, child, newBounds, modifierMask); + } + + @Override + public void onResizeEnd(@Nullable DropFeedback feedback, @NonNull INode child, + @NonNull INode parent, final @NonNull Rect newBounds) { + if (feedback == null) { + return; + } + final ResizeHandler state = (ResizeHandler) feedback.userData; + + child.editXml("Resize", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + state.removeCycles(); + state.applyConstraints(n); + } + }); + } + + // ==== Layout Actions Bar ==== + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + + actions.add(createGravityAction(Collections.<INode>singletonList(parentNode), + ATTR_GRAVITY)); + actions.add(RuleAction.createSeparator(25)); + actions.add(createMarginAction(parentNode, children)); + + IMenuCallback callback = new IMenuCallback() { + @Override + public void action(@NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + final @Nullable Boolean newValue) { + final String id = action.getId(); + if (id.equals(ACTION_CENTER_VERTICAL)|| id.equals(ACTION_CENTER_HORIZONTAL)) { + parentNode.editXml("Center", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + if (id.equals(ACTION_CENTER_VERTICAL)) { + for (INode child : children) { + centerVertically(child); + } + } else if (id.equals(ACTION_CENTER_HORIZONTAL)) { + for (INode child : children) { + centerHorizontally(child); + } + } + mRulesEngine.redraw(); + } + + }); + } else if (id.equals(ACTION_SHOW_CONSTRAINTS)) { + sShowConstraints = !sShowConstraints; + mRulesEngine.redraw(); + } else { + assert id.equals(ACTION_SHOW_STRUCTURE); + sShowStructure = !sShowStructure; + mRulesEngine.redraw(); + } + } + }; + + // Centering actions + if (children != null && children.size() > 0) { + actions.add(RuleAction.createSeparator(150)); + actions.add(RuleAction.createAction(ACTION_CENTER_VERTICAL, "Center Vertically", + callback, ICON_CENTER_VERTICALLY, 160, false)); + actions.add(RuleAction.createAction(ACTION_CENTER_HORIZONTAL, "Center Horizontally", + callback, ICON_CENTER_HORIZONTALLY, 170, false)); + } + + actions.add(RuleAction.createSeparator(80)); + actions.add(RuleAction.createToggle(ACTION_SHOW_CONSTRAINTS, "Show Constraints", + sShowConstraints, callback, ICON_SHOW_CONSTRAINTS, 180, false)); + actions.add(RuleAction.createToggle(ACTION_SHOW_STRUCTURE, "Show All Relationships", + sShowStructure, callback, ICON_SHOW_STRUCTURE, 190, false)); + } + + private void centerHorizontally(INode node) { + // Clear horizontal-oriented attributes from the node + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_LEFT, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_LEFT, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_TO_RIGHT_OF, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_RIGHT, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_RIGHT, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_TO_LEFT_OF, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, null); + + if (VALUE_TRUE.equals(node.getStringAttr(ANDROID_URI, ATTR_LAYOUT_CENTER_IN_PARENT))) { + // Already done + } else if (VALUE_TRUE.equals(node.getStringAttr(ANDROID_URI, + ATTR_LAYOUT_CENTER_VERTICAL))) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_VERTICAL, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_IN_PARENT, VALUE_TRUE); + } else { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, VALUE_TRUE); + } + } + + private void centerVertically(INode node) { + // Clear vertical-oriented attributes from the node + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_TOP, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_TOP, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_BELOW, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_BOTTOM, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ABOVE, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_VERTICAL, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_BASELINE, null); + + // Center vertically + if (VALUE_TRUE.equals(node.getStringAttr(ANDROID_URI, ATTR_LAYOUT_CENTER_IN_PARENT))) { + // ALready done + } else if (VALUE_TRUE.equals(node.getStringAttr(ANDROID_URI, + ATTR_LAYOUT_CENTER_HORIZONTAL))) { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, null); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_IN_PARENT, VALUE_TRUE); + } else { + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_VERTICAL, VALUE_TRUE); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ResizeState.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ResizeState.java new file mode 100644 index 000000000..42b9083ad --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ResizeState.java @@ -0,0 +1,131 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.VALUE_N_DP; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; + +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.Segment; +import com.android.ide.common.api.SegmentType; + +/** State held during resizing operations */ +class ResizeState { + /** + * The associated rule + */ + private final BaseLayoutRule mRule; + + /** + * The node being resized + */ + public final INode node; + + /** + * The layout containing the resized node + */ + public final INode layout; + + /** The proposed resized bounds of the node */ + public Rect bounds; + + /** The preferred wrap_content bounds of the node */ + public Rect wrapBounds; + + /** The suggested horizontal fill_parent guideline position */ + public Segment horizontalFillSegment; + + /** The suggested vertical fill_parent guideline position */ + public Segment verticalFillSegment; + + /** The type of horizontal edge being resized, or null */ + public SegmentType horizontalEdgeType; + + /** The type of vertical edge being resized, or null */ + public SegmentType verticalEdgeType; + + /** Whether the user has snapped to the wrap_content width */ + public boolean wrapWidth; + + /** Whether the user has snapped to the wrap_content height */ + public boolean wrapHeight; + + /** Whether the user has snapped to the match_parent width */ + public boolean fillWidth; + + /** Whether the user has snapped to the match_parent height */ + public boolean fillHeight; + + /** Custom field for use by subclasses */ + public Object clientData; + + /** Keyboard mask */ + public int modifierMask; + + /** + * The actual view object for the layout containing the resizing operation, + * or null if not known + */ + public Object layoutView; + + /** + * Constructs a new {@link ResizeState} + * + * @param rule the associated rule + * @param layout the parent layout containing the resized node + * @param layoutView the actual View instance for the layout, or null if not known + * @param node the node being resized + */ + ResizeState(BaseLayoutRule rule, INode layout, Object layoutView, INode node) { + mRule = rule; + + this.layout = layout; + this.node = node; + this.layoutView = layoutView; + } + + /** + * Returns the width attribute to be set to match the new bounds + * + * @return the width string, never null + */ + public String getWidthAttribute() { + if (wrapWidth) { + return VALUE_WRAP_CONTENT; + } else if (fillWidth) { + return mRule.getFillParentValueName(); + } else { + return String.format(VALUE_N_DP, mRule.mRulesEngine.pxToDp(bounds.w)); + } + } + + /** + * Returns the height attribute to be set to match the new bounds + * + * @return the height string, never null + */ + public String getHeightAttribute() { + if (wrapHeight) { + return VALUE_WRAP_CONTENT; + } else if (fillHeight) { + return mRule.getFillParentValueName(); + } else { + return String.format(VALUE_N_DP, mRule.mRulesEngine.pxToDp(bounds.h)); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ScrollViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ScrollViewRule.java new file mode 100644 index 000000000..9f2b4ae6f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ScrollViewRule.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; +import static com.android.SdkConstants.VALUE_VERTICAL; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; + +/** + * An {@link IViewRule} for android.widget.ScrollView. + */ +public class ScrollViewRule extends FrameLayoutRule { + + @Override + public void onChildInserted(@NonNull INode child, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onChildInserted(child, parent, insertType); + + // The child of the ScrollView should fill in both directions + String fillParent = getFillParentValueName(); + child.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + child.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + } + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + // Insert a default linear layout (which will in turn be registered as + // a child of this node and the create child method above will set its + // fill parent attributes, its id, etc. + INode linear = node.appendChild(FQCN_LINEAR_LAYOUT); + linear.setAttribute(ANDROID_URI, ATTR_ORIENTATION, VALUE_VERTICAL); + } + } + + @Override + public DropFeedback onDropMove(@NonNull INode targetNode, @NonNull IDragElement[] elements, + @Nullable DropFeedback feedback, @NonNull Point p) { + DropFeedback f = super.onDropMove(targetNode, elements, feedback, p); + + // ScrollViews only allow a single child + if (targetNode.getChildren().length > 0) { + f.invalidTarget = true; + } + return f; + } + + @Override + protected void drawFeedback( + IGraphics gc, + INode targetNode, + IDragElement[] elements, + DropFeedback feedback) { + if (targetNode.getChildren().length > 0) { + Rect b = targetNode.getBounds(); + if (b.isValid()) { + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + } + } else { + super.drawFeedback(gc, targetNode, elements, feedback); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/SeekBarRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/SeekBarRule.java new file mode 100644 index 000000000..b88f8ab25 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/SeekBarRule.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.SeekBar + */ +public class SeekBarRule extends BaseViewRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + // A SeekBar isn't useful with wrap_content because it packs itself down to + // almost no usable width -- so just make it grow in all layouts + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, getFillParentValueName()); + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/SlidingDrawerRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/SlidingDrawerRule.java new file mode 100644 index 000000000..e4267bb10 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/SlidingDrawerRule.java @@ -0,0 +1,68 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ATTR_CONTENT; +import static com.android.SdkConstants.ATTR_HANDLE; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_TEXT; + + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.SlidingDrawerRule which initializes new sliding + * drawers with their mandatory children and default sizing attributes + */ +public class SlidingDrawerRule extends BaseLayoutRule { + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + String matchParent = getFillParentValueName(); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, matchParent); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, matchParent); + + // Create mandatory children and reference them from the handle and content + // attributes of the sliding drawer + String handleId = "@+id/handle"; //$NON-NLS-1$ + String contentId = "@+id/content"; //$NON-NLS-1$ + node.setAttribute(ANDROID_URI, ATTR_HANDLE, handleId); + node.setAttribute(ANDROID_URI, ATTR_CONTENT, contentId); + + // Handle + INode handle = node.appendChild(SdkConstants.FQCN_BUTTON); + handle.setAttribute(ANDROID_URI, ATTR_TEXT, "Handle"); + handle.setAttribute(ANDROID_URI, ATTR_ID, handleId); + + // Content + INode content = node.appendChild(SdkConstants.FQCN_LINEAR_LAYOUT); + content.setAttribute(ANDROID_URI, ATTR_ID, contentId); + content.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, matchParent); + content.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, matchParent); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TabHostRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TabHostRule.java new file mode 100644 index 000000000..cb2153b50 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TabHostRule.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.FQCN_FRAME_LAYOUT; +import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; +import static com.android.SdkConstants.FQCN_TAB_WIDGET; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.TabHost. + */ +public class TabHostRule extends IgnoredLayoutRule { + // The TabHost layout states in its documentation that you typically + // manipulate its children via the TabHost rather than directly manipulating + // the child elements yourself, e.g. via addTab() etc. + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + String fillParent = getFillParentValueName(); + + // Configure default Table setup as described in the Table tutorial + node.setAttribute(ANDROID_URI, ATTR_ID, "@android:id/tabhost"); //$NON-NLS-1$ + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + + INode linear = node.appendChild(FQCN_LINEAR_LAYOUT); + linear.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + linear.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + linear.setAttribute(ANDROID_URI, ATTR_ORIENTATION, + VALUE_VERTICAL); + + INode tab = linear.appendChild(FQCN_TAB_WIDGET); + tab.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + tab.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, VALUE_WRAP_CONTENT); + tab.setAttribute(ANDROID_URI, ATTR_ID, "@android:id/tabs"); //$NON-NLS-1$ + + INode frame = linear.appendChild(FQCN_FRAME_LAYOUT); + frame.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + frame.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + frame.setAttribute(ANDROID_URI, ATTR_ID, "@android:id/tabcontent"); //$NON-NLS-1$ + + for (int i = 0; i < 3; i++) { + INode child = frame.appendChild(FQCN_LINEAR_LAYOUT); + child.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent); + child.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent); + child.setAttribute(ANDROID_URI, ATTR_ID, + String.format("@+id/tab%d", i + 1)); //$NON-NLS-1$ + } + } + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TabWidgetRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TabWidgetRule.java new file mode 100755 index 000000000..7ebaea54d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TabWidgetRule.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.ide.common.api.IViewRule; + +/** + * An {@link IViewRule} for android.widget.TabWidget. + */ +public class TabWidgetRule extends IgnoredLayoutRule { + // TabWidgets aren't configurable as plain LinearLayout since they + // are supposed to be manipulated by their parent TabHost. +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TableLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TableLayoutRule.java new file mode 100644 index 000000000..b6aeeb486 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TableLayoutRule.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.FQCN_TABLE_ROW; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.IMenuCallback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.SegmentType; + +import java.net.URL; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * An {@link IViewRule} for android.widget.TableLayout. + */ +public class TableLayoutRule extends LinearLayoutRule { + // A table is a linear layout, but with a few differences: + // the default is vertical, not horizontal + // The fill of all children should be wrap_content + + private static final String ACTION_ADD_ROW = "_addrow"; //$NON-NLS-1$ + private static final String ACTION_REMOVE_ROW = "_removerow"; //$NON-NLS-1$ + private static final URL ICON_ADD_ROW = + TableLayoutRule.class.getResource("addrow.png"); //$NON-NLS-1$ + private static final URL ICON_REMOVE_ROW = + TableLayoutRule.class.getResource("removerow.png"); //$NON-NLS-1$ + + @Override + protected boolean isVertical(INode node) { + // Tables are always vertical + return true; + } + + @Override + protected boolean supportsOrientation() { + return false; + } + + @Override + public void onChildInserted(@NonNull INode child, @NonNull INode parent, + @NonNull InsertType insertType) { + // Overridden to inhibit the setting of layout_width/layout_height since + // it should always be match_parent + } + + /** + * Add an explicit "Add Row" action to the context menu + */ + @Override + public void addContextMenuActions(@NonNull List<RuleAction> actions, + final @NonNull INode selectedNode) { + super.addContextMenuActions(actions, selectedNode); + + IMenuCallback addTab = new IMenuCallback() { + @Override + public void action( + @NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + @Nullable Boolean newValue) { + final INode node = selectedNode; + INode newRow = node.appendChild(FQCN_TABLE_ROW); + mRulesEngine.select(Collections.singletonList(newRow)); + } + }; + actions.add(RuleAction.createAction("_addrow", "Add Row", addTab, null, 5, false)); //$NON-NLS-1$ + } + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + addTableLayoutActions(mRulesEngine, actions, parentNode, children); + } + + /** + * Adds layout actions to add and remove toolbar items + */ + static void addTableLayoutActions(final IClientRulesEngine rulesEngine, + List<RuleAction> actions, final INode parentNode, + final List<? extends INode> children) { + IMenuCallback actionCallback = new IMenuCallback() { + @Override + public void action( + final @NonNull RuleAction action, + @NonNull List<? extends INode> selectedNodes, + final @Nullable String valueId, + final @Nullable Boolean newValue) { + parentNode.editXml("Add/Remove Table Row", new INodeHandler() { + @Override + public void handle(@NonNull INode n) { + if (action.getId().equals(ACTION_ADD_ROW)) { + // Determine the index of the selection, if any; if there is + // a selection, insert the row before the current row, otherwise + // append it to the table. + int index = -1; + INode[] rows = parentNode.getChildren(); + if (children != null) { + findTableIndex: + for (INode child : children) { + // Find direct child of table layout + while (child != null && child.getParent() != parentNode) { + child = child.getParent(); + } + if (child != null) { + // Compute index of direct child of table layout + for (int i = 0; i < rows.length; i++) { + if (rows[i] == child) { + index = i; + break findTableIndex; + } + } + } + } + } + INode newRow; + if (index == -1) { + newRow = parentNode.appendChild(FQCN_TABLE_ROW); + } else { + newRow = parentNode.insertChildAt(FQCN_TABLE_ROW, index); + } + rulesEngine.select(Collections.singletonList(newRow)); + } else if (action.getId().equals(ACTION_REMOVE_ROW)) { + // Find the direct children of the TableLayout to delete; + // this is necessary since TableRow might also use + // this implementation, so the parentNode is the true + // TableLayout but the children might be grand children. + Set<INode> targets = new HashSet<INode>(); + for (INode child : children) { + while (child != null && child.getParent() != parentNode) { + child = child.getParent(); + } + if (child != null) { + targets.add(child); + } + } + for (INode target : targets) { + parentNode.removeChild(target); + } + } + } + }); + } + }; + + // Add Row + actions.add(RuleAction.createSeparator(150)); + actions.add(RuleAction.createAction(ACTION_ADD_ROW, "Add Table Row", actionCallback, + ICON_ADD_ROW, 160, false)); + + // Remove Row (if something is selected) + if (children != null && children.size() > 0) { + actions.add(RuleAction.createAction(ACTION_REMOVE_ROW, "Remove Table Row", + actionCallback, ICON_REMOVE_ROW, 170, false)); + } + } + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + // Start the table with 4 rows + for (int i = 0; i < 4; i++) { + node.appendChild(FQCN_TABLE_ROW); + } + } + } + + @Override + public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent, + @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge, + @Nullable Object childView, @Nullable Object parentView) { + // Children of a table layout cannot set their widths (it is controlled by column + // settings on the table). They can set their heights (though for TableRow, the + // height is always wrap_content). + if (horizontalEdge == null) { // Widths are edited by vertical edges. + // The user is not editing a vertical height so don't allow resizing at all + return null; + } + if (child.getFqcn().equals(FQCN_TABLE_ROW)) { + // TableRows are always WRAP_CONTENT + return null; + } + + // Allow resizing heights only + return super.onResizeBegin(child, parent, horizontalEdge, null /*verticalEdge*/, + childView, parentView); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TableRowRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TableRowRule.java new file mode 100644 index 000000000..6e3f202ee --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TableRowRule.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.FQCN_TABLE_LAYOUT; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.SegmentType; + +import java.util.List; + +/** + * An {@link IViewRule} for android.widget.TableRow. + */ +public class TableRowRule extends LinearLayoutRule { + @Override + protected boolean isVertical(INode node) { + return false; + } + + @Override + protected boolean supportsOrientation() { + return false; + } + + @Override + public void onChildInserted(@NonNull INode child, @NonNull INode parent, + @NonNull InsertType insertType) { + // Overridden to inhibit the setting of layout_width/layout_height since + // the table row will enforce match_parent and wrap_content for width and height + // respectively. + } + + @Override + public void addLayoutActions( + @NonNull List<RuleAction> actions, + final @NonNull INode parentNode, + final @NonNull List<? extends INode> children) { + super.addLayoutActions(actions, parentNode, children); + + // Also apply table-specific actions on the table row such that you can + // select something in a table row and still get offered actions on the surrounding + // table. + if (children != null) { + INode grandParent = parentNode.getParent(); + if (grandParent != null && grandParent.getFqcn().equals(FQCN_TABLE_LAYOUT)) { + TableLayoutRule.addTableLayoutActions(mRulesEngine, actions, grandParent, + children); + } + } + } + + @Override + public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent, + @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge, + @Nullable Object childView, @Nullable Object parentView) { + // No resizing in TableRows; the width is *always* match_parent and the height is + // *always* wrap_content. + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TimePickerRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TimePickerRule.java new file mode 100755 index 000000000..1eb603d43 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/TimePickerRule.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.ide.common.api.IViewRule; + +/** + * An {@link IViewRule} for android.widget.TimePicker. + */ +public class TimePickerRule extends IgnoredLayoutRule { + // A TimePicker inherits from FrameLayout but is not a general purpose + // FrameLayout +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ViewRule.java new file mode 100755 index 000000000..a7b23ab75 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ViewRule.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.ide.common.api.IViewRule; + +/** + * An {@link IViewRule} for android.view.View and all its derived classes. This + * is the "root" rule, that is used whenever there is not more specific rule to + * apply. + * <p/> + * There is no customization here, everything that is common to all views is + * simply implemented in BaseViewRule. + */ +public class ViewRule extends BaseViewRule { + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ViewTagRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ViewTagRule.java new file mode 100644 index 000000000..a89a3d851 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ViewTagRule.java @@ -0,0 +1,49 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ATTR_CLASS; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; + +/** + * An {@link IViewRule} for the special XML {@code <view>} tag. + */ +public class ViewTagRule extends BaseViewRule { + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + // When dropping a view tag, ask the user which custom view class to use + if (insertType == InsertType.CREATE) { // NOT InsertType.CREATE_PREVIEW + String fqcn = mRulesEngine.displayCustomViewClassInput(); + if (fqcn != null) { + if (!ViewElementDescriptor.viewNeedsPackage(fqcn)) { + fqcn = fqcn.substring(fqcn.lastIndexOf('.') + 1); + } + node.editXml("Set Custom View Class", + new PropertySettingNodeHandler(null, ATTR_CLASS, + fqcn.length() > 0 ? fqcn : null)); + } else { + // Remove the view; the insertion was canceled + parent.removeChild(node); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/WebViewRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/WebViewRule.java new file mode 100644 index 000000000..42b06e65b --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/WebViewRule.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.InsertType; + +/** + * An {@link IViewRule} for android.widget.ZoomControls. + */ +public class WebViewRule extends IgnoredLayoutRule { + // A WebView is not a general purpose AbsoluteLayout you should drop stuff + // into; it's an AbsoluteLayout for implementation purposes. + + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + String matchParent = getFillParentValueName(); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, matchParent); + node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, matchParent); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ZoomButtonRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ZoomButtonRule.java new file mode 100644 index 000000000..66cbd45f0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ZoomButtonRule.java @@ -0,0 +1,35 @@ +/* + * 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.common.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_SRC; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.InsertType; + +public class ZoomButtonRule extends BaseViewRule { + @Override + public void onCreate(@NonNull INode node, @NonNull INode parent, + @NonNull InsertType insertType) { + super.onCreate(node, parent, insertType); + + if (insertType.isCreate()) { + node.setAttribute(ANDROID_URI, ATTR_SRC, "@android:drawable/btn_plus"); //$NON-NLS-1$ + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ZoomControlsRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ZoomControlsRule.java new file mode 100755 index 000000000..226bf4eb5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/ZoomControlsRule.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2010 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.common.layout; + +import com.android.ide.common.api.IViewRule; + +/** + * An {@link IViewRule} for android.widget.ZoomControls. + */ +public class ZoomControlsRule extends IgnoredLayoutRule { + // A ZoomControl is only a LinearLayout in terms of borrowing + // implementation; it does not behave like one in terms of configurability. +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/addcol.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/addcol.png Binary files differnew file mode 100644 index 000000000..21391ef53 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/addcol.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/addrow.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/addrow.png Binary files differnew file mode 100644 index 000000000..0faa3e607 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/addrow.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/allweight.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/allweight.png Binary files differnew file mode 100644 index 000000000..506c66320 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/allweight.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/baseline.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/baseline.png Binary files differnew file mode 100644 index 000000000..acb187ca0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/baseline.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/centerHorizontally.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/centerHorizontally.png Binary files differnew file mode 100644 index 000000000..5053cdadd --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/centerHorizontally.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/centerVertically.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/centerVertically.png Binary files differnew file mode 100644 index 000000000..ebba8e812 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/centerVertically.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/clearweights.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/clearweights.png Binary files differnew file mode 100644 index 000000000..ad27c174d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/clearweights.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/constraints.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/constraints.png Binary files differnew file mode 100644 index 000000000..7247d5a09 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/constraints.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/distribute.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/distribute.png Binary files differnew file mode 100644 index 000000000..eac2340f9 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/distribute.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/fillheight.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/fillheight.png Binary files differnew file mode 100644 index 000000000..38e137deb --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/fillheight.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/fillwidth.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/fillwidth.png Binary files differnew file mode 100644 index 000000000..f272aab68 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/fillwidth.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/gravity.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/gravity.png Binary files differnew file mode 100644 index 000000000..4f20928ad --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/gravity.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridDropHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridDropHandler.java new file mode 100644 index 000000000..8bdb56bfe --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridDropHandler.java @@ -0,0 +1,840 @@ +/* + * 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.common.layout.grid; + +import static com.android.SdkConstants.ATTR_COLUMN_COUNT; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN_SPAN; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_ROW; +import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN; +import static com.android.ide.common.layout.GravityHelper.getGravity; +import static com.android.ide.common.layout.GridLayoutRule.GRID_SIZE; +import static com.android.ide.common.layout.GridLayoutRule.MARGIN_SIZE; +import static com.android.ide.common.layout.GridLayoutRule.MAX_CELL_DIFFERENCE; +import static com.android.ide.common.layout.GridLayoutRule.SHORT_GAP_DP; +import static com.android.ide.common.layout.grid.GridModel.UNDEFINED; +import static java.lang.Math.abs; + +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.common.layout.GravityHelper; +import com.android.ide.common.layout.GridLayoutRule; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * The {@link GridDropHandler} handles drag and drop operations into and within a + * GridLayout, computing guidelines, handling drops to edit the grid model, and so on. + */ +public class GridDropHandler { + private final GridModel mGrid; + private final GridLayoutRule mRule; + private GridMatch mColumnMatch; + private GridMatch mRowMatch; + + /** + * Creates a new {@link GridDropHandler} for + * @param gridLayoutRule the corresponding {@link GridLayoutRule} + * @param layout the GridLayout node + * @param view the view instance of the grid layout receiving the drop + */ + public GridDropHandler(GridLayoutRule gridLayoutRule, INode layout, Object view) { + mRule = gridLayoutRule; + mGrid = GridModel.get(mRule.getRulesEngine(), layout, view); + } + + /** + * Computes the best horizontal and vertical matches for a drag to the given position. + * + * @param feedback a {@link DropFeedback} object containing drag state like the drag + * bounds and the drag baseline + * @param p the mouse position + */ + public void computeMatches(DropFeedback feedback, Point p) { + mRowMatch = mColumnMatch = null; + feedback.tooltip = null; + + Rect bounds = mGrid.layout.getBounds(); + int x1 = p.x; + int y1 = p.y; + + Rect dragBounds = feedback.dragBounds; + int w = dragBounds != null ? dragBounds.w : 0; + int h = dragBounds != null ? dragBounds.h : 0; + if (!GridLayoutRule.sGridMode) { + if (dragBounds != null) { + // Sometimes the items are centered under the mouse so + // offset by the top left corner distance + x1 += dragBounds.x; + y1 += dragBounds.y; + } + + int x2 = x1 + w; + int y2 = y1 + h; + + if (x2 < bounds.x || y2 < bounds.y || x1 > bounds.x2() || y1 > bounds.y2()) { + return; + } + + List<GridMatch> columnMatches = new ArrayList<GridMatch>(); + List<GridMatch> rowMatches = new ArrayList<GridMatch>(); + int max = BaseLayoutRule.getMaxMatchDistance(); + + // Column matches: + addLeftSideMatch(x1, columnMatches, max); + addRightSideMatch(x2, columnMatches, max); + addCenterColumnMatch(bounds, x1, y1, x2, y2, columnMatches, max); + + // Row matches: + int row = (mGrid.getViewCount() == 0) ? 0 : mGrid.getClosestRow(y1); + int rowY = mGrid.getRowY(row); + addTopMatch(y1, rowMatches, max, row, rowY); + addBaselineMatch(feedback.dragBaseline, y1, rowMatches, max, row, rowY); + addBottomMatch(y2, rowMatches, max); + + // Look for gap-matches: Predefined spacing between widgets. + // TODO: Make this use metadata for predefined spacing between + // pairs of types of components. For example, buttons have certain + // inserts in their 9-patch files (depending on the theme) that should + // be considered and subtracted from the overall proposed distance! + addColumnGapMatch(bounds, x1, x2, columnMatches, max); + addRowGapMatch(bounds, y1, y2, rowMatches, max); + + // Fallback: Split existing cell. Also do snap-to-grid. + if (GridLayoutRule.sSnapToGrid) { + x1 = ((x1 - MARGIN_SIZE - bounds.x) / GRID_SIZE) * GRID_SIZE + + MARGIN_SIZE + bounds.x; + y1 = ((y1 - MARGIN_SIZE - bounds.y) / GRID_SIZE) * GRID_SIZE + + MARGIN_SIZE + bounds.y; + x2 = x1 + w; + y2 = y1 + h; + } + + + if (columnMatches.size() == 0 && x1 >= bounds.x) { + // Split the current cell since we have no matches + // TODO: Decide whether it should be gravity left or right... + columnMatches.add(new GridMatch(SegmentType.LEFT, 0, x1, mGrid.getColumn(x1), + true /* createCell */, UNDEFINED)); + } + if (rowMatches.size() == 0 && y1 >= bounds.y) { + rowMatches.add(new GridMatch(SegmentType.TOP, 0, y1, mGrid.getRow(y1), + true /* createCell */, UNDEFINED)); + } + + // Pick best matches + Collections.sort(rowMatches); + Collections.sort(columnMatches); + + mColumnMatch = null; + mRowMatch = null; + String columnDescription = null; + String rowDescription = null; + if (columnMatches.size() > 0) { + mColumnMatch = columnMatches.get(0); + columnDescription = mColumnMatch.getDisplayName(mGrid.layout); + } + if (rowMatches.size() > 0) { + mRowMatch = rowMatches.get(0); + rowDescription = mRowMatch.getDisplayName(mGrid.layout); + } + + if (columnDescription != null && rowDescription != null) { + feedback.tooltip = columnDescription + '\n' + rowDescription; + } + + feedback.invalidTarget = mColumnMatch == null || mRowMatch == null; + } else { + // Find which cell we're inside. + + // TODO: Find out where within the cell we are, and offer to tweak the gravity + // based on the position. + int column = mGrid.getColumn(x1); + int row = mGrid.getRow(y1); + + int leftDistance = mGrid.getColumnDistance(column, x1); + int rightDistance = mGrid.getColumnDistance(column + 1, x1); + int topDistance = mGrid.getRowDistance(row, y1); + int bottomDistance = mGrid.getRowDistance(row + 1, y1); + + int SLOP = 2; + int radius = mRule.getNewCellSize(); + if (rightDistance < radius + SLOP) { + column = Math.min(column + 1, mGrid.actualColumnCount); + leftDistance = rightDistance; + } + if (bottomDistance < radius + SLOP) { + row = Math.min(row + 1, mGrid.actualRowCount); + topDistance = bottomDistance; + } + + boolean createColumn = leftDistance < radius + SLOP; + boolean createRow = topDistance < radius + SLOP; + if (x1 >= bounds.x2()) { + createColumn = true; + } + if (y1 >= bounds.y2()) { + createRow = true; + } + + int cellWidth = leftDistance + rightDistance; + int cellHeight = topDistance + bottomDistance; + SegmentType horizontalType = SegmentType.LEFT; + SegmentType verticalType = SegmentType.TOP; + int minDistance = 10; // Don't center or right/bottom align in tiny cells + if (!createColumn && leftDistance > minDistance + && dragBounds != null && dragBounds.w < cellWidth - 10) { + if (rightDistance < leftDistance) { + horizontalType = SegmentType.RIGHT; + } + + int centerDistance = Math.abs(cellWidth / 2 - leftDistance); + if (centerDistance < leftDistance / 2 && centerDistance < rightDistance / 2) { + horizontalType = SegmentType.CENTER_HORIZONTAL; + } + } + if (!createRow && topDistance > minDistance + && dragBounds != null && dragBounds.h < cellHeight - 10) { + if (bottomDistance < topDistance) { + verticalType = SegmentType.BOTTOM; + } + int centerDistance = Math.abs(cellHeight / 2 - topDistance); + if (centerDistance < topDistance / 2 && centerDistance < bottomDistance / 2) { + verticalType = SegmentType.CENTER_VERTICAL; + } + } + + mColumnMatch = new GridMatch(horizontalType, 0, x1, column, createColumn, 0); + mRowMatch = new GridMatch(verticalType, 0, y1, row, createRow, 0); + + StringBuilder description = new StringBuilder(50); + String rowString = Integer.toString(mColumnMatch.cellIndex + 1); + String columnString = Integer.toString(mRowMatch.cellIndex + 1); + if (mRowMatch.createCell && mRowMatch.cellIndex < mGrid.actualRowCount) { + description.append(String.format("Shift row %1$d down", mRowMatch.cellIndex + 1)); + description.append('\n'); + } + if (mColumnMatch.createCell && mColumnMatch.cellIndex < mGrid.actualColumnCount) { + description.append(String.format("Shift column %1$d right", + mColumnMatch.cellIndex + 1)); + description.append('\n'); + } + description.append(String.format("Insert into cell (%1$s,%2$s)", + rowString, columnString)); + description.append('\n'); + description.append(String.format("Align %1$s, %2$s", + horizontalType.name().toLowerCase(Locale.US), + verticalType.name().toLowerCase(Locale.US))); + feedback.tooltip = description.toString(); + } + } + + /** + * Adds a match to align the left edge with some other edge. + */ + private void addLeftSideMatch(int x1, List<GridMatch> columnMatches, int max) { + int column = (mGrid.getViewCount() == 0) ? 0 : mGrid.getClosestColumn(x1); + int columnX = mGrid.getColumnX(column); + int distance = abs(columnX - x1); + if (distance <= max) { + columnMatches.add(new GridMatch(SegmentType.LEFT, distance, columnX, column, + false, UNDEFINED)); + } + } + + /** + * Adds a match to align the right edge with some other edge. + */ + private void addRightSideMatch(int x2, List<GridMatch> columnMatches, int max) { + // TODO: Only match the right hand side if the drag bounds fit fully within the + // cell! Ditto for match below. + int columnRight = (mGrid.getViewCount() == 0) ? 0 : mGrid.getClosestColumn(x2); + int rightDistance = mGrid.getColumnDistance(columnRight, x2); + if (rightDistance < max) { + int columnX = mGrid.getColumnX(columnRight); + if (columnX > mGrid.layout.getBounds().x) { + columnMatches.add(new GridMatch(SegmentType.RIGHT, rightDistance, columnX, + columnRight, false, UNDEFINED)); + } + } + } + + /** + * Adds a horizontal match with the center axis of the GridLayout + */ + private void addCenterColumnMatch(Rect bounds, int x1, int y1, int x2, int y2, + List<GridMatch> columnMatches, int max) { + Collection<INode> intersectsRow = mGrid.getIntersectsRow(y1, y2); + if (intersectsRow.size() == 0) { + // Offer centering on this row since there isn't anything there + int matchedLine = bounds.centerX(); + int distance = abs((x1 + x2) / 2 - matchedLine); + if (distance <= 2 * max) { + boolean createCell = false; // always just put in column 0 + columnMatches.add(new GridMatch(SegmentType.CENTER_HORIZONTAL, distance, + matchedLine, 0 /* column */, createCell, UNDEFINED)); + } + } + } + + /** + * Adds a match to align the top edge with some other edge. + */ + private void addTopMatch(int y1, List<GridMatch> rowMatches, int max, int row, int rowY) { + int distance = mGrid.getRowDistance(row, y1); + if (distance <= max) { + rowMatches.add(new GridMatch(SegmentType.TOP, distance, rowY, row, false, + UNDEFINED)); + } + } + + /** + * Adds a match to align the bottom edge with some other edge. + */ + private void addBottomMatch(int y2, List<GridMatch> rowMatches, int max) { + int rowBottom = (mGrid.getViewCount() == 0) ? 0 : mGrid.getClosestRow(y2); + int distance = mGrid.getRowDistance(rowBottom, y2); + if (distance < max) { + int rowY = mGrid.getRowY(rowBottom); + if (rowY > mGrid.layout.getBounds().y) { + rowMatches.add(new GridMatch(SegmentType.BOTTOM, distance, rowY, + rowBottom, false, UNDEFINED)); + } + } + } + + /** + * Adds a baseline match, if applicable. + */ + private void addBaselineMatch(int dragBaseline, int y1, List<GridMatch> rowMatches, int max, + int row, int rowY) { + int dragBaselineY = y1 + dragBaseline; + int rowBaseline = mGrid.getBaseline(row); + if (rowBaseline != -1) { + int rowBaselineY = rowY + rowBaseline; + int distance = abs(dragBaselineY - rowBaselineY); + if (distance < max) { + rowMatches.add(new GridMatch(SegmentType.BASELINE, distance, rowBaselineY, row, + false, UNDEFINED)); + } + } + } + + /** + * Computes a horizontal "gap" match - a preferred distance from the nearest edge, + * including margin edges + */ + private void addColumnGapMatch(Rect bounds, int x1, int x2, List<GridMatch> columnMatches, + int max) { + if (x1 < bounds.x + MARGIN_SIZE + max) { + int matchedLine = bounds.x + MARGIN_SIZE; + int distance = abs(matchedLine - x1); + if (distance <= max) { + boolean createCell = mGrid.getColumnX(mGrid.getColumn(matchedLine)) != matchedLine; + columnMatches.add(new GridMatch(SegmentType.LEFT, distance, matchedLine, + 0, createCell, MARGIN_SIZE)); + } + } else if (x2 > bounds.x2() - MARGIN_SIZE - max) { + int matchedLine = bounds.x2() - MARGIN_SIZE; + int distance = abs(matchedLine - x2); + if (distance <= max) { + // This does not yet work properly; we need to use columnWeights to achieve this + //boolean createCell = mGrid.getColumnX(mGrid.getColumn(matchedLine)) != matchedLine; + //columnMatches.add(new GridMatch(SegmentType.RIGHT, distance, matchedLine, + // mGrid.actualColumnCount - 1, createCell, MARGIN_SIZE)); + } + } else { + int columnRight = mGrid.getColumn(x1 - SHORT_GAP_DP); + int columnX = mGrid.getColumnMaxX(columnRight); + int matchedLine = columnX + SHORT_GAP_DP; + int distance = abs(matchedLine - x1); + if (distance <= max) { + boolean createCell = mGrid.getColumnX(mGrid.getColumn(matchedLine)) != matchedLine; + columnMatches.add(new GridMatch(SegmentType.LEFT, distance, matchedLine, + columnRight, createCell, SHORT_GAP_DP)); + } + + // Add a column directly adjacent (no gap) + columnRight = mGrid.getColumn(x1); + columnX = mGrid.getColumnMaxX(columnRight); + matchedLine = columnX; + distance = abs(matchedLine - x1); + + // Let's say you have this arrangement: + // [button1][button2] + // This is two columns, where the right hand side edge of column 1 is + // flush with the left side edge of column 2, because in fact the width of + // button1 is what defines the width of column 1, and that in turn is what + // defines the left side position of column 2. + // + // In this case we don't want to consider inserting a new column at the + // right hand side of button1 a better match than matching left on column 2. + // Therefore, to ensure that this doesn't happen, we "penalize" right column + // matches such that they don't get preferential treatment when the matching + // line is on the left side of the column. + distance += 2; + + if (distance <= max) { + boolean createCell = mGrid.getColumnX(mGrid.getColumn(matchedLine)) != matchedLine; + columnMatches.add(new GridMatch(SegmentType.LEFT, distance, matchedLine, + columnRight, createCell, 0)); + } + } + } + + /** + * Computes a vertical "gap" match - a preferred distance from the nearest edge, + * including margin edges + */ + private void addRowGapMatch(Rect bounds, int y1, int y2, List<GridMatch> rowMatches, int max) { + if (y1 < bounds.y + MARGIN_SIZE + max) { + int matchedLine = bounds.y + MARGIN_SIZE; + int distance = abs(matchedLine - y1); + if (distance <= max) { + boolean createCell = mGrid.getRowY(mGrid.getRow(matchedLine)) != matchedLine; + rowMatches.add(new GridMatch(SegmentType.TOP, distance, matchedLine, + 0, createCell, MARGIN_SIZE)); + } + } else if (y2 > bounds.y2() - MARGIN_SIZE - max) { + int matchedLine = bounds.y2() - MARGIN_SIZE; + int distance = abs(matchedLine - y2); + if (distance <= max) { + // This does not yet work properly; we need to use columnWeights to achieve this + //boolean createCell = mGrid.getRowY(mGrid.getRow(matchedLine)) != matchedLine; + //rowMatches.add(new GridMatch(SegmentType.BOTTOM, distance, matchedLine, + // mGrid.actualRowCount - 1, createCell, MARGIN_SIZE)); + } + } else { + int rowBottom = mGrid.getRow(y1 - SHORT_GAP_DP); + int rowY = mGrid.getRowMaxY(rowBottom); + int matchedLine = rowY + SHORT_GAP_DP; + int distance = abs(matchedLine - y1); + if (distance <= max) { + boolean createCell = mGrid.getRowY(mGrid.getRow(matchedLine)) != matchedLine; + rowMatches.add(new GridMatch(SegmentType.TOP, distance, matchedLine, + rowBottom, createCell, SHORT_GAP_DP)); + } + + // Add a row directly adjacent (no gap) + rowBottom = mGrid.getRow(y1); + rowY = mGrid.getRowMaxY(rowBottom); + matchedLine = rowY; + distance = abs(matchedLine - y1); + distance += 2; // See explanation in addColumnGapMatch + if (distance <= max) { + boolean createCell = mGrid.getRowY(mGrid.getRow(matchedLine)) != matchedLine; + rowMatches.add(new GridMatch(SegmentType.TOP, distance, matchedLine, + rowBottom, createCell, 0)); + } + + } + } + + /** + * Called when a node is dropped in free-form mode. This will insert the dragged + * element into the grid and returns the newly created node. + * + * @param targetNode the GridLayout node + * @param element the dragged element + * @return the newly created {@link INode} + */ + public INode handleFreeFormDrop(INode targetNode, IDragElement element) { + assert mRowMatch != null; + assert mColumnMatch != null; + + String fqcn = element.getFqcn(); + + INode newChild = null; + + Rect bounds = element.getBounds(); + int row = mRowMatch.cellIndex; + int column = mColumnMatch.cellIndex; + + if (targetNode.getChildren().length == 0) { + // + // Set up the initial structure: + // + // + // Fixed Fixed + // Size Size + // Column Expanding Column Column + // +-----+-------------------------------+-----+ + // | | | | + // | 0,0 | 0,1 | 0,2 | Fixed Size Row + // | | | | + // +-----+-------------------------------+-----+ + // | | | | + // | | | | + // | | | | + // | 1,0 | 1,1 | 1,2 | Expanding Row + // | | | | + // | | | | + // | | | | + // +-----+-------------------------------+-----+ + // | | | | + // | 2,0 | 2,1 | 2,2 | Fixed Size Row + // | | | | + // +-----+-------------------------------+-----+ + // + // This is implemented in GridLayout by the following grid, where + // SC1 has columnWeight=1 and SR1 has rowWeight=1. + // (SC=Space for Column, SR=Space for Row) + // + // +------+-------------------------------+------+ + // | | | | + // | SCR0 | SC1 | SC2 | + // | | | | + // +------+-------------------------------+------+ + // | | | | + // | | | | + // | | | | + // | SR1 | | | + // | | | | + // | | | | + // | | | | + // +------+-------------------------------+------+ + // | | | | + // | SR2 | | | + // | | | | + // +------+-------------------------------+------+ + // + // Note that when we split columns and rows here, if splitting the expanding + // row or column then the row or column weight should be moved to the right or + // bottom half! + + + //int columnX = mGrid.getColumnX(column); + //int rowY = mGrid.getRowY(row); + + mGrid.setGridAttribute(targetNode, ATTR_COLUMN_COUNT, 2); + //mGrid.setGridAttribute(targetNode, ATTR_COLUMN_COUNT, 3); + //INode scr0 = addSpacer(targetNode, -1, 0, 0, 1, 1); + //INode sc1 = addSpacer(targetNode, -1, 0, 1, 0, 0); + //INode sc2 = addSpacer(targetNode, -1, 0, 2, 1, 0); + //INode sr1 = addSpacer(targetNode, -1, 1, 0, 0, 0); + //INode sr2 = addSpacer(targetNode, -1, 2, 0, 0, 1); + //mGrid.setGridAttribute(sc1, ATTR_LAYOUT_GRAVITY, VALUE_FILL_HORIZONTAL); + //mGrid.setGridAttribute(sr1, ATTR_LAYOUT_GRAVITY, VALUE_FILL_VERTICAL); + // + //mGrid.loadFromXml(); + //column = mGrid.getColumn(columnX); + //row = mGrid.getRow(rowY); + } + + int startX, endX; + if (mColumnMatch.type == SegmentType.RIGHT) { + endX = mColumnMatch.matchedLine - 1; + startX = endX - bounds.w; + column = mGrid.getColumn(startX); + } else { + startX = mColumnMatch.matchedLine; // TODO: What happens on type=RIGHT? + endX = startX + bounds.w; + } + int startY, endY; + if (mRowMatch.type == SegmentType.BOTTOM) { + endY = mRowMatch.matchedLine - 1; + startY = endY - bounds.h; + row = mGrid.getRow(startY); + } else if (mRowMatch.type == SegmentType.BASELINE) { + // TODO: The rowSpan should always be 1 for baseline alignments, since + // otherwise the alignment won't work! + startY = endY = mRowMatch.matchedLine; + } else { + startY = mRowMatch.matchedLine; + endY = startY + bounds.h; + } + int endColumn = mGrid.getColumn(endX); + int endRow = mGrid.getRow(endY); + int columnSpan = endColumn - column + 1; + int rowSpan = endRow - row + 1; + + // Make sure my math was right: + assert mRowMatch.type != SegmentType.BASELINE || rowSpan == 1 : rowSpan; + + // If the item almost fits into the row (at most N % bigger) then just enlarge + // the row; don't add a rowspan since that will defeat baseline alignment etc + if (!mRowMatch.createCell && bounds.h <= MAX_CELL_DIFFERENCE * mGrid.getRowHeight( + mRowMatch.type == SegmentType.BOTTOM ? endRow : row, 1)) { + if (mRowMatch.type == SegmentType.BOTTOM) { + row += rowSpan - 1; + } + rowSpan = 1; + } + if (!mColumnMatch.createCell && bounds.w <= MAX_CELL_DIFFERENCE * mGrid.getColumnWidth( + mColumnMatch.type == SegmentType.RIGHT ? endColumn : column, 1)) { + if (mColumnMatch.type == SegmentType.RIGHT) { + column += columnSpan - 1; + } + columnSpan = 1; + } + + if (mColumnMatch.type == SegmentType.CENTER_HORIZONTAL) { + column = 0; + columnSpan = mGrid.actualColumnCount; + } + + // Temporary: Ensure we don't get in trouble with implicit positions + mGrid.applyPositionAttributes(); + + // Split cells to make a new column + if (mColumnMatch.createCell) { + int columnWidthPx = mGrid.getColumnDistance(column, mColumnMatch.matchedLine); + //assert columnWidthPx == columnMatch.distance; // TBD? IF so simplify + int columnWidthDp = mRule.getRulesEngine().pxToDp(columnWidthPx); + + int maxX = mGrid.getColumnMaxX(column); + boolean insertMarginColumn = false; + if (mColumnMatch.margin == 0) { + columnWidthDp = 0; + } else if (mColumnMatch.margin != UNDEFINED) { + int distance = abs(mColumnMatch.matchedLine - (maxX + mColumnMatch.margin)); + insertMarginColumn = column > 0 && distance < 2; + if (insertMarginColumn) { + int margin = mColumnMatch.margin; + if (ViewMetadataRepository.INSETS_SUPPORTED) { + IViewMetadata metadata = mRule.getRulesEngine().getMetadata(fqcn); + if (metadata != null) { + Margins insets = metadata.getInsets(); + if (insets != null) { + // TODO: + // Consider left or right side attachment + // TODO: Also consider inset of element on cell to the left + margin -= insets.left; + } + } + } + + columnWidthDp = mRule.getRulesEngine().pxToDp(margin); + } + } + + column++; + mGrid.splitColumn(column, insertMarginColumn, columnWidthDp, mColumnMatch.matchedLine); + if (insertMarginColumn) { + column++; + } + } + + // Split cells to make a new row + if (mRowMatch.createCell) { + int rowHeightPx = mGrid.getRowDistance(row, mRowMatch.matchedLine); + //assert rowHeightPx == rowMatch.distance; // TBD? If so simplify + int rowHeightDp = mRule.getRulesEngine().pxToDp(rowHeightPx); + + int maxY = mGrid.getRowMaxY(row); + boolean insertMarginRow = false; + if (mRowMatch.margin == 0) { + rowHeightDp = 0; + } else if (mRowMatch.margin != UNDEFINED) { + int distance = abs(mRowMatch.matchedLine - (maxY + mRowMatch.margin)); + insertMarginRow = row > 0 && distance < 2; + if (insertMarginRow) { + int margin = mRowMatch.margin; + IViewMetadata metadata = mRule.getRulesEngine().getMetadata(element.getFqcn()); + if (metadata != null) { + Margins insets = metadata.getInsets(); + if (insets != null) { + // TODO: + // Consider left or right side attachment + // TODO: Also consider inset of element on cell to the left + margin -= insets.top; + } + } + + rowHeightDp = mRule.getRulesEngine().pxToDp(margin); + } + } + + row++; + mGrid.splitRow(row, insertMarginRow, rowHeightDp, mRowMatch.matchedLine); + if (insertMarginRow) { + row++; + } + } + + // Figure out where to insert the new child + + int index = mGrid.getInsertIndex(row, column); + if (index == -1) { + // Couldn't find a later place to insert + newChild = targetNode.appendChild(fqcn); + } else { + GridModel.ViewData next = mGrid.getView(index); + + newChild = targetNode.insertChildAt(fqcn, index); + + // Must also apply positions to the following child to ensure + // that the new child doesn't affect the implicit numbering! + // TODO: We can later check whether the implied number is equal to + // what it already is such that we don't need this + next.applyPositionAttributes(); + } + + // Set the cell position (gravity) of the new widget + int gravity = 0; + if (mColumnMatch.type == SegmentType.RIGHT) { + gravity |= GravityHelper.GRAVITY_RIGHT; + } else if (mColumnMatch.type == SegmentType.CENTER_HORIZONTAL) { + gravity |= GravityHelper.GRAVITY_CENTER_HORIZ; + } + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_COLUMN, column); + if (mRowMatch.type == SegmentType.BASELINE) { + // There *is* no baseline gravity constant, instead, leave the + // vertical gravity unspecified and GridLayout will treat it as + // baseline alignment + //gravity |= GravityHelper.GRAVITY_BASELINE; + } else if (mRowMatch.type == SegmentType.BOTTOM) { + gravity |= GravityHelper.GRAVITY_BOTTOM; + } else if (mRowMatch.type == SegmentType.CENTER_VERTICAL) { + gravity |= GravityHelper.GRAVITY_CENTER_VERT; + } + // Ensure that we have at least one horizontal and vertical constraint, otherwise + // the new item will be fixed. As an example, if we have a single button in the + // table which we inserted *without* a gravity, and we then insert a button + // above it with a vertical gravity, then only the top column would be considered + // stretchable, and it will fill all available vertical space and the previous + // button will jump to the bottom. + if (!GravityHelper.isConstrainedHorizontally(gravity)) { + gravity |= GravityHelper.GRAVITY_LEFT; + } + /* This causes problems: Try placing two buttons vertically from the top of the layout. + We need to solve the free column/free row problem first. + if (!GravityHelper.isConstrainedVertically(gravity) + // There is no baseline constant, so we have to leave it unconstrained instead + && mRowMatch.type != SegmentType.BASELINE + // You also can't baseline align one element with another that has vertical + // alignment top or bottom, so when we first "freely" place views (e.g. + // at a particular y location), also place it freely (no constraint). + && !mRowMatch.createCell) { + gravity |= GravityHelper.GRAVITY_TOP; + } + */ + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_GRAVITY, getGravity(gravity)); + + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_ROW, row); + + // Apply spans to ensure that the widget can fit without pushing columns + if (columnSpan > 1) { + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_COLUMN_SPAN, columnSpan); + } + if (rowSpan > 1) { + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_ROW_SPAN, rowSpan); + } + + // Ensure that we don't store columnCount=0 + if (mGrid.actualColumnCount == 0) { + mGrid.setGridAttribute(mGrid.layout, ATTR_COLUMN_COUNT, Math.max(1, column + 1)); + } + + return newChild; + } + + /** + * Called when a drop is completed and we're in grid-editing mode. This will insert + * the dragged element into the target cell. + * + * @param targetNode the GridLayout node + * @param element the dragged element + * @return the newly created node + */ + public INode handleGridModeDrop(INode targetNode, IDragElement element) { + String fqcn = element.getFqcn(); + INode newChild = targetNode.appendChild(fqcn); + + int column = mColumnMatch.cellIndex; + if (mColumnMatch.createCell) { + mGrid.addColumn(column, + newChild, UNDEFINED, false, UNDEFINED, UNDEFINED); + } + int row = mRowMatch.cellIndex; + if (mRowMatch.createCell) { + mGrid.addRow(row, newChild, UNDEFINED, false, UNDEFINED, UNDEFINED); + } + + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_COLUMN, column); + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_ROW, row); + + int gravity = 0; + if (mColumnMatch.type == SegmentType.RIGHT) { + gravity |= GravityHelper.GRAVITY_RIGHT; + } else if (mColumnMatch.type == SegmentType.CENTER_HORIZONTAL) { + gravity |= GravityHelper.GRAVITY_CENTER_HORIZ; + } + if (mRowMatch.type == SegmentType.BASELINE) { + // There *is* no baseline gravity constant, instead, leave the + // vertical gravity unspecified and GridLayout will treat it as + // baseline alignment + //gravity |= GravityHelper.GRAVITY_BASELINE; + } else if (mRowMatch.type == SegmentType.BOTTOM) { + gravity |= GravityHelper.GRAVITY_BOTTOM; + } else if (mRowMatch.type == SegmentType.CENTER_VERTICAL) { + gravity |= GravityHelper.GRAVITY_CENTER_VERT; + } + if (!GravityHelper.isConstrainedHorizontally(gravity)) { + gravity |= GravityHelper.GRAVITY_LEFT; + } + if (!GravityHelper.isConstrainedVertically(gravity)) { + gravity |= GravityHelper.GRAVITY_TOP; + } + mGrid.setGridAttribute(newChild, ATTR_LAYOUT_GRAVITY, getGravity(gravity)); + + if (mGrid.declaredColumnCount == UNDEFINED || mGrid.declaredColumnCount < column + 1) { + mGrid.setGridAttribute(mGrid.layout, ATTR_COLUMN_COUNT, column + 1); + } + + return newChild; + } + + /** + * Returns the best horizontal match + * + * @return the best horizontal match, or null if there is no match + */ + public GridMatch getColumnMatch() { + return mColumnMatch; + } + + /** + * Returns the best vertical match + * + * @return the best vertical match, or null if there is no match + */ + public GridMatch getRowMatch() { + return mRowMatch; + } + + /** + * Returns the grid used by the drop handler + * + * @return the grid used by the drop handler, never null + */ + public GridModel getGrid() { + return mGrid; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridLayoutPainter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridLayoutPainter.java new file mode 100644 index 000000000..7e2d3a799 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridLayoutPainter.java @@ -0,0 +1,370 @@ +/* + * 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.common.layout.grid; + +import static com.android.ide.common.layout.GridLayoutRule.GRID_SIZE; +import static com.android.ide.common.layout.GridLayoutRule.MARGIN_SIZE; +import static com.android.ide.common.layout.grid.GridModel.UNDEFINED; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.GridLayoutRule; +import com.android.utils.Pair; + +/** + * Painter which paints feedback during drag, drop and resizing operations, as well as + * static selection feedback + */ +public class GridLayoutPainter { + + /** + * Creates a painter for drop feedback + * + * @param rule the corresponding {@link GridLayoutRule} + * @param elements the dragged elements + * @return a {@link IFeedbackPainter} which can paint the drop feedback + */ + public static IFeedbackPainter createDropFeedbackPainter(GridLayoutRule rule, + IDragElement[] elements) { + return new DropFeedbackPainter(rule, elements); + } + + /** + * Paints the structure (the grid model) of the given GridLayout. + * + * @param style the drawing style to use to paint the structure lines + * @param layout the grid layout node + * @param gc the graphics context to paint into + * @param grid the grid model to be visualized + */ + public static void paintStructure(DrawingStyle style, INode layout, IGraphics gc, + GridModel grid) { + Rect b = layout.getBounds(); + + gc.useStyle(style); + for (int row = 0; row < grid.actualRowCount; row++) { + int y = grid.getRowY(row); + gc.drawLine(b.x, y, b.x2(), y); + } + for (int column = 0; column < grid.actualColumnCount; column++) { + int x = grid.getColumnX(column); + gc.drawLine(x, b.y, x, b.y2()); + } + } + + /** + * Paints a regular grid according to the {@link GridLayoutRule#GRID_SIZE} and + * {@link GridLayoutRule#MARGIN_SIZE} dimensions. These are the same lines that + * snap-to-grid will align with. + * + * @param layout the GridLayout node + * @param gc the graphics context to paint the grid into + */ + public static void paintGrid(INode layout, IGraphics gc) { + Rect b = layout.getBounds(); + + int oldAlpha = gc.getAlpha(); + gc.useStyle(DrawingStyle.GUIDELINE); + gc.setAlpha(128); + + int y1 = b.y + MARGIN_SIZE; + int y2 = b.y2() - MARGIN_SIZE; + for (int y = y1; y < y2; y += GRID_SIZE) { + int x1 = b.x + MARGIN_SIZE; + int x2 = b.x2() - MARGIN_SIZE; + for (int x = x1; x < x2; x += GRID_SIZE) { + gc.drawPoint(x, y); + } + } + gc.setAlpha(oldAlpha); + } + + /** + * Paint resizing feedback (which currently paints the grid model faintly.) + * + * @param gc the graphics context + * @param layout the GridLayout + * @param grid the grid model + */ + public static void paintResizeFeedback(IGraphics gc, INode layout, GridModel grid) { + paintStructure(DrawingStyle.GRID, layout, gc, grid); + } + + /** + * A painter which can paint the drop feedback for elements being dragged into or + * within a GridLayout. + */ + private static class DropFeedbackPainter implements IFeedbackPainter { + private final GridLayoutRule mRule; + private final IDragElement[] mElements; + + /** Constructs a new {@link GridLayoutPainter} bound to the given {@link GridLayoutRule} + * @param rule the corresponding rule + * @param elements the elements to draw */ + public DropFeedbackPainter(GridLayoutRule rule, IDragElement[] elements) { + mRule = rule; + mElements = elements; + } + + // Implements IFeedbackPainter + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, + @NonNull DropFeedback feedback) { + Rect b = node.getBounds(); + if (!b.isValid()) { + return; + } + + // Highlight the receiver + gc.useStyle(DrawingStyle.DROP_RECIPIENT); + gc.drawRect(b); + GridDropHandler data = (GridDropHandler) feedback.userData; + + if (!GridLayoutRule.sGridMode) { + paintFreeFormDropFeedback(gc, node, feedback, b, data); + } else { + paintGridModeDropFeedback(gc, b, data); + } + } + + /** + * Paints the drag feedback for a free-form mode drag + */ + private void paintFreeFormDropFeedback(IGraphics gc, INode node, DropFeedback feedback, + Rect b, GridDropHandler data) { + GridModel grid = data.getGrid(); + if (GridLayoutRule.sSnapToGrid) { + GridLayoutPainter.paintGrid(node, gc); + } + GridLayoutPainter.paintStructure(DrawingStyle.GRID, node, gc, grid); + + GridMatch rowMatch = data.getRowMatch(); + GridMatch columnMatch = data.getColumnMatch(); + + if (rowMatch == null || columnMatch == null) { + return; + } + + IDragElement first = mElements[0]; + Rect dragBounds = first.getBounds(); + int offsetX = 0; + int offsetY = 0; + if (rowMatch.type == SegmentType.BOTTOM) { + offsetY -= dragBounds.h; + } else if (rowMatch.type == SegmentType.BASELINE) { + offsetY -= feedback.dragBaseline; + } + if (columnMatch.type == SegmentType.RIGHT) { + offsetX -= dragBounds.w; + } else if (columnMatch.type == SegmentType.CENTER_HORIZONTAL) { + offsetX -= dragBounds.w / 2; + } + + // Draw guidelines for matches + int y = rowMatch.matchedLine; + int x = columnMatch.matchedLine; + Rect bounds = first.getBounds(); + + // Draw margin + if (rowMatch.margin != UNDEFINED && rowMatch.margin > 0) { + gc.useStyle(DrawingStyle.DISTANCE); + int centerX = bounds.w / 2 + offsetX + x; + int y1; + int y2; + if (rowMatch.type == SegmentType.TOP) { + y1 = offsetY + y - 1; + y2 = rowMatch.matchedLine - rowMatch.margin; + } else { + assert rowMatch.type == SegmentType.BOTTOM; + y1 = bounds.h + offsetY + y - 1; + y2 = rowMatch.matchedLine + rowMatch.margin; + } + gc.drawLine(b.x, y1, b.x2(), y1); + gc.drawLine(b.x, y2, b.x2(), y2); + gc.drawString(Integer.toString(rowMatch.margin), + centerX - 3, y1 + (y2 - y1 - 16) / 2); + } else { + gc.useStyle(rowMatch.margin == 0 ? DrawingStyle.DISTANCE + : rowMatch.createCell ? DrawingStyle.GUIDELINE_DASHED + : DrawingStyle.GUIDELINE); + gc.drawLine(b.x, y, b.x2(), y ); + } + + if (columnMatch.margin != UNDEFINED && columnMatch.margin > 0) { + gc.useStyle(DrawingStyle.DISTANCE); + int centerY = bounds.h / 2 + offsetY + y; + int x1; + int x2; + if (columnMatch.type == SegmentType.LEFT) { + x1 = offsetX + x - 1; + x2 = columnMatch.matchedLine - columnMatch.margin; + } else { + assert columnMatch.type == SegmentType.RIGHT; + x1 = bounds.w + offsetX + x - 1; + x2 = columnMatch.matchedLine + columnMatch.margin; + } + gc.drawLine(x1, b.y, x1, b.y2()); + gc.drawLine(x2, b.y, x2, b.y2()); + gc.drawString(Integer.toString(columnMatch.margin), + x1 + (x2 - x1 - 16) / 2, centerY - 3); + } else { + gc.useStyle(columnMatch.margin == 0 ? DrawingStyle.DISTANCE + : columnMatch.createCell ? DrawingStyle.GUIDELINE_DASHED + : DrawingStyle.GUIDELINE); + gc.drawLine(x, b.y, x, b.y2()); + } + + // Draw preview rectangles for all the dragged elements + gc.useStyle(DrawingStyle.DROP_PREVIEW); + offsetX += x - bounds.x; + offsetY += y - bounds.y; + + for (IDragElement element : mElements) { + if (element == first) { + mRule.drawElement(gc, first, offsetX, offsetY); + // Preview baseline as well + if (feedback.dragBaseline != -1) { + int x1 = dragBounds.x + offsetX; + int y1 = dragBounds.y + offsetY + feedback.dragBaseline; + gc.drawLine(x1, y1, x1 + dragBounds.w, y1); + } + } else { + b = element.getBounds(); + if (b.isValid()) { + gc.drawRect(b.x + offsetX, b.y + offsetY, + b.x + offsetX + b.w, b.y + offsetY + b.h); + } + } + } + } + + /** + * Paints the drag feedback for a grid-mode drag + */ + private void paintGridModeDropFeedback(IGraphics gc, Rect b, GridDropHandler data) { + int radius = mRule.getNewCellSize(); + GridModel grid = data.getGrid(); + + gc.useStyle(DrawingStyle.GUIDELINE); + // Paint grid + for (int row = 1; row < grid.actualRowCount; row++) { + int y = grid.getRowY(row); + gc.drawLine(b.x, y - radius, b.x2(), y - radius); + gc.drawLine(b.x, y + radius, b.x2(), y + radius); + + } + for (int column = 1; column < grid.actualColumnCount; column++) { + int x = grid.getColumnX(column); + gc.drawLine(x - radius, b.y, x - radius, b.y2()); + gc.drawLine(x + radius, b.y, x + radius, b.y2()); + } + gc.drawRect(b.x, b.y, b.x2(), b.y2()); + gc.drawRect(b.x + 2 * radius, b.y + 2 * radius, + b.x2() - 2 * radius, b.y2() - 2 * radius); + + GridMatch columnMatch = data.getColumnMatch(); + GridMatch rowMatch = data.getRowMatch(); + int column = columnMatch.cellIndex; + int row = rowMatch.cellIndex; + boolean createColumn = columnMatch.createCell; + boolean createRow = rowMatch.createCell; + + Rect cellBounds = grid.getCellBounds(row, column, 1, 1); + + IDragElement first = mElements[0]; + Rect dragBounds = first.getBounds(); + int offsetX = cellBounds.x - dragBounds.x; + int offsetY = cellBounds.y - dragBounds.y; + + gc.useStyle(DrawingStyle.DROP_ZONE_ACTIVE); + if (createColumn) { + gc.fillRect(new Rect(cellBounds.x - radius, + cellBounds.y + (createRow ? -radius : radius), + 2 * radius + 1, cellBounds.h - (createRow ? 0 : 2 * radius))); + offsetX -= radius + dragBounds.w / 2; + } + if (createRow) { + gc.fillRect(new Rect(cellBounds.x + radius, cellBounds.y - radius, + cellBounds.w - 2 * radius, 2 * radius + 1)); + offsetY -= radius + dragBounds.h / 2; + } else if (!createColumn) { + // Choose this cell + gc.fillRect(new Rect(cellBounds.x + radius, cellBounds.y + radius, + cellBounds.w - 2 * radius, cellBounds.h - 2 * radius)); + } + + gc.useStyle(DrawingStyle.DROP_PREVIEW); + + Rect bounds = first.getBounds(); + int x = offsetX; + int y = offsetY; + if (columnMatch.type == SegmentType.RIGHT) { + x += cellBounds.w - bounds.w; + } else if (columnMatch.type == SegmentType.CENTER_HORIZONTAL) { + x += cellBounds.w / 2 - bounds.w / 2; + } + if (rowMatch.type == SegmentType.BOTTOM) { + y += cellBounds.h - bounds.h; + } else if (rowMatch.type == SegmentType.CENTER_VERTICAL) { + y += cellBounds.h / 2 - bounds.h / 2; + } + + mRule.drawElement(gc, first, x, y); + } + } + + /** + * Paints the structure (the row and column boundaries) of the given + * GridLayout + * + * @param view the instance of the GridLayout whose structure should be + * painted + * @param style the drawing style to use for the cell boundaries + * @param layout the layout element + * @param gc the graphics context + * @return true if the structure was successfully inferred from the view and + * painted + */ + public static boolean paintStructure(Object view, DrawingStyle style, INode layout, + IGraphics gc) { + Pair<int[],int[]> cellBounds = GridModel.getAxisBounds(view); + if (cellBounds != null) { + int[] xs = cellBounds.getFirst(); + int[] ys = cellBounds.getSecond(); + Rect b = layout.getBounds(); + gc.useStyle(style); + for (int row = 0; row < ys.length; row++) { + int y = ys[row] + b.y; + gc.drawLine(b.x, y, b.x2(), y); + } + for (int column = 0; column < xs.length; column++) { + int x = xs[column] + b.x; + gc.drawLine(x, b.y, x, b.y2()); + } + + return true; + } else { + return false; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridMatch.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridMatch.java new file mode 100644 index 000000000..9bee34345 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridMatch.java @@ -0,0 +1,154 @@ +/* + * 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.common.layout.grid; + +import static com.android.ide.common.layout.grid.GridModel.UNDEFINED; + +import com.android.ide.common.api.INode; +import com.android.ide.common.api.SegmentType; + +/** + * A match for a drag within a GridLayout, corresponding to an alignment with another + * edge, or a margin, or centering, or a gap distance from another edge and so on. + */ +class GridMatch implements Comparable<GridMatch> { + /** The distance to the matched edge - used to pick best matches */ + public final int distance; + + /** Type of edge that was matched (this refers to the edge on the dragged node, + * not on the matched node/row/cell etc) */ + public final SegmentType type; + + /** Row or column for the match */ + public int cellIndex; + + /** If true, create a new row/column */ + public boolean createCell; + + /** The actual x or y position of the matched segment */ + public int matchedLine; + + /** Amount of margin between the matched edges */ + public int margin; + + /** + * Constructs a match. + * + * @param type the edge of the dragged element that was matched + * @param distance the absolute distance from the ideal match - used to find the best + * match + * @param matchedLine the actual X or Y location of the ideal match + * @param cellIndex the index of the row or column we matched with + * @param createCell if true, create a new cell by splitting the existing cell at the + * matchedLine position + * @param margin a margin distance to add to the actual location from the matched line + */ + GridMatch(SegmentType type, int distance, int matchedLine, int cellIndex, + boolean createCell, int margin) { + super(); + this.type = type; + this.distance = distance; + this.matchedLine = matchedLine; + this.cellIndex = cellIndex; + this.createCell = createCell; + this.margin = margin; + } + + // Implements Comparable<GridMatch> + @Override + public int compareTo(GridMatch o) { + // Pick closest matches first + if (distance != o.distance) { + return distance - o.distance; + } + + // Prefer some types of matches over other matches + return getPriority() - o.getPriority(); + } + + /** + * Describes the match for the user + * + * @param layout the GridLayout containing the match + * @return a short description for the user of the match + */ + public String getDisplayName(INode layout) { + switch (type) { + case BASELINE: + return String.format("Align baseline in row %1$d", cellIndex + 1); + case CENTER_HORIZONTAL: + return "Center horizontally"; + case LEFT: + if (!createCell) { + return String.format("Insert into column %1$d", cellIndex + 1); + } + if (margin != UNDEFINED) { + if (cellIndex == 0 && margin != 0) { + return "Add one margin distance from the left"; + } + return String.format("Add next to column %1$d", cellIndex + 1); + } + return String.format("Align left at x=%1$d", matchedLine - layout.getBounds().x); + case RIGHT: + if (!createCell) { + return String.format("Insert right-aligned into column %1$d", cellIndex + 1); + } + return String.format("Align right at x=%1$d", matchedLine - layout.getBounds().x); + case TOP: + if (!createCell) { + return String.format("Insert into row %1$d", cellIndex + 1); + } + if (margin != UNDEFINED) { + if (cellIndex == 0 && margin != 0) { + return "Add one margin distance from the top"; + } + return String.format("Add below row %1$d", cellIndex + 1); + } + return String.format("Align top at y=%1d", matchedLine - layout.getBounds().y); + case BOTTOM: + if (!createCell) { + return String.format("Insert into bottom of row %1$d", cellIndex + 1); + } + return String.format("Align bottom at y=%1d", matchedLine - layout.getBounds().y); + case CENTER_VERTICAL: + return "Center vertically"; + case UNKNOWN: + default: + return null; + } + } + + /** + * Computes the sorting priority of this match, giving baseline matches higher + * precedence than centering which in turn is ordered before external edge matches + */ + private int getPriority() { + switch (type) { + case BASELINE: + return 0; + case CENTER_HORIZONTAL: + case CENTER_VERTICAL: + return 1; + case BOTTOM: + case LEFT: + case RIGHT: + case TOP: + return 2; + } + + return 3; + } +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridModel.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridModel.java new file mode 100644 index 000000000..46770e82c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/grid/GridModel.java @@ -0,0 +1,2384 @@ +/* + * 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.common.layout.grid; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_COLUMN_COUNT; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN; +import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN_SPAN; +import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ROW; +import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.ATTR_ROW_COUNT; +import static com.android.SdkConstants.FQCN_GRID_LAYOUT; +import static com.android.SdkConstants.FQCN_SPACE; +import static com.android.SdkConstants.FQCN_SPACE_V7; +import static com.android.SdkConstants.GRID_LAYOUT; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.SPACE; +import static com.android.SdkConstants.VALUE_BOTTOM; +import static com.android.SdkConstants.VALUE_CENTER_VERTICAL; +import static com.android.SdkConstants.VALUE_N_DP; +import static com.android.SdkConstants.VALUE_TOP; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_BOTTOM; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_CENTER_HORIZ; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_CENTER_VERT; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_RIGHT; +import static java.lang.Math.abs; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.layout.GravityHelper; +import com.android.ide.common.layout.GridLayoutRule; +import com.android.utils.Pair; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Models a GridLayout */ +public class GridModel { + /** Marker value used to indicate values (rows, columns, etc) which have not been set */ + static final int UNDEFINED = Integer.MIN_VALUE; + + /** The size of spacers in the dimension that they are not defining */ + static final int SPACER_SIZE_DP = 1; + + /** Attribute value used for {@link #SPACER_SIZE_DP} */ + private static final String SPACER_SIZE = String.format(VALUE_N_DP, SPACER_SIZE_DP); + + /** Width assigned to a newly added column with the Add Column action */ + private static final int DEFAULT_CELL_WIDTH = 100; + + /** Height assigned to a newly added row with the Add Row action */ + private static final int DEFAULT_CELL_HEIGHT = 15; + + /** The GridLayout node, never null */ + public final INode layout; + + /** True if this is a vertical layout, and false if it is horizontal (the default) */ + public boolean vertical; + + /** The declared count of rows (which may be {@link #UNDEFINED} if not specified) */ + public int declaredRowCount; + + /** The declared count of columns (which may be {@link #UNDEFINED} if not specified) */ + public int declaredColumnCount; + + /** The actual count of rows found in the grid */ + public int actualRowCount; + + /** The actual count of columns found in the grid */ + public int actualColumnCount; + + /** + * Array of positions (indexed by column) of the left edge of table cells; this + * corresponds to the column positions in the grid + */ + private int[] mLeft; + + /** + * Array of positions (indexed by row) of the top edge of table cells; this + * corresponds to the row positions in the grid + */ + private int[] mTop; + + /** + * Array of positions (indexed by column) of the maximum right hand side bounds of a + * node in the given column; this represents the visual edge of a column even when the + * actual column is wider + */ + private int[] mMaxRight; + + /** + * Array of positions (indexed by row) of the maximum bottom bounds of a node in the + * given row; this represents the visual edge of a row even when the actual row is + * taller + */ + private int[] mMaxBottom; + + /** + * Array of baselines computed for the rows. This array is populated lazily and should + * not be accessed directly; call {@link #getBaseline(int)} instead. + */ + private int[] mBaselines; + + /** List of all the view data for the children in this layout */ + private List<ViewData> mChildViews; + + /** The {@link IClientRulesEngine} */ + private final IClientRulesEngine mRulesEngine; + + /** + * An actual instance of a GridLayout object that this grid model corresponds to. + */ + private Object mViewObject; + + /** The namespace to use for attributes */ + private String mNamespace; + + /** + * Constructs a {@link GridModel} for the given layout + * + * @param rulesEngine the associated rules engine + * @param node the GridLayout node + * @param viewObject an actual GridLayout instance, or null + */ + private GridModel(IClientRulesEngine rulesEngine, INode node, Object viewObject) { + mRulesEngine = rulesEngine; + layout = node; + mViewObject = viewObject; + loadFromXml(); + } + + // Factory cache for most recent item (used primarily because during paints and drags + // the grid model is called repeatedly for the same view object.) + private static WeakReference<Object> sCachedViewObject = new WeakReference<Object>(null); + private static WeakReference<GridModel> sCachedViewModel; + + /** + * Factory which returns a grid model for the given node. + * + * @param rulesEngine the associated rules engine + * @param node the GridLayout node + * @param viewObject an actual GridLayout instance, or null + * @return a new model + */ + @NonNull + public static GridModel get( + @NonNull IClientRulesEngine rulesEngine, + @NonNull INode node, + @Nullable Object viewObject) { + if (viewObject != null && viewObject == sCachedViewObject.get()) { + GridModel model = sCachedViewModel.get(); + if (model != null) { + return model; + } + } + + GridModel model = new GridModel(rulesEngine, node, viewObject); + sCachedViewModel = new WeakReference<GridModel>(model); + sCachedViewObject = new WeakReference<Object>(viewObject); + return model; + } + + /** + * Returns the {@link ViewData} for the child at the given index + * + * @param index the position of the child node whose view we want to look up + * @return the corresponding {@link ViewData} + */ + public ViewData getView(int index) { + return mChildViews.get(index); + } + + /** + * Returns the {@link ViewData} for the given child node. + * + * @param node the node for which we want the view info + * @return the view info for the node, or null if not found + */ + public ViewData getView(INode node) { + for (ViewData view : mChildViews) { + if (view.node == node) { + return view; + } + } + + return null; + } + + /** + * Computes the index (among the children nodes) to insert a new node into which + * should be positioned at the given row and column. This will skip over any nodes + * that have implicit positions earlier than the given node, and will also ensure that + * all nodes are placed before the spacer nodes. + * + * @param row the target row of the new node + * @param column the target column of the new node + * @return the insert position to use or -1 if no preference is found + */ + public int getInsertIndex(int row, int column) { + if (vertical) { + for (ViewData view : mChildViews) { + if (view.column > column || view.column == column && view.row >= row) { + return view.index; + } + } + } else { + for (ViewData view : mChildViews) { + if (view.row > row || view.row == row && view.column >= column) { + return view.index; + } + } + } + + // Place it before the first spacer + for (ViewData view : mChildViews) { + if (view.isSpacer()) { + return view.index; + } + } + + return -1; + } + + /** + * Returns the baseline of the given row, or -1 if none is found. This looks for views + * in the row which have baseline vertical alignment and also define their own + * baseline, and returns the first such match. + * + * @param row the row to look up a baseline for + * @return the baseline relative to the row position, or -1 if not defined + */ + public int getBaseline(int row) { + if (row < 0 || row >= mBaselines.length) { + return -1; + } + + int baseline = mBaselines[row]; + if (baseline == UNDEFINED) { + baseline = -1; + + // TBD: Consider stringing together row information in the view data + // so I can quickly identify the views in a given row instead of searching + // among all? + for (ViewData view : mChildViews) { + // We only count baselines for views with rowSpan=1 because + // baseline alignment doesn't work for cell spanning views + if (view.row == row && view.rowSpan == 1) { + baseline = view.node.getBaseline(); + if (baseline != -1) { + // Even views that do have baselines do not count towards a row + // baseline if they have a vertical gravity + String gravity = getGridAttribute(view.node, ATTR_LAYOUT_GRAVITY); + if (gravity == null + || !(gravity.contains(VALUE_TOP) + || gravity.contains(VALUE_BOTTOM) + || gravity.contains(VALUE_CENTER_VERTICAL))) { + // Compute baseline relative to the row, not the view itself + baseline += view.node.getBounds().y - getRowY(row); + break; + } + } + } + } + mBaselines[row] = baseline; + } + + return baseline; + } + + /** Applies the row and column values into the XML */ + void applyPositionAttributes() { + for (ViewData view : mChildViews) { + view.applyPositionAttributes(); + } + + // Also fix the columnCount + if (getGridAttribute(layout, ATTR_COLUMN_COUNT) != null && + declaredColumnCount > actualColumnCount) { + setGridAttribute(layout, ATTR_COLUMN_COUNT, actualColumnCount); + } + } + + /** + * Sets the given GridLayout attribute (rowCount, layout_row, etc) to the + * given value. This automatically handles using the right XML namespace + * based on whether the GridLayout is the android.widget.GridLayout, or the + * support library GridLayout, and whether it's in a library project or not + * etc. + * + * @param node the node to apply the attribute to + * @param name the local name of the attribute + * @param value the integer value to set the attribute to + */ + public void setGridAttribute(INode node, String name, int value) { + setGridAttribute(node, name, Integer.toString(value)); + } + + /** + * Sets the given GridLayout attribute (rowCount, layout_row, etc) to the + * given value. This automatically handles using the right XML namespace + * based on whether the GridLayout is the android.widget.GridLayout, or the + * support library GridLayout, and whether it's in a library project or not + * etc. + * + * @param node the node to apply the attribute to + * @param name the local name of the attribute + * @param value the string value to set the attribute to, or null to clear + * it + */ + public void setGridAttribute(INode node, String name, String value) { + node.setAttribute(getNamespace(), name, value); + } + + /** + * Returns the namespace URI to use for GridLayout-specific attributes, such + * as columnCount, layout_column, layout_column_span, layout_gravity etc. + * + * @return the namespace, never null + */ + public String getNamespace() { + if (mNamespace == null) { + mNamespace = ANDROID_URI; + + String fqcn = layout.getFqcn(); + if (!fqcn.equals(GRID_LAYOUT) && !fqcn.equals(FQCN_GRID_LAYOUT)) { + mNamespace = mRulesEngine.getAppNameSpace(); + } + } + + return mNamespace; + } + + /** Removes the given flag from a flag attribute value and returns the result */ + static String removeFlag(String flag, String value) { + if (value.equals(flag)) { + return null; + } + // Handle spaces between pipes and flag are a prefix, suffix and interior occurrences + int index = value.indexOf(flag); + if (index != -1) { + int pipe = value.lastIndexOf('|', index); + int endIndex = index + flag.length(); + if (pipe != -1) { + value = value.substring(0, pipe).trim() + value.substring(endIndex).trim(); + } else { + pipe = value.indexOf('|', endIndex); + if (pipe != -1) { + value = value.substring(0, index).trim() + value.substring(pipe + 1).trim(); + } else { + value = value.substring(0, index).trim() + value.substring(endIndex).trim(); + } + } + } + + return value; + } + + /** + * Loads a {@link GridModel} from the XML model. + */ + private void loadFromXml() { + INode[] children = layout.getChildren(); + + declaredRowCount = getGridAttribute(layout, ATTR_ROW_COUNT, UNDEFINED); + declaredColumnCount = getGridAttribute(layout, ATTR_COLUMN_COUNT, UNDEFINED); + // Horizontal is the default, so if no value is specified it is horizontal. + vertical = VALUE_VERTICAL.equals(getGridAttribute(layout, ATTR_ORIENTATION)); + + mChildViews = new ArrayList<ViewData>(children.length); + int index = 0; + for (INode child : children) { + ViewData view = new ViewData(child, index++); + mChildViews.add(view); + } + + // Assign row/column positions to all cells that do not explicitly define them + if (!assignRowsAndColumnsFromViews(mChildViews)) { + assignRowsAndColumnsFromXml( + declaredRowCount == UNDEFINED ? children.length : declaredRowCount, + declaredColumnCount == UNDEFINED ? children.length : declaredColumnCount); + } + + assignCellBounds(); + + for (int i = 0; i <= actualRowCount; i++) { + mBaselines[i] = UNDEFINED; + } + } + + private Pair<Map<Integer, Integer>, Map<Integer, Integer>> findCellsOutsideDeclaredBounds() { + // See if we have any (row,column) pairs that fall outside the declared + // bounds; for these we identify the number of unique values and assign these + // consecutive values + Map<Integer, Integer> extraColumnsMap = null; + Map<Integer, Integer> extraRowsMap = null; + if (declaredRowCount != UNDEFINED) { + Set<Integer> extraRows = null; + for (ViewData view : mChildViews) { + if (view.row >= declaredRowCount) { + if (extraRows == null) { + extraRows = new HashSet<Integer>(); + } + extraRows.add(view.row); + } + } + if (extraRows != null && declaredRowCount != UNDEFINED) { + List<Integer> rows = new ArrayList<Integer>(extraRows); + Collections.sort(rows); + int row = declaredRowCount; + extraRowsMap = new HashMap<Integer, Integer>(); + for (Integer declared : rows) { + extraRowsMap.put(declared, row++); + } + } + } + if (declaredColumnCount != UNDEFINED) { + Set<Integer> extraColumns = null; + for (ViewData view : mChildViews) { + if (view.column >= declaredColumnCount) { + if (extraColumns == null) { + extraColumns = new HashSet<Integer>(); + } + extraColumns.add(view.column); + } + } + if (extraColumns != null && declaredColumnCount != UNDEFINED) { + List<Integer> columns = new ArrayList<Integer>(extraColumns); + Collections.sort(columns); + int column = declaredColumnCount; + extraColumnsMap = new HashMap<Integer, Integer>(); + for (Integer declared : columns) { + extraColumnsMap.put(declared, column++); + } + } + } + + return Pair.of(extraRowsMap, extraColumnsMap); + } + + /** + * Figure out actual row and column numbers for views that do not specify explicit row + * and/or column numbers + * TODO: Consolidate with the algorithm in GridLayout to ensure we get the + * exact same results! + */ + private void assignRowsAndColumnsFromXml(int rowCount, int columnCount) { + Pair<Map<Integer, Integer>, Map<Integer, Integer>> p = findCellsOutsideDeclaredBounds(); + Map<Integer, Integer> extraRowsMap = p.getFirst(); + Map<Integer, Integer> extraColumnsMap = p.getSecond(); + + if (!vertical) { + // Horizontal GridLayout: this is the default. Row and column numbers + // are assigned by assuming that the children are assigned successive + // column numbers until we get to the column count of the grid, at which + // point we jump to the next row. If any cell specifies either an explicit + // row number of column number, we jump to the next available position. + // Note also that if there are any rowspans on the current row, then the + // next row we jump to is below the largest such rowspan - in other words, + // the algorithm does not fill holes in the middle! + + // TODO: Ensure that we don't run into trouble if a later element specifies + // an earlier number... find out what the layout does in that case! + int row = 0; + int column = 0; + int nextRow = 1; + for (ViewData view : mChildViews) { + int declaredColumn = view.column; + if (declaredColumn != UNDEFINED) { + if (declaredColumn >= columnCount) { + assert extraColumnsMap != null; + declaredColumn = extraColumnsMap.get(declaredColumn); + view.column = declaredColumn; + } + if (declaredColumn < column) { + // Must jump to the next row to accommodate the new row + assert nextRow > row; + //row++; + row = nextRow; + } + column = declaredColumn; + } else { + view.column = column; + } + if (view.row != UNDEFINED) { + // TODO: Should this adjust the column number too? (If so must + // also update view.column since we've already processed the local + // column number) + row = view.row; + } else { + view.row = row; + } + + nextRow = Math.max(nextRow, view.row + view.rowSpan); + + // Advance + column += view.columnSpan; + if (column >= columnCount) { + column = 0; + assert nextRow > row; + //row++; + row = nextRow; + } + } + } else { + // Vertical layout: successive children are assigned to the same column in + // successive rows. + int row = 0; + int column = 0; + int nextColumn = 1; + for (ViewData view : mChildViews) { + int declaredRow = view.row; + if (declaredRow != UNDEFINED) { + if (declaredRow >= rowCount) { + declaredRow = extraRowsMap.get(declaredRow); + view.row = declaredRow; + } + if (declaredRow < row) { + // Must jump to the next column to accommodate the new column + assert nextColumn > column; + column = nextColumn; + } + row = declaredRow; + } else { + view.row = row; + } + if (view.column != UNDEFINED) { + // TODO: Should this adjust the row number too? (If so must + // also update view.row since we've already processed the local + // row number) + column = view.column; + } else { + view.column = column; + } + + nextColumn = Math.max(nextColumn, view.column + view.columnSpan); + + // Advance + row += view.rowSpan; + if (row >= rowCount) { + row = 0; + assert nextColumn > column; + //row++; + column = nextColumn; + } + } + } + } + + private static boolean sAttemptSpecReflection = true; + + private boolean assignRowsAndColumnsFromViews(List<ViewData> views) { + if (!sAttemptSpecReflection) { + return false; + } + + try { + // Lazily initialized reflection methods + Field spanField = null; + Field rowSpecField = null; + Field colSpecField = null; + Field minField = null; + Field maxField = null; + Method getLayoutParams = null; + + for (ViewData view : views) { + // TODO: If the element *specifies* anything in XML, use that instead + Object child = mRulesEngine.getViewObject(view.node); + if (child == null) { + // Fallback to XML model + return false; + } + + if (getLayoutParams == null) { + getLayoutParams = child.getClass().getMethod("getLayoutParams"); //$NON-NLS-1$ + } + Object layoutParams = getLayoutParams.invoke(child); + if (rowSpecField == null) { + Class<? extends Object> layoutParamsClass = layoutParams.getClass(); + rowSpecField = layoutParamsClass.getDeclaredField("rowSpec"); //$NON-NLS-1$ + colSpecField = layoutParamsClass.getDeclaredField("columnSpec"); //$NON-NLS-1$ + rowSpecField.setAccessible(true); + colSpecField.setAccessible(true); + } + assert colSpecField != null; + + Object rowSpec = rowSpecField.get(layoutParams); + Object colSpec = colSpecField.get(layoutParams); + if (spanField == null) { + spanField = rowSpec.getClass().getDeclaredField("span"); //$NON-NLS-1$ + spanField.setAccessible(true); + } + assert spanField != null; + Object rowInterval = spanField.get(rowSpec); + Object colInterval = spanField.get(colSpec); + if (minField == null) { + Class<? extends Object> intervalClass = rowInterval.getClass(); + minField = intervalClass.getDeclaredField("min"); //$NON-NLS-1$ + maxField = intervalClass.getDeclaredField("max"); //$NON-NLS-1$ + minField.setAccessible(true); + maxField.setAccessible(true); + } + assert maxField != null; + + int row = minField.getInt(rowInterval); + int col = minField.getInt(colInterval); + int rowEnd = maxField.getInt(rowInterval); + int colEnd = maxField.getInt(colInterval); + + view.column = col; + view.row = row; + view.columnSpan = colEnd - col; + view.rowSpan = rowEnd - row; + } + + return true; + + } catch (Throwable e) { + sAttemptSpecReflection = false; + return false; + } + } + + /** + * Computes the positions of the column and row boundaries + */ + private void assignCellBounds() { + if (!assignCellBoundsFromView()) { + assignCellBoundsFromBounds(); + } + initializeMaxBounds(); + mBaselines = new int[actualRowCount + 1]; + } + + /** + * Computes the positions of the column and row boundaries, using actual + * layout data from the associated GridLayout instance (stored in + * {@link #mViewObject}) + */ + private boolean assignCellBoundsFromView() { + if (mViewObject != null) { + Pair<int[], int[]> cellBounds = GridModel.getAxisBounds(mViewObject); + if (cellBounds != null) { + int[] xs = cellBounds.getFirst(); + int[] ys = cellBounds.getSecond(); + Rect layoutBounds = layout.getBounds(); + + // Handle "blank" grid layouts: insert a fake grid of CELL_COUNT^2 cells + // where the user can do initial placement + if (actualColumnCount <= 1 && actualRowCount <= 1 && mChildViews.isEmpty()) { + final int CELL_COUNT = 1; + xs = new int[CELL_COUNT + 1]; + ys = new int[CELL_COUNT + 1]; + int cellWidth = layoutBounds.w / CELL_COUNT; + int cellHeight = layoutBounds.h / CELL_COUNT; + + for (int i = 0; i <= CELL_COUNT; i++) { + xs[i] = i * cellWidth; + ys[i] = i * cellHeight; + } + } + + actualColumnCount = xs.length - 1; + actualRowCount = ys.length - 1; + + int layoutBoundsX = layoutBounds.x; + int layoutBoundsY = layoutBounds.y; + mLeft = new int[xs.length]; + mTop = new int[ys.length]; + for (int i = 0; i < xs.length; i++) { + mLeft[i] = xs[i] + layoutBoundsX; + } + for (int i = 0; i < ys.length; i++) { + mTop[i] = ys[i] + layoutBoundsY; + } + + return true; + } + } + + return false; + } + + /** + * Computes the boundaries of the rows and columns by considering the bounds of the + * children. + */ + private void assignCellBoundsFromBounds() { + Rect layoutBounds = layout.getBounds(); + + // Compute the actualColumnCount and actualRowCount. This -should- be + // as easy as declaredColumnCount + extraColumnsMap.size(), + // but the user doesn't *have* to declare a column count (or a row count) + // and we need both, so go and find the actual row and column maximums. + int maxColumn = 0; + int maxRow = 0; + for (ViewData view : mChildViews) { + maxColumn = max(maxColumn, view.column); + maxRow = max(maxRow, view.row); + } + actualColumnCount = maxColumn + 1; + actualRowCount = maxRow + 1; + + mLeft = new int[actualColumnCount + 1]; + for (int i = 1; i < actualColumnCount; i++) { + mLeft[i] = UNDEFINED; + } + mLeft[0] = layoutBounds.x; + mLeft[actualColumnCount] = layoutBounds.x2(); + mTop = new int[actualRowCount + 1]; + for (int i = 1; i < actualRowCount; i++) { + mTop[i] = UNDEFINED; + } + mTop[0] = layoutBounds.y; + mTop[actualRowCount] = layoutBounds.y2(); + + for (ViewData view : mChildViews) { + Rect bounds = view.node.getBounds(); + if (!bounds.isValid()) { + continue; + } + int column = view.column; + int row = view.row; + + if (mLeft[column] == UNDEFINED) { + mLeft[column] = bounds.x; + } else { + mLeft[column] = Math.min(bounds.x, mLeft[column]); + } + if (mTop[row] == UNDEFINED) { + mTop[row] = bounds.y; + } else { + mTop[row] = Math.min(bounds.y, mTop[row]); + } + } + + // Ensure that any empty columns/rows have a valid boundary value; for now, + for (int i = actualColumnCount - 1; i >= 0; i--) { + if (mLeft[i] == UNDEFINED) { + if (i == 0) { + mLeft[i] = layoutBounds.x; + } else if (i < actualColumnCount - 1) { + mLeft[i] = mLeft[i + 1] - 1; + if (mLeft[i - 1] != UNDEFINED && mLeft[i] < mLeft[i - 1]) { + mLeft[i] = mLeft[i - 1]; + } + } else { + mLeft[i] = layoutBounds.x2(); + } + } + } + for (int i = actualRowCount - 1; i >= 0; i--) { + if (mTop[i] == UNDEFINED) { + if (i == 0) { + mTop[i] = layoutBounds.y; + } else if (i < actualRowCount - 1) { + mTop[i] = mTop[i + 1] - 1; + if (mTop[i - 1] != UNDEFINED && mTop[i] < mTop[i - 1]) { + mTop[i] = mTop[i - 1]; + } + } else { + mTop[i] = layoutBounds.y2(); + } + } + } + + // The bounds should be in ascending order now + if (false && GridLayoutRule.sDebugGridLayout) { + for (int i = 1; i < actualRowCount; i++) { + assert mTop[i + 1] >= mTop[i]; + } + for (int i = 0; i < actualColumnCount; i++) { + assert mLeft[i + 1] >= mLeft[i]; + } + } + } + + /** + * Determine, for each row and column, what the largest x and y edges are + * within that row or column. This is used to find a natural split point to + * suggest when adding something "to the right of" or "below" another view. + */ + private void initializeMaxBounds() { + mMaxRight = new int[actualColumnCount + 1]; + mMaxBottom = new int[actualRowCount + 1]; + + for (ViewData view : mChildViews) { + Rect bounds = view.node.getBounds(); + if (!bounds.isValid()) { + continue; + } + + if (!view.isSpacer()) { + int x2 = bounds.x2(); + int y2 = bounds.y2(); + int column = view.column; + int row = view.row; + int targetColumn = min(actualColumnCount - 1, + column + view.columnSpan - 1); + int targetRow = min(actualRowCount - 1, row + view.rowSpan - 1); + IViewMetadata metadata = mRulesEngine.getMetadata(view.node.getFqcn()); + if (metadata != null) { + Margins insets = metadata.getInsets(); + if (insets != null) { + x2 -= insets.right; + y2 -= insets.bottom; + } + } + if (mMaxRight[targetColumn] < x2 + && ((view.gravity & (GRAVITY_CENTER_HORIZ | GRAVITY_RIGHT)) == 0)) { + mMaxRight[targetColumn] = x2; + } + if (mMaxBottom[targetRow] < y2 + && ((view.gravity & (GRAVITY_CENTER_VERT | GRAVITY_BOTTOM)) == 0)) { + mMaxBottom[targetRow] = y2; + } + } + } + } + + /** + * Looks up the x[] and y[] locations of the columns and rows in the given GridLayout + * instance. + * + * @param view the GridLayout object, which should already have performed layout + * @return a pair of x[] and y[] integer arrays, or null if it could not be found + */ + public static Pair<int[], int[]> getAxisBounds(Object view) { + try { + Class<?> clz = view.getClass(); + String verticalAxisName = "verticalAxis"; + Field horizontalAxis; + try { + horizontalAxis = clz.getDeclaredField("horizontalAxis"); //$NON-NLS-1$ + } catch (NoSuchFieldException e) { + // Field names changed in KitKat + horizontalAxis = clz.getDeclaredField("mHorizontalAxis"); //$NON-NLS-1$ + verticalAxisName = "mVerticalAxis"; + } + Field verticalAxis = clz.getDeclaredField(verticalAxisName); + horizontalAxis.setAccessible(true); + verticalAxis.setAccessible(true); + Object horizontal = horizontalAxis.get(view); + Object vertical = verticalAxis.get(view); + Field locations = horizontal.getClass().getDeclaredField("locations"); //$NON-NLS-1$ + assert locations.getType().isArray() : locations.getType(); + locations.setAccessible(true); + Object horizontalLocations = locations.get(horizontal); + Object verticalLocations = locations.get(vertical); + int[] xs = (int[]) horizontalLocations; + int[] ys = (int[]) verticalLocations; + return Pair.of(xs, ys); + } catch (Throwable t) { + // Probably trying to show a GridLayout on a platform that does not support it. + // Return null to indicate that the grid bounds must be computed from view bounds. + return null; + } + } + + /** + * Add a new column. + * + * @param selectedChildren if null or empty, add the column at the end of the grid, + * and otherwise add it before the column of the first selected child + * @return the newly added column spacer + */ + public INode addColumn(List<? extends INode> selectedChildren) { + // Determine insert index + int newColumn = actualColumnCount; + if (selectedChildren != null && selectedChildren.size() > 0) { + INode first = selectedChildren.get(0); + ViewData view = getView(first); + newColumn = view.column; + } + + INode newView = addColumn(newColumn, null, UNDEFINED, false, UNDEFINED, UNDEFINED); + if (newView != null) { + mRulesEngine.select(Collections.singletonList(newView)); + } + + return newView; + } + + /** + * Adds a new column. + * + * @param newColumn the column index to insert before + * @param newView the {@link INode} to insert as the column spacer, which may be null + * (in which case a spacer is automatically created) + * @param columnWidthDp the width, in device independent pixels, of the column to be + * added (which may be {@link #UNDEFINED} + * @param split if true, split the existing column into two at the given x position + * @param row the row to add the newView to + * @param x the x position of the column we're inserting + * @return the column spacer + */ + public INode addColumn(int newColumn, INode newView, int columnWidthDp, + boolean split, int row, int x) { + // Insert a new column + actualColumnCount++; + if (declaredColumnCount != UNDEFINED) { + declaredColumnCount++; + setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); + } + + boolean isLastColumn = true; + for (ViewData view : mChildViews) { + if (view.column >= newColumn) { + isLastColumn = false; + break; + } + } + + for (ViewData view : mChildViews) { + boolean columnSpanSet = false; + + int endColumn = view.column + view.columnSpan; + if (view.column >= newColumn || endColumn == newColumn) { + if (view.column == newColumn || endColumn == newColumn) { + //if (view.row == 0) { + if (newView == null && !isLastColumn) { + // Insert a new spacer + int index = getChildIndex(layout.getChildren(), view.node); + assert view.index == index; // TODO: Get rid of getter + if (endColumn == newColumn) { + // This cell -ends- at the desired position: insert it after + index++; + } + + ViewData newViewData = addSpacer(layout, index, + split ? row : UNDEFINED, + split ? newColumn - 1 : UNDEFINED, + columnWidthDp != UNDEFINED ? columnWidthDp : DEFAULT_CELL_WIDTH, + DEFAULT_CELL_HEIGHT); + newViewData.column = newColumn - 1; + newViewData.row = row; + newView = newViewData.node; + } + + // Set the actual row number on the first cell on the new row. + // This means we don't really need the spacer above to imply + // the new row number, but we use the spacer to assign the row + // some height. + if (view.column == newColumn) { + view.column++; + setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); + } // else: endColumn == newColumn: handled below + } else if (getGridAttribute(view.node, ATTR_LAYOUT_COLUMN) != null) { + view.column++; + setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); + } + } else if (endColumn > newColumn) { + view.columnSpan++; + setColumnSpanAttribute(view.node, view.columnSpan); + columnSpanSet = true; + } + + if (split && !columnSpanSet && view.node.getBounds().x2() > x) { + if (view.node.getBounds().x < x) { + view.columnSpan++; + setColumnSpanAttribute(view.node, view.columnSpan); + } + } + } + + // Hardcode the row numbers if the last column is a new column such that + // they don't jump back to backfill the previous row's new last cell + if (isLastColumn) { + for (ViewData view : mChildViews) { + if (view.column == 0 && view.row > 0) { + setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); + } + } + if (split) { + assert newView == null; + addSpacer(layout, -1, row, newColumn -1, + columnWidthDp != UNDEFINED ? columnWidthDp : DEFAULT_CELL_WIDTH, + SPACER_SIZE_DP); + } + } + + return newView; + } + + /** + * Removes the columns containing the given selection + * + * @param selectedChildren a list of nodes whose columns should be deleted + */ + public void removeColumns(List<? extends INode> selectedChildren) { + if (selectedChildren.size() == 0) { + return; + } + + // Figure out which columns should be removed + Set<Integer> removeColumns = new HashSet<Integer>(); + Set<ViewData> removedViews = new HashSet<ViewData>(); + for (INode child : selectedChildren) { + ViewData view = getView(child); + removedViews.add(view); + removeColumns.add(view.column); + } + // Sort them in descending order such that we can process each + // deletion independently + List<Integer> removed = new ArrayList<Integer>(removeColumns); + Collections.sort(removed, Collections.reverseOrder()); + + for (int removedColumn : removed) { + // Remove column. + // First, adjust column count. + // TODO: Don't do this if the column being deleted is outside + // the declared column range! + // TODO: Do this under a write lock? / editXml lock? + actualColumnCount--; + if (declaredColumnCount != UNDEFINED) { + declaredColumnCount--; + } + + // Remove any elements that begin in the deleted columns... + // If they have colspan > 1, then we must insert a spacer instead. + // For any other elements that overlap, we need to subtract from the span. + + for (ViewData view : mChildViews) { + if (view.column == removedColumn) { + int index = getChildIndex(layout.getChildren(), view.node); + assert view.index == index; // TODO: Get rid of getter + if (view.columnSpan > 1) { + // Make a new spacer which is the width of the following + // columns + int columnWidth = getColumnWidth(removedColumn, view.columnSpan) - + getColumnWidth(removedColumn, 1); + int columnWidthDip = mRulesEngine.pxToDp(columnWidth); + ViewData spacer = addSpacer(layout, index, UNDEFINED, UNDEFINED, + columnWidthDip, SPACER_SIZE_DP); + spacer.row = 0; + spacer.column = removedColumn; + } + layout.removeChild(view.node); + } else if (view.column < removedColumn + && view.column + view.columnSpan > removedColumn) { + // Subtract column span to skip this item + view.columnSpan--; + setColumnSpanAttribute(view.node, view.columnSpan); + } else if (view.column > removedColumn) { + view.column--; + if (getGridAttribute(view.node, ATTR_LAYOUT_COLUMN) != null) { + setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); + } + } + } + } + + // Remove children from child list! + if (removedViews.size() <= 2) { + mChildViews.removeAll(removedViews); + } else { + List<ViewData> remaining = + new ArrayList<ViewData>(mChildViews.size() - removedViews.size()); + for (ViewData view : mChildViews) { + if (!removedViews.contains(view)) { + remaining.add(view); + } + } + mChildViews = remaining; + } + + //if (declaredColumnCount != UNDEFINED) { + setGridAttribute(layout, ATTR_COLUMN_COUNT, actualColumnCount); + //} + + } + + /** + * Add a new row. + * + * @param selectedChildren if null or empty, add the row at the bottom of the grid, + * and otherwise add it before the row of the first selected child + * @return the newly added row spacer + */ + public INode addRow(List<? extends INode> selectedChildren) { + // Determine insert index + int newRow = actualRowCount; + if (selectedChildren.size() > 0) { + INode first = selectedChildren.get(0); + ViewData view = getView(first); + newRow = view.row; + } + + INode newView = addRow(newRow, null, UNDEFINED, false, UNDEFINED, UNDEFINED); + if (newView != null) { + mRulesEngine.select(Collections.singletonList(newView)); + } + + return newView; + } + + /** + * Adds a new column. + * + * @param newRow the row index to insert before + * @param newView the {@link INode} to insert as the row spacer, which may be null (in + * which case a spacer is automatically created) + * @param rowHeightDp the height, in device independent pixels, of the row to be added + * (which may be {@link #UNDEFINED} + * @param split if true, split the existing row into two at the given y position + * @param column the column to add the newView to + * @param y the y position of the row we're inserting + * @return the row spacer + */ + public INode addRow(int newRow, INode newView, int rowHeightDp, boolean split, + int column, int y) { + actualRowCount++; + if (declaredRowCount != UNDEFINED) { + declaredRowCount++; + setGridAttribute(layout, ATTR_ROW_COUNT, declaredRowCount); + } + + boolean added = false; + for (ViewData view : mChildViews) { + if (view.row >= newRow) { + // Adjust the column count + if (view.row == newRow && view.column == 0) { + // Insert a new spacer + if (newView == null) { + int index = getChildIndex(layout.getChildren(), view.node); + assert view.index == index; // TODO: Get rid of getter + if (declaredColumnCount != UNDEFINED && !split) { + setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); + } + ViewData newViewData = addSpacer(layout, index, + split ? newRow - 1 : UNDEFINED, + split ? column : UNDEFINED, + SPACER_SIZE_DP, + rowHeightDp != UNDEFINED ? rowHeightDp : DEFAULT_CELL_HEIGHT); + newViewData.column = column; + newViewData.row = newRow - 1; + newView = newViewData.node; + } + + // Set the actual row number on the first cell on the new row. + // This means we don't really need the spacer above to imply + // the new row number, but we use the spacer to assign the row + // some height. + view.row++; + setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); + + added = true; + } else if (getGridAttribute(view.node, ATTR_LAYOUT_ROW) != null) { + view.row++; + setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); + } + } else { + int endRow = view.row + view.rowSpan; + if (endRow > newRow) { + view.rowSpan++; + setRowSpanAttribute(view.node, view.rowSpan); + } else if (split && view.node.getBounds().y2() > y) { + if (view.node.getBounds().y < y) { + view.rowSpan++; + setRowSpanAttribute(view.node, view.rowSpan); + } + } + } + } + + if (!added) { + // Append a row at the end + if (newView == null) { + ViewData newViewData = addSpacer(layout, -1, UNDEFINED, UNDEFINED, + SPACER_SIZE_DP, + rowHeightDp != UNDEFINED ? rowHeightDp : DEFAULT_CELL_HEIGHT); + newViewData.column = column; + // TODO: MAke sure this row number is right! + newViewData.row = split ? newRow - 1 : newRow; + newView = newViewData.node; + } + if (declaredColumnCount != UNDEFINED && !split) { + setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); + } + if (split) { + setGridAttribute(newView, ATTR_LAYOUT_ROW, newRow - 1); + setGridAttribute(newView, ATTR_LAYOUT_COLUMN, column); + } + } + + return newView; + } + + /** + * Removes the rows containing the given selection + * + * @param selectedChildren a list of nodes whose rows should be deleted + */ + public void removeRows(List<? extends INode> selectedChildren) { + if (selectedChildren.size() == 0) { + return; + } + + // Figure out which rows should be removed + Set<ViewData> removedViews = new HashSet<ViewData>(); + Set<Integer> removedRows = new HashSet<Integer>(); + for (INode child : selectedChildren) { + ViewData view = getView(child); + removedViews.add(view); + removedRows.add(view.row); + } + // Sort them in descending order such that we can process each + // deletion independently + List<Integer> removed = new ArrayList<Integer>(removedRows); + Collections.sort(removed, Collections.reverseOrder()); + + for (int removedRow : removed) { + // Remove row. + // First, adjust row count. + // TODO: Don't do this if the row being deleted is outside + // the declared row range! + actualRowCount--; + if (declaredRowCount != UNDEFINED) { + declaredRowCount--; + setGridAttribute(layout, ATTR_ROW_COUNT, declaredRowCount); + } + + // Remove any elements that begin in the deleted rows... + // If they have colspan > 1, then we must hardcode a new row number + // instead. + // For any other elements that overlap, we need to subtract from the span. + + for (ViewData view : mChildViews) { + if (view.row == removedRow) { + // We don't have to worry about a rowSpan > 1 here, because even + // if it is, those rowspans are not used to assign default row/column + // positions for other cells +// TODO: Check this; it differs from the removeColumns logic! + layout.removeChild(view.node); + } else if (view.row > removedRow) { + view.row--; + if (getGridAttribute(view.node, ATTR_LAYOUT_ROW) != null) { + setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); + } + } else if (view.row < removedRow + && view.row + view.rowSpan > removedRow) { + // Subtract row span to skip this item + view.rowSpan--; + setRowSpanAttribute(view.node, view.rowSpan); + } + } + } + + // Remove children from child list! + if (removedViews.size() <= 2) { + mChildViews.removeAll(removedViews); + } else { + List<ViewData> remaining = + new ArrayList<ViewData>(mChildViews.size() - removedViews.size()); + for (ViewData view : mChildViews) { + if (!removedViews.contains(view)) { + remaining.add(view); + } + } + mChildViews = remaining; + } + } + + /** + * Returns the row containing the given y line + * + * @param y the vertical position + * @return the row containing the given line + */ + public int getRow(int y) { + int row = Arrays.binarySearch(mTop, y); + if (row == -1) { + // Smaller than the first element; just use the first row + return 0; + } else if (row < 0) { + row = -(row + 2); + } + + return row; + } + + /** + * Returns the column containing the given x line + * + * @param x the horizontal position + * @return the column containing the given line + */ + public int getColumn(int x) { + int column = Arrays.binarySearch(mLeft, x); + if (column == -1) { + // Smaller than the first element; just use the first column + return 0; + } else if (column < 0) { + column = -(column + 2); + } + + return column; + } + + /** + * Returns the closest row to the given y line. This is + * either the row containing the line, or the row below it. + * + * @param y the vertical position + * @return the closest row + */ + public int getClosestRow(int y) { + int row = Arrays.binarySearch(mTop, y); + if (row == -1) { + // Smaller than the first element; just use the first column + return 0; + } else if (row < 0) { + row = -(row + 2); + } + + if (getRowDistance(row, y) < getRowDistance(row + 1, y)) { + return row; + } else { + return row + 1; + } + } + + /** + * Returns the closest column to the given x line. This is + * either the column containing the line, or the column following it. + * + * @param x the horizontal position + * @return the closest column + */ + public int getClosestColumn(int x) { + int column = Arrays.binarySearch(mLeft, x); + if (column == -1) { + // Smaller than the first element; just use the first column + return 0; + } else if (column < 0) { + column = -(column + 2); + } + + if (getColumnDistance(column, x) < getColumnDistance(column + 1, x)) { + return column; + } else { + return column + 1; + } + } + + /** + * Returns the distance between the given x position and the beginning of the given column + * + * @param column the column + * @param x the x position + * @return the distance between the two + */ + public int getColumnDistance(int column, int x) { + return abs(getColumnX(column) - x); + } + + /** + * Returns the actual width of the given column. This returns the difference between + * the rightmost edge of the views (not including spacers) and the left edge of the + * column. + * + * @param column the column + * @return the actual width of the non-spacer views in the column + */ + public int getColumnActualWidth(int column) { + return getColumnMaxX(column) - getColumnX(column); + } + + /** + * Returns the distance between the given y position and the top of the given row + * + * @param row the row + * @param y the y position + * @return the distance between the two + */ + public int getRowDistance(int row, int y) { + return abs(getRowY(row) - y); + } + + /** + * Returns the y position of the top of the given row + * + * @param row the target row + * @return the y position of its top edge + */ + public int getRowY(int row) { + return mTop[min(mTop.length - 1, max(0, row))]; + } + + /** + * Returns the bottom-most edge of any of the non-spacer children in the given row + * + * @param row the target row + * @return the bottom-most edge of any of the non-spacer children in the row + */ + public int getRowMaxY(int row) { + return mMaxBottom[min(mMaxBottom.length - 1, max(0, row))]; + } + + /** + * Returns the actual height of the given row. This returns the difference between + * the bottom-most edge of the views (not including spacers) and the top edge of the + * row. + * + * @param row the row + * @return the actual height of the non-spacer views in the row + */ + public int getRowActualHeight(int row) { + return getRowMaxY(row) - getRowY(row); + } + + /** + * Returns a list of all the nodes that intersects the rows in the range + * {@code y1 <= y <= y2}. + * + * @param y1 the starting y, inclusive + * @param y2 the ending y, inclusive + * @return a list of nodes intersecting the given rows, never null but possibly empty + */ + public Collection<INode> getIntersectsRow(int y1, int y2) { + List<INode> nodes = new ArrayList<INode>(); + + for (ViewData view : mChildViews) { + if (!view.isSpacer()) { + Rect bounds = view.node.getBounds(); + if (bounds.y2() >= y1 && bounds.y <= y2) { + nodes.add(view.node); + } + } + } + + return nodes; + } + + /** + * Returns the height of the given row or rows (if the rowSpan is greater than 1) + * + * @param row the target row + * @param rowSpan the row span + * @return the height in pixels of the given rows + */ + public int getRowHeight(int row, int rowSpan) { + return getRowY(row + rowSpan) - getRowY(row); + } + + /** + * Returns the x position of the left edge of the given column + * + * @param column the target column + * @return the x position of its left edge + */ + public int getColumnX(int column) { + return mLeft[min(mLeft.length - 1, max(0, column))]; + } + + /** + * Returns the rightmost edge of any of the non-spacer children in the given row + * + * @param column the target column + * @return the rightmost edge of any of the non-spacer children in the column + */ + public int getColumnMaxX(int column) { + return mMaxRight[min(mMaxRight.length - 1, max(0, column))]; + } + + /** + * Returns the width of the given column or columns (if the columnSpan is greater than 1) + * + * @param column the target column + * @param columnSpan the column span + * @return the width in pixels of the given columns + */ + public int getColumnWidth(int column, int columnSpan) { + return getColumnX(column + columnSpan) - getColumnX(column); + } + + /** + * Returns the bounds of the cell at the given row and column position, with the given + * row and column spans. + * + * @param row the target row + * @param column the target column + * @param rowSpan the row span + * @param columnSpan the column span + * @return the bounds, in pixels, of the given cell + */ + public Rect getCellBounds(int row, int column, int rowSpan, int columnSpan) { + return new Rect(getColumnX(column), getRowY(row), + getColumnWidth(column, columnSpan), + getRowHeight(row, rowSpan)); + } + + /** + * Produces a display of view contents along with the pixel positions of each + * row/column, like the following (used for diagnostics only) + * + * <pre> + * |0 |49 |143 |192 |240 + * 36| | |button2 | + * 72| |radioButton1 |button2 | + * 74|button1 |radioButton1 |button2 | + * 108|button1 | |button2 | + * 110| | |button2 | + * 149| | | | + * 320 + * </pre> + */ + @Override + public String toString() { + // Dump out the view table + int cellWidth = 25; + + List<List<List<ViewData>>> rowList = new ArrayList<List<List<ViewData>>>(mTop.length); + for (int row = 0; row < mTop.length; row++) { + List<List<ViewData>> columnList = new ArrayList<List<ViewData>>(mLeft.length); + for (int col = 0; col < mLeft.length; col++) { + columnList.add(new ArrayList<ViewData>(4)); + } + rowList.add(columnList); + } + for (ViewData view : mChildViews) { + for (int i = 0; i < view.rowSpan; i++) { + if (view.row + i > mTop.length) { // Guard against bogus span values + break; + } + if (rowList.size() <= view.row + i) { + break; + } + for (int j = 0; j < view.columnSpan; j++) { + List<List<ViewData>> columnList = rowList.get(view.row + i); + if (columnList.size() <= view.column + j) { + break; + } + columnList.get(view.column + j).add(view); + } + } + } + + StringWriter stringWriter = new StringWriter(); + PrintWriter out = new PrintWriter(stringWriter); + out.printf("%" + cellWidth + "s", ""); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + for (int col = 0; col < actualColumnCount + 1; col++) { + out.printf("|%-" + (cellWidth - 1) + "d", mLeft[col]); //$NON-NLS-1$ //$NON-NLS-2$ + } + out.printf("\n"); //$NON-NLS-1$ + for (int row = 0; row < actualRowCount + 1; row++) { + out.printf("%" + cellWidth + "d", mTop[row]); //$NON-NLS-1$ //$NON-NLS-2$ + if (row == actualRowCount) { + break; + } + for (int col = 0; col < actualColumnCount; col++) { + List<ViewData> views = rowList.get(row).get(col); + + StringBuilder sb = new StringBuilder(); + for (ViewData view : views) { + String id = view != null ? view.getId() : ""; //$NON-NLS-1$ + if (id.startsWith(NEW_ID_PREFIX)) { + id = id.substring(NEW_ID_PREFIX.length()); + } + if (id.length() > cellWidth - 2) { + id = id.substring(0, cellWidth - 2); + } + if (sb.length() > 0) { + sb.append(','); + } + sb.append(id); + } + String cellString = sb.toString(); + if (cellString.contains(",") && cellString.length() > cellWidth - 2) { //$NON-NLS-1$ + cellString = cellString.substring(0, cellWidth - 6) + "...,"; //$NON-NLS-1$ + } + out.printf("|%-" + (cellWidth - 2) + "s ", cellString); //$NON-NLS-1$ //$NON-NLS-2$ + } + out.printf("\n"); //$NON-NLS-1$ + } + + out.flush(); + return stringWriter.toString(); + } + + /** + * Split a cell into two or three columns. + * + * @param newColumn The column number to insert before + * @param insertMarginColumn If false, then the cell at newColumn -1 is split with the + * left part taking up exactly columnWidthDp dips. If true, then the column + * is split twice; the left part is the implicit width of the column, the + * new middle (margin) column is exactly the columnWidthDp size and the + * right column is the remaining space of the old cell. + * @param columnWidthDp The width of the column inserted before the new column (or if + * insertMarginColumn is false, then the width of the margin column) + * @param x the x coordinate of the new column + */ + public void splitColumn(int newColumn, boolean insertMarginColumn, int columnWidthDp, int x) { + actualColumnCount++; + + // Insert a new column + if (declaredColumnCount != UNDEFINED) { + declaredColumnCount++; + if (insertMarginColumn) { + declaredColumnCount++; + } + setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); + } + + // Are we inserting a new last column in the grid? That requires some special handling... + boolean isLastColumn = true; + for (ViewData view : mChildViews) { + if (view.column >= newColumn) { + isLastColumn = false; + break; + } + } + + // Hardcode the row numbers if the last column is a new column such that + // they don't jump back to backfill the previous row's new last cell: + // TODO: Only do this for horizontal layouts! + if (isLastColumn) { + for (ViewData view : mChildViews) { + if (view.column == 0 && view.row > 0) { + if (getGridAttribute(view.node, ATTR_LAYOUT_ROW) == null) { + setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); + } + } + } + } + + // Find the spacer which marks this column, and if found, mark it as a split + ViewData prevColumnSpacer = null; + for (ViewData view : mChildViews) { + if (view.column == newColumn - 1 && view.isColumnSpacer()) { + prevColumnSpacer = view; + break; + } + } + + // Process all existing grid elements: + // * Increase column numbers for all columns that have a hardcoded column number + // greater than the new column + // * Set an explicit column=0 where needed (TODO: Implement this) + // * Increase the columnSpan for all columns that overlap the newly inserted column edge + // * Split the spacer which defined the size of this column into two + // (and if not found, create a new spacer) + // + for (ViewData view : mChildViews) { + if (view == prevColumnSpacer) { + continue; + } + + INode node = view.node; + int column = view.column; + if (column > newColumn || (column == newColumn && view.node.getBounds().x2() > x)) { + // ALWAYS set the column, because + // (1) if it has been set, it needs to be corrected + // (2) if it has not been set, it needs to be set to cause this column + // to skip over the new column (there may be no views for the new + // column on this row). + // TODO: Enhance this such that we only set the column to a skip number + // where necessary, e.g. only on the FIRST view on this row following the + // skipped column! + + //if (getGridAttribute(node, ATTR_LAYOUT_COLUMN) != null) { + view.column += insertMarginColumn ? 2 : 1; + setGridAttribute(node, ATTR_LAYOUT_COLUMN, view.column); + //} + } else if (!view.isSpacer()) { + // Adjust the column span? We must increase it if + // (1) the new column is inside the range [column, column + columnSpan] + // (2) the new column is within the last cell in the column span, + // and the exact X location of the split is within the horizontal + // *bounds* of this node (provided it has gravity=left) + // (3) the new column is within the last cell and the cell has gravity + // right or gravity center + int endColumn = column + view.columnSpan; + if (endColumn > newColumn + || endColumn == newColumn && (view.node.getBounds().x2() > x + || GravityHelper.isConstrainedHorizontally(view.gravity) + && !GravityHelper.isLeftAligned(view.gravity))) { + // This cell spans the new insert position, so increment the column span + view.columnSpan += insertMarginColumn ? 2 : 1; + setColumnSpanAttribute(node, view.columnSpan); + } + } + } + + // Insert new spacer: + if (prevColumnSpacer != null) { + int px = getColumnWidth(newColumn - 1, 1); + if (insertMarginColumn || columnWidthDp == 0) { + px -= getColumnActualWidth(newColumn - 1); + } + int dp = mRulesEngine.pxToDp(px); + int remaining = dp - columnWidthDp; + if (remaining > 0) { + prevColumnSpacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, + String.format(VALUE_N_DP, remaining)); + prevColumnSpacer.column = insertMarginColumn ? newColumn + 1 : newColumn; + setGridAttribute(prevColumnSpacer.node, ATTR_LAYOUT_COLUMN, + prevColumnSpacer.column); + } + } + + if (columnWidthDp > 0) { + int index = prevColumnSpacer != null ? prevColumnSpacer.index : -1; + + addSpacer(layout, index, 0, insertMarginColumn ? newColumn : newColumn - 1, + columnWidthDp, SPACER_SIZE_DP); + } + } + + /** + * Split a cell into two or three rows. + * + * @param newRow The row number to insert before + * @param insertMarginRow If false, then the cell at newRow -1 is split with the above + * part taking up exactly rowHeightDp dips. If true, then the row is split + * twice; the top part is the implicit height of the row, the new middle + * (margin) row is exactly the rowHeightDp size and the bottom column is + * the remaining space of the old cell. + * @param rowHeightDp The height of the row inserted before the new row (or if + * insertMarginRow is false, then the height of the margin row) + * @param y the y coordinate of the new row + */ + public void splitRow(int newRow, boolean insertMarginRow, int rowHeightDp, int y) { + actualRowCount++; + + // Insert a new row + if (declaredRowCount != UNDEFINED) { + declaredRowCount++; + if (insertMarginRow) { + declaredRowCount++; + } + setGridAttribute(layout, ATTR_ROW_COUNT, declaredRowCount); + } + + // Find the spacer which marks this row, and if found, mark it as a split + ViewData prevRowSpacer = null; + for (ViewData view : mChildViews) { + if (view.row == newRow - 1 && view.isRowSpacer()) { + prevRowSpacer = view; + break; + } + } + + // Se splitColumn() for details + for (ViewData view : mChildViews) { + if (view == prevRowSpacer) { + continue; + } + + INode node = view.node; + int row = view.row; + if (row > newRow || (row == newRow && view.node.getBounds().y2() > y)) { + //if (getGridAttribute(node, ATTR_LAYOUT_ROW) != null) { + view.row += insertMarginRow ? 2 : 1; + setGridAttribute(node, ATTR_LAYOUT_ROW, view.row); + //} + } else if (!view.isSpacer()) { + int endRow = row + view.rowSpan; + if (endRow > newRow + || endRow == newRow && (view.node.getBounds().y2() > y + || GravityHelper.isConstrainedVertically(view.gravity) + && !GravityHelper.isTopAligned(view.gravity))) { + // This cell spans the new insert position, so increment the row span + view.rowSpan += insertMarginRow ? 2 : 1; + setRowSpanAttribute(node, view.rowSpan); + } + } + } + + // Insert new spacer: + if (prevRowSpacer != null) { + int px = getRowHeight(newRow - 1, 1); + if (insertMarginRow || rowHeightDp == 0) { + px -= getRowActualHeight(newRow - 1); + } + int dp = mRulesEngine.pxToDp(px); + int remaining = dp - rowHeightDp; + if (remaining > 0) { + prevRowSpacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, + String.format(VALUE_N_DP, remaining)); + prevRowSpacer.row = insertMarginRow ? newRow + 1 : newRow; + setGridAttribute(prevRowSpacer.node, ATTR_LAYOUT_ROW, prevRowSpacer.row); + } + } + + if (rowHeightDp > 0) { + int index = prevRowSpacer != null ? prevRowSpacer.index : -1; + addSpacer(layout, index, insertMarginRow ? newRow : newRow - 1, + 0, SPACER_SIZE_DP, rowHeightDp); + } + } + + /** + * Data about a view in a table; this is not the same as a cell because multiple views + * can share a single cell, and a view can span many cells. + */ + public class ViewData { + public final INode node; + public final int index; + public int row; + public int column; + public int rowSpan; + public int columnSpan; + public int gravity; + + ViewData(INode n, int index) { + node = n; + this.index = index; + + column = getGridAttribute(n, ATTR_LAYOUT_COLUMN, UNDEFINED); + columnSpan = getGridAttribute(n, ATTR_LAYOUT_COLUMN_SPAN, 1); + row = getGridAttribute(n, ATTR_LAYOUT_ROW, UNDEFINED); + rowSpan = getGridAttribute(n, ATTR_LAYOUT_ROW_SPAN, 1); + gravity = GravityHelper.getGravity(getGridAttribute(n, ATTR_LAYOUT_GRAVITY), 0); + } + + /** Applies the column and row fields into the XML model */ + void applyPositionAttributes() { + setGridAttribute(node, ATTR_LAYOUT_COLUMN, column); + setGridAttribute(node, ATTR_LAYOUT_ROW, row); + } + + /** Returns the id of this node, or makes one up for display purposes */ + String getId() { + String id = node.getStringAttr(ANDROID_URI, ATTR_ID); + if (id == null) { + id = "<unknownid>"; //$NON-NLS-1$ + String fqn = node.getFqcn(); + fqn = fqn.substring(fqn.lastIndexOf('.') + 1); + id = fqn + "-" + + Integer.toString(System.identityHashCode(node)).substring(0, 3); + } + + return id; + } + + /** Returns true if this {@link ViewData} represents a spacer */ + boolean isSpacer() { + return isSpace(node.getFqcn()); + } + + /** + * Returns true if this {@link ViewData} represents a column spacer + */ + boolean isColumnSpacer() { + return isSpacer() && + // Any spacer not found in column 0 is a column spacer since we + // place all horizontal spacers in column 0 + ((column > 0) + // TODO: Find a cleaner way. Maybe set ids on the elements in (0,0) and + // for column distinguish by id. Or at least only do this for column 0! + || !SPACER_SIZE.equals(node.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH))); + } + + /** + * Returns true if this {@link ViewData} represents a row spacer + */ + boolean isRowSpacer() { + return isSpacer() && + // Any spacer not found in row 0 is a row spacer since we + // place all vertical spacers in row 0 + ((row > 0) + // TODO: Find a cleaner way. Maybe set ids on the elements in (0,0) and + // for column distinguish by id. Or at least only do this for column 0! + || !SPACER_SIZE.equals(node.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT))); + } + } + + /** + * Sets the column span of the given node to the given value (or if the value is 1, + * removes it) + * + * @param node the target node + * @param span the new column span + */ + public void setColumnSpanAttribute(INode node, int span) { + setGridAttribute(node, ATTR_LAYOUT_COLUMN_SPAN, span > 1 ? Integer.toString(span) : null); + } + + /** + * Sets the row span of the given node to the given value (or if the value is 1, + * removes it) + * + * @param node the target node + * @param span the new row span + */ + public void setRowSpanAttribute(INode node, int span) { + setGridAttribute(node, ATTR_LAYOUT_ROW_SPAN, span > 1 ? Integer.toString(span) : null); + } + + /** Returns the index of the given target node in the given child node array */ + static int getChildIndex(INode[] children, INode target) { + int index = 0; + for (INode child : children) { + if (child == target) { + return index; + } + index++; + } + + return -1; + } + + /** + * Update the model to account for the given nodes getting deleted. The nodes + * are not actually deleted by this method; that is assumed to be performed by the + * caller. Instead this method performs whatever model updates are necessary to + * preserve the grid structure. + * + * @param nodes the nodes to be deleted + */ + public void onDeleted(@NonNull List<INode> nodes) { + if (nodes.size() == 0) { + return; + } + + // Attempt to clean up spacer objects for any newly-empty rows or columns + // as the result of this deletion + + Set<INode> deleted = new HashSet<INode>(); + + for (INode child : nodes) { + // We don't care about deletion of spacers + String fqcn = child.getFqcn(); + if (fqcn.equals(FQCN_SPACE) || fqcn.equals(FQCN_SPACE_V7)) { + continue; + } + deleted.add(child); + } + + Set<Integer> usedColumns = new HashSet<Integer>(actualColumnCount); + Set<Integer> usedRows = new HashSet<Integer>(actualRowCount); + Multimap<Integer, ViewData> columnSpacers = ArrayListMultimap.create(actualColumnCount, 2); + Multimap<Integer, ViewData> rowSpacers = ArrayListMultimap.create(actualRowCount, 2); + Set<ViewData> removedViews = new HashSet<ViewData>(); + + for (ViewData view : mChildViews) { + if (deleted.contains(view.node)) { + removedViews.add(view); + } else if (view.isColumnSpacer()) { + columnSpacers.put(view.column, view); + } else if (view.isRowSpacer()) { + rowSpacers.put(view.row, view); + } else { + usedColumns.add(Integer.valueOf(view.column)); + usedRows.add(Integer.valueOf(view.row)); + } + } + + if (usedColumns.size() == 0 || usedRows.size() == 0) { + // No more views - just remove all the spacers + for (ViewData spacer : columnSpacers.values()) { + layout.removeChild(spacer.node); + } + for (ViewData spacer : rowSpacers.values()) { + layout.removeChild(spacer.node); + } + mChildViews.clear(); + actualColumnCount = 0; + declaredColumnCount = 2; + actualRowCount = 0; + declaredRowCount = UNDEFINED; + setGridAttribute(layout, ATTR_COLUMN_COUNT, 2); + + return; + } + + // Determine columns to introduce spacers into: + // This is tricky; I should NOT combine spacers if there are cells tied to + // individual ones + + // TODO: Invalidate column sizes too! Otherwise repeated updates might get confused! + // Similarly, inserts need to do the same! + + // Produce map of old column numbers to new column numbers + // Collapse regions of consecutive space and non-space ranges together + int[] columnMap = new int[actualColumnCount + 1]; // +1: Easily handle columnSpans as well + int newColumn = 0; + boolean prevUsed = usedColumns.contains(0); + for (int column = 1; column < actualColumnCount; column++) { + boolean used = usedColumns.contains(column); + if (used || prevUsed != used) { + newColumn++; + prevUsed = used; + } + columnMap[column] = newColumn; + } + newColumn++; + columnMap[actualColumnCount] = newColumn; + assert columnMap[0] == 0; + + int[] rowMap = new int[actualRowCount + 1]; // +1: Easily handle rowSpans as well + int newRow = 0; + prevUsed = usedRows.contains(0); + for (int row = 1; row < actualRowCount; row++) { + boolean used = usedRows.contains(row); + if (used || prevUsed != used) { + newRow++; + prevUsed = used; + } + rowMap[row] = newRow; + } + newRow++; + rowMap[actualRowCount] = newRow; + assert rowMap[0] == 0; + + + // Adjust column and row numbers to account for deletions: for a given cell, if it + // is to the right of a deleted column, reduce its column number, and if it only + // spans across the deleted column, reduce its column span. + for (ViewData view : mChildViews) { + if (removedViews.contains(view)) { + continue; + } + int newColumnStart = columnMap[Math.min(columnMap.length - 1, view.column)]; + // Gracefully handle rogue/invalid columnSpans in the XML + int newColumnEnd = columnMap[Math.min(columnMap.length - 1, + view.column + view.columnSpan)]; + if (newColumnStart != view.column) { + view.column = newColumnStart; + setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); + } + + int columnSpan = newColumnEnd - newColumnStart; + if (columnSpan != view.columnSpan) { + if (columnSpan >= 1) { + view.columnSpan = columnSpan; + setColumnSpanAttribute(view.node, view.columnSpan); + } // else: merging spacing columns together + } + + + int newRowStart = rowMap[Math.min(rowMap.length - 1, view.row)]; + int newRowEnd = rowMap[Math.min(rowMap.length - 1, view.row + view.rowSpan)]; + if (newRowStart != view.row) { + view.row = newRowStart; + setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); + } + + int rowSpan = newRowEnd - newRowStart; + if (rowSpan != view.rowSpan) { + if (rowSpan >= 1) { + view.rowSpan = rowSpan; + setRowSpanAttribute(view.node, view.rowSpan); + } // else: merging spacing rows together + } + } + + // Merge spacers (and add spacers for newly empty columns) + int start = 0; + while (start < actualColumnCount) { + // Find next unused span + while (start < actualColumnCount && usedColumns.contains(start)) { + start++; + } + if (start == actualColumnCount) { + break; + } + assert !usedColumns.contains(start); + // Find the next span of unused columns and produce a SINGLE + // spacer for that range (unless it's a zero-sized columns) + int end = start + 1; + for (; end < actualColumnCount; end++) { + if (usedColumns.contains(end)) { + break; + } + } + + // Add up column sizes + int width = getColumnWidth(start, end - start); + + // Find all spacers: the first one found should be moved to the start column + // and assigned to the full height of the columns, and + // the column count reduced by the corresponding amount + + // TODO: if width = 0, fully remove + + boolean isFirstSpacer = true; + for (int column = start; column < end; column++) { + Collection<ViewData> spacers = columnSpacers.get(column); + if (spacers != null && !spacers.isEmpty()) { + // Avoid ConcurrentModificationException since we're inserting into the + // map within this loop (always at a different index, but the map doesn't + // know that) + spacers = new ArrayList<ViewData>(spacers); + for (ViewData spacer : spacers) { + if (isFirstSpacer) { + isFirstSpacer = false; + spacer.column = columnMap[start]; + setGridAttribute(spacer.node, ATTR_LAYOUT_COLUMN, spacer.column); + if (end - start > 1) { + // Compute a merged width for all the spacers (not needed if + // there's just one spacer; it should already have the correct width) + int columnWidthDp = mRulesEngine.pxToDp(width); + spacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, + String.format(VALUE_N_DP, columnWidthDp)); + } + columnSpacers.put(start, spacer); + } else { + removedViews.add(spacer); // Mark for model removal + layout.removeChild(spacer.node); + } + } + } + } + + if (isFirstSpacer) { + // No spacer: create one + int columnWidthDp = mRulesEngine.pxToDp(width); + addSpacer(layout, -1, UNDEFINED, columnMap[start], columnWidthDp, DEFAULT_CELL_HEIGHT); + } + + start = end; + } + actualColumnCount = newColumn; +//if (usedColumns.contains(newColumn)) { +// // TODO: This may be totally wrong for right aligned content! +// actualColumnCount++; +//} + + // Merge spacers for rows + start = 0; + while (start < actualRowCount) { + // Find next unused span + while (start < actualRowCount && usedRows.contains(start)) { + start++; + } + if (start == actualRowCount) { + break; + } + assert !usedRows.contains(start); + // Find the next span of unused rows and produce a SINGLE + // spacer for that range (unless it's a zero-sized rows) + int end = start + 1; + for (; end < actualRowCount; end++) { + if (usedRows.contains(end)) { + break; + } + } + + // Add up row sizes + int height = getRowHeight(start, end - start); + + // Find all spacers: the first one found should be moved to the start row + // and assigned to the full height of the rows, and + // the row count reduced by the corresponding amount + + // TODO: if width = 0, fully remove + + boolean isFirstSpacer = true; + for (int row = start; row < end; row++) { + Collection<ViewData> spacers = rowSpacers.get(row); + if (spacers != null && !spacers.isEmpty()) { + // Avoid ConcurrentModificationException since we're inserting into the + // map within this loop (always at a different index, but the map doesn't + // know that) + spacers = new ArrayList<ViewData>(spacers); + for (ViewData spacer : spacers) { + if (isFirstSpacer) { + isFirstSpacer = false; + spacer.row = rowMap[start]; + setGridAttribute(spacer.node, ATTR_LAYOUT_ROW, spacer.row); + if (end - start > 1) { + // Compute a merged width for all the spacers (not needed if + // there's just one spacer; it should already have the correct height) + int rowHeightDp = mRulesEngine.pxToDp(height); + spacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, + String.format(VALUE_N_DP, rowHeightDp)); + } + rowSpacers.put(start, spacer); + } else { + removedViews.add(spacer); // Mark for model removal + layout.removeChild(spacer.node); + } + } + } + } + + if (isFirstSpacer) { + // No spacer: create one + int rowWidthDp = mRulesEngine.pxToDp(height); + addSpacer(layout, -1, rowMap[start], UNDEFINED, DEFAULT_CELL_WIDTH, rowWidthDp); + } + + start = end; + } + actualRowCount = newRow; +// if (usedRows.contains(newRow)) { +// actualRowCount++; +// } + + // Update the model: remove removed children from the view data list + if (removedViews.size() <= 2) { + mChildViews.removeAll(removedViews); + } else { + List<ViewData> remaining = + new ArrayList<ViewData>(mChildViews.size() - removedViews.size()); + for (ViewData view : mChildViews) { + if (!removedViews.contains(view)) { + remaining.add(view); + } + } + mChildViews = remaining; + } + + // Update the final column and row declared attributes + if (declaredColumnCount != UNDEFINED) { + declaredColumnCount = actualColumnCount; + setGridAttribute(layout, ATTR_COLUMN_COUNT, actualColumnCount); + } + if (declaredRowCount != UNDEFINED) { + declaredRowCount = actualRowCount; + setGridAttribute(layout, ATTR_ROW_COUNT, actualRowCount); + } + } + + /** + * Adds a spacer to the given parent, at the given index. + * + * @param parent the GridLayout + * @param index the index to insert the spacer at, or -1 to append + * @param row the row to add the spacer to (or {@link #UNDEFINED} to not set a row yet + * @param column the column to add the spacer to (or {@link #UNDEFINED} to not set a + * column yet + * @param widthDp the width in device independent pixels to assign to the spacer + * @param heightDp the height in device independent pixels to assign to the spacer + * @return the newly added spacer + */ + ViewData addSpacer(INode parent, int index, int row, int column, + int widthDp, int heightDp) { + INode spacer; + + String tag = FQCN_SPACE; + String gridLayout = parent.getFqcn(); + if (!gridLayout.equals(GRID_LAYOUT) && gridLayout.length() > GRID_LAYOUT.length()) { + String pkg = gridLayout.substring(0, gridLayout.length() - GRID_LAYOUT.length()); + tag = pkg + SPACE; + } + if (index != -1) { + spacer = parent.insertChildAt(tag, index); + } else { + spacer = parent.appendChild(tag); + } + + ViewData view = new ViewData(spacer, index != -1 ? index : mChildViews.size()); + mChildViews.add(view); + + if (row != UNDEFINED) { + view.row = row; + setGridAttribute(spacer, ATTR_LAYOUT_ROW, row); + } + if (column != UNDEFINED) { + view.column = column; + setGridAttribute(spacer, ATTR_LAYOUT_COLUMN, column); + } + if (widthDp > 0) { + spacer.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, + String.format(VALUE_N_DP, widthDp)); + } + if (heightDp > 0) { + spacer.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, + String.format(VALUE_N_DP, heightDp)); + } + + // Temporary hack + if (GridLayoutRule.sDebugGridLayout) { + //String id = NEW_ID_PREFIX + "s"; + //if (row == 0) { + // id += "c"; + //} + //if (column == 0) { + // id += "r"; + //} + //if (row > 0) { + // id += Integer.toString(row); + //} + //if (column > 0) { + // id += Integer.toString(column); + //} + String id = NEW_ID_PREFIX + "spacer_" //$NON-NLS-1$ + + Integer.toString(System.identityHashCode(spacer)).substring(0, 3); + spacer.setAttribute(ANDROID_URI, ATTR_ID, id); + } + + + return view; + } + + /** + * Returns the string value of the given attribute, or null if it does not + * exist. This only works for attributes that are GridLayout specific, such + * as columnCount, layout_column, layout_row_span, etc. + * + * @param node the target node + * @param name the attribute name (which must be in the android: namespace) + * @return the attribute value or null + */ + + public String getGridAttribute(INode node, String name) { + return node.getStringAttr(getNamespace(), name); + } + + /** + * Returns the integer value of the given attribute, or the given defaultValue if the + * attribute was not set. This only works for attributes that are GridLayout specific, + * such as columnCount, layout_column, layout_row_span, etc. + * + * @param node the target node + * @param attribute the attribute name (which must be in the android: namespace) + * @param defaultValue the default value to use if the value is not set + * @return the attribute integer value + */ + private int getGridAttribute(INode node, String attribute, int defaultValue) { + String valueString = node.getStringAttr(getNamespace(), attribute); + if (valueString != null) { + try { + return Integer.decode(valueString); + } catch (NumberFormatException nufe) { + // Ignore - error in user's XML + } + } + + return defaultValue; + } + + /** + * Returns the number of children views in the GridLayout + * + * @return the number of children views in the GridLayout + */ + public int getViewCount() { + return mChildViews.size(); + } + + /** + * Returns true if the given class name represents a spacer + * + * @param fqcn the fully qualified class name + * @return true if this is a spacer + */ + public static boolean isSpace(String fqcn) { + return FQCN_SPACE.equals(fqcn) || FQCN_SPACE_V7.equals(fqcn); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/gridmode.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/gridmode.png Binary files differnew file mode 100644 index 000000000..59e0a4511 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/gridmode.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/hlinear.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/hlinear.png Binary files differnew file mode 100644 index 000000000..b293fe7c5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/hlinear.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/margins.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/margins.png Binary files differnew file mode 100644 index 000000000..b0d814116 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/margins.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ConstraintPainter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ConstraintPainter.java new file mode 100644 index 000000000..447d2d880 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ConstraintPainter.java @@ -0,0 +1,783 @@ +/* + * 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.common.layout.relative; + +import static com.android.ide.common.api.DrawingStyle.DEPENDENCY; +import static com.android.ide.common.api.DrawingStyle.GUIDELINE; +import static com.android.ide.common.api.DrawingStyle.GUIDELINE_DASHED; +import static com.android.ide.common.api.SegmentType.BASELINE; +import static com.android.ide.common.api.SegmentType.BOTTOM; +import static com.android.ide.common.api.SegmentType.CENTER_HORIZONTAL; +import static com.android.ide.common.api.SegmentType.CENTER_VERTICAL; +import static com.android.ide.common.api.SegmentType.LEFT; +import static com.android.ide.common.api.SegmentType.RIGHT; +import static com.android.ide.common.api.SegmentType.TOP; +import static com.android.ide.common.api.SegmentType.UNKNOWN; +import static com.android.ide.common.layout.relative.ConstraintType.ALIGN_BASELINE; +import static com.android.ide.common.layout.relative.ConstraintType.ALIGN_BOTTOM; +import static com.android.ide.common.layout.relative.ConstraintType.LAYOUT_ABOVE; +import static com.android.ide.common.layout.relative.ConstraintType.LAYOUT_BELOW; +import static com.android.ide.common.layout.relative.ConstraintType.LAYOUT_LEFT_OF; +import static com.android.ide.common.layout.relative.ConstraintType.LAYOUT_RIGHT_OF; + +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.relative.DependencyGraph.Constraint; +import com.android.ide.common.layout.relative.DependencyGraph.ViewData; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The {@link ConstraintPainter} is responsible for painting relative layout constraints - + * such as a source node having its top edge constrained to a target node with a given margin. + * This painter is used both to show static constraints, as well as visualizing proposed + * constraints during a move or resize operation. + */ +public class ConstraintPainter { + /** The size of the arrow head */ + private static final int ARROW_SIZE = 5; + /** Size (height for horizontal, and width for vertical) parent feedback rectangles */ + private static final int PARENT_RECT_SIZE = 12; + + /** + * Paints a given match as a constraint. + * + * @param graphics the graphics context + * @param sourceBounds the source bounds + * @param match the match + */ + static void paintConstraint(IGraphics graphics, Rect sourceBounds, Match match) { + Rect targetBounds = match.edge.node.getBounds(); + ConstraintType type = match.type; + assert type != null; + paintConstraint(graphics, type, match.with.node, sourceBounds, match.edge.node, + targetBounds, null /* allConstraints */, true /* highlightTargetEdge */); + } + + /** + * Paints a constraint. + * <p> + * TODO: when there are multiple links originating in the same direction from + * center, maybe offset them slightly from each other? + * + * @param graphics the graphics context to draw into + * @param constraint The constraint to be drawn + */ + private static void paintConstraint(IGraphics graphics, Constraint constraint, + Set<Constraint> allConstraints) { + ViewData source = constraint.from; + ViewData target = constraint.to; + + INode sourceNode = source.node; + INode targetNode = target.node; + if (sourceNode == targetNode) { + // Self reference - don't visualize + return; + } + + Rect sourceBounds = sourceNode.getBounds(); + Rect targetBounds = targetNode.getBounds(); + paintConstraint(graphics, constraint.type, sourceNode, sourceBounds, targetNode, + targetBounds, allConstraints, false /* highlightTargetEdge */); + } + + /** + * Paint selection feedback by painting constraints for the selected nodes + * + * @param graphics the graphics context + * @param parentNode the parent relative layout + * @param childNodes the nodes whose constraints should be painted + * @param showDependents whether incoming constraints should be shown as well + */ + public static void paintSelectionFeedback(IGraphics graphics, INode parentNode, + List<? extends INode> childNodes, boolean showDependents) { + + DependencyGraph dependencyGraph = new DependencyGraph(parentNode); + Set<INode> horizontalDeps = dependencyGraph.dependsOn(childNodes, false /* vertical */); + Set<INode> verticalDeps = dependencyGraph.dependsOn(childNodes, true /* vertical */); + Set<INode> deps = new HashSet<INode>(horizontalDeps.size() + verticalDeps.size()); + deps.addAll(horizontalDeps); + deps.addAll(verticalDeps); + if (deps.size() > 0) { + graphics.useStyle(DEPENDENCY); + for (INode node : deps) { + // Don't highlight the selected nodes themselves + if (childNodes.contains(node)) { + continue; + } + Rect bounds = node.getBounds(); + graphics.fillRect(bounds); + } + } + + graphics.useStyle(GUIDELINE); + for (INode childNode : childNodes) { + ViewData view = dependencyGraph.getView(childNode); + if (view == null) { + continue; + } + + // Paint all incoming constraints + if (showDependents) { + paintConstraints(graphics, view.dependedOnBy); + } + + // Paint all outgoing constraints + paintConstraints(graphics, view.dependsOn); + } + } + + /** + * Paints a set of constraints. + */ + private static void paintConstraints(IGraphics graphics, List<Constraint> constraints) { + Set<Constraint> mutableConstraintSet = new HashSet<Constraint>(constraints); + + // WORKAROUND! Hide alignBottom attachments if we also have a alignBaseline + // constraint; this is because we also *add* alignBottom attachments when you add + // alignBaseline constraints to work around a surprising behavior of baseline + // constraints. + for (Constraint constraint : constraints) { + if (constraint.type == ALIGN_BASELINE) { + // Remove any baseline + for (Constraint c : constraints) { + if (c.type == ALIGN_BOTTOM && c.to.node == constraint.to.node) { + mutableConstraintSet.remove(c); + } + } + } + } + + for (Constraint constraint : constraints) { + // paintConstraint can digest more than one constraint, so we need to keep + // checking to see if the given constraint is still relevant. + if (mutableConstraintSet.contains(constraint)) { + paintConstraint(graphics, constraint, mutableConstraintSet); + } + } + } + + /** + * Paints a constraint of the given type from the given source node, to the + * given target node, with the specified bounds. + */ + private static void paintConstraint(IGraphics graphics, ConstraintType type, INode sourceNode, + Rect sourceBounds, INode targetNode, Rect targetBounds, + Set<Constraint> allConstraints, boolean highlightTargetEdge) { + + SegmentType sourceSegmentTypeX = type.sourceSegmentTypeX; + SegmentType sourceSegmentTypeY = type.sourceSegmentTypeY; + SegmentType targetSegmentTypeX = type.targetSegmentTypeX; + SegmentType targetSegmentTypeY = type.targetSegmentTypeY; + + // Horizontal center constraint? + if (sourceSegmentTypeX == CENTER_VERTICAL && targetSegmentTypeX == CENTER_VERTICAL) { + paintHorizontalCenterConstraint(graphics, sourceBounds, targetBounds); + return; + } + + // Vertical center constraint? + if (sourceSegmentTypeY == CENTER_HORIZONTAL && targetSegmentTypeY == CENTER_HORIZONTAL) { + paintVerticalCenterConstraint(graphics, sourceBounds, targetBounds); + return; + } + + // Corner constraint? + if (allConstraints != null + && (type == LAYOUT_ABOVE || type == LAYOUT_BELOW + || type == LAYOUT_LEFT_OF || type == LAYOUT_RIGHT_OF)) { + if (paintCornerConstraint(graphics, type, sourceNode, sourceBounds, targetNode, + targetBounds, allConstraints)) { + return; + } + } + + // Vertical constraint? + if (sourceSegmentTypeX == UNKNOWN) { + paintVerticalConstraint(graphics, type, sourceNode, sourceBounds, targetNode, + targetBounds, highlightTargetEdge); + return; + } + + // Horizontal constraint? + if (sourceSegmentTypeY == UNKNOWN) { + paintHorizontalConstraint(graphics, type, sourceNode, sourceBounds, targetNode, + targetBounds, highlightTargetEdge); + return; + } + + // This shouldn't happen - it means we have a constraint that defines all sides + // and is not a centering constraint + assert false; + } + + /** + * Paints a corner constraint, or returns false if this constraint is not a corner. + * A corner is one where there are two constraints from this source node to the + * same target node, one horizontal and one vertical, to the closest edges on + * the target node. + * <p> + * Corners are a common occurrence. If we treat the horizontal and vertical + * constraints separately (below & toRightOf), then we end up with a lot of + * extra lines and arrows -- e.g. two shared edges and arrows pointing to these + * shared edges: + * + * <pre> + * +--------+ | + * | Target --> + * +----|---+ | + * v + * - - - - - -|- - - - - - + * ^ + * | +---|----+ + * <-- Source | + * | +--------+ + * + * Instead, we can simply draw a diagonal arrow here to represent BOTH constraints and + * reduce clutter: + * + * +---------+ + * | Target _| + * +-------|\+ + * \ + * \--------+ + * | Source | + * +--------+ + * </pre> + * + * @param graphics the graphics context to draw + * @param type the constraint to be drawn + * @param sourceNode the source node + * @param sourceBounds the bounds of the source node + * @param targetNode the target node + * @param targetBounds the bounds of the target node + * @param allConstraints the set of all constraints; if a corner is found and painted the + * matching corner constraint is removed from the set + * @return true if the constraint was handled and painted as a corner, false otherwise + */ + private static boolean paintCornerConstraint(IGraphics graphics, ConstraintType type, + INode sourceNode, Rect sourceBounds, INode targetNode, Rect targetBounds, + Set<Constraint> allConstraints) { + + SegmentType sourceSegmentTypeX = type.sourceSegmentTypeX; + SegmentType sourceSegmentTypeY = type.sourceSegmentTypeY; + SegmentType targetSegmentTypeX = type.targetSegmentTypeX; + SegmentType targetSegmentTypeY = type.targetSegmentTypeY; + + ConstraintType opposite1 = null, opposite2 = null; + switch (type) { + case LAYOUT_BELOW: + case LAYOUT_ABOVE: + opposite1 = LAYOUT_LEFT_OF; + opposite2 = LAYOUT_RIGHT_OF; + break; + case LAYOUT_LEFT_OF: + case LAYOUT_RIGHT_OF: + opposite1 = LAYOUT_ABOVE; + opposite2 = LAYOUT_BELOW; + break; + default: + return false; + } + Constraint pair = null; + for (Constraint constraint : allConstraints) { + if ((constraint.type == opposite1 || constraint.type == opposite2) && + constraint.to.node == targetNode && constraint.from.node == sourceNode) { + pair = constraint; + break; + } + } + + // TODO -- ensure that the nodes are adjacent! In other words, that + // their bounds are within N pixels. + + if (pair != null) { + // Visualize the corner constraint + if (sourceSegmentTypeX == UNKNOWN) { + sourceSegmentTypeX = pair.type.sourceSegmentTypeX; + } + if (sourceSegmentTypeY == UNKNOWN) { + sourceSegmentTypeY = pair.type.sourceSegmentTypeY; + } + if (targetSegmentTypeX == UNKNOWN) { + targetSegmentTypeX = pair.type.targetSegmentTypeX; + } + if (targetSegmentTypeY == UNKNOWN) { + targetSegmentTypeY = pair.type.targetSegmentTypeY; + } + + int x1, y1, x2, y2; + if (sourceSegmentTypeX == LEFT) { + x1 = sourceBounds.x + 1 * sourceBounds.w / 4; + } else { + x1 = sourceBounds.x + 3 * sourceBounds.w / 4; + } + if (sourceSegmentTypeY == TOP) { + y1 = sourceBounds.y + 1 * sourceBounds.h / 4; + } else { + y1 = sourceBounds.y + 3 * sourceBounds.h / 4; + } + if (targetSegmentTypeX == LEFT) { + x2 = targetBounds.x + 1 * targetBounds.w / 4; + } else { + x2 = targetBounds.x + 3 * targetBounds.w / 4; + } + if (targetSegmentTypeY == TOP) { + y2 = targetBounds.y + 1 * targetBounds.h / 4; + } else { + y2 = targetBounds.y + 3 * targetBounds.h / 4; + } + + graphics.useStyle(GUIDELINE); + graphics.drawArrow(x1, y1, x2, y2, ARROW_SIZE); + + // Don't process this constraint on its own later. + allConstraints.remove(pair); + + return true; + } + + return false; + } + + /** + * Paints a vertical constraint, handling the various scenarios where there are + * margins, or where the two nodes overlap horizontally and where they don't, etc. + * <p> + * Here's an example of what will be shown for a "below" constraint where the + * nodes do not overlap horizontally and the target node has a bottom margin: + * <pre> + * +--------+ + * | Target | + * +--------+ + * | + * v + * - - - - - - - - - - - - - - + * ^ + * | + * +--------+ + * | Source | + * +--------+ + * </pre> + */ + private static void paintVerticalConstraint(IGraphics graphics, ConstraintType type, + INode sourceNode, Rect sourceBounds, INode targetNode, Rect targetBounds, + boolean highlightTargetEdge) { + SegmentType sourceSegmentTypeY = type.sourceSegmentTypeY; + SegmentType targetSegmentTypeY = type.targetSegmentTypeY; + Margins targetMargins = targetNode.getMargins(); + + assert sourceSegmentTypeY != UNKNOWN; + assert targetBounds != null; + + int sourceY = sourceSegmentTypeY.getY(sourceNode, sourceBounds); + int targetY = targetSegmentTypeY == + UNKNOWN ? sourceY : targetSegmentTypeY.getY(targetNode, targetBounds); + + if (highlightTargetEdge && type.isRelativeToParentEdge()) { + graphics.useStyle(DrawingStyle.DROP_ZONE_ACTIVE); + graphics.fillRect(targetBounds.x, targetY - PARENT_RECT_SIZE / 2, + targetBounds.x2(), targetY + PARENT_RECT_SIZE / 2); + } + + // First see if the two views overlap horizontally. If so, we can just draw a direct + // arrow from the source up to (or down to) the target. + // + // +--------+ + // | Target | + // +--------+ + // ^ + // | + // | + // +--------+ + // | Source | + // +--------+ + // + int maxLeft = Math.max(sourceBounds.x, targetBounds.x); + int minRight = Math.min(sourceBounds.x2(), targetBounds.x2()); + + int center = (maxLeft + minRight) / 2; + if (center > sourceBounds.x && center < sourceBounds.x2()) { + // Yes, the lines overlap -- just draw a straight arrow + // + // + // If however there is a margin on the target edge, it should be drawn like this: + // + // +--------+ + // | Target | + // +--------+ + // | + // | + // v + // - - - - - - - + // ^ + // | + // | + // +--------+ + // | Source | + // +--------+ + // + // Use a minimum threshold for this visualization since it doesn't look good + // for small margins + if (targetSegmentTypeY == BOTTOM && targetMargins.bottom > 5) { + int sharedY = targetY + targetMargins.bottom; + if (sourceY > sharedY + 2) { // Skip when source falls on the margin line + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(targetBounds.x, sharedY, targetBounds.x2(), sharedY); + graphics.useStyle(GUIDELINE); + graphics.drawArrow(center, sourceY, center, sharedY + 2, ARROW_SIZE); + graphics.drawArrow(center, targetY, center, sharedY - 3, ARROW_SIZE); + } else { + graphics.useStyle(GUIDELINE); + // Draw reverse arrow to make it clear the node is as close + // at it can be + graphics.drawArrow(center, targetY, center, sourceY, ARROW_SIZE); + } + return; + } else if (targetSegmentTypeY == TOP && targetMargins.top > 5) { + int sharedY = targetY - targetMargins.top; + if (sourceY < sharedY - 2) { + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(targetBounds.x, sharedY, targetBounds.x2(), sharedY); + graphics.useStyle(GUIDELINE); + graphics.drawArrow(center, sourceY, center, sharedY - 3, ARROW_SIZE); + graphics.drawArrow(center, targetY, center, sharedY + 3, ARROW_SIZE); + } else { + graphics.useStyle(GUIDELINE); + graphics.drawArrow(center, targetY, center, sourceY, ARROW_SIZE); + } + return; + } + + // TODO: If the center falls smack in the center of the sourceBounds, + // AND the source node is part of the selection, then adjust the + // center location such that it is off to the side, let's say 1/4 or 3/4 of + // the overlap region, to ensure that it does not overlap the center selection + // handle + + // When the constraint is for two immediately adjacent edges, we + // need to make some adjustments to make sure the arrow points in the right + // direction + if (sourceY == targetY) { + if (sourceSegmentTypeY == BOTTOM || sourceSegmentTypeY == BASELINE) { + sourceY -= 2 * ARROW_SIZE; + } else if (sourceSegmentTypeY == TOP) { + sourceY += 2 * ARROW_SIZE; + } else { + assert sourceSegmentTypeY == CENTER_HORIZONTAL : sourceSegmentTypeY; + sourceY += sourceBounds.h / 2 - 2 * ARROW_SIZE; + } + } else if (sourceSegmentTypeY == BASELINE) { + sourceY = targetY - 2 * ARROW_SIZE; + } + + // Center the vertical line in the overlap region + graphics.useStyle(GUIDELINE); + graphics.drawArrow(center, sourceY, center, targetY, ARROW_SIZE); + + return; + } + + // If there is no horizontal overlap in the vertical constraints, then we + // will show the attachment relative to a dashed line that extends beyond + // the target bounds, like this: + // + // +--------+ + // | Target | + // +--------+ - - - - - - - - - + // ^ + // | + // +--------+ + // | Source | + // +--------+ + // + // However, if the target node has a vertical margin, we may need to offset + // the line: + // + // +--------+ + // | Target | + // +--------+ + // | + // v + // - - - - - - - - - - - - - - + // ^ + // | + // +--------+ + // | Source | + // +--------+ + // + // If not, we'll need to indicate a shared edge. This is the edge that separate + // them (but this will require me to evaluate margins!) + + // Compute overlap region and pick the middle + int sharedY = targetSegmentTypeY == + UNKNOWN ? sourceY : targetSegmentTypeY.getY(targetNode, targetBounds); + if (type.relativeToMargin) { + if (targetSegmentTypeY == TOP) { + sharedY -= targetMargins.top; + } else if (targetSegmentTypeY == BOTTOM) { + sharedY += targetMargins.bottom; + } + } + + int startX; + int endX; + if (center <= sourceBounds.x) { + startX = targetBounds.x + targetBounds.w / 4; + endX = sourceBounds.x2(); + } else { + assert (center >= sourceBounds.x2()); + startX = sourceBounds.x; + endX = targetBounds.x + 3 * targetBounds.w / 4; + } + // Must draw segmented line instead + // Place the arrow 1/4 instead of 1/2 in the source to avoid overlapping with the + // selection handles + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(startX, sharedY, endX, sharedY); + + // Adjust position of source arrow such that it does not sit across edge; it + // should point directly at the edge + if (Math.abs(sharedY - sourceY) < 2 * ARROW_SIZE) { + if (sourceSegmentTypeY == BASELINE) { + sourceY = sharedY - 2 * ARROW_SIZE; + } else if (sourceSegmentTypeY == TOP) { + sharedY = sourceY; + sourceY = sharedY + 2 * ARROW_SIZE; + } else { + sharedY = sourceY; + sourceY = sharedY - 2 * ARROW_SIZE; + } + } + + graphics.useStyle(GUIDELINE); + + // Draw the line from the source anchor to the shared edge + int x = sourceBounds.x + ((sourceSegmentTypeY == BASELINE) ? + sourceBounds.w / 2 : sourceBounds.w / 4); + graphics.drawArrow(x, sourceY, x, sharedY, ARROW_SIZE); + + // Draw the line from the target to the horizontal shared edge + int tx = targetBounds.centerX(); + if (targetSegmentTypeY == TOP) { + int ty = targetBounds.y; + int margin = targetMargins.top; + if (margin == 0 || !type.relativeToMargin) { + graphics.drawArrow(tx, ty + 2 * ARROW_SIZE, tx, ty, ARROW_SIZE); + } else { + graphics.drawArrow(tx, ty, tx, ty - margin, ARROW_SIZE); + } + } else if (targetSegmentTypeY == BOTTOM) { + int ty = targetBounds.y2(); + int margin = targetMargins.bottom; + if (margin == 0 || !type.relativeToMargin) { + graphics.drawArrow(tx, ty - 2 * ARROW_SIZE, tx, ty, ARROW_SIZE); + } else { + graphics.drawArrow(tx, ty, tx, ty + margin, ARROW_SIZE); + } + } else { + assert targetSegmentTypeY == BASELINE : targetSegmentTypeY; + int ty = targetSegmentTypeY.getY(targetNode, targetBounds); + graphics.drawArrow(tx, ty - 2 * ARROW_SIZE, tx, ty, ARROW_SIZE); + } + + return; + } + + /** + * Paints a horizontal constraint, handling the various scenarios where there are margins, + * or where the two nodes overlap horizontally and where they don't, etc. + */ + private static void paintHorizontalConstraint(IGraphics graphics, ConstraintType type, + INode sourceNode, Rect sourceBounds, INode targetNode, Rect targetBounds, + boolean highlightTargetEdge) { + SegmentType sourceSegmentTypeX = type.sourceSegmentTypeX; + SegmentType targetSegmentTypeX = type.targetSegmentTypeX; + Margins targetMargins = targetNode.getMargins(); + + assert sourceSegmentTypeX != UNKNOWN; + assert targetBounds != null; + + // See paintVerticalConstraint for explanations of the various cases. + + int sourceX = sourceSegmentTypeX.getX(sourceNode, sourceBounds); + int targetX = targetSegmentTypeX == UNKNOWN ? + sourceX : targetSegmentTypeX.getX(targetNode, targetBounds); + + if (highlightTargetEdge && type.isRelativeToParentEdge()) { + graphics.useStyle(DrawingStyle.DROP_ZONE_ACTIVE); + graphics.fillRect(targetX - PARENT_RECT_SIZE / 2, targetBounds.y, + targetX + PARENT_RECT_SIZE / 2, targetBounds.y2()); + } + + int maxTop = Math.max(sourceBounds.y, targetBounds.y); + int minBottom = Math.min(sourceBounds.y2(), targetBounds.y2()); + + // First see if the two views overlap vertically. If so, we can just draw a direct + // arrow from the source over to the target. + int center = (maxTop + minBottom) / 2; + if (center > sourceBounds.y && center < sourceBounds.y2()) { + // See if we should draw a margin line + if (targetSegmentTypeX == RIGHT && targetMargins.right > 5) { + int sharedX = targetX + targetMargins.right; + if (sourceX > sharedX + 2) { // Skip when source falls on the margin line + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(sharedX, targetBounds.y, sharedX, targetBounds.y2()); + graphics.useStyle(GUIDELINE); + graphics.drawArrow(sourceX, center, sharedX + 2, center, ARROW_SIZE); + graphics.drawArrow(targetX, center, sharedX - 3, center, ARROW_SIZE); + } else { + graphics.useStyle(GUIDELINE); + // Draw reverse arrow to make it clear the node is as close + // at it can be + graphics.drawArrow(targetX, center, sourceX, center, ARROW_SIZE); + } + return; + } else if (targetSegmentTypeX == LEFT && targetMargins.left > 5) { + int sharedX = targetX - targetMargins.left; + if (sourceX < sharedX - 2) { + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(sharedX, targetBounds.y, sharedX, targetBounds.y2()); + graphics.useStyle(GUIDELINE); + graphics.drawArrow(sourceX, center, sharedX - 3, center, ARROW_SIZE); + graphics.drawArrow(targetX, center, sharedX + 3, center, ARROW_SIZE); + } else { + graphics.useStyle(GUIDELINE); + graphics.drawArrow(targetX, center, sourceX, center, ARROW_SIZE); + } + return; + } + + if (sourceX == targetX) { + if (sourceSegmentTypeX == RIGHT) { + sourceX -= 2 * ARROW_SIZE; + } else if (sourceSegmentTypeX == LEFT ) { + sourceX += 2 * ARROW_SIZE; + } else { + assert sourceSegmentTypeX == CENTER_VERTICAL : sourceSegmentTypeX; + sourceX += sourceBounds.w / 2 - 2 * ARROW_SIZE; + } + } + + graphics.useStyle(GUIDELINE); + graphics.drawArrow(sourceX, center, targetX, center, ARROW_SIZE); + return; + } + + // Segment line + + // Compute overlap region and pick the middle + int sharedX = targetSegmentTypeX == UNKNOWN ? + sourceX : targetSegmentTypeX.getX(targetNode, targetBounds); + if (type.relativeToMargin) { + if (targetSegmentTypeX == LEFT) { + sharedX -= targetMargins.left; + } else if (targetSegmentTypeX == RIGHT) { + sharedX += targetMargins.right; + } + } + + int startY, endY; + if (center <= sourceBounds.y) { + startY = targetBounds.y + targetBounds.h / 4; + endY = sourceBounds.y2(); + } else { + assert (center >= sourceBounds.y2()); + startY = sourceBounds.y; + endY = targetBounds.y + 3 * targetBounds.h / 2; + } + + // Must draw segmented line instead + // Place at 1/4 instead of 1/2 to avoid overlapping with selection handles + int y = sourceBounds.y + sourceBounds.h / 4; + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(sharedX, startY, sharedX, endY); + + // Adjust position of source arrow such that it does not sit across edge; it + // should point directly at the edge + if (Math.abs(sharedX - sourceX) < 2 * ARROW_SIZE) { + if (sourceSegmentTypeX == LEFT) { + sharedX = sourceX; + sourceX = sharedX + 2 * ARROW_SIZE; + } else { + sharedX = sourceX; + sourceX = sharedX - 2 * ARROW_SIZE; + } + } + + graphics.useStyle(GUIDELINE); + + // Draw the line from the source anchor to the shared edge + graphics.drawArrow(sourceX, y, sharedX, y, ARROW_SIZE); + + // Draw the line from the target to the horizontal shared edge + int ty = targetBounds.centerY(); + if (targetSegmentTypeX == LEFT) { + int tx = targetBounds.x; + int margin = targetMargins.left; + if (margin == 0 || !type.relativeToMargin) { + graphics.drawArrow(tx + 2 * ARROW_SIZE, ty, tx, ty, ARROW_SIZE); + } else { + graphics.drawArrow(tx, ty, tx - margin, ty, ARROW_SIZE); + } + } else { + assert targetSegmentTypeX == RIGHT; + int tx = targetBounds.x2(); + int margin = targetMargins.right; + if (margin == 0 || !type.relativeToMargin) { + graphics.drawArrow(tx - 2 * ARROW_SIZE, ty, tx, ty, ARROW_SIZE); + } else { + graphics.drawArrow(tx, ty, tx + margin, ty, ARROW_SIZE); + } + } + + return; + } + + /** + * Paints a vertical center constraint. The constraint is shown as a dashed line + * through the vertical view, and a solid line over the node bounds. + */ + private static void paintVerticalCenterConstraint(IGraphics graphics, Rect sourceBounds, + Rect targetBounds) { + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(targetBounds.x, targetBounds.centerY(), + targetBounds.x2(), targetBounds.centerY()); + graphics.useStyle(GUIDELINE); + graphics.drawLine(sourceBounds.x, sourceBounds.centerY(), + sourceBounds.x2(), sourceBounds.centerY()); + } + + /** + * Paints a horizontal center constraint. The constraint is shown as a dashed line + * through the horizontal view, and a solid line over the node bounds. + */ + private static void paintHorizontalCenterConstraint(IGraphics graphics, Rect sourceBounds, + Rect targetBounds) { + graphics.useStyle(GUIDELINE_DASHED); + graphics.drawLine(targetBounds.centerX(), targetBounds.y, + targetBounds.centerX(), targetBounds.y2()); + graphics.useStyle(GUIDELINE); + graphics.drawLine(sourceBounds.centerX(), sourceBounds.y, + sourceBounds.centerX(), sourceBounds.y2()); + } +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ConstraintType.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ConstraintType.java new file mode 100644 index 000000000..ed4ac1bf4 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ConstraintType.java @@ -0,0 +1,241 @@ +/* + * 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.common.layout.relative; + +import static com.android.ide.common.api.SegmentType.BASELINE; +import static com.android.ide.common.api.SegmentType.BOTTOM; +import static com.android.ide.common.api.SegmentType.CENTER_HORIZONTAL; +import static com.android.ide.common.api.SegmentType.CENTER_VERTICAL; +import static com.android.ide.common.api.SegmentType.LEFT; +import static com.android.ide.common.api.SegmentType.RIGHT; +import static com.android.ide.common.api.SegmentType.TOP; +import static com.android.ide.common.api.SegmentType.UNKNOWN; +import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.SegmentType; + +import java.util.HashMap; +import java.util.Map; + +/** + * Each constraint type corresponds to a type of constraint available for the + * RelativeLayout; for example, {@link #LAYOUT_ABOVE} corresponds to the layout_above constraint. + */ +enum ConstraintType { + LAYOUT_ABOVE(ATTR_LAYOUT_ABOVE, + null /* sourceX */, BOTTOM, null /* targetX */, TOP, + false /* targetParent */, true /* horizontalEdge */, false /* verticalEdge */, + true /* relativeToMargin */), + + LAYOUT_BELOW(ATTR_LAYOUT_BELOW, null, TOP, null, BOTTOM, false, true, false, true), + ALIGN_TOP(ATTR_LAYOUT_ALIGN_TOP, null, TOP, null, TOP, false, true, false, false), + ALIGN_BOTTOM(ATTR_LAYOUT_ALIGN_BOTTOM, null, BOTTOM, null, BOTTOM, false, true, false, false), + ALIGN_LEFT(ATTR_LAYOUT_ALIGN_LEFT, LEFT, null, LEFT, null, false, false, true, false), + ALIGN_RIGHT(ATTR_LAYOUT_ALIGN_RIGHT, RIGHT, null, RIGHT, null, false, false, true, false), + LAYOUT_LEFT_OF(ATTR_LAYOUT_TO_LEFT_OF, RIGHT, null, LEFT, null, false, false, true, true), + LAYOUT_RIGHT_OF(ATTR_LAYOUT_TO_RIGHT_OF, LEFT, null, RIGHT, null, false, false, true, true), + ALIGN_PARENT_TOP(ATTR_LAYOUT_ALIGN_PARENT_TOP, null, TOP, null, TOP, true, true, false, false), + ALIGN_BASELINE(ATTR_LAYOUT_ALIGN_BASELINE, null, BASELINE, null, BASELINE, false, true, false, + false), + ALIGN_PARENT_LEFT(ATTR_LAYOUT_ALIGN_PARENT_LEFT, LEFT, null, LEFT, null, true, false, true, + false), + ALIGN_PARENT_RIGHT(ATTR_LAYOUT_ALIGN_PARENT_RIGHT, RIGHT, null, RIGHT, null, true, false, true, + false), + ALIGN_PARENT_BOTTOM(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, null, BOTTOM, null, BOTTOM, true, true, + false, false), + LAYOUT_CENTER_HORIZONTAL(ATTR_LAYOUT_CENTER_HORIZONTAL, CENTER_VERTICAL, null, CENTER_VERTICAL, + null, true, true, false, false), + LAYOUT_CENTER_VERTICAL(ATTR_LAYOUT_CENTER_VERTICAL, null, CENTER_HORIZONTAL, null, + CENTER_HORIZONTAL, true, false, true, false), + LAYOUT_CENTER_IN_PARENT(ATTR_LAYOUT_CENTER_IN_PARENT, CENTER_VERTICAL, CENTER_HORIZONTAL, + CENTER_VERTICAL, CENTER_HORIZONTAL, true, true, true, false); + + private ConstraintType(String name, SegmentType sourceSegmentTypeX, + SegmentType sourceSegmentTypeY, SegmentType targetSegmentTypeX, + SegmentType targetSegmentTypeY, boolean targetParent, boolean horizontalEdge, + boolean verticalEdge, boolean relativeToMargin) { + assert horizontalEdge || verticalEdge; + + this.name = name; + this.sourceSegmentTypeX = sourceSegmentTypeX != null ? sourceSegmentTypeX : UNKNOWN; + this.sourceSegmentTypeY = sourceSegmentTypeY != null ? sourceSegmentTypeY : UNKNOWN; + this.targetSegmentTypeX = targetSegmentTypeX != null ? targetSegmentTypeX : UNKNOWN; + this.targetSegmentTypeY = targetSegmentTypeY != null ? targetSegmentTypeY : UNKNOWN; + this.targetParent = targetParent; + this.horizontalEdge = horizontalEdge; + this.verticalEdge = verticalEdge; + this.relativeToMargin = relativeToMargin; + } + + /** The attribute name of the constraint */ + public final String name; + + /** The horizontal position of the source of the constraint */ + public final SegmentType sourceSegmentTypeX; + + /** The vertical position of the source of the constraint */ + public final SegmentType sourceSegmentTypeY; + + /** The horizontal position of the target of the constraint */ + public final SegmentType targetSegmentTypeX; + + /** The vertical position of the target of the constraint */ + public final SegmentType targetSegmentTypeY; + + /** + * If true, the constraint targets the parent layout, otherwise it targets another + * view + */ + public final boolean targetParent; + + /** If true, this constraint affects the horizontal dimension */ + public final boolean horizontalEdge; + + /** If true, this constraint affects the vertical dimension */ + public final boolean verticalEdge; + + /** + * Whether this constraint is relative to the margin bounds of the node rather than + * the node's actual bounds + */ + public final boolean relativeToMargin; + + /** Map from attribute name to constraint type */ + private static Map<String, ConstraintType> sNameToType; + + /** + * Returns the {@link ConstraintType} corresponding to the given attribute name, or + * null if not found. + * + * @param attribute the name of the attribute to look up + * @return the corresponding {@link ConstraintType} + */ + @Nullable + public static ConstraintType fromAttribute(@NonNull String attribute) { + if (sNameToType == null) { + ConstraintType[] types = ConstraintType.values(); + Map<String, ConstraintType> map = new HashMap<String, ConstraintType>(types.length); + for (ConstraintType type : types) { + map.put(type.name, type); + } + sNameToType = map; + } + return sNameToType.get(attribute); + } + + /** + * Returns true if this constraint type represents a constraint where the target edge + * is one of the parent edges (actual edge, not center/baseline segments) + * + * @return true if the target segment is a parent edge + */ + public boolean isRelativeToParentEdge() { + return this == ALIGN_PARENT_LEFT || this == ALIGN_PARENT_RIGHT || this == ALIGN_PARENT_TOP + || this == ALIGN_PARENT_BOTTOM; + } + + /** + * Returns a {@link ConstraintType} for a potential match of edges. + * + * @param withParent if true, the target is the parent + * @param from the source edge + * @param to the target edge + * @return a {@link ConstraintType}, or null + */ + @Nullable + public static ConstraintType forMatch(boolean withParent, SegmentType from, SegmentType to) { + // Attached to parent edge? + if (withParent) { + switch (from) { + case TOP: + return ALIGN_PARENT_TOP; + case BOTTOM: + return ALIGN_PARENT_BOTTOM; + case LEFT: + return ALIGN_PARENT_LEFT; + case RIGHT: + return ALIGN_PARENT_RIGHT; + case CENTER_HORIZONTAL: + return LAYOUT_CENTER_VERTICAL; + case CENTER_VERTICAL: + return LAYOUT_CENTER_HORIZONTAL; + } + + return null; + } + + // Attached to some other node. + switch (from) { + case TOP: + switch (to) { + case TOP: + return ALIGN_TOP; + case BOTTOM: + return LAYOUT_BELOW; + case BASELINE: + return ALIGN_BASELINE; + } + break; + case BOTTOM: + switch (to) { + case TOP: + return LAYOUT_ABOVE; + case BOTTOM: + return ALIGN_BOTTOM; + case BASELINE: + return ALIGN_BASELINE; + } + break; + case LEFT: + switch (to) { + case LEFT: + return ALIGN_LEFT; + case RIGHT: + return LAYOUT_RIGHT_OF; + } + break; + case RIGHT: + switch (to) { + case LEFT: + return LAYOUT_LEFT_OF; + case RIGHT: + return ALIGN_RIGHT; + } + break; + case BASELINE: + return ALIGN_BASELINE; + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/DeletionHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/DeletionHandler.java new file mode 100644 index 000000000..3eac510df --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/DeletionHandler.java @@ -0,0 +1,267 @@ +/* + * 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.common.layout.relative; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.ide.common.layout.BaseViewRule.stripIdPrefix; +import static com.android.ide.common.layout.relative.ConstraintType.LAYOUT_CENTER_HORIZONTAL; +import static com.android.ide.common.layout.relative.ConstraintType.LAYOUT_CENTER_VERTICAL; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INode.IAttribute; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Handles deletions in a relative layout, transferring constraints across + * deleted nodes + * <p> + * TODO: Consider adding the + * {@link SdkConstants#ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING} attribute to a + * node if it's pointing to a node which is deleted and which has no transitive + * reference to another node. + */ +public class DeletionHandler { + private final INode mLayout; + private final INode[] mChildren; + private final List<INode> mDeleted; + private final Set<String> mDeletedIds; + private final Map<String, INode> mNodeMap; + private final List<INode> mMoved; + + /** + * Creates a new {@link DeletionHandler} + * + * @param deleted the deleted nodes + * @param moved nodes that were moved (e.g. deleted, but also inserted elsewhere) + * @param layout the parent layout of the deleted nodes + */ + public DeletionHandler(@NonNull List<INode> deleted, @NonNull List<INode> moved, + @NonNull INode layout) { + mDeleted = deleted; + mMoved = moved; + mLayout = layout; + + mChildren = mLayout.getChildren(); + mNodeMap = Maps.newHashMapWithExpectedSize(mChildren.length); + for (INode child : mChildren) { + String id = child.getStringAttr(ANDROID_URI, ATTR_ID); + if (id != null) { + mNodeMap.put(stripIdPrefix(id), child); + } + } + + mDeletedIds = Sets.newHashSetWithExpectedSize(mDeleted.size()); + for (INode node : mDeleted) { + String id = node.getStringAttr(ANDROID_URI, ATTR_ID); + if (id != null) { + mDeletedIds.add(stripIdPrefix(id)); + } + } + + // Any widgets that remain (e.g. typically because they were moved) should + // keep their incoming dependencies + for (INode node : mMoved) { + String id = node.getStringAttr(ANDROID_URI, ATTR_ID); + if (id != null) { + mDeletedIds.remove(stripIdPrefix(id)); + } + } + } + + @Nullable + private static String getId(@NonNull IAttribute attribute) { + if (attribute.getName().startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attribute.getUri()) + && !attribute.getName().startsWith(ATTR_LAYOUT_MARGIN)) { + String id = attribute.getValue(); + // It might not be an id reference, so check manually rather than just + // calling stripIdPrefix(): + if (id.startsWith(NEW_ID_PREFIX)) { + return id.substring(NEW_ID_PREFIX.length()); + } else if (id.startsWith(ID_PREFIX)) { + return id.substring(ID_PREFIX.length()); + } + } + + return null; + } + + /** + * Updates the constraints in the layout to handle deletion of a set of + * nodes. This ensures that any constraints pointing to one of the deleted + * nodes are changed properly to point to a non-deleted node with similar + * constraints. + */ + public void updateConstraints() { + if (mChildren.length == mDeleted.size()) { + // Deleting everything: Nothing to be done + return; + } + + // Now remove incoming edges to any views that were deleted. If possible, + // don't just delete them but replace them with a transitive constraint, e.g. + // if we have "A <= B <= C" and "B" is removed, then we end up with "A <= C", + + for (INode child : mChildren) { + if (mDeleted.contains(child)) { + continue; + } + + for (IAttribute attribute : child.getLiveAttributes()) { + String id = getId(attribute); + if (id != null) { + if (mDeletedIds.contains(id)) { + // Unset this reference to a deleted widget. It might be + // replaced if the pointed to node points to some other node + // on the same side, but it may use a different constraint name, + // or have none at all (e.g. parent). + String name = attribute.getName(); + child.setAttribute(ANDROID_URI, name, null); + + INode deleted = mNodeMap.get(id); + if (deleted != null) { + ConstraintType type = ConstraintType.fromAttribute(name); + if (type != null) { + transfer(deleted, child, type, 0); + } + } + } + } + } + } + } + + private void transfer(INode deleted, INode target, ConstraintType targetType, int depth) { + if (depth == 20) { + // Prevent really deep flow or unbounded recursion in case there is a bug in + // the cycle detection code + return; + } + + assert mDeleted.contains(deleted); + + for (IAttribute attribute : deleted.getLiveAttributes()) { + String name = attribute.getName(); + ConstraintType type = ConstraintType.fromAttribute(name); + if (type == null) { + continue; + } + + ConstraintType transfer = getCompatibleConstraint(type, targetType); + if (transfer != null) { + String id = getId(attribute); + if (id != null) { + if (mDeletedIds.contains(id)) { + INode nextDeleted = mNodeMap.get(id); + if (nextDeleted != null) { + // Points to another deleted node: recurse + transfer(nextDeleted, target, targetType, depth + 1); + } + } else { + // Found an undeleted node destination: point to it directly. + // Note that we're using the + target.setAttribute(ANDROID_URI, transfer.name, attribute.getValue()); + } + } else { + // Pointing to parent or center etc (non-id ref): replicate this on the target + target.setAttribute(ANDROID_URI, name, attribute.getValue()); + } + } + } + } + + /** + * Determines if two constraints are in the same direction and if so returns + * the constraint in the same direction. Rather than returning boolean true + * or false, this returns the constraint which is sometimes modified. For + * example, if you have a node which points left to a node which is centered + * in parent, then the constraint is turned into center horizontal. + */ + @Nullable + private static ConstraintType getCompatibleConstraint( + @NonNull ConstraintType first, @NonNull ConstraintType second) { + if (first == second) { + return first; + } + + switch (second) { + case ALIGN_LEFT: + case LAYOUT_RIGHT_OF: + switch (first) { + case LAYOUT_CENTER_HORIZONTAL: + case LAYOUT_LEFT_OF: + case ALIGN_LEFT: + return first; + case LAYOUT_CENTER_IN_PARENT: + return LAYOUT_CENTER_HORIZONTAL; + } + return null; + + case ALIGN_RIGHT: + case LAYOUT_LEFT_OF: + switch (first) { + case LAYOUT_CENTER_HORIZONTAL: + case ALIGN_RIGHT: + case LAYOUT_LEFT_OF: + return first; + case LAYOUT_CENTER_IN_PARENT: + return LAYOUT_CENTER_HORIZONTAL; + } + return null; + + case ALIGN_TOP: + case LAYOUT_BELOW: + case ALIGN_BASELINE: + switch (first) { + case LAYOUT_CENTER_VERTICAL: + case ALIGN_TOP: + case LAYOUT_BELOW: + case ALIGN_BASELINE: + return first; + case LAYOUT_CENTER_IN_PARENT: + return LAYOUT_CENTER_VERTICAL; + } + return null; + case ALIGN_BOTTOM: + case LAYOUT_ABOVE: + switch (first) { + case LAYOUT_CENTER_VERTICAL: + case ALIGN_BOTTOM: + case LAYOUT_ABOVE: + case ALIGN_BASELINE: + return first; + case LAYOUT_CENTER_IN_PARENT: + return LAYOUT_CENTER_VERTICAL; + } + return null; + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/DependencyGraph.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/DependencyGraph.java new file mode 100644 index 000000000..43d52d137 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/DependencyGraph.java @@ -0,0 +1,326 @@ +/* + * 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.common.layout.relative; + +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.VALUE_TRUE; + + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.IDragElement.IDragAttribute; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INode.IAttribute; +import com.android.ide.common.layout.BaseLayoutRule; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Data structure about relative layout relationships which makes it possible to: + * <ul> + * <li> Quickly determine not just the dependencies on other nodes, but which nodes + * depend on this node such that they can be visualized for the selection + * <li> Determine if there are cyclic dependencies, and whether a potential move + * would result in a cycle + * <li> Determine the "depth" of a given node (in terms of how many connections it + * is away from a parent edge) such that we can prioritize connections which + * minimizes the depth + * </ul> + */ +class DependencyGraph { + /** Format to chain include cycles in: a=>b=>c=>d etc */ + static final String CHAIN_FORMAT = "%1$s=>%2$s"; //$NON-NLS-1$ + + /** Format to chain constraint dependencies: button 1 above button2 etc */ + private static final String DEPENDENCY_FORMAT = "%1$s %2$s %3$s"; //$NON-NLS-1$ + + private final Map<String, ViewData> mIdToView = new HashMap<String, ViewData>(); + private final Map<INode, ViewData> mNodeToView = new HashMap<INode, ViewData>(); + + /** Constructs a new {@link DependencyGraph} for the given relative layout */ + DependencyGraph(INode layout) { + INode[] nodes = layout.getChildren(); + + // Parent view: + String parentId = layout.getStringAttr(ANDROID_URI, ATTR_ID); + if (parentId != null) { + parentId = BaseLayoutRule.stripIdPrefix(parentId); + } else { + parentId = "RelativeLayout"; // For display purposes; we never reference + // the parent id from a constraint, only via parent-relative params + // like centerInParent + } + ViewData parentView = new ViewData(layout, parentId); + mNodeToView.put(layout, parentView); + if (parentId != null) { + mIdToView.put(parentId, parentView); + } + + for (INode child : nodes) { + String id = child.getStringAttr(ANDROID_URI, ATTR_ID); + if (id != null) { + id = BaseLayoutRule.stripIdPrefix(id); + } + ViewData view = new ViewData(child, id); + mNodeToView.put(child, view); + if (id != null) { + mIdToView.put(id, view); + } + } + + for (ViewData view : mNodeToView.values()) { + for (IAttribute attribute : view.node.getLiveAttributes()) { + String name = attribute.getName(); + ConstraintType type = ConstraintType.fromAttribute(name); + if (type != null) { + String value = attribute.getValue(); + + if (type.targetParent) { + if (value.equals(VALUE_TRUE)) { + Constraint constraint = new Constraint(type, view, parentView); + view.dependsOn.add(constraint); + parentView.dependedOnBy.add(constraint); + } + } else { + // id-based constraint. + // NOTE: The id could refer to some widget that is NOT a sibling! + String targetId = BaseLayoutRule.stripIdPrefix(value); + ViewData target = mIdToView.get(targetId); + if (target == view) { + // Self-reference. RelativeLayout ignores these so it's + // not an error like a deeper cycle (where RelativeLayout + // will throw an exception), but we might as well warn + // the user about it. + // TODO: Where do we emit this error? + } else if (target != null) { + Constraint constraint = new Constraint(type, view, target); + view.dependsOn.add(constraint); + target.dependedOnBy.add(constraint); + } else { + // This is valid but we might want to warn... + //System.out.println("Warning: no view data found for " + targetId); + } + } + } + } + } + } + + public ViewData getView(IDragElement element) { + IDragAttribute attribute = element.getAttribute(ANDROID_URI, ATTR_ID); + if (attribute != null) { + String id = attribute.getValue(); + id = BaseLayoutRule.stripIdPrefix(id); + return getView(id); + } + + return null; + } + + public ViewData getView(String id) { + return mIdToView.get(id); + } + + public ViewData getView(INode node) { + return mNodeToView.get(node); + } + + /** + * Returns the set of views that depend on the given node in either the horizontal or + * vertical direction + * + * @param nodes the set of nodes that we want to compute the transitive dependencies + * for + * @param vertical if true, look for vertical edge dependencies, otherwise look for + * horizontal edge dependencies + * @return the set of nodes that directly or indirectly depend on the given nodes in + * the given direction + */ + public Set<INode> dependsOn(Collection<? extends INode> nodes, boolean vertical) { + List<ViewData> reachable = new ArrayList<ViewData>(); + + // Traverse the graph of constraints and determine all nodes affected by + // this node + Set<ViewData> visiting = new HashSet<ViewData>(); + for (INode node : nodes) { + ViewData view = mNodeToView.get(node); + if (view != null) { + findBackwards(view, visiting, reachable, vertical, view); + } + } + + Set<INode> dependents = new HashSet<INode>(reachable.size()); + + for (ViewData v : reachable) { + dependents.add(v.node); + } + + return dependents; + } + + private void findBackwards(ViewData view, + Set<ViewData> visiting, List<ViewData> reachable, + boolean vertical, ViewData start) { + visiting.add(view); + reachable.add(view); + + for (Constraint constraint : view.dependedOnBy) { + if (vertical && !constraint.type.verticalEdge) { + continue; + } else if (!vertical && !constraint.type.horizontalEdge) { + continue; + } + + assert constraint.to == view; + ViewData from = constraint.from; + if (visiting.contains(from)) { + // Cycle - what do we do to highlight this? + List<Constraint> path = getPathTo(start.node, view.node, vertical); + if (path != null) { + // TODO: display to the user somehow. We need log access for the + // view rules. + System.out.println(Constraint.describePath(path, null, null)); + } + } else { + findBackwards(from, visiting, reachable, vertical, start); + } + } + + visiting.remove(view); + } + + public List<Constraint> getPathTo(INode from, INode to, boolean vertical) { + // Traverse the graph of constraints and determine all nodes affected by + // this node + Set<ViewData> visiting = new HashSet<ViewData>(); + List<Constraint> path = new ArrayList<Constraint>(); + ViewData view = mNodeToView.get(from); + if (view != null) { + return findForwards(view, visiting, path, vertical, to); + } + + return null; + } + + private List<Constraint> findForwards(ViewData view, Set<ViewData> visiting, + List<Constraint> path, boolean vertical, INode target) { + visiting.add(view); + + for (Constraint constraint : view.dependsOn) { + if (vertical && !constraint.type.verticalEdge) { + continue; + } else if (!vertical && !constraint.type.horizontalEdge) { + continue; + } + + try { + path.add(constraint); + + if (constraint.to.node == target) { + return new ArrayList<Constraint>(path); + } + + assert constraint.from == view; + ViewData to = constraint.to; + if (visiting.contains(to)) { + // CYCLE! + continue; + } + + List<Constraint> chain = findForwards(to, visiting, path, vertical, target); + if (chain != null) { + return chain; + } + } finally { + path.remove(constraint); + } + } + + visiting.remove(view); + + return null; + } + + /** + * Info about a specific widget child of a relative layout and its constraints. This + * is a node in the dependency graph. + */ + static class ViewData { + public final INode node; + public final String id; + public final List<Constraint> dependsOn = new ArrayList<Constraint>(4); + public final List<Constraint> dependedOnBy = new ArrayList<Constraint>(8); + + ViewData(INode node, String id) { + this.node = node; + this.id = id; + } + } + + /** + * Info about a specific constraint between two widgets in a relative layout. This is + * an edge in the dependency graph. + */ + static class Constraint { + public final ConstraintType type; + public final ViewData from; + public final ViewData to; + + // TODO: Initialize depth -- should be computed independently for top, left, etc. + // We can use this in GuidelineHandler.MatchComparator to prefer matches that + // are closer to a parent edge: + //public int depth; + + Constraint(ConstraintType type, ViewData from, ViewData to) { + this.type = type; + this.from = from; + this.to = to; + } + + static String describePath(List<Constraint> path, String newName, String newId) { + String s = ""; + for (int i = path.size() - 1; i >= 0; i--) { + Constraint constraint = path.get(i); + String suffix = (i == path.size() -1) ? constraint.to.id : s; + s = String.format(DEPENDENCY_FORMAT, constraint.from.id, + stripLayoutAttributePrefix(constraint.type.name), suffix); + } + + if (newName != null) { + s = String.format(DEPENDENCY_FORMAT, s, stripLayoutAttributePrefix(newName), + BaseLayoutRule.stripIdPrefix(newId)); + } + + return s; + } + + private static String stripLayoutAttributePrefix(String name) { + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + return name.substring(ATTR_LAYOUT_RESOURCE_PREFIX.length()); + } + + return name; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/GuidelineHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/GuidelineHandler.java new file mode 100644 index 000000000..db08b1857 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/GuidelineHandler.java @@ -0,0 +1,839 @@ +/* + * 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.common.layout.relative; + +import static com.android.ide.common.api.MarginType.NO_MARGIN; +import static com.android.ide.common.api.MarginType.WITHOUT_MARGIN; +import static com.android.ide.common.api.MarginType.WITH_MARGIN; +import static com.android.ide.common.api.SegmentType.BASELINE; +import static com.android.ide.common.api.SegmentType.BOTTOM; +import static com.android.ide.common.api.SegmentType.CENTER_HORIZONTAL; +import static com.android.ide.common.api.SegmentType.CENTER_VERTICAL; +import static com.android.ide.common.api.SegmentType.LEFT; +import static com.android.ide.common.api.SegmentType.RIGHT; +import static com.android.ide.common.api.SegmentType.TOP; +import static com.android.ide.common.layout.BaseLayoutRule.getMaxMatchDistance; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT; +import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL; +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_TO_LEFT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; +import static com.android.SdkConstants.VALUE_N_DP; +import static com.android.SdkConstants.VALUE_TRUE; +import static com.android.ide.common.layout.relative.ConstraintType.ALIGN_BASELINE; + +import static java.lang.Math.abs; + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.Segment; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.common.layout.relative.DependencyGraph.Constraint; +import com.android.ide.common.layout.relative.DependencyGraph.ViewData; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +/** + * The {@link GuidelineHandler} class keeps track of state related to a guideline operation + * like move and resize, and performs various constraint computations. + */ +public class GuidelineHandler { + /** + * A dependency graph for the relative layout recording constraint relationships + */ + protected DependencyGraph mDependencyGraph; + + /** The RelativeLayout we are moving/resizing within */ + public INode layout; + + /** The set of nodes being dragged (may be null) */ + protected Collection<INode> mDraggedNodes; + + /** The bounds of the primary child node being dragged */ + protected Rect mBounds; + + /** Whether the left edge is being moved/resized */ + protected boolean mMoveLeft; + + /** Whether the right edge is being moved/resized */ + protected boolean mMoveRight; + + /** Whether the top edge is being moved/resized */ + protected boolean mMoveTop; + + /** Whether the bottom edge is being moved/resized */ + protected boolean mMoveBottom; + + /** + * Whether the drop/move/resize position should be snapped (which can be turned off + * with a modifier key during the operation) + */ + protected boolean mSnap = true; + + /** + * The set of nodes which depend on the currently selected nodes, including + * transitively, through horizontal constraints (a "horizontal constraint" + * is a constraint between two horizontal edges) + */ + protected Set<INode> mHorizontalDeps; + + /** + * The set of nodes which depend on the currently selected nodes, including + * transitively, through vertical constraints (a "vertical constraint" + * is a constraint between two vertical edges) + */ + protected Set<INode> mVerticalDeps; + + /** The current list of constraints which result in a horizontal cycle (if applicable) */ + protected List<Constraint> mHorizontalCycle; + + /** The current list of constraints which result in a vertical cycle (if applicable) */ + protected List<Constraint> mVerticalCycle; + + /** + * All horizontal segments in the relative layout - top and bottom edges, baseline + * edges, and top and bottom edges offset by the applicable margins in each direction + */ + protected List<Segment> mHorizontalEdges; + + /** + * All vertical segments in the relative layout - left and right edges, and left and + * right edges offset by the applicable margins in each direction + */ + protected List<Segment> mVerticalEdges; + + /** + * All center vertical segments in the relative layout. These are kept separate since + * they only match other center edges. + */ + protected List<Segment> mCenterVertEdges; + + /** + * All center horizontal segments in the relative layout. These are kept separate + * since they only match other center edges. + */ + protected List<Segment> mCenterHorizEdges; + + /** + * Suggestions for horizontal matches. There could be more than one, but all matches + * will be equidistant from the current position (as well as in the same direction, + * which means that you can't have one match 5 pixels to the left and one match 5 + * pixels to the right since it would be impossible to snap to fit with both; you can + * however have multiple matches all 5 pixels to the left.) + * <p + * The best vertical match will be found in {@link #mCurrentTopMatch} or + * {@link #mCurrentBottomMatch}. + */ + protected List<Match> mHorizontalSuggestions; + + /** + * Suggestions for vertical matches. + * <p + * The best vertical match will be found in {@link #mCurrentLeftMatch} or + * {@link #mCurrentRightMatch}. + */ + protected List<Match> mVerticalSuggestions; + + /** + * The current match on the left edge, or null if no match or if the left edge is not + * being moved or resized. + */ + protected Match mCurrentLeftMatch; + + /** + * The current match on the top edge, or null if no match or if the top edge is not + * being moved or resized. + */ + protected Match mCurrentTopMatch; + + /** + * The current match on the right edge, or null if no match or if the right edge is + * not being moved or resized. + */ + protected Match mCurrentRightMatch; + + /** + * The current match on the bottom edge, or null if no match or if the bottom edge is + * not being moved or resized. + */ + protected Match mCurrentBottomMatch; + + /** + * The amount of margin to add to the top edge, or 0 + */ + protected int mTopMargin; + + /** + * The amount of margin to add to the bottom edge, or 0 + */ + protected int mBottomMargin; + + /** + * The amount of margin to add to the left edge, or 0 + */ + protected int mLeftMargin; + + /** + * The amount of margin to add to the right edge, or 0 + */ + protected int mRightMargin; + + /** + * The associated rules engine + */ + protected IClientRulesEngine mRulesEngine; + + /** + * Construct a new {@link GuidelineHandler} for the given relative layout. + * + * @param layout the RelativeLayout to handle + */ + GuidelineHandler(INode layout, IClientRulesEngine rulesEngine) { + this.layout = layout; + mRulesEngine = rulesEngine; + + mHorizontalEdges = new ArrayList<Segment>(); + mVerticalEdges = new ArrayList<Segment>(); + mCenterVertEdges = new ArrayList<Segment>(); + mCenterHorizEdges = new ArrayList<Segment>(); + mDependencyGraph = new DependencyGraph(layout); + } + + /** + * Returns true if the handler has any suggestions to offer + * + * @return true if the handler has any suggestions to offer + */ + public boolean haveSuggestions() { + return mCurrentLeftMatch != null || mCurrentTopMatch != null + || mCurrentRightMatch != null || mCurrentBottomMatch != null; + } + + /** + * Returns the closest match. + * + * @return the closest match, or null if nothing matched + */ + protected Match pickBestMatch(List<Match> matches) { + int alternatives = matches.size(); + if (alternatives == 0) { + return null; + } else if (alternatives == 1) { + Match match = matches.get(0); + return match; + } else { + assert alternatives > 1; + Collections.sort(matches, new MatchComparator()); + return matches.get(0); + } + } + + private boolean checkCycle(DropFeedback feedback, Match match, boolean vertical) { + if (match != null && match.cycle) { + for (INode node : mDraggedNodes) { + INode from = match.edge.node; + assert match.with.node == null || match.with.node == node; + INode to = node; + List<Constraint> path = mDependencyGraph.getPathTo(from, to, vertical); + if (path != null) { + if (vertical) { + mVerticalCycle = path; + } else { + mHorizontalCycle = path; + } + String desc = Constraint.describePath(path, + match.type.name, match.edge.id); + + feedback.errorMessage = "Constraint creates a cycle: " + desc; + return true; + } + } + } + + return false; + } + + /** + * Checks for any cycles in the dependencies + * + * @param feedback the drop feedback state + */ + public void checkCycles(DropFeedback feedback) { + // Deliberate short circuit evaluation -- only list the first cycle + feedback.errorMessage = null; + mHorizontalCycle = null; + mVerticalCycle = null; + + if (checkCycle(feedback, mCurrentTopMatch, true /* vertical */) + || checkCycle(feedback, mCurrentBottomMatch, true)) { + } + + if (checkCycle(feedback, mCurrentLeftMatch, false) + || checkCycle(feedback, mCurrentRightMatch, false)) { + } + } + + /** Records the matchable outside edges for the given node to the potential match list */ + protected void addBounds(INode node, String id, + boolean addHorizontal, boolean addVertical) { + Rect b = node.getBounds(); + Margins margins = node.getMargins(); + if (addHorizontal) { + if (margins.top != 0) { + mHorizontalEdges.add(new Segment(b.y, b.x, b.x2(), node, id, TOP, WITHOUT_MARGIN)); + mHorizontalEdges.add(new Segment(b.y - margins.top, b.x, b.x2(), node, id, + TOP, WITH_MARGIN)); + } else { + mHorizontalEdges.add(new Segment(b.y, b.x, b.x2(), node, id, TOP, NO_MARGIN)); + } + if (margins.bottom != 0) { + mHorizontalEdges.add(new Segment(b.y2(), b.x, b.x2(), node, id, BOTTOM, + WITHOUT_MARGIN)); + mHorizontalEdges.add(new Segment(b.y2() + margins.bottom, b.x, b.x2(), node, + id, BOTTOM, WITH_MARGIN)); + } else { + mHorizontalEdges.add(new Segment(b.y2(), b.x, b.x2(), node, id, + BOTTOM, NO_MARGIN)); + } + } + if (addVertical) { + if (margins.left != 0) { + mVerticalEdges.add(new Segment(b.x, b.y, b.y2(), node, id, LEFT, WITHOUT_MARGIN)); + mVerticalEdges.add(new Segment(b.x - margins.left, b.y, b.y2(), node, id, LEFT, + WITH_MARGIN)); + } else { + mVerticalEdges.add(new Segment(b.x, b.y, b.y2(), node, id, LEFT, NO_MARGIN)); + } + + if (margins.right != 0) { + mVerticalEdges.add(new Segment(b.x2(), b.y, b.y2(), node, id, + RIGHT, WITHOUT_MARGIN)); + mVerticalEdges.add(new Segment(b.x2() + margins.right, b.y, b.y2(), node, id, + RIGHT, WITH_MARGIN)); + } else { + mVerticalEdges.add(new Segment(b.x2(), b.y, b.y2(), node, id, + RIGHT, NO_MARGIN)); + } + } + } + + /** Records the center edges for the given node to the potential match list */ + protected void addCenter(INode node, String id, + boolean addHorizontal, boolean addVertical) { + Rect b = node.getBounds(); + + if (addHorizontal) { + mCenterHorizEdges.add(new Segment(b.centerY(), b.x, b.x2(), + node, id, CENTER_HORIZONTAL, NO_MARGIN)); + } + if (addVertical) { + mCenterVertEdges.add(new Segment(b.centerX(), b.y, b.y2(), + node, id, CENTER_VERTICAL, NO_MARGIN)); + } + } + + /** Records the baseline edge for the given node to the potential match list */ + protected int addBaseLine(INode node, String id) { + int baselineY = node.getBaseline(); + if (baselineY != -1) { + Rect b = node.getBounds(); + mHorizontalEdges.add(new Segment(b.y + baselineY, b.x, b.x2(), node, id, BASELINE, + NO_MARGIN)); + } + + return baselineY; + } + + protected void snapVertical(Segment vEdge, int x, Rect newBounds) { + newBounds.x = x; + } + + protected void snapHorizontal(Segment hEdge, int y, Rect newBounds) { + newBounds.y = y; + } + + /** + * Returns whether two edge types are compatible. For example, we only match the + * center of one object with the center of another. + * + * @param edge the first edge type to compare + * @param dragged the second edge type to compare the first one with + * @param delta the delta between the two edge locations + * @return true if the two edge types can be compatibly matched + */ + protected boolean isEdgeTypeCompatible(SegmentType edge, SegmentType dragged, int delta) { + + if (Math.abs(delta) > BaseLayoutRule.getMaxMatchDistance()) { + if (dragged == LEFT || dragged == TOP) { + if (delta > 0) { + return false; + } + } else { + if (delta < 0) { + return false; + } + } + } + + switch (edge) { + case BOTTOM: + case TOP: + return dragged == TOP || dragged == BOTTOM; + case LEFT: + case RIGHT: + return dragged == LEFT || dragged == RIGHT; + + // Center horizontal, center vertical and Baseline only matches the same + // type, and only within the matching distance -- no margins! + case BASELINE: + case CENTER_HORIZONTAL: + case CENTER_VERTICAL: + return dragged == edge && Math.abs(delta) < getMaxMatchDistance(); + default: assert false : edge; + } + return false; + } + + /** + * Finds the closest matching segments among the given list of edges for the given + * dragged edge, and returns these as a list of matches + */ + protected List<Match> findClosest(Segment draggedEdge, List<Segment> edges) { + List<Match> closest = new ArrayList<Match>(); + addClosest(draggedEdge, edges, closest); + return closest; + } + + protected void addClosest(Segment draggedEdge, List<Segment> edges, + List<Match> closest) { + int at = draggedEdge.at; + int closestDelta = closest.size() > 0 ? closest.get(0).delta : Integer.MAX_VALUE; + int closestDistance = abs(closestDelta); + for (Segment edge : edges) { + assert draggedEdge.edgeType.isHorizontal() == edge.edgeType.isHorizontal(); + + int delta = edge.at - at; + int distance = abs(delta); + if (distance > closestDistance) { + continue; + } + + if (!isEdgeTypeCompatible(edge.edgeType, draggedEdge.edgeType, delta)) { + continue; + } + + boolean withParent = edge.node == layout; + ConstraintType type = ConstraintType.forMatch(withParent, + draggedEdge.edgeType, edge.edgeType); + if (type == null) { + continue; + } + + // Ensure that the edge match is compatible; for example, a "below" + // constraint can only apply to the margin bounds and a "bottom" + // constraint can only apply to the non-margin bounds. + if (type.relativeToMargin && edge.marginType == WITHOUT_MARGIN) { + continue; + } else if (!type.relativeToMargin && edge.marginType == WITH_MARGIN) { + continue; + } + + Match match = new Match(this, edge, draggedEdge, type, delta); + + if (distance < closestDistance) { + closest.clear(); + closestDistance = distance; + closestDelta = delta; + } else if (delta * closestDelta < 0) { + // They have different signs, e.g. the matches are equal but + // on opposite sides; can't accept them both + continue; + } + closest.add(match); + } + } + + protected void clearSuggestions() { + mHorizontalSuggestions = mVerticalSuggestions = null; + mCurrentLeftMatch = mCurrentRightMatch = null; + mCurrentTopMatch = mCurrentBottomMatch = null; + } + + /** + * Given a node, apply the suggestions by expressing them as relative layout param + * values + * + * @param n the node to apply constraints to + */ + public void applyConstraints(INode n) { + // Process each edge separately + String centerBoth = n.getStringAttr(ANDROID_URI, ATTR_LAYOUT_CENTER_IN_PARENT); + if (centerBoth != null && centerBoth.equals(VALUE_TRUE)) { + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_IN_PARENT, null); + + // If you had a center-in-both-directions attribute, and you're + // only resizing in one dimension, then leave the other dimension + // centered, e.g. if you have centerInParent and apply alignLeft, + // then you should end up with alignLeft and centerVertically + if (mCurrentTopMatch == null && mCurrentBottomMatch == null) { + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_VERTICAL, VALUE_TRUE); + } + if (mCurrentLeftMatch == null && mCurrentRightMatch == null) { + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, VALUE_TRUE); + } + } + + if (mMoveTop) { + // Remove top attachments + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_TOP, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_TOP, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_BELOW, null); + + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_VERTICAL, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_BASELINE, null); + + } + + if (mMoveBottom) { + // Remove bottom attachments + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_BOTTOM, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ABOVE, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_VERTICAL, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_BASELINE, null); + } + + if (mMoveLeft) { + // Remove left attachments + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_LEFT, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_LEFT, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_TO_RIGHT_OF, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, null); + } + + if (mMoveRight) { + // Remove right attachments + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_PARENT_RIGHT, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_ALIGN_RIGHT, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_TO_LEFT_OF, null); + n.setAttribute(ANDROID_URI, ATTR_LAYOUT_CENTER_HORIZONTAL, null); + } + + if (mMoveTop && mCurrentTopMatch != null) { + applyConstraint(n, mCurrentTopMatch.getConstraint(true /* generateId */)); + if (mCurrentTopMatch.type == ALIGN_BASELINE) { + // HACK! WORKAROUND! Baseline doesn't provide a new bottom edge for attachments + String c = mCurrentTopMatch.getConstraint(true); + c = c.replace(ATTR_LAYOUT_ALIGN_BASELINE, ATTR_LAYOUT_ALIGN_BOTTOM); + applyConstraint(n, c); + } + } + + if (mMoveBottom && mCurrentBottomMatch != null) { + applyConstraint(n, mCurrentBottomMatch.getConstraint(true)); + } + + if (mMoveLeft && mCurrentLeftMatch != null) { + applyConstraint(n, mCurrentLeftMatch.getConstraint(true)); + } + + if (mMoveRight && mCurrentRightMatch != null) { + applyConstraint(n, mCurrentRightMatch.getConstraint(true)); + } + + if (mMoveLeft) { + applyMargin(n, ATTR_LAYOUT_MARGIN_LEFT, mLeftMargin); + } + if (mMoveRight) { + applyMargin(n, ATTR_LAYOUT_MARGIN_RIGHT, mRightMargin); + } + if (mMoveTop) { + applyMargin(n, ATTR_LAYOUT_MARGIN_TOP, mTopMargin); + } + if (mMoveBottom) { + applyMargin(n, ATTR_LAYOUT_MARGIN_BOTTOM, mBottomMargin); + } + } + + private void applyConstraint(INode n, String constraint) { + assert constraint.contains("=") : constraint; + String name = constraint.substring(0, constraint.indexOf('=')); + String value = constraint.substring(constraint.indexOf('=') + 1); + n.setAttribute(ANDROID_URI, name, value); + } + + private void applyMargin(INode n, String marginAttribute, int margin) { + if (margin > 0) { + int dp = mRulesEngine.pxToDp(margin); + n.setAttribute(ANDROID_URI, marginAttribute, String.format(VALUE_N_DP, dp)); + } else if (n.getStringAttr(ANDROID_URI, marginAttribute) != null) { + // Clear out existing margin + n.setAttribute(ANDROID_URI, marginAttribute, null); + } + } + + private void removeRelativeParams(INode node) { + for (ConstraintType type : ConstraintType.values()) { + node.setAttribute(ANDROID_URI, type.name, null); + } + node.setAttribute(ANDROID_URI,ATTR_LAYOUT_MARGIN_LEFT, null); + node.setAttribute(ANDROID_URI,ATTR_LAYOUT_MARGIN_RIGHT, null); + node.setAttribute(ANDROID_URI,ATTR_LAYOUT_MARGIN_TOP, null); + node.setAttribute(ANDROID_URI,ATTR_LAYOUT_MARGIN_BOTTOM, null); + } + + /** + * Attach the new child to the previous node + * @param previous the previous child + * @param node the new child to attach it to + */ + public void attachPrevious(INode previous, INode node) { + removeRelativeParams(node); + + String id = previous.getStringAttr(ANDROID_URI, ATTR_ID); + if (id == null) { + return; + } + + if (mCurrentTopMatch != null || mCurrentBottomMatch != null) { + // Attaching the top: arrange below, and for bottom arrange above + node.setAttribute(ANDROID_URI, + mCurrentTopMatch != null ? ATTR_LAYOUT_BELOW : ATTR_LAYOUT_ABOVE, id); + // Apply same left/right constraints as the parent + if (mCurrentLeftMatch != null) { + applyConstraint(node, mCurrentLeftMatch.getConstraint(true)); + applyMargin(node, ATTR_LAYOUT_MARGIN_LEFT, mLeftMargin); + } else if (mCurrentRightMatch != null) { + applyConstraint(node, mCurrentRightMatch.getConstraint(true)); + applyMargin(node, ATTR_LAYOUT_MARGIN_RIGHT, mRightMargin); + } + } else if (mCurrentLeftMatch != null || mCurrentRightMatch != null) { + node.setAttribute(ANDROID_URI, + mCurrentLeftMatch != null ? ATTR_LAYOUT_TO_RIGHT_OF : ATTR_LAYOUT_TO_LEFT_OF, + id); + // Apply same top/bottom constraints as the parent + if (mCurrentTopMatch != null) { + applyConstraint(node, mCurrentTopMatch.getConstraint(true)); + applyMargin(node, ATTR_LAYOUT_MARGIN_TOP, mTopMargin); + } else if (mCurrentBottomMatch != null) { + applyConstraint(node, mCurrentBottomMatch.getConstraint(true)); + applyMargin(node, ATTR_LAYOUT_MARGIN_BOTTOM, mBottomMargin); + } + } else { + return; + } + } + + /** Breaks any cycles detected by the handler */ + public void removeCycles() { + if (mHorizontalCycle != null) { + removeCycles(mHorizontalDeps); + } + if (mVerticalCycle != null) { + removeCycles(mVerticalDeps); + } + } + + private void removeCycles(Set<INode> deps) { + for (INode node : mDraggedNodes) { + ViewData view = mDependencyGraph.getView(node); + if (view != null) { + for (Constraint constraint : view.dependedOnBy) { + // For now, remove ALL constraints pointing to this node in this orientation. + // Later refine this to be smarter. (We can't JUST remove the constraints + // identified in the cycle since there could be multiple.) + constraint.from.node.setAttribute(ANDROID_URI, constraint.type.name, null); + } + } + } + } + + /** + * Comparator used to sort matches such that the first match is the most desirable + * match (where we prefer attaching to parent bounds, we avoid matches that lead to a + * cycle, we prefer constraints on closer widgets rather than ones further away, and + * so on.) + * <p> + * There are a number of sorting criteria. One of them is the distance between the + * matched edges. We may end up with multiple matches that are the same distance. In + * that case we look at the orientation; on the left side, prefer left-oriented + * attachments, and on the right-side prefer right-oriented attachments. For example, + * consider the following scenario: + * + * <pre> + * +--------------------+-------------------------+ + * | Attached on left | | + * +--------------------+ | + * | | + * | +-----+ | + * | | A | | + * | +-----+ | + * | | + * | +-------------------------+ + * | | Attached on right | + * +--------------------+-------------------------+ + * </pre> + * + * Here, dragging the left edge should attach to the top left attached view, whereas + * in the following layout dragging the right edge would attach to the bottom view: + * + * <pre> + * +--------------------------+-------------------+ + * | Attached on left | | + * +--------------------------+ | + * | | + * | +-----+ | + * | | A | | + * | +-----+ | + * | | + * | +-------------------+ + * | | Attached on right | + * +--------------------------+-------------------+ + * + * </pre> + * + * </ul> + */ + private final class MatchComparator implements Comparator<Match> { + @Override + public int compare(Match m1, Match m2) { + // Always prefer matching parent bounds + int parent1 = m1.edge.node == layout ? -1 : 1; + int parent2 = m2.edge.node == layout ? -1 : 1; + // unless it's a center bound -- those should always get lowest priority since + // they overlap with other usually more interesting edges near the center of + // the layout. + if (m1.edge.edgeType == CENTER_HORIZONTAL + || m1.edge.edgeType == CENTER_VERTICAL) { + parent1 = 2; + } + if (m2.edge.edgeType == CENTER_HORIZONTAL + || m2.edge.edgeType == CENTER_VERTICAL) { + parent2 = 2; + } + if (parent1 != parent2) { + return parent1 - parent2; + } + + // Avoid matching edges that would lead to a cycle + if (m1.edge.edgeType.isHorizontal()) { + int cycle1 = mHorizontalDeps.contains(m1.edge.node) ? 1 : -1; + int cycle2 = mHorizontalDeps.contains(m2.edge.node) ? 1 : -1; + if (cycle1 != cycle2) { + return cycle1 - cycle2; + } + } else { + int cycle1 = mVerticalDeps.contains(m1.edge.node) ? 1 : -1; + int cycle2 = mVerticalDeps.contains(m2.edge.node) ? 1 : -1; + if (cycle1 != cycle2) { + return cycle1 - cycle2; + } + } + + // TODO: Sort by minimum depth -- do we have the depth anywhere? + + // Prefer nodes that are closer + int distance1, distance2; + if (m1.edge.to <= m1.with.from) { + distance1 = m1.with.from - m1.edge.to; + } else if (m1.edge.from >= m1.with.to) { + distance1 = m1.edge.from - m1.with.to; + } else { + // Some kind of overlap - not sure how to prioritize these yet... + distance1 = 0; + } + if (m2.edge.to <= m2.with.from) { + distance2 = m2.with.from - m2.edge.to; + } else if (m2.edge.from >= m2.with.to) { + distance2 = m2.edge.from - m2.with.to; + } else { + // Some kind of overlap - not sure how to prioritize these yet... + distance2 = 0; + } + + if (distance1 != distance2) { + return distance1 - distance2; + } + + // Prefer matching on baseline + int baseline1 = (m1.edge.edgeType == BASELINE) ? -1 : 1; + int baseline2 = (m2.edge.edgeType == BASELINE) ? -1 : 1; + if (baseline1 != baseline2) { + return baseline1 - baseline2; + } + + // Prefer matching top/left edges before matching bottom/right edges + int orientation1 = (m1.with.edgeType == LEFT || + m1.with.edgeType == TOP) ? -1 : 1; + int orientation2 = (m2.with.edgeType == LEFT || + m2.with.edgeType == TOP) ? -1 : 1; + if (orientation1 != orientation2) { + return orientation1 - orientation2; + } + + // Prefer opposite-matching over same-matching. + // In other words, if we have the choice of matching + // our left edge with another element's left edge, + // or matching our left edge with another element's right + // edge, prefer the right edge since that + // The two matches have identical distance; try to sort by + // orientation + int edgeType1 = (m1.edge.edgeType != m1.with.edgeType) ? -1 : 1; + int edgeType2 = (m2.edge.edgeType != m2.with.edgeType) ? -1 : 1; + if (edgeType1 != edgeType2) { + return edgeType1 - edgeType2; + } + + return 0; + } + } + + /** + * Returns the {@link IClientRulesEngine} IDE callback + * + * @return the {@link IClientRulesEngine} IDE callback, never null + */ + public IClientRulesEngine getRulesEngine() { + return mRulesEngine; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/GuidelinePainter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/GuidelinePainter.java new file mode 100644 index 000000000..2fe74768f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/GuidelinePainter.java @@ -0,0 +1,208 @@ +/* + * 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.common.layout.relative; + +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.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IFeedbackPainter; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.relative.DependencyGraph.Constraint; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The {@link GuidelinePainter} is responsible for painting guidelines during an operation + * which uses a {@link GuidelineHandler} such as a resize operation. + */ +public final class GuidelinePainter implements IFeedbackPainter { + // ---- Implements IFeedbackPainter ---- + @Override + public void paint(@NonNull IGraphics gc, @NonNull INode node, @NonNull DropFeedback feedback) { + GuidelineHandler state = (GuidelineHandler) feedback.userData; + + for (INode dragged : state.mDraggedNodes) { + gc.useStyle(DrawingStyle.DRAGGED); + Rect bounds = dragged.getBounds(); + if (bounds.isValid()) { + gc.fillRect(bounds); + } + } + + Set<INode> horizontalDeps = state.mHorizontalDeps; + Set<INode> verticalDeps = state.mVerticalDeps; + Set<INode> deps = new HashSet<INode>(horizontalDeps.size() + verticalDeps.size()); + deps.addAll(horizontalDeps); + deps.addAll(verticalDeps); + if (deps.size() > 0) { + gc.useStyle(DrawingStyle.DEPENDENCY); + for (INode n : deps) { + // Don't highlight the selected nodes themselves + if (state.mDraggedNodes.contains(n)) { + continue; + } + Rect bounds = n.getBounds(); + gc.fillRect(bounds); + } + } + + if (state.mBounds != null) { + if (state instanceof MoveHandler) { + gc.useStyle(DrawingStyle.DROP_PREVIEW); + } else { + // Resizing + if (state.haveSuggestions()) { + gc.useStyle(DrawingStyle.RESIZE_PREVIEW); + } else { + gc.useStyle(DrawingStyle.RESIZE_FAIL); + } + } + gc.drawRect(state.mBounds); + + // Draw baseline preview too + if (feedback.dragBaseline != -1) { + int y = state.mBounds.y + feedback.dragBaseline; + gc.drawLine(state.mBounds.x, y, state.mBounds.x2(), y); + } + } + + List<String> strings = new ArrayList<String>(); + + showMatch(gc, state.mCurrentLeftMatch, state, strings, + state.mLeftMargin, ATTR_LAYOUT_MARGIN_LEFT); + showMatch(gc, state.mCurrentRightMatch, state, strings, + state.mRightMargin, ATTR_LAYOUT_MARGIN_RIGHT); + showMatch(gc, state.mCurrentTopMatch, state, strings, + state.mTopMargin, ATTR_LAYOUT_MARGIN_TOP); + showMatch(gc, state.mCurrentBottomMatch, state, strings, + state.mBottomMargin, ATTR_LAYOUT_MARGIN_BOTTOM); + + if (strings.size() > 0) { + // Update the drag tooltip + StringBuilder sb = new StringBuilder(200); + for (String s : strings) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(s); + } + feedback.tooltip = sb.toString(); + + // Set the tooltip orientation to ensure that it does not interfere with + // the constraint arrows + if (state.mCurrentLeftMatch != null) { + feedback.tooltipX = SegmentType.RIGHT; + } else if (state.mCurrentRightMatch != null) { + feedback.tooltipX = SegmentType.LEFT; + } + if (state.mCurrentTopMatch != null) { + feedback.tooltipY = SegmentType.BOTTOM; + } else if (state.mCurrentBottomMatch != null) { + feedback.tooltipY = SegmentType.TOP; + } + } else { + feedback.tooltip = null; + } + + if (state.mHorizontalCycle != null) { + paintCycle(gc, state, state.mHorizontalCycle); + } + if (state.mVerticalCycle != null) { + paintCycle(gc, state, state.mVerticalCycle); + } + } + + /** Paints a particular match constraint */ + private void showMatch(IGraphics gc, Match m, GuidelineHandler state, List<String> strings, + int margin, String marginAttribute) { + if (m == null) { + return; + } + ConstraintPainter.paintConstraint(gc, state.mBounds, m); + + // Display the constraint. Remove the @id/ and @+id/ prefixes to make the text + // shorter and easier to read. This doesn't use stripPrefix() because the id is + // usually not a prefix of the value (for example, 'layout_alignBottom=@+id/foo'). + String constraint = m.getConstraint(false /* generateId */); + String description = constraint.replace(NEW_ID_PREFIX, "").replace(ID_PREFIX, ""); + if (description.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + description = description.substring(ATTR_LAYOUT_RESOURCE_PREFIX.length()); + } + if (margin > 0) { + int dp = state.getRulesEngine().pxToDp(margin); + description = String.format("%1$s, margin=%2$d dp", description, dp); + } + strings.add(description); + } + + /** Paints a constraint cycle */ + void paintCycle(IGraphics gc, GuidelineHandler state, List<Constraint> cycle) { + gc.useStyle(DrawingStyle.CYCLE); + assert cycle.size() > 0; + + INode from = cycle.get(0).from.node; + Rect fromBounds = from.getBounds(); + if (state.mDraggedNodes.contains(from)) { + fromBounds = state.mBounds; + } + Point fromCenter = fromBounds.center(); + INode to = null; + + List<Point> points = new ArrayList<Point>(); + points.add(fromCenter); + + for (Constraint constraint : cycle) { + assert constraint.from.node == from; + to = constraint.to.node; + assert from != null && to != null; + + Point toCenter = to.getBounds().center(); + points.add(toCenter); + + // Also go through the dragged node bounds + boolean isDragged = state.mDraggedNodes.contains(to); + if (isDragged) { + toCenter = state.mBounds.center(); + points.add(toCenter); + } + + from = to; + fromCenter = toCenter; + } + + points.add(fromCenter); + points.add(points.get(0)); + + for (int i = 1, n = points.size(); i < n; i++) { + gc.drawLine(points.get(i-1), points.get(i)); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/Match.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/Match.java new file mode 100644 index 000000000..6f3f0d0f7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/Match.java @@ -0,0 +1,100 @@ +/* + * 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.common.layout.relative; + +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.VALUE_TRUE; + + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.ide.common.api.Segment; + +/** A match is a potential pairing of two segments with a given {@link ConstraintType}. */ +class Match { + /** the edge of the dragged node that is matched */ + public final Segment with; + + /** the "other" edge that the dragged edge is matched with */ + public final Segment edge; + + /** the signed distance between the matched edges */ + public final int delta; + + /** the type of constraint this is a match for */ + public final ConstraintType type; + + /** whether this {@link Match} results in a cycle */ + public boolean cycle; + + /** The associated {@link GuidelineHander} which performed the match */ + private final GuidelineHandler mHandler; + + /** + * Create a new match. + * + * @param handler the handler which performed the match + * @param edge the "other" edge that the dragged edge is matched with + * @param with the edge of the dragged node that is matched + * @param type the type of constraint this is a match for + * @param delta the signed distance between the matched edges + */ + public Match(GuidelineHandler handler, Segment edge, Segment with, + ConstraintType type, int delta) { + mHandler = handler; + + this.edge = edge; + this.with = with; + this.type = type; + this.delta = delta; + } + + /** + * Returns the XML constraint attribute value for this match + * + * @param generateId whether an id should be generated if one is missing + * @return the XML constraint attribute value for this match + */ + public String getConstraint(boolean generateId) { + if (type.targetParent) { + return type.name + '=' + VALUE_TRUE; + } else { + String id = edge.id; + if (id == null || id.length() == -1) { + if (!generateId) { + // Placeholder to display for the user during dragging + id = "<generated>"; + } else { + // Must generate an id on the fly! + // See if it's been set by a different constraint we've already applied + // to this same node + id = edge.node.getStringAttr(ANDROID_URI, ATTR_ID); + if (id == null || id.length() == 0) { + id = mHandler.getRulesEngine().getUniqueId(edge.node.getFqcn()); + edge.node.setAttribute(ANDROID_URI, ATTR_ID, id); + } + } + } + return type.name + '=' + id; + } + } + + @Override + public String toString() { + return "Match [type=" + type + ", delta=" + delta + ", edge=" + edge + + "]"; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/MoveHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/MoveHandler.java new file mode 100644 index 000000000..0fa915d81 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/MoveHandler.java @@ -0,0 +1,299 @@ +/* + * 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.common.layout.relative; + +import static com.android.ide.common.api.MarginType.NO_MARGIN; +import static com.android.ide.common.api.SegmentType.BASELINE; +import static com.android.ide.common.api.SegmentType.BOTTOM; +import static com.android.ide.common.api.SegmentType.CENTER_HORIZONTAL; +import static com.android.ide.common.api.SegmentType.CENTER_VERTICAL; +import static com.android.ide.common.api.SegmentType.LEFT; +import static com.android.ide.common.api.SegmentType.RIGHT; +import static com.android.ide.common.api.SegmentType.TOP; +import static com.android.SdkConstants.ATTR_ID; + +import static java.lang.Math.abs; + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.Segment; +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.common.layout.relative.DependencyGraph.ViewData; + +import java.util.ArrayList; +import java.util.List; + +/** + * A {@link MoveHandler} is a {@link GuidelineHandler} which handles move and drop + * gestures, and offers guideline suggestions and snapping. + * <p> + * Unlike the {@link ResizeHandler}, the {@link MoveHandler} looks for matches for all + * different segment types -- the left edge, the right edge, the baseline, the center + * edges, and so on -- and picks the best among these. + */ +public class MoveHandler extends GuidelineHandler { + private int mDraggedBaseline; + + /** + * Creates a new {@link MoveHandler}. + * + * @param layout the layout element the handler is operating on + * @param elements the elements being dragged in the move operation + * @param rulesEngine the corresponding {@link IClientRulesEngine} + */ + public MoveHandler(INode layout, IDragElement[] elements, IClientRulesEngine rulesEngine) { + super(layout, rulesEngine); + + // Compute list of nodes being dragged within the layout, if any + List<INode> nodes = new ArrayList<INode>(); + for (IDragElement element : elements) { + ViewData view = mDependencyGraph.getView(element); + if (view != null) { + nodes.add(view.node); + } + } + mDraggedNodes = nodes; + + mHorizontalDeps = mDependencyGraph.dependsOn(nodes, false /* verticalEdge */); + mVerticalDeps = mDependencyGraph.dependsOn(nodes, true /* verticalEdge */); + + for (INode child : layout.getChildren()) { + Rect bc = child.getBounds(); + if (bc.isValid()) { + // First see if this node looks like it's the same as one of the + // *dragged* bounds + boolean isDragged = false; + for (IDragElement element : elements) { + // This tries to determine if an INode corresponds to an + // IDragElement, by comparing their bounds. + if (bc.equals(element.getBounds())) { + isDragged = true; + } + } + + if (!isDragged) { + String id = child.getStringAttr(ANDROID_URI, ATTR_ID); + // It's okay for id to be null; if you apply a constraint + // to a node with a missing id we will generate the id + + boolean addHorizontal = !mHorizontalDeps.contains(child); + boolean addVertical = !mVerticalDeps.contains(child); + + addBounds(child, id, addHorizontal, addVertical); + if (addHorizontal) { + addBaseLine(child, id); + } + } + } + } + + String id = layout.getStringAttr(ANDROID_URI, ATTR_ID); + addBounds(layout, id, true, true); + addCenter(layout, id, true, true); + } + + @Override + protected void snapVertical(Segment vEdge, int x, Rect newBounds) { + int maxDistance = BaseLayoutRule.getMaxMatchDistance(); + if (vEdge.edgeType == LEFT) { + int margin = !mSnap ? 0 : abs(newBounds.x - x); + if (margin > maxDistance) { + mLeftMargin = margin; + } else { + newBounds.x = x; + } + } else if (vEdge.edgeType == RIGHT) { + int margin = !mSnap ? 0 : abs(newBounds.x - (x - newBounds.w)); + if (margin > maxDistance) { + mRightMargin = margin; + } else { + newBounds.x = x - newBounds.w; + } + } else if (vEdge.edgeType == CENTER_VERTICAL) { + newBounds.x = x - newBounds.w / 2; + } else { + assert false : vEdge; + } + } + + // TODO: Consider unifying this with the snapping logic in ResizeHandler + @Override + protected void snapHorizontal(Segment hEdge, int y, Rect newBounds) { + int maxDistance = BaseLayoutRule.getMaxMatchDistance(); + if (hEdge.edgeType == TOP) { + int margin = !mSnap ? 0 : abs(newBounds.y - y); + if (margin > maxDistance) { + mTopMargin = margin; + } else { + newBounds.y = y; + } + } else if (hEdge.edgeType == BOTTOM) { + int margin = !mSnap ? 0 : abs(newBounds.y - (y - newBounds.h)); + if (margin > maxDistance) { + mBottomMargin = margin; + } else { + newBounds.y = y - newBounds.h; + } + } else if (hEdge.edgeType == CENTER_HORIZONTAL) { + int margin = !mSnap ? 0 : abs(newBounds.y - (y - newBounds.h / 2)); + if (margin > maxDistance) { + mTopMargin = margin; + // or bottomMargin? + } else { + newBounds.y = y - newBounds.h / 2; + } + } else if (hEdge.edgeType == BASELINE) { + newBounds.y = y - mDraggedBaseline; + } else { + assert false : hEdge; + } + } + + /** + * Updates the handler for the given mouse move + * + * @param feedback the feedback handler + * @param elements the elements being dragged + * @param offsetX the new mouse X coordinate + * @param offsetY the new mouse Y coordinate + * @param modifierMask the keyboard modifiers pressed during the drag + */ + public void updateMove(DropFeedback feedback, IDragElement[] elements, + int offsetX, int offsetY, int modifierMask) { + mSnap = (modifierMask & DropFeedback.MODIFIER2) == 0; + + Rect firstBounds = elements[0].getBounds(); + INode firstNode = null; + if (mDraggedNodes != null && mDraggedNodes.size() > 0) { + // TODO - this isn't quite right; this could be a different node than we have + // bounds for! + firstNode = mDraggedNodes.iterator().next(); + firstBounds = firstNode.getBounds(); + } + + mBounds = new Rect(offsetX, offsetY, firstBounds.w, firstBounds.h); + Rect layoutBounds = layout.getBounds(); + if (mBounds.x2() > layoutBounds.x2()) { + mBounds.x -= mBounds.x2() - layoutBounds.x2(); + } + if (mBounds.y2() > layoutBounds.y2()) { + mBounds.y -= mBounds.y2() - layoutBounds.y2(); + } + if (mBounds.x < layoutBounds.x) { + mBounds.x = layoutBounds.x; + } + if (mBounds.y < layoutBounds.y) { + mBounds.y = layoutBounds.y; + } + + clearSuggestions(); + + Rect b = mBounds; + Segment edge = new Segment(b.y, b.x, b.x2(), null, null, TOP, NO_MARGIN); + List<Match> horizontalMatches = findClosest(edge, mHorizontalEdges); + edge = new Segment(b.y2(), b.x, b.x2(), null, null, BOTTOM, NO_MARGIN); + addClosest(edge, mHorizontalEdges, horizontalMatches); + + edge = new Segment(b.x, b.y, b.y2(), null, null, LEFT, NO_MARGIN); + List<Match> verticalMatches = findClosest(edge, mVerticalEdges); + edge = new Segment(b.x2(), b.y, b.y2(), null, null, RIGHT, NO_MARGIN); + addClosest(edge, mVerticalEdges, verticalMatches); + + // Match center + edge = new Segment(b.centerX(), b.y, b.y2(), null, null, CENTER_VERTICAL, NO_MARGIN); + addClosest(edge, mCenterVertEdges, verticalMatches); + edge = new Segment(b.centerY(), b.x, b.x2(), null, null, CENTER_HORIZONTAL, NO_MARGIN); + addClosest(edge, mCenterHorizEdges, horizontalMatches); + + // Match baseline + if (firstNode != null) { + int baseline = firstNode.getBaseline(); + if (baseline != -1) { + mDraggedBaseline = baseline; + edge = new Segment(b.y + baseline, b.x, b.x2(), firstNode, null, BASELINE, + NO_MARGIN); + addClosest(edge, mHorizontalEdges, horizontalMatches); + } + } else { + int baseline = feedback.dragBaseline; + if (baseline != -1) { + mDraggedBaseline = baseline; + edge = new Segment(offsetY + baseline, b.x, b.x2(), null, null, BASELINE, + NO_MARGIN); + addClosest(edge, mHorizontalEdges, horizontalMatches); + } + } + + mHorizontalSuggestions = horizontalMatches; + mVerticalSuggestions = verticalMatches; + mTopMargin = mBottomMargin = mLeftMargin = mRightMargin = 0; + + Match match = pickBestMatch(mHorizontalSuggestions); + if (match != null) { + if (mHorizontalDeps.contains(match.edge.node)) { + match.cycle = true; + } + + // Reset top AND bottom bounds regardless of whether both are bound + mMoveTop = true; + mMoveBottom = true; + + // TODO: Consider doing the snap logic on all the possible matches + // BEFORE sorting, in case this affects the best-pick algorithm (since some + // edges snap and others don't). + snapHorizontal(match.with, match.edge.at, mBounds); + + if (match.with.edgeType == TOP) { + mCurrentTopMatch = match; + } else if (match.with.edgeType == BOTTOM) { + mCurrentBottomMatch = match; + } else { + assert match.with.edgeType == CENTER_HORIZONTAL + || match.with.edgeType == BASELINE : match.with.edgeType; + mCurrentTopMatch = match; + } + } + + match = pickBestMatch(mVerticalSuggestions); + if (match != null) { + if (mVerticalDeps.contains(match.edge.node)) { + match.cycle = true; + } + + // Reset left AND right bounds regardless of whether both are bound + mMoveLeft = true; + mMoveRight = true; + + snapVertical(match.with, match.edge.at, mBounds); + + if (match.with.edgeType == LEFT) { + mCurrentLeftMatch = match; + } else if (match.with.edgeType == RIGHT) { + mCurrentRightMatch = match; + } else { + assert match.with.edgeType == CENTER_VERTICAL; + mCurrentLeftMatch = match; + } + } + + checkCycles(feedback); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ResizeHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ResizeHandler.java new file mode 100644 index 000000000..a5e071d74 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/relative/ResizeHandler.java @@ -0,0 +1,265 @@ +/* + * 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.common.layout.relative; + +import static com.android.ide.common.api.MarginType.NO_MARGIN; +import static com.android.ide.common.api.SegmentType.BASELINE; +import static com.android.ide.common.api.SegmentType.BOTTOM; +import static com.android.ide.common.api.SegmentType.CENTER_HORIZONTAL; +import static com.android.ide.common.api.SegmentType.CENTER_VERTICAL; +import static com.android.ide.common.api.SegmentType.LEFT; +import static com.android.ide.common.api.SegmentType.RIGHT; +import static com.android.ide.common.api.SegmentType.TOP; +import static com.android.SdkConstants.ATTR_ID; + +import static java.lang.Math.abs; + +import com.android.SdkConstants; +import static com.android.SdkConstants.ANDROID_URI; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IClientRulesEngine; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.Segment; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.BaseLayoutRule; + +import java.util.Collections; +import java.util.Set; + +/** + * A {@link ResizeHandler} is a {@link GuidelineHandler} which handles resizing of individual + * edges in a RelativeLayout. + */ +public class ResizeHandler extends GuidelineHandler { + private final SegmentType mHorizontalEdgeType; + private final SegmentType mVerticalEdgeType; + + /** + * Creates a new {@link ResizeHandler} + * + * @param layout the layout containing the resized node + * @param resized the node being resized + * @param rulesEngine the applicable {@link IClientRulesEngine} + * @param horizontalEdgeType the type of horizontal edge being resized, or null + * @param verticalEdgeType the type of vertical edge being resized, or null + */ + public ResizeHandler(INode layout, INode resized, + IClientRulesEngine rulesEngine, + SegmentType horizontalEdgeType, SegmentType verticalEdgeType) { + super(layout, rulesEngine); + + assert horizontalEdgeType != null || verticalEdgeType != null; + assert horizontalEdgeType != BASELINE && verticalEdgeType != BASELINE; + assert horizontalEdgeType != CENTER_HORIZONTAL && verticalEdgeType != CENTER_HORIZONTAL; + assert horizontalEdgeType != CENTER_VERTICAL && verticalEdgeType != CENTER_VERTICAL; + + mHorizontalEdgeType = horizontalEdgeType; + mVerticalEdgeType = verticalEdgeType; + + Set<INode> nodes = Collections.singleton(resized); + mDraggedNodes = nodes; + + mHorizontalDeps = mDependencyGraph.dependsOn(nodes, false /* vertical */); + mVerticalDeps = mDependencyGraph.dependsOn(nodes, true /* vertical */); + + if (horizontalEdgeType != null) { + if (horizontalEdgeType == TOP) { + mMoveTop = true; + } else if (horizontalEdgeType == BOTTOM) { + mMoveBottom = true; + } + } + if (verticalEdgeType != null) { + if (verticalEdgeType == LEFT) { + mMoveLeft = true; + } else if (verticalEdgeType == RIGHT) { + mMoveRight = true; + } + } + + for (INode child : layout.getChildren()) { + if (child != resized) { + String id = child.getStringAttr(ANDROID_URI, ATTR_ID); + addBounds(child, id, + !mHorizontalDeps.contains(child), + !mVerticalDeps.contains(child)); + } + } + + addBounds(layout, layout.getStringAttr(ANDROID_URI, ATTR_ID), true, true); + } + + @Override + protected void snapVertical(Segment vEdge, int x, Rect newBounds) { + int maxDistance = BaseLayoutRule.getMaxMatchDistance(); + if (vEdge.edgeType == LEFT) { + int margin = mSnap ? 0 : abs(newBounds.x - x); + if (margin > maxDistance) { + mLeftMargin = margin; + } else { + newBounds.w += newBounds.x - x; + newBounds.x = x; + } + } else if (vEdge.edgeType == RIGHT) { + int margin = mSnap ? 0 : abs(newBounds.x - (x - newBounds.w)); + if (margin > maxDistance) { + mRightMargin = margin; + } else { + newBounds.w = x - newBounds.x; + } + } else { + assert false : vEdge; + } + } + + @Override + protected void snapHorizontal(Segment hEdge, int y, Rect newBounds) { + int maxDistance = BaseLayoutRule.getMaxMatchDistance(); + if (hEdge.edgeType == TOP) { + int margin = mSnap ? 0 : abs(newBounds.y - y); + if (margin > maxDistance) { + mTopMargin = margin; + } else { + newBounds.h += newBounds.y - y; + newBounds.y = y; + } + } else if (hEdge.edgeType == BOTTOM) { + int margin = mSnap ? 0 : abs(newBounds.y - (y - newBounds.h)); + if (margin > maxDistance) { + mBottomMargin = margin; + } else { + newBounds.h = y - newBounds.y; + } + } else { + assert false : hEdge; + } + } + + @Override + protected boolean isEdgeTypeCompatible(SegmentType edge, SegmentType dragged, int delta) { + boolean compatible = super.isEdgeTypeCompatible(edge, dragged, delta); + + // When resizing and not snapping (e.g. using margins to pick a specific pixel + // width) we cannot use -negative- margins to jump back to a closer edge; we + // must always use positive margins, so mark closer edges that result in a negative + // margin as not compatible. + if (compatible && !mSnap) { + switch (dragged) { + case LEFT: + case TOP: + return delta <= 0; + default: + return delta >= 0; + } + } + + return compatible; + } + + /** + * Updates the handler for the given mouse resize + * + * @param feedback the feedback handler + * @param child the node being resized + * @param newBounds the new bounds of the resize rectangle + * @param modifierMask the keyboard modifiers pressed during the drag + */ + public void updateResize(DropFeedback feedback, INode child, Rect newBounds, + int modifierMask) { + mSnap = (modifierMask & DropFeedback.MODIFIER2) == 0; + mBounds = newBounds; + clearSuggestions(); + + Rect b = newBounds; + Segment hEdge = null; + Segment vEdge = null; + String childId = child.getStringAttr(ANDROID_URI, ATTR_ID); + + // TODO: MarginType=NO_MARGIN may not be right. Consider resizing a widget + // that has margins and how that should be handled. + + if (mHorizontalEdgeType == TOP) { + hEdge = new Segment(b.y, b.x, b.x2(), child, childId, mHorizontalEdgeType, NO_MARGIN); + } else if (mHorizontalEdgeType == BOTTOM) { + hEdge = new Segment(b.y2(), b.x, b.x2(), child, childId, mHorizontalEdgeType, + NO_MARGIN); + } else { + assert mHorizontalEdgeType == null; + } + + if (mVerticalEdgeType == LEFT) { + vEdge = new Segment(b.x, b.y, b.y2(), child, childId, mVerticalEdgeType, NO_MARGIN); + } else if (mVerticalEdgeType == RIGHT) { + vEdge = new Segment(b.x2(), b.y, b.y2(), child, childId, mVerticalEdgeType, NO_MARGIN); + } else { + assert mVerticalEdgeType == null; + } + + mTopMargin = mBottomMargin = mLeftMargin = mRightMargin = 0; + + if (hEdge != null && mHorizontalEdges.size() > 0) { + // Compute horizontal matches + mHorizontalSuggestions = findClosest(hEdge, mHorizontalEdges); + + Match match = pickBestMatch(mHorizontalSuggestions); + if (match != null + && (!mSnap || Math.abs(match.delta) < BaseLayoutRule.getMaxMatchDistance())) { + if (mHorizontalDeps.contains(match.edge.node)) { + match.cycle = true; + } + + snapHorizontal(hEdge, match.edge.at, newBounds); + + if (hEdge.edgeType == TOP) { + mCurrentTopMatch = match; + } else if (hEdge.edgeType == BOTTOM) { + mCurrentBottomMatch = match; + } else { + assert hEdge.edgeType == CENTER_HORIZONTAL + || hEdge.edgeType == BASELINE : hEdge; + mCurrentTopMatch = match; + } + } + } + + if (vEdge != null && mVerticalEdges.size() > 0) { + mVerticalSuggestions = findClosest(vEdge, mVerticalEdges); + + Match match = pickBestMatch(mVerticalSuggestions); + if (match != null + && (!mSnap || Math.abs(match.delta) < BaseLayoutRule.getMaxMatchDistance())) { + if (mVerticalDeps.contains(match.edge.node)) { + match.cycle = true; + } + + // Snap + snapVertical(vEdge, match.edge.at, newBounds); + + if (vEdge.edgeType == LEFT) { + mCurrentLeftMatch = match; + } else if (vEdge.edgeType == RIGHT) { + mCurrentRightMatch = match; + } else { + assert vEdge.edgeType == CENTER_VERTICAL; + mCurrentLeftMatch = match; + } + } + } + + checkCycles(feedback); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/removecol.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/removecol.png Binary files differnew file mode 100644 index 000000000..c41261afa --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/removecol.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/removerow.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/removerow.png Binary files differnew file mode 100644 index 000000000..db695a714 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/removerow.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/showgrid.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/showgrid.png Binary files differnew file mode 100644 index 000000000..6f7bf9160 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/showgrid.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/snap.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/snap.png Binary files differnew file mode 100644 index 000000000..b50a16ed1 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/snap.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/structure.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/structure.png Binary files differnew file mode 100644 index 000000000..e5d753885 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/structure.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/vlinear.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/vlinear.png Binary files differnew file mode 100644 index 000000000..e03c16e00 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/vlinear.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/weights.png b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/weights.png Binary files differnew file mode 100644 index 000000000..cb654a140 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/weights.png diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/AttributeInfo.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/AttributeInfo.java new file mode 100755 index 000000000..e246975bb --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/AttributeInfo.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2008 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.common.resources.platform; + +import static com.android.SdkConstants.ANDROID_PREFIX; +import static com.android.SdkConstants.ANDROID_THEME_PREFIX; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.PREFIX_THEME_REF; +import static com.android.SdkConstants.VALUE_FALSE; +import static com.android.SdkConstants.VALUE_TRUE; +import static com.android.ide.common.api.IAttributeInfo.Format.BOOLEAN; +import static com.android.ide.common.api.IAttributeInfo.Format.COLOR; +import static com.android.ide.common.api.IAttributeInfo.Format.DIMENSION; +import static com.android.ide.common.api.IAttributeInfo.Format.ENUM; +import static com.android.ide.common.api.IAttributeInfo.Format.FLAG; +import static com.android.ide.common.api.IAttributeInfo.Format.FLOAT; +import static com.android.ide.common.api.IAttributeInfo.Format.FRACTION; +import static com.android.ide.common.api.IAttributeInfo.Format.INTEGER; +import static com.android.ide.common.api.IAttributeInfo.Format.STRING; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.common.resources.ResourceRepository; +import com.android.resources.ResourceType; +import com.google.common.base.Splitter; + +import java.util.EnumSet; +import java.util.regex.Pattern; + + +/** + * Information about an attribute as gathered from the attrs.xml file where + * the attribute was declared. This must include a format (string, reference, float, etc.), + * possible flag or enum values, whether it's deprecated and its javadoc. + */ +public class AttributeInfo implements IAttributeInfo { + /** XML Name of the attribute */ + private String mName; + + /** Formats of the attribute. Cannot be null. Should have at least one format. */ + private EnumSet<Format> mFormats; + /** Values for enum. null for other types. */ + private String[] mEnumValues; + /** Values for flag. null for other types. */ + private String[] mFlagValues; + /** Short javadoc (i.e. the first sentence). */ + private String mJavaDoc; + /** Documentation for deprecated attributes. Null if not deprecated. */ + private String mDeprecatedDoc; + /** The source class defining this attribute */ + private String mDefinedBy; + + /** + * @param name The XML Name of the attribute + * @param formats The formats of the attribute. Cannot be null. + * Should have at least one format. + */ + public AttributeInfo(@NonNull String name, @NonNull EnumSet<Format> formats) { + mName = name; + mFormats = formats; + } + + /** + * @param name The XML Name of the attribute + * @param formats The formats of the attribute. Cannot be null. + * Should have at least one format. + * @param javadoc Short javadoc (i.e. the first sentence). + */ + public AttributeInfo(@NonNull String name, @NonNull EnumSet<Format> formats, String javadoc) { + mName = name; + mFormats = formats; + mJavaDoc = javadoc; + } + + public AttributeInfo(AttributeInfo info) { + mName = info.mName; + mFormats = info.mFormats; + mEnumValues = info.mEnumValues; + mFlagValues = info.mFlagValues; + mJavaDoc = info.mJavaDoc; + mDeprecatedDoc = info.mDeprecatedDoc; + } + + /** + * Sets the XML Name of the attribute + * + * @param name the new name to assign + */ + public void setName(String name) { + mName = name; + } + + /** Returns the XML Name of the attribute */ + @Override + public @NonNull String getName() { + return mName; + } + /** Returns the formats of the attribute. Cannot be null. + * Should have at least one format. */ + @Override + public @NonNull EnumSet<Format> getFormats() { + return mFormats; + } + /** Returns the values for enums. null for other types. */ + @Override + public String[] getEnumValues() { + return mEnumValues; + } + /** Returns the values for flags. null for other types. */ + @Override + public String[] getFlagValues() { + return mFlagValues; + } + /** Returns a short javadoc, .i.e. the first sentence. */ + @Override + public @NonNull String getJavaDoc() { + return mJavaDoc; + } + /** Returns the documentation for deprecated attributes. Null if not deprecated. */ + @Override + public String getDeprecatedDoc() { + return mDeprecatedDoc; + } + + /** Sets the values for enums. null for other types. */ + public AttributeInfo setEnumValues(String[] values) { + mEnumValues = values; + return this; + } + + /** Sets the values for flags. null for other types. */ + public AttributeInfo setFlagValues(String[] values) { + mFlagValues = values; + return this; + } + + /** Sets a short javadoc, .i.e. the first sentence. */ + public void setJavaDoc(String javaDoc) { + mJavaDoc = javaDoc; + } + + /** Sets the documentation for deprecated attributes. Null if not deprecated. */ + public void setDeprecatedDoc(String deprecatedDoc) { + mDeprecatedDoc = deprecatedDoc; + } + + /** + * Sets the name of the class (fully qualified class name) which defined + * this attribute + * + * @param definedBy the name of the class (fully qualified class name) which + * defined this attribute + */ + public void setDefinedBy(String definedBy) { + mDefinedBy = definedBy; + } + + /** + * Returns the name of the class (fully qualified class name) which defined + * this attribute + * + * @return the name of the class (fully qualified class name) which defined + * this attribute + */ + @Override + public @NonNull String getDefinedBy() { + return mDefinedBy; + } + + private final static Pattern INTEGER_PATTERN = Pattern.compile("-?[0-9]+"); //$NON-NLS-1$ + private final static Pattern FLOAT_PATTERN = + Pattern.compile("-?[0-9]?(\\.[0-9]+)?"); //$NON-NLS-1$ + private final static Pattern DIMENSION_PATTERN = + Pattern.compile("-?[0-9]+(\\.[0-9]+)?(dp|dip|sp|px|pt|in|mm)"); //$NON-NLS-1$ + + /** + * Checks the given value and returns true only if it is a valid XML value + * for this attribute. + * + * @param value the XML value to check + * @param projectResources project resources to validate resource URLs with, + * if any + * @param frameworkResources framework resources to validate resource URLs + * with, if any + * @return true if the value is valid, false otherwise + */ + public boolean isValid( + @NonNull String value, + @Nullable ResourceRepository projectResources, + @Nullable ResourceRepository frameworkResources) { + + if (mFormats.contains(STRING) || mFormats.isEmpty()) { + // Anything is allowed + return true; + } + + // All other formats require a nonempty string + if (value.isEmpty()) { + // Except for flags + if (mFormats.contains(FLAG)) { + return true; + } + + return false; + } + char first = value.charAt(0); + + // There are many attributes which are incorrectly marked in the attrs.xml + // file, such as "duration", "minHeight", etc. These are marked as only + // accepting "integer", but also appear to accept "reference". Therefore, + // in these cases, be more lenient. (This happens for theme references too, + // such as ?android:attr/listPreferredItemHeight) + if ((first == '@' || first == '?') /* && mFormats.contains(REFERENCE)*/) { + if (value.equals("@null")) { + return true; + } + + if (value.startsWith(NEW_ID_PREFIX) || value.startsWith(ID_PREFIX)) { + // These are handled in the IdGeneratingResourceFile; we shouldn't + // complain about not finding ids in the repository yet since they may + // not yet have been defined (@+id's can be defined in the same layout, + // later on.) + return true; + } + + if (value.startsWith(ANDROID_PREFIX) || value.startsWith(ANDROID_THEME_PREFIX)) { + if (frameworkResources != null) { + return frameworkResources.hasResourceItem(value); + } + } else if (projectResources != null) { + return projectResources.hasResourceItem(value); + } + + // Validate resource string + String url = value; + int typeEnd = url.indexOf('/', 1); + if (typeEnd != -1) { + int typeBegin = url.startsWith("@+") ? 2 : 1; //$NON-NLS-1$ + int colon = url.lastIndexOf(':', typeEnd); + if (colon != -1) { + typeBegin = colon + 1; + } + String typeName = url.substring(typeBegin, typeEnd); + ResourceType type = ResourceType.getEnum(typeName); + if (type != null) { + // TODO: Validate that the name portion conforms to the rules + // (is an identifier but not a keyword, etc.) + // Also validate that the prefix before the colon is either + // not there or is "android" + + //int nameBegin = typeEnd + 1; + //String name = url.substring(nameBegin); + return true; + } + } else if (value.startsWith(PREFIX_THEME_REF)) { + if (projectResources != null) { + return projectResources.hasResourceItem(ResourceType.ATTR, + value.substring(PREFIX_THEME_REF.length())); + } else { + // Until proven otherwise + return true; + } + } + } + + if (mFormats.contains(ENUM) && mEnumValues != null) { + for (String e : mEnumValues) { + if (value.equals(e)) { + return true; + } + } + } + + if (mFormats.contains(FLAG) && mFlagValues != null) { + for (String v : Splitter.on('|').split(value)) { + for (String e : mFlagValues) { + if (v.equals(e)) { + return true; + } + } + } + } + + if (mFormats.contains(DIMENSION)) { + if (DIMENSION_PATTERN.matcher(value).matches()) { + return true; + } + } + + if (mFormats.contains(BOOLEAN)) { + if (value.equalsIgnoreCase(VALUE_TRUE) || value.equalsIgnoreCase(VALUE_FALSE)) { + return true; + } + } + + if (mFormats.contains(FLOAT)) { + if (Character.isDigit(first) || first == '-' || first == '.') { + if (FLOAT_PATTERN.matcher(value).matches()) { + return true; + } + // AAPT accepts more general floats, such as ".1", + try { + Float.parseFloat(value); + return true; + } catch (NumberFormatException nufe) { + // Not a float + } + } + } + + if (mFormats.contains(INTEGER)) { + if (Character.isDigit(first) || first == '-') { + if (INTEGER_PATTERN.matcher(value).matches()) { + return true; + } + } + } + + if (mFormats.contains(COLOR)) { + if (first == '#' && value.length() <= 9) { // Only allowed 32 bit ARGB + try { + // Use Long.parseLong rather than Integer.parseInt to not overflow on + // 32 big hex values like "ff191919" + Long.parseLong(value.substring(1), 16); + return true; + } catch (NumberFormatException nufe) { + // Not a valid color number + } + } + } + + if (mFormats.contains(FRACTION)) { + // should end with % or %p + return true; + } + + return false; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/AttrsXmlParser.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/AttrsXmlParser.java new file mode 100644 index 000000000..1330c50f3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/AttrsXmlParser.java @@ -0,0 +1,698 @@ +/* + * Copyright (C) 2008 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.common.resources.platform; + +import static com.android.SdkConstants.DOT_LAYOUT_PARAMS; +import static com.android.ide.eclipse.adt.AdtConstants.DOC_HIDE; + +import com.android.ide.common.api.IAttributeInfo.Format; +import com.android.ide.common.resources.platform.ViewClassInfo.LayoutParamsInfo; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.utils.ILogger; +import com.google.common.collect.Maps; + +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.xml.sax.SAXException; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +/** + * Parser for attributes description files. + */ +public final class AttrsXmlParser { + + public static final String ANDROID_MANIFEST_STYLEABLE = "AndroidManifest"; //$NON-NLS-1$ + + private Document mDocument; + private String mOsAttrsXmlPath; + + // all attributes that have the same name are supposed to have the same + // parameters so we'll keep a cache of them to avoid processing them twice. + private Map<String, AttributeInfo> mAttributeMap; + + /** Map of all attribute names for a given element */ + private final Map<String, DeclareStyleableInfo> mStyleMap = + new HashMap<String, DeclareStyleableInfo>(); + + /** Map from format name (lower case) to the uppercase version */ + private Map<String, Format> mFormatNames = new HashMap<String, Format>(10); + + /** + * Map of all (constant, value) pairs for attributes of format enum or flag. + * E.g. for attribute name=gravity, this tells us there's an enum/flag called "center" + * with value 0x11. + */ + private Map<String, Map<String, Integer>> mEnumFlagValues; + + /** + * A logger object. Must not be null. + */ + private final ILogger mLog; + + /** + * Creates a new {@link AttrsXmlParser}, set to load things from the given + * XML file. Nothing has been parsed yet. Callers should call {@link #preload()} + * next. + * + * @param osAttrsXmlPath The path of the <code>attrs.xml</code> file to parse. + * Must not be null. Should point to an existing valid XML document. + * @param log A logger object. Must not be null. + * @param expectedAttributeCount expected number of attributes in the file + */ + public AttrsXmlParser(String osAttrsXmlPath, ILogger log, int expectedAttributeCount) { + this(osAttrsXmlPath, null /* inheritableAttributes */, log, expectedAttributeCount); + } + + /** + * Returns the parsed map of attribute infos + * + * @return a map from string name to {@link AttributeInfo} + */ + public Map<String, AttributeInfo> getAttributeMap() { + return mAttributeMap; + } + + /** + * Creates a new {@link AttrsXmlParser} set to load things from the given + * XML file. + * <p/> + * If inheritableAttributes is non-null, it must point to a preloaded + * {@link AttrsXmlParser} which attributes will be used for this one. Since + * already defined attributes are not modifiable, they are thus "inherited". + * + * @param osAttrsXmlPath The path of the <code>attrs.xml</code> file to parse. + * Must not be null. Should point to an existing valid XML document. + * @param inheritableAttributes An optional parser with attributes to inherit. Can be null. + * If not null, the parser must have had its {@link #preload()} method + * invoked prior to being used here. + * @param log A logger object. Must not be null. + * @param expectedAttributeCount expected number of attributes in the file + */ + public AttrsXmlParser( + String osAttrsXmlPath, + AttrsXmlParser inheritableAttributes, + ILogger log, + int expectedAttributeCount) { + mOsAttrsXmlPath = osAttrsXmlPath; + mLog = log; + + assert osAttrsXmlPath != null; + assert log != null; + + mAttributeMap = Maps.newHashMapWithExpectedSize(expectedAttributeCount); + if (inheritableAttributes == null) { + mEnumFlagValues = new HashMap<String, Map<String,Integer>>(); + } else { + mAttributeMap.putAll(inheritableAttributes.mAttributeMap); + mEnumFlagValues = new HashMap<String, Map<String,Integer>>( + inheritableAttributes.mEnumFlagValues); + } + + // Pre-compute the set of format names such that we don't have to compute the uppercase + // version of the same format string names again and again + for (Format f : Format.values()) { + mFormatNames.put(f.name().toLowerCase(Locale.US), f); + } + } + + /** + * Returns the OS path of the attrs.xml file parsed. + */ + public String getOsAttrsXmlPath() { + return mOsAttrsXmlPath; + } + + /** + * Preloads the document, parsing all attributes and declared styles. + * + * @return Self, for command chaining. + */ + public AttrsXmlParser preload() { + Document doc = getDocument(); + + if (doc == null) { + mLog.warning("Failed to find %1$s", //$NON-NLS-1$ + mOsAttrsXmlPath); + return this; + } + + Node res = doc.getFirstChild(); + while (res != null && + res.getNodeType() != Node.ELEMENT_NODE && + !res.getNodeName().equals("resources")) { //$NON-NLS-1$ + res = res.getNextSibling(); + } + + if (res == null) { + mLog.warning("Failed to find a <resources> node in %1$s", //$NON-NLS-1$ + mOsAttrsXmlPath); + return this; + } + + parseResources(res); + return this; + } + + /** + * Loads all attributes & javadoc for the view class info based on the class name. + */ + public void loadViewAttributes(ViewClassInfo info) { + if (getDocument() != null) { + String xmlName = info.getShortClassName(); + DeclareStyleableInfo style = mStyleMap.get(xmlName); + if (style != null) { + String definedBy = info.getFullClassName(); + AttributeInfo[] attributes = style.getAttributes(); + for (AttributeInfo attribute : attributes) { + if (attribute.getDefinedBy() == null) { + attribute.setDefinedBy(definedBy); + } + } + info.setAttributes(attributes); + info.setJavaDoc(style.getJavaDoc()); + } + } + } + + /** + * Loads all attributes for the layout data info based on the class name. + */ + public void loadLayoutParamsAttributes(LayoutParamsInfo info) { + if (getDocument() != null) { + // Transforms "LinearLayout" and "LayoutParams" into "LinearLayout_Layout". + ViewClassInfo viewLayoutClass = info.getViewLayoutClass(); + String xmlName = String.format("%1$s_%2$s", //$NON-NLS-1$ + viewLayoutClass.getShortClassName(), + info.getShortClassName()); + xmlName = AdtUtils.stripSuffix(xmlName, "Params"); //$NON-NLS-1$ + + DeclareStyleableInfo style = mStyleMap.get(xmlName); + if (style != null) { + // For defined by, use the actual class name, e.g. + // android.widget.LinearLayout.LayoutParams + String definedBy = viewLayoutClass.getFullClassName() + DOT_LAYOUT_PARAMS; + AttributeInfo[] attributes = style.getAttributes(); + for (AttributeInfo attribute : attributes) { + if (attribute.getDefinedBy() == null) { + attribute.setDefinedBy(definedBy); + } + } + info.setAttributes(attributes); + } + } + } + + /** + * Returns a list of all <code>declare-styleable</code> found in the XML file. + */ + public Map<String, DeclareStyleableInfo> getDeclareStyleableList() { + return Collections.unmodifiableMap(mStyleMap); + } + + /** + * Returns a map of all enum and flag constants sorted by parent attribute name. + * The map is attribute_name => (constant_name => integer_value). + */ + public Map<String, Map<String, Integer>> getEnumFlagValues() { + return mEnumFlagValues; + } + + //------------------------- + + /** + * Creates an XML document from the attrs.xml OS path. + * May return null if the file doesn't exist or cannot be parsed. + */ + private Document getDocument() { + if (mDocument == null) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setIgnoringComments(false); + try { + DocumentBuilder builder = factory.newDocumentBuilder(); + mDocument = builder.parse(new File(mOsAttrsXmlPath)); + } catch (ParserConfigurationException e) { + mLog.error(e, "Failed to create XML document builder for %1$s", //$NON-NLS-1$ + mOsAttrsXmlPath); + } catch (SAXException e) { + mLog.error(e, "Failed to parse XML document %1$s", //$NON-NLS-1$ + mOsAttrsXmlPath); + } catch (IOException e) { + mLog.error(e, "Failed to read XML document %1$s", //$NON-NLS-1$ + mOsAttrsXmlPath); + } + } + return mDocument; + } + + /** + * Finds all the <declare-styleable> and <attr> nodes + * in the top <resources> node. + */ + private void parseResources(Node res) { + + Map<String, String> unknownParents = new HashMap<String, String>(); + + Node lastComment = null; + for (Node node = res.getFirstChild(); node != null; node = node.getNextSibling()) { + switch (node.getNodeType()) { + case Node.COMMENT_NODE: + lastComment = node; + break; + case Node.ELEMENT_NODE: + if (node.getNodeName().equals("declare-styleable")) { //$NON-NLS-1$ + Node nameNode = node.getAttributes().getNamedItem("name"); //$NON-NLS-1$ + if (nameNode != null) { + String name = nameNode.getNodeValue(); + + Node parentNode = node.getAttributes().getNamedItem("parent"); //$NON-NLS-1$ + String parents = parentNode == null ? null : parentNode.getNodeValue(); + + if (name != null && !mStyleMap.containsKey(name)) { + DeclareStyleableInfo style = parseDeclaredStyleable(name, node); + if (parents != null) { + String[] parentsArray = + parseStyleableParents(parents, mStyleMap, unknownParents); + style.setParents(parentsArray); + } + mStyleMap.put(name, style); + unknownParents.remove(name); + if (lastComment != null) { + String nodeValue = lastComment.getNodeValue(); + if (nodeValue.contains(DOC_HIDE)) { + mStyleMap.remove(name); + } else { + style.setJavaDoc(parseJavadoc(nodeValue)); + } + } + } + } + } else if (node.getNodeName().equals("attr")) { //$NON-NLS-1$ + parseAttr(node, lastComment); + } + lastComment = null; + break; + } + } + + // If we have any unknown parent, re-create synthetic styleable for them. + for (Entry<String, String> entry : unknownParents.entrySet()) { + String name = entry.getKey(); + String parent = entry.getValue(); + + DeclareStyleableInfo style = new DeclareStyleableInfo(name, (AttributeInfo[])null); + if (parent != null) { + style.setParents(new String[] { parent }); + } + mStyleMap.put(name, style); + + // Simplify parents names. See SDK Bug 3125910. + // Implementation detail: that since we want to delete and add to the map, + // we can't just use an iterator. + for (String key : new ArrayList<String>(mStyleMap.keySet())) { + if (key.startsWith(name) && !key.equals(name)) { + // We found a child which name starts with the full name of the + // parent. Simplify the children name. + String newName = ANDROID_MANIFEST_STYLEABLE + key.substring(name.length()); + + DeclareStyleableInfo newStyle = + new DeclareStyleableInfo(newName, mStyleMap.get(key)); + mStyleMap.remove(key); + mStyleMap.put(newName, newStyle); + } + } + } + } + + /** + * Parses the "parents" attribute from a <declare-styleable>. + * <p/> + * The syntax is the following: + * <pre> + * parent[.parent]* [[space|,] parent[.parent]* ] + * </pre> + * <p/> + * In English: </br> + * - There can be one or more parents, separated by whitespace or commas. </br> + * - Whitespace is ignored and trimmed. </br> + * - A parent name is actually composed of one or more identifiers joined by a dot. + * <p/> + * Styleables do not usually need to declare their parent chain (e.g. the grand-parents + * of a parent.) Parent names are unique, so in most cases a styleable will only declare + * its immediate parent. + * <p/> + * However it is possible for a styleable's parent to not exist, e.g. if you have a + * styleable "A" that is the root and then styleable "C" declares its parent to be "A.B". + * In this case we record "B" as the parent, even though it is unknown and will never be + * known. Any parent that is currently not in the knownParent map is thus added to the + * unknownParent set. The caller will remove the name from the unknownParent set when it + * sees a declaration for it. + * + * @param parents The parents string to parse. Must not be null or empty. + * @param knownParents The map of all declared styles known so far. + * @param unknownParents A map of all unknown parents collected here. + * @return The array of terminal parent names parsed from the parents string. + */ + private String[] parseStyleableParents(String parents, + Map<String, DeclareStyleableInfo> knownParents, + Map<String, String> unknownParents) { + + ArrayList<String> result = new ArrayList<String>(); + + for (String parent : parents.split("[ \t\n\r\f,|]")) { //$NON-NLS-1$ + parent = parent.trim(); + if (parent.length() == 0) { + continue; + } + if (parent.indexOf('.') >= 0) { + // This is a grand-parent/parent chain. Make sure we know about the + // parents and only record the terminal one. + String last = null; + for (String name : parent.split("\\.")) { //$NON-NLS-1$ + if (name.length() > 0) { + if (!knownParents.containsKey(name)) { + // Record this unknown parent and its grand parent. + unknownParents.put(name, last); + } + last = name; + } + } + parent = last; + } + + result.add(parent); + } + + return result.toArray(new String[result.size()]); + } + + /** + * Parses an <attr> node and convert it into an {@link AttributeInfo} if it is valid. + */ + private AttributeInfo parseAttr(Node attrNode, Node lastComment) { + AttributeInfo info = null; + Node nameNode = attrNode.getAttributes().getNamedItem("name"); //$NON-NLS-1$ + if (nameNode != null) { + String name = nameNode.getNodeValue(); + if (name != null) { + info = mAttributeMap.get(name); + // If the attribute is unknown yet, parse it. + // If the attribute is know but its format is unknown, parse it too. + if (info == null || info.getFormats().size() == 0) { + info = parseAttributeTypes(attrNode, name); + if (info != null) { + mAttributeMap.put(name, info); + } + } else if (lastComment != null) { + info = new AttributeInfo(info); + } + if (info != null) { + if (lastComment != null) { + String nodeValue = lastComment.getNodeValue(); + if (nodeValue.contains(DOC_HIDE)) { + return null; + } + info.setJavaDoc(parseJavadoc(nodeValue)); + info.setDeprecatedDoc(parseDeprecatedDoc(nodeValue)); + } + } + } + } + return info; + } + + /** + * Finds all the attributes for a particular style node, + * e.g. a declare-styleable of name "TextView" or "LinearLayout_Layout". + * + * @param styleName The name of the declare-styleable node + * @param declareStyleableNode The declare-styleable node itself + */ + private DeclareStyleableInfo parseDeclaredStyleable(String styleName, + Node declareStyleableNode) { + ArrayList<AttributeInfo> attrs = new ArrayList<AttributeInfo>(); + Node lastComment = null; + for (Node node = declareStyleableNode.getFirstChild(); + node != null; + node = node.getNextSibling()) { + + switch (node.getNodeType()) { + case Node.COMMENT_NODE: + lastComment = node; + break; + case Node.ELEMENT_NODE: + if (node.getNodeName().equals("attr")) { //$NON-NLS-1$ + AttributeInfo info = parseAttr(node, lastComment); + if (info != null) { + attrs.add(info); + } + } + lastComment = null; + break; + } + + } + + return new DeclareStyleableInfo(styleName, attrs.toArray(new AttributeInfo[attrs.size()])); + } + + /** + * Returns the {@link AttributeInfo} for a specific <attr> XML node. + * This gets the javadoc, the type, the name and the enum/flag values if any. + * <p/> + * The XML node is expected to have the following attributes: + * <ul> + * <li>"name", which is mandatory. The node is skipped if this is missing.</li> + * <li>"format".</li> + * </ul> + * The format may be one type or two types (e.g. "reference|color"). + * An extra format can be implied: "enum" or "flag" are not specified in the "format" attribute, + * they are implicitly stated by the presence of sub-nodes <enum> or <flag>. + * <p/> + * By design, attr nodes of the same name MUST have the same type. + * Attribute nodes are thus cached by name and reused as much as possible. + * When reusing a node, it is duplicated and its javadoc reassigned. + */ + private AttributeInfo parseAttributeTypes(Node attrNode, String name) { + EnumSet<Format> formats = null; + String[] enumValues = null; + String[] flagValues = null; + + Node attrFormat = attrNode.getAttributes().getNamedItem("format"); //$NON-NLS-1$ + if (attrFormat != null) { + for (String f : attrFormat.getNodeValue().split("\\|")) { //$NON-NLS-1$ + Format format = mFormatNames.get(f); + if (format == null) { + mLog.info( + "Unknown format name '%s' in <attr name=\"%s\">, file '%s'.", //$NON-NLS-1$ + f, name, getOsAttrsXmlPath()); + } else if (format != AttributeInfo.Format.ENUM && + format != AttributeInfo.Format.FLAG) { + if (formats == null) { + formats = format.asSet(); + } else { + if (formats.size() == 1) { + formats = EnumSet.copyOf(formats); + } + formats.add(format); + } + } + } + } + + // does this <attr> have <enum> children? + enumValues = parseEnumFlagValues(attrNode, "enum", name); //$NON-NLS-1$ + if (enumValues != null) { + if (formats == null) { + formats = Format.ENUM_SET; + } else { + if (formats.size() == 1) { + formats = EnumSet.copyOf(formats); + } + formats.add(Format.ENUM); + } + } + + // does this <attr> have <flag> children? + flagValues = parseEnumFlagValues(attrNode, "flag", name); //$NON-NLS-1$ + if (flagValues != null) { + if (formats == null) { + formats = Format.FLAG_SET; + } else { + if (formats.size() == 1) { + formats = EnumSet.copyOf(formats); + } + formats.add(Format.FLAG); + } + } + + if (formats == null) { + formats = Format.NONE; + } + + AttributeInfo info = new AttributeInfo(name, formats); + info.setEnumValues(enumValues); + info.setFlagValues(flagValues); + return info; + } + + /** + * Given an XML node that represents an <attr> node, this method searches + * if the node has any children nodes named "target" (e.g. "enum" or "flag"). + * Such nodes must have a "name" attribute. + * <p/> + * If "attrNode" is null, look for any <attr> that has the given attrNode + * and the requested children nodes. + * <p/> + * This method collects all the possible names of these children nodes and + * return them. + * + * @param attrNode The <attr> XML node + * @param filter The child node to look for, either "enum" or "flag". + * @param attrName The value of the name attribute of <attr> + * + * @return Null if there are no such children nodes, otherwise an array of length >= 1 + * of all the names of these children nodes. + */ + private String[] parseEnumFlagValues(Node attrNode, String filter, String attrName) { + ArrayList<String> names = null; + for (Node child = attrNode.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child.getNodeType() == Node.ELEMENT_NODE && child.getNodeName().equals(filter)) { + Node nameNode = child.getAttributes().getNamedItem("name"); //$NON-NLS-1$ + if (nameNode == null) { + mLog.warning( + "Missing name attribute in <attr name=\"%s\"><%s></attr>", //$NON-NLS-1$ + attrName, filter); + } else { + if (names == null) { + names = new ArrayList<String>(); + } + String name = nameNode.getNodeValue(); + names.add(name); + + Node valueNode = child.getAttributes().getNamedItem("value"); //$NON-NLS-1$ + if (valueNode == null) { + mLog.warning( + "Missing value attribute in <attr name=\"%s\"><%s name=\"%s\"></attr>", //$NON-NLS-1$ + attrName, filter, name); + } else { + String value = valueNode.getNodeValue(); + try { + // Integer.decode cannot handle "ffffffff", see JDK issue 6624867 + int i = (int) (long) Long.decode(value); + + Map<String, Integer> map = mEnumFlagValues.get(attrName); + if (map == null) { + map = new HashMap<String, Integer>(); + mEnumFlagValues.put(attrName, map); + } + map.put(name, Integer.valueOf(i)); + + } catch(NumberFormatException e) { + mLog.error(e, + "Value in <attr name=\"%s\"><%s name=\"%s\" value=\"%s\"></attr> is not a valid decimal or hexadecimal", //$NON-NLS-1$ + attrName, filter, name, value); + } + } + } + } + } + return names == null ? null : names.toArray(new String[names.size()]); + } + + /** + * Parses the javadoc comment. + * Only keeps the first sentence. + * <p/> + * This does not remove nor simplify links and references. + */ + private String parseJavadoc(String comment) { + if (comment == null) { + return null; + } + + // sanitize & collapse whitespace + comment = comment.replaceAll("\\s+", " "); //$NON-NLS-1$ //$NON-NLS-2$ + + // Explicitly remove any @deprecated tags since they are handled separately. + comment = comment.replaceAll("(?:\\{@deprecated[^}]*\\}|@deprecated[^@}]*)", ""); + + // take everything up to the first dot that is followed by a space or the end of the line. + // I love regexps :-). For the curious, the regexp is: + // - start of line + // - ignore whitespace + // - group: + // - everything, not greedy + // - non-capturing group (?: ) + // - end of string + // or + // - not preceded by a letter, a dot and another letter (for "i.e" and "e.g" ) + // (<! non-capturing zero-width negative look-behind) + // - a dot + // - followed by a space (?= non-capturing zero-width positive look-ahead) + // - anything else is ignored + comment = comment.replaceFirst("^\\s*(.*?(?:$|(?<![a-zA-Z]\\.[a-zA-Z])\\.(?=\\s))).*", "$1"); //$NON-NLS-1$ //$NON-NLS-2$ + + return comment; + } + + + /** + * Parses the javadoc and extract the first @deprecated tag, if any. + * Returns null if there's no @deprecated tag. + * The deprecated tag can be of two forms: + * - {+@deprecated ...text till the next bracket } + * Note: there should be no space or + between { and @. I need one in this comment otherwise + * this method will be tagged as deprecated ;-) + * - @deprecated ...text till the next @tag or end of the comment. + * In both cases the comment can be multi-line. + */ + private String parseDeprecatedDoc(String comment) { + // Skip if we can't even find the tag in the comment. + if (comment == null) { + return null; + } + + // sanitize & collapse whitespace + comment = comment.replaceAll("\\s+", " "); //$NON-NLS-1$ //$NON-NLS-2$ + + int pos = comment.indexOf("{@deprecated"); + if (pos >= 0) { + comment = comment.substring(pos + 12 /* len of {@deprecated */); + comment = comment.replaceFirst("^([^}]*).*", "$1"); + } else if ((pos = comment.indexOf("@deprecated")) >= 0) { + comment = comment.substring(pos + 11 /* len of @deprecated */); + comment = comment.replaceFirst("^(.*?)(?:@.*|$)", "$1"); + } else { + return null; + } + + return comment.trim(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/DeclareStyleableInfo.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/DeclareStyleableInfo.java new file mode 100644 index 000000000..40111e24e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/DeclareStyleableInfo.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2008 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.common.resources.platform; + + +/** + * Information needed to represent a View or ViewGroup (aka Layout) item + * in the layout hierarchy, as extracted from the main android.jar and the + * associated attrs.xml. + */ +public class DeclareStyleableInfo { + /** The style name, never null. */ + private final String mStyleName; + /** Attributes for this view or view group. Can be empty but never null. */ + private final AttributeInfo[] mAttributes; + /** Short javadoc. Can be null. */ + private String mJavaDoc; + /** Optional name of the parents styleable. Can be null. */ + private String[] mParents; + + /** + * Creates a new {@link DeclareStyleableInfo}. + * + * @param styleName The name of the style. Should not be empty nor null. + * @param attributes The initial list of attributes. Can be null. + */ + public DeclareStyleableInfo(String styleName, AttributeInfo[] attributes) { + mStyleName = styleName; + mAttributes = attributes == null ? new AttributeInfo[0] : attributes; + } + + /** + * Creates a new {@link DeclareStyleableInfo} that has the same attributes + * as an existing one and only differs by name. + * + * @param styleName The name of the style. Should not be empty nor null. + * @param existing The existing {@link DeclareStyleableInfo} to mirror. + */ + public DeclareStyleableInfo(String styleName, DeclareStyleableInfo existing) { + mStyleName = styleName; + + mJavaDoc = existing.getJavaDoc(); + + String[] parents = existing.getParents(); + if (parents != null) { + mParents = new String[parents.length]; + System.arraycopy(parents, 0, mParents, 0, parents.length); + } + + AttributeInfo[] attrs = existing.getAttributes(); + if (attrs == null || attrs.length == 0) { + mAttributes = new AttributeInfo[0]; + } else { + mAttributes = new AttributeInfo[attrs.length]; + System.arraycopy(attrs, 0, mAttributes, 0, attrs.length); + } + } + + /** Returns style name */ + public String getStyleName() { + return mStyleName; + } + + /** Returns the attributes for this view or view group. Maybe empty but not null. */ + public AttributeInfo[] getAttributes() { + return mAttributes; + } + + /** Returns a short javadoc */ + public String getJavaDoc() { + return mJavaDoc; + } + + /** Sets the javadoc. */ + public void setJavaDoc(String javaDoc) { + mJavaDoc = javaDoc; + } + + /** Sets the name of the parents styleable. Can be null. */ + public void setParents(String[] parents) { + mParents = parents; + } + + /** Returns the name of the parents styleable. Can be null. */ + public String[] getParents() { + return mParents; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/ViewClassInfo.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/ViewClassInfo.java new file mode 100644 index 000000000..214eb9cb4 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/resources/platform/ViewClassInfo.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2008 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.common.resources.platform; + + +/** + * Information needed to represent a View or ViewGroup (aka Layout) item + * in the layout hierarchy, as extracted from the main android.jar and the + * associated attrs.xml. + */ +public class ViewClassInfo { + /** Is this a layout class (i.e. ViewGroup) or just a view? */ + private boolean mIsLayout; + /** FQCN e.g. android.view.View, never null. */ + private String mFullClassName; + /** Short class name, e.g. View, never null. */ + private String mShortClassName; + /** Super class. Can be null. */ + private ViewClassInfo mSuperClass; + /** Short javadoc. Can be null. */ + private String mJavaDoc; + /** Attributes for this view or view group. Can be empty but never null. */ + private AttributeInfo[] mAttributes; + + public static class LayoutParamsInfo { + /** Short class name, e.g. LayoutData, never null. */ + private String mShortClassName; + /** ViewLayout class info owning this layout data */ + private ViewClassInfo mViewLayoutClass; + /** Super class. Can be null. */ + private LayoutParamsInfo mSuperClass; + /** Layout Data Attributes for layout classes. Can be empty but not null. */ + private AttributeInfo[] mAttributes; + + public LayoutParamsInfo(ViewClassInfo enclosingViewClassInfo, + String shortClassName, LayoutParamsInfo superClassInfo) { + mShortClassName = shortClassName; + mViewLayoutClass = enclosingViewClassInfo; + mSuperClass = superClassInfo; + mAttributes = new AttributeInfo[0]; + } + + /** Returns short class name, e.g. "LayoutData" */ + public String getShortClassName() { + return mShortClassName; + } + /** Returns the ViewLayout class info enclosing this layout data. Cannot null. */ + public ViewClassInfo getViewLayoutClass() { + return mViewLayoutClass; + } + /** Returns the super class info. Can be null. */ + public LayoutParamsInfo getSuperClass() { + return mSuperClass; + } + /** Returns the LayoutData attributes. Can be empty but not null. */ + public AttributeInfo[] getAttributes() { + return mAttributes; + } + /** Sets the LayoutData attributes. Can be empty but not null. */ + public void setAttributes(AttributeInfo[] attributes) { + mAttributes = attributes; + } + } + + /** Layout data info for a layout class. Null for all non-layout classes and always + * non-null for a layout class. */ + public LayoutParamsInfo mLayoutData; + + // -------- + + public ViewClassInfo(boolean isLayout, String fullClassName, String shortClassName) { + mIsLayout = isLayout; + mFullClassName = fullClassName; + mShortClassName = shortClassName; + mAttributes = new AttributeInfo[0]; + } + + /** Returns whether this is a layout class (i.e. ViewGroup) or just a View */ + public boolean isLayout() { + return mIsLayout; + } + + /** Returns FQCN e.g. "android.view.View" */ + public String getFullClassName() { + return mFullClassName; + } + + /** Returns short class name, e.g. "View" */ + public String getShortClassName() { + return mShortClassName; + } + + /** Returns the super class. Can be null. */ + public ViewClassInfo getSuperClass() { + return mSuperClass; + } + + /** Returns a short javadoc */ + public String getJavaDoc() { + return mJavaDoc; + } + + /** Returns the attributes for this view or view group. Maybe empty but not null. */ + public AttributeInfo[] getAttributes() { + return mAttributes; + } + + /** Returns the LayoutData info for layout classes. Null for non-layout view classes. */ + public LayoutParamsInfo getLayoutData() { + return mLayoutData; + } + + /** + * Sets a link on the info of the super class of this View or ViewGroup. + * <p/> + * The super class info must be of the same kind (i.e. group to group or view to view) + * except for the top ViewGroup which links to the View info. + * <p/> + * The super class cannot be null except for the top View info. + */ + public void setSuperClass(ViewClassInfo superClass) { + mSuperClass = superClass; + } + + /** Sets the javadoc for this View or ViewGroup. */ + public void setJavaDoc(String javaDoc) { + mJavaDoc = javaDoc; + } + + /** Sets the list of attributes for this View or ViewGroup. */ + public void setAttributes(AttributeInfo[] attributes) { + mAttributes = attributes; + } + + /** + * Sets the {@link LayoutParamsInfo} for layout classes. + * Does nothing for non-layout view classes. + */ + public void setLayoutParams(LayoutParamsInfo layoutData) { + if (mIsLayout) { + mLayoutData = layoutData; + } + } +} |