diff options
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout')
157 files changed, 63020 insertions, 0 deletions
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ActionBarHandler.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ActionBarHandler.java new file mode 100644 index 000000000..49585a3ef --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ActionBarHandler.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout; + +import java.util.ArrayList; +import java.util.List; + +import org.w3c.dom.Element; + +import com.android.ide.common.rendering.api.ActionBarCallback; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo.ActivityAttributes; +import com.google.common.base.Splitter; +import com.google.common.collect.Iterables; + +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.SdkConstants.VALUE_SPLIT_ACTION_BAR_WHEN_NARROW; + +public class ActionBarHandler extends ActionBarCallback { + + private final GraphicalEditorPart mEditor; + + ActionBarHandler(GraphicalEditorPart editor) { + mEditor = editor; + } + + @Override + public List<String> getMenuIdNames() { + String commaSeparatedMenus = getXmlAttribute(ATTR_MENU); + List<String> menus = new ArrayList<String>(); + Iterables.addAll(menus, Splitter.on(',').trimResults().omitEmptyStrings() + .split(commaSeparatedMenus)); + return menus; + } + + @Override + public boolean getSplitActionBarWhenNarrow() { + ActivityAttributes attributes = getActivityAttributes(); + if (attributes != null) { + return VALUE_SPLIT_ACTION_BAR_WHEN_NARROW.equals(attributes.getUiOptions()); + } + return false; + } + + @Override + public int getNavigationMode() { + String navMode = getXmlAttribute(ATTR_NAV_MODE); + if (navMode.equalsIgnoreCase(VALUE_NAV_MODE_TABS)) { + return NAVIGATION_MODE_TABS; + } + if (navMode.equalsIgnoreCase(VALUE_NAV_MODE_LIST)) { + return NAVIGATION_MODE_LIST; + } + return NAVIGATION_MODE_STANDARD; + } + + @Override + public HomeButtonStyle getHomeButtonStyle() { + ActivityAttributes attributes = getActivityAttributes(); + if (attributes != null && attributes.getParentActivity() != null) { + return HomeButtonStyle.SHOW_HOME_AS_UP; + } + return HomeButtonStyle.NONE; + } + + private ActivityAttributes getActivityAttributes() { + ManifestInfo manifest = ManifestInfo.get(mEditor.getProject()); + String activity = mEditor.getConfigurationChooser().getConfiguration().getActivity(); + return manifest.getActivityAttributes(activity); + } + + private String getXmlAttribute(String name) { + Element element = mEditor.getModel().getUiRoot().getXmlDocument().getDocumentElement(); + String value = element.getAttributeNS(TOOLS_URI, name); + if (value == null) { + return ""; + } + return value.trim(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/BasePullParser.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/BasePullParser.java new file mode 100644 index 000000000..43fb1a5bd --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/BasePullParser.java @@ -0,0 +1,244 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import com.android.ide.common.rendering.legacy.ILegacyPullParser; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.InputStream; +import java.io.Reader; + +/** + * Base implementation of an {@link ILegacyPullParser} for cases where the parser is not sitting + * on top of an actual XML file. + * <p/>It's designed to work on layout files, and will most likely not work on other resource + * files. + */ +public abstract class BasePullParser implements ILegacyPullParser { + + protected int mParsingState = START_DOCUMENT; + + public BasePullParser() { + } + + // --- new methods to override --- + + public abstract void onNextFromStartDocument(); + public abstract void onNextFromStartTag(); + public abstract void onNextFromEndTag(); + + // --- basic implementation of IXmlPullParser --- + + @Override + public void setFeature(String name, boolean state) throws XmlPullParserException { + if (FEATURE_PROCESS_NAMESPACES.equals(name) && state) { + return; + } + if (FEATURE_REPORT_NAMESPACE_ATTRIBUTES.equals(name) && state) { + return; + } + throw new XmlPullParserException("Unsupported feature: " + name); + } + + @Override + public boolean getFeature(String name) { + if (FEATURE_PROCESS_NAMESPACES.equals(name)) { + return true; + } + if (FEATURE_REPORT_NAMESPACE_ATTRIBUTES.equals(name)) { + return true; + } + return false; + } + + @Override + public void setProperty(String name, Object value) throws XmlPullParserException { + throw new XmlPullParserException("setProperty() not supported"); + } + + @Override + public Object getProperty(String name) { + return null; + } + + @Override + public void setInput(Reader in) throws XmlPullParserException { + throw new XmlPullParserException("setInput() not supported"); + } + + @Override + public void setInput(InputStream inputStream, String inputEncoding) + throws XmlPullParserException { + throw new XmlPullParserException("setInput() not supported"); + } + + @Override + public void defineEntityReplacementText(String entityName, String replacementText) + throws XmlPullParserException { + throw new XmlPullParserException("defineEntityReplacementText() not supported"); + } + + @Override + public String getNamespacePrefix(int pos) throws XmlPullParserException { + throw new XmlPullParserException("getNamespacePrefix() not supported"); + } + + @Override + public String getInputEncoding() { + return null; + } + + @Override + public String getNamespace(String prefix) { + throw new RuntimeException("getNamespace() not supported"); + } + + @Override + public int getNamespaceCount(int depth) throws XmlPullParserException { + throw new XmlPullParserException("getNamespaceCount() not supported"); + } + + @Override + public String getNamespaceUri(int pos) throws XmlPullParserException { + throw new XmlPullParserException("getNamespaceUri() not supported"); + } + + @Override + public int getColumnNumber() { + return -1; + } + + @Override + public int getLineNumber() { + return -1; + } + + @Override + public String getAttributeType(int arg0) { + return "CDATA"; + } + + @Override + public int getEventType() { + return mParsingState; + } + + @Override + public String getText() { + return null; + } + + @Override + public char[] getTextCharacters(int[] arg0) { + return null; + } + + @Override + public boolean isAttributeDefault(int arg0) { + return false; + } + + @Override + public boolean isWhitespace() { + return false; + } + + @Override + public int next() throws XmlPullParserException { + switch (mParsingState) { + case END_DOCUMENT: + throw new XmlPullParserException("Nothing after the end"); + case START_DOCUMENT: + onNextFromStartDocument(); + break; + case START_TAG: + onNextFromStartTag(); + break; + case END_TAG: + onNextFromEndTag(); + break; + case TEXT: + // not used + break; + case CDSECT: + // not used + break; + case ENTITY_REF: + // not used + break; + case IGNORABLE_WHITESPACE: + // not used + break; + case PROCESSING_INSTRUCTION: + // not used + break; + case COMMENT: + // not used + break; + case DOCDECL: + // not used + break; + } + + return mParsingState; + } + + @Override + public int nextTag() throws XmlPullParserException { + int eventType = next(); + if (eventType != START_TAG && eventType != END_TAG) { + throw new XmlPullParserException("expected start or end tag", this, null); + } + return eventType; + } + + @Override + public String nextText() throws XmlPullParserException { + if (getEventType() != START_TAG) { + throw new XmlPullParserException("parser must be on START_TAG to read next text", this, + null); + } + int eventType = next(); + if (eventType == TEXT) { + String result = getText(); + eventType = next(); + if (eventType != END_TAG) { + throw new XmlPullParserException( + "event TEXT it must be immediately followed by END_TAG", this, null); + } + return result; + } else if (eventType == END_TAG) { + return ""; + } else { + throw new XmlPullParserException("parser must be on START_TAG or TEXT to read text", + this, null); + } + } + + @Override + public int nextToken() throws XmlPullParserException { + return next(); + } + + @Override + public void require(int type, String namespace, String name) throws XmlPullParserException { + if (type != getEventType() || (namespace != null && !namespace.equals(getNamespace())) + || (name != null && !name.equals(getName()))) + throw new XmlPullParserException("expected " + TYPES[type] + getPositionDescription()); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ContextPullParser.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ContextPullParser.java new file mode 100644 index 000000000..f30406520 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ContextPullParser.java @@ -0,0 +1,166 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import static com.android.SdkConstants.ATTR_IGNORE; +import static com.android.SdkConstants.ATTR_LAYOUT; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.EXPANDABLE_LIST_VIEW; +import static com.android.SdkConstants.GRID_VIEW; +import static com.android.SdkConstants.LIST_VIEW; +import static com.android.SdkConstants.SPINNER; +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.SdkConstants.VALUE_FILL_PARENT; +import static com.android.SdkConstants.VALUE_MATCH_PARENT; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata.KEY_FRAGMENT_LAYOUT; + +import com.android.SdkConstants; +import com.android.ide.common.rendering.api.ILayoutPullParser; +import com.android.ide.common.rendering.api.IProjectCallback; +import com.android.ide.common.res2.ValueXmlHelper; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata; +import com.google.common.collect.Maps; + +import org.kxml2.io.KXmlParser; + +import java.io.File; +import java.util.Map; + +/** + * Modified {@link KXmlParser} that adds the methods of {@link ILayoutPullParser}, and + * performs other layout-specific parser behavior like translating fragment tags into + * include tags. + * <p/> + * It will return a given parser when queried for one through + * {@link ILayoutPullParser#getParser(String)} for a given name. + * + */ +public class ContextPullParser extends KXmlParser implements ILayoutPullParser { + private static final String COMMENT_PREFIX = "<!--"; //$NON-NLS-1$ + private static final String COMMENT_SUFFIX = "-->"; //$NON-NLS-1$ + /** The callback to request parsers from */ + private final IProjectCallback mProjectCallback; + /** The {@link File} for the layout currently being parsed */ + private File mFile; + /** The layout to be shown for the current {@code <fragment>} tag. Usually null. */ + private String mFragmentLayout = null; + + /** + * Creates a new {@link ContextPullParser} + * + * @param projectCallback the associated callback + * @param file the file to be parsed + */ + public ContextPullParser(IProjectCallback projectCallback, File file) { + super(); + mProjectCallback = projectCallback; + mFile = file; + } + + // --- Layout lib API methods + + @Override + /** + * this is deprecated but must still be implemented for older layout libraries. + * @deprecated use {@link IProjectCallback#getParser(String)}. + */ + @Deprecated + public ILayoutPullParser getParser(String layoutName) { + return mProjectCallback.getParser(layoutName); + } + + @Override + public Object getViewCookie() { + String name = super.getName(); + if (name == null) { + return null; + } + + // Store tools attributes if this looks like a layout we'll need adapter view + // bindings for in the ProjectCallback. + if (LIST_VIEW.equals(name) + || EXPANDABLE_LIST_VIEW.equals(name) + || GRID_VIEW.equals(name) + || SPINNER.equals(name)) { + Map<String, String> map = null; + int count = getAttributeCount(); + for (int i = 0; i < count; i++) { + String namespace = getAttributeNamespace(i); + if (namespace != null && namespace.equals(TOOLS_URI)) { + String attribute = getAttributeName(i); + if (attribute.equals(ATTR_IGNORE)) { + continue; + } + if (map == null) { + map = Maps.newHashMapWithExpectedSize(4); + } + map.put(attribute, getAttributeValue(i)); + } + } + + return map; + } + + return null; + } + + // --- KXMLParser override + + @Override + public String getName() { + String name = super.getName(); + + // At designtime, replace fragments with includes. + if (name.equals(VIEW_FRAGMENT)) { + mFragmentLayout = LayoutMetadata.getProperty(this, KEY_FRAGMENT_LAYOUT); + if (mFragmentLayout != null) { + return VIEW_INCLUDE; + } + } else { + mFragmentLayout = null; + } + + + return name; + } + + @Override + public String getAttributeValue(String namespace, String localName) { + if (ATTR_LAYOUT.equals(localName) && mFragmentLayout != null) { + return mFragmentLayout; + } + + String value = super.getAttributeValue(namespace, localName); + + // on the fly convert match_parent to fill_parent for compatibility with older + // platforms. + if (VALUE_MATCH_PARENT.equals(value) && + (ATTR_LAYOUT_WIDTH.equals(localName) || + ATTR_LAYOUT_HEIGHT.equals(localName)) && + SdkConstants.NS_RESOURCES.equals(namespace)) { + return VALUE_FILL_PARENT; + } + + // Handle unicode escapes etc + value = ValueXmlHelper.unescapeResourceString(value, false, false); + + return value; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ExplodedRenderingHelper.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ExplodedRenderingHelper.java new file mode 100644 index 000000000..25fa3e991 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ExplodedRenderingHelper.java @@ -0,0 +1,421 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout; + +import com.android.SdkConstants; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.core.resources.IProject; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +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.Map.Entry; +import java.util.Set; + +/** + * This class computes the new screen size in "exploded rendering" mode. + * It goes through the whole layout tree and figures out how many embedded layouts will have + * extra padding and compute how that will affect the screen size. + * + * TODO + * - find a better class name :) + * - move the logic for each layout to the layout rule classes? + * - support custom classes (by querying JDT for its super class and reverting to its behavior) + */ +public final class ExplodedRenderingHelper { + /** value of the padding in pixel. + * TODO: make a preference? + */ + public final static int PADDING_VALUE = 10; + + private final int[] mPadding = new int[] { 0, 0 }; + private Set<String> mLayoutNames; + + /** + * Computes the padding. access the result through {@link #getWidthPadding()} and + * {@link #getHeightPadding()}. + * @param root the root node (ie the top layout). + * @param iProject the project to which the layout belong. + */ + public ExplodedRenderingHelper(Node root, IProject iProject) { + // get the layout descriptors to get the name of all the layout classes. + IAndroidTarget target = Sdk.getCurrent().getTarget(iProject); + AndroidTargetData data = Sdk.getCurrent().getTargetData(target); + LayoutDescriptors descriptors = data.getLayoutDescriptors(); + + mLayoutNames = new HashSet<String>(); + List<ViewElementDescriptor> layoutDescriptors = descriptors.getLayoutDescriptors(); + for (ViewElementDescriptor desc : layoutDescriptors) { + mLayoutNames.add(desc.getXmlLocalName()); + } + + computePadding(root, mPadding); + } + + /** + * (Unit tests only) + * Computes the padding. access the result through {@link #getWidthPadding()} and + * {@link #getHeightPadding()}. + * @param root the root node (ie the top layout). + * @param layoutNames the list of layout classes + */ + public ExplodedRenderingHelper(Node root, Set<String> layoutNames) { + mLayoutNames = layoutNames; + + computePadding(root, mPadding); + } + + /** + * Returns the number of extra padding in the X axis. This doesn't return a number of pixel + * or dip, but how many paddings are pushing the screen dimension out. + */ + public int getWidthPadding() { + return mPadding[0]; + } + + /** + * Returns the number of extra padding in the Y axis. This doesn't return a number of pixel + * or dip, but how many paddings are pushing the screen dimension out. + */ + public int getHeightPadding() { + return mPadding[1]; + } + + /** + * Computes the number of padding for a given view, and fills the given array of int. + * <p/>index 0 is X axis, index 1 is Y axis + * @param view the view to compute + * @param padding the result padding (index 0 is X axis, index 1 is Y axis) + */ + private void computePadding(Node view, int[] padding) { + String localName = view.getLocalName(); + + // first compute for each children + NodeList children = view.getChildNodes(); + int count = children.getLength(); + if (count > 0) { + // compute the padding for all the children. + Map<Node, int[]> childrenPadding = new HashMap<Node, int[]>(count); + for (int i = 0 ; i < count ; i++) { + Node child = children.item(i); + short type = child.getNodeType(); + if (type == Node.ELEMENT_NODE) { // ignore TEXT/CDATA nodes. + int[] p = new int[] { 0, 0 }; + childrenPadding.put(child, p); + computePadding(child, p); + } + } + + // since the non ELEMENT_NODE children were filtered out, count must be updated. + count = childrenPadding.size(); + + // now combine/compare based on the parent. + if (count == 1) { + int[] p = childrenPadding.get(childrenPadding.keySet().iterator().next()); + padding[0] = p[0]; + padding[1] = p[1]; + } else { + if ("LinearLayout".equals(localName)) { //$NON-NLS-1$ + String orientation = getAttribute(view, "orientation", null); //$NON-NLS-1$ + + // default value is horizontal + boolean horizontal = orientation == null || + "horizontal".equals("vertical"); //$NON-NLS-1$ //$NON-NLS-2$ + combineLinearLayout(childrenPadding.values(), padding, horizontal); + } else if ("TableLayout".equals(localName)) { //$NON-NLS-1$ + combineLinearLayout(childrenPadding.values(), padding, false /*horizontal*/); + } else if ("TableRow".equals(localName)) { //$NON-NLS-1$ + combineLinearLayout(childrenPadding.values(), padding, true /*true*/); + // TODO: properly support Relative Layouts. +// } else if ("RelativeLayout".equals(localName)) { //$NON-NLS-1$ +// combineRelativeLayout(childrenPadding, padding); + } else { + // unknown layout. For now, let's consider it's better to add the children + // margins in both dimensions than not at all. + for (int[] p : childrenPadding.values()) { + padding[0] += p[0]; + padding[1] += p[1]; + } + } + } + } + + // if the view itself is a layout, add its padding + if (mLayoutNames.contains(localName)) { + padding[0]++; + padding[1]++; + } + } + + /** + * Combines the padding of the children of a linear layout. + * <p/>For this layout, the padding of the children are added in the direction of + * the layout, while the max is taken for the other direction. + * @param paddings the list of the padding for the children. + * @param resultPadding the result padding array to fill. + * @param horizontal whether this layout is horizontal (<code>true</code>) or vertical + * (<code>false</code>) + */ + private void combineLinearLayout(Collection<int[]> paddings, int[] resultPadding, + boolean horizontal) { + // The way the children are combined will depend on the direction. + // For instance in a vertical layout, we add the y padding as they all add to the length + // of the needed canvas, while we take the biggest x padding needed by the children + + // the axis in which we take the sum of the padding of the children + int sumIndex = horizontal ? 0 : 1; + // the axis in which we take the max of the padding of the children + int maxIndex = horizontal ? 1 : 0; + + int max = -1; + for (int[] p : paddings) { + resultPadding[sumIndex] += p[sumIndex]; + if (max == -1 || max < p[maxIndex]) { + max = p[maxIndex]; + } + } + resultPadding[maxIndex] = max; + } + + /** + * Combine the padding of children of a relative layout. + * @param childrenPadding a map of the children. This is guaranteed that the node object + * are of type ELEMENT_NODE + * @param padding + * + * TODO: Not used yet. Still need (lots of) work. + */ + private void combineRelativeLayout(Map<Node, int[]> childrenPadding, int[] padding) { + /* + * Combines the children of the layout. + * The way this works: for each children, for each direction, look for all the chidrens + * connected and compute the combined margin in that direction. + * + * There's a chance the returned value will be too much. this is due to the layout sometimes + * dropping views which will not be dropped here. It's ok, as it's better to have too + * much than not enough. + * We could fix this by matching those UiElementNode with their bounds as returned + * by the rendering (ie if bounds is 0/0 in h/w, then ignore the child) + */ + + // list of the UiElementNode + Set<Node> nodeSet = childrenPadding.keySet(); + // map of Id -> node + Map<String, Node> idNodeMap = computeIdNodeMap(nodeSet); + + for (Entry<Node, int[]> entry : childrenPadding.entrySet()) { + Node node = entry.getKey(); + + // first horizontal, to the left. + int[] leftResult = getBiggestMarginInDirection(node, 0 /*horizontal*/, + "layout_toRightOf", "layout_toLeftOf", //$NON-NLS-1$ //$NON-NLS-2$ + childrenPadding, nodeSet, idNodeMap, + false /*includeThisPadding*/); + + // then to the right + int[] rightResult = getBiggestMarginInDirection(node, 0 /*horizontal*/, + "layout_toLeftOf", "layout_toRightOf", //$NON-NLS-1$ //$NON-NLS-2$ + childrenPadding, nodeSet, idNodeMap, + false /*includeThisPadding*/); + + // compute total horizontal margins + int[] thisPadding = childrenPadding.get(node); + int combinedMargin = + (thisPadding != null ? thisPadding[0] : 0) + + (leftResult != null ? leftResult[0] : 0) + + (rightResult != null ? rightResult[0] : 0); + if (combinedMargin > padding[0]) { + padding[0] = combinedMargin; + } + + // first vertical, above. + int[] topResult = getBiggestMarginInDirection(node, 1 /*horizontal*/, + "layout_below", "layout_above", //$NON-NLS-1$ //$NON-NLS-2$ + childrenPadding, nodeSet, idNodeMap, + false /*includeThisPadding*/); + + // then below + int[] bottomResult = getBiggestMarginInDirection(node, 1 /*horizontal*/, + "layout_above", "layout_below", //$NON-NLS-1$ //$NON-NLS-2$ + childrenPadding, nodeSet, idNodeMap, + false /*includeThisPadding*/); + + // compute total horizontal margins + combinedMargin = + (thisPadding != null ? thisPadding[1] : 0) + + (topResult != null ? topResult[1] : 0) + + (bottomResult != null ? bottomResult[1] : 0); + if (combinedMargin > padding[1]) { + padding[1] = combinedMargin; + } + } + } + + /** + * Computes the biggest margin in a given direction. + * + * TODO: Not used yet. Still need (lots of) work. + */ + private int[] getBiggestMarginInDirection(Node node, int resIndex, String relativeTo, + String inverseRelation, Map<Node, int[]> childrenPadding, + Set<Node> nodeSet, Map<String, Node> idNodeMap, + boolean includeThisPadding) { + NamedNodeMap attributes = node.getAttributes(); + + String viewId = getAttribute(node, "id", attributes); //$NON-NLS-1$ + + // first get the item this one is positioned relative to. + String toLeftOfRef = getAttribute(node, relativeTo, attributes); + Node toLeftOf = null; + if (toLeftOfRef != null) { + toLeftOf = idNodeMap.get(cleanUpIdReference(toLeftOfRef)); + } + + ArrayList<Node> list = null; + if (viewId != null) { + // now to the left for items being placed to the left of this one. + list = getMatchingNode(nodeSet, cleanUpIdReference(viewId), inverseRelation); + } + + // now process each children in the same direction. + if (toLeftOf != null) { + if (list == null) { + list = new ArrayList<Node>(); + } + + if (list.indexOf(toLeftOf) == -1) { + list.add(toLeftOf); + } + } + + int[] thisPadding = childrenPadding.get(node); + + if (list != null) { + // since there's a combination to do, we'll return a new result object + int[] result = null; + for (Node nodeOnLeft : list) { + int[] tempRes = getBiggestMarginInDirection(nodeOnLeft, resIndex, relativeTo, + inverseRelation, childrenPadding, nodeSet, idNodeMap, true); + if (tempRes != null && (result == null || result[resIndex] < tempRes[resIndex])) { + result = tempRes; + } + } + + // return the combined padding + if (includeThisPadding == false || thisPadding[resIndex] == 0) { + // just return the one we got since this object adds no padding (or doesn't + // need to be comibined) + return result; + } else if (result != null) { // if result is null, the main return below is used. + // add the result we got with the padding from the current node + int[] realRes = new int [2]; + realRes[resIndex] = thisPadding[resIndex] + result[resIndex]; + return realRes; + } + } + + // if we reach this, there were no other views to the left of this one, so just return + // the view padding. + return includeThisPadding ? thisPadding : null; + } + + /** + * Computes and returns a map of (id, node) for each node of a given {@link Set}. + * <p/> + * Nodes with no id are ignored and not put in the map. + * @param nodes the nodes to fill the map with. + * @return a newly allocated, non-null, map of (id, node) + */ + private Map<String, Node> computeIdNodeMap(Set<Node> nodes) { + Map<String, Node> map = new HashMap<String, Node>(); + for (Node node : nodes) { + String viewId = getAttribute(node, "id", null); //$NON-NLS-1$ + if (viewId != null) { + map.put(cleanUpIdReference(viewId), node); + } + } + return map; + } + + /** + * Cleans up a reference to an ID to return the ID itself only. + * @param reference the reference to "clean up". + * @return the id string only. + */ + private String cleanUpIdReference(String reference) { + // format is @id/foo or @+id/foo or @android:id/foo, or something similar. + int slash = reference.indexOf('/'); + return reference.substring(slash); + } + + /** + * Returns a list of nodes for which a given attribute contains a reference to a given ID. + * + * @param nodes the list of nodes to search through + * @param resId the requested ID + * @param attribute the name of the attribute to test. + * @return a newly allocated, non-null, list of nodes. Could be empty. + */ + private ArrayList<Node> getMatchingNode(Set<Node> nodes, String resId, + String attribute) { + ArrayList<Node> list = new ArrayList<Node>(); + + for (Node node : nodes) { + String value = getAttribute(node, attribute, null); + if (value != null) { + value = cleanUpIdReference(value); + if (value.equals(resId)) { + list.add(node); + } + } + } + + return list; + } + + /** + * Returns an attribute for a given node. + * @param node the node to query + * @param name the name of an attribute + * @param attributes the option {@link NamedNodeMap} object to use to read the attributes from. + */ + private static String getAttribute(Node node, String name, NamedNodeMap attributes) { + if (attributes == null) { + attributes = node.getAttributes(); + } + + if (attributes != null) { + Node attribute = attributes.getNamedItemNS(SdkConstants.NS_RESOURCES, name); + if (attribute != null) { + return attribute.getNodeValue(); + } + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutContentAssist.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutContentAssist.java new file mode 100644 index 000000000..99549ab89 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutContentAssist.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout; + +import static com.android.SdkConstants.ANDROID_PKG_PREFIX; +import static com.android.SdkConstants.ATTR_CLASS; +import static com.android.SdkConstants.ATTR_CONTEXT; +import static com.android.SdkConstants.ATTR_NAME; +import static com.android.SdkConstants.CLASS_ACTIVITY; +import static com.android.SdkConstants.CLASS_FRAGMENT; +import static com.android.SdkConstants.CLASS_V4_FRAGMENT; +import static com.android.SdkConstants.CLASS_VIEW; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_TAG; + +import com.android.annotations.Nullable; +import com.android.annotations.VisibleForTesting; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidContentAssist; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.CustomViewDescriptorService; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CustomViewFinder; +import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.google.common.collect.Lists; +import com.google.common.collect.ObjectArrays; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.ITypeHierarchy; +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Content Assist Processor for /res/layout XML files + */ +@VisibleForTesting +public final class LayoutContentAssist extends AndroidContentAssist { + + /** + * Constructor for LayoutContentAssist + */ + public LayoutContentAssist() { + super(AndroidTargetData.DESCRIPTOR_LAYOUT); + } + + @Override + protected Object[] getChoicesForElement(String parent, Node currentNode) { + Object[] choices = super.getChoicesForElement(parent, currentNode); + if (choices == null) { + if (currentNode.getParentNode().getNodeType() == Node.ELEMENT_NODE) { + String parentName = currentNode.getParentNode().getNodeName(); + if (parentName.indexOf('.') != -1) { + // Custom view with unknown children; just use the root descriptor + // to get all eligible views instead + ElementDescriptor[] children = getRootDescriptor().getChildren(); + for (ElementDescriptor e : children) { + if (e.getXmlName().startsWith(parent)) { + return sort(children); + } + } + } + } + } + + if (choices == null && parent.length() >= 1 && Character.isLowerCase(parent.charAt(0))) { + // Custom view prefix? + List<ElementDescriptor> descriptors = getCustomViews(); + if (descriptors != null && !descriptors.isEmpty()) { + List<ElementDescriptor> matches = Lists.newArrayList(); + for (ElementDescriptor descriptor : descriptors) { + if (descriptor.getXmlLocalName().startsWith(parent)) { + matches.add(descriptor); + } + } + if (!matches.isEmpty()) { + return matches.toArray(new ElementDescriptor[matches.size()]); + } + } + } + + return choices; + } + + @Override + protected ElementDescriptor[] getElementChoicesForTextNode(Node parentNode) { + ElementDescriptor[] choices = super.getElementChoicesForTextNode(parentNode); + + // Add in custom views, if any + List<ElementDescriptor> descriptors = getCustomViews(); + if (descriptors != null && !descriptors.isEmpty()) { + ElementDescriptor[] array = descriptors.toArray( + new ElementDescriptor[descriptors.size()]); + choices = ObjectArrays.concat(choices, array, ElementDescriptor.class); + choices = sort(choices); + } + + return choices; + } + + @Nullable + private List<ElementDescriptor> getCustomViews() { + // Add in custom views, if any + IProject project = mEditor.getProject(); + CustomViewFinder finder = CustomViewFinder.get(project); + Collection<String> views = finder.getAllViews(); + if (views == null) { + finder.refresh(); + views = finder.getAllViews(); + } + if (views != null && !views.isEmpty()) { + List<ElementDescriptor> descriptors = Lists.newArrayListWithExpectedSize(views.size()); + CustomViewDescriptorService customViews = CustomViewDescriptorService.getInstance(); + for (String fqcn : views) { + ViewElementDescriptor descriptor = customViews.getDescriptor(project, fqcn); + if (descriptor != null) { + descriptors.add(descriptor); + } + } + + return descriptors; + } + + return null; + } + + @Override + protected boolean computeAttributeValues(List<ICompletionProposal> proposals, int offset, + String parentTagName, String attributeName, Node node, String wordPrefix, + boolean skipEndTag, int replaceLength) { + super.computeAttributeValues(proposals, offset, parentTagName, attributeName, node, + wordPrefix, skipEndTag, replaceLength); + + boolean projectOnly = false; + List<String> superClasses = null; + if (VIEW_FRAGMENT.equals(parentTagName) && (attributeName.endsWith(ATTR_NAME) + || attributeName.equals(ATTR_CLASS))) { + // Insert fragment class matches + superClasses = Arrays.asList(CLASS_V4_FRAGMENT, CLASS_FRAGMENT); + } else if (VIEW_TAG.equals(parentTagName) && attributeName.endsWith(ATTR_CLASS)) { + // Insert custom view matches + superClasses = Collections.singletonList(CLASS_VIEW); + projectOnly = true; + } else if (attributeName.endsWith(ATTR_CONTEXT)) { + // Insert activity matches + superClasses = Collections.singletonList(CLASS_ACTIVITY); + } + + if (superClasses != null) { + IProject project = mEditor.getProject(); + if (project == null) { + return false; + } + try { + IJavaProject javaProject = BaseProjectHelper.getJavaProject(project); + IType type = javaProject.findType(superClasses.get(0)); + Set<IType> elements = new HashSet<IType>(); + if (type != null) { + ITypeHierarchy hierarchy = type.newTypeHierarchy(new NullProgressMonitor()); + IType[] allSubtypes = hierarchy.getAllSubtypes(type); + for (IType subType : allSubtypes) { + if (!projectOnly || subType.getResource() != null) { + elements.add(subType); + } + } + } + assert superClasses.size() <= 2; // If more, need to do additional work below + if (superClasses.size() == 2) { + type = javaProject.findType(superClasses.get(1)); + if (type != null) { + ITypeHierarchy hierarchy = type.newTypeHierarchy( + new NullProgressMonitor()); + IType[] allSubtypes = hierarchy.getAllSubtypes(type); + for (IType subType : allSubtypes) { + if (!projectOnly || subType.getResource() != null) { + elements.add(subType); + } + } + } + } + + List<IType> sorted = new ArrayList<IType>(elements); + Collections.sort(sorted, new Comparator<IType>() { + @Override + public int compare(IType type1, IType type2) { + String fqcn1 = type1.getFullyQualifiedName(); + String fqcn2 = type2.getFullyQualifiedName(); + int category1 = fqcn1.startsWith(ANDROID_PKG_PREFIX) ? 1 : -1; + int category2 = fqcn2.startsWith(ANDROID_PKG_PREFIX) ? 1 : -1; + if (category1 != category2) { + return category1 - category2; + } + return fqcn1.compareTo(fqcn2); + } + }); + addMatchingProposals(proposals, sorted.toArray(), offset, node, wordPrefix, + (char) 0, false /* isAttribute */, false /* isNew */, + false /* skipEndTag */, replaceLength); + return true; + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + } + + return false; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutEditorDelegate.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutEditorDelegate.java new file mode 100644 index 000000000..1015d7d86 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutEditorDelegate.java @@ -0,0 +1,1001 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.annotations.VisibleForTesting; +import com.android.annotations.VisibleForTesting.Visibility; +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.XmlEditorMultiOutline; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlDelegate; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.IUnknownDescriptorProvider; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.CustomViewDescriptorService; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutActionBar; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.OutlinePage; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionManager; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.editors.layout.properties.PropertySheetPage; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.lint.EclipseLintClient; +import com.android.ide.eclipse.adt.internal.lint.EclipseLintRunner; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.ResourceFolderType; +import com.android.sdklib.IAndroidTarget; +import com.android.tools.lint.client.api.IssueRegistry; + +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.jobs.IJobChangeEvent; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.core.runtime.jobs.JobChangeAdapter; +import org.eclipse.jface.text.source.ISourceViewer; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.ui.IActionBars; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IFileEditorInput; +import org.eclipse.ui.ISelectionListener; +import org.eclipse.ui.ISelectionService; +import org.eclipse.ui.IShowEditorInput; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.IWorkbenchPartSite; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.forms.editor.IFormPage; +import org.eclipse.ui.part.FileEditorInput; +import org.eclipse.ui.views.contentoutline.IContentOutlinePage; +import org.eclipse.ui.views.properties.IPropertySheetPage; +import org.eclipse.wst.sse.ui.StructuredTextEditor; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import java.io.File; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Multi-page form editor for /res/layout XML files. + */ +public class LayoutEditorDelegate extends CommonXmlDelegate + implements IShowEditorInput, CommonXmlDelegate.IActionContributorDelegate { + + /** The prefix for layout folders that are not the default layout folder */ + private static final String LAYOUT_FOLDER_PREFIX = "layout-"; //$NON-NLS-1$ + + public static class Creator implements IDelegateCreator { + @Override + @SuppressWarnings("unchecked") + public LayoutEditorDelegate createForFile( + @NonNull CommonXmlEditor delegator, + @Nullable ResourceFolderType type) { + if (ResourceFolderType.LAYOUT == type) { + return new LayoutEditorDelegate(delegator); + } + + return null; + } + } + + /** + * Old standalone-editor ID. + * Use {@link CommonXmlEditor#ID} instead. + */ + public static final String LEGACY_EDITOR_ID = + AdtConstants.EDITORS_NAMESPACE + ".layout.LayoutEditor"; //$NON-NLS-1$ + + /** Root node of the UI element hierarchy */ + private UiDocumentNode mUiDocRootNode; + + private GraphicalEditorPart mGraphicalEditor; + private int mGraphicalEditorIndex; + + /** Implementation of the {@link IContentOutlinePage} for this editor */ + private OutlinePage mLayoutOutline; + + /** The XML editor outline */ + private IContentOutlinePage mEditorOutline; + + /** Multiplexing outline, used for multi-page editors that have their own outline */ + private XmlEditorMultiOutline mMultiOutline; + + /** + * Temporary flag set by the editor caret listener which is used to cause + * the next getAdapter(IContentOutlinePage.class) call to return the editor + * outline rather than the multi-outline. See the {@link #delegateGetAdapter} + * method for details. + */ + private boolean mCheckOutlineAdapter; + + /** Custom implementation of {@link IPropertySheetPage} for this editor */ + private IPropertySheetPage mPropertyPage; + + private final HashMap<String, ElementDescriptor> mUnknownDescriptorMap = + new HashMap<String, ElementDescriptor>(); + + private EclipseLintClient mClient; + + /** + * Flag indicating if the replacement file is due to a config change. + * If false, it means the new file is due to an "open action" from the user. + */ + private boolean mNewFileOnConfigChange = false; + + /** + * Checks whether an editor part is an instance of {@link CommonXmlEditor} + * with an associated {@link LayoutEditorDelegate} delegate. + * + * @param editorPart An editor part. Can be null. + * @return The {@link LayoutEditorDelegate} delegate associated with the editor or null. + */ + public static @Nullable LayoutEditorDelegate fromEditor(@Nullable IEditorPart editorPart) { + if (editorPart instanceof CommonXmlEditor) { + CommonXmlDelegate delegate = ((CommonXmlEditor) editorPart).getDelegate(); + if (delegate instanceof LayoutEditorDelegate) { + return ((LayoutEditorDelegate) delegate); + } + } else if (editorPart instanceof GraphicalEditorPart) { + GraphicalEditorPart part = (GraphicalEditorPart) editorPart; + return part.getEditorDelegate(); + } + return null; + } + + /** + * Creates the form editor for resources XML files. + */ + @VisibleForTesting(visibility=Visibility.PRIVATE) + protected LayoutEditorDelegate(CommonXmlEditor editor) { + super(editor, new LayoutContentAssist()); + // Note that LayoutEditor has its own listeners and does not + // need to call editor.addDefaultTargetListener(). + } + + /** + * Returns the {@link RulesEngine} associated with this editor + * + * @return the {@link RulesEngine} associated with this editor. + */ + public RulesEngine getRulesEngine() { + return mGraphicalEditor.getRulesEngine(); + } + + /** + * Returns the {@link GraphicalEditorPart} associated with this editor + * + * @return the {@link GraphicalEditorPart} associated with this editor + */ + public GraphicalEditorPart getGraphicalEditor() { + return mGraphicalEditor; + } + + /** + * @return The root node of the UI element hierarchy + */ + @Override + public UiDocumentNode getUiRootNode() { + return mUiDocRootNode; + } + + public void setNewFileOnConfigChange(boolean state) { + mNewFileOnConfigChange = state; + } + + // ---- Base Class Overrides ---- + + @Override + public void dispose() { + super.dispose(); + if (mGraphicalEditor != null) { + mGraphicalEditor.dispose(); + mGraphicalEditor = null; + } + } + + /** + * Save the XML. + * <p/> + * Clients must NOT call this directly. Instead they should always + * call {@link CommonXmlEditor#doSave(IProgressMonitor)} so that th + * editor super class can commit the data properly. + * <p/> + * Here we just need to tell the graphical editor that the model has + * been saved. + */ + @Override + public void delegateDoSave(IProgressMonitor monitor) { + super.delegateDoSave(monitor); + if (mGraphicalEditor != null) { + mGraphicalEditor.doSave(monitor); + } + } + + /** + * Create the various form pages. + */ + @Override + public void delegateCreateFormPages() { + try { + // get the file being edited so that it can be passed to the layout editor. + IFile editedFile = null; + IEditorInput input = getEditor().getEditorInput(); + if (input instanceof FileEditorInput) { + FileEditorInput fileInput = (FileEditorInput)input; + editedFile = fileInput.getFile(); + if (!editedFile.isAccessible()) { + return; + } + } else { + AdtPlugin.log(IStatus.ERROR, + "Input is not of type FileEditorInput: %1$s", //$NON-NLS-1$ + input.toString()); + } + + // It is possible that the Layout Editor already exits if a different version + // of the same layout is being opened (either through "open" action from + // the user, or through a configuration change in the configuration selector.) + if (mGraphicalEditor == null) { + + // Instantiate GLE v2 + mGraphicalEditor = new GraphicalEditorPart(this); + + mGraphicalEditorIndex = getEditor().addPage(mGraphicalEditor, + getEditor().getEditorInput()); + getEditor().setPageText(mGraphicalEditorIndex, mGraphicalEditor.getTitle()); + + mGraphicalEditor.openFile(editedFile); + } else { + if (mNewFileOnConfigChange) { + mGraphicalEditor.changeFileOnNewConfig(editedFile); + mNewFileOnConfigChange = false; + } else { + mGraphicalEditor.replaceFile(editedFile); + } + } + } catch (PartInitException e) { + AdtPlugin.log(e, "Error creating nested page"); //$NON-NLS-1$ + } + } + + @Override + public void delegatePostCreatePages() { + // Optional: set the default page. Eventually a default page might be + // restored by selectDefaultPage() later based on the last page used by the user. + // For example, to make the last page the default one (rather than the first page), + // uncomment this line: + // setActivePage(getPageCount() - 1); + } + + /* (non-java doc) + * Change the tab/title name to include the name of the layout. + */ + @Override + public void delegateSetInput(IEditorInput input) { + handleNewInput(input); + } + + /* + * (non-Javadoc) + * @see org.eclipse.ui.part.EditorPart#setInputWithNotify(org.eclipse.ui.IEditorInput) + */ + public void delegateSetInputWithNotify(IEditorInput input) { + handleNewInput(input); + } + + /** + * Called to replace the current {@link IEditorInput} with another one. + * <p/> + * This is used when {@link LayoutEditorMatchingStrategy} returned + * <code>true</code> which means we're opening a different configuration of + * the same layout. + */ + @Override + public void showEditorInput(IEditorInput editorInput) { + if (getEditor().getEditorInput().equals(editorInput)) { + return; + } + + // Save the current editor input. This must be called on the editor itself + // since it's the base editor that commits pending changes. + getEditor().doSave(new NullProgressMonitor()); + + // Get the current page + int currentPage = getEditor().getActivePage(); + + // Remove the pages, except for the graphical editor, which will be dynamically adapted + // to the new model. + // page after the graphical editor: + int count = getEditor().getPageCount(); + for (int i = count - 1 ; i > mGraphicalEditorIndex ; i--) { + getEditor().removePage(i); + } + // Pages before the graphical editor + for (int i = mGraphicalEditorIndex - 1 ; i >= 0 ; i--) { + getEditor().removePage(i); + } + + // Set the current input. We're in the delegate, the input must + // be set into the actual editor instance. + getEditor().setInputWithNotify(editorInput); + + // Re-create or reload the pages with the default page shown as the previous active page. + getEditor().createAndroidPages(); + getEditor().selectDefaultPage(Integer.toString(currentPage)); + + // When changing an input file of an the editor, the titlebar is not refreshed to + // show the new path/to/file being edited. So we force a refresh + getEditor().firePropertyChange(IWorkbenchPart.PROP_TITLE); + } + + /** Performs a complete refresh of the XML model */ + public void refreshXmlModel() { + Document xmlDoc = mUiDocRootNode.getXmlDocument(); + + delegateInitUiRootNode(true /*force*/); + mUiDocRootNode.loadFromXmlNode(xmlDoc); + + // Update the model first, since it is used by the viewers. + // No need to call AndroidXmlEditor.xmlModelChanged(xmlDoc) since it's + // a no-op. Instead call onXmlModelChanged on the graphical editor. + + if (mGraphicalEditor != null) { + mGraphicalEditor.onXmlModelChanged(); + } + } + + /** + * Processes the new XML Model, which XML root node is given. + * + * @param xml_doc The XML document, if available, or null if none exists. + */ + @Override + public void delegateXmlModelChanged(Document xml_doc) { + // init the ui root on demand + delegateInitUiRootNode(false /*force*/); + + mUiDocRootNode.loadFromXmlNode(xml_doc); + + // Update the model first, since it is used by the viewers. + // No need to call AndroidXmlEditor.xmlModelChanged(xmlDoc) since it's + // a no-op. Instead call onXmlModelChanged on the graphical editor. + + if (mGraphicalEditor != null) { + mGraphicalEditor.onXmlModelChanged(); + } + } + + /** + * Tells the graphical editor to recompute its layout. + */ + public void recomputeLayout() { + mGraphicalEditor.recomputeLayout(); + } + + /** + * Does this editor participate in the "format GUI editor changes" option? + * + * @return true since this editor supports automatically formatting XML + * affected by GUI changes + */ + @Override + public boolean delegateSupportsFormatOnGuiEdit() { + return true; + } + + /** + * Returns one of the issues for the given node (there could be more than one) + * + * @param node the node to look up lint issues for + * @return the marker for one of the issues found for the given node + */ + @Nullable + public IMarker getIssueForNode(@Nullable UiViewElementNode node) { + if (node == null) { + return null; + } + + if (mClient != null) { + return mClient.getIssueForNode(node); + } + + return null; + } + + /** + * Returns a collection of nodes that have one or more lint warnings + * associated with them (retrievable via + * {@link #getIssueForNode(UiViewElementNode)}) + * + * @return a collection of nodes, which should <b>not</b> be modified by the + * caller + */ + @Nullable + public Collection<Node> getLintNodes() { + if (mClient != null) { + return mClient.getIssueNodes(); + } + + return null; + } + + @Override + public Job delegateRunLint() { + // We want to customize the {@link EclipseLintClient} created to run this + // single file lint, in particular such that we can set the mode which collects + // nodes on that lint job, such that we can quickly look up error nodes + //Job job = super.delegateRunLint(); + + Job job = null; + IFile file = getEditor().getInputFile(); + if (file != null) { + IssueRegistry registry = EclipseLintClient.getRegistry(); + List<IFile> resources = Collections.singletonList(file); + mClient = new EclipseLintClient(registry, + resources, getEditor().getStructuredDocument(), false /*fatal*/); + + mClient.setCollectNodes(true); + + job = EclipseLintRunner.startLint(mClient, resources, file, + false /*show*/); + } + + if (job != null) { + GraphicalEditorPart graphicalEditor = getGraphicalEditor(); + if (graphicalEditor != null) { + job.addJobChangeListener(new LintJobListener(graphicalEditor)); + } + } + return job; + } + + private class LintJobListener extends JobChangeAdapter implements Runnable { + private final GraphicalEditorPart mEditor; + private final LayoutCanvas mCanvas; + + LintJobListener(GraphicalEditorPart editor) { + mEditor = editor; + mCanvas = editor.getCanvasControl(); + } + + @Override + public void done(IJobChangeEvent event) { + LayoutActionBar bar = mEditor.getLayoutActionBar(); + if (!bar.isDisposed()) { + bar.updateErrorIndicator(); + } + + // Redraw + if (!mCanvas.isDisposed()) { + mCanvas.getDisplay().asyncExec(this); + } + } + + @Override + public void run() { + if (!mCanvas.isDisposed()) { + mCanvas.redraw(); + + OutlinePage outlinePage = mCanvas.getOutlinePage(); + if (outlinePage != null) { + outlinePage.refreshIcons(); + } + } + } + } + + /** + * Returns the custom IContentOutlinePage or IPropertySheetPage when asked for it. + */ + @Override + public Object delegateGetAdapter(Class<?> adapter) { + if (adapter == IContentOutlinePage.class) { + // Somebody has requested the outline. Eclipse can only have a single outline page, + // even for a multi-part editor: + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=1917 + // To work around this we use PDE's workaround of having a single multiplexing + // outline which switches its contents between the outline pages we register + // for it, and then on page switch we notify it to update itself. + + // There is one complication: The XML editor outline listens for the editor + // selection and uses this to automatically expand its tree children and show + // the current node containing the caret as selected. Unfortunately, this + // listener code contains this: + // + // /* Bug 136310, unless this page is that part's + // * IContentOutlinePage, ignore the selection change */ + // if (part.getAdapter(IContentOutlinePage.class) == this) { + // + // This means that when we return the multiplexing outline from this getAdapter + // method, the outline no longer updates to track the selection. + // To work around this, we use the following hack^H^H^H^H technique: + // - Add a selection listener *before* requesting the editor outline, such + // that the selection listener is told about the impending selection event + // right before the editor outline hears about it. Set the flag + // mCheckOutlineAdapter to true. (We also only set it if the editor view + // itself is active.) + // - In this getAdapter method, when somebody requests the IContentOutline.class, + // see if mCheckOutlineAdapter to see if this request is *likely* coming + // from the XML editor outline. If so, make sure it is by actually looking + // at the signature of the caller. If it's the editor outline, then return + // the editor outline instance itself rather than the multiplexing outline. + if (mCheckOutlineAdapter && mEditorOutline != null) { + mCheckOutlineAdapter = false; + // Make *sure* this is really the editor outline calling in case + // future versions of Eclipse changes the sequencing or dispatch of selection + // events: + StackTraceElement[] frames = new Throwable().fillInStackTrace().getStackTrace(); + if (frames.length > 2) { + StackTraceElement frame = frames[2]; + if (frame.getClassName().equals( + "org.eclipse.wst.sse.ui.internal.contentoutline." + //$NON-NLS-1$ + "ConfigurableContentOutlinePage$PostSelectionServiceListener")) { //$NON-NLS-1$ + return mEditorOutline; + } + } + } + + // Use a multiplexing outline: workaround for + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=1917 + if (mMultiOutline == null || mMultiOutline.isDisposed()) { + mMultiOutline = new XmlEditorMultiOutline(); + mMultiOutline.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + ISelection selection = event.getSelection(); + getEditor().getSite().getSelectionProvider().setSelection(selection); + if (getEditor().getIgnoreXmlUpdate()) { + return; + } + SelectionManager manager = + mGraphicalEditor.getCanvasControl().getSelectionManager(); + manager.setSelection(selection); + } + }); + updateOutline(getEditor().getActivePageInstance()); + } + + return mMultiOutline; + } + + if (IPropertySheetPage.class == adapter && mGraphicalEditor != null) { + if (mPropertyPage == null) { + mPropertyPage = new PropertySheetPage(mGraphicalEditor); + } + + return mPropertyPage; + } + + // return default + return super.delegateGetAdapter(adapter); + } + + /** + * Update the contents of the outline to show either the XML editor outline + * or the layout editor graphical outline depending on which tab is visible + */ + private void updateOutline(IFormPage page) { + if (mMultiOutline == null) { + return; + } + + IContentOutlinePage outline; + CommonXmlEditor editor = getEditor(); + if (!editor.isEditorPageActive()) { + outline = getGraphicalOutline(); + } else { + // Use plain XML editor outline instead + if (mEditorOutline == null) { + StructuredTextEditor structuredTextEditor = editor.getStructuredTextEditor(); + if (structuredTextEditor != null) { + IWorkbenchWindow window = editor.getSite().getWorkbenchWindow(); + ISelectionService service = window.getSelectionService(); + service.addPostSelectionListener(new ISelectionListener() { + @Override + public void selectionChanged(IWorkbenchPart part, ISelection selection) { + if (getEditor().isEditorPageActive()) { + mCheckOutlineAdapter = true; + } + } + }); + + mEditorOutline = (IContentOutlinePage) structuredTextEditor.getAdapter( + IContentOutlinePage.class); + } + } + + outline = mEditorOutline; + } + + mMultiOutline.setPageActive(outline); + } + + /** + * Returns the graphical outline associated with the layout editor + * + * @return the outline page, never null + */ + @NonNull + public OutlinePage getGraphicalOutline() { + if (mLayoutOutline == null) { + mLayoutOutline = new OutlinePage(mGraphicalEditor); + } + + return mLayoutOutline; + } + + @Override + public void delegatePageChange(int newPageIndex) { + if (getEditor().getCurrentPage() == getEditor().getTextPageIndex() && + newPageIndex == mGraphicalEditorIndex) { + // You're switching from the XML editor to the WYSIWYG editor; + // look at the caret position and figure out which node it corresponds to + // (if any) and if found, select the corresponding visual element. + ISourceViewer textViewer = getEditor().getStructuredSourceViewer(); + int caretOffset = textViewer.getTextWidget().getCaretOffset(); + if (caretOffset >= 0) { + Node node = DomUtilities.getNode(textViewer.getDocument(), caretOffset); + if (node != null && mGraphicalEditor != null) { + mGraphicalEditor.select(node); + } + } + } + + super.delegatePageChange(newPageIndex); + + if (mGraphicalEditor != null) { + if (newPageIndex == mGraphicalEditorIndex) { + mGraphicalEditor.activated(); + } else { + mGraphicalEditor.deactivated(); + } + } + } + + @Override + public int delegateGetPersistenceCategory() { + return AndroidXmlEditor.CATEGORY_LAYOUT; + } + + @Override + public void delegatePostPageChange(int newPageIndex) { + super.delegatePostPageChange(newPageIndex); + + if (mGraphicalEditor != null) { + LayoutCanvas canvas = mGraphicalEditor.getCanvasControl(); + if (canvas != null) { + IActionBars bars = getEditor().getEditorSite().getActionBars(); + if (bars != null) { + canvas.updateGlobalActions(bars); + } + } + } + + IFormPage page = getEditor().getActivePageInstance(); + updateOutline(page); + } + + @Override + public IFormPage delegatePostSetActivePage(IFormPage superReturned, String pageIndex) { + IFormPage page = superReturned; + if (page != null) { + updateOutline(page); + } + + return page; + } + + // ----- IActionContributorDelegate methods ---- + + @Override + public void setActiveEditor(IEditorPart part, IActionBars bars) { + if (mGraphicalEditor != null) { + LayoutCanvas canvas = mGraphicalEditor.getCanvasControl(); + if (canvas != null) { + canvas.updateGlobalActions(bars); + } + } + } + + + @Override + public void delegateActivated() { + if (mGraphicalEditor != null) { + if (getEditor().getActivePage() == mGraphicalEditorIndex) { + mGraphicalEditor.activated(); + } else { + mGraphicalEditor.deactivated(); + } + } + } + + @Override + public void delegateDeactivated() { + if (mGraphicalEditor != null && getEditor().getActivePage() == mGraphicalEditorIndex) { + mGraphicalEditor.deactivated(); + } + } + + @Override + public String delegateGetPartName() { + IEditorInput editorInput = getEditor().getEditorInput(); + if (!AdtPrefs.getPrefs().isSharedLayoutEditor() + && editorInput instanceof IFileEditorInput) { + IFileEditorInput fileInput = (IFileEditorInput) editorInput; + IFile file = fileInput.getFile(); + IContainer parent = file.getParent(); + if (parent != null) { + String parentName = parent.getName(); + if (parentName.startsWith(LAYOUT_FOLDER_PREFIX)) { + parentName = parentName.substring(LAYOUT_FOLDER_PREFIX.length()); + return parentName + File.separatorChar + file.getName(); + } + } + } + + return super.delegateGetPartName(); + } + + // ---- Local Methods ---- + + /** + * Returns true if the Graphics editor page is visible. This <b>must</b> be + * called from the UI thread. + */ + public boolean isGraphicalEditorActive() { + IWorkbenchPartSite workbenchSite = getEditor().getSite(); + IWorkbenchPage workbenchPage = workbenchSite.getPage(); + + // check if the editor is visible in the workbench page + if (workbenchPage.isPartVisible(getEditor()) + && workbenchPage.getActiveEditor() == getEditor()) { + // and then if the page of the editor is visible (not to be confused with + // the workbench page) + return mGraphicalEditorIndex == getEditor().getActivePage(); + } + + return false; + } + + @Override + public void delegateInitUiRootNode(boolean force) { + // The root UI node is always created, even if there's no corresponding XML node. + if (mUiDocRootNode == null || force) { + // get the target data from the opened file (and its project) + AndroidTargetData data = getEditor().getTargetData(); + + Document doc = null; + if (mUiDocRootNode != null) { + doc = mUiDocRootNode.getXmlDocument(); + } + + DocumentDescriptor desc; + if (data == null) { + desc = new DocumentDescriptor("temp", null /*children*/); + } else { + desc = data.getLayoutDescriptors().getDescriptor(); + } + + // get the descriptors from the data. + mUiDocRootNode = (UiDocumentNode) desc.createUiNode(); + super.setUiRootNode(mUiDocRootNode); + mUiDocRootNode.setEditor(getEditor()); + + mUiDocRootNode.setUnknownDescriptorProvider(new IUnknownDescriptorProvider() { + @Override + public ElementDescriptor getDescriptor(String xmlLocalName) { + ElementDescriptor unknown = mUnknownDescriptorMap.get(xmlLocalName); + if (unknown == null) { + unknown = createUnknownDescriptor(xmlLocalName); + mUnknownDescriptorMap.put(xmlLocalName, unknown); + } + + return unknown; + } + }); + + onDescriptorsChanged(doc); + } + } + + /** + * Creates a new {@link ViewElementDescriptor} for an unknown XML local name + * (i.e. one that was not mapped by the current descriptors). + * <p/> + * Since we deal with layouts, we returns either a descriptor for a custom view + * or one for the base View. + * + * @param xmlLocalName The XML local name to match. + * @return A non-null {@link ViewElementDescriptor}. + */ + private ViewElementDescriptor createUnknownDescriptor(String xmlLocalName) { + ViewElementDescriptor desc = null; + IEditorInput editorInput = getEditor().getEditorInput(); + if (editorInput instanceof IFileEditorInput) { + IFileEditorInput fileInput = (IFileEditorInput)editorInput; + IProject project = fileInput.getFile().getProject(); + + // Check if we can find a custom view specific to this project. + // This only works if there's an actual matching custom class in the project. + if (xmlLocalName.indexOf('.') != -1) { + desc = CustomViewDescriptorService.getInstance().getDescriptor(project, + xmlLocalName); + } + + if (desc == null) { + // If we didn't find a custom view, create a synthetic one using the + // the base View descriptor as a model. + // This is a layout after all, so every XML node should represent + // a view. + + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(project); + if (target != null) { + AndroidTargetData data = currentSdk.getTargetData(target); + if (data != null) { + // data can be null when the target is still loading + ViewElementDescriptor viewDesc = + data.getLayoutDescriptors().getBaseViewDescriptor(); + + desc = new ViewElementDescriptor( + xmlLocalName, // xml local name + xmlLocalName, // ui_name + xmlLocalName, // canonical class name + null, // tooltip + null, // sdk_url + viewDesc.getAttributes(), + viewDesc.getLayoutAttributes(), + null, // children + false /* mandatory */); + desc.setSuperClass(viewDesc); + } + } + } + } + } + + if (desc == null) { + // We can only arrive here if the SDK's android target has not finished + // loading. Just create a dummy descriptor with no attributes to be able + // to continue. + desc = new ViewElementDescriptor(xmlLocalName, xmlLocalName); + } + return desc; + } + + private void onDescriptorsChanged(Document document) { + + mUnknownDescriptorMap.clear(); + + if (document != null) { + mUiDocRootNode.loadFromXmlNode(document); + } else { + mUiDocRootNode.reloadFromXmlNode(mUiDocRootNode.getXmlDocument()); + } + + if (mGraphicalEditor != null) { + mGraphicalEditor.onTargetChange(); + mGraphicalEditor.reloadPalette(); + mGraphicalEditor.getCanvasControl().syncPreviewMode(); + } + } + + /** + * Handles a new input, and update the part name. + * @param input the new input. + */ + private void handleNewInput(IEditorInput input) { + if (input instanceof FileEditorInput) { + FileEditorInput fileInput = (FileEditorInput) input; + IFile file = fileInput.getFile(); + getEditor().setPartName(String.format("%1$s", file.getName())); + } + } + + /** + * Helper method that returns a {@link ViewElementDescriptor} for the requested FQCN. + * Will return null if we can't find that FQCN or we lack the editor/data/descriptors info. + */ + public ViewElementDescriptor getFqcnViewDescriptor(String fqcn) { + ViewElementDescriptor desc = null; + + AndroidTargetData data = getEditor().getTargetData(); + if (data != null) { + LayoutDescriptors layoutDesc = data.getLayoutDescriptors(); + if (layoutDesc != null) { + DocumentDescriptor docDesc = layoutDesc.getDescriptor(); + if (docDesc != null) { + desc = internalFindFqcnViewDescriptor(fqcn, docDesc.getChildren(), null); + } + } + } + + if (desc == null) { + // We failed to find a descriptor for the given FQCN. + // Let's consider custom classes and create one as needed. + desc = createUnknownDescriptor(fqcn); + } + + return desc; + } + + /** + * Internal helper to recursively search for a {@link ViewElementDescriptor} that matches + * the requested FQCN. + * + * @param fqcn The target View FQCN to find. + * @param descriptors A list of children descriptors to iterate through. + * @param visited A set we use to remember which descriptors have already been visited, + * necessary since the view descriptor hierarchy is cyclic. + * @return Either a matching {@link ViewElementDescriptor} or null. + */ + private ViewElementDescriptor internalFindFqcnViewDescriptor(String fqcn, + ElementDescriptor[] descriptors, + Set<ElementDescriptor> visited) { + if (visited == null) { + visited = new HashSet<ElementDescriptor>(); + } + + if (descriptors != null) { + for (ElementDescriptor desc : descriptors) { + if (visited.add(desc)) { + // Set.add() returns true if this a new element that was added to the set. + // That means we haven't visited this descriptor yet. + // We want a ViewElementDescriptor with a matching FQCN. + if (desc instanceof ViewElementDescriptor && + fqcn.equals(((ViewElementDescriptor) desc).getFullClassName())) { + return (ViewElementDescriptor) desc; + } + + // Visit its children + ViewElementDescriptor vd = + internalFindFqcnViewDescriptor(fqcn, desc.getChildren(), visited); + if (vd != null) { + return vd; + } + } + } + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutEditorMatchingStrategy.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutEditorMatchingStrategy.java new file mode 100644 index 000000000..c1c606854 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutEditorMatchingStrategy.java @@ -0,0 +1,81 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import com.android.ide.common.resources.ResourceFolder; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.resources.ResourceFolderType; + +import org.eclipse.core.resources.IFile; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorMatchingStrategy; +import org.eclipse.ui.IEditorReference; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.part.FileEditorInput; + +/** + * Matching strategy for the Layout Editor. This is used to open all configurations of a layout + * in the same editor. + */ +public class LayoutEditorMatchingStrategy implements IEditorMatchingStrategy { + + @Override + public boolean matches(IEditorReference editorRef, IEditorInput input) { + // first check that the file being opened is a layout file. + if (input instanceof FileEditorInput) { + FileEditorInput fileInput = (FileEditorInput)input; + + // get the IFile object and check it's in one of the layout folders. + IFile file = fileInput.getFile(); + ResourceManager manager = ResourceManager.getInstance(); + ResourceFolder resFolder = manager.getResourceFolder(file); + + // Per the IEditorMatchingStrategy documentation, editorRef.getEditorInput() + // is expensive so try exclude files that definitely don't match, such + // as those with the wrong extension or wrong file name + if (!file.getName().equals(editorRef.getName()) || + !editorRef.getId().equals(CommonXmlEditor.ID)) { + return false; + } + + // if it's a layout, we now check the name of the fileInput against the name of the + // file being currently edited by the editor since those are independent of the config. + if (resFolder != null && resFolder.getType() == ResourceFolderType.LAYOUT) { + try { + IEditorInput editorInput = editorRef.getEditorInput(); + if (editorInput instanceof FileEditorInput) { + FileEditorInput editorFileInput = (FileEditorInput)editorInput; + IFile editorFile = editorFileInput.getFile(); + + ResourceFolder editorFolder = manager.getResourceFolder(editorFile); + if (editorFolder == null + || editorFolder.getType() != ResourceFolderType.LAYOUT) { + return false; + } + + return editorFile.getProject().equals(file.getProject()) + && editorFile.getName().equals(file.getName()); + } + } catch (PartInitException e) { + // we do nothing, we'll just return false. + } + } + } + return false; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutReloadMonitor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutReloadMonitor.java new file mode 100644 index 000000000..4e4429dc8 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/LayoutReloadMonitor.java @@ -0,0 +1,375 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.ResourceFile; +import com.android.ide.common.resources.ResourceFolder; +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.resources.manager.GlobalProjectMonitor; +import com.android.ide.eclipse.adt.internal.resources.manager.GlobalProjectMonitor.IFileListener; +import com.android.ide.eclipse.adt.internal.resources.manager.GlobalProjectMonitor.IResourceEventListener; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager.IResourceListener; +import com.android.ide.eclipse.adt.internal.sdk.ProjectState; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.ResourceType; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarkerDelta; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResourceDelta; +import org.eclipse.core.runtime.CoreException; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** + * Monitor for file changes that could trigger a layout redraw, or a UI update + */ +public final class LayoutReloadMonitor { + + // singleton, enforced by private constructor. + private final static LayoutReloadMonitor sThis = new LayoutReloadMonitor(); + + /** + * Map of listeners by IProject. + */ + private final Map<IProject, List<ILayoutReloadListener>> mListenerMap = + new HashMap<IProject, List<ILayoutReloadListener>>(); + + public final static class ChangeFlags { + public boolean code = false; + /** any non-layout resource changes */ + public boolean resources = false; + public boolean rClass = false; + public boolean localeList = false; + public boolean manifest = false; + + boolean isAllTrue() { + return code && resources && rClass && localeList && manifest; + } + } + + /** + * List of projects having received a resource change. + */ + private final Map<IProject, ChangeFlags> mProjectFlags = new HashMap<IProject, ChangeFlags>(); + + /** + * Classes which implement this interface provide a method to respond to resource changes + * triggering a layout redraw + */ + public interface ILayoutReloadListener { + /** + * Sent when the layout needs to be redrawn + * + * @param flags a {@link ChangeFlags} object indicating what type of resource changed. + * @param libraryModified <code>true</code> if the changeFlags are not for the project + * associated with the listener, but instead correspond to a library. + */ + void reloadLayout(ChangeFlags flags, boolean libraryModified); + } + + /** + * Returns the single instance of {@link LayoutReloadMonitor}. + */ + public static LayoutReloadMonitor getMonitor() { + return sThis; + } + + private LayoutReloadMonitor() { + // listen to resource changes. Used for non-layout resource (trigger a redraw), or + // any resource folder (trigger a locale list refresh) + ResourceManager.getInstance().addListener(mResourceListener); + + // also listen for .class file changed in case the layout has custom view classes. + GlobalProjectMonitor monitor = GlobalProjectMonitor.getMonitor(); + monitor.addFileListener(mFileListener, + IResourceDelta.ADDED | IResourceDelta.CHANGED | IResourceDelta.REMOVED); + + monitor.addResourceEventListener(mResourceEventListener); + } + + /** + * Adds a listener for a given {@link IProject}. + * @param project + * @param listener + */ + public void addListener(IProject project, ILayoutReloadListener listener) { + synchronized (mListenerMap) { + List<ILayoutReloadListener> list = mListenerMap.get(project); + if (list == null) { + list = new ArrayList<ILayoutReloadListener>(); + mListenerMap.put(project, list); + } + + list.add(listener); + } + } + + /** + * Removes a listener for a given {@link IProject}. + */ + public void removeListener(IProject project, ILayoutReloadListener listener) { + synchronized (mListenerMap) { + List<ILayoutReloadListener> list = mListenerMap.get(project); + if (list != null) { + list.remove(listener); + } + } + } + + /** + * Removes a listener, no matter which {@link IProject} it was associated with. + */ + public void removeListener(ILayoutReloadListener listener) { + synchronized (mListenerMap) { + + for (List<ILayoutReloadListener> list : mListenerMap.values()) { + Iterator<ILayoutReloadListener> it = list.iterator(); + while (it.hasNext()) { + ILayoutReloadListener i = it.next(); + if (i == listener) { + it.remove(); + } + } + } + } + } + + /** + * Implementation of the {@link IFileListener} as an internal class so that the methods + * do not appear in the public API of {@link LayoutReloadMonitor}. + * + * This is only to detect code and manifest change. Resource changes (located in res/) + * is done through {@link #mResourceListener}. + */ + private IFileListener mFileListener = new IFileListener() { + /* + * Callback for IFileListener. Called when a file changed. + * This records the changes for each project, but does not notify listeners. + */ + @Override + public void fileChanged(@NonNull IFile file, @NonNull IMarkerDelta[] markerDeltas, + int kind, @Nullable String extension, int flags, boolean isAndroidProject) { + // This listener only cares about .class files and AndroidManifest.xml files + if (!(SdkConstants.EXT_CLASS.equals(extension) + || SdkConstants.EXT_XML.equals(extension) + && SdkConstants.FN_ANDROID_MANIFEST_XML.equals(file.getName()))) { + return; + } + + // get the file's project + IProject project = file.getProject(); + + if (isAndroidProject) { + // project is an Android project, it's the one being affected + // directly by its own file change. + processFileChanged(file, project, extension); + } else { + // check the projects depending on it, if they are Android project, update them. + IProject[] referencingProjects = project.getReferencingProjects(); + + for (IProject p : referencingProjects) { + try { + boolean hasAndroidNature = p.hasNature(AdtConstants.NATURE_DEFAULT); + if (hasAndroidNature) { + // the changed project is a dependency on an Android project, + // update the main project. + processFileChanged(file, p, extension); + } + } catch (CoreException e) { + // do nothing if the nature cannot be queried. + } + } + } + } + + /** + * Processes a file change for a given project which may or may not be the file's project. + * @param file the changed file + * @param project the project impacted by the file change. + */ + private void processFileChanged(IFile file, IProject project, String extension) { + // if this project has already been marked as modified, we do nothing. + ChangeFlags changeFlags = mProjectFlags.get(project); + if (changeFlags != null && changeFlags.isAllTrue()) { + return; + } + + // here we only care about code change (so change for .class files). + // Resource changes is handled by the IResourceListener. + if (SdkConstants.EXT_CLASS.equals(extension)) { + if (file.getName().matches("R[\\$\\.](.*)")) { + // this is a R change! + if (changeFlags == null) { + changeFlags = new ChangeFlags(); + mProjectFlags.put(project, changeFlags); + } + + changeFlags.rClass = true; + } else { + // this is a code change! + if (changeFlags == null) { + changeFlags = new ChangeFlags(); + mProjectFlags.put(project, changeFlags); + } + + changeFlags.code = true; + } + } else if (SdkConstants.FN_ANDROID_MANIFEST_XML.equals(file.getName()) && + file.getParent().equals(project)) { + // this is a manifest change! + if (changeFlags == null) { + changeFlags = new ChangeFlags(); + mProjectFlags.put(project, changeFlags); + } + + changeFlags.manifest = true; + } + } + }; + + /** + * Implementation of the {@link IResourceEventListener} as an internal class so that the methods + * do not appear in the public API of {@link LayoutReloadMonitor}. + */ + private IResourceEventListener mResourceEventListener = new IResourceEventListener() { + /* + * Callback for ResourceMonitor.IResourceEventListener. Called at the beginning of a + * resource change event. This is called once, while fileChanged can be + * called several times. + * + */ + @Override + public void resourceChangeEventStart() { + // nothing to be done here, it all happens in the resourceChangeEventEnd + } + + /* + * Callback for ResourceMonitor.IResourceEventListener. Called at the end of a resource + * change event. This is where we notify the listeners. + */ + @Override + public void resourceChangeEventEnd() { + // for each IProject that was changed, we notify all the listeners. + for (Entry<IProject, ChangeFlags> entry : mProjectFlags.entrySet()) { + IProject project = entry.getKey(); + + // notify the project itself. + notifyForProject(project, entry.getValue(), false); + + // check if the project is a library, and if it is search for what other + // project depends on this one (directly or not) + ProjectState state = Sdk.getProjectState(project); + if (state != null && state.isLibrary()) { + Set<ProjectState> mainProjects = Sdk.getMainProjectsFor(project); + for (ProjectState mainProject : mainProjects) { + // always give the changeflag of the modified project. + notifyForProject(mainProject.getProject(), entry.getValue(), true); + } + } + } + + // empty the list. + mProjectFlags.clear(); + } + + /** + * Notifies the listeners for a given project. + * @param project the project for which the listeners must be notified + * @param flags the change flags to pass to the listener + * @param libraryChanged a flag indicating if the change flags are for the give project, + * or if they are for a library dependency. + */ + private void notifyForProject(IProject project, ChangeFlags flags, + boolean libraryChanged) { + synchronized (mListenerMap) { + List<ILayoutReloadListener> listeners = mListenerMap.get(project); + + if (listeners != null) { + for (ILayoutReloadListener listener : listeners) { + try { + listener.reloadLayout(flags, libraryChanged); + } catch (Throwable t) { + AdtPlugin.log(t, "Failed to call ILayoutReloadListener.reloadLayout"); + } + } + } + } + } + }; + + /** + * Implementation of the {@link IResourceListener} as an internal class so that the methods + * do not appear in the public API of {@link LayoutReloadMonitor}. + */ + private IResourceListener mResourceListener = new IResourceListener() { + + @Override + public void folderChanged(IProject project, ResourceFolder folder, int eventType) { + // if this project has already been marked as modified, we do nothing. + ChangeFlags changeFlags = mProjectFlags.get(project); + if (changeFlags != null && changeFlags.isAllTrue()) { + return; + } + + // this means a new resource folder was added or removed, which can impact the + // locale list. + if (changeFlags == null) { + changeFlags = new ChangeFlags(); + mProjectFlags.put(project, changeFlags); + } + + changeFlags.localeList = true; + } + + @Override + public void fileChanged(IProject project, ResourceFile file, int eventType) { + // if this project has already been marked as modified, we do nothing. + ChangeFlags changeFlags = mProjectFlags.get(project); + if (changeFlags != null && changeFlags.isAllTrue()) { + return; + } + + // now check that the file is *NOT* a layout file (those automatically trigger a layout + // reload and we don't want to do it twice.) + Collection<ResourceType> resTypes = file.getResourceTypes(); + + // it's unclear why but there has been cases of resTypes being empty! + if (resTypes.size() > 0) { + // this is a resource change, that may require a layout redraw! + if (changeFlags == null) { + changeFlags = new ChangeFlags(); + mProjectFlags.put(project, changeFlags); + } + + changeFlags.resources = true; + } + } + }; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ProjectCallback.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ProjectCallback.java new file mode 100644 index 000000000..020c666b9 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/ProjectCallback.java @@ -0,0 +1,693 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import static com.android.SdkConstants.ANDROID_PKG_PREFIX; +import static com.android.SdkConstants.CALENDAR_VIEW; +import static com.android.SdkConstants.CLASS_VIEW; +import static com.android.SdkConstants.EXPANDABLE_LIST_VIEW; +import static com.android.SdkConstants.FQCN_GRID_VIEW; +import static com.android.SdkConstants.FQCN_SPINNER; +import static com.android.SdkConstants.GRID_VIEW; +import static com.android.SdkConstants.LIST_VIEW; +import static com.android.SdkConstants.SPINNER; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; + +import com.android.SdkConstants; +import com.android.ide.common.rendering.LayoutLibrary; +import com.android.ide.common.rendering.RenderSecurityManager; +import com.android.ide.common.rendering.api.ActionBarCallback; +import com.android.ide.common.rendering.api.AdapterBinding; +import com.android.ide.common.rendering.api.DataBindingItem; +import com.android.ide.common.rendering.api.Features; +import com.android.ide.common.rendering.api.ILayoutPullParser; +import com.android.ide.common.rendering.api.IProjectCallback; +import com.android.ide.common.rendering.api.LayoutlibCallback; +import com.android.ide.common.rendering.api.LayoutLog; +import com.android.ide.common.rendering.api.ResourceReference; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.Result; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.common.xml.ManifestData; +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderLogger; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.project.AndroidManifestHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectClassLoader; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.resources.ResourceType; +import com.android.util.Pair; +import com.google.common.base.Charsets; +import com.google.common.io.Files; + +import org.eclipse.core.resources.IProject; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.StringReader; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Loader for Android Project class in order to use them in the layout editor. + * <p/>This implements {@link IProjectCallback} for the old and new API through + * {@link LayoutlibCallback} + */ +public final class ProjectCallback extends LayoutlibCallback { + private final HashMap<String, Class<?>> mLoadedClasses = new HashMap<String, Class<?>>(); + private final Set<String> mMissingClasses = new TreeSet<String>(); + private final Set<String> mBrokenClasses = new TreeSet<String>(); + private final IProject mProject; + private final ClassLoader mParentClassLoader; + private final ProjectResources mProjectRes; + private final Object mCredential; + private boolean mUsed = false; + private String mNamespace; + private ProjectClassLoader mLoader = null; + private LayoutLog mLogger; + private LayoutLibrary mLayoutLib; + private String mLayoutName; + private ILayoutPullParser mLayoutEmbeddedParser; + private ResourceResolver mResourceResolver; + private GraphicalEditorPart mEditor; + + /** + * Creates a new {@link ProjectCallback} to be used with the layout lib. + * + * @param layoutLib The layout library this callback is going to be invoked from + * @param projectRes the {@link ProjectResources} for the project. + * @param project the project. + * @param credential the sandbox credential + */ + public ProjectCallback(LayoutLibrary layoutLib, + ProjectResources projectRes, IProject project, Object credential, + GraphicalEditorPart editor) { + mLayoutLib = layoutLib; + mParentClassLoader = layoutLib.getClassLoader(); + mProjectRes = projectRes; + mProject = project; + mCredential = credential; + mEditor = editor; + } + + public Set<String> getMissingClasses() { + return mMissingClasses; + } + + public Set<String> getUninstantiatableClasses() { + return mBrokenClasses; + } + + /** + * Sets the {@link LayoutLog} logger to use for error messages during problems + * + * @param logger the new logger to use, or null to clear it out + */ + public void setLogger(LayoutLog logger) { + mLogger = logger; + } + + /** + * Returns the {@link LayoutLog} logger used for error messages, or null + * + * @return the logger being used, or null if no logger is in use + */ + public LayoutLog getLogger() { + return mLogger; + } + + /** + * {@inheritDoc} + * + * This implementation goes through the output directory of the Eclipse project and loads the + * <code>.class</code> file directly. + */ + @Override + @SuppressWarnings("unchecked") + public Object loadView(String className, Class[] constructorSignature, + Object[] constructorParameters) + throws Exception { + mUsed = true; + + if (className == null) { + // Just make a plain <View> if you specify <view> without a class= attribute. + className = CLASS_VIEW; + } + + // look for a cached version + Class<?> clazz = mLoadedClasses.get(className); + if (clazz != null) { + return instantiateClass(clazz, constructorSignature, constructorParameters); + } + + // load the class. + + try { + if (mLoader == null) { + // Allow creating class loaders during rendering; may be prevented by the + // RenderSecurityManager + boolean token = RenderSecurityManager.enterSafeRegion(mCredential); + try { + mLoader = new ProjectClassLoader(mParentClassLoader, mProject); + } finally { + RenderSecurityManager.exitSafeRegion(token); + } + } + clazz = mLoader.loadClass(className); + } catch (Exception e) { + // Add the missing class to the list so that the renderer can print them later. + // no need to log this. + if (!className.equals(VIEW_FRAGMENT) && !className.equals(VIEW_INCLUDE)) { + mMissingClasses.add(className); + } + } + + try { + if (clazz != null) { + // first try to instantiate it because adding it the list of loaded class so that + // we don't add broken classes. + Object view = instantiateClass(clazz, constructorSignature, constructorParameters); + mLoadedClasses.put(className, clazz); + + return view; + } + } catch (Throwable e) { + // Find root cause to log it. + while (e.getCause() != null) { + e = e.getCause(); + } + + appendToIdeLog(e, "%1$s failed to instantiate.", className); //$NON-NLS-1$ + + // Add the missing class to the list so that the renderer can print them later. + if (mLogger instanceof RenderLogger) { + RenderLogger renderLogger = (RenderLogger) mLogger; + renderLogger.recordThrowable(e); + + } + mBrokenClasses.add(className); + } + + // Create a mock view instead. We don't cache it in the mLoadedClasses map. + // If any exception is thrown, we'll return a CFN with the original class name instead. + try { + clazz = mLoader.loadClass(SdkConstants.CLASS_MOCK_VIEW); + Object view = instantiateClass(clazz, constructorSignature, constructorParameters); + + // Set the text of the mock view to the simplified name of the custom class + Method m = view.getClass().getMethod("setText", + new Class<?>[] { CharSequence.class }); + String label = getShortClassName(className); + if (label.equals(VIEW_FRAGMENT)) { + label = "<fragment>\n" + + "Pick preview layout from the \"Fragment Layout\" context menu"; + } else if (label.equals(VIEW_INCLUDE)) { + label = "Text"; + } + + m.invoke(view, label); + + // Call MockView.setGravity(Gravity.CENTER) to get the text centered in + // MockViews. + // TODO: Do this in layoutlib's MockView class instead. + try { + // Look up android.view.Gravity#CENTER - or can we just hard-code + // the value (17) here? + Class<?> gravity = + Class.forName("android.view.Gravity", //$NON-NLS-1$ + true, view.getClass().getClassLoader()); + Field centerField = gravity.getField("CENTER"); //$NON-NLS-1$ + int center = centerField.getInt(null); + m = view.getClass().getMethod("setGravity", + new Class<?>[] { Integer.TYPE }); + // Center + //int center = (0x0001 << 4) | (0x0001 << 0); + m.invoke(view, Integer.valueOf(center)); + } catch (Exception e) { + // Not important to center views + } + + return view; + } catch (Exception e) { + // We failed to create and return a mock view. + // Just throw back a CNF with the original class name. + throw new ClassNotFoundException(className, e); + } + } + + private String getShortClassName(String fqcn) { + // The name is typically a fully-qualified class name. Let's make it a tad shorter. + + if (fqcn.startsWith("android.")) { //$NON-NLS-1$ + // For android classes, convert android.foo.Name to android...Name + int first = fqcn.indexOf('.'); + int last = fqcn.lastIndexOf('.'); + if (last > first) { + return fqcn.substring(0, first) + ".." + fqcn.substring(last); //$NON-NLS-1$ + } + } else { + // For custom non-android classes, it's best to keep the 2 first segments of + // the namespace, e.g. we want to get something like com.example...MyClass + int first = fqcn.indexOf('.'); + first = fqcn.indexOf('.', first + 1); + int last = fqcn.lastIndexOf('.'); + if (last > first) { + return fqcn.substring(0, first) + ".." + fqcn.substring(last); //$NON-NLS-1$ + } + } + + return fqcn; + } + + /** + * Returns the namespace for the project. The namespace contains a standard part + the + * application package. + * + * @return The package namespace of the project or null in case of error. + */ + @Override + public String getNamespace() { + if (mNamespace == null) { + boolean token = RenderSecurityManager.enterSafeRegion(mCredential); + try { + ManifestData manifestData = AndroidManifestHelper.parseForData(mProject); + if (manifestData != null) { + String javaPackage = manifestData.getPackage(); + mNamespace = String.format(AdtConstants.NS_CUSTOM_RESOURCES, javaPackage); + } + } finally { + RenderSecurityManager.exitSafeRegion(token); + } + } + + return mNamespace; + } + + @Override + public Pair<ResourceType, String> resolveResourceId(int id) { + if (mProjectRes != null) { + return mProjectRes.resolveResourceId(id); + } + + return null; + } + + @Override + public String resolveResourceId(int[] id) { + if (mProjectRes != null) { + return mProjectRes.resolveStyleable(id); + } + + return null; + } + + @Override + public Integer getResourceId(ResourceType type, String name) { + if (mProjectRes != null) { + return mProjectRes.getResourceId(type, name); + } + + return null; + } + + /** + * Returns whether the loader has received requests to load custom views. Note that + * the custom view loading may not actually have succeeded; this flag only records + * whether it was <b>requested</b>. + * <p/> + * This allows to efficiently only recreate when needed upon code change in the + * project. + * + * @return true if the loader has been asked to load custom views + */ + public boolean isUsed() { + return mUsed; + } + + /** + * Instantiate a class object, using a specific constructor and parameters. + * @param clazz the class to instantiate + * @param constructorSignature the signature of the constructor to use + * @param constructorParameters the parameters to use in the constructor. + * @return A new class object, created using a specific constructor and parameters. + * @throws Exception + */ + @SuppressWarnings("unchecked") + private Object instantiateClass(Class<?> clazz, + Class[] constructorSignature, + Object[] constructorParameters) throws Exception { + Constructor<?> constructor = null; + + try { + constructor = clazz.getConstructor(constructorSignature); + + } catch (NoSuchMethodException e) { + // Custom views can either implement a 3-parameter, 2-parameter or a + // 1-parameter. Let's synthetically build and try all the alternatives. + // That's kind of like switching to the other box. + // + // The 3-parameter constructor takes the following arguments: + // ...(Context context, AttributeSet attrs, int defStyle) + + int n = constructorSignature.length; + if (n == 0) { + // There is no parameter-less constructor. Nobody should ask for one. + throw e; + } + + for (int i = 3; i >= 1; i--) { + if (i == n) { + // Let's skip the one we know already fails + continue; + } + Class[] sig = new Class[i]; + Object[] params = new Object[i]; + + int k = i; + if (n < k) { + k = n; + } + System.arraycopy(constructorSignature, 0, sig, 0, k); + System.arraycopy(constructorParameters, 0, params, 0, k); + + for (k++; k <= i; k++) { + if (k == 2) { + // Parameter 2 is the AttributeSet + sig[k-1] = clazz.getClassLoader().loadClass("android.util.AttributeSet"); + params[k-1] = null; + + } else if (k == 3) { + // Parameter 3 is the int defstyle + sig[k-1] = int.class; + params[k-1] = 0; + } + } + + constructorSignature = sig; + constructorParameters = params; + + try { + // Try again... + constructor = clazz.getConstructor(constructorSignature); + if (constructor != null) { + // Found a suitable constructor, now let's use it. + // (But let's warn the user if the simple View constructor was found + // since Unexpected Things may happen if the attribute set constructors + // are not found) + if (constructorSignature.length < 2 && mLogger != null) { + mLogger.warning("wrongconstructor", //$NON-NLS-1$ + String.format("Custom view %1$s is not using the 2- or 3-argument " + + "View constructors; XML attributes will not work", + clazz.getSimpleName()), null /*data*/); + } + break; + } + } catch (NoSuchMethodException e1) { + // pass + } + } + + // If all the alternatives failed, throw the initial exception. + if (constructor == null) { + throw e; + } + } + + constructor.setAccessible(true); + return constructor.newInstance(constructorParameters); + } + + public void setLayoutParser(String layoutName, ILayoutPullParser layoutParser) { + mLayoutName = layoutName; + mLayoutEmbeddedParser = layoutParser; + } + + @Override + public ILayoutPullParser getParser(String layoutName) { + boolean token = RenderSecurityManager.enterSafeRegion(mCredential); + try { + // Try to compute the ResourceValue for this layout since layoutlib + // must be an older version which doesn't pass the value: + if (mResourceResolver != null) { + ResourceValue value = mResourceResolver.getProjectResource(ResourceType.LAYOUT, + layoutName); + if (value != null) { + return getParser(value); + } + } + + return getParser(layoutName, null); + } finally { + RenderSecurityManager.exitSafeRegion(token); + } + } + + @Override + public ILayoutPullParser getParser(ResourceValue layoutResource) { + boolean token = RenderSecurityManager.enterSafeRegion(mCredential); + try { + return getParser(layoutResource.getName(), + new File(layoutResource.getValue())); + } finally { + RenderSecurityManager.exitSafeRegion(token); + } + } + + private ILayoutPullParser getParser(String layoutName, File xml) { + if (layoutName.equals(mLayoutName)) { + ILayoutPullParser parser = mLayoutEmbeddedParser; + // The parser should only be used once!! If it is included more than once, + // subsequent includes should just use a plain pull parser that is not tied + // to the XML model + mLayoutEmbeddedParser = null; + return parser; + } + + // For included layouts, create a ContextPullParser such that we get the + // layout editor behavior in included layouts as well - which for example + // replaces <fragment> tags with <include>. + if (xml != null && xml.isFile()) { + ContextPullParser parser = new ContextPullParser(this, xml); + try { + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + String xmlText = Files.toString(xml, Charsets.UTF_8); + parser.setInput(new StringReader(xmlText)); + return parser; + } catch (XmlPullParserException e) { + appendToIdeLog(e, null); + } catch (FileNotFoundException e) { + // Shouldn't happen since we check isFile() above + } catch (IOException e) { + appendToIdeLog(e, null); + } + } + + return null; + } + + @Override + public Object getAdapterItemValue(ResourceReference adapterView, Object adapterCookie, + ResourceReference itemRef, + int fullPosition, int typePosition, int fullChildPosition, int typeChildPosition, + ResourceReference viewRef, ViewAttribute viewAttribute, Object defaultValue) { + + // Special case for the palette preview + if (viewAttribute == ViewAttribute.TEXT + && adapterView.getName().startsWith("android_widget_")) { //$NON-NLS-1$ + String name = adapterView.getName(); + if (viewRef.getName().equals("text2")) { //$NON-NLS-1$ + return "Sub Item"; + } + if (fullPosition == 0) { + String viewName = name.substring("android_widget_".length()); + if (viewName.equals(EXPANDABLE_LIST_VIEW)) { + return "ExpandableList"; // ExpandableListView is too wide, character-wraps + } + return viewName; + } else { + return "Next Item"; + } + } + + if (itemRef.isFramework()) { + // Special case for list_view_item_2 and friends + if (viewRef.getName().equals("text2")) { //$NON-NLS-1$ + return "Sub Item " + (fullPosition + 1); + } + } + + if (viewAttribute == ViewAttribute.TEXT && ((String) defaultValue).length() == 0) { + return "Item " + (fullPosition + 1); + } + + return null; + } + + /** + * For the given class, finds and returns the nearest super class which is a ListView + * or an ExpandableListView or a GridView (which uses a list adapter), or returns null. + * + * @param clz the class of the view object + * @return the fully qualified class name of the list ancestor, or null if there + * is no list view ancestor + */ + public static String getListAdapterViewFqcn(Class<?> clz) { + String fqcn = clz.getName(); + if (fqcn.endsWith(LIST_VIEW)) { // including EXPANDABLE_LIST_VIEW + return fqcn; + } else if (fqcn.equals(FQCN_GRID_VIEW)) { + return fqcn; + } else if (fqcn.equals(FQCN_SPINNER)) { + return fqcn; + } else if (fqcn.startsWith(ANDROID_PKG_PREFIX)) { + return null; + } + Class<?> superClass = clz.getSuperclass(); + if (superClass != null) { + return getListAdapterViewFqcn(superClass); + } else { + // Should not happen; we would have encountered android.view.View first, + // and it should have been covered by the ANDROID_PKG_PREFIX case above. + return null; + } + } + + /** + * Looks at the parent-chain of the view and if it finds a custom view, or a + * CalendarView, within the given distance then it returns true. A ListView within a + * CalendarView should not be assigned a custom list view type because it sets its own + * and then attempts to cast the layout to its own type which would fail if the normal + * default list item binding is used. + */ + private boolean isWithinIllegalParent(Object viewObject, int depth) { + String fqcn = viewObject.getClass().getName(); + if (fqcn.endsWith(CALENDAR_VIEW) || !fqcn.startsWith(ANDROID_PKG_PREFIX)) { + return true; + } + + if (depth > 0) { + Result result = mLayoutLib.getViewParent(viewObject); + if (result.isSuccess()) { + Object parent = result.getData(); + if (parent != null) { + return isWithinIllegalParent(parent, depth -1); + } + } + } + + return false; + } + + @Override + public AdapterBinding getAdapterBinding(final ResourceReference adapterView, + final Object adapterCookie, final Object viewObject) { + // Look for user-recorded preference for layout to be used for previews + if (adapterCookie instanceof UiViewElementNode) { + UiViewElementNode uiNode = (UiViewElementNode) adapterCookie; + AdapterBinding binding = LayoutMetadata.getNodeBinding(viewObject, uiNode); + if (binding != null) { + return binding; + } + } else if (adapterCookie instanceof Map<?,?>) { + @SuppressWarnings("unchecked") + Map<String, String> map = (Map<String, String>) adapterCookie; + AdapterBinding binding = LayoutMetadata.getNodeBinding(viewObject, map); + if (binding != null) { + return binding; + } + } + + if (viewObject == null) { + return null; + } + + // Is this a ListView or ExpandableListView? If so, return its fully qualified + // class name, otherwise return null. This is used to filter out other types + // of AdapterViews (such as Spinners) where we don't want to use the list item + // binding. + String listFqcn = getListAdapterViewFqcn(viewObject.getClass()); + if (listFqcn == null) { + return null; + } + + // Is this ListView nested within an "illegal" container, such as a CalendarView? + // If so, don't change the bindings below. Some views, such as CalendarView, and + // potentially some custom views, might be doing specific things with the ListView + // that could break if we add our own list binding, so for these leave the list + // alone. + if (isWithinIllegalParent(viewObject, 2)) { + return null; + } + + int count = listFqcn.endsWith(GRID_VIEW) ? 24 : 12; + AdapterBinding binding = new AdapterBinding(count); + if (listFqcn.endsWith(EXPANDABLE_LIST_VIEW)) { + binding.addItem(new DataBindingItem(LayoutMetadata.DEFAULT_EXPANDABLE_LIST_ITEM, + true /* isFramework */, 1)); + } else if (listFqcn.equals(SPINNER)) { + binding.addItem(new DataBindingItem(LayoutMetadata.DEFAULT_SPINNER_ITEM, + true /* isFramework */, 1)); + } else { + binding.addItem(new DataBindingItem(LayoutMetadata.DEFAULT_LIST_ITEM, + true /* isFramework */, 1)); + } + + return binding; + } + + /** + * Sets the {@link ResourceResolver} to be used when looking up resources + * + * @param resolver the resolver to use + */ + public void setResourceResolver(ResourceResolver resolver) { + mResourceResolver = resolver; + } + + // Append the given message to the ADT log. Bypass the sandbox if necessary + // such that we can write to the log file. + private void appendToIdeLog(Throwable exception, String format, Object ... args) { + boolean token = RenderSecurityManager.enterSafeRegion(mCredential); + try { + AdtPlugin.log(exception, format, args); + } finally { + RenderSecurityManager.exitSafeRegion(token); + } + } + + @Override + public ActionBarCallback getActionBarCallback() { + return new ActionBarHandler(mEditor); + } + + @Override + public boolean supports(int feature) { + return feature <= Features.LAST_CAPABILITY; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/UiElementPullParser.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/UiElementPullParser.java new file mode 100644 index 000000000..858156884 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/UiElementPullParser.java @@ -0,0 +1,661 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_PADDING; +import static com.android.SdkConstants.AUTO_URI; +import static com.android.SdkConstants.UNIT_DIP; +import static com.android.SdkConstants.UNIT_DP; +import static com.android.SdkConstants.UNIT_IN; +import static com.android.SdkConstants.UNIT_MM; +import static com.android.SdkConstants.UNIT_PT; +import static com.android.SdkConstants.UNIT_PX; +import static com.android.SdkConstants.UNIT_SP; +import static com.android.SdkConstants.VALUE_FILL_PARENT; +import static com.android.SdkConstants.VALUE_MATCH_PARENT; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; + +import com.android.ide.common.rendering.api.ILayoutPullParser; +import com.android.ide.common.rendering.api.ViewInfo; +import com.android.ide.common.res2.ValueXmlHelper; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.FragmentMenu; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.Density; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.core.resources.IProject; +import org.w3c.dom.Document; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.xmlpull.v1.XmlPullParserException; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * {@link ILayoutPullParser} implementation on top of {@link UiElementNode}. + * <p/> + * It's designed to work on layout files, and will most likely not work on other resource files. + * <p/> + * This pull parser generates {@link ViewInfo}s which key is a {@link UiElementNode}. + */ +public class UiElementPullParser extends BasePullParser { + private final static Pattern FLOAT_PATTERN = Pattern.compile("(-?[0-9]+(?:\\.[0-9]+)?)(.*)"); //$NON-NLS-1$ + + private final int[] sIntOut = new int[1]; + + private final ArrayList<UiElementNode> mNodeStack = new ArrayList<UiElementNode>(); + private UiElementNode mRoot; + private final boolean mExplodedRendering; + private boolean mZeroAttributeIsPadding = false; + private boolean mIncreaseExistingPadding = false; + private LayoutDescriptors mDescriptors; + private final Density mDensity; + + /** + * Number of pixels to pad views with in exploded-rendering mode. + */ + private static final String DEFAULT_PADDING_VALUE = + ExplodedRenderingHelper.PADDING_VALUE + UNIT_PX; + + /** + * Number of pixels to pad exploded individual views with. (This is HALF the width of the + * rectangle since padding is repeated on both sides of the empty content.) + */ + private static final String FIXED_PADDING_VALUE = "20px"; //$NON-NLS-1$ + + /** + * Set of nodes that we want to auto-pad using {@link #FIXED_PADDING_VALUE} as the padding + * attribute value. Can be null, which is the case when we don't want to perform any + * <b>individual</b> node exploding. + */ + private final Set<UiElementNode> mExplodeNodes; + + /** + * Constructs a new {@link UiElementPullParser}, a parser dedicated to the special case of + * parsing a layout resource files, and handling "exploded rendering" - adding padding on views + * to make them easier to see and operate on. + * + * @param top The {@link UiElementNode} for the root node. + * @param explodeRendering When true, add padding to <b>all</b> nodes in the hierarchy. This + * will add rather than replace padding of a node. + * @param explodeNodes A set of individual nodes that should be assigned a fixed amount of + * padding ({@link #FIXED_PADDING_VALUE}). This is intended for use with nodes that + * (without padding) would be invisible. This parameter can be null, in which case + * nodes are not individually exploded (but they may all be exploded with the + * explodeRendering parameter. + * @param density the density factor for the screen. + * @param project Project containing this layout. + */ + public UiElementPullParser(UiElementNode top, boolean explodeRendering, + Set<UiElementNode> explodeNodes, + Density density, IProject project) { + super(); + mRoot = top; + mExplodedRendering = explodeRendering; + mExplodeNodes = explodeNodes; + mDensity = density; + if (mExplodedRendering) { + // get the layout descriptor + IAndroidTarget target = Sdk.getCurrent().getTarget(project); + AndroidTargetData data = Sdk.getCurrent().getTargetData(target); + mDescriptors = data.getLayoutDescriptors(); + } + push(mRoot); + } + + protected UiElementNode getCurrentNode() { + if (mNodeStack.size() > 0) { + return mNodeStack.get(mNodeStack.size()-1); + } + + return null; + } + + private Node getAttribute(int i) { + if (mParsingState != START_TAG) { + throw new IndexOutOfBoundsException(); + } + + // get the current uiNode + UiElementNode uiNode = getCurrentNode(); + + // get its xml node + Node xmlNode = uiNode.getXmlNode(); + + if (xmlNode != null) { + return xmlNode.getAttributes().item(i); + } + + return null; + } + + private void push(UiElementNode node) { + mNodeStack.add(node); + + mZeroAttributeIsPadding = false; + mIncreaseExistingPadding = false; + + if (mExplodedRendering) { + // first get the node name + String xml = node.getDescriptor().getXmlLocalName(); + ViewElementDescriptor descriptor = mDescriptors.findDescriptorByTag(xml); + if (descriptor != null) { + NamedNodeMap attributes = node.getXmlNode().getAttributes(); + Node padding = attributes.getNamedItemNS(ANDROID_URI, ATTR_PADDING); + if (padding == null) { + // we'll return an extra padding + mZeroAttributeIsPadding = true; + } else { + mIncreaseExistingPadding = true; + } + } + } + } + + private UiElementNode pop() { + return mNodeStack.remove(mNodeStack.size()-1); + } + + // ------------- IXmlPullParser -------- + + /** + * {@inheritDoc} + * <p/> + * This implementation returns the underlying DOM node of type {@link UiElementNode}. + * Note that the link between the GLE and the parsing code depends on this being the actual + * type returned, so you can't just randomly change it here. + * <p/> + * Currently used by: + * - private method GraphicalLayoutEditor#updateNodeWithBounds(ILayoutViewInfo). + * - private constructor of LayoutCanvas.CanvasViewInfo. + */ + @Override + public Object getViewCookie() { + return getCurrentNode(); + } + + /** + * Legacy method required by {@link com.android.layoutlib.api.IXmlPullParser} + */ + @Override + public Object getViewKey() { + return getViewCookie(); + } + + /** + * This implementation does nothing for now as all the embedded XML will use a normal KXML + * parser. + */ + @Override + public ILayoutPullParser getParser(String layoutName) { + return null; + } + + // ------------- XmlPullParser -------- + + @Override + public String getPositionDescription() { + return "XML DOM element depth:" + mNodeStack.size(); + } + + /* + * This does not seem to be called by the layoutlib, but we keep this (and maintain + * it) just in case. + */ + @Override + public int getAttributeCount() { + UiElementNode node = getCurrentNode(); + + if (node != null) { + Collection<UiAttributeNode> attributes = node.getAllUiAttributes(); + int count = attributes.size(); + + return count + (mZeroAttributeIsPadding ? 1 : 0); + } + + return 0; + } + + /* + * This does not seem to be called by the layoutlib, but we keep this (and maintain + * it) just in case. + */ + @Override + public String getAttributeName(int i) { + if (mZeroAttributeIsPadding) { + if (i == 0) { + return ATTR_PADDING; + } else { + i--; + } + } + + Node attribute = getAttribute(i); + if (attribute != null) { + return attribute.getLocalName(); + } + + return null; + } + + /* + * This does not seem to be called by the layoutlib, but we keep this (and maintain + * it) just in case. + */ + @Override + public String getAttributeNamespace(int i) { + if (mZeroAttributeIsPadding) { + if (i == 0) { + return ANDROID_URI; + } else { + i--; + } + } + + Node attribute = getAttribute(i); + if (attribute != null) { + return attribute.getNamespaceURI(); + } + return ""; //$NON-NLS-1$ + } + + /* + * This does not seem to be called by the layoutlib, but we keep this (and maintain + * it) just in case. + */ + @Override + public String getAttributePrefix(int i) { + if (mZeroAttributeIsPadding) { + if (i == 0) { + // figure out the prefix associated with the android namespace. + Document doc = mRoot.getXmlDocument(); + return doc.lookupPrefix(ANDROID_URI); + } else { + i--; + } + } + + Node attribute = getAttribute(i); + if (attribute != null) { + return attribute.getPrefix(); + } + return null; + } + + /* + * This does not seem to be called by the layoutlib, but we keep this (and maintain + * it) just in case. + */ + @Override + public String getAttributeValue(int i) { + if (mZeroAttributeIsPadding) { + if (i == 0) { + return DEFAULT_PADDING_VALUE; + } else { + i--; + } + } + + Node attribute = getAttribute(i); + if (attribute != null) { + String value = attribute.getNodeValue(); + if (mIncreaseExistingPadding && ATTR_PADDING.equals(attribute.getLocalName()) && + ANDROID_URI.equals(attribute.getNamespaceURI())) { + // add the padding and return the value + return addPaddingToValue(value); + } + return value; + } + + return null; + } + + /* + * This is the main method used by the LayoutInflater to query for attributes. + */ + @Override + public String getAttributeValue(String namespace, String localName) { + if (mExplodeNodes != null && ATTR_PADDING.equals(localName) && + ANDROID_URI.equals(namespace)) { + UiElementNode node = getCurrentNode(); + if (node != null && mExplodeNodes.contains(node)) { + return FIXED_PADDING_VALUE; + } + } + + if (mZeroAttributeIsPadding && ATTR_PADDING.equals(localName) && + ANDROID_URI.equals(namespace)) { + return DEFAULT_PADDING_VALUE; + } + + // get the current uiNode + UiElementNode uiNode = getCurrentNode(); + + // get its xml node + Node xmlNode = uiNode.getXmlNode(); + + if (xmlNode != null) { + if (ATTR_LAYOUT.equals(localName) && VIEW_FRAGMENT.equals(xmlNode.getNodeName())) { + String layout = FragmentMenu.getFragmentLayout(xmlNode); + if (layout != null) { + return layout; + } + } + + Node attribute = xmlNode.getAttributes().getNamedItemNS(namespace, localName); + + // Auto-convert http://schemas.android.com/apk/res-auto resources. The lookup + // will be for the current application's resource package, e.g. + // http://schemas.android.com/apk/res/foo.bar, but the XML document will + // be using http://schemas.android.com/apk/res-auto in library projects: + if (attribute == null && namespace != null && !namespace.equals(ANDROID_URI)) { + attribute = xmlNode.getAttributes().getNamedItemNS(AUTO_URI, localName); + } + + if (attribute != null) { + String value = attribute.getNodeValue(); + if (mIncreaseExistingPadding && ATTR_PADDING.equals(localName) && + ANDROID_URI.equals(namespace)) { + // add the padding and return the value + return addPaddingToValue(value); + } + + // on the fly convert match_parent to fill_parent for compatibility with older + // platforms. + if (VALUE_MATCH_PARENT.equals(value) && + (ATTR_LAYOUT_WIDTH.equals(localName) || + ATTR_LAYOUT_HEIGHT.equals(localName)) && + ANDROID_URI.equals(namespace)) { + return VALUE_FILL_PARENT; + } + + // Handle unicode escapes etc + value = ValueXmlHelper.unescapeResourceString(value, false, false); + + return value; + } + } + + return null; + } + + @Override + public int getDepth() { + return mNodeStack.size(); + } + + @Override + public String getName() { + if (mParsingState == START_TAG || mParsingState == END_TAG) { + String name = getCurrentNode().getDescriptor().getXmlLocalName(); + + if (name.equals(VIEW_FRAGMENT)) { + // Temporarily translate <fragment> to <include> (and in getAttribute + // we will also provide a layout-attribute for the corresponding + // fragment name attribute) + String layout = FragmentMenu.getFragmentLayout(getCurrentNode().getXmlNode()); + if (layout != null) { + return VIEW_INCLUDE; + } + } + + return name; + } + + return null; + } + + @Override + public String getNamespace() { + if (mParsingState == START_TAG || mParsingState == END_TAG) { + return getCurrentNode().getDescriptor().getNamespace(); + } + + return null; + } + + @Override + public String getPrefix() { + if (mParsingState == START_TAG || mParsingState == END_TAG) { + Document doc = mRoot.getXmlDocument(); + return doc.lookupPrefix(getCurrentNode().getDescriptor().getNamespace()); + } + + return null; + } + + @Override + public boolean isEmptyElementTag() throws XmlPullParserException { + if (mParsingState == START_TAG) { + return getCurrentNode().getUiChildren().size() == 0; + } + + throw new XmlPullParserException("Call to isEmptyElementTag while not in START_TAG", + this, null); + } + + @Override + public void onNextFromStartDocument() { + onNextFromStartTag(); + } + + @Override + public void onNextFromStartTag() { + // get the current node, and look for text or children (children first) + UiElementNode node = getCurrentNode(); + List<UiElementNode> children = node.getUiChildren(); + if (children.size() > 0) { + // move to the new child, and don't change the state. + push(children.get(0)); + + // in case the current state is CURRENT_DOC, we set the proper state. + mParsingState = START_TAG; + } else { + if (mParsingState == START_DOCUMENT) { + // this handles the case where there's no node. + mParsingState = END_DOCUMENT; + } else { + mParsingState = END_TAG; + } + } + } + + @Override + public void onNextFromEndTag() { + // look for a sibling. if no sibling, go back to the parent + UiElementNode node = getCurrentNode(); + node = node.getUiNextSibling(); + if (node != null) { + // to go to the sibling, we need to remove the current node, + pop(); + // and add its sibling. + push(node); + mParsingState = START_TAG; + } else { + // move back to the parent + pop(); + + // we have only one element left (mRoot), then we're done with the document. + if (mNodeStack.size() == 1) { + mParsingState = END_DOCUMENT; + } else { + mParsingState = END_TAG; + } + } + } + + // ------- TypedValue stuff + // This is adapted from com.android.layoutlib.bridge.ResourceHelper + // (but modified to directly take the parsed value and convert it into pixel instead of + // storing it into a TypedValue) + // this was originally taken from platform/frameworks/base/libs/utils/ResourceTypes.cpp + + private static final class DimensionEntry { + String name; + int type; + + DimensionEntry(String name, int unit) { + this.name = name; + this.type = unit; + } + } + + /** {@link DimensionEntry} complex unit: Value is raw pixels. */ + private static final int COMPLEX_UNIT_PX = 0; + /** {@link DimensionEntry} complex unit: Value is Device Independent + * Pixels. */ + private static final int COMPLEX_UNIT_DIP = 1; + /** {@link DimensionEntry} complex unit: Value is a scaled pixel. */ + private static final int COMPLEX_UNIT_SP = 2; + /** {@link DimensionEntry} complex unit: Value is in points. */ + private static final int COMPLEX_UNIT_PT = 3; + /** {@link DimensionEntry} complex unit: Value is in inches. */ + private static final int COMPLEX_UNIT_IN = 4; + /** {@link DimensionEntry} complex unit: Value is in millimeters. */ + private static final int COMPLEX_UNIT_MM = 5; + + private final static DimensionEntry[] sDimensions = new DimensionEntry[] { + new DimensionEntry(UNIT_PX, COMPLEX_UNIT_PX), + new DimensionEntry(UNIT_DIP, COMPLEX_UNIT_DIP), + new DimensionEntry(UNIT_DP, COMPLEX_UNIT_DIP), + new DimensionEntry(UNIT_SP, COMPLEX_UNIT_SP), + new DimensionEntry(UNIT_PT, COMPLEX_UNIT_PT), + new DimensionEntry(UNIT_IN, COMPLEX_UNIT_IN), + new DimensionEntry(UNIT_MM, COMPLEX_UNIT_MM), + }; + + /** + * Adds padding to an existing dimension. + * <p/>This will resolve the attribute value (which can be px, dip, dp, sp, pt, in, mm) to + * a pixel value, add the padding value ({@link ExplodedRenderingHelper#PADDING_VALUE}), + * and then return a string with the new value as a px string ("42px"); + * If the conversion fails, only the special padding is returned. + */ + private String addPaddingToValue(String s) { + int padding = ExplodedRenderingHelper.PADDING_VALUE; + if (stringToPixel(s)) { + padding += sIntOut[0]; + } + + return padding + UNIT_PX; + } + + /** + * Convert the string into a pixel value, and puts it in {@link #sIntOut} + * @param s the dimension value from an XML attribute + * @return true if success. + */ + private boolean stringToPixel(String s) { + // remove the space before and after + s = s.trim(); + int len = s.length(); + + if (len <= 0) { + return false; + } + + // check that there's no non ASCII characters. + char[] buf = s.toCharArray(); + for (int i = 0 ; i < len ; i++) { + if (buf[i] > 255) { + return false; + } + } + + // check the first character + if (buf[0] < '0' && buf[0] > '9' && buf[0] != '.') { + return false; + } + + // now look for the string that is after the float... + Matcher m = FLOAT_PATTERN.matcher(s); + if (m.matches()) { + String f_str = m.group(1); + String end = m.group(2); + + float f; + try { + f = Float.parseFloat(f_str); + } catch (NumberFormatException e) { + // this shouldn't happen with the regexp above. + return false; + } + + if (end.length() > 0 && end.charAt(0) != ' ') { + // We only support dimension-type values, so try to parse the unit for dimension + DimensionEntry dimension = parseDimension(end); + if (dimension != null) { + // convert the value into pixel based on the dimention type + // This is similar to TypedValue.applyDimension() + switch (dimension.type) { + case COMPLEX_UNIT_PX: + // do nothing, value is already in px + break; + case COMPLEX_UNIT_DIP: + case COMPLEX_UNIT_SP: // intended fall-through since we don't + // adjust for font size + f *= (float)mDensity.getDpiValue() / Density.DEFAULT_DENSITY; + break; + case COMPLEX_UNIT_PT: + f *= mDensity.getDpiValue() * (1.0f / 72); + break; + case COMPLEX_UNIT_IN: + f *= mDensity.getDpiValue(); + break; + case COMPLEX_UNIT_MM: + f *= mDensity.getDpiValue() * (1.0f / 25.4f); + break; + } + + // store result (converted to int) + sIntOut[0] = (int) (f + 0.5); + + return true; + } + } + } + + return false; + } + + private static DimensionEntry parseDimension(String str) { + str = str.trim(); + + for (DimensionEntry d : sDimensions) { + if (d.name.equals(str)) { + return d; + } + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/WidgetPullParser.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/WidgetPullParser.java new file mode 100644 index 000000000..dce2ccbf1 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/WidgetPullParser.java @@ -0,0 +1,174 @@ +/* + * 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.eclipse.adt.internal.editors.layout; + +import com.android.SdkConstants; +import com.android.ide.common.rendering.api.ILayoutPullParser; +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.layoutlib.api.ILayoutResult.ILayoutViewInfo; + +import org.xmlpull.v1.XmlPullParserException; + +/** + * {@link ILayoutPullParser} implementation to render android widget bitmap. + * <p/> + * The parser emulates a layout that contains just one widget, described by the + * {@link ViewElementDescriptor} passed in the constructor. + * <p/> + * This pull parser generates {@link ILayoutViewInfo}s which key is a {@link ViewElementDescriptor}. + */ +public class WidgetPullParser extends BasePullParser { + + private final ViewElementDescriptor mDescriptor; + private String[][] mAttributes = new String[][] { + { "text", null }, + { "layout_width", "wrap_content" }, + { "layout_height", "wrap_content" }, + }; + + public WidgetPullParser(ViewElementDescriptor descriptor) { + mDescriptor = descriptor; + + String[] segments = mDescriptor.getFullClassName().split(AdtConstants.RE_DOT); + mAttributes[0][1] = segments[segments.length-1]; + } + + @Override + public Object getViewCookie() { + // we need a viewKey or the ILayoutResult will not contain any ILayoutViewInfo + return mDescriptor; + } + + /** + * Legacy method required by {@link com.android.layoutlib.api.IXmlPullParser} + */ + @Override + public Object getViewKey() { + return getViewCookie(); + } + + @Override + public ILayoutPullParser getParser(String layoutName) { + // there's no embedded layout for a single widget. + return null; + } + + @Override + public int getAttributeCount() { + return mAttributes.length; // text attribute + } + + @Override + public String getAttributeName(int index) { + if (index < mAttributes.length) { + return mAttributes[index][0]; + } + + return null; + } + + @Override + public String getAttributeNamespace(int index) { + return SdkConstants.NS_RESOURCES; + } + + @Override + public String getAttributePrefix(int index) { + // pass + return null; + } + + @Override + public String getAttributeValue(int index) { + if (index < mAttributes.length) { + return mAttributes[index][1]; + } + + return null; + } + + @Override + public String getAttributeValue(String ns, String name) { + if (SdkConstants.NS_RESOURCES.equals(ns)) { + for (String[] attribute : mAttributes) { + if (name.equals(attribute[0])) { + return attribute[1]; + } + } + } + + return null; + } + + @Override + public int getDepth() { + // pass + return 0; + } + + @Override + public String getName() { + return mDescriptor.getXmlLocalName(); + } + + @Override + public String getNamespace() { + // pass + return null; + } + + @Override + public String getPositionDescription() { + // pass + return null; + } + + @Override + public String getPrefix() { + // pass + return null; + } + + @Override + public boolean isEmptyElementTag() throws XmlPullParserException { + if (mParsingState == START_TAG) { + return true; + } + + throw new XmlPullParserException("Call to isEmptyElementTag while not in START_TAG", + this, null); + } + + @Override + public void onNextFromStartDocument() { + // just go to start_tag + mParsingState = START_TAG; + } + + @Override + public void onNextFromStartTag() { + // since we have no children, just go to end_tag + mParsingState = END_TAG; + } + + @Override + public void onNextFromEndTag() { + // just one tag. we are done. + mParsingState = END_DOCUMENT; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ActivityMenuListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ActivityMenuListener.java new file mode 100644 index 000000000..36cd0fbbb --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ActivityMenuListener.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; + +import org.eclipse.core.resources.IProject; +import org.eclipse.jdt.ui.ISharedImages; +import org.eclipse.jdt.ui.JavaUI; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.ToolItem; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The {@linkplain ActivityMenuListener} class is responsible for + * generating the activity menu in the {@link ConfigurationChooser}. + */ +class ActivityMenuListener extends SelectionAdapter { + private static final int ACTION_OPEN_ACTIVITY = 1; + private static final int ACTION_SELECT_ACTIVITY = 2; + + private final ConfigurationChooser mConfigChooser; + private final int mAction; + private final String mFqcn; + + ActivityMenuListener( + @NonNull ConfigurationChooser configChooser, + int action, + @Nullable String fqcn) { + mConfigChooser = configChooser; + mAction = action; + mFqcn = fqcn; + } + + @Override + public void widgetSelected(SelectionEvent e) { + switch (mAction) { + case ACTION_OPEN_ACTIVITY: { + Configuration configuration = mConfigChooser.getConfiguration(); + String fqcn = configuration.getActivity(); + AdtPlugin.openJavaClass(mConfigChooser.getProject(), fqcn); + break; + } + case ACTION_SELECT_ACTIVITY: { + mConfigChooser.selectActivity(mFqcn); + mConfigChooser.onSelectActivity(); + break; + } + default: assert false : mAction; + } + } + + static void show(ConfigurationChooser chooser, ToolItem combo) { + // TODO: Allow using fragments here as well? + Menu menu = new Menu(chooser.getShell(), SWT.POP_UP); + ISharedImages sharedImages = JavaUI.getSharedImages(); + Configuration configuration = chooser.getConfiguration(); + String current = configuration.getActivity(); + + if (current != null) { + MenuItem item = new MenuItem(menu, SWT.PUSH); + String label = ConfigurationChooser.getActivityLabel(current, true); + item.setText( String.format("Open %1$s...", label)); + Image image = sharedImages.getImage(ISharedImages.IMG_OBJS_CUNIT); + item.setImage(image); + item.addSelectionListener( + new ActivityMenuListener(chooser, ACTION_OPEN_ACTIVITY, null)); + + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + } + + IProject project = chooser.getProject(); + Image image = sharedImages.getImage(ISharedImages.IMG_OBJS_CLASS); + + // Add activities found to be relevant to this layout + String layoutName = ResourceHelper.getLayoutName(chooser.getEditedFile()); + String pkg = ManifestInfo.get(project).getPackage(); + List<String> preferred = ManifestInfo.guessActivities(project, layoutName, pkg); + current = addActivities(chooser, menu, current, image, preferred); + + // Add all activities + List<String> activities = ManifestInfo.getProjectActivities(project); + if (preferred.size() > 0) { + // Filter out the activities we've already listed above + List<String> filtered = new ArrayList<String>(activities.size()); + Set<String> remove = new HashSet<String>(preferred); + for (String fqcn : activities) { + if (!remove.contains(fqcn)) { + filtered.add(fqcn); + } + } + activities = filtered; + } + + if (activities.size() > 0) { + if (preferred.size() > 0) { + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + } + + addActivities(chooser, menu, current, image, activities); + } + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } + + private static String addActivities(ConfigurationChooser chooser, Menu menu, String current, + Image image, List<String> activities) { + for (final String fqcn : activities) { + String title = ConfigurationChooser.getActivityLabel(fqcn, false); + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(title); + item.setImage(image); + + boolean selected = title.equals(current); + if (selected) { + item.setSelection(true); + current = null; // Only show the first occurrence as selected + // such that we don't show it selected again in the full activity list + } + + item.addSelectionListener(new ActivityMenuListener(chooser, + ACTION_SELECT_ACTIVITY, fqcn)); + } + + return current; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/Configuration.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/Configuration.java new file mode 100644 index 000000000..c4253cddf --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/Configuration.java @@ -0,0 +1,1091 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.LayoutLibrary; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.resources.ResourceFolder; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.configuration.DensityQualifier; +import com.android.ide.common.resources.configuration.DeviceConfigHelper; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LayoutDirectionQualifier; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.resources.configuration.NightModeQualifier; +import com.android.ide.common.resources.configuration.ScreenSizeQualifier; +import com.android.ide.common.resources.configuration.UiModeQualifier; +import com.android.ide.common.resources.configuration.VersionQualifier; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderService; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo.ActivityAttributes; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.Density; +import com.android.resources.LayoutDirection; +import com.android.resources.NightMode; +import com.android.resources.ScreenSize; +import com.android.resources.UiMode; +import com.android.sdklib.AndroidVersion; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.State; +import com.android.utils.Pair; +import com.google.common.base.Objects; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.QualifiedName; + +import java.util.List; + +/** + * A {@linkplain Configuration} is a selection of device, orientation, theme, + * etc for use when rendering a layout. + */ +public class Configuration { + /** The {@link FolderConfiguration} in change flags or override flags */ + public static final int CFG_FOLDER = 1 << 0; + /** The {@link Device} in change flags or override flags */ + public static final int CFG_DEVICE = 1 << 1; + /** The {@link State} in change flags or override flags */ + public static final int CFG_DEVICE_STATE = 1 << 2; + /** The theme in change flags or override flags */ + public static final int CFG_THEME = 1 << 3; + /** The locale in change flags or override flags */ + public static final int CFG_LOCALE = 1 << 4; + /** The rendering {@link IAndroidTarget} in change flags or override flags */ + public static final int CFG_TARGET = 1 << 5; + /** The {@link NightMode} in change flags or override flags */ + public static final int CFG_NIGHT_MODE = 1 << 6; + /** The {@link UiMode} in change flags or override flags */ + public static final int CFG_UI_MODE = 1 << 7; + /** The {@link UiMode} in change flags or override flags */ + public static final int CFG_ACTIVITY = 1 << 8; + + /** References all attributes */ + public static final int MASK_ALL = 0xFFFF; + + /** Attributes which affect which best-layout-file selection */ + public static final int MASK_FILE_ATTRS = + CFG_DEVICE|CFG_DEVICE_STATE|CFG_LOCALE|CFG_TARGET|CFG_NIGHT_MODE|CFG_UI_MODE; + + /** Attributes which affect rendering appearance */ + public static final int MASK_RENDERING = MASK_FILE_ATTRS|CFG_THEME; + + /** + * Setting name for project-wide setting controlling rendering target and locale which + * is shared for all files + */ + public final static QualifiedName NAME_RENDER_STATE = + new QualifiedName(AdtPlugin.PLUGIN_ID, "render"); //$NON-NLS-1$ + + private final static String MARKER_FRAMEWORK = "-"; //$NON-NLS-1$ + private final static String MARKER_PROJECT = "+"; //$NON-NLS-1$ + private final static String SEP = ":"; //$NON-NLS-1$ + private final static String SEP_LOCALE = "-"; //$NON-NLS-1$ + + @NonNull + protected ConfigurationChooser mConfigChooser; + + /** The {@link FolderConfiguration} representing the state of the UI controls */ + @NonNull + protected final FolderConfiguration mFullConfig = new FolderConfiguration(); + + /** The {@link FolderConfiguration} being edited. */ + @Nullable + protected FolderConfiguration mEditedConfig; + + /** The target of the project of the file being edited. */ + @Nullable + private IAndroidTarget mTarget; + + /** The theme style to render with */ + @Nullable + private String mTheme; + + /** The device to render with */ + @Nullable + private Device mDevice; + + /** The device state */ + @Nullable + private State mState; + + /** + * The activity associated with the layout. This is just a cached value of + * the true value stored on the layout. + */ + @Nullable + private String mActivity; + + /** The locale to use for this configuration */ + @NonNull + private Locale mLocale = Locale.ANY; + + /** UI mode */ + @NonNull + private UiMode mUiMode = UiMode.NORMAL; + + /** Night mode */ + @NonNull + private NightMode mNightMode = NightMode.NOTNIGHT; + + /** The display name */ + private String mDisplayName; + + /** + * Creates a new {@linkplain Configuration} + * + * @param chooser the associated chooser + */ + protected Configuration(@NonNull ConfigurationChooser chooser) { + mConfigChooser = chooser; + } + + /** + * Sets the associated configuration chooser + * + * @param chooser the chooser + */ + void setChooser(@NonNull ConfigurationChooser chooser) { + // TODO: We should get rid of the binding between configurations + // and configuration choosers. This is currently needed because + // the choosers contain vital data such as the set of available + // rendering targets, the set of available locales etc, which + // also doesn't belong inside the configuration but is needed by it. + mConfigChooser = chooser; + } + + /** + * Gets the associated configuration chooser + * + * @return the chooser + */ + @NonNull + ConfigurationChooser getChooser() { + return mConfigChooser; + } + + /** + * Creates a new {@linkplain Configuration} + * + * @param chooser the associated chooser + * @return a new configuration + */ + @NonNull + public static Configuration create(@NonNull ConfigurationChooser chooser) { + return new Configuration(chooser); + } + + /** + * Creates a configuration suitable for the given file + * + * @param base the base configuration to base the file configuration off of + * @param file the file to look up a configuration for + * @return a suitable configuration + */ + @NonNull + public static Configuration create( + @NonNull Configuration base, + @NonNull IFile file) { + Configuration configuration = copy(base); + ConfigurationChooser chooser = base.getChooser(); + ProjectResources resources = chooser.getResources(); + ConfigurationMatcher matcher = new ConfigurationMatcher(chooser, configuration, file, + resources, false); + + ResourceFolder resFolder = ResourceManager.getInstance().getResourceFolder(file); + configuration.mEditedConfig = new FolderConfiguration(); + configuration.mEditedConfig.set(resFolder.getConfiguration()); + + matcher.adaptConfigSelection(true /*needBestMatch*/); + configuration.syncFolderConfig(); + + return configuration; + } + + /** + * Creates a new {@linkplain Configuration} that is a copy from a different configuration + * + * @param original the original to copy from + * @return a new configuration copied from the original + */ + @NonNull + public static Configuration copy(@NonNull Configuration original) { + Configuration copy = create(original.mConfigChooser); + copy.mFullConfig.set(original.mFullConfig); + if (original.mEditedConfig != null) { + copy.mEditedConfig = new FolderConfiguration(); + copy.mEditedConfig.set(original.mEditedConfig); + } + copy.mTarget = original.getTarget(); + copy.mTheme = original.getTheme(); + copy.mDevice = original.getDevice(); + copy.mState = original.getDeviceState(); + copy.mActivity = original.getActivity(); + copy.mLocale = original.getLocale(); + copy.mUiMode = original.getUiMode(); + copy.mNightMode = original.getNightMode(); + copy.mDisplayName = original.getDisplayName(); + + return copy; + } + + /** + * Returns the associated activity + * + * @return the activity + */ + @Nullable + public String getActivity() { + return mActivity; + } + + /** + * Returns the chosen device. + * + * @return the chosen device + */ + @Nullable + public Device getDevice() { + return mDevice; + } + + /** + * Returns the chosen device state + * + * @return the device state + */ + @Nullable + public State getDeviceState() { + return mState; + } + + /** + * Returns the chosen locale + * + * @return the locale + */ + @NonNull + public Locale getLocale() { + return mLocale; + } + + /** + * Returns the UI mode + * + * @return the UI mode + */ + @NonNull + public UiMode getUiMode() { + return mUiMode; + } + + /** + * Returns the day/night mode + * + * @return the night mode + */ + @NonNull + public NightMode getNightMode() { + return mNightMode; + } + + /** + * Returns the current theme style + * + * @return the theme style + */ + @Nullable + public String getTheme() { + return mTheme; + } + + /** + * Returns the rendering target + * + * @return the target + */ + @Nullable + public IAndroidTarget getTarget() { + return mTarget; + } + + /** + * Returns the display name to show for this configuration + * + * @return the display name, or null if none has been assigned + */ + @Nullable + public String getDisplayName() { + return mDisplayName; + } + + /** + * Returns whether the configuration's theme is a project theme. + * <p/> + * The returned value is meaningless if {@link #getTheme()} returns + * <code>null</code>. + * + * @return true for project a theme, false for a framework theme + */ + public boolean isProjectTheme() { + String theme = getTheme(); + if (theme != null) { + assert theme.startsWith(STYLE_RESOURCE_PREFIX) + || theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX); + + return ResourceHelper.isProjectStyle(theme); + } + + return false; + } + + /** + * Returns true if the current layout is locale-specific + * + * @return if this configuration represents a locale-specific layout + */ + public boolean isLocaleSpecificLayout() { + return mEditedConfig == null || mEditedConfig.getLocaleQualifier() != null; + } + + /** + * Returns the full, complete {@link FolderConfiguration} + * + * @return the full configuration + */ + @NonNull + public FolderConfiguration getFullConfig() { + return mFullConfig; + } + + /** + * Copies the full, complete {@link FolderConfiguration} into the given + * folder config instance. + * + * @param dest the {@link FolderConfiguration} instance to copy into + */ + public void copyFullConfig(FolderConfiguration dest) { + dest.set(mFullConfig); + } + + /** + * Returns the edited {@link FolderConfiguration} (this is not a full + * configuration, so you can think of it as the "constraints" used by the + * {@link ConfigurationMatcher} to produce a full configuration. + * + * @return the constraints configuration + */ + @NonNull + public FolderConfiguration getEditedConfig() { + return mEditedConfig; + } + + /** + * Sets the edited {@link FolderConfiguration} (this is not a full + * configuration, so you can think of it as the "constraints" used by the + * {@link ConfigurationMatcher} to produce a full configuration. + * + * @param editedConfig the constraints configuration + */ + public void setEditedConfig(@NonNull FolderConfiguration editedConfig) { + mEditedConfig = editedConfig; + } + + /** + * Sets the associated activity + * + * @param activity the activity + */ + public void setActivity(String activity) { + mActivity = activity; + } + + /** + * Sets the device + * + * @param device the device + * @param skipSync if true, don't sync folder configuration (typically because + * you are going to set other configuration parameters and you'll call + * {@link #syncFolderConfig()} once at the end) + */ + public void setDevice(Device device, boolean skipSync) { + mDevice = device; + + if (!skipSync) { + syncFolderConfig(); + } + } + + /** + * Sets the device state + * + * @param state the device state + * @param skipSync if true, don't sync folder configuration (typically because + * you are going to set other configuration parameters and you'll call + * {@link #syncFolderConfig()} once at the end) + */ + public void setDeviceState(State state, boolean skipSync) { + mState = state; + + if (!skipSync) { + syncFolderConfig(); + } + } + + /** + * Sets the locale + * + * @param locale the locale + * @param skipSync if true, don't sync folder configuration (typically because + * you are going to set other configuration parameters and you'll call + * {@link #syncFolderConfig()} once at the end) + */ + public void setLocale(@NonNull Locale locale, boolean skipSync) { + mLocale = locale; + + if (!skipSync) { + syncFolderConfig(); + } + } + + /** + * Sets the rendering target + * + * @param target rendering target + * @param skipSync if true, don't sync folder configuration (typically because + * you are going to set other configuration parameters and you'll call + * {@link #syncFolderConfig()} once at the end) + */ + public void setTarget(IAndroidTarget target, boolean skipSync) { + mTarget = target; + + if (!skipSync) { + syncFolderConfig(); + } + } + + /** + * Sets the display name to be shown for this configuration. + * + * @param displayName the new display name + */ + public void setDisplayName(@Nullable String displayName) { + mDisplayName = displayName; + } + + /** + * Sets the night mode + * + * @param night the night mode + * @param skipSync if true, don't sync folder configuration (typically because + * you are going to set other configuration parameters and you'll call + * {@link #syncFolderConfig()} once at the end) + */ + public void setNightMode(@NonNull NightMode night, boolean skipSync) { + mNightMode = night; + + if (!skipSync) { + syncFolderConfig(); + } + } + + /** + * Sets the UI mode + * + * @param uiMode the UI mode + * @param skipSync if true, don't sync folder configuration (typically because + * you are going to set other configuration parameters and you'll call + * {@link #syncFolderConfig()} once at the end) + */ + public void setUiMode(@NonNull UiMode uiMode, boolean skipSync) { + mUiMode = uiMode; + + if (!skipSync) { + syncFolderConfig(); + } + } + + /** + * Sets the theme style + * + * @param theme the theme + */ + public void setTheme(String theme) { + mTheme = theme; + checkThemePrefix(); + } + + /** + * Updates the folder configuration such that it reflects changes in + * configuration state such as the device orientation, the UI mode, the + * rendering target, etc. + */ + public void syncFolderConfig() { + Device device = getDevice(); + if (device == null) { + return; + } + + // get the device config from the device/state combos. + FolderConfiguration config = DeviceConfigHelper.getFolderConfig(getDeviceState()); + + // replace the config with the one from the device + mFullConfig.set(config); + + // sync the selected locale + Locale locale = getLocale(); + mFullConfig.setLocaleQualifier(locale.qualifier); + if (!locale.hasLanguage()) { + // Avoid getting the layout library if the locale doesn't have any language. + mFullConfig.setLayoutDirectionQualifier( + new LayoutDirectionQualifier(LayoutDirection.LTR)); + } else { + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + AndroidTargetData targetData = currentSdk.getTargetData(getTarget()); + if (targetData != null) { + LayoutLibrary layoutLib = targetData.getLayoutLibrary(); + if (layoutLib != null) { + if (layoutLib.isRtl(locale.toLocaleId())) { + mFullConfig.setLayoutDirectionQualifier( + new LayoutDirectionQualifier(LayoutDirection.RTL)); + } else { + mFullConfig.setLayoutDirectionQualifier( + new LayoutDirectionQualifier(LayoutDirection.LTR)); + } + } + } + } + } + + // Replace the UiMode with the selected one, if one is selected + UiMode uiMode = getUiMode(); + if (uiMode != null) { + mFullConfig.setUiModeQualifier(new UiModeQualifier(uiMode)); + } + + // Replace the NightMode with the selected one, if one is selected + NightMode nightMode = getNightMode(); + if (nightMode != null) { + mFullConfig.setNightModeQualifier(new NightModeQualifier(nightMode)); + } + + // replace the API level by the selection of the combo + IAndroidTarget target = getTarget(); + if (target == null && mConfigChooser != null) { + target = mConfigChooser.getProjectTarget(); + } + if (target != null) { + int apiLevel = target.getVersion().getApiLevel(); + mFullConfig.setVersionQualifier(new VersionQualifier(apiLevel)); + } + } + + /** + * Creates a string suitable for persistence, which can be initialized back + * to a configuration via {@link #initialize(String)} + * + * @return a persistent string + */ + @NonNull + public String toPersistentString() { + StringBuilder sb = new StringBuilder(32); + Device device = getDevice(); + if (device != null) { + sb.append(device.getName()); + sb.append(SEP); + State state = getDeviceState(); + if (state != null) { + sb.append(state.getName()); + } + sb.append(SEP); + Locale locale = getLocale(); + if (isLocaleSpecificLayout() && locale != null && locale.qualifier.hasLanguage()) { + // locale[0]/[1] can be null sometimes when starting Eclipse + sb.append(locale.qualifier.getLanguage()); + sb.append(SEP_LOCALE); + if (locale.qualifier.hasRegion()) { + sb.append(locale.qualifier.getRegion()); + } + } + sb.append(SEP); + // Need to escape the theme: if we write the full theme style, then + // we can end up with ":"'s in the string (as in @android:style/Theme) which + // can be mistaken for {@link #SEP}. Instead use {@link #MARKER_FRAMEWORK}. + String theme = getTheme(); + if (theme != null) { + String themeName = ResourceHelper.styleToTheme(theme); + if (theme.startsWith(STYLE_RESOURCE_PREFIX)) { + sb.append(MARKER_PROJECT); + } else if (theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)) { + sb.append(MARKER_FRAMEWORK); + } + sb.append(themeName); + } + sb.append(SEP); + UiMode uiMode = getUiMode(); + if (uiMode != null) { + sb.append(uiMode.getResourceValue()); + } + sb.append(SEP); + NightMode nightMode = getNightMode(); + if (nightMode != null) { + sb.append(nightMode.getResourceValue()); + } + sb.append(SEP); + + // We used to store the render target here in R9. Leave a marker + // to ensure that we don't reuse this slot; add new extra fields after it. + sb.append(SEP); + String activity = getActivity(); + if (activity != null) { + sb.append(activity); + } + } + + return sb.toString(); + } + + /** Returns the preferred theme, or null */ + @Nullable + String computePreferredTheme() { + IProject project = mConfigChooser.getProject(); + ManifestInfo manifest = ManifestInfo.get(project); + + // Look up the screen size for the current state + ScreenSize screenSize = null; + Device device = getDevice(); + if (device != null) { + List<State> states = device.getAllStates(); + for (State state : states) { + FolderConfiguration folderConfig = DeviceConfigHelper.getFolderConfig(state); + if (folderConfig != null) { + ScreenSizeQualifier qualifier = folderConfig.getScreenSizeQualifier(); + screenSize = qualifier.getValue(); + break; + } + } + } + + // Look up the default/fallback theme to use for this project (which + // depends on the screen size when no particular theme is specified + // in the manifest) + String defaultTheme = manifest.getDefaultTheme(getTarget(), screenSize); + + String preferred = defaultTheme; + if (getTheme() == null) { + // If we are rendering a layout in included context, pick the theme + // from the outer layout instead + + String activity = getActivity(); + if (activity != null) { + ActivityAttributes attributes = manifest.getActivityAttributes(activity); + if (attributes != null) { + preferred = attributes.getTheme(); + } + } + if (preferred == null) { + preferred = defaultTheme; + } + setTheme(preferred); + } + + return preferred; + } + + private void checkThemePrefix() { + if (mTheme != null && !mTheme.startsWith(PREFIX_RESOURCE_REF)) { + if (mTheme.isEmpty()) { + computePreferredTheme(); + return; + } + ResourceRepository frameworkRes = mConfigChooser.getClient().getFrameworkResources(); + if (frameworkRes != null + && frameworkRes.hasResourceItem(ANDROID_STYLE_RESOURCE_PREFIX + mTheme)) { + mTheme = ANDROID_STYLE_RESOURCE_PREFIX + mTheme; + } else { + mTheme = STYLE_RESOURCE_PREFIX + mTheme; + } + } + } + + /** + * Initializes a string previously created with + * {@link #toPersistentString()} + * + * @param data the string to initialize back from + * @return true if the configuration was initialized + */ + boolean initialize(String data) { + String[] values = data.split(SEP); + if (values.length >= 6 && values.length <= 8) { + for (Device d : mConfigChooser.getDevices()) { + if (d.getName().equals(values[0])) { + mDevice = d; + String stateName = null; + FolderConfiguration config = null; + if (!values[1].isEmpty() && !values[1].equals("null")) { //$NON-NLS-1$ + stateName = values[1]; + config = DeviceConfigHelper.getFolderConfig(mDevice, stateName); + } else if (mDevice.getAllStates().size() > 0) { + State first = mDevice.getAllStates().get(0); + stateName = first.getName(); + config = DeviceConfigHelper.getFolderConfig(first); + } + mState = getState(mDevice, stateName); + if (config != null) { + // Load locale. Note that this can get overwritten by the + // project-wide settings read below. + LocaleQualifier locale = Locale.ANY_QUALIFIER; + String locales[] = values[2].split(SEP_LOCALE); + if (locales.length >= 2 && locales[0].length() > 0 + && !LocaleQualifier.FAKE_VALUE.equals(locales[0])) { + String language = locales[0]; + String region = locales[1]; + if (region.length() > 0 && !LocaleQualifier.FAKE_VALUE.equals(region)) { + locale = LocaleQualifier.getQualifier(language + "-r" + region); + } else { + locale = new LocaleQualifier(language); + } + mLocale = Locale.create(locale); + } + + // Decode the theme name: See {@link #getData} + mTheme = values[3]; + if (mTheme.startsWith(MARKER_FRAMEWORK)) { + mTheme = ANDROID_STYLE_RESOURCE_PREFIX + + mTheme.substring(MARKER_FRAMEWORK.length()); + } else if (mTheme.startsWith(MARKER_PROJECT)) { + mTheme = STYLE_RESOURCE_PREFIX + + mTheme.substring(MARKER_PROJECT.length()); + } else { + checkThemePrefix(); + } + + mUiMode = UiMode.getEnum(values[4]); + if (mUiMode == null) { + mUiMode = UiMode.NORMAL; + } + mNightMode = NightMode.getEnum(values[5]); + if (mNightMode == null) { + mNightMode = NightMode.NOTNIGHT; + } + + // element 7/values[6]: used to store render target in R9. + // No longer stored here. If adding more data, make + // sure you leave 7 alone. + + Pair<Locale, IAndroidTarget> pair = loadRenderState(mConfigChooser); + if (pair != null) { + // We only use the "global" setting + if (!isLocaleSpecificLayout()) { + mLocale = pair.getFirst(); + } + mTarget = pair.getSecond(); + } + + if (values.length == 8) { + mActivity = values[7]; + } + + return true; + } + } + } + } + + return false; + } + + /** + * Loads the render state (the locale and the render target, which are shared among + * all the layouts meaning that changing it in one will change it in all) and returns + * the current project-wide locale and render target to be used. + * + * @param chooser the {@link ConfigurationChooser} providing information about + * loaded targets + * @return a pair of a locale and a render target + */ + @Nullable + static Pair<Locale, IAndroidTarget> loadRenderState(ConfigurationChooser chooser) { + IProject project = chooser.getProject(); + if (project == null || !project.isAccessible()) { + return null; + } + + try { + String data = project.getPersistentProperty(NAME_RENDER_STATE); + if (data != null) { + Locale locale = Locale.ANY; + IAndroidTarget target = null; + + String[] values = data.split(SEP); + if (values.length == 2) { + + LocaleQualifier qualifier = Locale.ANY_QUALIFIER; + String locales[] = values[0].split(SEP_LOCALE); + if (locales.length >= 2 && locales[0].length() > 0 + && !LocaleQualifier.FAKE_VALUE.equals(locales[0])) { + String language = locales[0]; + String region = locales[1]; + if (region.length() > 0 && !LocaleQualifier.FAKE_VALUE.equals(region)) { + locale = Locale.create(LocaleQualifier.getQualifier(language + "-r" + region)); + } else { + locale = Locale.create(new LocaleQualifier(language)); + } + } else { + locale = Locale.ANY; + } + if (AdtPrefs.getPrefs().isAutoPickRenderTarget()) { + target = ConfigurationMatcher.findDefaultRenderTarget(chooser); + } else { + String targetString = values[1]; + target = stringToTarget(chooser, targetString); + // See if we should "correct" the rendering target to a + // better version. If you're using a pre-release version + // of the render target, and a final release is + // available and installed, we should switch to that + // one instead. + if (target != null) { + AndroidVersion version = target.getVersion(); + List<IAndroidTarget> targetList = chooser.getTargetList(); + if (version.getCodename() != null && targetList != null) { + int targetApiLevel = version.getApiLevel() + 1; + for (IAndroidTarget t : targetList) { + if (t.getVersion().getApiLevel() == targetApiLevel + && t.isPlatform()) { + target = t; + break; + } + } + } + } else { + target = ConfigurationMatcher.findDefaultRenderTarget(chooser); + } + } + } + + return Pair.of(locale, target); + } + + return Pair.of(Locale.ANY, ConfigurationMatcher.findDefaultRenderTarget(chooser)); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + + return null; + } + + /** + * Saves the render state (the current locale and render target settings) into the + * project wide settings storage + */ + void saveRenderState() { + IProject project = mConfigChooser.getProject(); + if (project == null) { + return; + } + try { + // Generate a persistent string from locale+target + StringBuilder sb = new StringBuilder(32); + Locale locale = getLocale(); + if (locale != null) { + // locale[0]/[1] can be null sometimes when starting Eclipse + sb.append(locale.qualifier.getLanguage()); + sb.append(SEP_LOCALE); + if (locale.qualifier.hasRegion()) { + sb.append(locale.qualifier.getRegion()); + } + } + sb.append(SEP); + IAndroidTarget target = getTarget(); + if (target != null) { + sb.append(targetToString(target)); + sb.append(SEP); + } + + project.setPersistentProperty(NAME_RENDER_STATE, sb.toString()); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + } + + /** + * Returns a String id to represent an {@link IAndroidTarget} which can be translated + * back to an {@link IAndroidTarget} by the matching {@link #stringToTarget}. The id + * will never contain the {@link #SEP} character. + * + * @param target the target to return an id for + * @return an id for the given target; never null + */ + @NonNull + public static String targetToString(@NonNull IAndroidTarget target) { + return target.getFullName().replace(SEP, ""); //$NON-NLS-1$ + } + + /** + * Returns an {@link IAndroidTarget} that corresponds to the given id that was + * originally returned by {@link #targetToString}. May be null, if the platform is no + * longer available, or if the platform list has not yet been initialized. + * + * @param chooser the {@link ConfigurationChooser} providing information about + * loaded targets + * @param id the id that corresponds to the desired platform + * @return an {@link IAndroidTarget} that matches the given id, or null + */ + @Nullable + public static IAndroidTarget stringToTarget( + @NonNull ConfigurationChooser chooser, + @NonNull String id) { + List<IAndroidTarget> targetList = chooser.getTargetList(); + if (targetList != null && targetList.size() > 0) { + for (IAndroidTarget target : targetList) { + if (id.equals(targetToString(target))) { + return target; + } + } + } + + return null; + } + + /** + * Returns an {@link IAndroidTarget} that corresponds to the given id that was + * originally returned by {@link #targetToString}. May be null, if the platform is no + * longer available, or if the platform list has not yet been initialized. + * + * @param id the id that corresponds to the desired platform + * @return an {@link IAndroidTarget} that matches the given id, or null + */ + @Nullable + public static IAndroidTarget stringToTarget( + @NonNull String id) { + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget[] targets = currentSdk.getTargets(); + for (IAndroidTarget target : targets) { + if (id.equals(targetToString(target))) { + return target; + } + } + } + + return null; + } + + /** + * Returns the {@link State} by the given name for the given {@link Device} + * + * @param device the device + * @param name the name of the state + */ + @Nullable + static State getState(@Nullable Device device, @Nullable String name) { + if (device == null) { + return null; + } else if (name != null) { + State state = device.getState(name); + if (state != null) { + return state; + } + } + + return device.getDefaultState(); + } + + /** + * Returns the currently selected {@link Density}. This is guaranteed to be non null. + * + * @return the density + */ + @NonNull + public Density getDensity() { + if (mFullConfig != null) { + DensityQualifier qual = mFullConfig.getDensityQualifier(); + if (qual != null) { + // just a sanity check + Density d = qual.getValue(); + if (d != Density.NODPI) { + return d; + } + } + } + + // no config? return medium as the default density. + return Density.MEDIUM; + } + + /** + * Get the next cyclical state after the given state + * + * @param from the state to start with + * @return the following state following + */ + @Nullable + public State getNextDeviceState(@Nullable State from) { + Device device = getDevice(); + if (device == null) { + return null; + } + List<State> states = device.getAllStates(); + for (int i = 0; i < states.size(); i++) { + if (states.get(i) == from) { + return states.get((i + 1) % states.size()); + } + } + + return null; + } + + /** + * Returns true if this configuration supports the given rendering + * capability + * + * @param capability the capability to check + * @return true if the capability is supported + */ + public boolean supports(Capability capability) { + IAndroidTarget target = getTarget(); + if (target != null) { + return RenderService.supports(target, capability); + } + + return false; + } + + @Override + public String toString() { + return Objects.toStringHelper(this.getClass()) + .add("display", getDisplayName()) //$NON-NLS-1$ + .add("persistent", toPersistentString()) //$NON-NLS-1$ + .toString(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationChooser.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationChooser.java new file mode 100644 index 000000000..009b8646c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationChooser.java @@ -0,0 +1,2096 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.SdkConstants.ANDROID_NS_NAME_PREFIX; +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_CONTEXT; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.RES_QUALIFIER_SEP; +import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.ide.eclipse.adt.AdtUtils.isUiThread; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_DEVICE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_DEVICE_STATE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_FOLDER; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_LOCALE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_TARGET; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_THEME; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.MASK_ALL; +import static com.google.common.base.Objects.equal; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.StyleResourceValue; +import com.android.ide.common.resources.LocaleManager; +import com.android.ide.common.resources.ResourceFile; +import com.android.ide.common.resources.ResourceFolder; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.configuration.DeviceConfigHelper; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.resources.configuration.ResourceQualifier; +import com.android.ide.common.sdk.LoadStatus; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlDelegate; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo.ActivityAttributes; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.ResourceType; +import com.android.resources.ScreenOrientation; +import com.android.sdklib.AndroidVersion; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.DeviceManager; +import com.android.sdklib.devices.DeviceManager.DevicesChangedListener; +import com.android.sdklib.devices.State; +import com.android.utils.Pair; +import com.google.common.base.Objects; +import com.google.common.base.Strings; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.DisposeEvent; +import org.eclipse.swt.events.DisposeListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.swt.widgets.ToolItem; +import org.eclipse.ui.IEditorPart; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedSet; + +/** + * The {@linkplain ConfigurationChooser} allows the user to pick a + * {@link Configuration} by configuring various constraints. + */ +public class ConfigurationChooser extends Composite + implements DevicesChangedListener, DisposeListener { + private static final String ICON_SQUARE = "square"; //$NON-NLS-1$ + private static final String ICON_LANDSCAPE = "landscape"; //$NON-NLS-1$ + private static final String ICON_PORTRAIT = "portrait"; //$NON-NLS-1$ + private static final String ICON_LANDSCAPE_FLIP = "flip_landscape";//$NON-NLS-1$ + private static final String ICON_PORTRAIT_FLIP = "flip_portrait";//$NON-NLS-1$ + private static final String ICON_DISPLAY = "display"; //$NON-NLS-1$ + private static final String ICON_THEMES = "themes"; //$NON-NLS-1$ + private static final String ICON_ACTIVITY = "activity"; //$NON-NLS-1$ + + /** The configuration state associated with this editor */ + private @NonNull Configuration mConfiguration = Configuration.create(this); + + /** Serialized state to use when initializing the configuration after the SDK is loaded */ + private String mInitialState; + + /** The client of the configuration editor */ + private final ConfigurationClient mClient; + + /** Counter for programmatic UI changes: if greater than 0, we're within a call */ + private int mDisableUpdates = 0; + + /** List of available devices */ + private Collection<Device> mDevices = Collections.emptyList(); + + /** List of available targets */ + private final List<IAndroidTarget> mTargetList = new ArrayList<IAndroidTarget>(); + + /** List of available themes */ + private final List<String> mThemeList = new ArrayList<String>(); + + /** List of available locales */ + private final List<Locale > mLocaleList = new ArrayList<Locale>(); + + /** The file being edited */ + private IFile mEditedFile; + + /** The {@link ProjectResources} for the edited file's project */ + private ProjectResources mResources; + + /** The target of the project of the file being edited. */ + private IAndroidTarget mProjectTarget; + + /** Dropdown for configurations */ + private ToolItem mConfigCombo; + + /** Dropdown for devices */ + private ToolItem mDeviceCombo; + + /** Dropdown for device states */ + private ToolItem mOrientationCombo; + + /** Dropdown for themes */ + private ToolItem mThemeCombo; + + /** Dropdown for locales */ + private ToolItem mLocaleCombo; + + /** Dropdown for activities */ + private ToolItem mActivityCombo; + + /** Dropdown for rendering targets */ + private ToolItem mTargetCombo; + + /** Whether the SDK has changed since the last model reload; if so we must reload targets */ + private boolean mSdkChanged = true; + + /** + * Creates a new {@linkplain ConfigurationChooser} and adds it to the + * parent. The method also receives custom buttons to set into the + * configuration composite. The list is organized as an array of arrays. + * Each array represents a group of buttons thematically grouped together. + * + * @param client the client embedding this configuration chooser + * @param parent The parent composite. + * @param initialState The initial state (serialized form) to use for the + * configuration + */ + public ConfigurationChooser( + @NonNull ConfigurationClient client, + Composite parent, + @Nullable String initialState) { + super(parent, SWT.NONE); + mClient = client; + + setVisible(false); // Delayed until the targets are loaded + + mInitialState = initialState; + setLayout(new GridLayout(1, false)); + + IconFactory icons = IconFactory.getInstance(); + + // TODO: Consider switching to a CoolBar instead + ToolBar toolBar = new ToolBar(this, SWT.WRAP | SWT.FLAT | SWT.RIGHT | SWT.HORIZONTAL); + toolBar.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + + mConfigCombo = new ToolItem(toolBar, SWT.DROP_DOWN ); + mConfigCombo.setImage(icons.getIcon("android_file")); //$NON-NLS-1$ + mConfigCombo.setToolTipText("Configuration to render this layout with in Eclipse"); + + @SuppressWarnings("unused") + ToolItem separator2 = new ToolItem(toolBar, SWT.SEPARATOR); + + mDeviceCombo = new ToolItem(toolBar, SWT.DROP_DOWN); + mDeviceCombo.setImage(icons.getIcon(ICON_DISPLAY)); + + @SuppressWarnings("unused") + ToolItem separator3 = new ToolItem(toolBar, SWT.SEPARATOR); + + mOrientationCombo = new ToolItem(toolBar, SWT.DROP_DOWN); + mOrientationCombo.setImage(icons.getIcon(ICON_PORTRAIT)); + mOrientationCombo.setToolTipText("Go to next state"); + + @SuppressWarnings("unused") + ToolItem separator4 = new ToolItem(toolBar, SWT.SEPARATOR); + + mThemeCombo = new ToolItem(toolBar, SWT.DROP_DOWN); + mThemeCombo.setImage(icons.getIcon(ICON_THEMES)); + + @SuppressWarnings("unused") + ToolItem separator5 = new ToolItem(toolBar, SWT.SEPARATOR); + + mActivityCombo = new ToolItem(toolBar, SWT.DROP_DOWN); + mActivityCombo.setToolTipText("Associated activity or fragment providing context"); + // The JDT class icon is lopsided, presumably because they've left room in the + // bottom right corner for badges (for static, final etc). Unfortunately, this + // means that the icon looks out of place when sitting close to the language globe + // icon, the theme icon, etc so that it looks vertically misaligned: + //mActivityCombo.setImage(JavaUI.getSharedImages().getImage(ISharedImages.IMG_OBJS_CLASS)); + // ...so use one that is centered instead: + mActivityCombo.setImage(icons.getIcon(ICON_ACTIVITY)); + + @SuppressWarnings("unused") + ToolItem separator6 = new ToolItem(toolBar, SWT.SEPARATOR); + + //ToolBar rightToolBar = new ToolBar(this, SWT.WRAP | SWT.FLAT | SWT.RIGHT | SWT.HORIZONTAL); + //rightToolBar.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 1, 1)); + ToolBar rightToolBar = toolBar; + + mLocaleCombo = new ToolItem(rightToolBar, SWT.DROP_DOWN); + mLocaleCombo.setImage(FlagManager.getGlobeIcon()); + mLocaleCombo.setToolTipText("Locale to use when rendering layouts in Eclipse"); + + @SuppressWarnings("unused") + ToolItem separator7 = new ToolItem(rightToolBar, SWT.SEPARATOR); + + mTargetCombo = new ToolItem(rightToolBar, SWT.DROP_DOWN); + mTargetCombo.setImage(AdtPlugin.getAndroidLogo()); + mTargetCombo.setToolTipText("Android version to use when rendering layouts in Eclipse"); + + SelectionListener listener = new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Object source = e.getSource(); + + if (source == mConfigCombo) { + ConfigurationMenuListener.show(ConfigurationChooser.this, mConfigCombo); + } else if (source == mActivityCombo) { + ActivityMenuListener.show(ConfigurationChooser.this, mActivityCombo); + } else if (source == mLocaleCombo) { + LocaleMenuListener.show(ConfigurationChooser.this, mLocaleCombo); + } else if (source == mDeviceCombo) { + DeviceMenuListener.show(ConfigurationChooser.this, mDeviceCombo); + } else if (source == mTargetCombo) { + TargetMenuListener.show(ConfigurationChooser.this, mTargetCombo); + } else if (source == mThemeCombo) { + ThemeMenuAction.showThemeMenu(ConfigurationChooser.this, mThemeCombo, + mThemeList); + } else if (source == mOrientationCombo) { + if (e.detail == SWT.ARROW) { + OrientationMenuAction.showMenu(ConfigurationChooser.this, + mOrientationCombo); + } else { + gotoNextState(); + } + } + } + }; + mConfigCombo.addSelectionListener(listener); + mActivityCombo.addSelectionListener(listener); + mLocaleCombo.addSelectionListener(listener); + mDeviceCombo.addSelectionListener(listener); + mTargetCombo.addSelectionListener(listener); + mThemeCombo.addSelectionListener(listener); + mOrientationCombo.addSelectionListener(listener); + + addDisposeListener(this); + + initDevices(); + initTargets(); + } + + /** + * Returns the edited file + * + * @return the file + */ + @Nullable + public IFile getEditedFile() { + return mEditedFile; + } + + /** + * Returns the project of the edited file + * + * @return the project + */ + @Nullable + public IProject getProject() { + if (mEditedFile != null) { + return mEditedFile.getProject(); + } else { + return null; + } + } + + ConfigurationClient getClient() { + return mClient; + } + + /** + * Returns the project resources for the project being configured by this + * chooser + * + * @return the project resources + */ + @Nullable + public ProjectResources getResources() { + return mResources; + } + + /** + * Returns the full, complete {@link FolderConfiguration} + * + * @return the full configuration + */ + public FolderConfiguration getFullConfiguration() { + return mConfiguration.getFullConfig(); + } + + /** + * Returns the project target + * + * @return the project target + */ + public IAndroidTarget getProjectTarget() { + return mProjectTarget; + } + + /** + * Returns the configuration being edited by this {@linkplain ConfigurationChooser} + * + * @return the configuration + */ + public Configuration getConfiguration() { + return mConfiguration; + } + + /** + * Returns the list of locales + * @return a list of {@link ResourceQualifier} pairs + */ + @NonNull + public List<Locale> getLocaleList() { + return mLocaleList; + } + + /** + * Returns the list of available devices + * + * @return a list of {@link Device} objects + */ + @NonNull + public Collection<Device> getDevices() { + return mDevices; + } + + /** + * Returns the list of available render targets + * + * @return a list of {@link IAndroidTarget} objects + */ + @NonNull + public List<IAndroidTarget> getTargetList() { + return mTargetList; + } + + // ---- Configuration State Lookup ---- + + /** + * Returns the rendering target to be used + * + * @return the target + */ + @NonNull + public IAndroidTarget getTarget() { + IAndroidTarget target = mConfiguration.getTarget(); + if (target == null) { + target = mProjectTarget; + } + + return target; + } + + /** + * Returns the current device string, or null if no device is selected + * + * @return the device name, or null + */ + @Nullable + public String getDeviceName() { + Device device = mConfiguration.getDevice(); + if (device != null) { + return device.getName(); + } + + return null; + } + + /** + * Returns the current theme, or null if none has been selected + * + * @return the theme name, or null + */ + @Nullable + public String getThemeName() { + String theme = mConfiguration.getTheme(); + if (theme != null) { + theme = ResourceHelper.styleToTheme(theme); + } + + return theme; + } + + /** Move to the next device state, changing the icon if it changes orientation */ + private void gotoNextState() { + State state = mConfiguration.getDeviceState(); + State flipped = mConfiguration.getNextDeviceState(state); + if (flipped != state) { + selectDeviceState(flipped); + onDeviceConfigChange(); + } + } + + // ---- Implements DisposeListener ---- + + @Override + public void widgetDisposed(DisposeEvent e) { + dispose(); + } + + @Override + public void dispose() { + if (!isDisposed()) { + super.dispose(); + + final Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + DeviceManager manager = sdk.getDeviceManager(); + manager.unregisterListener(this); + } + } + } + + // ---- Init and reset/reload methods ---- + + /** + * Sets the reference to the file being edited. + * <p/>The UI is initialized in {@link #onXmlModelLoaded()} which is called as the XML model is + * loaded (or reloaded as the SDK/target changes). + * + * @param file the file being opened + * + * @see #onXmlModelLoaded() + * @see #replaceFile(IFile) + * @see #changeFileOnNewConfig(IFile) + */ + public void setFile(IFile file) { + mEditedFile = file; + ensureInitialized(); + } + + /** + * Replaces the UI with a given file configuration. This is meant to answer the user + * explicitly opening a different version of the same layout from the Package Explorer. + * <p/>This attempts to keep the current config, but may change it if it's not compatible or + * not the best match + * @param file the file being opened. + */ + public void replaceFile(IFile file) { + // if there is no previous selection, revert to default mode. + if (mConfiguration.getDevice() == null) { + setFile(file); // onTargetChanged will be called later. + return; + } + + setFile(file); + IProject project = mEditedFile.getProject(); + mResources = ResourceManager.getInstance().getProjectResources(project); + + ResourceFolder resFolder = ResourceManager.getInstance().getResourceFolder(file); + mConfiguration.setEditedConfig(resFolder.getConfiguration()); + + mDisableUpdates++; // we do not want to trigger onXXXChange when setting + // new values in the widgets. + + try { + // only attempt to do anything if the SDK and targets are loaded. + LoadStatus sdkStatus = AdtPlugin.getDefault().getSdkLoadStatus(); + + if (sdkStatus == LoadStatus.LOADED) { + setVisible(true); + + LoadStatus targetStatus = Sdk.getCurrent().checkAndLoadTargetData(mProjectTarget, + null /*project*/); + + if (targetStatus == LoadStatus.LOADED) { + + // update the current config selection to make sure it's + // compatible with the new file + ConfigurationMatcher matcher = new ConfigurationMatcher(this); + matcher.adaptConfigSelection(true /*needBestMatch*/); + mConfiguration.syncFolderConfig(); + + // update the string showing the config value + selectConfiguration(mConfiguration.getEditedConfig()); + updateActivity(); + } + } else if (sdkStatus == LoadStatus.FAILED) { + setVisible(true); + } + } finally { + mDisableUpdates--; + } + } + + /** + * Updates the UI with a new file that was opened in response to a config change. + * @param file the file being opened. + * + * @see #replaceFile(IFile) + */ + public void changeFileOnNewConfig(IFile file) { + setFile(file); + IProject project = mEditedFile.getProject(); + mResources = ResourceManager.getInstance().getProjectResources(project); + + ResourceFolder resFolder = ResourceManager.getInstance().getResourceFolder(file); + FolderConfiguration config = resFolder.getConfiguration(); + mConfiguration.setEditedConfig(config); + + // All that's needed is to update the string showing the config value + // (since the config combo settings chosen by the user). + selectConfiguration(config); + } + + /** + * Resets the configuration chooser to reflect the given file configuration. This is + * intended to be used by the "Show Included In" functionality where the user has + * picked a non-default configuration (such as a particular landscape layout) and the + * configuration chooser must be switched to a landscape layout. This method will + * trigger a model change. + * <p> + * This will NOT trigger a redraw event! + * <p> + * FIXME: We are currently setting the configuration file to be the configuration for + * the "outer" (the including) file, rather than the inner file, which is the file the + * user is actually editing. We need to refine this, possibly with a way for the user + * to choose which configuration they are editing. And in particular, we should be + * filtering the configuration chooser to only show options in the outer configuration + * that are compatible with the inner included file. + * + * @param file the file to be configured + */ + public void resetConfigFor(IFile file) { + setFile(file); + + IFolder parent = (IFolder) mEditedFile.getParent(); + ResourceFolder resFolder = mResources.getResourceFolder(parent); + if (resFolder != null) { + mConfiguration.setEditedConfig(resFolder.getConfiguration()); + } else { + FolderConfiguration config = FolderConfiguration.getConfig( + parent.getName().split(RES_QUALIFIER_SEP)); + if (config != null) { + mConfiguration.setEditedConfig(config); + } else { + mConfiguration.setEditedConfig(new FolderConfiguration()); + } + } + + onXmlModelLoaded(); + } + + + /** + * Sets the current configuration to match the given folder configuration, + * the given theme name, the given device and device state. + * + * @param configuration new folder configuration to use + */ + public void setConfiguration(@NonNull Configuration configuration) { + if (mClient != null) { + mClient.aboutToChange(MASK_ALL); + } + + Configuration oldConfiguration = mConfiguration; + mConfiguration = configuration; + mConfiguration.setChooser(this); + + selectTheme(configuration.getTheme()); + selectLocale(configuration.getLocale()); + selectDevice(configuration.getDevice()); + selectDeviceState(configuration.getDeviceState()); + selectTarget(configuration.getTarget()); + selectActivity(configuration.getActivity()); + + // This may be a second refresh after triggered by theme above + if (mClient != null) { + LayoutCanvas canvas = mClient.getCanvas(); + if (canvas != null) { + assert mConfiguration != oldConfiguration; + canvas.getPreviewManager().updateChooserConfig(oldConfiguration, mConfiguration); + } + + boolean accepted = mClient.changed(MASK_ALL); + if (!accepted) { + configuration = oldConfiguration; + selectTheme(configuration.getTheme()); + selectLocale(configuration.getLocale()); + selectDevice(configuration.getDevice()); + selectDeviceState(configuration.getDeviceState()); + selectTarget(configuration.getTarget()); + selectActivity(configuration.getActivity()); + if (canvas != null && mConfiguration != oldConfiguration) { + canvas.getPreviewManager().updateChooserConfig(mConfiguration, + oldConfiguration); + } + return; + } else { + int changed = 0; + if (!equal(oldConfiguration.getTheme(), mConfiguration.getTheme())) { + changed |= CFG_THEME; + } + if (!equal(oldConfiguration.getDevice(), mConfiguration.getDevice())) { + changed |= CFG_DEVICE | CFG_DEVICE_STATE; + } + if (changed != 0) { + syncToVariations(changed, mEditedFile, mConfiguration, false, true); + } + } + } + + saveConstraints(); + } + + /** + * Responds to the event that the basic SDK information finished loading. + * @param target the possibly new target object associated with the file being edited (in case + * the SDK path was changed). + */ + public void onSdkLoaded(IAndroidTarget target) { + // a change to the SDK means that we need to check for new/removed devices. + mSdkChanged = true; + + // store the new target. + mProjectTarget = target; + + mDisableUpdates++; // we do not want to trigger onXXXChange when setting + // new values in the widgets. + try { + updateDevices(); + updateTargets(); + ensureInitialized(); + } finally { + mDisableUpdates--; + } + } + + /** + * Responds to the XML model being loaded, either the first time or when the + * Target/SDK changes. + * <p> + * This initializes the UI, either with the first compatible configuration + * found, or it will attempt to restore a configuration if one is found to + * have been saved in the file persistent storage. + * <p> + * If the SDK or target are not loaded, nothing will happen (but the method + * must be called back when they are.) + * <p> + * The method automatically handles being called the first time after editor + * creation, or being called after during SDK/Target changes (as long as + * {@link #onSdkLoaded(IAndroidTarget)} is properly called). + * + * @return the target data for the rendering target used to render the + * layout + * + * @see #saveConstraints() + * @see #onSdkLoaded(IAndroidTarget) + */ + public AndroidTargetData onXmlModelLoaded() { + AndroidTargetData targetData = null; + + // only attempt to do anything if the SDK and targets are loaded. + LoadStatus sdkStatus = AdtPlugin.getDefault().getSdkLoadStatus(); + if (sdkStatus == LoadStatus.LOADED) { + mDisableUpdates++; // we do not want to trigger onXXXChange when setting + + try { + // init the devices if needed (new SDK or first time going through here) + if (mSdkChanged) { + updateDevices(); + updateTargets(); + ensureInitialized(); + mSdkChanged = false; + } + + IProject project = mEditedFile.getProject(); + + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + mProjectTarget = currentSdk.getTarget(project); + } + + LoadStatus targetStatus = LoadStatus.FAILED; + if (mProjectTarget != null) { + targetStatus = Sdk.getCurrent().checkAndLoadTargetData(mProjectTarget, null); + updateTargets(); + ensureInitialized(); + } + + if (targetStatus == LoadStatus.LOADED) { + setVisible(true); + if (mResources == null) { + mResources = ResourceManager.getInstance().getProjectResources(project); + } + if (mConfiguration.getEditedConfig() == null) { + IFolder parent = (IFolder) mEditedFile.getParent(); + ResourceFolder resFolder = mResources.getResourceFolder(parent); + if (resFolder != null) { + mConfiguration.setEditedConfig(resFolder.getConfiguration()); + } else { + FolderConfiguration config = FolderConfiguration.getConfig( + parent.getName().split(RES_QUALIFIER_SEP)); + if (config != null) { + mConfiguration.setEditedConfig(config); + } else { + mConfiguration.setEditedConfig(new FolderConfiguration()); + } + } + } + + targetData = Sdk.getCurrent().getTargetData(mProjectTarget); + + // get the file stored state + ensureInitialized(); + boolean loadedConfigData = mConfiguration.getDevice() != null && + mConfiguration.getDeviceState() != null; + + // Load locale list. This must be run after we initialize the + // configuration above, since it attempts to sync the UI with + // the value loaded into the configuration. + updateLocales(); + + // If the current state was loaded from the persistent storage, we update the + // UI with it and then try to adapt it (which will handle incompatible + // configuration). + // Otherwise, just look for the first compatible configuration. + ConfigurationMatcher matcher = new ConfigurationMatcher(this); + if (loadedConfigData) { + // first make sure we have the config to adapt + selectDevice(mConfiguration.getDevice()); + selectDeviceState(mConfiguration.getDeviceState()); + mConfiguration.syncFolderConfig(); + + matcher.adaptConfigSelection(false); + + IAndroidTarget target = mConfiguration.getTarget(); + selectTarget(target); + targetData = Sdk.getCurrent().getTargetData(target); + } else { + matcher.findAndSetCompatibleConfig(false); + + // Default to modern layout lib + IAndroidTarget target = ConfigurationMatcher.findDefaultRenderTarget(this); + if (target != null) { + targetData = Sdk.getCurrent().getTargetData(target); + selectTarget(target); + mConfiguration.setTarget(target, true); + } + } + + // Update activity: This is done before updateThemes() since + // the themes selection can depend on the currently selected activity + // (e.g. when there are manifest registrations for the theme to use + // for a given activity) + updateActivity(); + + // Update themes. This is done after updating the devices above, + // since we want to look at the chosen device size to decide + // what the default theme (for example, with Honeycomb we choose + // Holo as the default theme but only if the screen size is XLARGE + // (and of course only if the manifest does not specify another + // default theme). + updateThemes(); + + // update the string showing the config value + selectConfiguration(mConfiguration.getEditedConfig()); + + // compute the final current config + mConfiguration.syncFolderConfig(); + } else if (targetStatus == LoadStatus.FAILED) { + setVisible(true); + } + } finally { + mDisableUpdates--; + } + } + + return targetData; + } + + /** + * This is a temporary workaround for a infrequently happening bug; apparently + * there are cases where the configuration chooser isn't shown + */ + public void ensureVisible() { + if (!isVisible()) { + LoadStatus sdkStatus = AdtPlugin.getDefault().getSdkLoadStatus(); + if (sdkStatus == LoadStatus.LOADED) { + onXmlModelLoaded(); + } + } + } + + /** + * An alternate layout for this layout has been created. This means that the + * current layout may no longer be a best fit. However, since we support multiple + * layouts being open at the same time, we need to adjust the current configuration + * back to something where this layout <b>is</b> a best match. + */ + public void onAlternateLayoutCreated() { + IFile best = ConfigurationMatcher.getBestFileMatch(this); + if (best != null && !best.equals(mEditedFile)) { + ConfigurationMatcher matcher = new ConfigurationMatcher(this); + matcher.adaptConfigSelection(true /*needBestMatch*/); + mConfiguration.syncFolderConfig(); + if (mClient != null) { + mClient.changed(MASK_ALL); + } + } + } + + /** + * Loads the list of {@link Device}s and inits the UI with it. + */ + private void initDevices() { + final Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + DeviceManager manager = sdk.getDeviceManager(); + // This method can be called more than once, so avoid duplicate entries + manager.unregisterListener(this); + manager.registerListener(this); + mDevices = manager.getDevices(DeviceManager.ALL_DEVICES); + } else { + mDevices = new ArrayList<Device>(); + } + } + + /** + * Loads the list of {@link IAndroidTarget} and inits the UI with it. + */ + private boolean initTargets() { + mTargetList.clear(); + + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget[] targets = currentSdk.getTargets(); + for (int i = 0 ; i < targets.length; i++) { + if (targets[i].hasRenderingLibrary()) { + mTargetList.add(targets[i]); + } + } + + return true; + } + + return false; + } + + /** Ensures that the configuration has been initialized */ + public void ensureInitialized() { + if (mConfiguration.getDevice() == null && mEditedFile != null) { + String data = ConfigurationDescription.getDescription(mEditedFile); + if (mInitialState != null) { + data = mInitialState; + mInitialState = null; + } + if (data != null) { + mConfiguration.initialize(data); + mConfiguration.syncFolderConfig(); + } + } + } + + private void updateDevices() { + if (mDevices.size() == 0) { + initDevices(); + } + } + + private void updateTargets() { + if (mTargetList.size() == 0) { + if (!initTargets()) { + return; + } + } + + IAndroidTarget renderingTarget = mConfiguration.getTarget(); + + IAndroidTarget match = null; + for (IAndroidTarget target : mTargetList) { + if (renderingTarget != null) { + // use equals because the rendering could be from a previous SDK, so + // it may not be the same instance. + if (renderingTarget.equals(target)) { + match = target; + } + } else if (mProjectTarget == target) { + match = target; + } + + } + + if (match == null) { + // the rendering target is the same as the project. + renderingTarget = mProjectTarget; + } else { + // set the rendering target to the new object. + renderingTarget = match; + } + + mConfiguration.setTarget(renderingTarget, true); + selectTarget(renderingTarget); + } + + /** Update the toolbar whenever a label has changed, to not only + * cause the layout in the current toolbar to update, but to possibly + * wrap the toolbars and update the layout of the surrounding area. + */ + private void resizeToolBar() { + Point size = getSize(); + Point newSize = computeSize(size.x, SWT.DEFAULT, true); + setSize(newSize); + Composite parent = getParent(); + parent.layout(); + parent.redraw(); + } + + + Image getOrientationIcon(ScreenOrientation orientation, boolean flip) { + IconFactory icons = IconFactory.getInstance(); + switch (orientation) { + case LANDSCAPE: + return icons.getIcon(flip ? ICON_LANDSCAPE_FLIP : ICON_LANDSCAPE); + case SQUARE: + return icons.getIcon(ICON_SQUARE); + case PORTRAIT: + default: + return icons.getIcon(flip ? ICON_PORTRAIT_FLIP : ICON_PORTRAIT); + } + } + + ImageDescriptor getOrientationImage(ScreenOrientation orientation, boolean flip) { + IconFactory icons = IconFactory.getInstance(); + switch (orientation) { + case LANDSCAPE: + return icons.getImageDescriptor(flip ? ICON_LANDSCAPE_FLIP : ICON_LANDSCAPE); + case SQUARE: + return icons.getImageDescriptor(ICON_SQUARE); + case PORTRAIT: + default: + return icons.getImageDescriptor(flip ? ICON_PORTRAIT_FLIP : ICON_PORTRAIT); + } + } + + @NonNull + ScreenOrientation getOrientation(State state) { + FolderConfiguration config = DeviceConfigHelper.getFolderConfig(state); + ScreenOrientation orientation = null; + if (config != null && config.getScreenOrientationQualifier() != null) { + orientation = config.getScreenOrientationQualifier().getValue(); + } + + if (orientation == null) { + orientation = ScreenOrientation.PORTRAIT; + } + + return orientation; + } + + /** + * Stores the current config selection into the edited file such that we can + * bring it back the next time this layout is opened. + */ + public void saveConstraints() { + String description = mConfiguration.toPersistentString(); + if (description != null && !description.isEmpty()) { + ConfigurationDescription.setDescription(mEditedFile, description); + } + } + + // ---- Setting the current UI state ---- + + void selectDeviceState(@Nullable State state) { + assert isUiThread(); + try { + mDisableUpdates++; + mOrientationCombo.setData(state); + + State nextState = mConfiguration.getNextDeviceState(state); + mOrientationCombo.setImage(getOrientationIcon(getOrientation(state), + nextState != state)); + } finally { + mDisableUpdates--; + } + } + + void selectTarget(IAndroidTarget target) { + assert isUiThread(); + try { + mDisableUpdates++; + mTargetCombo.setData(target); + String label = getRenderingTargetLabel(target, true); + mTargetCombo.setText(label); + resizeToolBar(); + } finally { + mDisableUpdates--; + } + } + + /** + * Selects a given {@link Device} in the device combo, if it is found. + * @param device the device to select + * @return true if the device was found. + */ + boolean selectDevice(@Nullable Device device) { + assert isUiThread(); + try { + mDisableUpdates++; + mDeviceCombo.setData(device); + if (device != null) { + mDeviceCombo.setText(getDeviceLabel(device, true)); + } else { + mDeviceCombo.setText("Device"); + } + resizeToolBar(); + } finally { + mDisableUpdates--; + } + + return false; + } + + void selectActivity(@Nullable String fqcn) { + assert isUiThread(); + try { + mDisableUpdates++; + if (fqcn != null) { + mActivityCombo.setData(fqcn); + String label = getActivityLabel(fqcn, true); + mActivityCombo.setText(label); + } else { + mActivityCombo.setText("(Select)"); + } + resizeToolBar(); + } finally { + mDisableUpdates--; + } + } + + void selectTheme(@Nullable String theme) { + assert isUiThread(); + try { + mDisableUpdates++; + assert theme == null || theme.startsWith(STYLE_RESOURCE_PREFIX) + || theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX) : theme; + mThemeCombo.setData(theme); + if (theme != null) { + mThemeCombo.setText(getThemeLabel(theme, true)); + } else { + // FIXME eclipse claims this is dead code. + mThemeCombo.setText("(Set Theme)"); + } + resizeToolBar(); + } finally { + mDisableUpdates--; + } + } + + void selectLocale(@Nullable Locale locale) { + assert isUiThread(); + try { + mDisableUpdates++; + mLocaleCombo.setData(locale); + String label = Strings.nullToEmpty(getLocaleLabel(this, locale, true)); + mLocaleCombo.setText(label); + + Image image = getFlagImage(locale); + mLocaleCombo.setImage(image); + + resizeToolBar(); + } finally { + mDisableUpdates--; + } + } + + @NonNull + Image getFlagImage(@Nullable Locale locale) { + if (locale != null) { + return locale.getFlagImage(); + } + + return FlagManager.getGlobeIcon(); + } + + private void selectConfiguration(FolderConfiguration fileConfig) { + /* For now, don't show any text in the configuration combo, use just an + icon. This has the advantage that the configuration contents don't + shift around, so you can for example click back and forth between + portrait and landscape without the icon moving under the mouse. + If this works well, remove this whole method post ADT 21. + assert isUiThread(); + try { + String current = mEditedFile.getParent().getName(); + if (current.equals(FD_RES_LAYOUT)) { + current = "default"; + } + + // Pretty things up a bit + //if (current == null || current.equals("default")) { + // current = "Default Configuration"; + //} + mConfigCombo.setText(current); + resizeToolBar(); + } finally { + mDisableUpdates--; + } + */ + } + + /** + * Finds a locale matching the config from a file. + * + * @param language the language qualifier or null if none is set. + * @param region the region qualifier or null if none is set. + * @return true if there was a change in the combobox as a result of + * applying the locale + */ + private boolean setLocale(@Nullable Locale locale) { + boolean changed = !Objects.equal(mConfiguration.getLocale(), locale); + selectLocale(locale); + + return changed; + } + + // ---- Creating UI labels ---- + + /** + * Returns a suitable label to use to display the given activity + * + * @param fqcn the activity class to look up a label for + * @param brief if true, generate a brief label (suitable for a toolbar + * button), otherwise a fuller name (suitable for a menu item) + * @return the label + */ + public static String getActivityLabel(String fqcn, boolean brief) { + if (brief) { + String label = fqcn; + int packageIndex = label.lastIndexOf('.'); + if (packageIndex != -1) { + label = label.substring(packageIndex + 1); + } + int innerClass = label.lastIndexOf('$'); + if (innerClass != -1) { + label = label.substring(innerClass + 1); + } + + // Also strip out the "Activity" or "Fragment" common suffix + // if this is a long name + if (label.endsWith("Activity") && label.length() > 8 + 12) { // 12 chars + 8 in suffix + label = label.substring(0, label.length() - 8); + } else if (label.endsWith("Fragment") && label.length() > 8 + 12) { + label = label.substring(0, label.length() - 8); + } + + return label; + } + + return fqcn; + } + + /** + * Returns a suitable label to use to display the given theme + * + * @param theme the theme to produce a label for + * @param brief if true, generate a brief label (suitable for a toolbar + * button), otherwise a fuller name (suitable for a menu item) + * @return the label + */ + public static String getThemeLabel(String theme, boolean brief) { + theme = ResourceHelper.styleToTheme(theme); + + if (brief) { + int index = theme.lastIndexOf('.'); + if (index < theme.length() - 1) { + return theme.substring(index + 1); + } + } + return theme; + } + + /** + * Returns a suitable label to use to display the given rendering target + * + * @param target the target to produce a label for + * @param brief if true, generate a brief label (suitable for a toolbar + * button), otherwise a fuller name (suitable for a menu item) + * @return the label + */ + public static String getRenderingTargetLabel(IAndroidTarget target, boolean brief) { + if (target == null) { + return "<null>"; + } + + AndroidVersion version = target.getVersion(); + + if (brief) { + if (target.isPlatform()) { + return Integer.toString(version.getApiLevel()); + } else { + return target.getName() + ':' + Integer.toString(version.getApiLevel()); + } + } + + String label = String.format("API %1$d: %2$s", + version.getApiLevel(), + target.getShortClasspathName()); + + return label; + } + + /** + * Returns a suitable label to use to display the given device + * + * @param device the device to produce a label for + * @param brief if true, generate a brief label (suitable for a toolbar + * button), otherwise a fuller name (suitable for a menu item) + * @return the label + */ + public static String getDeviceLabel(@Nullable Device device, boolean brief) { + if (device == null) { + return ""; + } + String name = device.getName(); + + if (brief) { + // Produce a really brief summary of the device name, suitable for + // use in the narrow space available in the toolbar for example + int nexus = name.indexOf("Nexus"); //$NON-NLS-1$ + if (nexus != -1) { + int begin = name.indexOf('('); + if (begin != -1) { + begin++; + int end = name.indexOf(')', begin); + if (end != -1) { + return name.substring(begin, end).trim(); + } + } + } + } + + return name; + } + + /** + * Returns a suitable label to use to display the given locale + * + * @param chooser the chooser, if known + * @param locale the locale to look up a label for + * @param brief if true, generate a brief label (suitable for a toolbar + * button), otherwise a fuller name (suitable for a menu item) + * @return the label + */ + @Nullable + public static String getLocaleLabel( + @Nullable ConfigurationChooser chooser, + @Nullable Locale locale, + boolean brief) { + if (locale == null) { + return null; + } + + if (!locale.hasLanguage()) { + if (brief) { + // Just use the icon + return ""; + } + + boolean hasLocale = false; + ResourceRepository projectRes = chooser != null ? chooser.mClient.getProjectResources() + : null; + if (projectRes != null) { + hasLocale = projectRes.getLanguages().size() > 0; + } + + if (hasLocale) { + return "Other"; + } else { + return "Any"; + } + } + + String languageCode = locale.qualifier.getLanguage(); + String languageName = LocaleManager.getLanguageName(languageCode); + + if (!locale.hasRegion()) { + // TODO: Make the region string use "Other" instead of "Any" if + // there is more than one region for a given language + //if (regions.size() > 0) { + // return String.format("%1$s / Other", language); + //} else { + // return String.format("%1$s / Any", language); + //} + if (!brief && languageName != null) { + return String.format("%1$s (%2$s)", languageName, languageCode); + } else { + return languageCode; + } + } else { + String regionCode = locale.qualifier.getRegion(); + if (!brief && languageName != null) { + String regionName = LocaleManager.getRegionName(regionCode); + if (regionName != null) { + return String.format("%1$s (%2$s) in %3$s (%4$s)", languageName, languageCode, + regionName, regionCode); + } + return String.format("%1$s (%2$s) in %3$s", languageName, languageCode, + regionCode); + } + return String.format("%1$s / %2$s", languageCode, regionCode); + } + } + + // ---- Implements DevicesChangedListener ---- + + @Override + public void onDevicesChanged() { + final Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + mDevices = sdk.getDeviceManager().getDevices(DeviceManager.ALL_DEVICES); + } else { + mDevices = new ArrayList<Device>(); + } + } + + // ---- Reacting to UI changes ---- + + /** + * Called when the selection of the device combo changes. + */ + void onDeviceChange() { + // because changing the content of a combo triggers a change event, respect the + // mDisableUpdates flag + if (mDisableUpdates > 0) { + return; + } + + // Attempt to preserve the device state + String stateName = null; + Device prevDevice = mConfiguration.getDevice(); + State prevState = mConfiguration.getDeviceState(); + Device device = (Device) mDeviceCombo.getData(); + if (prevDevice != null && prevState != null && device != null) { + // get the previous config, so that we can look for a close match + FolderConfiguration oldConfig = DeviceConfigHelper.getFolderConfig(prevState); + if (oldConfig != null) { + stateName = ConfigurationMatcher.getClosestMatch(oldConfig, device.getAllStates()); + } + } + mConfiguration.setDevice(device, true); + State newState = Configuration.getState(device, stateName); + mConfiguration.setDeviceState(newState, true); + selectDeviceState(newState); + mConfiguration.syncFolderConfig(); + + // Notify + IFile file = mEditedFile; + boolean accepted = mClient.changed(CFG_DEVICE | CFG_DEVICE_STATE); + if (!accepted) { + mConfiguration.setDevice(prevDevice, true); + mConfiguration.setDeviceState(prevState, true); + mConfiguration.syncFolderConfig(); + selectDevice(prevDevice); + selectDeviceState(prevState); + return; + } else { + syncToVariations(CFG_DEVICE | CFG_DEVICE_STATE, file, mConfiguration, false, true); + } + + saveConstraints(); + } + + /** + * Synchronizes changes to the given attributes (indicated by the mask + * referencing the {@code CFG_} configuration attribute bit flags in + * {@link Configuration} to the layout variations of the given updated file. + * + * @param flags the attributes which were updated + * @param updatedFile the file which was updated + * @param base the base configuration to base the chooser off of + * @param includeSelf whether the updated file itself should be updated + * @param async whether the updates should be performed asynchronously + */ + public void syncToVariations( + final int flags, + final @NonNull IFile updatedFile, + final @NonNull Configuration base, + final boolean includeSelf, + boolean async) { + if (async) { + getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + doSyncToVariations(flags, updatedFile, includeSelf, base); + } + }); + } else { + doSyncToVariations(flags, updatedFile, includeSelf, base); + } + } + + private void doSyncToVariations(int flags, IFile updatedFile, boolean includeSelf, + Configuration base) { + // Synchronize the given changes to other configurations as well + List<IFile> files = AdtUtils.getResourceVariations(updatedFile, includeSelf); + for (IFile file : files) { + Configuration configuration = Configuration.create(base, file); + configuration.setTheme(base.getTheme()); + configuration.setActivity(base.getActivity()); + Collection<IEditorPart> editors = AdtUtils.findEditorsFor(file, false); + boolean found = false; + for (IEditorPart editor : editors) { + if (editor instanceof CommonXmlEditor) { + CommonXmlDelegate delegate = ((CommonXmlEditor) editor).getDelegate(); + if (delegate instanceof LayoutEditorDelegate) { + editor = ((LayoutEditorDelegate) delegate).getGraphicalEditor(); + } + } + if (editor instanceof GraphicalEditorPart) { + ConfigurationChooser chooser = + ((GraphicalEditorPart) editor).getConfigurationChooser(); + chooser.setConfiguration(configuration); + found = true; + } + } + if (!found) { + // Just update the file persistence + String description = configuration.toPersistentString(); + ConfigurationDescription.setDescription(file, description); + } + } + } + + /** + * Called when the device config selection changes. + */ + void onDeviceConfigChange() { + // because changing the content of a combo triggers a change event, respect the + // mDisableUpdates flag + if (mDisableUpdates > 0) { + return; + } + + State prev = mConfiguration.getDeviceState(); + State state = (State) mOrientationCombo.getData(); + mConfiguration.setDeviceState(state, false); + + if (mClient != null) { + boolean accepted = mClient.changed(CFG_DEVICE | CFG_DEVICE_STATE); + if (!accepted) { + mConfiguration.setDeviceState(prev, false); + selectDeviceState(prev); + return; + } + } + + saveConstraints(); + } + + /** + * Call back for language combo selection + */ + void onLocaleChange() { + // because mLocaleList triggers onLocaleChange at each modification, the filling + // of the combo with data will trigger notifications, and we don't want that. + if (mDisableUpdates > 0) { + return; + } + + Locale prev = mConfiguration.getLocale(); + Locale locale = (Locale) mLocaleCombo.getData(); + if (locale == null) { + locale = Locale.ANY; + } + mConfiguration.setLocale(locale, false); + + if (mClient != null) { + boolean accepted = mClient.changed(CFG_LOCALE); + if (!accepted) { + mConfiguration.setLocale(prev, false); + selectLocale(prev); + } + } + + // Store locale project-wide setting + mConfiguration.saveRenderState(); + } + + + void onThemeChange() { + if (mDisableUpdates > 0) { + return; + } + + String prev = mConfiguration.getTheme(); + mConfiguration.setTheme((String) mThemeCombo.getData()); + + if (mClient != null) { + boolean accepted = mClient.changed(CFG_THEME); + if (!accepted) { + mConfiguration.setTheme(prev); + selectTheme(prev); + return; + } else { + syncToVariations(CFG_DEVICE|CFG_DEVICE_STATE, mEditedFile, mConfiguration, + false, true); + } + } + + saveConstraints(); + } + + void notifyFolderConfigChanged() { + if (mDisableUpdates > 0 || mClient == null) { + return; + } + + if (mClient.changed(CFG_FOLDER)) { + saveConstraints(); + } + } + + void onSelectActivity() { + if (mDisableUpdates > 0) { + return; + } + + String activity = (String) mActivityCombo.getData(); + mConfiguration.setActivity(activity); + + if (activity == null) { + return; + } + + // See if there is a default theme assigned to this activity, and if so, use it + ManifestInfo manifest = ManifestInfo.get(mEditedFile.getProject()); + String preferred = null; + ActivityAttributes attributes = manifest.getActivityAttributes(activity); + if (attributes != null) { + preferred = attributes.getTheme(); + } + if (preferred != null && !Objects.equal(preferred, mConfiguration.getTheme())) { + // Yes, switch to it + selectTheme(preferred); + onThemeChange(); + } + + // Persist in XML + if (mClient != null) { + mClient.setActivity(activity); + } + + saveConstraints(); + } + + /** + * Call back for api level combo selection + */ + void onRenderingTargetChange() { + // because mApiCombo triggers onApiLevelChange at each modification, the filling + // of the combo with data will trigger notifications, and we don't want that. + if (mDisableUpdates > 0) { + return; + } + + IAndroidTarget prevTarget = mConfiguration.getTarget(); + String prevTheme = mConfiguration.getTheme(); + + int changeFlags = 0; + + // tell the listener a new rendering target is being set. Need to do this before updating + // mRenderingTarget. + if (prevTarget != null) { + changeFlags |= CFG_TARGET; + mClient.aboutToChange(changeFlags); + } + + IAndroidTarget target = (IAndroidTarget) mTargetCombo.getData(); + mConfiguration.setTarget(target, true); + + // force a theme update to reflect the new rendering target. + // This must be done after computeCurrentConfig since it'll depend on the currentConfig + // to figure out the theme list. + String oldTheme = mConfiguration.getTheme(); + updateThemes(); + // updateThemes may change the theme (based on theme availability in the new rendering + // target) so mark theme change if necessary + if (!Objects.equal(oldTheme, mConfiguration.getTheme())) { + changeFlags |= CFG_THEME; + } + + if (target != null) { + changeFlags |= CFG_TARGET; + changeFlags |= CFG_FOLDER; // In case we added a -vNN qualifier + } + + // Store project-wide render-target setting + mConfiguration.saveRenderState(); + + mConfiguration.syncFolderConfig(); + + if (mClient != null) { + boolean accepted = mClient.changed(changeFlags); + if (!accepted) { + mConfiguration.setTarget(prevTarget, true); + mConfiguration.setTheme(prevTheme); + mConfiguration.syncFolderConfig(); + selectTheme(prevTheme); + selectTarget(prevTarget); + } + } + } + + /** + * Syncs this configuration to the project wide locale and render target settings. The + * locale may ignore the project-wide setting if it is a locale-specific + * configuration. + * + * @return true if one or both of the toggles were changed, false if there were no + * changes + */ + public boolean syncRenderState() { + if (mConfiguration.getEditedConfig() == null) { + // Startup; ignore + return false; + } + + boolean renderTargetChanged = false; + + // When a page is re-activated, force the toggles to reflect the current project + // state + + Pair<Locale, IAndroidTarget> pair = Configuration.loadRenderState(this); + + int changeFlags = 0; + // Only sync the locale if this layout is not already a locale-specific layout! + if (pair != null && !mConfiguration.isLocaleSpecificLayout()) { + Locale locale = pair.getFirst(); + if (locale != null) { + boolean localeChanged = setLocale(locale); + if (localeChanged) { + changeFlags |= CFG_LOCALE; + } + } else { + locale = Locale.ANY; + } + mConfiguration.setLocale(locale, true); + } + + // Sync render target + IAndroidTarget configurationTarget = mConfiguration.getTarget(); + IAndroidTarget target = pair != null ? pair.getSecond() : configurationTarget; + if (target != null && configurationTarget != target) { + if (mClient != null && configurationTarget != null) { + changeFlags |= CFG_TARGET; + mClient.aboutToChange(changeFlags); + } + + mConfiguration.setTarget(target, true); + selectTarget(target); + renderTargetChanged = true; + } + + // Neither locale nor render target changed: nothing to do + if (changeFlags == 0) { + return false; + } + + // Update the locale and/or the render target. This code contains a logical + // merge of the onRenderingTargetChange() and onLocaleChange() methods, combined + // such that we don't duplicate work. + + // Compute the new configuration; we want to do this both for locale changes + // and for render targets. + mConfiguration.syncFolderConfig(); + changeFlags |= CFG_FOLDER; // in case we added/remove a -v<NN> qualifier + + if (renderTargetChanged) { + // force a theme update to reflect the new rendering target. + // This must be done after computeCurrentConfig since it'll depend on the currentConfig + // to figure out the theme list. + updateThemes(); + } + + if (mClient != null) { + mClient.changed(changeFlags); + } + + return true; + } + + // ---- Populate data structures with themes, locales, etc ---- + + /** + * Updates the internal list of themes. + */ + private void updateThemes() { + if (mClient == null) { + return; // can't do anything without it. + } + + ResourceRepository frameworkRes = mClient.getFrameworkResources( + mConfiguration.getTarget()); + + mDisableUpdates++; + + try { + if (mEditedFile != null) { + String theme = mConfiguration.getTheme(); + if (theme == null || theme.isEmpty() || mClient.getIncludedWithin() != null) { + mConfiguration.setTheme(null); + mConfiguration.computePreferredTheme(); + } + assert mConfiguration.getTheme() != null; + } + + mThemeList.clear(); + + ArrayList<String> themes = new ArrayList<String>(); + ResourceRepository projectRes = mClient.getProjectResources(); + // in cases where the opened file is not linked to a project, this could be null. + if (projectRes != null) { + // get the configured resources for the project + Map<ResourceType, Map<String, ResourceValue>> configuredProjectRes = + mClient.getConfiguredProjectResources(); + + if (configuredProjectRes != null) { + // get the styles. + Map<String, ResourceValue> styleMap = configuredProjectRes.get( + ResourceType.STYLE); + + if (styleMap != null) { + // collect the themes out of all the styles, ie styles that extend, + // directly or indirectly a platform theme. + for (ResourceValue value : styleMap.values()) { + if (isTheme(value, styleMap, null)) { + String theme = value.getName(); + themes.add(theme); + } + } + + Collections.sort(themes); + + for (String theme : themes) { + if (!theme.startsWith(PREFIX_RESOURCE_REF)) { + theme = STYLE_RESOURCE_PREFIX + theme; + } + mThemeList.add(theme); + } + } + } + themes.clear(); + } + + // get the themes, and languages from the Framework. + if (frameworkRes != null) { + // get the configured resources for the framework + Map<ResourceType, Map<String, ResourceValue>> frameworResources = + frameworkRes.getConfiguredResources(mConfiguration.getFullConfig()); + + if (frameworResources != null) { + // get the styles. + Map<String, ResourceValue> styles = frameworResources.get(ResourceType.STYLE); + + // collect the themes out of all the styles. + for (ResourceValue value : styles.values()) { + String name = value.getName(); + if (name.startsWith("Theme.") || name.equals("Theme")) { //$NON-NLS-1$ //$NON-NLS-2$ + themes.add(value.getName()); + } + } + + // sort them and add them to the combo + Collections.sort(themes); + + for (String theme : themes) { + if (!theme.startsWith(PREFIX_RESOURCE_REF)) { + theme = ANDROID_STYLE_RESOURCE_PREFIX + theme; + } + mThemeList.add(theme); + } + + themes.clear(); + } + } + + // Migration: In the past we didn't store the style prefix in the settings; + // this meant we might lose track of whether the theme is a project style + // or a framework style. For now we need to migrate. Search through the + // theme list until we have a match + String theme = mConfiguration.getTheme(); + if (theme != null && !theme.startsWith(PREFIX_RESOURCE_REF)) { + String projectStyle = STYLE_RESOURCE_PREFIX + theme; + String frameworkStyle = ANDROID_STYLE_RESOURCE_PREFIX + theme; + for (String t : mThemeList) { + if (t.equals(projectStyle)) { + mConfiguration.setTheme(projectStyle); + break; + } else if (t.equals(frameworkStyle)) { + mConfiguration.setTheme(frameworkStyle); + break; + } + } + if (!theme.startsWith(PREFIX_RESOURCE_REF)) { + // Arbitrary guess + if (theme.startsWith("Theme.")) { + theme = ANDROID_STYLE_RESOURCE_PREFIX + theme; + } else { + theme = STYLE_RESOURCE_PREFIX + theme; + } + } + } + + // TODO: Handle the case where you have a theme persisted that isn't available?? + // We could look up mConfiguration.theme and make sure it appears in the list! And if + // not, picking one. + selectTheme(mConfiguration.getTheme()); + } finally { + mDisableUpdates--; + } + } + + private void updateActivity() { + if (mEditedFile != null) { + String preferred = getPreferredActivity(mEditedFile); + selectActivity(preferred); + } + } + + /** + * Updates the locale combo. + * This must be called from the UI thread. + */ + public void updateLocales() { + if (mClient == null) { + return; // can't do anything w/o it. + } + + mDisableUpdates++; + + try { + mLocaleList.clear(); + + SortedSet<String> languages = null; + + // get the languages from the project. + ResourceRepository projectRes = mClient.getProjectResources(); + + // in cases where the opened file is not linked to a project, this could be null. + if (projectRes != null) { + // now get the languages from the project. + languages = projectRes.getLanguages(); + + for (String language : languages) { + // find the matching regions and add them + SortedSet<String> regions = projectRes.getRegions(language); + for (String region : regions) { + LocaleQualifier locale = LocaleQualifier.getQualifier(language + "-r" + region); + if (locale != null) { + mLocaleList.add(Locale.create(locale)); + } + } + + // now the entry for the other regions the language alone + // create a region qualifier that will never be matched by qualified resources. + LocaleQualifier locale = new LocaleQualifier(language); + mLocaleList.add(Locale.create(locale)); + } + } + + // create language/region qualifier that will never be matched by qualified resources. + mLocaleList.add(Locale.ANY); + + Locale locale = mConfiguration.getLocale(); + setLocale(locale); + } finally { + mDisableUpdates--; + } + } + + @Nullable + private String getPreferredActivity(@NonNull IFile file) { + // Store/restore the activity context in the config state to help with + // performance if for some reason we can't write it into the XML file and to + // avoid having to open the model below + if (mConfiguration.getActivity() != null) { + return mConfiguration.getActivity(); + } + + IProject project = file.getProject(); + + // Look up from XML file + Document document = DomUtilities.getDocument(file); + if (document != null) { + Element element = document.getDocumentElement(); + if (element != null) { + String activity = element.getAttributeNS(TOOLS_URI, ATTR_CONTEXT); + if (activity != null && !activity.isEmpty()) { + if (activity.startsWith(".") || activity.indexOf('.') == -1) { //$NON-NLS-1$ + ManifestInfo manifest = ManifestInfo.get(project); + String pkg = manifest.getPackage(); + if (!pkg.isEmpty()) { + if (activity.startsWith(".")) { //$NON-NLS-1$ + activity = pkg + activity; + } else { + activity = activity + '.' + pkg; + } + } + } + + mConfiguration.setActivity(activity); + saveConstraints(); + return activity; + } + } + } + + // No, not available there: try to infer it from the code index + String includedIn = null; + Reference includedWithin = mClient.getIncludedWithin(); + if (mClient != null && includedWithin != null) { + includedIn = includedWithin.getName(); + } + + ManifestInfo manifest = ManifestInfo.get(project); + String pkg = manifest.getPackage(); + String layoutName = ResourceHelper.getLayoutName(mEditedFile); + + // If we are rendering a layout in included context, pick the theme + // from the outer layout instead + if (includedIn != null) { + layoutName = includedIn; + } + + String activity = ManifestInfo.guessActivity(project, layoutName, pkg); + + if (activity == null) { + List<String> activities = ManifestInfo.getProjectActivities(project); + if (activities.size() == 1) { + activity = activities.get(0); + } + } + + if (activity != null) { + mConfiguration.setActivity(activity); + saveConstraints(); + return activity; + } + + // TODO: Do anything else, such as pick the first activity found? + // Or just leave some default label instead? + // Also, figure out what to store in the mState so I don't keep trying + + return null; + } + + /** + * Returns whether the given <var>style</var> is a theme. + * This is done by making sure the parent is a theme. + * @param value the style to check + * @param styleMap the map of styles for the current project. Key is the style name. + * @param seen the map of styles we have already processed (or null if not yet + * initialized). Only the keys are significant (since there is no IdentityHashSet). + * @return True if the given <var>style</var> is a theme. + */ + private static boolean isTheme(ResourceValue value, Map<String, ResourceValue> styleMap, + IdentityHashMap<ResourceValue, Boolean> seen) { + if (value instanceof StyleResourceValue) { + StyleResourceValue style = (StyleResourceValue)value; + + boolean frameworkStyle = false; + String parentStyle = style.getParentStyle(); + if (parentStyle == null) { + // if there is no specified parent style we look an implied one. + // For instance 'Theme.light' is implied child style of 'Theme', + // and 'Theme.light.fullscreen' is implied child style of 'Theme.light' + String name = style.getName(); + int index = name.lastIndexOf('.'); + if (index != -1) { + parentStyle = name.substring(0, index); + } + } else { + // remove the useless @ if it's there + if (parentStyle.startsWith("@")) { + parentStyle = parentStyle.substring(1); + } + + // check for framework identifier. + if (parentStyle.startsWith(ANDROID_NS_NAME_PREFIX)) { + frameworkStyle = true; + parentStyle = parentStyle.substring(ANDROID_NS_NAME_PREFIX.length()); + } + + // at this point we could have the format style/<name>. we want only the name + if (parentStyle.startsWith("style/")) { + parentStyle = parentStyle.substring("style/".length()); + } + } + + if (parentStyle != null) { + if (frameworkStyle) { + // if the parent is a framework style, it has to be 'Theme' or 'Theme.*' + return parentStyle.equals("Theme") || parentStyle.startsWith("Theme."); + } else { + // if it's a project style, we check this is a theme. + ResourceValue parentValue = styleMap.get(parentStyle); + + // also prevent stack overflow in case the dev mistakenly declared + // the parent of the style as the style itself. + if (parentValue != null && !parentValue.equals(value)) { + if (seen == null) { + seen = new IdentityHashMap<ResourceValue, Boolean>(); + seen.put(value, Boolean.TRUE); + } else if (seen.containsKey(parentValue)) { + return false; + } + seen.put(parentValue, Boolean.TRUE); + return isTheme(parentValue, styleMap, seen); + } + } + } + } + + return false; + } + + /** + * Returns true if this configuration chooser represents the best match for + * the given file + * + * @param file the file to test + * @param config the config to test + * @return true if the given config is the best match for the given file + */ + public boolean isBestMatchFor(IFile file, FolderConfiguration config) { + ResourceFile match = mResources.getMatchingFile(mEditedFile.getName(), + ResourceType.LAYOUT, config); + if (match != null) { + return match.getFile().equals(mEditedFile); + } + + return false; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationClient.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationClient.java new file mode 100644 index 000000000..3df2feda3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationClient.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.resources.ResourceType; +import com.android.sdklib.IAndroidTarget; + +import java.util.Map; + +/** + * Interface implemented by clients who embed a {@link ConfigurationChooser}. + */ +public interface ConfigurationClient { + /** + * The configuration is about to be changed. + * + * @param flags details about what changed; consult the {@code CFG_} flags + * in {@link Configuration} such as + * {@link Configuration#CFG_DEVICE}, + * {@link Configuration#CFG_LOCALE}, etc. + */ + void aboutToChange(int flags); + + /** + * The configuration has changed. If the client returns false, it means that + * the change was rejected. This typically means that changing the + * configuration in this particular way makes a configuration which has a + * better file match than the current client's file, so it will open that + * file to edit the new configuration -- and the current configuration + * should go back to editing the state prior to this change. + * + * @param flags details about what changed; consult the {@code CFG_} flags + * such as {@link Configuration#CFG_DEVICE}, + * {@link Configuration#CFG_LOCALE}, etc. + * @return true if the change was accepted, false if it was rejected. + */ + boolean changed(int flags); + + /** + * Compute the project resources + * + * @return the project resources as a {@link ResourceRepository} + */ + @Nullable + ResourceRepository getProjectResources(); + + /** + * Compute the framework resources + * + * @return the project resources as a {@link ResourceRepository} + */ + @Nullable + ResourceRepository getFrameworkResources(); + + /** + * Compute the framework resources for the given Android API target + * + * @param target the target to look up framework resources for + * @return the project resources as a {@link ResourceRepository} + */ + @Nullable + ResourceRepository getFrameworkResources(@Nullable IAndroidTarget target); + + /** + * Returns the configured project resources for the current file and + * configuration + * + * @return resource type maps to names to resource values + */ + @NonNull + Map<ResourceType, Map<String, ResourceValue>> getConfiguredProjectResources(); + + /** + * Returns the configured framework resources for the current file and + * configuration + * + * @return resource type maps to names to resource values + */ + @NonNull + Map<ResourceType, Map<String, ResourceValue>> getConfiguredFrameworkResources(); + + /** + * If the current layout is an included layout rendered within an outer layout, + * returns the outer layout. + * + * @return the outer including layout, or null + */ + @Nullable + Reference getIncludedWithin(); + + /** + * Called when the "Create" button is clicked. + */ + void createConfigFile(); + + /** + * Called when an associated activity is picked + * + * @param fqcn the fully qualified class name for the associated activity context + */ + void setActivity(@NonNull String fqcn); + + /** + * Returns the associated layout canvas, if any + * + * @return the canvas, if any + */ + @Nullable + LayoutCanvas getCanvas(); +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationDescription.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationDescription.java new file mode 100644 index 000000000..956ac1839 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationDescription.java @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_NAME; +import static com.android.SdkConstants.ATTR_THEME; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.configuration.DeviceConfigHelper; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.resources.configuration.ScreenSizeQualifier; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo.ActivityAttributes; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.NightMode; +import com.android.resources.ResourceFolderType; +import com.android.resources.ScreenSize; +import com.android.resources.UiMode; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.State; +import com.google.common.base.Splitter; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.QualifiedName; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.util.Collection; +import java.util.List; + +/** A description of a configuration, used for persistence */ +public class ConfigurationDescription { + private static final String TAG_PREVIEWS = "previews"; //$NON-NLS-1$ + private static final String TAG_PREVIEW = "preview"; //$NON-NLS-1$ + private static final String ATTR_TARGET = "target"; //$NON-NLS-1$ + private static final String ATTR_CONFIG = "config"; //$NON-NLS-1$ + private static final String ATTR_LOCALE = "locale"; //$NON-NLS-1$ + private static final String ATTR_ACTIVITY = "activity"; //$NON-NLS-1$ + private static final String ATTR_DEVICE = "device"; //$NON-NLS-1$ + private static final String ATTR_STATE = "devicestate"; //$NON-NLS-1$ + private static final String ATTR_UIMODE = "ui"; //$NON-NLS-1$ + private static final String ATTR_NIGHTMODE = "night"; //$NON-NLS-1$ + private final static String SEP_LOCALE = "-"; //$NON-NLS-1$ + + /** + * Settings name for file-specific configuration preferences, such as which theme or + * device to render the current layout with + */ + public final static QualifiedName NAME_CONFIG_STATE = + new QualifiedName(AdtPlugin.PLUGIN_ID, "state");//$NON-NLS-1$ + + /** The project corresponding to this configuration's description */ + public final IProject project; + + /** The display name */ + public String displayName; + + /** The theme */ + public String theme; + + /** The target */ + public IAndroidTarget target; + + /** The display name */ + public FolderConfiguration folder; + + /** The locale */ + public Locale locale = Locale.ANY; + + /** The device */ + public Device device; + + /** The device state */ + public State state; + + /** The activity */ + public String activity; + + /** UI mode */ + @NonNull + public UiMode uiMode = UiMode.NORMAL; + + /** Night mode */ + @NonNull + public NightMode nightMode = NightMode.NOTNIGHT; + + private ConfigurationDescription(@Nullable IProject project) { + this.project = project; + } + + /** + * Returns the persistent configuration description from the given file + * + * @param file the file to look up a description from + * @return the description or null if never written + */ + @Nullable + public static String getDescription(@NonNull IFile file) { + return AdtPlugin.getFileProperty(file, NAME_CONFIG_STATE); + } + + /** + * Sets the persistent configuration description data for the given file + * + * @param file the file to associate the description with + * @param description the description + */ + public static void setDescription(@NonNull IFile file, @NonNull String description) { + AdtPlugin.setFileProperty(file, NAME_CONFIG_STATE, description); + } + + /** + * Creates a description from a given configuration + * + * @param project the project for this configuration's description + * @param configuration the configuration to describe + * @return a new configuration + */ + public static ConfigurationDescription fromConfiguration( + @Nullable IProject project, + @NonNull Configuration configuration) { + ConfigurationDescription description = new ConfigurationDescription(project); + description.displayName = configuration.getDisplayName(); + description.theme = configuration.getTheme(); + description.target = configuration.getTarget(); + description.folder = new FolderConfiguration(); + description.folder.set(configuration.getFullConfig()); + description.locale = configuration.getLocale(); + description.device = configuration.getDevice(); + description.state = configuration.getDeviceState(); + description.activity = configuration.getActivity(); + return description; + } + + /** + * Initializes a string previously created with + * {@link #toXml(Document)} + * + * @param project the project for this configuration's description + * @param element the element to read back from + * @param deviceList list of available devices + * @return true if the configuration was initialized + */ + @Nullable + public static ConfigurationDescription fromXml( + @Nullable IProject project, + @NonNull Element element, + @NonNull Collection<Device> deviceList) { + ConfigurationDescription description = new ConfigurationDescription(project); + + if (!TAG_PREVIEW.equals(element.getTagName())) { + return null; + } + + String displayName = element.getAttribute(ATTR_NAME); + if (!displayName.isEmpty()) { + description.displayName = displayName; + } + + String config = element.getAttribute(ATTR_CONFIG); + Iterable<String> segments = Splitter.on('-').split(config); + description.folder = FolderConfiguration.getConfig(segments); + + String theme = element.getAttribute(ATTR_THEME); + if (!theme.isEmpty()) { + description.theme = theme; + } + + String targetId = element.getAttribute(ATTR_TARGET); + if (!targetId.isEmpty()) { + IAndroidTarget target = Configuration.stringToTarget(targetId); + description.target = target; + } + + String localeString = element.getAttribute(ATTR_LOCALE); + if (!localeString.isEmpty()) { + // Load locale. Note that this can get overwritten by the + // project-wide settings read below. + String locales[] = localeString.split(SEP_LOCALE); + if (locales[0].length() > 0 && !LocaleQualifier.FAKE_VALUE.equals(locales[0])) { + String language = locales[0]; + if (locales.length >= 2 && locales[1].length() > 0 && !LocaleQualifier.FAKE_VALUE.equals(locales[1])) { + description.locale = Locale.create(LocaleQualifier.getQualifier(language + "-r" + locales[1])); + } else { + description.locale = Locale.create(new LocaleQualifier(language)); + } + } else { + description.locale = Locale.ANY; + } + + + } + + String activity = element.getAttribute(ATTR_ACTIVITY); + if (activity.isEmpty()) { + activity = null; + } + + String deviceString = element.getAttribute(ATTR_DEVICE); + if (!deviceString.isEmpty()) { + for (Device d : deviceList) { + if (d.getName().equals(deviceString)) { + description.device = d; + String stateName = element.getAttribute(ATTR_STATE); + if (stateName.isEmpty() || stateName.equals("null")) { + description.state = Configuration.getState(d, stateName); + } else if (d.getAllStates().size() > 0) { + description.state = d.getAllStates().get(0); + } + break; + } + } + } + + String uiModeString = element.getAttribute(ATTR_UIMODE); + if (!uiModeString.isEmpty()) { + description.uiMode = UiMode.getEnum(uiModeString); + if (description.uiMode == null) { + description.uiMode = UiMode.NORMAL; + } + } + + String nightModeString = element.getAttribute(ATTR_NIGHTMODE); + if (!nightModeString.isEmpty()) { + description.nightMode = NightMode.getEnum(nightModeString); + if (description.nightMode == null) { + description.nightMode = NightMode.NOTNIGHT; + } + } + + + // Should I really be storing the FULL configuration? Might be trouble if + // you bring a different device + + return description; + } + + /** + * Write this description into the given document as a new element. + * + * @param document the document to add the description to + * @return the newly inserted element + */ + @NonNull + public Element toXml(Document document) { + Element element = document.createElement(TAG_PREVIEW); + + element.setAttribute(ATTR_NAME, displayName); + FolderConfiguration fullConfig = folder; + String folderName = fullConfig.getFolderName(ResourceFolderType.LAYOUT); + element.setAttribute(ATTR_CONFIG, folderName); + if (theme != null) { + element.setAttribute(ATTR_THEME, theme); + } + if (target != null) { + element.setAttribute(ATTR_TARGET, Configuration.targetToString(target)); + } + + if (locale != null && (locale.hasLanguage() || locale.hasRegion())) { + String value; + if (locale.hasRegion()) { + value = locale.qualifier.getLanguage() + SEP_LOCALE + locale.qualifier.getRegion(); + } else { + value = locale.qualifier.getLanguage(); + } + element.setAttribute(ATTR_LOCALE, value); + } + + if (device != null) { + element.setAttribute(ATTR_DEVICE, device.getName()); + if (state != null) { + element.setAttribute(ATTR_STATE, state.getName()); + } + } + + if (activity != null) { + element.setAttribute(ATTR_ACTIVITY, activity); + } + + if (uiMode != null && uiMode != UiMode.NORMAL) { + element.setAttribute(ATTR_UIMODE, uiMode.getResourceValue()); + } + + if (nightMode != null && nightMode != NightMode.NOTNIGHT) { + element.setAttribute(ATTR_NIGHTMODE, nightMode.getResourceValue()); + } + + Element parent = document.getDocumentElement(); + if (parent == null) { + parent = document.createElement(TAG_PREVIEWS); + document.appendChild(parent); + } + parent.appendChild(element); + + return element; + } + + /** Returns the preferred theme, or null */ + @Nullable + String computePreferredTheme() { + if (project == null) { + return "Theme"; + } + ManifestInfo manifest = ManifestInfo.get(project); + + // Look up the screen size for the current state + ScreenSize screenSize = null; + if (device != null) { + List<State> states = device.getAllStates(); + for (State s : states) { + FolderConfiguration folderConfig = DeviceConfigHelper.getFolderConfig(s); + if (folderConfig != null) { + ScreenSizeQualifier qualifier = folderConfig.getScreenSizeQualifier(); + screenSize = qualifier.getValue(); + break; + } + } + } + + // Look up the default/fallback theme to use for this project (which + // depends on the screen size when no particular theme is specified + // in the manifest) + String defaultTheme = manifest.getDefaultTheme(target, screenSize); + + String preferred = defaultTheme; + if (theme == null) { + // If we are rendering a layout in included context, pick the theme + // from the outer layout instead + + if (activity != null) { + ActivityAttributes attributes = manifest.getActivityAttributes(activity); + if (attributes != null) { + preferred = attributes.getTheme(); + } + } + if (preferred == null) { + preferred = defaultTheme; + } + theme = preferred; + } + + return preferred; + } + + private void checkThemePrefix() { + if (theme != null && !theme.startsWith(PREFIX_RESOURCE_REF)) { + if (theme.isEmpty()) { + computePreferredTheme(); + return; + } + + if (target != null) { + Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + AndroidTargetData data = sdk.getTargetData(target); + + if (data != null) { + ResourceRepository resources = data.getFrameworkResources(); + if (resources != null + && resources.hasResourceItem(ANDROID_STYLE_RESOURCE_PREFIX + theme)) { + theme = ANDROID_STYLE_RESOURCE_PREFIX + theme; + return; + } + } + } + } + + theme = STYLE_RESOURCE_PREFIX + theme; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationMatcher.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationMatcher.java new file mode 100644 index 000000000..9724d4015 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationMatcher.java @@ -0,0 +1,843 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.ResourceFile; +import com.android.ide.common.resources.configuration.DensityQualifier; +import com.android.ide.common.resources.configuration.DeviceConfigHelper; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.resources.configuration.NightModeQualifier; +import com.android.ide.common.resources.configuration.ResourceQualifier; +import com.android.ide.common.resources.configuration.ScreenOrientationQualifier; +import com.android.ide.common.resources.configuration.ScreenSizeQualifier; +import com.android.ide.common.resources.configuration.UiModeQualifier; +import com.android.ide.common.resources.configuration.VersionQualifier; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.ide.eclipse.adt.io.IFileWrapper; +import com.android.resources.Density; +import com.android.resources.NightMode; +import com.android.resources.ResourceType; +import com.android.resources.ScreenOrientation; +import com.android.resources.ScreenSize; +import com.android.resources.UiMode; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.State; +import com.android.sdklib.repository.PkgProps; +import com.android.utils.Pair; +import com.android.utils.SparseIntArray; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.ui.IEditorPart; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Produces matches for configurations + * <p> + * See algorithm described here: + * http://developer.android.com/guide/topics/resources/providing-resources.html + */ +public class ConfigurationMatcher { + private static final boolean PREFER_RECENT_RENDER_TARGETS = true; + + private final ConfigurationChooser mConfigChooser; + private final Configuration mConfiguration; + private final IFile mEditedFile; + private final ProjectResources mResources; + private final boolean mUpdateUi; + + ConfigurationMatcher(ConfigurationChooser chooser) { + this(chooser, chooser.getConfiguration(), chooser.getEditedFile(), + chooser.getResources(), true); + } + + ConfigurationMatcher( + @NonNull ConfigurationChooser chooser, + @NonNull Configuration configuration, + @Nullable IFile editedFile, + @Nullable ProjectResources resources, + boolean updateUi) { + mConfigChooser = chooser; + mConfiguration = configuration; + mEditedFile = editedFile; + mResources = resources; + mUpdateUi = updateUi; + } + + // ---- Finding matching configurations ---- + + private static class ConfigBundle { + private final FolderConfiguration config; + private int localeIndex; + private int dockModeIndex; + private int nightModeIndex; + + private ConfigBundle() { + config = new FolderConfiguration(); + } + + private ConfigBundle(ConfigBundle bundle) { + config = new FolderConfiguration(); + config.set(bundle.config); + localeIndex = bundle.localeIndex; + dockModeIndex = bundle.dockModeIndex; + nightModeIndex = bundle.nightModeIndex; + } + } + + private static class ConfigMatch { + final FolderConfiguration testConfig; + final Device device; + final State state; + final ConfigBundle bundle; + + public ConfigMatch(@NonNull FolderConfiguration testConfig, @NonNull Device device, + @NonNull State state, @NonNull ConfigBundle bundle) { + this.testConfig = testConfig; + this.device = device; + this.state = state; + this.bundle = bundle; + } + + @Override + public String toString() { + return device.getName() + " - " + state.getName(); + } + } + + /** + * Checks whether the current edited file is the best match for a given config. + * <p> + * This tests against other versions of the same layout in the project. + * <p> + * The given config must be compatible with the current edited file. + * @param config the config to test. + * @return true if the current edited file is the best match in the project for the + * given config. + */ + public boolean isCurrentFileBestMatchFor(FolderConfiguration config) { + ResourceFile match = mResources.getMatchingFile(mEditedFile.getName(), + ResourceType.LAYOUT, config); + + if (match != null) { + return match.getFile().equals(mEditedFile); + } else { + // if we stop here that means the current file is not even a match! + AdtPlugin.log(IStatus.ERROR, "Current file is not a match for the given config."); + } + + return false; + } + + /** + * Adapts the current device/config selection so that it's compatible with + * the configuration. + * <p> + * If the current selection is compatible, nothing is changed. + * <p> + * If it's not compatible, configs from the current devices are tested. + * <p> + * If none are compatible, it reverts to + * {@link #findAndSetCompatibleConfig(boolean)} + */ + void adaptConfigSelection(boolean needBestMatch) { + // check the device config (ie sans locale) + boolean needConfigChange = true; // if still true, we need to find another config. + boolean currentConfigIsCompatible = false; + State selectedState = mConfiguration.getDeviceState(); + FolderConfiguration editedConfig = mConfiguration.getEditedConfig(); + if (selectedState != null) { + FolderConfiguration currentConfig = DeviceConfigHelper.getFolderConfig(selectedState); + if (currentConfig != null && editedConfig.isMatchFor(currentConfig)) { + currentConfigIsCompatible = true; // current config is compatible + if (!needBestMatch || isCurrentFileBestMatchFor(currentConfig)) { + needConfigChange = false; + } + } + } + + if (needConfigChange) { + List<Locale> localeList = mConfigChooser.getLocaleList(); + + // if the current state/locale isn't a correct match, then + // look for another state/locale in the same device. + FolderConfiguration testConfig = new FolderConfiguration(); + + // first look in the current device. + State matchState = null; + int localeIndex = -1; + Device device = mConfiguration.getDevice(); + if (device != null) { + mainloop: for (State state : device.getAllStates()) { + testConfig.set(DeviceConfigHelper.getFolderConfig(state)); + + // loop on the locales. + for (int i = 0 ; i < localeList.size() ; i++) { + Locale locale = localeList.get(i); + + // update the test config with the locale qualifiers + testConfig.setLocaleQualifier(locale.qualifier); + + + if (editedConfig.isMatchFor(testConfig) && + isCurrentFileBestMatchFor(testConfig)) { + matchState = state; + localeIndex = i; + break mainloop; + } + } + } + } + + if (matchState != null) { + mConfiguration.setDeviceState(matchState, true); + Locale locale = localeList.get(localeIndex); + mConfiguration.setLocale(locale, true); + if (mUpdateUi) { + mConfigChooser.selectDeviceState(matchState); + mConfigChooser.selectLocale(locale); + } + mConfiguration.syncFolderConfig(); + } else { + // no match in current device with any state/locale + // attempt to find another device that can display this + // particular state. + findAndSetCompatibleConfig(currentConfigIsCompatible); + } + } + } + + /** + * Finds a device/config that can display a configuration. + * <p> + * Once found the device and config combos are set to the config. + * <p> + * If there is no compatible configuration, a custom one is created. + * + * @param favorCurrentConfig if true, and no best match is found, don't + * change the current config. This must only be true if the + * current config is compatible. + */ + void findAndSetCompatibleConfig(boolean favorCurrentConfig) { + List<Locale> localeList = mConfigChooser.getLocaleList(); + Collection<Device> devices = mConfigChooser.getDevices(); + FolderConfiguration editedConfig = mConfiguration.getEditedConfig(); + FolderConfiguration currentConfig = mConfiguration.getFullConfig(); + + // list of compatible device/state/locale + List<ConfigMatch> anyMatches = new ArrayList<ConfigMatch>(); + + // list of actual best match (ie the file is a best match for the + // device/state) + List<ConfigMatch> bestMatches = new ArrayList<ConfigMatch>(); + + // get a locale that match the host locale roughly (may not be exact match on the region.) + int localeHostMatch = getLocaleMatch(); + + // build a list of combinations of non standard qualifiers to add to each device's + // qualifier set when testing for a match. + // These qualifiers are: locale, night-mode, car dock. + List<ConfigBundle> configBundles = new ArrayList<ConfigBundle>(200); + + // If the edited file has locales, then we have to select a matching locale from + // the list. + // However, if it doesn't, we don't randomly take the first locale, we take one + // matching the current host locale (making sure it actually exist in the project) + int start, max; + if (editedConfig.getLocaleQualifier() != null || localeHostMatch == -1) { + // add all the locales + start = 0; + max = localeList.size(); + } else { + // only add the locale host match + start = localeHostMatch; + max = localeHostMatch + 1; // test is < + } + + for (int i = start ; i < max ; i++) { + Locale l = localeList.get(i); + + ConfigBundle bundle = new ConfigBundle(); + bundle.config.setLocaleQualifier(l.qualifier); + + bundle.localeIndex = i; + configBundles.add(bundle); + } + + // add the dock mode to the bundle combinations. + addDockModeToBundles(configBundles); + + // add the night mode to the bundle combinations. + addNightModeToBundles(configBundles); + + addRenderTargetToBundles(configBundles); + + for (Device device : devices) { + for (State state : device.getAllStates()) { + + // loop on the list of config bundles to create full + // configurations. + FolderConfiguration stateConfig = DeviceConfigHelper.getFolderConfig(state); + for (ConfigBundle bundle : configBundles) { + // create a new config with device config + FolderConfiguration testConfig = new FolderConfiguration(); + testConfig.set(stateConfig); + + // add on top of it, the extra qualifiers from the bundle + testConfig.add(bundle.config); + + if (editedConfig.isMatchFor(testConfig)) { + // this is a basic match. record it in case we don't + // find a match + // where the edited file is a best config. + anyMatches.add(new ConfigMatch(testConfig, device, state, bundle)); + + if (isCurrentFileBestMatchFor(testConfig)) { + // this is what we want. + bestMatches.add(new ConfigMatch(testConfig, device, state, bundle)); + } + } + } + } + } + + if (bestMatches.size() == 0) { + if (favorCurrentConfig) { + // quick check + if (!editedConfig.isMatchFor(currentConfig)) { + AdtPlugin.log(IStatus.ERROR, + "favorCurrentConfig can only be true if the current config is compatible"); + } + + // just display the warning + AdtPlugin.printErrorToConsole(mEditedFile.getProject(), + String.format( + "'%1$s' is not a best match for any device/locale combination.", + editedConfig.toDisplayString()), + String.format( + "Displaying it with '%1$s'", + currentConfig.toDisplayString())); + } else if (anyMatches.size() > 0) { + // select the best device anyway. + ConfigMatch match = selectConfigMatch(anyMatches); + mConfiguration.setDevice(match.device, true); + mConfiguration.setDeviceState(match.state, true); + mConfiguration.setLocale(localeList.get(match.bundle.localeIndex), true); + mConfiguration.setUiMode(UiMode.getByIndex(match.bundle.dockModeIndex), true); + mConfiguration.setNightMode(NightMode.getByIndex(match.bundle.nightModeIndex), + true); + + if (mUpdateUi) { + mConfigChooser.selectDevice(mConfiguration.getDevice()); + mConfigChooser.selectDeviceState(mConfiguration.getDeviceState()); + mConfigChooser.selectLocale(mConfiguration.getLocale()); + } + + mConfiguration.syncFolderConfig(); + + // TODO: display a better warning! + AdtPlugin.printErrorToConsole(mEditedFile.getProject(), + String.format( + "'%1$s' is not a best match for any device/locale combination.", + editedConfig.toDisplayString()), + String.format( + "Displaying it with '%1$s' which is compatible, but will " + + "actually be displayed with another more specific version of " + + "the layout.", + currentConfig.toDisplayString())); + + } else { + // TODO: there is no device/config able to display the layout, create one. + // For the base config values, we'll take the first device and state, + // and replace whatever qualifier required by the layout file. + } + } else { + ConfigMatch match = selectConfigMatch(bestMatches); + mConfiguration.setDevice(match.device, true); + mConfiguration.setDeviceState(match.state, true); + mConfiguration.setLocale(localeList.get(match.bundle.localeIndex), true); + mConfiguration.setUiMode(UiMode.getByIndex(match.bundle.dockModeIndex), true); + mConfiguration.setNightMode(NightMode.getByIndex(match.bundle.nightModeIndex), true); + + mConfiguration.syncFolderConfig(); + + if (mUpdateUi) { + mConfigChooser.selectDevice(mConfiguration.getDevice()); + mConfigChooser.selectDeviceState(mConfiguration.getDeviceState()); + mConfigChooser.selectLocale(mConfiguration.getLocale()); + } + } + } + + private void addRenderTargetToBundles(List<ConfigBundle> configBundles) { + Pair<Locale, IAndroidTarget> state = Configuration.loadRenderState(mConfigChooser); + if (state != null) { + IAndroidTarget target = state.getSecond(); + if (target != null) { + int apiLevel = target.getVersion().getApiLevel(); + for (ConfigBundle bundle : configBundles) { + bundle.config.setVersionQualifier( + new VersionQualifier(apiLevel)); + } + } + } + } + + private void addDockModeToBundles(List<ConfigBundle> addConfig) { + ArrayList<ConfigBundle> list = new ArrayList<ConfigBundle>(); + + // loop on each item and for each, add all variations of the dock modes + for (ConfigBundle bundle : addConfig) { + int index = 0; + for (UiMode mode : UiMode.values()) { + ConfigBundle b = new ConfigBundle(bundle); + b.config.setUiModeQualifier(new UiModeQualifier(mode)); + b.dockModeIndex = index++; + list.add(b); + } + } + + addConfig.clear(); + addConfig.addAll(list); + } + + private void addNightModeToBundles(List<ConfigBundle> addConfig) { + ArrayList<ConfigBundle> list = new ArrayList<ConfigBundle>(); + + // loop on each item and for each, add all variations of the night modes + for (ConfigBundle bundle : addConfig) { + int index = 0; + for (NightMode mode : NightMode.values()) { + ConfigBundle b = new ConfigBundle(bundle); + b.config.setNightModeQualifier(new NightModeQualifier(mode)); + b.nightModeIndex = index++; + list.add(b); + } + } + + addConfig.clear(); + addConfig.addAll(list); + } + + private int getLocaleMatch() { + java.util.Locale defaultLocale = java.util.Locale.getDefault(); + if (defaultLocale != null) { + String currentLanguage = defaultLocale.getLanguage(); + String currentRegion = defaultLocale.getCountry(); + + List<Locale> localeList = mConfigChooser.getLocaleList(); + final int count = localeList.size(); + for (int l = 0; l < count; l++) { + Locale locale = localeList.get(l); + LocaleQualifier qualifier = locale.qualifier; + + // there's always a ##/Other or ##/Any (which is the same, the region + // contains FAKE_REGION_VALUE). If we don't find a perfect region match + // we take the fake region. Since it's last in the list, this makes the + // test easy. + if (qualifier.getLanguage().equals(currentLanguage) && + (qualifier.getRegion() == null || qualifier.getRegion().equals(currentRegion))) { + return l; + } + } + + // if no locale match the current local locale, it's likely that it is + // the default one which is the last one. + return count - 1; + } + + return -1; + } + + private ConfigMatch selectConfigMatch(List<ConfigMatch> matches) { + // API 11-13: look for a x-large device + Comparator<ConfigMatch> comparator = null; + Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + IAndroidTarget projectTarget = sdk.getTarget(mEditedFile.getProject()); + if (projectTarget != null) { + int apiLevel = projectTarget.getVersion().getApiLevel(); + if (apiLevel >= 11 && apiLevel < 14) { + // TODO: Maybe check the compatible-screen tag in the manifest to figure out + // what kind of device should be used for display. + comparator = new TabletConfigComparator(); + } + } + } + if (comparator == null) { + // lets look for a high density device + comparator = new PhoneConfigComparator(); + } + Collections.sort(matches, comparator); + + // Look at the currently active editor to see if it's a layout editor, and if so, + // look up its configuration and if the configuration is in our match list, + // use it. This means we "preserve" the current configuration when you open + // new layouts. + IEditorPart activeEditor = AdtUtils.getActiveEditor(); + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(activeEditor); + if (delegate != null + // (Only do this when the two files are in the same project) + && delegate.getEditor().getProject() == mEditedFile.getProject()) { + FolderConfiguration configuration = delegate.getGraphicalEditor().getConfiguration(); + if (configuration != null) { + for (ConfigMatch match : matches) { + if (configuration.equals(match.testConfig)) { + return match; + } + } + } + } + + // the list has been sorted so that the first item is the best config + return matches.get(0); + } + + /** Return the default render target to use, or null if no strong preference */ + @Nullable + static IAndroidTarget findDefaultRenderTarget(ConfigurationChooser chooser) { + if (PREFER_RECENT_RENDER_TARGETS) { + // Use the most recent target + List<IAndroidTarget> targetList = chooser.getTargetList(); + if (!targetList.isEmpty()) { + return targetList.get(targetList.size() - 1); + } + } + + IProject project = chooser.getProject(); + // Default to layoutlib version 5 + Sdk current = Sdk.getCurrent(); + if (current != null) { + IAndroidTarget projectTarget = current.getTarget(project); + int minProjectApi = Integer.MAX_VALUE; + if (projectTarget != null) { + if (!projectTarget.isPlatform() && projectTarget.hasRenderingLibrary()) { + // Renderable non-platform targets are all going to be adequate (they + // will have at least version 5 of layoutlib) so use the project + // target as the render target. + return projectTarget; + } + + if (projectTarget.getVersion().isPreview() + && projectTarget.hasRenderingLibrary()) { + // If the project target is a preview version, then just use it + return projectTarget; + } + + minProjectApi = projectTarget.getVersion().getApiLevel(); + } + + // We want to pick a render target that contains at least version 5 (and + // preferably version 6) of the layout library. To do this, we go through the + // targets and pick the -smallest- API level that is both simultaneously at + // least as big as the project API level, and supports layoutlib level 5+. + IAndroidTarget best = null; + int bestApiLevel = Integer.MAX_VALUE; + + for (IAndroidTarget target : current.getTargets()) { + // Non-platform targets are not chosen as the default render target + if (!target.isPlatform()) { + continue; + } + + int apiLevel = target.getVersion().getApiLevel(); + + // Ignore targets that have a lower API level than the minimum project + // API level: + if (apiLevel < minProjectApi) { + continue; + } + + // Look up the layout lib API level. This property is new so it will only + // be defined for version 6 or higher, which means non-null is adequate + // to see if this target is eligible: + String property = target.getProperty(PkgProps.LAYOUTLIB_API); + // In addition, Android 3.0 with API level 11 had version 5.0 which is adequate: + if (property != null || apiLevel >= 11) { + if (apiLevel < bestApiLevel) { + bestApiLevel = apiLevel; + best = target; + } + } + } + + return best; + } + + return null; + } + + /** + * Attempts to find a close state among a list + * + * @param oldConfig the reference config. + * @param states the list of states to search through + * @return the name of the closest state match, or possibly null if no states are compatible + * (this can only happen if the states don't have a single qualifier that is the same). + */ + @Nullable + static String getClosestMatch(@NonNull FolderConfiguration oldConfig, + @NonNull List<State> states) { + + // create 2 lists as we're going to go through one and put the + // candidates in the other. + List<State> list1 = new ArrayList<State>(states.size()); + List<State> list2 = new ArrayList<State>(states.size()); + + list1.addAll(states); + + final int count = FolderConfiguration.getQualifierCount(); + for (int i = 0 ; i < count ; i++) { + // compute the new candidate list by only taking states that have + // the same i-th qualifier as the old state + for (State s : list1) { + ResourceQualifier oldQualifier = oldConfig.getQualifier(i); + + FolderConfiguration folderConfig = DeviceConfigHelper.getFolderConfig(s); + ResourceQualifier newQualifier = + folderConfig != null ? folderConfig.getQualifier(i) : null; + + if (oldQualifier == null) { + if (newQualifier == null) { + list2.add(s); + } + } else if (oldQualifier.equals(newQualifier)) { + list2.add(s); + } + } + + // at any moment if the new candidate list contains only one match, its name + // is returned. + if (list2.size() == 1) { + return list2.get(0).getName(); + } + + // if the list is empty, then all the new states failed. It is considered ok, and + // we move to the next qualifier anyway. This way, if a qualifier is different for + // all new states it is simply ignored. + if (list2.size() != 0) { + // move the candidates back into list1. + list1.clear(); + list1.addAll(list2); + list2.clear(); + } + } + + // the only way to reach this point is if there's an exact match. + // (if there are more than one, then there's a duplicate state and it doesn't matter, + // we take the first one). + if (list1.size() > 0) { + return list1.get(0).getName(); + } + + return null; + } + + /** + * Returns the layout {@link IFile} which best matches the configuration + * selected in the given configuration chooser. + * + * @param chooser the associated configuration chooser holding project state + * @return the file which best matches the settings + */ + @Nullable + public static IFile getBestFileMatch(ConfigurationChooser chooser) { + // get the resources of the file's project. + ResourceManager manager = ResourceManager.getInstance(); + ProjectResources resources = manager.getProjectResources(chooser.getProject()); + if (resources == null) { + return null; + } + + // From the resources, look for a matching file + IFile editedFile = chooser.getEditedFile(); + if (editedFile == null) { + return null; + } + String name = editedFile.getName(); + FolderConfiguration config = chooser.getConfiguration().getFullConfig(); + ResourceFile match = resources.getMatchingFile(name, ResourceType.LAYOUT, config); + + if (match != null) { + // In Eclipse, the match's file is always an instance of IFileWrapper + return ((IFileWrapper) match.getFile()).getIFile(); + } + + return null; + } + + /** + * Note: this comparator imposes orderings that are inconsistent with equals. + */ + private static class TabletConfigComparator implements Comparator<ConfigMatch> { + @Override + public int compare(ConfigMatch o1, ConfigMatch o2) { + FolderConfiguration config1 = o1 != null ? o1.testConfig : null; + FolderConfiguration config2 = o2 != null ? o2.testConfig : null; + if (config1 == null) { + if (config2 == null) { + return 0; + } else { + return -1; + } + } else if (config2 == null) { + return 1; + } + + ScreenSizeQualifier size1 = config1.getScreenSizeQualifier(); + ScreenSizeQualifier size2 = config2.getScreenSizeQualifier(); + ScreenSize ss1 = size1 != null ? size1.getValue() : ScreenSize.NORMAL; + ScreenSize ss2 = size2 != null ? size2.getValue() : ScreenSize.NORMAL; + + // X-LARGE is better than all others (which are considered identical) + // if both X-LARGE, then LANDSCAPE is better than all others (which are identical) + + if (ss1 == ScreenSize.XLARGE) { + if (ss2 == ScreenSize.XLARGE) { + ScreenOrientationQualifier orientation1 = + config1.getScreenOrientationQualifier(); + ScreenOrientation so1 = orientation1.getValue(); + if (so1 == null) { + so1 = ScreenOrientation.PORTRAIT; + } + ScreenOrientationQualifier orientation2 = + config2.getScreenOrientationQualifier(); + ScreenOrientation so2 = orientation2.getValue(); + if (so2 == null) { + so2 = ScreenOrientation.PORTRAIT; + } + + if (so1 == ScreenOrientation.LANDSCAPE) { + if (so2 == ScreenOrientation.LANDSCAPE) { + return 0; + } else { + return -1; + } + } else if (so2 == ScreenOrientation.LANDSCAPE) { + return 1; + } else { + return 0; + } + } else { + return -1; + } + } else if (ss2 == ScreenSize.XLARGE) { + return 1; + } else { + return 0; + } + } + } + + /** + * Note: this comparator imposes orderings that are inconsistent with equals. + */ + private static class PhoneConfigComparator implements Comparator<ConfigMatch> { + + private final SparseIntArray mDensitySort = new SparseIntArray(4); + + public PhoneConfigComparator() { + // put the sort order for the density. + mDensitySort.put(Density.HIGH.getDpiValue(), 1); + mDensitySort.put(Density.MEDIUM.getDpiValue(), 2); + mDensitySort.put(Density.XHIGH.getDpiValue(), 3); + mDensitySort.put(Density.LOW.getDpiValue(), 4); + } + + @Override + public int compare(ConfigMatch o1, ConfigMatch o2) { + FolderConfiguration config1 = o1 != null ? o1.testConfig : null; + FolderConfiguration config2 = o2 != null ? o2.testConfig : null; + if (config1 == null) { + if (config2 == null) { + return 0; + } else { + return -1; + } + } else if (config2 == null) { + return 1; + } + + int dpi1 = Density.DEFAULT_DENSITY; + int dpi2 = Density.DEFAULT_DENSITY; + + DensityQualifier dpiQualifier1 = config1.getDensityQualifier(); + if (dpiQualifier1 != null) { + Density value = dpiQualifier1.getValue(); + dpi1 = value != null ? value.getDpiValue() : Density.DEFAULT_DENSITY; + } + dpi1 = mDensitySort.get(dpi1, 100 /* valueIfKeyNotFound*/); + + DensityQualifier dpiQualifier2 = config2.getDensityQualifier(); + if (dpiQualifier2 != null) { + Density value = dpiQualifier2.getValue(); + dpi2 = value != null ? value.getDpiValue() : Density.DEFAULT_DENSITY; + } + dpi2 = mDensitySort.get(dpi2, 100 /* valueIfKeyNotFound*/); + + if (dpi1 == dpi2) { + // portrait is better + ScreenOrientation so1 = ScreenOrientation.PORTRAIT; + ScreenOrientationQualifier orientationQualifier1 = + config1.getScreenOrientationQualifier(); + if (orientationQualifier1 != null) { + so1 = orientationQualifier1.getValue(); + if (so1 == null) { + so1 = ScreenOrientation.PORTRAIT; + } + } + ScreenOrientation so2 = ScreenOrientation.PORTRAIT; + ScreenOrientationQualifier orientationQualifier2 = + config2.getScreenOrientationQualifier(); + if (orientationQualifier2 != null) { + so2 = orientationQualifier2.getValue(); + if (so2 == null) { + so2 = ScreenOrientation.PORTRAIT; + } + } + + if (so1 == ScreenOrientation.PORTRAIT) { + if (so2 == ScreenOrientation.PORTRAIT) { + return 0; + } else { + return -1; + } + } else if (so2 == ScreenOrientation.PORTRAIT) { + return 1; + } else { + return 0; + } + } + + return dpi1 - dpi2; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationMenuListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationMenuListener.java new file mode 100644 index 000000000..a791c63f8 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ConfigurationMenuListener.java @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.CUSTOM; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.DEFAULT; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.INCLUDES; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.LOCALES; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.NONE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.SCREENS; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.VARIATIONS; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.ResourceFolder; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewManager; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.ToolItem; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.PartInitException; + +import java.util.List; + +/** + * The {@linkplain ConfigurationMenuListener} class is responsible for + * generating the configuration menu in the {@link ConfigurationChooser}. + */ +class ConfigurationMenuListener extends SelectionAdapter { + private static final String ICON_NEW_CONFIG = "newConfig"; //$NON-NLS-1$ + private static final int ACTION_SELECT_CONFIG = 1; + private static final int ACTION_CREATE_CONFIG_FILE = 2; + private static final int ACTION_ADD = 3; + private static final int ACTION_DELETE_ALL = 4; + private static final int ACTION_PREVIEW_MODE = 5; + + private final ConfigurationChooser mConfigChooser; + private final int mAction; + private final IFile mResource; + private final RenderPreviewMode mMode; + + ConfigurationMenuListener( + @NonNull ConfigurationChooser configChooser, + int action, + @Nullable IFile resource, + @Nullable RenderPreviewMode mode) { + mConfigChooser = configChooser; + mAction = action; + mResource = resource; + mMode = mode; + } + + @Override + public void widgetSelected(SelectionEvent e) { + switch (mAction) { + case ACTION_SELECT_CONFIG: { + try { + AdtPlugin.openFile(mResource, null, false); + } catch (PartInitException ex) { + AdtPlugin.log(ex, null); + } + return; + } + case ACTION_CREATE_CONFIG_FILE: { + ConfigurationClient client = mConfigChooser.getClient(); + if (client != null) { + client.createConfigFile(); + } + return; + } + } + + IEditorPart activeEditor = AdtUtils.getActiveEditor(); + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(activeEditor); + IFile editedFile = mConfigChooser.getEditedFile(); + + if (delegate == null || editedFile == null) { + return; + } + // (Only do this when the two files are in the same project) + IProject project = delegate.getEditor().getProject(); + if (project == null || + !project.equals(editedFile.getProject())) { + return; + } + LayoutCanvas canvas = delegate.getGraphicalEditor().getCanvasControl(); + RenderPreviewManager previewManager = canvas.getPreviewManager(); + + switch (mAction) { + case ACTION_ADD: { + previewManager.addAsThumbnail(); + break; + } + case ACTION_PREVIEW_MODE: { + previewManager.selectMode(mMode); + break; + } + case ACTION_DELETE_ALL: { + previewManager.deleteManualPreviews(); + break; + } + default: assert false : mAction; + } + canvas.setFitScale(true /*onlyZoomOut*/, false /*allowZoomIn*/); + canvas.redraw(); + } + + static void show(ConfigurationChooser chooser, ToolItem combo) { + Menu menu = new Menu(chooser.getShell(), SWT.POP_UP); + RenderPreviewMode mode = AdtPrefs.getPrefs().getRenderPreviewMode(); + + // Configuration Previews + create(menu, "Add As Thumbnail...", + new ConfigurationMenuListener(chooser, ACTION_ADD, null, null), + SWT.PUSH, false); + if (mode == RenderPreviewMode.CUSTOM) { + MenuItem item = create(menu, "Delete All Thumbnails", + new ConfigurationMenuListener(chooser, ACTION_DELETE_ALL, null, null), + SWT.PUSH, false); + IEditorPart activeEditor = AdtUtils.getActiveEditor(); + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(activeEditor); + if (delegate != null) { + LayoutCanvas canvas = delegate.getGraphicalEditor().getCanvasControl(); + RenderPreviewManager previewManager = canvas.getPreviewManager(); + if (!previewManager.hasManualPreviews()) { + item.setEnabled(false); + } + } + } + + @SuppressWarnings("unused") + MenuItem configSeparator = new MenuItem(menu, SWT.SEPARATOR); + + create(menu, "Preview Representative Sample", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + DEFAULT), SWT.RADIO, mode == DEFAULT); + create(menu, "Preview All Screen Sizes", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + SCREENS), SWT.RADIO, mode == SCREENS); + + MenuItem localeItem = create(menu, "Preview All Locales", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + LOCALES), SWT.RADIO, mode == LOCALES); + if (chooser.getLocaleList().size() <= 1) { + localeItem.setEnabled(false); + } + + boolean canPreviewIncluded = false; + IProject project = chooser.getProject(); + if (project != null) { + IncludeFinder finder = IncludeFinder.get(project); + final List<Reference> includedBy = finder.getIncludedBy(chooser.getEditedFile()); + canPreviewIncluded = includedBy != null && !includedBy.isEmpty(); + } + //if (!graphicalEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) { + // canPreviewIncluded = false; + //} + MenuItem includedItem = create(menu, "Preview Included", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + INCLUDES), SWT.RADIO, mode == INCLUDES); + if (!canPreviewIncluded) { + includedItem.setEnabled(false); + } + + IFile file = chooser.getEditedFile(); + List<IFile> variations = AdtUtils.getResourceVariations(file, true); + MenuItem variationsItem = create(menu, "Preview Layout Versions", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + VARIATIONS), SWT.RADIO, mode == VARIATIONS); + if (variations.size() <= 1) { + variationsItem.setEnabled(false); + } + + create(menu, "Manual Previews", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + CUSTOM), SWT.RADIO, mode == CUSTOM); + create(menu, "None", + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, + NONE), SWT.RADIO, mode == NONE); + + if (variations.size() > 1) { + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + + ResourceManager manager = ResourceManager.getInstance(); + for (final IFile resource : variations) { + IFolder parent = (IFolder) resource.getParent(); + ResourceFolder parentResource = manager.getResourceFolder(parent); + FolderConfiguration configuration = parentResource.getConfiguration(); + String title = configuration.toDisplayString(); + + MenuItem item = create(menu, title, + new ConfigurationMenuListener(chooser, ACTION_SELECT_CONFIG, + resource, null), + SWT.CHECK, false); + + if (file != null) { + boolean selected = file.equals(resource); + if (selected) { + item.setSelection(true); + item.setEnabled(false); + } + } + } + } + + Configuration configuration = chooser.getConfiguration(); + if (configuration.getEditedConfig() != null && + !configuration.getEditedConfig().equals(configuration.getFullConfig())) { + if (variations.size() > 0) { + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + } + + // Add action for creating a new configuration + MenuItem item = create(menu, "Create New...", + new ConfigurationMenuListener(chooser, ACTION_CREATE_CONFIG_FILE, + null, null), + SWT.PUSH, false); + item.setImage(IconFactory.getInstance().getIcon(ICON_NEW_CONFIG)); + } + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } + + @NonNull + public static MenuItem create(@NonNull Menu menu, String title, + ConfigurationMenuListener listener, int style, boolean selected) { + MenuItem item = new MenuItem(menu, style); + item.setText(title); + item.addSelectionListener(listener); + if (selected) { + item.setSelection(true); + } + return item; + } + + @NonNull + static MenuItem addTogglePreviewModeAction( + @NonNull Menu menu, + @NonNull String title, + @NonNull ConfigurationChooser chooser, + @NonNull RenderPreviewMode mode) { + boolean selected = AdtPrefs.getPrefs().getRenderPreviewMode() == mode; + if (selected) { + mode = RenderPreviewMode.NONE; + } + return create(menu, title, + new ConfigurationMenuListener(chooser, ACTION_PREVIEW_MODE, null, mode), + SWT.CHECK, selected); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/DeviceMenuListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/DeviceMenuListener.java new file mode 100644 index 000000000..72910f9cc --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/DeviceMenuListener.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.ide.common.rendering.HardwareConfigHelper.MANUFACTURER_GENERIC; +import static com.android.ide.common.rendering.HardwareConfigHelper.getGenericLabel; +import static com.android.ide.common.rendering.HardwareConfigHelper.getNexusLabel; +import static com.android.ide.common.rendering.HardwareConfigHelper.isGeneric; +import static com.android.ide.common.rendering.HardwareConfigHelper.isNexus; +import static com.android.ide.common.rendering.HardwareConfigHelper.sortNexusList; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.devices.Device; +import com.android.sdklib.internal.avd.AvdInfo; +import com.android.sdklib.internal.avd.AvdManager; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.ToolItem; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * The {@linkplain DeviceMenuListener} class is responsible for generating the device + * menu in the {@link ConfigurationChooser}. + */ +class DeviceMenuListener extends SelectionAdapter { + private final ConfigurationChooser mConfigChooser; + private final Device mDevice; + + DeviceMenuListener( + @NonNull ConfigurationChooser configChooser, + @Nullable Device device) { + mConfigChooser = configChooser; + mDevice = device; + } + + @Override + public void widgetSelected(SelectionEvent e) { + mConfigChooser.selectDevice(mDevice); + mConfigChooser.onDeviceChange(); + } + + static void show(final ConfigurationChooser chooser, ToolItem combo) { + Configuration configuration = chooser.getConfiguration(); + Device current = configuration.getDevice(); + Menu menu = new Menu(chooser.getShell(), SWT.POP_UP); + + Collection<Device> deviceCollection = chooser.getDevices(); + Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + AvdManager avdManager = sdk.getAvdManager(); + if (avdManager != null) { + boolean separatorNeeded = false; + AvdInfo[] avds = avdManager.getValidAvds(); + for (AvdInfo avd : avds) { + for (Device device : deviceCollection) { + if (device.getManufacturer().equals(avd.getDeviceManufacturer()) + && device.getName().equals(avd.getDeviceName())) { + separatorNeeded = true; + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(avd.getName()); + item.setSelection(current == device); + + item.addSelectionListener(new DeviceMenuListener(chooser, device)); + } + } + } + + if (separatorNeeded) { + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + } + } + } + + // Group the devices by manufacturer, then put them in the menu. + // If we don't have anything but Nexus devices, group them together rather than + // make many manufacturer submenus. + boolean haveNexus = false; + boolean haveNonNexus = false; + if (!deviceCollection.isEmpty()) { + Map<String, List<Device>> manufacturers = new TreeMap<String, List<Device>>(); + for (Device device : deviceCollection) { + List<Device> devices; + if (isNexus(device)) { + haveNexus = true; + } else if (!isGeneric(device)) { + haveNonNexus = true; + } + if (manufacturers.containsKey(device.getManufacturer())) { + devices = manufacturers.get(device.getManufacturer()); + } else { + devices = new ArrayList<Device>(); + manufacturers.put(device.getManufacturer(), devices); + } + devices.add(device); + } + if (haveNonNexus) { + for (List<Device> devices : manufacturers.values()) { + Menu manufacturerMenu = menu; + if (manufacturers.size() > 1) { + MenuItem item = new MenuItem(menu, SWT.CASCADE); + item.setText(devices.get(0).getManufacturer()); + manufacturerMenu = new Menu(menu); + item.setMenu(manufacturerMenu); + } + for (final Device device : devices) { + MenuItem deviceItem = new MenuItem(manufacturerMenu, SWT.CHECK); + deviceItem.setText(getGenericLabel(device)); + deviceItem.setSelection(current == device); + deviceItem.addSelectionListener(new DeviceMenuListener(chooser, device)); + } + } + } else { + List<Device> nexus = new ArrayList<Device>(); + List<Device> generic = new ArrayList<Device>(); + if (haveNexus) { + // Nexus + for (List<Device> devices : manufacturers.values()) { + for (Device device : devices) { + if (isNexus(device)) { + if (device.getManufacturer().equals(MANUFACTURER_GENERIC)) { + generic.add(device); + } else { + nexus.add(device); + } + } else { + generic.add(device); + } + } + } + } + + if (!nexus.isEmpty()) { + sortNexusList(nexus); + for (final Device device : nexus) { + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(getNexusLabel(device)); + item.setSelection(current == device); + item.addSelectionListener(new DeviceMenuListener(chooser, device)); + } + + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + } + + // Generate the generic menu. + Collections.reverse(generic); + for (final Device device : generic) { + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(getGenericLabel(device)); + item.setSelection(current == device); + item.addSelectionListener(new DeviceMenuListener(chooser, device)); + } + } + } + + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + + ConfigurationMenuListener.addTogglePreviewModeAction(menu, + "Preview All Screens", chooser, RenderPreviewMode.SCREENS); + + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/FlagManager.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/FlagManager.java new file mode 100644 index 000000000..15623cf30 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/FlagManager.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.LocaleManager; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.google.common.collect.Maps; + +import org.eclipse.swt.graphics.Image; +import org.eclipse.wb.internal.core.DesignerPlugin; + +import java.util.Locale; +import java.util.Map; + +/** + * The {@linkplain FlagManager} provides access to flags for regions known + * to {@link LocaleManager}. It also contains some locale related display + * functions. + * <p> + * All the flag images came from the WindowBuilder subversion repository + * http://dev.eclipse.org/svnroot/tools/org.eclipse.windowbuilder/trunk (and in + * particular, a snapshot of revision 424). However, it appears that the icons + * are from http://www.famfamfam.com/lab/icons/flags/ which states that "these + * flag icons are available for free use for any purpose with no requirement for + * attribution." Adding the URL here such that we can check back occasionally + * and see if there are corrections or updates. Also note that the flag names + * are in ISO 3166-1 alpha-2 country codes. + */ +public class FlagManager { + private static final FlagManager sInstance = new FlagManager(); + + /** + * Returns the {@linkplain FlagManager} singleton + * + * @return the {@linkplain FlagManager} singleton, never null + */ + @NonNull + public static FlagManager get() { + return sInstance; + } + + /** Use the {@link #get()} factory method */ + private FlagManager() { + } + + /** Map from region to flag icon */ + private final Map<String, Image> mImageMap = Maps.newHashMap(); + + /** + * Returns the empty flag icon used to indicate an unknown country + * + * @return the globe icon used to indicate an unknown country + */ + public static Image getEmptyIcon() { + return DesignerPlugin.getImage("nls/flags/flag_empty.png"); //$NON-NLS-1$ + } + + /** + * Returns the globe icon used to indicate "any" language + * + * @return the globe icon used to indicate "any" language + */ + public static Image getGlobeIcon() { + return IconFactory.getInstance().getIcon("globe"); //$NON-NLS-1$ + } + + /** + * Returns the flag for the given language and region. + * + * @param language the language, or null (if null, region must not be null), + * the 2 letter language code (ISO 639-1), in lower case + * @param region the region, or null (if null, language must not be null), + * the 2 letter region code (ISO 3166-1 alpha-2), in upper case + * @return a suitable flag icon, or null + */ + @Nullable + public Image getFlag(@Nullable String language, @Nullable String region) { + assert region != null || language != null; + if (region == null || region.isEmpty()) { + // Look up the region for a given language + assert language != null; + + // Special cases where we have a dedicated flag available: + if (language.equals("ca")) { //$NON-NLS-1$ + return getIcon("catalonia"); //$NON-NLS-1$ + } + else if (language.equals("gd")) { //$NON-NLS-1$ + return getIcon("scotland"); //$NON-NLS-1$ + } + else if (language.equals("cy")) { //$NON-NLS-1$ + return getIcon("wales"); //$NON-NLS-1$ + } + + // Prefer the local registration of the current locale; even if + // for example the default locale for English is the US, if the current + // default locale is English, then use its associated country, which could + // for example be Australia. + Locale locale = Locale.getDefault(); + if (language.equals(locale.getLanguage())) { + Image flag = getFlag(locale.getCountry()); + if (flag != null) { + return flag; + } + } + + region = LocaleManager.getLanguageRegion(language); + } + + if (region == null || region.isEmpty()) { + // No country specified, and the language is for a country we + // don't have a flag for + return null; + } + + return getIcon(region); + } + + /** + * Returns the flag for the given language and region. + * + * @param language the language qualifier, or null (if null, region must not be null), + * @param region the region, or null (if null, language must not be null), + * @return a suitable flag icon, or null + */ + public Image getFlag(@Nullable LocaleQualifier locale) { + if (locale == null) { + return null; + } + String languageCode = locale.getLanguage(); + String regionCode = locale.getRegion(); + if (LocaleQualifier.FAKE_VALUE.equals(languageCode)) { + languageCode = null; + } + return getFlag(languageCode, regionCode); + } + + /** + * Returns a flag for a given resource folder name (such as + * {@code values-en-rUS}), or null + * + * @param folder the folder name + * @return a corresponding flag icon, or null if none was found + */ + @Nullable + public Image getFlagForFolderName(@NonNull String folder) { + FolderConfiguration configuration = FolderConfiguration.getConfigForFolder(folder); + if (configuration != null) { + return get().getFlag(configuration); + } + + return null; + } + + /** + * Returns the flag for the given folder + * + * @param configuration the folder configuration + * @return a suitable flag icon, or null + */ + @Nullable + public Image getFlag(@NonNull FolderConfiguration configuration) { + return getFlag(configuration.getLocaleQualifier()); + } + + + + /** + * Returns the flag for the given region. + * + * @param region the 2 letter region code (ISO 3166-1 alpha-2), in upper case + * @return a suitable flag icon, or null + */ + @Nullable + public Image getFlag(@NonNull String region) { + assert region.length() == 2 + && Character.isUpperCase(region.charAt(0)) + && Character.isUpperCase(region.charAt(1)) : region; + + return getIcon(region); + } + + private Image getIcon(@NonNull String base) { + Image flagImage = mImageMap.get(base); + if (flagImage == null) { + // TODO: Special case locale currently running on system such + // that the current country matches the current locale + if (mImageMap.containsKey(base)) { + // Already checked: there's just no image there + return null; + } + String flagFileName = base.toLowerCase(Locale.US) + ".png"; //$NON-NLS-1$ + flagImage = DesignerPlugin.getImage("nls/flags/" + flagFileName); //$NON-NLS-1$ + mImageMap.put(base, flagImage); + } + + return flagImage; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/LayoutCreatorDialog.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/LayoutCreatorDialog.java new file mode 100644 index 000000000..97ff66845 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/LayoutCreatorDialog.java @@ -0,0 +1,149 @@ +/* + * 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.eclipse.adt.internal.editors.layout.configuration; + +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.ResourceQualifier; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector; +import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector.ConfigurationState; +import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector.SelectorMode; +import com.android.resources.ResourceFolderType; +import com.android.sdkuilib.ui.GridDialog; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; + +/** + * Dialog to choose a non existing {@link FolderConfiguration}. + */ +public final class LayoutCreatorDialog extends GridDialog { + + private ConfigurationSelector mSelector; + private Composite mStatusComposite; + private Label mStatusLabel; + private Label mStatusImage; + + private final FolderConfiguration mConfig = new FolderConfiguration(); + private final String mFileName; + + /** + * Creates a dialog, and init the UI from a {@link FolderConfiguration}. + * @param parentShell the parent {@link Shell}. + * @param fileName the filename associated with the configuration + * @param config The starting configuration. + */ + public LayoutCreatorDialog(Shell parentShell, String fileName, FolderConfiguration config) { + super(parentShell, 1, false); + + mFileName = fileName; + + // FIXME: add some data to know what configurations already exist. + mConfig.set(config); + } + + @Override + public void createDialogContent(Composite parent) { + new Label(parent, SWT.NONE).setText( + String.format("Configuration for the alternate version of %1$s", mFileName)); + + mSelector = new ConfigurationSelector(parent, SelectorMode.CONFIG_ONLY); + mSelector.setConfiguration(mConfig); + + // because the ConfigSelector is running in CONFIG_ONLY mode, the current config + // displayed by it is not mConfig anymore, so get the current config. + mSelector.getConfiguration(mConfig); + + // parent's layout is a GridLayout as specified in the javadoc. + GridData gd = new GridData(); + gd.widthHint = ConfigurationSelector.WIDTH_HINT; + gd.heightHint = ConfigurationSelector.HEIGHT_HINT; + mSelector.setLayoutData(gd); + + // add a listener to check on the validity of the FolderConfiguration as + // they are built. + mSelector.setOnChangeListener(new Runnable() { + @Override + public void run() { + ConfigurationState state = mSelector.getState(); + + switch (state) { + case OK: + mSelector.getConfiguration(mConfig); + + resetStatus(); + mStatusImage.setImage(null); + getButton(IDialogConstants.OK_ID).setEnabled(true); + break; + case INVALID_CONFIG: + ResourceQualifier invalidQualifier = mSelector.getInvalidQualifier(); + mStatusLabel.setText(String.format( + "Invalid Configuration: %1$s has no filter set.", + invalidQualifier.getName())); + mStatusImage.setImage(IconFactory.getInstance().getIcon("warning")); //$NON-NLS-1$ + getButton(IDialogConstants.OK_ID).setEnabled(false); + break; + case REGION_WITHOUT_LANGUAGE: + mStatusLabel.setText( + "The Region qualifier requires the Language qualifier."); + mStatusImage.setImage(IconFactory.getInstance().getIcon("warning")); //$NON-NLS-1$ + getButton(IDialogConstants.OK_ID).setEnabled(false); + break; + } + + // need to relayout, because of the change in size in mErrorImage. + mStatusComposite.layout(); + } + }); + + mStatusComposite = new Composite(parent, SWT.NONE); + mStatusComposite.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + GridLayout gl = new GridLayout(2, false); + mStatusComposite.setLayout(gl); + gl.marginHeight = gl.marginWidth = 0; + + mStatusImage = new Label(mStatusComposite, SWT.NONE); + mStatusLabel = new Label(mStatusComposite, SWT.NONE); + mStatusLabel.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + resetStatus(); + } + + /** + * Sets the edited configuration on the given configuration parameter + * + * @param config the configuration to apply the current edits to + */ + public void getConfiguration(FolderConfiguration config) { + config.set(mConfig); + } + + /** + * resets the status label to show the file that will be created. + */ + private void resetStatus() { + String displayString = Dialog.shortenText(String.format("New File: res/%1$s/%2$s", + mConfig.getFolderName(ResourceFolderType.LAYOUT), mFileName), + mStatusLabel); + mStatusLabel.setText(displayString); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/Locale.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/Locale.java new file mode 100644 index 000000000..6cb396394 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/Locale.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.ide.common.resources.configuration.LocaleQualifier.FAKE_VALUE; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; + +import org.eclipse.swt.graphics.Image; + +/** + * A language,region pair + */ +public class Locale { + /** + * A special marker region qualifier representing any region + */ + public static final LocaleQualifier ANY_QUALIFIER = new LocaleQualifier(FAKE_VALUE); + + /** + * A locale which matches any language and region + */ + public static final Locale ANY = new Locale(ANY_QUALIFIER); + + /** + * The locale qualifier, or {@link #ANY_QUALIFIER} if this locale matches + * any locale + */ + @NonNull + public final LocaleQualifier qualifier; + + /** + * Constructs a new {@linkplain Locale} matching a given language in a given + * locale. + * + * @param locale the locale + */ + private Locale(@NonNull + LocaleQualifier locale) { + qualifier = locale; + } + + /** + * Constructs a new {@linkplain Locale} matching a given language in a given + * specific locale. + * + * @param locale the locale + * @return a locale with the given locale + */ + @NonNull + public static Locale create(@NonNull + LocaleQualifier locale) { + return new Locale(locale); + } + + /** + * Constructs a new {@linkplain Locale} for the given folder configuration + * + * @param folder the folder configuration + * @return a locale with the given language and region + */ + public static Locale create(FolderConfiguration folder) { + LocaleQualifier locale = folder.getLocaleQualifier(); + if (locale == null) { + return ANY; + } else { + return new Locale(locale); + } + } + + /** + * Constructs a new {@linkplain Locale} for the given locale string, e.g. + * "zh", "en-rUS", or "b+eng+US". + * + * @param localeString the locale description + * @return the corresponding locale + */ + @NonNull + public static Locale create(@NonNull + String localeString) { + // Load locale. Note that this can get overwritten by the + // project-wide settings read below. + + LocaleQualifier qualifier = LocaleQualifier.getQualifier(localeString); + if (qualifier != null) { + return new Locale(qualifier); + } else { + return ANY; + } + } + + /** + * Returns a flag image to use for this locale + * + * @return a flag image, or a default globe icon + */ + @NonNull + public Image getFlagImage() { + String languageCode = qualifier.hasLanguage() ? qualifier.getLanguage() : null; + if (languageCode == null) { + return FlagManager.getGlobeIcon(); + } + String regionCode = hasRegion() ? qualifier.getRegion() : null; + FlagManager icons = FlagManager.get(); + Image image = icons.getFlag(languageCode, regionCode); + if (image != null) { + return image; + } else { + return FlagManager.getGlobeIcon(); + } + } + + /** + * Returns true if this locale specifies a specific language. This is true + * for all locales except {@link #ANY}. + * + * @return true if this locale specifies a specific language + */ + public boolean hasLanguage() { + return !qualifier.hasFakeValue(); + } + + /** + * Returns true if this locale specifies a specific region + * + * @return true if this locale specifies a region + */ + public boolean hasRegion() { + return qualifier.getRegion() != null && !FAKE_VALUE.equals(qualifier.getRegion()); + } + + /** + * Returns the locale formatted as language-region. If region is not set, + * language is returned. If language is not set, empty string is returned. + */ + public String toLocaleId() { + return qualifier == ANY_QUALIFIER ? "" : qualifier.getTag(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + qualifier.hashCode(); + return result; + } + + @Override + public boolean equals(@Nullable + Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Locale other = (Locale) obj; + if (!qualifier.equals(other.qualifier)) + return false; + return true; + } + + @Override + public String toString() { + return qualifier.getTag(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/LocaleMenuListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/LocaleMenuListener.java new file mode 100644 index 000000000..2bc5417b0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/LocaleMenuListener.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode; +import com.android.ide.eclipse.adt.internal.wizards.newxmlfile.AddTranslationDialog; + +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.ToolItem; + +import java.util.List; + +/** + * The {@linkplain LocaleMenuListener} class is responsible for generating the locale + * menu in the {@link ConfigurationChooser}. + */ +class LocaleMenuListener extends SelectionAdapter { + private static final int ACTION_SET_LOCALE = 1; + private static final int ACTION_ADD_TRANSLATION = 2; + + private final ConfigurationChooser mConfigChooser; + private final int mAction; + private final Locale mLocale; + + LocaleMenuListener( + @NonNull ConfigurationChooser configChooser, + int action, + @Nullable Locale locale) { + mConfigChooser = configChooser; + mAction = action; + mLocale = locale; + } + + @Override + public void widgetSelected(SelectionEvent e) { + switch (mAction) { + case ACTION_SET_LOCALE: { + mConfigChooser.selectLocale(mLocale); + mConfigChooser.onLocaleChange(); + break; + } + case ACTION_ADD_TRANSLATION: { + IProject project = mConfigChooser.getProject(); + Shell shell = mConfigChooser.getShell(); + AddTranslationDialog dialog = new AddTranslationDialog(shell, project); + dialog.open(); + break; + } + default: assert false : mAction; + } + } + + static void show(final ConfigurationChooser chooser, ToolItem combo) { + Menu menu = new Menu(chooser.getShell(), SWT.POP_UP); + Configuration configuration = chooser.getConfiguration(); + List<Locale> locales = chooser.getLocaleList(); + Locale current = configuration.getLocale(); + + for (Locale locale : locales) { + String title = ConfigurationChooser.getLocaleLabel(chooser, locale, false); + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(title); + Image image = locale.getFlagImage(); + item.setImage(image); + + boolean selected = current == locale; + if (selected) { + item.setSelection(true); + } + + LocaleMenuListener listener = new LocaleMenuListener(chooser, ACTION_SET_LOCALE, + locale); + item.addSelectionListener(listener); + } + + if (locales.size() > 1) { + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + + ConfigurationMenuListener.addTogglePreviewModeAction(menu, + "Preview All Locales", chooser, RenderPreviewMode.LOCALES); + } + + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + + MenuItem item = new MenuItem(menu, SWT.PUSH); + item.setText("Add New Translation..."); + LocaleMenuListener listener = new LocaleMenuListener(chooser, + ACTION_ADD_TRANSLATION, null); + item.addSelectionListener(listener); + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/NestedConfiguration.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/NestedConfiguration.java new file mode 100644 index 000000000..50778e2f1 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/NestedConfiguration.java @@ -0,0 +1,506 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.resources.NightMode; +import com.android.resources.UiMode; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.State; +import com.google.common.base.Objects; + +/** + * An {@linkplain NestedConfiguration} is a {@link Configuration} which inherits + * all of its values from a different configuration, except for one or more + * attributes where it overrides a custom value. + * <p> + * Unlike a {@link VaryingConfiguration}, a {@linkplain NestedConfiguration} + * will always return the same overridden value, regardless of the inherited + * value. + * <p> + * For example, an {@linkplain NestedConfiguration} may fix the locale to always + * be "en", but otherwise inherit everything else. + */ +public class NestedConfiguration extends Configuration { + /** The configuration we are inheriting non-overridden values from */ + protected Configuration mParent; + + /** Bitmask of attributes to be overridden in this configuration */ + private int mOverride; + + /** + * Constructs a new {@linkplain NestedConfiguration}. + * Construct via + * + * @param chooser the associated chooser + * @param configuration the configuration to inherit from + */ + protected NestedConfiguration( + @NonNull ConfigurationChooser chooser, + @NonNull Configuration configuration) { + super(chooser); + mParent = configuration; + + mFullConfig.set(mParent.mFullConfig); + if (mParent.getEditedConfig() != null) { + mEditedConfig = new FolderConfiguration(); + mEditedConfig.set(mParent.mEditedConfig); + } + } + + /** + * Returns the override flags for this configuration. Corresponds to + * the {@code CFG_} flags in {@link ConfigurationClient}. + * + * @return the bitmask + */ + public int getOverrideFlags() { + return mOverride; + } + + /** + * Creates a new {@linkplain NestedConfiguration} that has the same overriding + * attributes as the given other {@linkplain NestedConfiguration}, and gets + * its values from the given {@linkplain Configuration}. + * + * @param other the configuration to copy overrides from + * @param values the configuration to copy values from + * @param parent the parent to tie the configuration to for inheriting values + * @return a new configuration + */ + @NonNull + public static NestedConfiguration create( + @NonNull NestedConfiguration other, + @NonNull Configuration values, + @NonNull Configuration parent) { + NestedConfiguration configuration = + new NestedConfiguration(other.mConfigChooser, parent); + initFrom(configuration, other, values, true /*sync*/); + return configuration; + } + + /** + * Initializes a new {@linkplain NestedConfiguration} with the overriding + * attributes as the given other {@linkplain NestedConfiguration}, and gets + * its values from the given {@linkplain Configuration}. + * + * @param configuration the configuration to initialize + * @param other the configuration to copy overrides from + * @param values the configuration to copy values from + * @param sync if true, sync the folder configuration from + */ + protected static void initFrom(NestedConfiguration configuration, + NestedConfiguration other, Configuration values, boolean sync) { + configuration.mOverride = other.mOverride; + configuration.setDisplayName(values.getDisplayName()); + configuration.setActivity(values.getActivity()); + + if (configuration.isOverridingLocale()) { + configuration.setLocale(values.getLocale(), true); + } + if (configuration.isOverridingTarget()) { + configuration.setTarget(values.getTarget(), true); + } + if (configuration.isOverridingDevice()) { + configuration.setDevice(values.getDevice(), true); + } + if (configuration.isOverridingDeviceState()) { + configuration.setDeviceState(values.getDeviceState(), true); + } + if (configuration.isOverridingNightMode()) { + configuration.setNightMode(values.getNightMode(), true); + } + if (configuration.isOverridingUiMode()) { + configuration.setUiMode(values.getUiMode(), true); + } + if (sync) { + configuration.syncFolderConfig(); + } + } + + /** + * Sets the parent configuration that this configuration is inheriting from. + * + * @param parent the parent configuration + */ + public void setParent(@NonNull Configuration parent) { + mParent = parent; + } + + /** + * Creates a new {@linkplain Configuration} which inherits values from the + * given parent {@linkplain Configuration}, possibly overriding some as + * well. + * + * @param chooser the associated chooser + * @param parent the configuration to inherit values from + * @return a new configuration + */ + @NonNull + public static NestedConfiguration create(@NonNull ConfigurationChooser chooser, + @NonNull Configuration parent) { + return new NestedConfiguration(chooser, parent); + } + + @Override + @Nullable + public String getTheme() { + // Never overridden: this is a static attribute of a layout, not something which + // varies by configuration or at runtime + return mParent.getTheme(); + } + + @Override + public void setTheme(String theme) { + // Never overridden + mParent.setTheme(theme); + } + + /** + * Sets whether the locale should be overridden by this configuration + * + * @param override if true, override the inherited value + */ + public void setOverrideLocale(boolean override) { + mOverride |= CFG_LOCALE; + } + + /** + * Returns true if the locale is overridden + * + * @return true if the locale is overridden + */ + public final boolean isOverridingLocale() { + return (mOverride & CFG_LOCALE) != 0; + } + + @Override + @NonNull + public Locale getLocale() { + if (isOverridingLocale()) { + return super.getLocale(); + } else { + return mParent.getLocale(); + } + } + + @Override + public void setLocale(@NonNull Locale locale, boolean skipSync) { + if (isOverridingLocale()) { + super.setLocale(locale, skipSync); + } else { + mParent.setLocale(locale, skipSync); + } + } + + /** + * Sets whether the rendering target should be overridden by this configuration + * + * @param override if true, override the inherited value + */ + public void setOverrideTarget(boolean override) { + mOverride |= CFG_TARGET; + } + + /** + * Returns true if the target is overridden + * + * @return true if the target is overridden + */ + public final boolean isOverridingTarget() { + return (mOverride & CFG_TARGET) != 0; + } + + @Override + @Nullable + public IAndroidTarget getTarget() { + if (isOverridingTarget()) { + return super.getTarget(); + } else { + return mParent.getTarget(); + } + } + + @Override + public void setTarget(IAndroidTarget target, boolean skipSync) { + if (isOverridingTarget()) { + super.setTarget(target, skipSync); + } else { + mParent.setTarget(target, skipSync); + } + } + + /** + * Sets whether the device should be overridden by this configuration + * + * @param override if true, override the inherited value + */ + public void setOverrideDevice(boolean override) { + mOverride |= CFG_DEVICE; + } + + /** + * Returns true if the device is overridden + * + * @return true if the device is overridden + */ + public final boolean isOverridingDevice() { + return (mOverride & CFG_DEVICE) != 0; + } + + @Override + @Nullable + public Device getDevice() { + if (isOverridingDevice()) { + return super.getDevice(); + } else { + return mParent.getDevice(); + } + } + + @Override + public void setDevice(Device device, boolean skipSync) { + if (isOverridingDevice()) { + super.setDevice(device, skipSync); + } else { + mParent.setDevice(device, skipSync); + } + } + + /** + * Sets whether the device state should be overridden by this configuration + * + * @param override if true, override the inherited value + */ + public void setOverrideDeviceState(boolean override) { + mOverride |= CFG_DEVICE_STATE; + } + + /** + * Returns true if the device state is overridden + * + * @return true if the device state is overridden + */ + public final boolean isOverridingDeviceState() { + return (mOverride & CFG_DEVICE_STATE) != 0; + } + + @Override + @Nullable + public State getDeviceState() { + if (isOverridingDeviceState()) { + return super.getDeviceState(); + } else { + State state = mParent.getDeviceState(); + if (isOverridingDevice()) { + // If the device differs, I need to look up a suitable equivalent state + // on our device + if (state != null) { + Device device = super.getDevice(); + if (device != null) { + return device.getState(state.getName()); + } + } + } + + return state; + } + } + + @Override + public void setDeviceState(State state, boolean skipSync) { + if (isOverridingDeviceState()) { + super.setDeviceState(state, skipSync); + } else { + if (isOverridingDevice()) { + Device device = super.getDevice(); + if (device != null) { + State equivalentState = device.getState(state.getName()); + if (equivalentState != null) { + state = equivalentState; + } + } + } + mParent.setDeviceState(state, skipSync); + } + } + + /** + * Sets whether the night mode should be overridden by this configuration + * + * @param override if true, override the inherited value + */ + public void setOverrideNightMode(boolean override) { + mOverride |= CFG_NIGHT_MODE; + } + + /** + * Returns true if the night mode is overridden + * + * @return true if the night mode is overridden + */ + public final boolean isOverridingNightMode() { + return (mOverride & CFG_NIGHT_MODE) != 0; + } + + @Override + @NonNull + public NightMode getNightMode() { + if (isOverridingNightMode()) { + return super.getNightMode(); + } else { + return mParent.getNightMode(); + } + } + + @Override + public void setNightMode(@NonNull NightMode night, boolean skipSync) { + if (isOverridingNightMode()) { + super.setNightMode(night, skipSync); + } else { + mParent.setNightMode(night, skipSync); + } + } + + /** + * Sets whether the UI mode should be overridden by this configuration + * + * @param override if true, override the inherited value + */ + public void setOverrideUiMode(boolean override) { + mOverride |= CFG_UI_MODE; + } + + /** + * Returns true if the UI mode is overridden + * + * @return true if the UI mode is overridden + */ + public final boolean isOverridingUiMode() { + return (mOverride & CFG_UI_MODE) != 0; + } + + @Override + @NonNull + public UiMode getUiMode() { + if (isOverridingUiMode()) { + return super.getUiMode(); + } else { + return mParent.getUiMode(); + } + } + + @Override + public void setUiMode(@NonNull UiMode uiMode, boolean skipSync) { + if (isOverridingUiMode()) { + super.setUiMode(uiMode, skipSync); + } else { + mParent.setUiMode(uiMode, skipSync); + } + } + + /** + * Returns the configuration this {@linkplain NestedConfiguration} is + * inheriting from + * + * @return the configuration this configuration is inheriting from + */ + @NonNull + public Configuration getParent() { + return mParent; + } + + @Override + @Nullable + public String getActivity() { + return mParent.getActivity(); + } + + @Override + public void setActivity(String activity) { + super.setActivity(activity); + } + + /** + * Returns a computed display name (ignoring the value stored by + * {@link #setDisplayName(String)}) by looking at the override flags + * and picking a suitable name. + * + * @return a suitable display name + */ + @Nullable + public String computeDisplayName() { + return computeDisplayName(mOverride, this); + } + + /** + * Computes a display name for the given configuration, using the given + * override flags (which correspond to the {@code CFG_} constants in + * {@link ConfigurationClient} + * + * @param flags the override bitmask + * @param configuration the configuration to fetch values from + * @return a suitable display name + */ + @Nullable + public static String computeDisplayName(int flags, @NonNull Configuration configuration) { + if ((flags & CFG_LOCALE) != 0) { + return ConfigurationChooser.getLocaleLabel(configuration.mConfigChooser, + configuration.getLocale(), false); + } + + if ((flags & CFG_TARGET) != 0) { + return ConfigurationChooser.getRenderingTargetLabel(configuration.getTarget(), false); + } + + if ((flags & CFG_DEVICE) != 0) { + return ConfigurationChooser.getDeviceLabel(configuration.getDevice(), true); + } + + if ((flags & CFG_DEVICE_STATE) != 0) { + State deviceState = configuration.getDeviceState(); + if (deviceState != null) { + return deviceState.getName(); + } + } + + if ((flags & CFG_NIGHT_MODE) != 0) { + return configuration.getNightMode().getLongDisplayValue(); + } + + if ((flags & CFG_UI_MODE) != 0) { + configuration.getUiMode().getLongDisplayValue(); + } + + return null; + } + + @Override + public String toString() { + return Objects.toStringHelper(this.getClass()) + .add("parent", mParent.getDisplayName()) //$NON-NLS-1$ + .add("display", getDisplayName()) //$NON-NLS-1$ + .add("overrideLocale", isOverridingLocale()) //$NON-NLS-1$ + .add("overrideTarget", isOverridingTarget()) //$NON-NLS-1$ + .add("overrideDevice", isOverridingDevice()) //$NON-NLS-1$ + .add("overrideDeviceState", isOverridingDeviceState()) //$NON-NLS-1$ + .add("persistent", toPersistentString()) //$NON-NLS-1$ + .toString(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/OrientationMenuAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/OrientationMenuAction.java new file mode 100644 index 000000000..5cad29afc --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/OrientationMenuAction.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SubmenuAction; +import com.android.resources.NightMode; +import com.android.resources.ScreenOrientation; +import com.android.resources.UiMode; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.State; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.ToolItem; + +import java.util.List; + +/** + * Action which creates a submenu that shows the available orientations as well + * as some related options for night mode and dock mode + */ +class OrientationMenuAction extends SubmenuAction { + // Constants used to indicate what type of menu is being shown, such that + // the submenus can lazily construct their contents + private static final int MENU_NIGHTMODE = 1; + private static final int MENU_UIMODE = 2; + + private final ConfigurationChooser mConfigChooser; + /** Type of menu; one of the constants {@link #MENU_NIGHTMODE} etc */ + private final int mType; + + OrientationMenuAction(int type, String title, ConfigurationChooser configuration) { + super(title); + mType = type; + mConfigChooser = configuration; + } + + static void showMenu(ConfigurationChooser configChooser, ToolItem combo) { + MenuManager manager = new MenuManager(); + + // Show toggles for all the available states + + Configuration configuration = configChooser.getConfiguration(); + Device device = configuration.getDevice(); + State current = configuration.getDeviceState(); + if (device != null) { + List<State> states = device.getAllStates(); + + if (states.size() > 1 && current != null) { + State flip = configuration.getNextDeviceState(current); + String flipName = flip != null ? flip.getName() : current.getName(); + manager.add(new DeviceConfigAction(configChooser, + String.format("Switch to %1$s", flipName), flip, false, true)); + manager.add(new Separator()); + } + + for (State config : states) { + manager.add(new DeviceConfigAction(configChooser, config.getName(), + config, config == current, false)); + } + manager.add(new Separator()); + } + manager.add(new OrientationMenuAction(MENU_UIMODE, "UI Mode", configChooser)); + manager.add(new Separator()); + manager.add(new OrientationMenuAction(MENU_NIGHTMODE, "Night Mode", configChooser)); + + Menu menu = manager.createContextMenu(configChooser.getShell()); + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } + + @Override + protected void addMenuItems(Menu menu) { + switch (mType) { + case MENU_NIGHTMODE: { + NightMode selected = mConfigChooser.getConfiguration().getNightMode(); + for (NightMode mode : NightMode.values()) { + boolean checked = mode == selected; + SelectNightModeAction action = new SelectNightModeAction(mode, checked); + new ActionContributionItem(action).fill(menu, -1); + + } + break; + } + case MENU_UIMODE: { + UiMode selected = mConfigChooser.getConfiguration().getUiMode(); + for (UiMode mode : UiMode.values()) { + boolean checked = mode == selected; + SelectUiModeAction action = new SelectUiModeAction(mode, checked); + new ActionContributionItem(action).fill(menu, -1); + } + break; + } + } + } + + + private class SelectNightModeAction extends Action { + private final NightMode mMode; + + private SelectNightModeAction(NightMode mode, boolean checked) { + super(mode.getLongDisplayValue(), IAction.AS_RADIO_BUTTON); + mMode = mode; + if (checked) { + setChecked(true); + } + } + + @Override + public void run() { + Configuration configuration = mConfigChooser.getConfiguration(); + configuration.setNightMode(mMode, false); + mConfigChooser.notifyFolderConfigChanged(); + } + } + + private class SelectUiModeAction extends Action { + private final UiMode mMode; + + private SelectUiModeAction(UiMode mode, boolean checked) { + super(mode.getLongDisplayValue(), IAction.AS_RADIO_BUTTON); + mMode = mode; + if (checked) { + setChecked(true); + } + } + + @Override + public void run() { + Configuration configuration = mConfigChooser.getConfiguration(); + configuration.setUiMode(mMode, false); + } + } + + private static class DeviceConfigAction extends Action { + private final ConfigurationChooser mConfiguration; + private final State mState; + + private DeviceConfigAction(ConfigurationChooser configuration, String title, + State state, boolean checked, boolean flip) { + super(title, IAction.AS_RADIO_BUTTON); + mConfiguration = configuration; + mState = state; + if (checked) { + setChecked(true); + } + ScreenOrientation orientation = configuration.getOrientation(state); + setImageDescriptor(configuration.getOrientationImage(orientation, flip)); + } + + @Override + public void run() { + mConfiguration.selectDeviceState(mState); + mConfiguration.onDeviceConfigChange(); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/SelectThemeAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/SelectThemeAction.java new file mode 100644 index 000000000..d062849d1 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/SelectThemeAction.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IAction; + +/** + * Action which brings up the "Create new XML File" wizard, pre-selected with the + * animation category + */ +class SelectThemeAction extends Action { + private final ConfigurationChooser mConfiguration; + private final String mTheme; + + public SelectThemeAction(ConfigurationChooser configuration, String title, String theme, + boolean selected) { + super(title, IAction.AS_RADIO_BUTTON); + assert theme.startsWith(STYLE_RESOURCE_PREFIX) + || theme.startsWith(ANDROID_STYLE_RESOURCE_PREFIX) : theme; + mConfiguration = configuration; + mTheme = theme; + if (selected) { + setChecked(selected); + } + } + + @Override + public void run() { + mConfiguration.selectTheme(mTheme); + mConfiguration.onThemeChange(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/TargetMenuListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/TargetMenuListener.java new file mode 100644 index 000000000..71905f7c9 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/TargetMenuListener.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.sdklib.AndroidVersion; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.ToolItem; + +import java.util.List; +import java.util.RandomAccess; + +/** + * The {@linkplain TargetMenuListener} class is responsible for + * generating the rendering target menu in the {@link ConfigurationChooser}. + */ +class TargetMenuListener extends SelectionAdapter { + private final ConfigurationChooser mConfigChooser; + private final IAndroidTarget mTarget; + private final boolean mPickBest; + + TargetMenuListener( + @NonNull ConfigurationChooser configChooser, + @Nullable IAndroidTarget target, + boolean pickBest) { + mConfigChooser = configChooser; + mTarget = target; + mPickBest = pickBest; + } + + @Override + public void widgetSelected(SelectionEvent e) { + IAndroidTarget target = mTarget; + AdtPrefs prefs = AdtPrefs.getPrefs(); + if (mPickBest) { + boolean autoPick = prefs.isAutoPickRenderTarget(); + autoPick = !autoPick; + prefs.setAutoPickRenderTarget(autoPick); + if (autoPick) { + target = ConfigurationMatcher.findDefaultRenderTarget(mConfigChooser); + } else { + // Turn it off, but keep current target until another one is chosen + return; + } + } else { + // Manually picked some other target: turn off auto-pick + prefs.setAutoPickRenderTarget(false); + } + mConfigChooser.selectTarget(target); + mConfigChooser.onRenderingTargetChange(); + } + + static void show(ConfigurationChooser chooser, ToolItem combo) { + Menu menu = new Menu(chooser.getShell(), SWT.POP_UP); + Configuration configuration = chooser.getConfiguration(); + IAndroidTarget current = configuration.getTarget(); + List<IAndroidTarget> targets = chooser.getTargetList(); + boolean haveRecent = false; + + MenuItem menuItem = new MenuItem(menu, SWT.CHECK); + menuItem.setText("Automatically Pick Best"); + menuItem.addSelectionListener(new TargetMenuListener(chooser, null, true)); + if (AdtPrefs.getPrefs().isAutoPickRenderTarget()) { + menuItem.setSelection(true); + } + + @SuppressWarnings("unused") + MenuItem separator = new MenuItem(menu, SWT.SEPARATOR); + + // Process in reverse order: most important targets first + assert targets instanceof RandomAccess; + for (int i = targets.size() - 1; i >= 0; i--) { + IAndroidTarget target = targets.get(i); + + AndroidVersion version = target.getVersion(); + if (version.getApiLevel() >= 7) { + haveRecent = true; + } else if (haveRecent) { + // Don't show ancient rendering targets; they're pretty broken + // (unless of course all you have are ancient targets) + break; + } + + String title = ConfigurationChooser.getRenderingTargetLabel(target, false); + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(title); + + boolean selected = current == target; + if (selected) { + item.setSelection(true); + } + + item.addSelectionListener(new TargetMenuListener(chooser, target, false)); + } + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ThemeMenuAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ThemeMenuAction.java new file mode 100644 index 000000000..b1ce21d36 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/ThemeMenuAction.java @@ -0,0 +1,318 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; + +import com.android.ide.eclipse.adt.internal.editors.Hyperlinks; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SubmenuAction; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo.ActivityAttributes; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.text.hyperlink.IHyperlink; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.ToolItem; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Action which creates a submenu displaying available themes + */ +class ThemeMenuAction extends SubmenuAction { + private static final String DEVICE_LIGHT_PREFIX = + ANDROID_STYLE_RESOURCE_PREFIX + "Theme.DeviceDefault.Light"; //$NON-NLS-1$ + private static final String HOLO_LIGHT_PREFIX = + ANDROID_STYLE_RESOURCE_PREFIX + "Theme.Holo.Light"; //$NON-NLS-1$ + private static final String DEVICE_PREFIX = + ANDROID_STYLE_RESOURCE_PREFIX + "Theme.DeviceDefault"; //$NON-NLS-1$ + private static final String HOLO_PREFIX = + ANDROID_STYLE_RESOURCE_PREFIX + "Theme.Holo"; //$NON-NLS-1$ + private static final String LIGHT_PREFIX = + ANDROID_STYLE_RESOURCE_PREFIX +"Theme.Light"; //$NON-NLS-1$ + private static final String THEME_PREFIX = + ANDROID_STYLE_RESOURCE_PREFIX +"Theme"; //$NON-NLS-1$ + + // Constants used to indicate what type of menu is being shown, such that + // the submenus can lazily construct their contents + private static final int MENU_MANIFEST = 1; + private static final int MENU_PROJECT = 2; + private static final int MENU_THEME = 3; + private static final int MENU_THEME_LIGHT = 4; + private static final int MENU_HOLO = 5; + private static final int MENU_HOLO_LIGHT = 6; + private static final int MENU_DEVICE = 7; + private static final int MENU_DEVICE_LIGHT = 8; + private static final int MENU_ALL = 9; + + private final ConfigurationChooser mConfigChooser; + private final List<String> mThemeList; + /** Type of menu; one of the constants {@link #MENU_ALL} etc */ + private final int mType; + + ThemeMenuAction(int type, String title, ConfigurationChooser configuration, + List<String> themeList) { + super(title); + mType = type; + mConfigChooser = configuration; + mThemeList = themeList; + } + + static void showThemeMenu(ConfigurationChooser configChooser, ToolItem combo, + List<String> themeList) { + MenuManager manager = new MenuManager(); + + // First show the currently selected theme (grayed out since you can't + // reselect it) + Configuration configuration = configChooser.getConfiguration(); + String currentTheme = configuration.getTheme(); + String currentName = null; + if (currentTheme != null) { + currentName = ResourceHelper.styleToTheme(currentTheme); + SelectThemeAction action = new SelectThemeAction(configChooser, + currentName, + currentTheme, + true /* selected */); + action.setEnabled(false); + manager.add(action); + manager.add(new Separator()); + } + + String preferred = configuration.computePreferredTheme(); + if (preferred != null && !preferred.equals(currentTheme)) { + manager.add(new SelectThemeAction(configChooser, + ResourceHelper.styleToTheme(preferred), + preferred, false /* selected */)); + manager.add(new Separator()); + } + + IAndroidTarget target = configuration.getTarget(); + int apiLevel = target != null ? target.getVersion().getApiLevel() : 1; + boolean hasHolo = apiLevel >= 11; // Honeycomb + boolean hasDeviceDefault = apiLevel >= 14; // ICS + + // TODO: Add variations of the current theme here, e.g. + // if you're using Theme.Holo, add Theme.Holo.Dialog, Theme.Holo.Panel, + // Theme.Holo.Wallpaper etc + + manager.add(new ThemeMenuAction(MENU_PROJECT, "Project Themes", + configChooser, themeList)); + manager.add(new ThemeMenuAction(MENU_MANIFEST, "Manifest Themes", + configChooser, themeList)); + + manager.add(new Separator()); + + if (hasHolo) { + manager.add(new ThemeMenuAction(MENU_HOLO, "Holo", + configChooser, themeList)); + manager.add(new ThemeMenuAction(MENU_HOLO_LIGHT, "Holo.Light", + configChooser, themeList)); + } + if (hasDeviceDefault) { + manager.add(new ThemeMenuAction(MENU_DEVICE, "DeviceDefault", + configChooser, themeList)); + manager.add(new ThemeMenuAction(MENU_DEVICE_LIGHT, "DeviceDefault.Light", + configChooser, themeList)); + } + manager.add(new ThemeMenuAction(MENU_THEME, "Theme", + configChooser, themeList)); + manager.add(new ThemeMenuAction(MENU_THEME_LIGHT, "Theme.Light", + configChooser, themeList)); + + // TODO: Add generic types like Wallpaper, Dialog, Alert, etc here, with + // submenus for picking it within each theme category? + + manager.add(new Separator()); + manager.add(new ThemeMenuAction(MENU_ALL, "All", + configChooser, themeList)); + + if (currentTheme != null) { + assert currentName != null; + manager.add(new Separator()); + String title = String.format("Open %1$s Declaration...", currentName); + manager.add(new OpenThemeAction(title, configChooser.getEditedFile(), currentTheme)); + } + + Menu menu = manager.createContextMenu(configChooser.getShell()); + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } + + @Override + protected void addMenuItems(Menu menu) { + switch (mType) { + case MENU_ALL: + addMenuItems(menu, mThemeList); + break; + + case MENU_MANIFEST: { + IProject project = mConfigChooser.getEditedFile().getProject(); + ManifestInfo manifest = ManifestInfo.get(project); + Configuration configuration = mConfigChooser.getConfiguration(); + String activity = configuration.getActivity(); + if (activity != null) { + ActivityAttributes attributes = manifest.getActivityAttributes(activity); + if (attributes != null) { + String theme = attributes.getTheme(); + if (theme != null) { + addMenuItem(menu, theme, isSelectedTheme(theme)); + } + } + } + + String manifestTheme = manifest.getManifestTheme(); + boolean found = false; + Set<String> allThemes = new HashSet<String>(); + if (manifestTheme != null) { + found = true; + allThemes.add(manifestTheme); + } + for (ActivityAttributes info : manifest.getActivityAttributesMap().values()) { + if (info.getTheme() != null) { + found = true; + allThemes.add(info.getTheme()); + } + } + List<String> sorted = new ArrayList<String>(allThemes); + Collections.sort(sorted); + String current = configuration.getTheme(); + for (String theme : sorted) { + boolean selected = theme.equals(current); + addMenuItem(menu, theme, selected); + } + if (!found) { + addDisabledMessageItem("No themes are registered in the manifest"); + } + break; + } + case MENU_PROJECT: { + int size = mThemeList.size(); + List<String> themes = new ArrayList<String>(size); + for (int i = 0; i < size; i++) { + String theme = mThemeList.get(i); + if (ResourceHelper.isProjectStyle(theme)) { + themes.add(theme); + } + } + if (themes.isEmpty()) { + addDisabledMessageItem("There are no local theme styles in the project"); + } else { + addMenuItems(menu, themes); + } + break; + } + case MENU_THEME: { + // Can't just use the usual filterThemes() call here because we need + // to exclude on multiple prefixes: Holo, DeviceDefault, Light, ... + List<String> themes = new ArrayList<String>(mThemeList.size()); + for (String theme : mThemeList) { + if (theme.startsWith(THEME_PREFIX) + && !theme.startsWith(LIGHT_PREFIX) + && !theme.startsWith(HOLO_PREFIX) + && !theme.startsWith(DEVICE_PREFIX)) { + themes.add(theme); + } + } + + addMenuItems(menu, themes); + break; + } + case MENU_THEME_LIGHT: + addMenuItems(menu, filterThemes(LIGHT_PREFIX, null)); + break; + case MENU_HOLO: + addMenuItems(menu, filterThemes(HOLO_PREFIX, HOLO_LIGHT_PREFIX)); + break; + case MENU_HOLO_LIGHT: + addMenuItems(menu, filterThemes(HOLO_LIGHT_PREFIX, null)); + break; + case MENU_DEVICE: + addMenuItems(menu, filterThemes(DEVICE_PREFIX, DEVICE_LIGHT_PREFIX)); + break; + case MENU_DEVICE_LIGHT: + addMenuItems(menu, filterThemes(DEVICE_LIGHT_PREFIX, null)); + break; + } + } + + private List<String> filterThemes(String include, String exclude) { + List<String> themes = new ArrayList<String>(mThemeList.size()); + for (String theme : mThemeList) { + if (theme.startsWith(include) && (exclude == null || !theme.startsWith(exclude))) { + themes.add(theme); + } + } + + return themes; + } + + private void addMenuItems(Menu menu, List<String> themes) { + String current = mConfigChooser.getConfiguration().getTheme(); + for (String theme : themes) { + addMenuItem(menu, theme, theme.equals(current)); + } + } + + private boolean isSelectedTheme(String theme) { + return theme.equals(mConfigChooser.getConfiguration().getTheme()); + } + + private void addMenuItem(Menu menu, String theme, boolean selected) { + String title = ResourceHelper.styleToTheme(theme); + SelectThemeAction action = new SelectThemeAction(mConfigChooser, title, theme, selected); + new ActionContributionItem(action).fill(menu, -1); + } + + private static class OpenThemeAction extends Action { + private final String mTheme; + private final IFile mFile; + + private OpenThemeAction(String title, IFile file, String theme) { + super(title, IAction.AS_PUSH_BUTTON); + mFile = file; + mTheme = theme; + } + + @Override + public void run() { + IProject project = mFile.getProject(); + IHyperlink[] links = Hyperlinks.getResourceLinks(null, mTheme, project, null); + if (links != null && links.length > 0) { + IHyperlink link = links[0]; + link.open(); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/VaryingConfiguration.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/VaryingConfiguration.java new file mode 100644 index 000000000..f472cd6b3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/configuration/VaryingConfiguration.java @@ -0,0 +1,509 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.configuration; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.resources.Density; +import com.android.resources.NightMode; +import com.android.resources.UiMode; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.Hardware; +import com.android.sdklib.devices.Screen; +import com.android.sdklib.devices.State; + +import java.util.Collection; +import java.util.List; + +/** + * An {@linkplain VaryingConfiguration} is a {@link Configuration} which + * inherits all of its values from a different configuration, except for one or + * more attributes where it overrides a custom value, and the overridden value + * will always <b>differ</b> from the inherited value! + * <p> + * For example, a {@linkplain VaryingConfiguration} may state that it + * overrides the locale, and if the inherited locale is "en", then the returned + * locale from the {@linkplain VaryingConfiguration} may be for example "nb", + * but never "en". + * <p> + * The configuration will attempt to make its changed inherited value to be as + * different as possible from the inherited value. Thus, a configuration which + * overrides the device will probably return a phone-sized screen if the + * inherited device is a tablet, or vice versa. + */ +public class VaryingConfiguration extends NestedConfiguration { + /** Variation version; see {@link #setVariation(int)} */ + private int mVariation; + + /** Variation version count; see {@link #setVariationCount(int)} */ + private int mVariationCount; + + /** Bitmask of attributes to be varied/alternated from the parent */ + private int mAlternate; + + /** + * Constructs a new {@linkplain VaryingConfiguration}. + * Construct via + * + * @param chooser the associated chooser + * @param configuration the configuration to inherit from + */ + private VaryingConfiguration( + @NonNull ConfigurationChooser chooser, + @NonNull Configuration configuration) { + super(chooser, configuration); + } + + /** + * Creates a new {@linkplain Configuration} which inherits values from the + * given parent {@linkplain Configuration}, possibly overriding some as + * well. + * + * @param chooser the associated chooser + * @param parent the configuration to inherit values from + * @return a new configuration + */ + @NonNull + public static VaryingConfiguration create(@NonNull ConfigurationChooser chooser, + @NonNull Configuration parent) { + return new VaryingConfiguration(chooser, parent); + } + + /** + * Creates a new {@linkplain VaryingConfiguration} that has the same overriding + * attributes as the given other {@linkplain VaryingConfiguration}. + * + * @param other the configuration to copy overrides from + * @param parent the parent to tie the configuration to for inheriting values + * @return a new configuration + */ + @NonNull + public static VaryingConfiguration create( + @NonNull VaryingConfiguration other, + @NonNull Configuration parent) { + VaryingConfiguration configuration = + new VaryingConfiguration(other.mConfigChooser, parent); + initFrom(configuration, other, other, false); + configuration.mAlternate = other.mAlternate; + configuration.mVariation = other.mVariation; + configuration.mVariationCount = other.mVariationCount; + configuration.syncFolderConfig(); + + return configuration; + } + + /** + * Returns the alternate flags for this configuration. Corresponds to + * the {@code CFG_} flags in {@link ConfigurationClient}. + * + * @return the bitmask + */ + public int getAlternateFlags() { + return mAlternate; + } + + @Override + public void syncFolderConfig() { + super.syncFolderConfig(); + updateDisplayName(); + } + + /** + * Sets the variation version for this + * {@linkplain VaryingConfiguration}. There might be multiple + * {@linkplain VaryingConfiguration} instances inheriting from a + * {@link Configuration}. The variation version allows them to choose + * different complementing values, so they don't all flip to the same other + * (out of multiple choices) value. The {@link #setVariationCount(int)} + * value can be used to determine how to partition the buckets of values. + * Also updates the variation count if necessary. + * + * @param variation variation version + */ + public void setVariation(int variation) { + mVariation = variation; + mVariationCount = Math.max(mVariationCount, variation + 1); + } + + /** + * Sets the number of {@link VaryingConfiguration} variations mapped + * to the same parent configuration as this one. See + * {@link #setVariation(int)} for details. + * + * @param count the total number of variation versions + */ + public void setVariationCount(int count) { + mVariationCount = count; + } + + /** + * Updates the display name in this configuration based on the values and override settings + */ + public void updateDisplayName() { + setDisplayName(computeDisplayName()); + } + + @Override + @NonNull + public Locale getLocale() { + if (isOverridingLocale()) { + return super.getLocale(); + } + Locale locale = mParent.getLocale(); + if (isAlternatingLocale() && locale != null) { + List<Locale> locales = mConfigChooser.getLocaleList(); + for (Locale l : locales) { + // TODO: Try to be smarter about which one we pick; for example, try + // to pick a language that is substantially different from the inherited + // language, such as either with the strings of the largest or shortest + // length, or perhaps based on some geography or population metrics + if (!l.equals(locale)) { + locale = l; + break; + } + } + } + + return locale; + } + + @Override + @Nullable + public IAndroidTarget getTarget() { + if (isOverridingTarget()) { + return super.getTarget(); + } + IAndroidTarget target = mParent.getTarget(); + if (isAlternatingTarget() && target != null) { + List<IAndroidTarget> targets = mConfigChooser.getTargetList(); + if (!targets.isEmpty()) { + // Pick a different target: if you're showing the most recent render target, + // then pick the lowest supported target, and vice versa + IAndroidTarget mostRecent = targets.get(targets.size() - 1); + if (target.equals(mostRecent)) { + // Find oldest supported + ManifestInfo info = ManifestInfo.get(mConfigChooser.getProject()); + int minSdkVersion = info.getMinSdkVersion(); + for (IAndroidTarget t : targets) { + if (t.getVersion().getApiLevel() >= minSdkVersion) { + target = t; + break; + } + } + } else { + target = mostRecent; + } + } + } + + return target; + } + + // Cached values, key=parent's device, cached value=device + private Device mPrevParentDevice; + private Device mPrevDevice; + + @Override + @Nullable + public Device getDevice() { + if (isOverridingDevice()) { + return super.getDevice(); + } + Device device = mParent.getDevice(); + if (isAlternatingDevice() && device != null) { + if (device == mPrevParentDevice) { + return mPrevDevice; + } + + mPrevParentDevice = device; + + // Pick a different device + Collection<Device> devices = mConfigChooser.getDevices(); + + // Divide up the available devices into {@link #mVariationCount} + 1 buckets + // (the + 1 is for the bucket now taken up by the inherited value). + // Then assign buckets to each {@link #mVariation} version, and pick one + // from the bucket assigned to this current configuration's variation version. + + // I could just divide up the device list count, but that would treat a lot of + // very similar phones as having the same kind of variety as the 7" and 10" + // tablets which are sitting right next to each other in the device list. + // Instead, do this by screen size. + + + double smallest = 100; + double biggest = 1; + for (Device d : devices) { + double size = getScreenSize(d); + if (size < 0) { + continue; // no data + } + if (size >= biggest) { + biggest = size; + } + if (size <= smallest) { + smallest = size; + } + } + + int bucketCount = mVariationCount + 1; + double inchesPerBucket = (biggest - smallest) / bucketCount; + + double overriddenSize = getScreenSize(device); + int overriddenBucket = (int) ((overriddenSize - smallest) / inchesPerBucket); + int bucket = (mVariation < overriddenBucket) ? mVariation : mVariation + 1; + double from = inchesPerBucket * bucket + smallest; + double to = from + inchesPerBucket; + if (biggest - to < 0.1) { + to = biggest + 0.1; + } + + boolean canScaleNinePatch = supports(Capability.FIXED_SCALABLE_NINE_PATCH); + for (Device d : devices) { + double size = getScreenSize(d); + if (size >= from && size < to) { + if (!canScaleNinePatch) { + Density density = getDensity(d); + if (density == Density.TV || density == Density.LOW) { + continue; + } + } + + device = d; + break; + } + } + + mPrevDevice = device; + } + + return device; + } + + /** + * Returns the density of the given device + * + * @param device the device to check + * @return the density or null + */ + @Nullable + private static Density getDensity(@NonNull Device device) { + Hardware hardware = device.getDefaultHardware(); + if (hardware != null) { + Screen screen = hardware.getScreen(); + if (screen != null) { + return screen.getPixelDensity(); + } + } + + return null; + } + + /** + * Returns the diagonal length of the given device + * + * @param device the device to check + * @return the diagonal length or -1 + */ + private static double getScreenSize(@NonNull Device device) { + Hardware hardware = device.getDefaultHardware(); + if (hardware != null) { + Screen screen = hardware.getScreen(); + if (screen != null) { + return screen.getDiagonalLength(); + } + } + + return -1; + } + + @Override + @Nullable + public State getDeviceState() { + if (isOverridingDeviceState()) { + return super.getDeviceState(); + } + State state = mParent.getDeviceState(); + if (isAlternatingDeviceState() && state != null) { + State alternate = getNextDeviceState(state); + + return alternate; + } else { + if ((isAlternatingDevice() || isOverridingDevice()) && state != null) { + // If the device differs, I need to look up a suitable equivalent state + // on our device + Device device = getDevice(); + if (device != null) { + return device.getState(state.getName()); + } + } + + return state; + } + } + + @Override + @NonNull + public NightMode getNightMode() { + if (isOverridingNightMode()) { + return super.getNightMode(); + } + NightMode nightMode = mParent.getNightMode(); + if (isAlternatingNightMode() && nightMode != null) { + nightMode = nightMode == NightMode.NIGHT ? NightMode.NOTNIGHT : NightMode.NIGHT; + return nightMode; + } else { + return nightMode; + } + } + + @Override + @NonNull + public UiMode getUiMode() { + if (isOverridingUiMode()) { + return super.getUiMode(); + } + UiMode uiMode = mParent.getUiMode(); + if (isAlternatingUiMode() && uiMode != null) { + // TODO: Use manifest's supports screen to decide which are most relevant + // (as well as which available configuration qualifiers are present in the + // layout) + UiMode[] values = UiMode.values(); + uiMode = values[(uiMode.ordinal() + 1) % values.length]; + return uiMode; + } else { + return uiMode; + } + } + + @Override + @Nullable + public String computeDisplayName() { + return computeDisplayName(getOverrideFlags() | mAlternate, this); + } + + /** + * Sets whether the locale should be alternated by this configuration + * + * @param alternate if true, alternate the inherited value + */ + public void setAlternateLocale(boolean alternate) { + mAlternate |= CFG_LOCALE; + } + + /** + * Returns true if the locale is alternated + * + * @return true if the locale is alternated + */ + public final boolean isAlternatingLocale() { + return (mAlternate & CFG_LOCALE) != 0; + } + + /** + * Sets whether the rendering target should be alternated by this configuration + * + * @param alternate if true, alternate the inherited value + */ + public void setAlternateTarget(boolean alternate) { + mAlternate |= CFG_TARGET; + } + + /** + * Returns true if the target is alternated + * + * @return true if the target is alternated + */ + public final boolean isAlternatingTarget() { + return (mAlternate & CFG_TARGET) != 0; + } + + /** + * Sets whether the device should be alternated by this configuration + * + * @param alternate if true, alternate the inherited value + */ + public void setAlternateDevice(boolean alternate) { + mAlternate |= CFG_DEVICE; + } + + /** + * Returns true if the device is alternated + * + * @return true if the device is alternated + */ + public final boolean isAlternatingDevice() { + return (mAlternate & CFG_DEVICE) != 0; + } + + /** + * Sets whether the device state should be alternated by this configuration + * + * @param alternate if true, alternate the inherited value + */ + public void setAlternateDeviceState(boolean alternate) { + mAlternate |= CFG_DEVICE_STATE; + } + + /** + * Returns true if the device state is alternated + * + * @return true if the device state is alternated + */ + public final boolean isAlternatingDeviceState() { + return (mAlternate & CFG_DEVICE_STATE) != 0; + } + + /** + * Sets whether the night mode should be alternated by this configuration + * + * @param alternate if true, alternate the inherited value + */ + public void setAlternateNightMode(boolean alternate) { + mAlternate |= CFG_NIGHT_MODE; + } + + /** + * Returns true if the night mode is alternated + * + * @return true if the night mode is alternated + */ + public final boolean isAlternatingNightMode() { + return (mAlternate & CFG_NIGHT_MODE) != 0; + } + + /** + * Sets whether the UI mode should be alternated by this configuration + * + * @param alternate if true, alternate the inherited value + */ + public void setAlternateUiMode(boolean alternate) { + mAlternate |= CFG_UI_MODE; + } + + /** + * Returns true if the UI mode is alternated + * + * @return true if the UI mode is alternated + */ + public final boolean isAlternatingUiMode() { + return (mAlternate & CFG_UI_MODE) != 0; + } + +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/CustomViewDescriptorService.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/CustomViewDescriptorService.java new file mode 100644 index 000000000..6df6929a7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/CustomViewDescriptorService.java @@ -0,0 +1,621 @@ +/* + * 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.eclipse.adt.internal.editors.layout.descriptors; + +import static com.android.SdkConstants.ANDROID_NS_NAME_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.AUTO_URI; +import static com.android.SdkConstants.CLASS_VIEWGROUP; +import static com.android.SdkConstants.URI_PREFIX; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.resources.ResourceFile; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.platform.AttributeInfo; +import com.android.ide.common.resources.platform.AttrsXmlParser; +import com.android.ide.common.resources.platform.ViewClassInfo; +import com.android.ide.common.resources.platform.ViewClassInfo.LayoutParamsInfo; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.ProjectState; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.ResourceType; +import com.android.sdklib.IAndroidTarget; +import com.google.common.collect.Maps; +import com.google.common.collect.ObjectArrays; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jdt.core.IClassFile; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.ITypeHierarchy; +import org.eclipse.jdt.core.JavaCore; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.swt.graphics.Image; + +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; + +/** + * Service responsible for creating/managing {@link ViewElementDescriptor} objects for custom + * View classes per project. + * <p/> + * The service provides an on-demand monitoring of custom classes to check for changes. Monitoring + * starts once a request for an {@link ViewElementDescriptor} object has been done for a specific + * class. + * <p/> + * The monitoring will notify a listener of any changes in the class triggering a change in its + * associated {@link ViewElementDescriptor} object. + * <p/> + * If the custom class does not exist, no monitoring is put in place to avoid having to listen + * to all class changes in the projects. + */ +public final class CustomViewDescriptorService { + + private static CustomViewDescriptorService sThis = new CustomViewDescriptorService(); + + /** + * Map where keys are the project, and values are another map containing all the known + * custom View class for this project. The custom View class are stored in a map + * where the keys are the fully qualified class name, and the values are their associated + * {@link ViewElementDescriptor}. + */ + private HashMap<IProject, HashMap<String, ViewElementDescriptor>> mCustomDescriptorMap = + new HashMap<IProject, HashMap<String, ViewElementDescriptor>>(); + + /** + * TODO will be used to update the ViewElementDescriptor of the custom view when it + * is modified (either the class itself or its attributes.xml) + */ + @SuppressWarnings("unused") + private ICustomViewDescriptorListener mListener; + + /** + * Classes which implements this interface provide a method that deal with modifications + * in custom View class triggering a change in its associated {@link ViewClassInfo} object. + */ + public interface ICustomViewDescriptorListener { + /** + * Sent when a custom View class has changed and + * its {@link ViewElementDescriptor} was modified. + * + * @param project the project containing the class. + * @param className the fully qualified class name. + * @param descriptor the updated ElementDescriptor. + */ + public void updatedClassInfo(IProject project, + String className, + ViewElementDescriptor descriptor); + } + + /** + * Returns the singleton instance of {@link CustomViewDescriptorService}. + */ + public static CustomViewDescriptorService getInstance() { + return sThis; + } + + /** + * Sets the listener receiving custom View class modification notifications. + * @param listener the listener to receive the notifications. + * + * TODO will be used to update the ViewElementDescriptor of the custom view when it + * is modified (either the class itself or its attributes.xml) + */ + public void setListener(ICustomViewDescriptorListener listener) { + mListener = listener; + } + + /** + * Returns the {@link ViewElementDescriptor} for a particular project/class when the + * fully qualified class name actually matches a class from the given project. + * <p/> + * Custom descriptors are created as needed. + * <p/> + * If it is the first time the {@link ViewElementDescriptor} is requested, the method + * will check that the specified class is in fact a custom View class. Once this is + * established, a monitoring for that particular class is initiated. Any change will + * trigger a notification to the {@link ICustomViewDescriptorListener}. + * + * @param project the project containing the class. + * @param fqcn the fully qualified name of the class. + * @return a {@link ViewElementDescriptor} or <code>null</code> if the class was not + * a custom View class. + */ + public ViewElementDescriptor getDescriptor(IProject project, String fqcn) { + // look in the map first + synchronized (mCustomDescriptorMap) { + HashMap<String, ViewElementDescriptor> map = mCustomDescriptorMap.get(project); + + if (map != null) { + ViewElementDescriptor descriptor = map.get(fqcn); + if (descriptor != null) { + return descriptor; + } + } + + // if we step here, it looks like we haven't created it yet. + // First lets check this is in fact a valid type in the project + + try { + // We expect the project to be both opened and of java type (since it's an android + // project), so we can create a IJavaProject object from our IProject. + IJavaProject javaProject = JavaCore.create(project); + + // replace $ by . in the class name + String javaClassName = fqcn.replaceAll("\\$", "\\."); //$NON-NLS-1$ //$NON-NLS-2$ + + // look for the IType object for this class + IType type = javaProject.findType(javaClassName); + if (type != null && type.exists()) { + // the type exists. Let's get the parent class and its ViewClassInfo. + + // get the type hierarchy + ITypeHierarchy hierarchy = type.newSupertypeHierarchy( + new NullProgressMonitor()); + + ViewElementDescriptor parentDescriptor = createViewDescriptor( + hierarchy.getSuperclass(type), project, hierarchy); + + if (parentDescriptor != null) { + // we have a valid parent, lets create a new ViewElementDescriptor. + List<AttributeDescriptor> attrList = new ArrayList<AttributeDescriptor>(); + List<AttributeDescriptor> paramList = new ArrayList<AttributeDescriptor>(); + Map<ResourceFile, Long> files = findCustomDescriptors(project, type, + attrList, paramList); + + AttributeDescriptor[] attributes = + getAttributeDescriptor(type, parentDescriptor); + if (!attrList.isEmpty()) { + attributes = join(attrList, attributes); + } + AttributeDescriptor[] layoutAttributes = + getLayoutAttributeDescriptors(type, parentDescriptor); + if (!paramList.isEmpty()) { + layoutAttributes = join(paramList, layoutAttributes); + } + String name = DescriptorsUtils.getBasename(fqcn); + ViewElementDescriptor descriptor = new CustomViewDescriptor(name, fqcn, + attributes, + layoutAttributes, + parentDescriptor.getChildren(), + project, files); + descriptor.setSuperClass(parentDescriptor); + + synchronized (mCustomDescriptorMap) { + map = mCustomDescriptorMap.get(project); + if (map == null) { + map = new HashMap<String, ViewElementDescriptor>(); + mCustomDescriptorMap.put(project, map); + } + + map.put(fqcn, descriptor); + } + + //TODO setup listener on this resource change. + + return descriptor; + } + } + } catch (JavaModelException e) { + // there was an error accessing any of the IType, we'll just return null; + } + } + + return null; + } + + private static AttributeDescriptor[] join( + @NonNull List<AttributeDescriptor> attributeList, + @NonNull AttributeDescriptor[] attributes) { + if (!attributeList.isEmpty()) { + return ObjectArrays.concat( + attributeList.toArray(new AttributeDescriptor[attributeList.size()]), + attributes, + AttributeDescriptor.class); + } else { + return attributes; + } + + } + + /** Cache used by {@link #getParser(ResourceFile)} */ + private Map<ResourceFile, AttrsXmlParser> mParserCache; + + private AttrsXmlParser getParser(ResourceFile file) { + if (mParserCache == null) { + mParserCache = new HashMap<ResourceFile, AttrsXmlParser>(); + } + + AttrsXmlParser parser = mParserCache.get(file); + if (parser == null) { + parser = new AttrsXmlParser( + file.getFile().getOsLocation(), + AdtPlugin.getDefault(), 20); + parser.preload(); + mParserCache.put(file, parser); + } + + return parser; + } + + /** Compute/find the styleable resources for the given type, if possible */ + private Map<ResourceFile, Long> findCustomDescriptors( + IProject project, + IType type, + List<AttributeDescriptor> customAttributes, + List<AttributeDescriptor> customLayoutAttributes) { + // Look up the project where the type is declared (could be a library project; + // we cannot use type.getJavaProject().getProject()) + IProject library = getProjectDeclaringType(type); + if (library == null) { + library = project; + } + + String className = type.getElementName(); + Set<ResourceFile> resourceFiles = findAttrsFiles(library, className); + if (resourceFiles != null && resourceFiles.size() > 0) { + String appUri = getAppResUri(project); + Map<ResourceFile, Long> timestamps = + Maps.newHashMapWithExpectedSize(resourceFiles.size()); + for (ResourceFile file : resourceFiles) { + AttrsXmlParser attrsXmlParser = getParser(file); + String fqcn = type.getFullyQualifiedName(); + + // Attributes + ViewClassInfo classInfo = new ViewClassInfo(true, fqcn, className); + attrsXmlParser.loadViewAttributes(classInfo); + appendAttributes(customAttributes, classInfo.getAttributes(), appUri); + + // Layout params + LayoutParamsInfo layoutInfo = new ViewClassInfo.LayoutParamsInfo( + classInfo, "Layout", null /*superClassInfo*/); //$NON-NLS-1$ + attrsXmlParser.loadLayoutParamsAttributes(layoutInfo); + appendAttributes(customLayoutAttributes, layoutInfo.getAttributes(), appUri); + + timestamps.put(file, file.getFile().getModificationStamp()); + } + + return timestamps; + } + + return null; + } + + /** + * Finds the set of XML files (if any) in the given library declaring + * attributes for the given class name + */ + @Nullable + private static Set<ResourceFile> findAttrsFiles(IProject library, String className) { + Set<ResourceFile> resourceFiles = null; + ResourceManager manager = ResourceManager.getInstance(); + ProjectResources resources = manager.getProjectResources(library); + if (resources != null) { + Collection<ResourceItem> items = + resources.getResourceItemsOfType(ResourceType.DECLARE_STYLEABLE); + for (ResourceItem item : items) { + String viewName = item.getName(); + if (viewName.equals(className) + || (viewName.startsWith(className) + && viewName.equals(className + "_Layout"))) { //$NON-NLS-1$ + if (resourceFiles == null) { + resourceFiles = new HashSet<ResourceFile>(); + } + resourceFiles.addAll(item.getSourceFileList()); + } + } + } + return resourceFiles; + } + + /** + * Find the project containing this type declaration. We cannot use + * {@link IType#getJavaProject()} since that will return the including + * project and we're after the library project such that we can find the + * attrs.xml file in the same project. + */ + @Nullable + private static IProject getProjectDeclaringType(IType type) { + IClassFile classFile = type.getClassFile(); + if (classFile != null) { + IPath path = classFile.getPath(); + IWorkspaceRoot workspace = ResourcesPlugin.getWorkspace().getRoot(); + IResource resource; + if (path.isAbsolute()) { + resource = AdtUtils.fileToResource(path.toFile()); + } else { + resource = workspace.findMember(path); + } + if (resource != null && resource.getProject() != null) { + return resource.getProject(); + } + } + + return null; + } + + /** Returns the name space to use for application attributes */ + private static String getAppResUri(IProject project) { + String appResource; + ProjectState projectState = Sdk.getProjectState(project); + if (projectState != null && projectState.isLibrary()) { + appResource = AUTO_URI; + } else { + ManifestInfo manifestInfo = ManifestInfo.get(project); + appResource = URI_PREFIX + manifestInfo.getPackage(); + } + return appResource; + } + + + /** Append the {@link AttributeInfo} objects converted {@link AttributeDescriptor} + * objects into the given attribute list. + * <p> + * This is nearly identical to + * {@link DescriptorsUtils#appendAttribute(List, String, String, AttributeInfo, boolean, Map)} + * but it handles namespace declarations in the attrs.xml file where the android: + * namespace is included in the names. + */ + private static void appendAttributes(List<AttributeDescriptor> attributes, + AttributeInfo[] attributeInfos, String appResource) { + // Custom attributes + for (AttributeInfo info : attributeInfos) { + String nsUri; + if (info.getName().startsWith(ANDROID_NS_NAME_PREFIX)) { + info.setName(info.getName().substring(ANDROID_NS_NAME_PREFIX.length())); + nsUri = ANDROID_URI; + } else { + nsUri = appResource; + } + + DescriptorsUtils.appendAttribute(attributes, + null /*elementXmlName*/, nsUri, info, false /*required*/, + null /*overrides*/); + } + } + + /** + * Computes (if needed) and returns the {@link ViewElementDescriptor} for the specified type. + * + * @return A {@link ViewElementDescriptor} or null if type or typeHierarchy is null. + */ + private ViewElementDescriptor createViewDescriptor(IType type, IProject project, + ITypeHierarchy typeHierarchy) { + // check if the type is a built-in View class. + List<ViewElementDescriptor> builtInList = null; + + // give up if there's no type + if (type == null) { + return null; + } + + String fqcn = type.getFullyQualifiedName(); + + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(project); + if (target != null) { + AndroidTargetData data = currentSdk.getTargetData(target); + if (data != null) { + LayoutDescriptors descriptors = data.getLayoutDescriptors(); + ViewElementDescriptor d = descriptors.findDescriptorByClass(fqcn); + if (d != null) { + return d; + } + builtInList = descriptors.getViewDescriptors(); + } + } + } + + // it's not a built-in class? Lets look if the superclass is built-in + // give up if there's no type + if (typeHierarchy == null) { + return null; + } + + IType parentType = typeHierarchy.getSuperclass(type); + if (parentType != null) { + ViewElementDescriptor parentDescriptor = createViewDescriptor(parentType, project, + typeHierarchy); + + if (parentDescriptor != null) { + // parent class is a valid View class with a descriptor, so we create one + // for this class. + String name = DescriptorsUtils.getBasename(fqcn); + // A custom view accepts children if its parent descriptor also does. + // The only exception to this is ViewGroup, which accepts children even though + // its parent does not. + boolean isViewGroup = fqcn.equals(CLASS_VIEWGROUP); + boolean hasChildren = isViewGroup || parentDescriptor.hasChildren(); + ViewElementDescriptor[] children = null; + if (hasChildren && builtInList != null) { + // We can't figure out what the allowable children are by just + // looking at the class, so assume any View is valid + children = builtInList.toArray(new ViewElementDescriptor[builtInList.size()]); + } + ViewElementDescriptor descriptor = new CustomViewDescriptor(name, fqcn, + getAttributeDescriptor(type, parentDescriptor), + getLayoutAttributeDescriptors(type, parentDescriptor), + children, project, null); + descriptor.setSuperClass(parentDescriptor); + + // add it to the map + synchronized (mCustomDescriptorMap) { + HashMap<String, ViewElementDescriptor> map = mCustomDescriptorMap.get(project); + + if (map == null) { + map = new HashMap<String, ViewElementDescriptor>(); + mCustomDescriptorMap.put(project, map); + } + + map.put(fqcn, descriptor); + + } + + //TODO setup listener on this resource change. + + return descriptor; + } + } + + // class is neither a built-in view class, nor extend one. return null. + return null; + } + + /** + * Returns the array of {@link AttributeDescriptor} for the specified {@link IType}. + * <p/> + * The array should contain the descriptor for this type and all its supertypes. + * + * @param type the type for which the {@link AttributeDescriptor} are returned. + * @param parentDescriptor the {@link ViewElementDescriptor} of the direct superclass. + */ + private static AttributeDescriptor[] getAttributeDescriptor(IType type, + ViewElementDescriptor parentDescriptor) { + // TODO add the class attribute descriptors to the parent descriptors. + return parentDescriptor.getAttributes(); + } + + private static AttributeDescriptor[] getLayoutAttributeDescriptors(IType type, + ViewElementDescriptor parentDescriptor) { + return parentDescriptor.getLayoutAttributes(); + } + + private class CustomViewDescriptor extends ViewElementDescriptor { + private Map<ResourceFile, Long> mTimeStamps; + private IProject mProject; + + public CustomViewDescriptor(String name, String fqcn, AttributeDescriptor[] attributes, + AttributeDescriptor[] layoutAttributes, + ElementDescriptor[] children, IProject project, + Map<ResourceFile, Long> timestamps) { + super( + fqcn, // xml name + name, // ui name + fqcn, // full class name + fqcn, // tooltip + null, // sdk_url + attributes, + layoutAttributes, + children, + false // mandatory + ); + mTimeStamps = timestamps; + mProject = project; + } + + @Override + public Image getGenericIcon() { + IconFactory iconFactory = IconFactory.getInstance(); + + int index = mXmlName.lastIndexOf('.'); + if (index != -1) { + return iconFactory.getIcon(mXmlName.substring(index + 1), + "customView"); //$NON-NLS-1$ + } + + return iconFactory.getIcon("customView"); //$NON-NLS-1$ + } + + @Override + public boolean syncAttributes() { + // Check if any of the descriptors + if (mTimeStamps != null) { + // Prevent checking actual file timestamps too frequently on rapid burst calls + long now = System.currentTimeMillis(); + if (now - sLastCheck < 1000) { + return true; + } + sLastCheck = now; + + // Check whether the resource files (typically just one) which defined + // custom attributes for this custom view have changed, and if so, + // refresh the attribute descriptors. + // This doesn't work the cases where you add descriptors for a custom + // view after using it, or add attributes in a separate file, but those + // scenarios aren't quite as common (and would require a bit more expensive + // analysis.) + for (Map.Entry<ResourceFile, Long> entry : mTimeStamps.entrySet()) { + ResourceFile file = entry.getKey(); + Long timestamp = entry.getValue(); + boolean recompute = false; + if (file.getFile().getModificationStamp() > timestamp.longValue()) { + // One or more attributes changed: recompute + recompute = true; + mParserCache.remove(file); + } + + if (recompute) { + IJavaProject javaProject = JavaCore.create(mProject); + String fqcn = getFullClassName(); + IType type = null; + try { + type = javaProject.findType(fqcn); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + if (type == null || !type.exists()) { + return true; + } + + List<AttributeDescriptor> attrList = new ArrayList<AttributeDescriptor>(); + List<AttributeDescriptor> paramList = new ArrayList<AttributeDescriptor>(); + + mTimeStamps = findCustomDescriptors(mProject, type, attrList, paramList); + + ViewElementDescriptor parentDescriptor = getSuperClassDesc(); + AttributeDescriptor[] attributes = + getAttributeDescriptor(type, parentDescriptor); + if (!attrList.isEmpty()) { + attributes = join(attrList, attributes); + } + attributes = attrList.toArray(new AttributeDescriptor[attrList.size()]); + setAttributes(attributes); + + return false; + } + } + } + + return true; + } + } + + /** Timestamp of the most recent {@link CustomViewDescriptor#syncAttributes} check */ + private static long sLastCheck; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/LayoutDescriptors.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/LayoutDescriptors.java new file mode 100644 index 000000000..7b2fe84f0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/LayoutDescriptors.java @@ -0,0 +1,597 @@ +/* + * 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.eclipse.adt.internal.editors.layout.descriptors; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_CLASS; +import static com.android.SdkConstants.ATTR_LAYOUT; +import static com.android.SdkConstants.ATTR_NAME; +import static com.android.SdkConstants.ATTR_TAG; +import static com.android.SdkConstants.CLASS_VIEW; +import static com.android.SdkConstants.FQCN_GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.REQUEST_FOCUS; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; +import static com.android.SdkConstants.VIEW_MERGE; +import static com.android.SdkConstants.VIEW_TAG; + +import com.android.SdkConstants; +import com.android.ide.common.api.IAttributeInfo.Format; +import com.android.ide.common.resources.platform.AttributeInfo; +import com.android.ide.common.resources.platform.DeclareStyleableInfo; +import com.android.ide.common.resources.platform.ViewClassInfo; +import com.android.ide.common.resources.platform.ViewClassInfo.LayoutParamsInfo; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.IDescriptorProvider; +import com.android.ide.eclipse.adt.internal.editors.descriptors.TextAttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.manifest.descriptors.ClassAttributeDescriptor; +import com.android.sdklib.IAndroidTarget; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + + +/** + * Complete description of the layout structure. + */ +public final class LayoutDescriptors implements IDescriptorProvider { + /** The document descriptor. Contains all layouts and views linked together. */ + private DocumentDescriptor mRootDescriptor = + new DocumentDescriptor("layout_doc", null); //$NON-NLS-1$ + + /** The list of all known ViewLayout descriptors. */ + private List<ViewElementDescriptor> mLayoutDescriptors = Collections.emptyList(); + + /** Read-Only list of View Descriptors. */ + private List<ViewElementDescriptor> mROLayoutDescriptors; + + /** The list of all known View (not ViewLayout) descriptors. */ + private List<ViewElementDescriptor> mViewDescriptors = Collections.emptyList(); + + /** Read-Only list of View Descriptors. */ + private List<ViewElementDescriptor> mROViewDescriptors; + + /** The descriptor matching android.view.View. */ + private ViewElementDescriptor mBaseViewDescriptor; + + /** Map from view full class name to view descriptor */ + private Map<String, ViewElementDescriptor> mFqcnToDescriptor = + // As of 3.1 there are 58 items in this map + new HashMap<String, ViewElementDescriptor>(80); + + /** Returns the document descriptor. Contains all layouts and views linked together. */ + @Override + public DocumentDescriptor getDescriptor() { + return mRootDescriptor; + } + + /** Returns the read-only list of all known ViewLayout descriptors. */ + public List<ViewElementDescriptor> getLayoutDescriptors() { + return mROLayoutDescriptors; + } + + /** Returns the read-only list of all known View (not ViewLayout) descriptors. */ + public List<ViewElementDescriptor> getViewDescriptors() { + return mROViewDescriptors; + } + + @Override + public ElementDescriptor[] getRootElementDescriptors() { + return mRootDescriptor.getChildren(); + } + + /** + * Returns the descriptor matching android.view.View, which is guaranteed + * to be a {@link ViewElementDescriptor}. + */ + public ViewElementDescriptor getBaseViewDescriptor() { + if (mBaseViewDescriptor == null) { + mBaseViewDescriptor = findDescriptorByClass(SdkConstants.CLASS_VIEW); + } + return mBaseViewDescriptor; + } + + /** + * Updates the document descriptor. + * <p/> + * It first computes the new children of the descriptor and then update them + * all at once. + * <p/> + * TODO: differentiate groups from views in the tree UI? => rely on icons + * <p/> + * + * @param views The list of views in the framework. + * @param layouts The list of layouts in the framework. + * @param styleMap A map from style names to style information provided by the SDK + * @param target The android target being initialized + */ + public synchronized void updateDescriptors(ViewClassInfo[] views, ViewClassInfo[] layouts, + Map<String, DeclareStyleableInfo> styleMap, IAndroidTarget target) { + + // This map links every ViewClassInfo to the ElementDescriptor we created. + // It is filled by convertView() and used later to fix the super-class hierarchy. + HashMap<ViewClassInfo, ViewElementDescriptor> infoDescMap = + new HashMap<ViewClassInfo, ViewElementDescriptor>(); + + ArrayList<ViewElementDescriptor> newViews = new ArrayList<ViewElementDescriptor>(40); + if (views != null) { + for (ViewClassInfo info : views) { + ViewElementDescriptor desc = convertView(info, infoDescMap); + newViews.add(desc); + mFqcnToDescriptor.put(desc.getFullClassName(), desc); + } + } + + // Create <include> as a synthetic regular view. + // Note: ViewStub is already described by attrs.xml + insertInclude(newViews); + + List<ViewElementDescriptor> newLayouts = new ArrayList<ViewElementDescriptor>(30); + if (layouts != null) { + for (ViewClassInfo info : layouts) { + ViewElementDescriptor desc = convertView(info, infoDescMap); + newLayouts.add(desc); + mFqcnToDescriptor.put(desc.getFullClassName(), desc); + } + } + + // Find View and inherit all its layout attributes + AttributeDescriptor[] frameLayoutAttrs = findViewLayoutAttributes( + SdkConstants.CLASS_FRAMELAYOUT); + + if (target.getVersion().getApiLevel() >= 4) { + ViewElementDescriptor fragmentTag = createFragment(frameLayoutAttrs, styleMap); + newViews.add(fragmentTag); + } + + List<ElementDescriptor> newDescriptors = new ArrayList<ElementDescriptor>(80); + newDescriptors.addAll(newLayouts); + newDescriptors.addAll(newViews); + + ViewElementDescriptor viewTag = createViewTag(frameLayoutAttrs); + newViews.add(viewTag); + newDescriptors.add(viewTag); + + ViewElementDescriptor requestFocus = createRequestFocus(); + newViews.add(requestFocus); + newDescriptors.add(requestFocus); + + // Link all layouts to everything else here.. recursively + for (ViewElementDescriptor layoutDesc : newLayouts) { + layoutDesc.setChildren(newDescriptors); + } + + // The gesture overlay descriptor is really a layout but not included in the layouts list + // so handle it specially + ViewElementDescriptor gestureView = findDescriptorByClass(FQCN_GESTURE_OVERLAY_VIEW); + if (gestureView != null) { + gestureView.setChildren(newDescriptors); + // Inherit layout attributes from FrameLayout + gestureView.setLayoutAttributes(frameLayoutAttrs); + } + + fixSuperClasses(infoDescMap); + + // The <merge> tag can only be a root tag, so it is added at the end. + // It gets everything else as children but it is not made a child itself. + ViewElementDescriptor mergeTag = createMerge(frameLayoutAttrs); + mergeTag.setChildren(newDescriptors); // mergeTag makes a copy of the list + newDescriptors.add(mergeTag); + newLayouts.add(mergeTag); + + // Sort palette contents + Collections.sort(newViews); + Collections.sort(newLayouts); + + mViewDescriptors = newViews; + mLayoutDescriptors = newLayouts; + mRootDescriptor.setChildren(newDescriptors); + + mBaseViewDescriptor = null; + mROLayoutDescriptors = Collections.unmodifiableList(mLayoutDescriptors); + mROViewDescriptors = Collections.unmodifiableList(mViewDescriptors); + } + + /** + * Creates an element descriptor from a given {@link ViewClassInfo}. + * + * @param info The {@link ViewClassInfo} to convert into a new {@link ViewElementDescriptor}. + * @param infoDescMap This map links every ViewClassInfo to the ElementDescriptor it created. + * It is filled by here and used later to fix the super-class hierarchy. + */ + private ViewElementDescriptor convertView( + ViewClassInfo info, + HashMap<ViewClassInfo, ViewElementDescriptor> infoDescMap) { + String xmlName = info.getShortClassName(); + String uiName = xmlName; + String fqcn = info.getFullClassName(); + if (ViewElementDescriptor.viewNeedsPackage(fqcn)) { + xmlName = fqcn; + } + String tooltip = info.getJavaDoc(); + + // Average is around 90, max (in 3.2) is 145 + ArrayList<AttributeDescriptor> attributes = new ArrayList<AttributeDescriptor>(120); + + // All views and groups have an implicit "style" attribute which is a reference. + AttributeInfo styleInfo = new AttributeInfo( + "style", //$NON-NLS-1$ xmlLocalName + Format.REFERENCE_SET); + styleInfo.setJavaDoc("A reference to a custom style"); //tooltip + DescriptorsUtils.appendAttribute(attributes, + "style", //$NON-NLS-1$ + null, //nsUri + styleInfo, + false, //required + null); // overrides + styleInfo.setDefinedBy(SdkConstants.CLASS_VIEW); + + // Process all View attributes + DescriptorsUtils.appendAttributes(attributes, + null, // elementName + ANDROID_URI, + info.getAttributes(), + null, // requiredAttributes + null /* overrides */); + + List<String> attributeSources = new ArrayList<String>(); + if (info.getAttributes() != null && info.getAttributes().length > 0) { + attributeSources.add(fqcn); + } + + for (ViewClassInfo link = info.getSuperClass(); + link != null; + link = link.getSuperClass()) { + AttributeInfo[] attrList = link.getAttributes(); + if (attrList.length > 0) { + attributeSources.add(link.getFullClassName()); + DescriptorsUtils.appendAttributes(attributes, + null, // elementName + ANDROID_URI, + attrList, + null, // requiredAttributes + null /* overrides */); + } + } + + // Process all LayoutParams attributes + ArrayList<AttributeDescriptor> layoutAttributes = new ArrayList<AttributeDescriptor>(); + LayoutParamsInfo layoutParams = info.getLayoutData(); + + for(; layoutParams != null; layoutParams = layoutParams.getSuperClass()) { + for (AttributeInfo attrInfo : layoutParams.getAttributes()) { + if (DescriptorsUtils.containsAttribute(layoutAttributes, + ANDROID_URI, attrInfo)) { + continue; + } + DescriptorsUtils.appendAttribute(layoutAttributes, + null, // elementName + ANDROID_URI, + attrInfo, + false, // required + null /* overrides */); + } + } + + ViewElementDescriptor desc = new ViewElementDescriptor( + xmlName, + uiName, + fqcn, + tooltip, + null, // sdk_url + attributes.toArray(new AttributeDescriptor[attributes.size()]), + layoutAttributes.toArray(new AttributeDescriptor[layoutAttributes.size()]), + null, // children + false /* mandatory */); + desc.setAttributeSources(Collections.unmodifiableList(attributeSources)); + infoDescMap.put(info, desc); + return desc; + } + + /** + * Creates a new {@code <include>} descriptor and adds it to the list of view descriptors. + * + * @param knownViews A list of view descriptors being populated. Also used to find the + * View descriptor and extract its layout attributes. + */ + private void insertInclude(List<ViewElementDescriptor> knownViews) { + String xmlName = VIEW_INCLUDE; + + // Create the include custom attributes + ArrayList<AttributeDescriptor> attributes = new ArrayList<AttributeDescriptor>(); + + // Find View and inherit all its layout attributes + AttributeDescriptor[] viewLayoutAttribs; + AttributeDescriptor[] viewAttributes = null; + ViewElementDescriptor viewDesc = findDescriptorByClass(SdkConstants.CLASS_VIEW); + if (viewDesc != null) { + viewAttributes = viewDesc.getAttributes(); + attributes = new ArrayList<AttributeDescriptor>(viewAttributes.length + 1); + viewLayoutAttribs = viewDesc.getLayoutAttributes(); + } else { + viewLayoutAttribs = new AttributeDescriptor[0]; + } + + // Note that the "layout" attribute does NOT have the Android namespace + DescriptorsUtils.appendAttribute(attributes, + null, //elementXmlName + null, //nsUri + new AttributeInfo( + ATTR_LAYOUT, + Format.REFERENCE_SET ), + true, //required + null); //overrides + + if (viewAttributes != null) { + for (AttributeDescriptor descriptor : viewAttributes) { + attributes.add(descriptor); + } + } + + // Create the include descriptor + ViewElementDescriptor desc = new ViewElementDescriptor(xmlName, + xmlName, // ui_name + VIEW_INCLUDE, // "class name"; the GLE only treats this as an element tag + "Lets you statically include XML layouts inside other XML layouts.", // tooltip + null, // sdk_url + attributes.toArray(new AttributeDescriptor[attributes.size()]), + viewLayoutAttribs, // layout attributes + null, // children + false /* mandatory */); + + knownViews.add(desc); + } + + /** + * Creates and returns a new {@code <merge>} descriptor. + * @param viewLayoutAttribs The layout attributes to use for the new descriptor + */ + private ViewElementDescriptor createMerge(AttributeDescriptor[] viewLayoutAttribs) { + String xmlName = VIEW_MERGE; + + // Create the include descriptor + ViewElementDescriptor desc = new ViewElementDescriptor(xmlName, + xmlName, // ui_name + VIEW_MERGE, // "class name"; the GLE only treats this as an element tag + "A root tag useful for XML layouts inflated using a ViewStub.", // tooltip + null, // sdk_url + null, // attributes + viewLayoutAttribs, // layout attributes + null, // children + false /* mandatory */); + + return desc; + } + + /** + * Creates and returns a new {@code <fragment>} descriptor. + * @param viewLayoutAttribs The layout attributes to use for the new descriptor + * @param styleMap The style map provided by the SDK + */ + private ViewElementDescriptor createFragment(AttributeDescriptor[] viewLayoutAttribs, + Map<String, DeclareStyleableInfo> styleMap) { + String xmlName = VIEW_FRAGMENT; + final ViewElementDescriptor descriptor; + + // First try to create the descriptor from metadata in attrs.xml: + DeclareStyleableInfo style = styleMap.get("Fragment"); //$NON-NLS-1$ + String fragmentTooltip = + "A Fragment is a piece of an application's user interface or behavior that " + + "can be placed in an Activity"; + String sdkUrl = "http://developer.android.com/guide/topics/fundamentals/fragments.html"; + TextAttributeDescriptor classAttribute = new ClassAttributeDescriptor( + // Should accept both CLASS_V4_FRAGMENT and CLASS_FRAGMENT + null /*superClassName*/, + ATTR_CLASS, null /* namespace */, + new AttributeInfo(ATTR_CLASS, Format.STRING_SET), + true /*mandatory*/) + .setTooltip("Supply the name of the fragment class to instantiate"); + + if (style != null) { + descriptor = new ViewElementDescriptor( + VIEW_FRAGMENT, VIEW_FRAGMENT, VIEW_FRAGMENT, + fragmentTooltip, // tooltip + sdkUrl, //, + null /* attributes */, + viewLayoutAttribs, // layout attributes + null /*childrenElements*/, + false /*mandatory*/); + ArrayList<AttributeDescriptor> descs = new ArrayList<AttributeDescriptor>(); + // The class attribute is not included in the attrs.xml + descs.add(classAttribute); + DescriptorsUtils.appendAttributes(descs, + null, // elementName + ANDROID_URI, + style.getAttributes(), + null, // requiredAttributes + null); // overrides + //descriptor.setTooltip(style.getJavaDoc()); + descriptor.setAttributes(descs.toArray(new AttributeDescriptor[descs.size()])); + } else { + // The above will only work on API 11 and up. However, fragments are *also* available + // on older platforms, via the fragment support library, so add in a manual + // entry if necessary. + descriptor = new ViewElementDescriptor(xmlName, + xmlName, // ui_name + xmlName, // "class name"; the GLE only treats this as an element tag + fragmentTooltip, + sdkUrl, + new AttributeDescriptor[] { + new ClassAttributeDescriptor( + null /*superClassName*/, + ATTR_NAME, ANDROID_URI, + new AttributeInfo(ATTR_NAME, Format.STRING_SET), + true /*mandatory*/) + .setTooltip("Supply the name of the fragment class to instantiate"), + classAttribute, + new ClassAttributeDescriptor( + null /*superClassName*/, + ATTR_TAG, ANDROID_URI, + new AttributeInfo(ATTR_TAG, Format.STRING_SET), + true /*mandatory*/) + .setTooltip("Supply a tag for the top-level view containing a String"), + }, // attributes + viewLayoutAttribs, // layout attributes + null, // children + false /* mandatory */); + } + + return descriptor; + } + + /** + * Creates and returns a new {@code <view>} descriptor. + * @param viewLayoutAttribs The layout attributes to use for the new descriptor + * @param styleMap The style map provided by the SDK + */ + private ViewElementDescriptor createViewTag(AttributeDescriptor[] viewLayoutAttribs) { + String xmlName = VIEW_TAG; + + TextAttributeDescriptor classAttribute = new ClassAttributeDescriptor( + CLASS_VIEW, + ATTR_CLASS, null /* namespace */, + new AttributeInfo(ATTR_CLASS, Format.STRING_SET), + true /*mandatory*/) + .setTooltip("Supply the name of the view class to instantiate"); + + // Create the include descriptor + ViewElementDescriptor desc = new ViewElementDescriptor(xmlName, + xmlName, // ui_name + xmlName, // "class name"; the GLE only treats this as an element tag + "A view tag whose class attribute names the class to be instantiated", // tooltip + null, // sdk_url + new AttributeDescriptor[] { // attributes + classAttribute + }, + viewLayoutAttribs, // layout attributes + null, // children + false /* mandatory */); + + return desc; + } + + /** + * Creates and returns a new {@code <requestFocus>} descriptor. + */ + private ViewElementDescriptor createRequestFocus() { + String xmlName = REQUEST_FOCUS; + + // Create the include descriptor + return new ViewElementDescriptor( + xmlName, // xml_name + xmlName, // ui_name + xmlName, // "class name"; the GLE only treats this as an element tag + "Requests focus for the parent element or one of its descendants", // tooltip + null, // sdk_url + null, // attributes + null, // layout attributes + null, // children + false /* mandatory */); + } + + /** + * Finds the descriptor and retrieves all its layout attributes. + */ + private AttributeDescriptor[] findViewLayoutAttributes( + String viewFqcn) { + ViewElementDescriptor viewDesc = findDescriptorByClass(viewFqcn); + if (viewDesc != null) { + return viewDesc.getLayoutAttributes(); + } + + return null; + } + + /** + * Set the super-class of each {@link ViewElementDescriptor} by using the super-class + * information available in the {@link ViewClassInfo}. + */ + private void fixSuperClasses(Map<ViewClassInfo, ViewElementDescriptor> infoDescMap) { + + for (Entry<ViewClassInfo, ViewElementDescriptor> entry : infoDescMap.entrySet()) { + ViewClassInfo info = entry.getKey(); + ViewElementDescriptor desc = entry.getValue(); + + ViewClassInfo sup = info.getSuperClass(); + if (sup != null) { + ViewElementDescriptor supDesc = infoDescMap.get(sup); + while (supDesc == null && sup != null) { + // We don't have a descriptor for the super-class. That means the class is + // probably abstract, so we just need to walk up the super-class chain till + // we find one we have. All views derive from android.view.View so we should + // surely find that eventually. + sup = sup.getSuperClass(); + if (sup != null) { + supDesc = infoDescMap.get(sup); + } + } + if (supDesc != null) { + desc.setSuperClass(supDesc); + } + } + } + } + + /** + * Returns the {@link ViewElementDescriptor} with the given fully qualified class + * name, or null if not found. This is a quick map lookup. + * + * @param fqcn the fully qualified class name + * @return the corresponding {@link ViewElementDescriptor} or null + */ + public ViewElementDescriptor findDescriptorByClass(String fqcn) { + return mFqcnToDescriptor.get(fqcn); + } + + /** + * Returns the {@link ViewElementDescriptor} with the given XML tag name, + * which usually does not include the package (depending on the + * value of {@link ViewElementDescriptor#viewNeedsPackage(String)}). + * + * @param tag the XML tag name + * @return the corresponding {@link ViewElementDescriptor} or null + */ + public ViewElementDescriptor findDescriptorByTag(String tag) { + // TODO: Consider whether we need to add a direct map lookup for this as well. + // Currently not done since this is not frequently needed (only needed for + // exploded rendering which was already performing list iteration.) + for (ViewElementDescriptor descriptor : mLayoutDescriptors) { + if (tag.equals(descriptor.getXmlLocalName())) { + return descriptor; + } + } + + return null; + } + + /** + * Returns a collection of all the view class names, including layouts + * + * @return a collection of all the view class names, never null + */ + public Collection<String> getAllViewClassNames() { + return mFqcnToDescriptor.keySet(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/ViewElementDescriptor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/ViewElementDescriptor.java new file mode 100644 index 000000000..79995249c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/descriptors/ViewElementDescriptor.java @@ -0,0 +1,249 @@ +/* + * 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.eclipse.adt.internal.editors.layout.descriptors; + +import static com.android.SdkConstants.ANDROID_VIEW_PKG; +import static com.android.SdkConstants.ANDROID_WEBKIT_PKG; +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.VIEW; +import static com.android.SdkConstants.VIEW_TAG; + +import com.android.ide.common.resources.platform.AttributeInfo; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; + +import org.eclipse.swt.graphics.Image; + +import java.util.Collections; +import java.util.List; + +/** + * {@link ViewElementDescriptor} describes the properties expected for a given XML element node + * representing a class in an XML Layout file. + * <p/> + * These descriptors describe Android views XML elements. + * <p/> + * The base class {@link ElementDescriptor} has a notion of "children", that is an XML element + * can produce another set of XML elements. Because of the flat nature of Android's layout + * XML files all possible views are children of the document and of themselves (that is any + * view group can contain any other view). This is an implied contract of this class that is + * enforces at construction by {@link LayoutDescriptors}. Note that by construction any code + * that deals with the children hierarchy must also deal with potential infinite loops since views + * <em>will</em> reference themselves (e.g. a ViewGroup can contain a ViewGroup). + * <p/> + * Since Views are also Java classes, they derive from each other. Here this is represented + * as the "super class", which denotes the fact that a given View java class derives from + * another class. These properties are also set at construction by {@link LayoutDescriptors}. + * The super class hierarchy is very different from the descriptor's children hierarchy: the + * later represents Java inheritance, the former represents an XML nesting capability. + * + * @see ElementDescriptor + */ +public class ViewElementDescriptor extends ElementDescriptor { + + /** The full class name (FQCN) of this view. */ + private final String mFullClassName; + + /** The list of layout attributes. Can be empty but not null. */ + private AttributeDescriptor[] mLayoutAttributes; + + /** The super-class descriptor. Can be null. */ + private ViewElementDescriptor mSuperClassDesc; + + /** List of attribute sources, classes that contribute attributes to {@link #mAttributes} */ + private List<String> mAttributeSources; + + /** + * Constructs a new {@link ViewElementDescriptor} based on its XML name, UI name, + * the canonical name of the class it represents, its tooltip, its SDK url, its attributes list, + * its children list and its mandatory flag. + * + * @param xml_name The XML element node name. Case sensitive. + * @param ui_name The XML element name for the user interface, typically capitalized. + * @param fullClassName The fully qualified class name the {@link ViewElementDescriptor} is + * representing. + * @param tooltip An optional tooltip. Can be null or empty. + * @param sdk_url An optional SKD URL. Can be null or empty. + * @param attributes The list of allowed attributes. Can be null or empty. + * @param layoutAttributes The list of layout attributes. Can be null or empty. + * @param children The list of allowed children. Can be null or empty. + * @param mandatory Whether this node must always exist (even for empty models). A mandatory + * UI node is never deleted and it may lack an actual XML node attached. A non-mandatory + * UI node MUST have an XML node attached and it will cease to exist when the XML node + * ceases to exist. + */ + public ViewElementDescriptor(String xml_name, String ui_name, + String fullClassName, + String tooltip, String sdk_url, + AttributeDescriptor[] attributes, AttributeDescriptor[] layoutAttributes, + ElementDescriptor[] children, boolean mandatory) { + super(xml_name, ui_name, tooltip, sdk_url, attributes, children, mandatory); + mFullClassName = fullClassName; + mLayoutAttributes = layoutAttributes != null ? layoutAttributes : new AttributeDescriptor[0]; + } + + /** + * Constructs a new {@link ElementDescriptor} based on its XML name and on the canonical + * name of the class it represents. + * The UI name is build by capitalizing the XML name. + * The UI nodes will be non-mandatory. + * + * @param xml_name The XML element node name. Case sensitive. + * @param fullClassName The fully qualified class name the {@link ViewElementDescriptor} is + * representing. + */ + public ViewElementDescriptor(String xml_name, String fullClassName) { + super(xml_name); + mFullClassName = fullClassName; + mLayoutAttributes = null; + } + + /** + * Returns the fully qualified name of the View class represented by this element descriptor + * e.g. "android.view.View". + * + * @return the fully qualified class name, never null + */ + public String getFullClassName() { + return mFullClassName; + } + + /** Returns the list of layout attributes. Can be empty but not null. + * + * @return the list of layout attributes, never null + */ + public AttributeDescriptor[] getLayoutAttributes() { + return mLayoutAttributes; + } + + /** + * Sets the list of layout attribute attributes. + * + * @param attributes the new layout attributes, not null + */ + public void setLayoutAttributes(AttributeDescriptor[] attributes) { + assert attributes != null; + mLayoutAttributes = attributes; + } + + /** + * Returns a new {@link UiViewElementNode} linked to this descriptor. + */ + @Override + public UiElementNode createUiNode() { + return new UiViewElementNode(this); + } + + /** + * Returns the {@link ViewElementDescriptor} of the super-class of this View descriptor + * that matches the java View hierarchy. Can be null. + * + * @return the super class' descriptor or null + */ + public ViewElementDescriptor getSuperClassDesc() { + return mSuperClassDesc; + } + + /** + * Sets the {@link ViewElementDescriptor} of the super-class of this View descriptor + * that matches the java View hierarchy. Can be null. + * + * @param superClassDesc the descriptor for the super class, or null + */ + public void setSuperClass(ViewElementDescriptor superClassDesc) { + mSuperClassDesc = superClassDesc; + } + + /** + * Returns an optional icon for the element. + * <p/> + * By default this tries to return an icon based on the XML name of the element. + * If this fails, it tries to return the default element icon as defined in the + * plugin. If all fails, it returns null. + * + * @return An icon for this element or null. + */ + @Override + public Image getGenericIcon() { + IconFactory factory = IconFactory.getInstance(); + String name = mXmlName; + if (name.indexOf('.') != -1) { + // If the user uses a fully qualified name, such as + // "android.gesture.GestureOverlayView" in their XML, we need to look up + // only by basename + name = name.substring(name.lastIndexOf('.') + 1); + } else if (VIEW_TAG.equals(name)) { + // Can't have both view.png and View.png; issues on case sensitive vs + // case insensitive file systems + name = VIEW; + } + + Image icon = factory.getIcon(name); + if (icon == null) { + icon = AdtPlugin.getAndroidLogo(); + } + + return icon; + } + + /** + * Returns the list of attribute sources for the attributes provided by this + * descriptor. An attribute source is the fully qualified class name of the + * defining class for some of the properties. The specific attribute source + * of a given {@link AttributeInfo} can be found by calling + * {@link AttributeInfo#getDefinedBy()}. + * <p> + * The attribute sources are ordered from class to super class. + * <p> + * The list may <b>not</b> be modified by clients. + * + * @return a non null list of attribute sources for this view + */ + public List<String> getAttributeSources() { + return mAttributeSources != null ? mAttributeSources : Collections.<String>emptyList(); + } + + /** + * Sets the attribute sources for this view. See {@link #getAttributes()} + * for details. + * + * @param attributeSources a non null list of attribute sources for this + * view descriptor + * @see #getAttributeSources() + */ + public void setAttributeSources(List<String> attributeSources) { + mAttributeSources = attributeSources; + } + + /** + * Returns true if views with the given fully qualified class name need to include + * their package in the layout XML tag + * + * @param fqcn the fully qualified class name, such as android.widget.Button + * @return true if the full package path should be included in the layout XML element + * tag + */ + public static boolean viewNeedsPackage(String fqcn) { + return !(fqcn.startsWith(ANDROID_WIDGET_PREFIX) + || fqcn.startsWith(ANDROID_VIEW_PKG) + || fqcn.startsWith(ANDROID_WEBKIT_PKG)); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/AccordionControl.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/AccordionControl.java new file mode 100644 index 000000000..b3dce0756 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/AccordionControl.java @@ -0,0 +1,396 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.eclipse.adt.internal.editors.IconFactory; + +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CLabel; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseTrackListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.layout.RowLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The accordion control allows a series of labels with associated content that can be + * shown. For more details on accordions, see http://en.wikipedia.org/wiki/Accordion_(GUI) + * <p> + * This control allows the children to be created lazily. You can also customize the + * composite which is created to hold the children items, to for example allow multiple + * columns of items rather than just the default vertical stack. + * <p> + * The visual appearance of the headers is built in; it uses a mild gradient, with a + * heavier gradient during mouse-overs. It also uses a bold label along with the eclipse + * folder icons. + * <p> + * The control can be configured to enforce a single category open at any time (the + * default), or allowing multiple categories open (where they share the available space). + * The control can also be configured to fill the available vertical space for the open + * category/categories. + */ +public abstract class AccordionControl extends Composite { + /** Pixel spacing between header items */ + private static final int HEADER_SPACING = 0; + + /** Pixel spacing between items in the content area */ + private static final int ITEM_SPACING = 0; + + private static final String KEY_CONTENT = "content"; //$NON-NLS-1$ + private static final String KEY_HEADER = "header"; //$NON-NLS-1$ + + private Image mClosed; + private Image mOpen; + private boolean mSingle = true; + private boolean mWrap; + + /** + * Creates the container which will hold the items in a category; this can be + * overridden to lay out the children with a different layout than the default + * vertical RowLayout + */ + protected Composite createChildContainer(Composite parent, Object header, int style) { + Composite composite = new Composite(parent, style); + if (mWrap) { + RowLayout layout = new RowLayout(SWT.HORIZONTAL); + layout.center = true; + composite.setLayout(layout); + } else { + RowLayout layout = new RowLayout(SWT.VERTICAL); + layout.spacing = ITEM_SPACING; + layout.marginHeight = 0; + layout.marginWidth = 0; + layout.marginLeft = 0; + layout.marginTop = 0; + layout.marginRight = 0; + layout.marginBottom = 0; + composite.setLayout(layout); + } + + // TODO - maybe do multi-column arrangement for simple nodes + return composite; + } + + /** + * Creates the children under a particular header + * + * @param parent the parent composite to add the SWT items to + * @param header the header object that is being opened for the first time + */ + protected abstract void createChildren(Composite parent, Object header); + + /** + * Set whether a single category should be enforced or not (default=true) + * + * @param single if true, enforce a single category open at a time + */ + public void setAutoClose(boolean single) { + mSingle = single; + } + + /** + * Returns whether a single category should be enforced or not (default=true) + * + * @return true if only a single category can be open at a time + */ + public boolean isAutoClose() { + return mSingle; + } + + /** + * Returns the labels used as header categories + * + * @return list of header labels + */ + public List<CLabel> getHeaderLabels() { + List<CLabel> headers = new ArrayList<CLabel>(); + for (Control c : getChildren()) { + if (c instanceof CLabel) { + headers.add((CLabel) c); + } + } + + return headers; + } + + /** + * Show all categories + * + * @param performLayout if true, call {@link #layout} and {@link #pack} when done + */ + public void expandAll(boolean performLayout) { + for (Control c : getChildren()) { + if (c instanceof CLabel) { + if (!isOpen(c)) { + toggle((CLabel) c, false, false); + } + } + } + if (performLayout) { + pack(); + layout(); + } + } + + /** + * Hide all categories + * + * @param performLayout if true, call {@link #layout} and {@link #pack} when done + */ + public void collapseAll(boolean performLayout) { + for (Control c : getChildren()) { + if (c instanceof CLabel) { + if (isOpen(c)) { + toggle((CLabel) c, false, false); + } + } + } + if (performLayout) { + layout(); + } + } + + /** + * Create the composite. + * + * @param parent the parent widget to add the accordion to + * @param style the SWT style mask to use + * @param headers a list of headers, whose {@link Object#toString} method should + * produce the heading label + * @param greedy if true, grow vertically as much as possible + * @param wrapChildren if true, configure the child area to be horizontally laid out + * with wrapping + * @param expand Set of headers to expand initially + */ + public AccordionControl(Composite parent, int style, List<?> headers, + boolean greedy, boolean wrapChildren, Set<String> expand) { + super(parent, style); + mWrap = wrapChildren; + + GridLayout gridLayout = new GridLayout(1, false); + gridLayout.verticalSpacing = HEADER_SPACING; + gridLayout.horizontalSpacing = 0; + gridLayout.marginWidth = 0; + gridLayout.marginHeight = 0; + setLayout(gridLayout); + + Font labelFont = null; + + mOpen = IconFactory.getInstance().getIcon("open-folder"); //$NON-NLS-1$ + mClosed = IconFactory.getInstance().getIcon("closed-folder"); //$NON-NLS-1$ + List<CLabel> expandLabels = new ArrayList<CLabel>(); + + for (Object header : headers) { + final CLabel label = new CLabel(this, SWT.SHADOW_OUT); + label.setText(header.toString().replace("&", "&&")); //$NON-NLS-1$ //$NON-NLS-2$ + updateBackground(label, false); + if (labelFont == null) { + labelFont = JFaceResources.getFontRegistry().getBold(JFaceResources.DEFAULT_FONT); + } + label.setFont(labelFont); + label.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + setHeader(header, label); + label.addMouseListener(new MouseAdapter() { + @Override + public void mouseUp(MouseEvent e) { + if (e.button == 1 && (e.stateMask & SWT.MODIFIER_MASK) == 0) { + toggle(label, true, mSingle); + } + } + }); + label.addMouseTrackListener(new MouseTrackListener() { + @Override + public void mouseEnter(MouseEvent e) { + updateBackground(label, true); + } + + @Override + public void mouseExit(MouseEvent e) { + updateBackground(label, false); + } + + @Override + public void mouseHover(MouseEvent e) { + } + }); + + // Turn off border? + final ScrolledComposite scrolledComposite = new ScrolledComposite(this, SWT.V_SCROLL); + ScrollBar verticalBar = scrolledComposite.getVerticalBar(); + verticalBar.setIncrement(20); + verticalBar.setPageIncrement(100); + + // Do we need the scrolled composite or can we just look at the next + // wizard in the hierarchy? + + setContentArea(label, scrolledComposite); + scrolledComposite.setExpandHorizontal(true); + scrolledComposite.setExpandVertical(true); + GridData scrollGridData = new GridData(SWT.FILL, + greedy ? SWT.FILL : SWT.TOP, false, greedy, 1, 1); + scrollGridData.exclude = true; + scrollGridData.grabExcessHorizontalSpace = wrapChildren; + scrolledComposite.setLayoutData(scrollGridData); + + if (wrapChildren) { + scrolledComposite.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Rectangle r = scrolledComposite.getClientArea(); + Control content = scrolledComposite.getContent(); + if (content != null && r != null) { + Point minSize = content.computeSize(r.width, SWT.DEFAULT); + scrolledComposite.setMinSize(minSize); + ScrollBar vBar = scrolledComposite.getVerticalBar(); + vBar.setPageIncrement(r.height); + } + } + }); + } + + updateIcon(label); + if (expand != null && expand.contains(label.getText())) { + // Comparing "label.getText()" rather than "header" because we make some + // tweaks to the label (replacing & with && etc) and in the getExpandedCategories + // method we return the label texts + expandLabels.add(label); + } + } + + // Expand the requested categories + for (CLabel label : expandLabels) { + toggle(label, false, false); + } + } + + /** Updates the background gradient of the given header label */ + private void updateBackground(CLabel label, boolean mouseOver) { + Display display = label.getDisplay(); + label.setBackground(new Color[] { + display.getSystemColor(SWT.COLOR_WIDGET_HIGHLIGHT_SHADOW), + display.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND), + display.getSystemColor(SWT.COLOR_WIDGET_LIGHT_SHADOW) + }, new int[] { + mouseOver ? 60 : 40, 100 + }, true); + } + + /** + * Updates the icon for a header label to be open/close based on the {@link #isOpen} + * state + */ + private void updateIcon(CLabel label) { + label.setImage(isOpen(label) ? mOpen : mClosed); + } + + /** Returns true if the content area for the given label is open/showing */ + private boolean isOpen(Control label) { + return !((GridData) getContentArea(label).getLayoutData()).exclude; + } + + /** Toggles the visibility of the children of the given label */ + private void toggle(CLabel label, boolean performLayout, boolean autoClose) { + if (autoClose) { + collapseAll(true); + } + ScrolledComposite scrolledComposite = getContentArea(label); + + GridData scrollGridData = (GridData) scrolledComposite.getLayoutData(); + boolean close = !scrollGridData.exclude; + scrollGridData.exclude = close; + scrolledComposite.setVisible(!close); + updateIcon(label); + + if (!scrollGridData.exclude && scrolledComposite.getContent() == null) { + Object header = getHeader(label); + Composite composite = createChildContainer(scrolledComposite, header, SWT.NONE); + createChildren(composite, header); + while (composite.getParent() != scrolledComposite) { + composite = composite.getParent(); + } + scrolledComposite.setContent(composite); + scrolledComposite.setMinSize(composite.computeSize(SWT.DEFAULT, SWT.DEFAULT)); + } + + if (performLayout) { + layout(true); + } + } + + /** Returns the header object for the given header label */ + private Object getHeader(Control label) { + return label.getData(KEY_HEADER); + } + + /** Sets the header object for the given header label */ + private void setHeader(Object header, final CLabel label) { + label.setData(KEY_HEADER, header); + } + + /** Returns the content area for the given header label */ + private ScrolledComposite getContentArea(Control label) { + return (ScrolledComposite) label.getData(KEY_CONTENT); + } + + /** Sets the content area for the given header label */ + private void setContentArea(final CLabel label, ScrolledComposite scrolledComposite) { + label.setData(KEY_CONTENT, scrolledComposite); + } + + @Override + protected void checkSubclass() { + // Disable the check that prevents subclassing of SWT components + } + + /** + * Returns the set of expanded categories in the palette. Note: Header labels will have + * escaped ampersand characters with double ampersands. + * + * @return the set of expanded categories in the palette - never null + */ + public Set<String> getExpandedCategories() { + Set<String> expanded = new HashSet<String>(); + for (Control c : getChildren()) { + if (c instanceof CLabel) { + if (isOpen(c)) { + expanded.add(((CLabel) c).getText()); + } + } + } + + return expanded; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/BinPacker.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/BinPacker.java new file mode 100644 index 000000000..9fc2e0937 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/BinPacker.java @@ -0,0 +1,352 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.Nullable; +import com.android.ide.common.api.Rect; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.imageio.ImageIO; + +/** + * This class implements 2D bin packing: packing rectangles into a given area as + * tightly as "possible" (bin packing in general is NP hard, so this class uses + * heuristics). + * <p> + * The algorithm implemented is to keep a set of (possibly overlapping) + * available areas for placement. For each newly inserted rectangle, we first + * pick which available space to occupy, and we then subdivide the + * current rectangle into all the possible remaining unoccupied sub-rectangles. + * We also remove any other space rectangles which are no longer eligible if + * they are intersecting the newly placed rectangle. + * <p> + * This algorithm is not very fast, so should not be used for a large number of + * rectangles. + */ +class BinPacker { + /** + * When enabled, the successive passes are dumped as PNG images showing the + * various available and occupied rectangles) + */ + private static final boolean DEBUG = false; + + private final List<Rect> mSpace = new ArrayList<Rect>(); + private final int mMinHeight; + private final int mMinWidth; + + /** + * Creates a new {@linkplain BinPacker}. To use it, first add one or more + * initial available space rectangles with {@link #addSpace(Rect)}, and then + * place the rectangles with {@link #occupy(int, int)}. The returned + * {@link Rect} from {@link #occupy(int, int)} gives the coordinates of the + * positioned rectangle. + * + * @param minWidth the smallest width of any rectangle placed into this bin + * @param minHeight the smallest height of any rectangle placed into this bin + */ + BinPacker(int minWidth, int minHeight) { + mMinWidth = minWidth; + mMinHeight = minHeight; + + if (DEBUG) { + mAllocated = new ArrayList<Rect>(); + sLayoutId++; + sRectId = 1; + } + } + + /** Adds more available space */ + void addSpace(Rect rect) { + if (rect.w >= mMinWidth && rect.h >= mMinHeight) { + mSpace.add(rect); + } + } + + /** Attempts to place a rectangle of the given dimensions, if possible */ + @Nullable + Rect occupy(int width, int height) { + int index = findBest(width, height); + if (index == -1) { + return null; + } + + return split(index, width, height); + } + + /** + * Finds the best available space rectangle to position a new rectangle of + * the given size in. + */ + private int findBest(int width, int height) { + if (mSpace.isEmpty()) { + return -1; + } + + // Try to pack as far up as possible first + int bestIndex = -1; + boolean multipleAtSameY = false; + int minY = Integer.MAX_VALUE; + for (int i = 0, n = mSpace.size(); i < n; i++) { + Rect rect = mSpace.get(i); + if (rect.y <= minY) { + if (rect.w >= width && rect.h >= height) { + if (rect.y < minY) { + minY = rect.y; + multipleAtSameY = false; + bestIndex = i; + } else if (minY == rect.y) { + multipleAtSameY = true; + } + } + } + } + + if (!multipleAtSameY) { + return bestIndex; + } + + bestIndex = -1; + + // Pick a rectangle. This currently tries to find the rectangle whose shortest + // side most closely matches the placed rectangle's size. + // Attempt to find the best short side fit + int bestShortDistance = Integer.MAX_VALUE; + int bestLongDistance = Integer.MAX_VALUE; + + for (int i = 0, n = mSpace.size(); i < n; i++) { + Rect rect = mSpace.get(i); + if (rect.y != minY) { // Only comparing elements at same y + continue; + } + if (rect.w >= width && rect.h >= height) { + if (width < height) { + int distance = rect.w - width; + if (distance < bestShortDistance || + distance == bestShortDistance && + (rect.h - height) < bestLongDistance) { + bestShortDistance = distance; + bestLongDistance = rect.h - height; + bestIndex = i; + } + } else { + int distance = rect.w - width; + if (distance < bestShortDistance || + distance == bestShortDistance && + (rect.h - height) < bestLongDistance) { + bestShortDistance = distance; + bestLongDistance = rect.h - height; + bestIndex = i; + } + } + } + } + + return bestIndex; + } + + /** + * Removes the rectangle at the given index. Since the rectangles are in an + * {@link ArrayList}, removing a rectangle in the normal way is slow (it + * would involve shifting all elements), but since we don't care about + * order, this always swaps the to-be-deleted element to the last position + * in the array first, <b>then</b> it deletes it (which should be + * immediate). + * + * @param index the index in the {@link #mSpace} list to remove a rectangle + * from + */ + private void removeRect(int index) { + assert !mSpace.isEmpty(); + int lastIndex = mSpace.size() - 1; + if (index != lastIndex) { + // Swap before remove to make deletion faster since we don't + // care about order + Rect temp = mSpace.get(index); + mSpace.set(index, mSpace.get(lastIndex)); + mSpace.set(lastIndex, temp); + } + + mSpace.remove(lastIndex); + } + + /** + * Splits the rectangle at the given rectangle index such that it can contain + * a rectangle of the given width and height. */ + private Rect split(int index, int width, int height) { + Rect rect = mSpace.get(index); + assert rect.w >= width && rect.h >= height : rect; + + Rect r = new Rect(rect); + r.w = width; + r.h = height; + + // Remove all rectangles that intersect my rectangle + for (int i = 0; i < mSpace.size(); i++) { + Rect other = mSpace.get(i); + if (other.intersects(r)) { + removeRect(i); + i--; + } + } + + + // Split along vertical line x = rect.x + width: + // (rect.x,rect.y) + // +-------------+-------------------------+ + // | | | + // | | | + // | | height | + // | | | + // | | | + // +-------------+ B | rect.h + // | width | + // | | | + // | A | + // | | | + // | | + // +---------------------------------------+ + // rect.w + int remainingHeight = rect.h - height; + int remainingWidth = rect.w - width; + if (remainingHeight >= mMinHeight) { + mSpace.add(new Rect(rect.x, rect.y + height, width, remainingHeight)); + } + if (remainingWidth >= mMinWidth) { + mSpace.add(new Rect(rect.x + width, rect.y, remainingWidth, rect.h)); + } + + // Split along horizontal line y = rect.y + height: + // +-------------+-------------------------+ + // | | | + // | | height | + // | | A | + // | | | + // | | | rect.h + // +-------------+ - - - - - - - - - - - - | + // | width | + // | | + // | B | + // | | + // | | + // +---------------------------------------+ + // rect.w + if (remainingHeight >= mMinHeight) { + mSpace.add(new Rect(rect.x, rect.y + height, rect.w, remainingHeight)); + } + if (remainingWidth >= mMinWidth) { + mSpace.add(new Rect(rect.x + width, rect.y, remainingWidth, height)); + } + + // Remove redundant rectangles. This is not very efficient. + for (int i = 0; i < mSpace.size() - 1; i++) { + for (int j = i + 1; j < mSpace.size(); j++) { + Rect iRect = mSpace.get(i); + Rect jRect = mSpace.get(j); + if (jRect.contains(iRect)) { + removeRect(i); + i--; + break; + } + if (iRect.contains(jRect)) { + removeRect(j); + j--; + } + } + } + + if (DEBUG) { + mAllocated.add(r); + dumpImage(); + } + + return r; + } + + // DEBUGGING CODE: Enable with DEBUG + + private List<Rect> mAllocated; + private static int sLayoutId; + private static int sRectId; + private void dumpImage() { + if (DEBUG) { + int width = 100; + int height = 100; + for (Rect rect : mSpace) { + width = Math.max(width, rect.w); + height = Math.max(height, rect.h); + } + width += 10; + height += 10; + + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + g.setColor(Color.BLACK); + g.fillRect(0, 0, image.getWidth(), image.getHeight()); + + Color[] colors = new Color[] { + Color.blue, Color.cyan, + Color.green, Color.magenta, Color.orange, + Color.pink, Color.red, Color.white, Color.yellow, Color.darkGray, + Color.lightGray, Color.gray, + }; + + char allocated = 'A'; + for (Rect rect : mAllocated) { + Color color = new Color(0x9FFFFFFF, true); + g.setColor(color); + g.setBackground(color); + g.fillRect(rect.x, rect.y, rect.w, rect.h); + g.setColor(Color.WHITE); + g.drawRect(rect.x, rect.y, rect.w, rect.h); + g.drawString("" + (allocated++), + rect.x + rect.w / 2, rect.y + rect.h / 2); + } + + int colorIndex = 0; + for (Rect rect : mSpace) { + Color color = colors[colorIndex]; + colorIndex = (colorIndex + 1) % colors.length; + + color = new Color(color.getRed(), color.getGreen(), color.getBlue(), 128); + g.setColor(color); + + g.fillRect(rect.x, rect.y, rect.w, rect.h); + g.setColor(Color.WHITE); + g.drawString(Integer.toString(colorIndex), + rect.x + rect.w / 2, rect.y + rect.h / 2); + } + + + g.dispose(); + + File file = new File("/tmp/layout" + sLayoutId + "_pass" + sRectId + ".png"); + try { + ImageIO.write(image, "PNG", file); + System.out.println("Wrote diagnostics image " + file); + } catch (IOException e) { + e.printStackTrace(); + } + sRectId++; + } + } +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasAlternateSelection.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasAlternateSelection.java new file mode 100644 index 000000000..c04061cbd --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasAlternateSelection.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import java.util.List; + +/** + * Information for the current alternate selection, i.e. the possible selected items + * that are located at the same x/y as the original view, either sibling or parents. + */ +/* package */ class CanvasAlternateSelection { + private final CanvasViewInfo mOriginatingView; + private final List<CanvasViewInfo> mAltViews; + private int mIndex; + + /** + * Creates a new alternate selection based on the given originating view and the + * given list of alternate views. Both cannot be null. + */ + public CanvasAlternateSelection(CanvasViewInfo originatingView, List<CanvasViewInfo> altViews) { + assert originatingView != null; + assert altViews != null; + mOriginatingView = originatingView; + mAltViews = altViews; + mIndex = altViews.size() - 1; + } + + /** Returns the list of alternate views. Cannot be null. */ + public List<CanvasViewInfo> getAltViews() { + return mAltViews; + } + + /** Returns the originating view. Cannot be null. */ + public CanvasViewInfo getOriginatingView() { + return mOriginatingView; + } + + /** + * Returns the current alternate view to select. + * Initially this is the top-most view. + */ + public CanvasViewInfo getCurrent() { + return mIndex >= 0 ? mAltViews.get(mIndex) : null; + } + + /** + * Changes the current view to be the next one and then returns it. + * This loops through the alternate views. + */ + public CanvasViewInfo getNext() { + if (mIndex == 0) { + mIndex = mAltViews.size() - 1; + } else if (mIndex > 0) { + mIndex--; + } + + return getCurrent(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasTransform.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasTransform.java new file mode 100644 index 000000000..ad5bd52e5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasTransform.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SHADOW_SIZE; + +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.widgets.ScrollBar; + +/** + * Helper class to convert between control pixel coordinates and canvas coordinates. + * Takes care of the zooming and offset of the canvas. + */ +public class CanvasTransform { + /** + * Default margin around the rendered image, reduced + * when the contents do not fit. + */ + public static final int DEFAULT_MARGIN = 25; + + /** + * The canvas which controls the zooming. + */ + private final LayoutCanvas mCanvas; + + /** Canvas image size (original, before zoom), in pixels. */ + private int mImgSize; + + /** Full size being scrolled (after zoom), in pixels */ + private int mFullSize;; + + /** Client size, in pixels. */ + private int mClientSize; + + /** Left-top offset in client pixel coordinates. */ + private int mTranslate; + + /** Current margin */ + private int mMargin = DEFAULT_MARGIN; + + /** Scaling factor, > 0. */ + private double mScale; + + /** Scrollbar widget. */ + private ScrollBar mScrollbar; + + public CanvasTransform(LayoutCanvas layoutCanvas, ScrollBar scrollbar) { + mCanvas = layoutCanvas; + mScrollbar = scrollbar; + mScale = 1.0; + mTranslate = 0; + + mScrollbar.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // User requested scrolling. Changes translation and redraw canvas. + mTranslate = mScrollbar.getSelection(); + CanvasTransform.this.mCanvas.redraw(); + } + }); + mScrollbar.setIncrement(20); + } + + /** + * Sets the new scaling factor. Recomputes scrollbars. + * @param scale Scaling factor, > 0. + */ + public void setScale(double scale) { + if (mScale != scale) { + mScale = scale; + resizeScrollbar(); + } + } + + /** Recomputes the scrollbar and view port settings */ + public void refresh() { + resizeScrollbar(); + } + + /** + * Returns current scaling factor. + * + * @return The current scaling factor + */ + public double getScale() { + return mScale; + } + + /** + * Returns Canvas image size (original, before zoom), in pixels. + * + * @return Canvas image size (original, before zoom), in pixels + */ + public int getImgSize() { + return mImgSize; + } + + /** + * Returns the scaled image size in pixels. + * + * @return The scaled image size in pixels. + */ + public int getScaledImgSize() { + return (int) (mImgSize * mScale); + } + + /** + * Changes the size of the canvas image and the client size. Recomputes + * scrollbars. + * + * @param imgSize the size of the image being scaled + * @param fullSize the size of the full view area being scrolled + * @param clientSize the size of the view port + */ + public void setSize(int imgSize, int fullSize, int clientSize) { + mImgSize = imgSize; + mFullSize = fullSize; + mClientSize = clientSize; + mScrollbar.setPageIncrement(clientSize); + resizeScrollbar(); + } + + private void resizeScrollbar() { + // scaled image size + int sx = (int) (mScale * mFullSize); + + // Adjust margin such that for zoomed out views + // we don't waste space (unless the viewport is + // large enough to accommodate it) + int delta = mClientSize - sx; + if (delta < 0) { + mMargin = 0; + } else if (delta < 2 * DEFAULT_MARGIN) { + mMargin = delta / 2; + + ImageOverlay imageOverlay = mCanvas.getImageOverlay(); + if (imageOverlay != null && imageOverlay.getShowDropShadow() + && delta >= SHADOW_SIZE / 2) { + mMargin -= SHADOW_SIZE / 2; + // Add a little padding on the top too, if there's room. The shadow assets + // include enough padding on the bottom to not make this look clipped. + if (mMargin < 4) { + mMargin += 4; + } + } + } else { + mMargin = DEFAULT_MARGIN; + } + + if (mCanvas.getPreviewManager().hasPreviews()) { + // Make more room for the previews + mMargin = 2; + } + + // actual client area is always reduced by the margins + int cx = mClientSize - 2 * mMargin; + + if (sx < cx) { + mTranslate = 0; + mScrollbar.setEnabled(false); + } else { + mScrollbar.setEnabled(true); + + int selection = mScrollbar.getSelection(); + int thumb = cx; + int maximum = sx; + + if (selection + thumb > maximum) { + selection = maximum - thumb; + if (selection < 0) { + selection = 0; + } + } + + mScrollbar.setValues(selection, mScrollbar.getMinimum(), maximum, thumb, mScrollbar + .getIncrement(), mScrollbar.getPageIncrement()); + + mTranslate = selection; + } + } + + public int getMargin() { + return mMargin; + } + + public int translate(int canvasX) { + return mMargin - mTranslate + (int) (mScale * canvasX); + } + + public int scale(int canwasW) { + return (int) (mScale * canwasW); + } + + public int inverseTranslate(int screenX) { + return (int) ((screenX - mMargin + mTranslate) / mScale); + } + + public int inverseScale(int canwasW) { + return (int) (canwasW / mScale); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasViewInfo.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasViewInfo.java new file mode 100644 index 000000000..03c6c3926 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CanvasViewInfo.java @@ -0,0 +1,1178 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.FQCN_SPACE; +import static com.android.SdkConstants.FQCN_SPACE_V7; +import static com.android.SdkConstants.GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.VIEW_MERGE; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.layout.GridLayoutRule; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.MergeCookie; +import com.android.ide.common.rendering.api.ViewInfo; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.UiElementPullParser; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.utils.Pair; + +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.ui.views.properties.IPropertyDescriptor; +import org.eclipse.ui.views.properties.IPropertySheetPage; +import org.eclipse.ui.views.properties.IPropertySource; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Maps a {@link ViewInfo} in a structure more adapted to our needs. + * The only large difference is that we keep both the original bounds of the view info + * and we pre-compute the selection bounds which are absolute to the rendered image + * (whereas the original bounds are relative to the parent view.) + * <p/> + * Each view also knows its parent and children. + * <p/> + * We can't alter {@link ViewInfo} as it is part of the LayoutBridge and needs to + * have a fixed API. + * <p/> + * The view info also implements {@link IPropertySource}, which enables a linked + * {@link IPropertySheetPage} to display the attributes of the selected element. + * This class actually delegates handling of {@link IPropertySource} to the underlying + * {@link UiViewElementNode}, if any. + */ +public class CanvasViewInfo implements IPropertySource { + + /** + * Minimal size of the selection, in case an empty view or layout is selected. + */ + public static final int SELECTION_MIN_SIZE = 6; + + private final Rectangle mAbsRect; + private final Rectangle mSelectionRect; + private final String mName; + private final Object mViewObject; + private final UiViewElementNode mUiViewNode; + private CanvasViewInfo mParent; + private ViewInfo mViewInfo; + private final List<CanvasViewInfo> mChildren = new ArrayList<CanvasViewInfo>(); + + /** + * Is this view info an individually exploded view? This is the case for views + * that were specially inflated by the {@link UiElementPullParser} and assigned + * fixed padding because they were invisible and somebody requested visibility. + */ + private boolean mExploded; + + /** + * Node sibling. This is usually null, but it's possible for a single node in the + * model to have <b>multiple</b> separate views in the canvas, for example + * when you {@code <include>} a view that has multiple widgets inside a + * {@code <merge>} tag. In this case, all the views have the same node model, + * the include tag, and selecting the include should highlight all the separate + * views that are linked to this node. That's what this field is all about: it is + * a <b>circular</b> list of all the siblings that share the same node. + */ + private List<CanvasViewInfo> mNodeSiblings; + + /** + * Constructs a {@link CanvasViewInfo} initialized with the given initial values. + */ + private CanvasViewInfo(CanvasViewInfo parent, String name, + Object viewObject, UiViewElementNode node, Rectangle absRect, + Rectangle selectionRect, ViewInfo viewInfo) { + mParent = parent; + mName = name; + mViewObject = viewObject; + mViewInfo = viewInfo; + mUiViewNode = node; + mAbsRect = absRect; + mSelectionRect = selectionRect; + } + + /** + * Returns the original {@link ViewInfo} bounds in absolute coordinates + * over the whole graphic. + * + * @return the bounding box in absolute coordinates + */ + @NonNull + public Rectangle getAbsRect() { + return mAbsRect; + } + + /** + * Returns the absolute selection bounds of the view info as a rectangle. + * The selection bounds will always have a size greater or equal to + * {@link #SELECTION_MIN_SIZE}. + * The width/height is inclusive (i.e. width = right-left-1). + * This is in absolute "screen" coordinates (relative to the rendered bitmap). + * + * @return the absolute selection bounds + */ + @NonNull + public Rectangle getSelectionRect() { + return mSelectionRect; + } + + /** + * Returns the view node. Could be null, although unlikely. + * @return An {@link UiViewElementNode} that uniquely identifies the object in the XML model. + * @see ViewInfo#getCookie() + */ + @Nullable + public UiViewElementNode getUiViewNode() { + return mUiViewNode; + } + + /** + * Returns the parent {@link CanvasViewInfo}. + * It is null for the root and non-null for children. + * + * @return the parent {@link CanvasViewInfo}, which can be null + */ + @Nullable + public CanvasViewInfo getParent() { + return mParent; + } + + /** + * Returns the list of children of this {@link CanvasViewInfo}. + * The list is never null. It can be empty. + * By contract, this.getChildren().get(0..n-1).getParent() == this. + * + * @return the children, never null + */ + @NonNull + public List<CanvasViewInfo> getChildren() { + return mChildren; + } + + /** + * For nodes that have multiple views rendered from a single node, such as the + * children of a {@code <merge>} tag included into a separate layout, return the + * "primary" view, the first view that is rendered + */ + @Nullable + private CanvasViewInfo getPrimaryNodeSibling() { + if (mNodeSiblings == null || mNodeSiblings.size() == 0) { + return null; + } + + return mNodeSiblings.get(0); + } + + /** + * Returns true if this view represents one view of many linked to a single node, and + * where this is the primary view. The primary view is the one that will be shown + * in the outline for example (since we only show nodes, not views, in the outline, + * and therefore don't want repetitions when a view has more than one view info.) + * + * @return true if this is the primary view among more than one linked to a single + * node + */ + private boolean isPrimaryNodeSibling() { + return getPrimaryNodeSibling() == this; + } + + /** + * Returns the list of node sibling of this view (which <b>will include this + * view</b>). For most views this is going to be null, but for views that share a + * single node (such as widgets inside a {@code <merge>} tag included into another + * layout), this will provide all the views that correspond to the node. + * + * @return a non-empty list of siblings (including this), or null + */ + @Nullable + public List<CanvasViewInfo> getNodeSiblings() { + return mNodeSiblings; + } + + /** + * Returns all the children of the canvas view info where each child corresponds to a + * unique node that the user can see and select. This is intended for use by the + * outline for example, where only the actual nodes are displayed, not the views + * themselves. + * <p> + * Most views have their own nodes, so this is generally the same as + * {@link #getChildren}, except in the case where you for example include a view that + * has multiple widgets inside a {@code <merge>} tag, where all these widgets have the + * same node (the {@code <merge>} tag). + * + * @return list of {@link CanvasViewInfo} objects that are children of this view, + * never null + */ + @NonNull + public List<CanvasViewInfo> getUniqueChildren() { + boolean haveHidden = false; + + for (CanvasViewInfo info : mChildren) { + if (info.mNodeSiblings != null) { + // We have secondary children; must create a new collection containing + // only non-secondary children + List<CanvasViewInfo> children = new ArrayList<CanvasViewInfo>(); + for (CanvasViewInfo vi : mChildren) { + if (vi.mNodeSiblings == null) { + children.add(vi); + } else if (vi.isPrimaryNodeSibling()) { + children.add(vi); + } + } + return children; + } + + haveHidden |= info.isHidden(); + } + + if (haveHidden) { + List<CanvasViewInfo> children = new ArrayList<CanvasViewInfo>(mChildren.size()); + for (CanvasViewInfo vi : mChildren) { + if (!vi.isHidden()) { + children.add(vi); + } + } + + return children; + } + + return mChildren; + } + + /** + * Returns true if the specific {@link CanvasViewInfo} is a parent + * of this {@link CanvasViewInfo}. It can be a direct parent or any + * grand-parent higher in the hierarchy. + * + * @param potentialParent the view info to check + * @return true if the given info is a parent of this view + */ + public boolean isParent(@NonNull CanvasViewInfo potentialParent) { + CanvasViewInfo p = mParent; + while (p != null) { + if (p == potentialParent) { + return true; + } + p = p.getParent(); + } + return false; + } + + /** + * Returns the name of the {@link CanvasViewInfo}. + * Could be null, although unlikely. + * Experience shows this is the full qualified Java name of the View. + * TODO: Rename this method to getFqcn. + * + * @return the name of the view info + * + * @see ViewInfo#getClassName() + */ + @NonNull + public String getName() { + return mName; + } + + /** + * Returns the View object associated with the {@link CanvasViewInfo}. + * @return the view object or null. + */ + @Nullable + public Object getViewObject() { + return mViewObject; + } + + /** + * Returns the baseline of this object, or -1 if it does not support a baseline + * + * @return the baseline or -1 + */ + public int getBaseline() { + if (mViewInfo != null) { + int baseline = mViewInfo.getBaseLine(); + if (baseline != Integer.MIN_VALUE) { + return baseline; + } + } + + return -1; + } + + /** + * Returns the {@link Margins} for this {@link CanvasViewInfo} + * + * @return the {@link Margins} for this {@link CanvasViewInfo} + */ + @Nullable + public Margins getMargins() { + if (mViewInfo != null) { + int leftMargin = mViewInfo.getLeftMargin(); + int topMargin = mViewInfo.getTopMargin(); + int rightMargin = mViewInfo.getRightMargin(); + int bottomMargin = mViewInfo.getBottomMargin(); + return new Margins( + leftMargin != Integer.MIN_VALUE ? leftMargin : 0, + rightMargin != Integer.MIN_VALUE ? rightMargin : 0, + topMargin != Integer.MIN_VALUE ? topMargin : 0, + bottomMargin != Integer.MIN_VALUE ? bottomMargin : 0 + ); + } + + return null; + } + + // ---- Implementation of IPropertySource + // TODO: Get rid of this once the old propertysheet implementation is fully gone + + @Override + public Object getEditableValue() { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + return ((IPropertySource) uiView).getEditableValue(); + } + return null; + } + + @Override + public IPropertyDescriptor[] getPropertyDescriptors() { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + return ((IPropertySource) uiView).getPropertyDescriptors(); + } + return null; + } + + @Override + public Object getPropertyValue(Object id) { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + return ((IPropertySource) uiView).getPropertyValue(id); + } + return null; + } + + @Override + public boolean isPropertySet(Object id) { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + return ((IPropertySource) uiView).isPropertySet(id); + } + return false; + } + + @Override + public void resetPropertyValue(Object id) { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + ((IPropertySource) uiView).resetPropertyValue(id); + } + } + + @Override + public void setPropertyValue(Object id, Object value) { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + ((IPropertySource) uiView).setPropertyValue(id, value); + } + } + + /** + * Returns the XML node corresponding to this info, or null if there is no + * such XML node. + * + * @return The XML node corresponding to this info object, or null + */ + @Nullable + public Node getXmlNode() { + UiViewElementNode uiView = getUiViewNode(); + if (uiView != null) { + return uiView.getXmlNode(); + } + + return null; + } + + /** + * Returns true iff this view info corresponds to a root element. + * + * @return True iff this is a root view info. + */ + public boolean isRoot() { + // Select the visual element -- unless it's the root. + // The root element is the one whose GRAND parent + // is null (because the parent will be a -document- + // node). + + // Special case: a gesture overlay is sometimes added as the root, but for all intents + // and purposes it is its layout child that is the real root so treat that one as the + // root as well (such that the whole layout canvas does not highlight as part of hovers + // etc) + if (mParent != null + && mParent.mName.endsWith(GESTURE_OVERLAY_VIEW) + && mParent.isRoot() + && mParent.mChildren.size() == 1) { + return true; + } + + return mUiViewNode == null || mUiViewNode.getUiParent() == null || + mUiViewNode.getUiParent().getUiParent() == null; + } + + /** + * Returns true if this {@link CanvasViewInfo} represents an invisible widget that + * should be highlighted when selected. This is the case for any layout that is less than the minimum + * threshold ({@link #SELECTION_MIN_SIZE}), or any other view that has -0- bounds. + * + * @return True if this is a tiny layout or invisible view + */ + public boolean isInvisible() { + if (isHidden()) { + // Don't expand and highlight hidden widgets + return false; + } + + if (mAbsRect.width < SELECTION_MIN_SIZE || mAbsRect.height < SELECTION_MIN_SIZE) { + return mUiViewNode != null && (mUiViewNode.getDescriptor().hasChildren() || + mAbsRect.width <= 0 || mAbsRect.height <= 0); + } + + return false; + } + + /** + * Returns true if this {@link CanvasViewInfo} represents a widget that should be + * hidden, such as a {@code <Space>} which are typically not manipulated by the user + * through dragging etc. + * + * @return true if this is a hidden view + */ + public boolean isHidden() { + if (GridLayoutRule.sDebugGridLayout) { + return false; + } + + return FQCN_SPACE.equals(mName) || FQCN_SPACE_V7.equals(mName); + } + + /** + * Is this {@link CanvasViewInfo} a view that has had its padding inflated in order to + * make it visible during selection or dragging? Note that this is NOT considered to + * be the case in the explode-all-views mode where all nodes have their padding + * increased; it's only used for views that individually exploded because they were + * requested visible and they returned true for {@link #isInvisible()}. + * + * @return True if this is an exploded node. + */ + public boolean isExploded() { + return mExploded; + } + + /** + * Mark this {@link CanvasViewInfo} as having been exploded or not. See the + * {@link #isExploded()} method for details on what this property means. + * + * @param exploded New value of the exploded property to mark this info with. + */ + void setExploded(boolean exploded) { + mExploded = exploded; + } + + /** + * Returns the info represented as a {@link SimpleElement}. + * + * @return A {@link SimpleElement} wrapping this info. + */ + @NonNull + SimpleElement toSimpleElement() { + + UiViewElementNode uiNode = getUiViewNode(); + + String fqcn = SimpleXmlTransfer.getFqcn(uiNode.getDescriptor()); + String parentFqcn = null; + Rect bounds = SwtUtils.toRect(getAbsRect()); + Rect parentBounds = null; + + UiElementNode uiParent = uiNode.getUiParent(); + if (uiParent != null) { + parentFqcn = SimpleXmlTransfer.getFqcn(uiParent.getDescriptor()); + } + if (getParent() != null) { + parentBounds = SwtUtils.toRect(getParent().getAbsRect()); + } + + SimpleElement e = new SimpleElement(fqcn, parentFqcn, bounds, parentBounds); + + for (UiAttributeNode attr : uiNode.getAllUiAttributes()) { + String value = attr.getCurrentValue(); + if (value != null && value.length() > 0) { + AttributeDescriptor attrDesc = attr.getDescriptor(); + SimpleAttribute a = new SimpleAttribute( + attrDesc.getNamespaceUri(), + attrDesc.getXmlLocalName(), + value); + e.addAttribute(a); + } + } + + for (CanvasViewInfo childVi : getChildren()) { + SimpleElement e2 = childVi.toSimpleElement(); + if (e2 != null) { + e.addInnerElement(e2); + } + } + + return e; + } + + /** + * Returns the layout url attribute value for the closest surrounding include or + * fragment element parent, or null if this {@link CanvasViewInfo} is not rendered as + * part of an include or fragment tag. + * + * @return the layout url attribute value for the surrounding include tag, or null if + * not applicable + */ + @Nullable + public String getIncludeUrl() { + CanvasViewInfo curr = this; + while (curr != null) { + if (curr.mUiViewNode != null) { + Node node = curr.mUiViewNode.getXmlNode(); + if (node != null && node.getNodeType() == Node.ELEMENT_NODE) { + String nodeName = node.getNodeName(); + if (node.getNamespaceURI() == null + && SdkConstants.VIEW_INCLUDE.equals(nodeName)) { + // Note: the layout attribute is NOT in the Android namespace + Element element = (Element) node; + String url = element.getAttribute(SdkConstants.ATTR_LAYOUT); + if (url.length() > 0) { + return url; + } + } else if (SdkConstants.VIEW_FRAGMENT.equals(nodeName)) { + String url = FragmentMenu.getFragmentLayout(node); + if (url != null) { + return url; + } + } + } + } + curr = curr.mParent; + } + + return null; + } + + /** Adds the given {@link CanvasViewInfo} as a new last child of this view */ + private void addChild(@NonNull CanvasViewInfo child) { + mChildren.add(child); + } + + /** Adds the given {@link CanvasViewInfo} as a child at the given index */ + private void addChildAt(int index, @NonNull CanvasViewInfo child) { + mChildren.add(index, child); + } + + /** + * Removes the given {@link CanvasViewInfo} from the child list of this view, and + * returns true if it was successfully removed + * + * @param child the child to be removed + * @return true if it was a child and was removed + */ + public boolean removeChild(@NonNull CanvasViewInfo child) { + return mChildren.remove(child); + } + + @Override + public String toString() { + return "CanvasViewInfo [name=" + mName + ", node=" + mUiViewNode + "]"; + } + + // ---- Factory functionality ---- + + /** + * Creates a new {@link CanvasViewInfo} hierarchy based on the given {@link ViewInfo} + * hierarchy. Note that this will not necessarily create one {@link CanvasViewInfo} + * for each {@link ViewInfo}. It will generally only create {@link CanvasViewInfo} + * objects for {@link ViewInfo} objects that contain a reference to an + * {@link UiViewElementNode}, meaning that it corresponds to an element in the XML + * file for this layout file. This is not always the case, such as in the following + * scenarios: + * <ul> + * <li>we link to other layouts with {@code <include>} + * <li>the current view is rendered within another view ("Show Included In") such that + * the outer file does not correspond to elements in the current included XML layout + * <li>on older platforms that don't support {@link Capability#EMBEDDED_LAYOUT} there + * is no reference to the {@code <include>} tag + * <li>with the {@code <merge>} tag we don't get a reference to the corresponding + * element + * <ul> + * <p> + * This method will build up a set of {@link CanvasViewInfo} that corresponds to the + * actual <b>selectable</b> views (which are also shown in the Outline). + * + * @param layoutlib5 if true, the {@link ViewInfo} hierarchy was created by layoutlib + * version 5 or higher, which means this algorithm can make certain assumptions + * (for example that {@code <merge>} siblings will provide {@link MergeCookie} + * references, so we don't have to search for them.) + * @param root the root {@link ViewInfo} to build from + * @return a {@link CanvasViewInfo} hierarchy + */ + @NonNull + public static Pair<CanvasViewInfo,List<Rectangle>> create(ViewInfo root, boolean layoutlib5) { + return new Builder(layoutlib5).create(root); + } + + /** Builder object which walks over a tree of {@link ViewInfo} objects and builds + * up a corresponding {@link CanvasViewInfo} hierarchy. */ + private static class Builder { + public Builder(boolean layoutlib5) { + mLayoutLib5 = layoutlib5; + } + + /** + * The mapping from nodes that have a {@code <merge>} as a parent in the node + * model to their corresponding views + */ + private Map<UiViewElementNode, List<CanvasViewInfo>> mMergeNodeMap; + + /** + * Whether the ViewInfos are provided by a layout library that is version 5 or + * later, since that will allow us to take several shortcuts + */ + private boolean mLayoutLib5; + + /** + * Creates a hierarchy of {@link CanvasViewInfo} objects and merge bounding + * rectangles from the given {@link ViewInfo} hierarchy + */ + private Pair<CanvasViewInfo,List<Rectangle>> create(ViewInfo root) { + Object cookie = root.getCookie(); + if (cookie == null) { + // Special case: If the root-most view does not have a view cookie, + // then we are rendering some outer layout surrounding this layout, and in + // that case we must search down the hierarchy for the (possibly multiple) + // sub-roots that correspond to elements in this layout, and place them inside + // an outer view that has no node. In the outline this item will be used to + // show the inclusion-context. + CanvasViewInfo rootView = createView(null, root, 0, 0); + addKeyedSubtrees(rootView, root, 0, 0); + + List<Rectangle> includedBounds = new ArrayList<Rectangle>(); + for (CanvasViewInfo vi : rootView.getChildren()) { + if (vi.getNodeSiblings() == null || vi.isPrimaryNodeSibling()) { + includedBounds.add(vi.getAbsRect()); + } + } + + // There are <merge> nodes here; see if we can insert it into the hierarchy + if (mMergeNodeMap != null) { + // Locate all the nodes that have a <merge> as a parent in the node model, + // and where the view sits at the top level inside the include-context node. + UiViewElementNode merge = null; + List<CanvasViewInfo> merged = new ArrayList<CanvasViewInfo>(); + for (Map.Entry<UiViewElementNode, List<CanvasViewInfo>> entry : mMergeNodeMap + .entrySet()) { + UiViewElementNode node = entry.getKey(); + if (!hasMergeParent(node)) { + continue; + } + List<CanvasViewInfo> views = entry.getValue(); + assert views.size() > 0; + CanvasViewInfo view = views.get(0); // primary + if (view.getParent() != rootView) { + continue; + } + UiElementNode parent = node.getUiParent(); + if (merge != null && parent != merge) { + continue; + } + merge = (UiViewElementNode) parent; + merged.add(view); + } + if (merged.size() > 0) { + // Compute a bounding box for the merged views + Rectangle absRect = null; + for (CanvasViewInfo child : merged) { + Rectangle rect = child.getAbsRect(); + if (absRect == null) { + absRect = rect; + } else { + absRect = absRect.union(rect); + } + } + + CanvasViewInfo mergeView = new CanvasViewInfo(rootView, VIEW_MERGE, null, + merge, absRect, absRect, null /* viewInfo */); + for (CanvasViewInfo view : merged) { + if (rootView.removeChild(view)) { + mergeView.addChild(view); + } + } + rootView.addChild(mergeView); + } + } + + return Pair.of(rootView, includedBounds); + } else { + // We have a view key at the top, so just go and create {@link CanvasViewInfo} + // objects for each {@link ViewInfo} until we run into a null key. + CanvasViewInfo rootView = addKeyedSubtrees(null, root, 0, 0); + + // Special case: look to see if the root element is really a <merge>, and if so, + // manufacture a view for it such that we can target this root element + // in drag & drop operations, such that we can show it in the outline, etc + if (rootView != null && hasMergeParent(rootView.getUiViewNode())) { + CanvasViewInfo merge = new CanvasViewInfo(null, VIEW_MERGE, null, + (UiViewElementNode) rootView.getUiViewNode().getUiParent(), + rootView.getAbsRect(), rootView.getSelectionRect(), + null /* viewInfo */); + // Insert the <merge> as the new real root + rootView.mParent = merge; + merge.addChild(rootView); + rootView = merge; + } + + return Pair.of(rootView, null); + } + } + + private boolean hasMergeParent(UiViewElementNode rootNode) { + UiElementNode rootParent = rootNode.getUiParent(); + return (rootParent instanceof UiViewElementNode + && VIEW_MERGE.equals(rootParent.getDescriptor().getXmlName())); + } + + /** Creates a {@link CanvasViewInfo} for a given {@link ViewInfo} but does not recurse */ + private CanvasViewInfo createView(CanvasViewInfo parent, ViewInfo root, int parentX, + int parentY) { + Object cookie = root.getCookie(); + UiViewElementNode node = null; + if (cookie instanceof UiViewElementNode) { + node = (UiViewElementNode) cookie; + } else if (cookie instanceof MergeCookie) { + cookie = ((MergeCookie) cookie).getCookie(); + if (cookie instanceof UiViewElementNode) { + node = (UiViewElementNode) cookie; + CanvasViewInfo view = createView(parent, root, parentX, parentY, node); + if (root.getCookie() instanceof MergeCookie && view.mNodeSiblings == null) { + List<CanvasViewInfo> v = mMergeNodeMap == null ? + null : mMergeNodeMap.get(node); + if (v != null) { + v.add(view); + } else { + v = new ArrayList<CanvasViewInfo>(); + v.add(view); + if (mMergeNodeMap == null) { + mMergeNodeMap = + new HashMap<UiViewElementNode, List<CanvasViewInfo>>(); + } + mMergeNodeMap.put(node, v); + } + view.mNodeSiblings = v; + } + + return view; + } + } + + return createView(parent, root, parentX, parentY, node); + } + + /** + * Creates a {@link CanvasViewInfo} for a given {@link ViewInfo} but does not recurse. + * This method specifies an explicit {@link UiViewElementNode} to use rather than + * relying on the view cookie in the info object. + */ + private CanvasViewInfo createView(CanvasViewInfo parent, ViewInfo root, int parentX, + int parentY, UiViewElementNode node) { + + int x = root.getLeft(); + int y = root.getTop(); + int w = root.getRight() - x; + int h = root.getBottom() - y; + + x += parentX; + y += parentY; + + Rectangle absRect = new Rectangle(x, y, w - 1, h - 1); + + if (w < SELECTION_MIN_SIZE) { + int d = (SELECTION_MIN_SIZE - w) / 2; + x -= d; + w += SELECTION_MIN_SIZE - w; + } + + if (h < SELECTION_MIN_SIZE) { + int d = (SELECTION_MIN_SIZE - h) / 2; + y -= d; + h += SELECTION_MIN_SIZE - h; + } + + Rectangle selectionRect = new Rectangle(x, y, w - 1, h - 1); + + return new CanvasViewInfo(parent, root.getClassName(), root.getViewObject(), node, + absRect, selectionRect, root); + } + + /** Create a subtree recursively until you run out of keys */ + private CanvasViewInfo createSubtree(CanvasViewInfo parent, ViewInfo viewInfo, + int parentX, int parentY) { + assert viewInfo.getCookie() != null; + + CanvasViewInfo view = createView(parent, viewInfo, parentX, parentY); + // Bug workaround: Ensure that we never have a child node identical + // to its parent node: this can happen for example when rendering a + // ZoomControls view where the merge cookies point to the parent. + if (parent != null && view.mUiViewNode == parent.mUiViewNode) { + return null; + } + + // Process children: + parentX += viewInfo.getLeft(); + parentY += viewInfo.getTop(); + + List<ViewInfo> children = viewInfo.getChildren(); + + if (mLayoutLib5) { + for (ViewInfo child : children) { + Object cookie = child.getCookie(); + if (cookie instanceof UiViewElementNode || cookie instanceof MergeCookie) { + CanvasViewInfo childView = createSubtree(view, child, + parentX, parentY); + if (childView != null) { + view.addChild(childView); + } + } // else: null cookies, adapter item references, etc: No child views. + } + + return view; + } + + // See if we have any missing keys at this level + int missingNodes = 0; + int mergeNodes = 0; + for (ViewInfo child : children) { + // Only use children which have a ViewKey of the correct type. + // We can't interact with those when they have a null key or + // an incompatible type. + Object cookie = child.getCookie(); + if (!(cookie instanceof UiViewElementNode)) { + if (cookie instanceof MergeCookie) { + mergeNodes++; + } else { + missingNodes++; + } + } + } + + if (missingNodes == 0 && mergeNodes == 0) { + // No missing nodes; this is the normal case, and we can just continue to + // recursively add our children + for (ViewInfo child : children) { + CanvasViewInfo childView = createSubtree(view, child, + parentX, parentY); + view.addChild(childView); + } + + // TBD: Emit placeholder views for keys that have no views? + } else { + // We don't have keys for one or more of the ViewInfos. There are many + // possible causes: we are on an SDK platform that does not support + // embedded_layout rendering, or we are including a view with a <merge> + // as the root element. + + UiViewElementNode uiViewNode = view.getUiViewNode(); + String containerName = uiViewNode != null + ? uiViewNode.getDescriptor().getXmlLocalName() : ""; //$NON-NLS-1$ + if (containerName.equals(SdkConstants.VIEW_INCLUDE)) { + // This is expected -- we don't WANT to get node keys for the content + // of an include since it's in a different file and should be treated + // as a single unit that cannot be edited (hence, no CanvasViewInfo + // children) + } else { + // We are getting children with null keys where we don't expect it; + // this usually means that we are dealing with an Android platform + // that does not support {@link Capability#EMBEDDED_LAYOUT}, or + // that there are <merge> tags which are doing surprising things + // to the view hierarchy + LinkedList<UiViewElementNode> unused = new LinkedList<UiViewElementNode>(); + if (uiViewNode != null) { + for (UiElementNode child : uiViewNode.getUiChildren()) { + if (child instanceof UiViewElementNode) { + unused.addLast((UiViewElementNode) child); + } + } + } + for (ViewInfo child : children) { + Object cookie = child.getCookie(); + if (mergeNodes > 0 && cookie instanceof MergeCookie) { + cookie = ((MergeCookie) cookie).getCookie(); + } + if (cookie != null) { + unused.remove(cookie); + } + } + + if (unused.size() > 0 || mergeNodes > 0) { + if (unused.size() == missingNodes) { + // The number of unmatched elements and ViewInfos are identical; + // it's very likely that they match one to one, so just use these + for (ViewInfo child : children) { + if (child.getCookie() == null) { + // Only create a flat (non-recursive) view + CanvasViewInfo childView = createView(view, child, parentX, + parentY, unused.removeFirst()); + view.addChild(childView); + } else { + CanvasViewInfo childView = createSubtree(view, child, parentX, + parentY); + view.addChild(childView); + } + } + } else { + // We have an uneven match. In this case we might be dealing + // with <merge> etc. + // We have no way to associate elements back with the + // corresponding <include> tags if there are more than one of + // them. That's not a huge tragedy since visually you are not + // allowed to edit these anyway; we just need to make a visual + // block for these for selection and outline purposes. + addMismatched(view, parentX, parentY, children, unused); + } + } else { + // No unused keys, but there are views without keys. + // We can't represent these since all views must have node keys + // such that you can operate on them. Just ignore these. + for (ViewInfo child : children) { + if (child.getCookie() != null) { + CanvasViewInfo childView = createSubtree(view, child, + parentX, parentY); + view.addChild(childView); + } + } + } + } + } + + return view; + } + + /** + * We have various {@link ViewInfo} children with null keys, and/or nodes in + * the corresponding UI model that are not referenced by any of the {@link ViewInfo} + * objects. This method attempts to account for this, by matching the views in + * the right order. + */ + private void addMismatched(CanvasViewInfo parentView, int parentX, int parentY, + List<ViewInfo> children, LinkedList<UiViewElementNode> unused) { + UiViewElementNode afterNode = null; + UiViewElementNode beforeNode = null; + // We have one important clue we can use when matching unused nodes + // with views: if we have a view V1 with node N1, and a view V2 with node N2, + // then we can only match unknown node UN with unknown node UV if + // V1 < UV < V2 and N1 < UN < N2. + // We can use these constraints to do the matching, for example by + // a simple DAG traversal. However, since the number of unmatched nodes + // will typically be very small, we'll just do a simple algorithm here + // which checks forwards/backwards whether a match is valid. + for (int index = 0, size = children.size(); index < size; index++) { + ViewInfo child = children.get(index); + if (child.getCookie() != null) { + CanvasViewInfo childView = createSubtree(parentView, child, parentX, parentY); + if (childView != null) { + parentView.addChild(childView); + } + if (child.getCookie() instanceof UiViewElementNode) { + afterNode = (UiViewElementNode) child.getCookie(); + } + } else { + beforeNode = nextViewNode(children, index); + + // Find first eligible node from unused + // TOD: What if there are more eligible? We need to process ALL views + // and all nodes in one go here + + UiViewElementNode matching = null; + for (UiViewElementNode candidate : unused) { + if (afterNode == null || isAfter(afterNode, candidate)) { + if (beforeNode == null || isBefore(beforeNode, candidate)) { + matching = candidate; + break; + } + } + } + + if (matching != null) { + unused.remove(matching); + CanvasViewInfo childView = createView(parentView, child, parentX, parentY, + matching); + parentView.addChild(childView); + afterNode = matching; + } else { + // We have no node for the view -- what do we do?? + // Nothing - we only represent stuff in the outline that is in the + // source model, not in the render + } + } + } + + // Add zero-bounded boxes for all remaining nodes since they need to show + // up in the outline, need to be selectable so you can press Delete, etc. + if (unused.size() > 0) { + Map<UiViewElementNode, Integer> rankMap = + new HashMap<UiViewElementNode, Integer>(); + Map<UiViewElementNode, CanvasViewInfo> infoMap = + new HashMap<UiViewElementNode, CanvasViewInfo>(); + UiElementNode parent = unused.get(0).getUiParent(); + if (parent != null) { + int index = 0; + for (UiElementNode child : parent.getUiChildren()) { + UiViewElementNode node = (UiViewElementNode) child; + rankMap.put(node, index++); + } + for (CanvasViewInfo child : parentView.getChildren()) { + infoMap.put(child.getUiViewNode(), child); + } + List<Integer> usedIndexes = new ArrayList<Integer>(); + for (UiViewElementNode node : unused) { + Integer rank = rankMap.get(node); + if (rank != null) { + usedIndexes.add(rank); + } + } + Collections.sort(usedIndexes); + for (int i = usedIndexes.size() - 1; i >= 0; i--) { + Integer rank = usedIndexes.get(i); + UiViewElementNode found = null; + for (UiViewElementNode node : unused) { + if (rankMap.get(node) == rank) { + found = node; + break; + } + } + if (found != null) { + Rectangle absRect = new Rectangle(parentX, parentY, 0, 0); + String name = found.getDescriptor().getXmlLocalName(); + CanvasViewInfo v = new CanvasViewInfo(parentView, name, null, found, + absRect, absRect, null /* viewInfo */); + // Find corresponding index in the parent view + List<CanvasViewInfo> siblings = parentView.getChildren(); + int insertPosition = siblings.size(); + for (int j = siblings.size() - 1; j >= 0; j--) { + CanvasViewInfo sibling = siblings.get(j); + UiViewElementNode siblingNode = sibling.getUiViewNode(); + if (siblingNode != null) { + Integer siblingRank = rankMap.get(siblingNode); + if (siblingRank != null && siblingRank < rank) { + insertPosition = j + 1; + break; + } + } + } + parentView.addChildAt(insertPosition, v); + unused.remove(found); + } + } + } + // Add in any remaining + for (UiViewElementNode node : unused) { + Rectangle absRect = new Rectangle(parentX, parentY, 0, 0); + String name = node.getDescriptor().getXmlLocalName(); + CanvasViewInfo v = new CanvasViewInfo(parentView, name, null, node, absRect, + absRect, null /* viewInfo */); + parentView.addChild(v); + } + } + } + + private boolean isBefore(UiViewElementNode beforeNode, UiViewElementNode candidate) { + UiElementNode parent = candidate.getUiParent(); + if (parent != null) { + for (UiElementNode sibling : parent.getUiChildren()) { + if (sibling == beforeNode) { + return false; + } else if (sibling == candidate) { + return true; + } + } + } + return false; + } + + private boolean isAfter(UiViewElementNode afterNode, UiViewElementNode candidate) { + UiElementNode parent = candidate.getUiParent(); + if (parent != null) { + for (UiElementNode sibling : parent.getUiChildren()) { + if (sibling == afterNode) { + return true; + } else if (sibling == candidate) { + return false; + } + } + } + return false; + } + + private UiViewElementNode nextViewNode(List<ViewInfo> children, int index) { + int size = children.size(); + for (; index < size; index++) { + ViewInfo child = children.get(index); + if (child.getCookie() instanceof UiViewElementNode) { + return (UiViewElementNode) child.getCookie(); + } + } + + return null; + } + + /** Search for a subtree with valid keys and add those subtrees */ + private CanvasViewInfo addKeyedSubtrees(CanvasViewInfo parent, ViewInfo viewInfo, + int parentX, int parentY) { + // We don't include MergeCookies when searching down for the first non-null key, + // since this means we are in a "Show Included In" context, and the include tag itself + // (which the merge cookie is pointing to) is still in the including-document rather + // than the included document. Therefore, we only accept real UiViewElementNodes here, + // not MergeCookies. + if (viewInfo.getCookie() != null) { + CanvasViewInfo subtree = createSubtree(parent, viewInfo, parentX, parentY); + if (parent != null && subtree != null) { + parent.mChildren.add(subtree); + } + return subtree; + } else { + for (ViewInfo child : viewInfo.getChildren()) { + addKeyedSubtrees(parent, child, parentX + viewInfo.getLeft(), parentY + + viewInfo.getTop()); + } + + return null; + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ClipboardSupport.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ClipboardSupport.java new file mode 100644 index 000000000..263456984 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ClipboardSupport.java @@ -0,0 +1,429 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_NS_NAME; +import static com.android.SdkConstants.NS_RESOURCES; +import static com.android.SdkConstants.XMLNS_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.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; + +import org.eclipse.jface.action.Action; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.dnd.TransferData; +import org.eclipse.swt.widgets.Composite; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The {@link ClipboardSupport} class manages the native clipboard, providing operations + * to copy, cut and paste view items, and can answer whether the clipboard contains + * a transferable we care about. + */ +public class ClipboardSupport { + private static final boolean DEBUG = false; + + /** SWT clipboard instance. */ + private Clipboard mClipboard; + private LayoutCanvas mCanvas; + + /** + * Constructs a new {@link ClipboardSupport} tied to the given + * {@link LayoutCanvas}. + * + * @param canvas The {@link LayoutCanvas} to provide clipboard support for. + * @param parent The parent widget in the SWT hierarchy of the canvas. + */ + public ClipboardSupport(LayoutCanvas canvas, Composite parent) { + mCanvas = canvas; + + mClipboard = new Clipboard(parent.getDisplay()); + } + + /** + * Frees up any resources held by the {@link ClipboardSupport}. + */ + public void dispose() { + if (mClipboard != null) { + mClipboard.dispose(); + mClipboard = null; + } + } + + /** + * Perform the "Copy" action, either from the Edit menu or from the context + * menu. + * <p/> + * This sanitizes the selection, so it must be a copy. It then inserts the + * selection both as text and as {@link SimpleElement}s in the clipboard. + * (If there is selected text in the error label, then the error is used + * as the text portion of the transferable.) + * + * @param selection A list of selection items to add to the clipboard; + * <b>this should be a copy already - this method will not make a + * copy</b> + */ + public void copySelectionToClipboard(List<SelectionItem> selection) { + SelectionManager.sanitize(selection); + + // The error message area shares the copy action with the canvas. Invoking the + // copy action when there are errors visible *AND* the user has selected text there, + // should include the error message as the text transferable. + String message = null; + GraphicalEditorPart graphicalEditor = mCanvas.getEditorDelegate().getGraphicalEditor(); + StyledText errorLabel = graphicalEditor.getErrorLabel(); + if (errorLabel.getSelectionCount() > 0) { + message = errorLabel.getSelectionText(); + } + + if (selection.isEmpty()) { + if (message != null) { + mClipboard.setContents( + new Object[] { message }, + new Transfer[] { TextTransfer.getInstance() } + ); + } + return; + } + + Object[] data = new Object[] { + SelectionItem.getAsElements(selection), + message != null ? message : SelectionItem.getAsText(mCanvas, selection) + }; + + Transfer[] types = new Transfer[] { + SimpleXmlTransfer.getInstance(), + TextTransfer.getInstance() + }; + + mClipboard.setContents(data, types); + } + + /** + * Perform the "Cut" action, either from the Edit menu or from the context + * menu. + * <p/> + * This sanitizes the selection, so it must be a copy. It uses the + * {@link #copySelectionToClipboard(List)} method to copy the selection to + * the clipboard. Finally it uses {@link #deleteSelection(String, List)} to + * delete the selection with a "Cut" verb for the title. + * + * @param selection A list of selection items to add to the clipboard; + * <b>this should be a copy already - this method will not make a + * copy</b> + */ + public void cutSelectionToClipboard(List<SelectionItem> selection) { + copySelectionToClipboard(selection); + deleteSelection(mCanvas.getCutLabel(), selection); + } + + /** + * Deletes the given selection. + * + * @param verb A translated verb for the action. Will be used for the + * undo/redo title. Typically this should be + * {@link Action#getText()} for either the cut or the delete + * actions in the canvas. + * @param selection The selection. Must not be null. Can be empty, in which + * case nothing happens. The selection list will be sanitized so + * the caller should pass in a copy. + */ + public void deleteSelection(String verb, final List<SelectionItem> selection) { + SelectionManager.sanitize(selection); + + if (selection.isEmpty()) { + return; + } + + // If all selected items have the same *kind* of parent, display that in the undo title. + String title = null; + for (SelectionItem cs : selection) { + CanvasViewInfo vi = cs.getViewInfo(); + if (vi != null && vi.getParent() != null) { + CanvasViewInfo parent = vi.getParent(); + assert parent != null; + if (title == null) { + title = parent.getName(); + } else if (!title.equals(parent.getName())) { + // More than one kind of parent selected. + title = null; + break; + } + } + } + + if (title != null) { + // Typically the name is an FQCN. Just get the last segment. + int pos = title.lastIndexOf('.'); + if (pos > 0 && pos < title.length() - 1) { + title = title.substring(pos + 1); + } + } + boolean multiple = mCanvas.getSelectionManager().hasMultiSelection(); + if (title == null) { + title = String.format( + multiple ? "%1$s elements" : "%1$s element", + verb); + } else { + title = String.format( + multiple ? "%1$s elements from %2$s" : "%1$s element from %2$s", + verb, title); + } + + // Implementation note: we don't clear the internal selection after removing + // the elements. An update XML model event should happen when the model gets released + // which will trigger a recompute of the layout, thus reloading the model thus + // resetting the selection. + mCanvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel(title, new Runnable() { + @Override + public void run() { + // Segment the deleted nodes into clusters of siblings + Map<NodeProxy, List<INode>> clusters = + new HashMap<NodeProxy, List<INode>>(); + for (SelectionItem cs : selection) { + NodeProxy node = cs.getNode(); + if (node == null) { + continue; + } + INode parent = node.getParent(); + if (parent != null) { + List<INode> children = clusters.get(parent); + if (children == null) { + children = new ArrayList<INode>(); + clusters.put((NodeProxy) parent, children); + } + children.add(node); + } + } + + // Notify parent views about children getting deleted + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + for (Map.Entry<NodeProxy, List<INode>> entry : clusters.entrySet()) { + NodeProxy parent = entry.getKey(); + List<INode> children = entry.getValue(); + assert children != null && children.size() > 0; + rulesEngine.callOnRemovingChildren(parent, children); + parent.applyPendingChanges(); + } + + for (SelectionItem cs : selection) { + CanvasViewInfo vi = cs.getViewInfo(); + // You can't delete the root element + if (vi != null && !vi.isRoot()) { + UiViewElementNode ui = vi.getUiViewNode(); + if (ui != null) { + ui.deleteXmlNode(); + } + } + } + } + }); + } + + /** + * Perform the "Paste" action, either from the Edit menu or from the context + * menu. + * + * @param selection A list of selection items to add to the clipboard; + * <b>this should be a copy already - this method will not make a + * copy</b> + */ + public void pasteSelection(List<SelectionItem> selection) { + + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + final SimpleElement[] pasted = (SimpleElement[]) mClipboard.getContents(sxt); + + if (pasted == null || pasted.length == 0) { + return; + } + + CanvasViewInfo lastRoot = mCanvas.getViewHierarchy().getRoot(); + if (lastRoot == null) { + // Pasting in an empty document. Only paste the first element. + pasteInEmptyDocument(pasted[0]); + return; + } + + // Otherwise use the current selection, if any, as a guide where to paste + // using the first selected element only. If there's no selection use + // the root as the insertion point. + SelectionManager.sanitize(selection); + final CanvasViewInfo target; + if (selection.size() > 0) { + SelectionItem cs = selection.get(0); + target = cs.getViewInfo(); + } else { + target = lastRoot; + } + + final NodeProxy targetNode = mCanvas.getNodeFactory().create(target); + mCanvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel("Paste", new Runnable() { + @Override + public void run() { + RulesEngine engine = mCanvas.getRulesEngine(); + NodeProxy node = engine.callOnPaste(targetNode, target.getViewObject(), pasted); + node.applyPendingChanges(); + } + }); + } + + /** + * Paste a new root into an empty XML layout. + * <p/> + * In case of error (unknown FQCN, document not empty), silently do nothing. + * In case of success, the new element will have some default attributes set (xmlns:android, + * layout_width and height). The edit is wrapped in a proper undo. + * <p/> + * Implementation is similar to {@link #createDocumentRoot} except we also + * copy all the attributes and inner elements recursively. + */ + private void pasteInEmptyDocument(final IDragElement pastedElement) { + String rootFqcn = pastedElement.getFqcn(); + + // Need a valid empty document to create the new root + final LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + final UiDocumentNode uiDoc = delegate.getUiRootNode(); + if (uiDoc == null || uiDoc.getUiChildren().size() > 0) { + debugPrintf("Failed to paste document root for %1$s: document is not empty", rootFqcn); + return; + } + + // Find the view descriptor matching our FQCN + final ViewElementDescriptor viewDesc = delegate.getFqcnViewDescriptor(rootFqcn); + if (viewDesc == null) { + // TODO this could happen if pasting a custom view not known in this project + debugPrintf("Failed to paste document root, unknown FQCN %1$s", rootFqcn); + return; + } + + // Get the last segment of the FQCN for the undo title + String title = rootFqcn; + int pos = title.lastIndexOf('.'); + if (pos > 0 && pos < title.length() - 1) { + title = title.substring(pos + 1); + } + title = String.format("Paste root %1$s in document", title); + + delegate.getEditor().wrapUndoEditXmlModel(title, new Runnable() { + @Override + public void run() { + UiElementNode uiNew = uiDoc.appendNewUiChild(viewDesc); + + // A root node requires the Android XMLNS + uiNew.setAttributeValue(ANDROID_NS_NAME, XMLNS_URI, NS_RESOURCES, + true /*override*/); + + // Copy all the attributes from the pasted element + for (IDragAttribute attr : pastedElement.getAttributes()) { + uiNew.setAttributeValue( + attr.getName(), + attr.getUri(), + attr.getValue(), + true /*override*/); + } + + // Adjust the attributes, adding the default layout_width/height + // only if they are not present (the original element should have + // them though.) + DescriptorsUtils.setDefaultLayoutAttributes(uiNew, false /*updateLayout*/); + + uiNew.createXmlNode(); + + // Now process all children + for (IDragElement childElement : pastedElement.getInnerElements()) { + addChild(uiNew, childElement); + } + } + + private void addChild(UiElementNode uiParent, IDragElement childElement) { + String childFqcn = childElement.getFqcn(); + final ViewElementDescriptor childDesc = + delegate.getFqcnViewDescriptor(childFqcn); + if (childDesc == null) { + // TODO this could happen if pasting a custom view + debugPrintf("Failed to paste element, unknown FQCN %1$s", childFqcn); + return; + } + + UiElementNode uiChild = uiParent.appendNewUiChild(childDesc); + + // Copy all the attributes from the pasted element + for (IDragAttribute attr : childElement.getAttributes()) { + uiChild.setAttributeValue( + attr.getName(), + attr.getUri(), + attr.getValue(), + true /*override*/); + } + + // Adjust the attributes, adding the default layout_width/height + // only if they are not present (the original element should have + // them though.) + DescriptorsUtils.setDefaultLayoutAttributes( + uiChild, false /*updateLayout*/); + + uiChild.createXmlNode(); + + // Now process all grand children + for (IDragElement grandChildElement : childElement.getInnerElements()) { + addChild(uiChild, grandChildElement); + } + } + }); + } + + /** + * Returns true if we have a a simple xml transfer data object on the + * clipboard. + * + * @return True if and only if the clipboard contains one of XML element + * objects. + */ + public boolean hasSxtOnClipboard() { + // The paste operation is only available if we can paste our custom type. + // We do not currently support pasting random text (e.g. XML). Maybe later. + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + for (TransferData td : mClipboard.getAvailableTypes()) { + if (sxt.isSupportedType(td)) { + return true; + } + } + + return false; + } + + private void debugPrintf(String message, Object... params) { + if (DEBUG) AdtPlugin.printToConsole("Clipboard", String.format(message, params)); + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ControlPoint.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ControlPoint.java new file mode 100644 index 000000000..55930f6cd --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ControlPoint.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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.dnd.DragSourceEvent; +import org.eclipse.swt.dnd.DragSourceListener; +import org.eclipse.swt.dnd.DropTargetEvent; +import org.eclipse.swt.events.MenuDetectEvent; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseListener; +import org.eclipse.swt.graphics.Point; + +/** + * A {@link ControlPoint} is a coordinate in the canvas control which corresponds + * exactly to (0,0) at the top left of the canvas. It is unaffected by canvas + * zooming. + */ +public final class ControlPoint { + /** Containing canvas which the point is relative to. */ + private final LayoutCanvas mCanvas; + + /** The X coordinate of the mouse coordinate. */ + public final int x; + + /** The Y coordinate of the mouse coordinate. */ + public final int y; + + /** + * Constructs a new {@link ControlPoint} from the given event. The event + * must be from a {@link MouseListener} associated with the + * {@link LayoutCanvas} such that the {@link MouseEvent#x} and + * {@link MouseEvent#y} fields are relative to the canvas. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param event The mouse event to construct the {@link ControlPoint} + * from. + * @return A {@link ControlPoint} which corresponds to the given + * {@link MouseEvent}. + */ + public static ControlPoint create(LayoutCanvas canvas, MouseEvent event) { + // The mouse event coordinates should already be relative to the canvas + // widget. + assert event.widget == canvas : event.widget; + return new ControlPoint(canvas, event.x, event.y); + } + + /** + * Constructs a new {@link ControlPoint} from the given menu detect event. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param event The menu detect event to construct the {@link ControlPoint} from. + * @return A {@link ControlPoint} which corresponds to the given + * {@link MenuDetectEvent}. + */ + public static ControlPoint create(LayoutCanvas canvas, MenuDetectEvent event) { + // The menu detect events are always display-relative. + org.eclipse.swt.graphics.Point p = canvas.toControl(event.x, event.y); + return new ControlPoint(canvas, p.x, p.y); + } + + /** + * Constructs a new {@link ControlPoint} from the given event. The event + * must be from a {@link DragSourceListener} associated with the + * {@link LayoutCanvas} such that the {@link DragSourceEvent#x} and + * {@link DragSourceEvent#y} fields are relative to the canvas. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param event The mouse event to construct the {@link ControlPoint} + * from. + * @return A {@link ControlPoint} which corresponds to the given + * {@link DragSourceEvent}. + */ + public static ControlPoint create(LayoutCanvas canvas, DragSourceEvent event) { + // The drag source event coordinates should already be relative to the + // canvas widget. + return new ControlPoint(canvas, event.x, event.y); + } + + /** + * Constructs a new {@link ControlPoint} from the given event. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param event The mouse event to construct the {@link ControlPoint} + * from. + * @return A {@link ControlPoint} which corresponds to the given + * {@link DropTargetEvent}. + */ + public static ControlPoint create(LayoutCanvas canvas, DropTargetEvent event) { + // The drop target events are always relative to the display, so we must + // first convert them to be canvas relative. + org.eclipse.swt.graphics.Point p = canvas.toControl(event.x, event.y); + return new ControlPoint(canvas, p.x, p.y); + } + + /** + * Constructs a new {@link ControlPoint} from the given x,y coordinates, + * which must be relative to the given {@link LayoutCanvas}. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param x The mouse event x coordinate relative to the canvas + * @param y The mouse event x coordinate relative to the canvas + * @return A {@link ControlPoint} which corresponds to the given + * coordinates. + */ + public static ControlPoint create(LayoutCanvas canvas, int x, int y) { + return new ControlPoint(canvas, x, y); + } + + /** + * Constructs a new canvas control coordinate with the given X and Y + * coordinates. This is private; use one of the factory methods + * {@link #create(LayoutCanvas, MouseEvent)}, + * {@link #create(LayoutCanvas, DragSourceEvent)} or + * {@link #create(LayoutCanvas, DropTargetEvent)} instead. + * + * @param canvas The canvas which contains this coordinate + * @param x The mouse x coordinate + * @param y The mouse y coordinate + */ + private ControlPoint(LayoutCanvas canvas, int x, int y) { + mCanvas = canvas; + this.x = x; + this.y = y; + } + + /** + * Returns the equivalent {@link LayoutPoint} to this + * {@link ControlPoint}. + * + * @return The equivalent {@link LayoutPoint} to this + * {@link ControlPoint}. + */ + public LayoutPoint toLayout() { + int lx = mCanvas.getHorizontalTransform().inverseTranslate(x); + int ly = mCanvas.getVerticalTransform().inverseTranslate(y); + + return LayoutPoint.create(mCanvas, lx, ly); + } + + @Override + public String toString() { + return "ControlPoint [x=" + x + ", y=" + y + "]"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + x; + result = prime * result + y; + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + ControlPoint other = (ControlPoint) obj; + if (x != other.x) + return false; + if (y != other.y) + return false; + if (mCanvas != other.mCanvas) { + return false; + } + return true; + } + + /** + * Returns this point as an SWT point in the display coordinate system + * + * @return this point as an SWT point in the display coordinate system + */ + public Point toDisplayPoint() { + return mCanvas.toDisplay(x, y); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CreateNewConfigJob.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CreateNewConfigJob.java new file mode 100644 index 000000000..44cd0810f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CreateNewConfigJob.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.resources.ResourceFolderType; +import com.google.common.base.Charsets; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.PartInitException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** Job which creates a new layout file for a given configuration */ +class CreateNewConfigJob extends Job { + private final GraphicalEditorPart mEditor; + private final IFile mFromFile; + private final FolderConfiguration mConfig; + + CreateNewConfigJob( + @NonNull GraphicalEditorPart editor, + @NonNull IFile fromFile, + @NonNull FolderConfiguration config) { + super("Create Alternate Layout"); + mEditor = editor; + mFromFile = fromFile; + mConfig = config; + } + + @Override + protected IStatus run(IProgressMonitor monitor) { + // get the folder name + String folderName = mConfig.getFolderName(ResourceFolderType.LAYOUT); + try { + // look to see if it exists. + // get the res folder + IFolder res = (IFolder) mFromFile.getParent().getParent(); + + IFolder newParentFolder = res.getFolder(folderName); + AdtUtils.ensureExists(newParentFolder); + final IFile file = newParentFolder.getFile(mFromFile.getName()); + if (file.exists()) { + String message = String.format("File 'res/%1$s/%2$s' already exists!", + folderName, mFromFile.getName()); + return new Status(IStatus.ERROR, AdtPlugin.PLUGIN_ID, message); + } + + // Read current document contents instead of from file: mFromFile.getContents() + String text = mEditor.getEditorDelegate().getEditor().getStructuredDocument().get(); + ByteArrayInputStream input = new ByteArrayInputStream(text.getBytes(Charsets.UTF_8)); + file.create(input, false, monitor); + input.close(); + + // Ensure that the project resources updates itself to notice the new configuration. + // In theory, this shouldn't be necessary, but we need to make sure the + // resource manager knows about this immediately such that the call below + // to find the best configuration takes the new folder into account. + ResourceManager resourceManager = ResourceManager.getInstance(); + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + IFolder folder = root.getFolder(newParentFolder.getFullPath()); + resourceManager.getResourceFolder(folder); + + // Switch to the new file + Display display = mEditor.getConfigurationChooser().getDisplay(); + display.asyncExec(new Runnable() { + @Override + public void run() { + // The given old layout has been forked into a new layout + // for a given configuration. This means that the old layout + // is no longer a match for the configuration, which is + // probably what it is still showing. We have to modify + // its configuration to no longer be an impossible + // configuration. + ConfigurationChooser chooser = mEditor.getConfigurationChooser(); + chooser.onAlternateLayoutCreated(); + + // Finally open the new layout + try { + AdtPlugin.openFile(file, null, false); + } catch (PartInitException e) { + AdtPlugin.log(e, null); + } + } + }); + } catch (IOException e2) { + String message = String.format( + "Failed to create File 'res/%1$s/%2$s' : %3$s", + folderName, mFromFile.getName(), e2.getMessage()); + AdtPlugin.displayError("Layout Creation", message); + + return new Status(IStatus.ERROR, AdtPlugin.PLUGIN_ID, + message, e2); + } catch (CoreException e2) { + String message = String.format( + "Failed to create File 'res/%1$s/%2$s' : %3$s", + folderName, mFromFile.getName(), e2.getMessage()); + AdtPlugin.displayError("Layout Creation", message); + + return e2.getStatus(); + } + + return Status.OK_STATUS; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CustomViewFinder.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CustomViewFinder.java new file mode 100644 index 000000000..1f97c8c54 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/CustomViewFinder.java @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.CLASS_VIEW; +import static com.android.SdkConstants.CLASS_VIEWGROUP; +import static com.android.SdkConstants.FN_FRAMEWORK_LIBRARY; + +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; +import com.android.ide.eclipse.adt.internal.sdk.ProjectState; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.Flags; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.IPackageFragment; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.search.IJavaSearchConstants; +import org.eclipse.jdt.core.search.IJavaSearchScope; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.SearchParticipant; +import org.eclipse.jdt.core.search.SearchPattern; +import org.eclipse.jdt.core.search.SearchRequestor; +import org.eclipse.jdt.internal.core.ResolvedBinaryType; +import org.eclipse.jdt.internal.core.ResolvedSourceType; +import org.eclipse.swt.widgets.Display; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The {@link CustomViewFinder} can look up the custom views and third party views + * available for a given project. + */ +@SuppressWarnings("restriction") // JDT model access for custom-view class lookup +public class CustomViewFinder { + /** + * Qualified name for the per-project non-persistent property storing the + * {@link CustomViewFinder} for this project + */ + private final static QualifiedName CUSTOM_VIEW_FINDER = new QualifiedName(AdtPlugin.PLUGIN_ID, + "viewfinder"); //$NON-NLS-1$ + + /** Project that this view finder locates views for */ + private final IProject mProject; + + private final List<Listener> mListeners = new ArrayList<Listener>(); + + private List<String> mCustomViews; + private List<String> mThirdPartyViews; + private boolean mRefreshing; + + /** + * Constructs an {@link CustomViewFinder} for the given project. Don't use this method; + * use the {@link #get} factory method instead. + * + * @param project project to create an {@link CustomViewFinder} for + */ + private CustomViewFinder(IProject project) { + mProject = project; + } + + /** + * Returns the {@link CustomViewFinder} for the given project + * + * @param project the project the finder is associated with + * @return a {@CustomViewFinder} for the given project, never null + */ + public static CustomViewFinder get(IProject project) { + CustomViewFinder finder = null; + try { + finder = (CustomViewFinder) project.getSessionProperty(CUSTOM_VIEW_FINDER); + } catch (CoreException e) { + // Not a problem; we will just create a new one + } + + if (finder == null) { + finder = new CustomViewFinder(project); + try { + project.setSessionProperty(CUSTOM_VIEW_FINDER, finder); + } catch (CoreException e) { + AdtPlugin.log(e, "Can't store CustomViewFinder"); + } + } + + return finder; + } + + public void refresh() { + refresh(null /*listener*/, true /* sync */); + } + + public void refresh(final Listener listener) { + refresh(listener, false /* sync */); + } + + private void refresh(final Listener listener, boolean sync) { + // Add this listener to the list of listeners which should be notified when the + // search is done. (There could be more than one since multiple requests could + // arrive for a slow search since the search is run in a different thread). + if (listener != null) { + synchronized (this) { + mListeners.add(listener); + } + } + synchronized (this) { + if (listener != null) { + mListeners.add(listener); + } + if (mRefreshing) { + return; + } + mRefreshing = true; + } + + FindViewsJob job = new FindViewsJob(); + job.schedule(); + if (sync) { + try { + job.join(); + } catch (InterruptedException e) { + AdtPlugin.log(e, null); + } + } + } + + public Collection<String> getCustomViews() { + return mCustomViews == null ? null : Collections.unmodifiableCollection(mCustomViews); + } + + public Collection<String> getThirdPartyViews() { + return mThirdPartyViews == null + ? null : Collections.unmodifiableCollection(mThirdPartyViews); + } + + public Collection<String> getAllViews() { + // Not yet initialized: return null + if (mCustomViews == null) { + return null; + } + List<String> all = new ArrayList<String>(mCustomViews.size() + mThirdPartyViews.size()); + all.addAll(mCustomViews); + all.addAll(mThirdPartyViews); + return all; + } + + /** + * Returns a pair of view lists - the custom views and the 3rd-party views. + * This method performs no caching; it is the same as asking the custom view finder + * to refresh itself and then waiting for the answer and returning it. + * + * @param project the Android project + * @param layoutsOnly if true, only search for layouts + * @return a pair of lists, the first containing custom views and the second + * containing 3rd party views + */ + public static Pair<List<String>,List<String>> findViews( + final IProject project, boolean layoutsOnly) { + CustomViewFinder finder = get(project); + + return finder.findViews(layoutsOnly); + } + + private Pair<List<String>,List<String>> findViews(final boolean layoutsOnly) { + final Set<String> customViews = new HashSet<String>(); + final Set<String> thirdPartyViews = new HashSet<String>(); + + ProjectState state = Sdk.getProjectState(mProject); + final List<IProject> libraries = state != null + ? state.getFullLibraryProjects() : Collections.<IProject>emptyList(); + + SearchRequestor requestor = new SearchRequestor() { + @Override + public void acceptSearchMatch(SearchMatch match) throws CoreException { + // Ignore matches in comments + if (match.isInsideDocComment()) { + return; + } + + Object element = match.getElement(); + if (element instanceof ResolvedBinaryType) { + // Third party view + ResolvedBinaryType type = (ResolvedBinaryType) element; + IPackageFragment fragment = type.getPackageFragment(); + IPath path = fragment.getPath(); + String last = path.lastSegment(); + // Filter out android.jar stuff + if (last.equals(FN_FRAMEWORK_LIBRARY)) { + return; + } + if (!isValidView(type, layoutsOnly)) { + return; + } + + IProject matchProject = match.getResource().getProject(); + if (mProject == matchProject || libraries.contains(matchProject)) { + String fqn = type.getFullyQualifiedName(); + thirdPartyViews.add(fqn); + } + } else if (element instanceof ResolvedSourceType) { + // User custom view + IProject matchProject = match.getResource().getProject(); + if (mProject == matchProject || libraries.contains(matchProject)) { + ResolvedSourceType type = (ResolvedSourceType) element; + if (!isValidView(type, layoutsOnly)) { + return; + } + String fqn = type.getFullyQualifiedName(); + fqn = fqn.replace('$', '.'); + customViews.add(fqn); + } + } + } + }; + try { + IJavaProject javaProject = BaseProjectHelper.getJavaProject(mProject); + if (javaProject != null) { + String className = layoutsOnly ? CLASS_VIEWGROUP : CLASS_VIEW; + IType viewType = javaProject.findType(className); + if (viewType != null) { + IJavaSearchScope scope = SearchEngine.createHierarchyScope(viewType); + SearchParticipant[] participants = new SearchParticipant[] { + SearchEngine.getDefaultSearchParticipant() + }; + int matchRule = SearchPattern.R_PATTERN_MATCH | SearchPattern.R_CASE_SENSITIVE; + + SearchPattern pattern = SearchPattern.createPattern("*", + IJavaSearchConstants.CLASS, IJavaSearchConstants.IMPLEMENTORS, + matchRule); + SearchEngine engine = new SearchEngine(); + engine.search(pattern, participants, scope, requestor, + new NullProgressMonitor()); + } + } + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + + + List<String> custom = new ArrayList<String>(customViews); + List<String> thirdParty = new ArrayList<String>(thirdPartyViews); + + if (!layoutsOnly) { + // Update our cached answers (unless we were filtered on only layouts) + mCustomViews = custom; + mThirdPartyViews = thirdParty; + } + + return Pair.of(custom, thirdParty); + } + + /** + * Determines whether the given member is a valid android.view.View to be added to the + * list of custom views or third party views. It checks that the view is public and + * not abstract for example. + */ + private static boolean isValidView(IType type, boolean layoutsOnly) + throws JavaModelException { + // Skip anonymous classes + if (type.isAnonymous()) { + return false; + } + int flags = type.getFlags(); + if (Flags.isAbstract(flags) || !Flags.isPublic(flags)) { + return false; + } + + // TODO: if (layoutsOnly) perhaps try to filter out AdapterViews and other ViewGroups + // not willing to accept children via XML + + // See if the class has one of the acceptable constructors + // needed for XML instantiation: + // View(Context context) + // View(Context context, AttributeSet attrs) + // View(Context context, AttributeSet attrs, int defStyle) + // We don't simply do three direct checks via type.getMethod() because the types + // are not resolved, so we don't know for each parameter if we will get the + // fully qualified or the unqualified class names. + // Instead, iterate over the methods and look for a match. + String typeName = type.getElementName(); + for (IMethod method : type.getMethods()) { + // Only care about constructors + if (!method.getElementName().equals(typeName)) { + continue; + } + + String[] parameterTypes = method.getParameterTypes(); + if (parameterTypes == null || parameterTypes.length < 1 || parameterTypes.length > 3) { + continue; + } + + String first = parameterTypes[0]; + // Look for the parameter type signatures -- produced by + // JDT's Signature.createTypeSignature("Context", false /*isResolved*/);. + // This is not a typo; they were copy/pasted from the actual parameter names + // observed in the debugger examining these data structures. + if (first.equals("QContext;") //$NON-NLS-1$ + || first.equals("Qandroid.content.Context;")) { //$NON-NLS-1$ + if (parameterTypes.length == 1) { + return true; + } + String second = parameterTypes[1]; + if (second.equals("QAttributeSet;") //$NON-NLS-1$ + || second.equals("Qandroid.util.AttributeSet;")) { //$NON-NLS-1$ + if (parameterTypes.length == 2) { + return true; + } + String third = parameterTypes[2]; + if (third.equals("I")) { //$NON-NLS-1$ + if (parameterTypes.length == 3) { + return true; + } + } + } + } + } + + return false; + } + + /** + * Interface implemented by clients of the {@link CustomViewFinder} to be notified + * when a custom view search has completed. Will always be called on the SWT event + * dispatch thread. + */ + public interface Listener { + void viewsUpdated(Collection<String> customViews, Collection<String> thirdPartyViews); + } + + /** + * Job for performing class search off the UI thread. This is marked as a system job + * so that it won't show up in the progress monitor etc. + */ + private class FindViewsJob extends Job { + FindViewsJob() { + super("Find Custom Views"); + setSystem(true); + } + @Override + protected IStatus run(IProgressMonitor monitor) { + Pair<List<String>, List<String>> views = findViews(false); + mCustomViews = views.getFirst(); + mThirdPartyViews = views.getSecond(); + + // Notify listeners on SWT's UI thread + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + Collection<String> customViews = + Collections.unmodifiableCollection(mCustomViews); + Collection<String> thirdPartyViews = + Collections.unmodifiableCollection(mThirdPartyViews); + synchronized (this) { + for (Listener l : mListeners) { + l.viewsUpdated(customViews, thirdPartyViews); + } + mListeners.clear(); + mRefreshing = false; + } + } + }); + return Status.OK_STATUS; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DelegatingAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DelegatingAction.java new file mode 100644 index 000000000..7a41b5b15 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DelegatingAction.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; + +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IMenuCreator; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.jface.util.IPropertyChangeListener; +import org.eclipse.swt.events.HelpListener; +import org.eclipse.swt.widgets.Event; + +/** + * Implementation of {@link IAction} which delegates to a different + * {@link IAction} which allows a subclass to wrap and customize some of the + * behavior of a different action + */ +public class DelegatingAction implements IAction { + private final IAction mAction; + + /** + * Construct a new delegate of the given action + * + * @param action the action to be delegated + */ + public DelegatingAction(@NonNull IAction action) { + mAction = action; + } + + @Override + public void addPropertyChangeListener(IPropertyChangeListener listener) { + mAction.addPropertyChangeListener(listener); + } + + @Override + public int getAccelerator() { + return mAction.getAccelerator(); + } + + @Override + public String getActionDefinitionId() { + return mAction.getActionDefinitionId(); + } + + @Override + public String getDescription() { + return mAction.getDescription(); + } + + @Override + public ImageDescriptor getDisabledImageDescriptor() { + return mAction.getDisabledImageDescriptor(); + } + + @Override + public HelpListener getHelpListener() { + return mAction.getHelpListener(); + } + + @Override + public ImageDescriptor getHoverImageDescriptor() { + return mAction.getHoverImageDescriptor(); + } + + @Override + public String getId() { + return mAction.getId(); + } + + @Override + public ImageDescriptor getImageDescriptor() { + return mAction.getImageDescriptor(); + } + + @Override + public IMenuCreator getMenuCreator() { + return mAction.getMenuCreator(); + } + + @Override + public int getStyle() { + return mAction.getStyle(); + } + + @Override + public String getText() { + return mAction.getText(); + } + + @Override + public String getToolTipText() { + return mAction.getToolTipText(); + } + + @Override + public boolean isChecked() { + return mAction.isChecked(); + } + + @Override + public boolean isEnabled() { + return mAction.isEnabled(); + } + + @Override + public boolean isHandled() { + return mAction.isHandled(); + } + + @Override + public void removePropertyChangeListener(IPropertyChangeListener listener) { + mAction.removePropertyChangeListener(listener); + } + + @Override + public void run() { + mAction.run(); + } + + @Override + public void runWithEvent(Event event) { + mAction.runWithEvent(event); + } + + @Override + public void setActionDefinitionId(String id) { + mAction.setActionDefinitionId(id); + } + + @Override + public void setChecked(boolean checked) { + mAction.setChecked(checked); + } + + @Override + public void setDescription(String text) { + mAction.setDescription(text); + } + + @Override + public void setDisabledImageDescriptor(ImageDescriptor newImage) { + mAction.setDisabledImageDescriptor(newImage); + } + + @Override + public void setEnabled(boolean enabled) { + mAction.setEnabled(enabled); + } + + @Override + public void setHelpListener(HelpListener listener) { + mAction.setHelpListener(listener); + } + + @Override + public void setHoverImageDescriptor(ImageDescriptor newImage) { + mAction.setHoverImageDescriptor(newImage); + } + + @Override + public void setId(String id) { + mAction.setId(id); + } + + @Override + public void setImageDescriptor(ImageDescriptor newImage) { + mAction.setImageDescriptor(newImage); + } + + @Override + public void setMenuCreator(IMenuCreator creator) { + mAction.setMenuCreator(creator); + } + + @Override + public void setText(String text) { + mAction.setText(text); + } + + @Override + public void setToolTipText(String text) { + mAction.setToolTipText(text); + } + + @Override + public void setAccelerator(int keycode) { + mAction.setAccelerator(keycode); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DomUtilities.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DomUtilities.java new file mode 100644 index 000000000..145036bf3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DomUtilities.java @@ -0,0 +1,915 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.TOOLS_URI; +import static org.eclipse.wst.xml.core.internal.provisional.contenttype.ContentTypeIdForXML.ContentTypeID_XML; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.text.IDocument; +import org.eclipse.wst.sse.core.StructuredModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; +import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; +import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +/** + * Various utility methods for manipulating DOM nodes. + */ +@SuppressWarnings("restriction") // No replacement for restricted XML model yet +public class DomUtilities { + /** + * Finds the nearest common parent of the two given nodes (which could be one of the + * two nodes as well) + * + * @param node1 the first node to test + * @param node2 the second node to test + * @return the nearest common parent of the two given nodes + */ + @Nullable + public static Node getCommonAncestor(@NonNull Node node1, @NonNull Node node2) { + while (node2 != null) { + Node current = node1; + while (current != null && current != node2) { + current = current.getParentNode(); + } + if (current == node2) { + return current; + } + node2 = node2.getParentNode(); + } + + return null; + } + + /** + * Returns all elements below the given node (which can be a document, + * element, etc). This will include the node itself, if it is an element. + * + * @param node the node to search from + * @return all elements in the subtree formed by the node parameter + */ + @NonNull + public static List<Element> getAllElements(@NonNull Node node) { + List<Element> elements = new ArrayList<Element>(64); + addElements(node, elements); + return elements; + } + + private static void addElements(@NonNull Node node, @NonNull List<Element> elements) { + if (node instanceof Element) { + elements.add((Element) node); + } + + NodeList childNodes = node.getChildNodes(); + for (int i = 0, n = childNodes.getLength(); i < n; i++) { + addElements(childNodes.item(i), elements); + } + } + + /** + * Returns the depth of the given node (with the document node having depth 0, + * and the document element having depth 1) + * + * @param node the node to test + * @return the depth in the document + */ + public static int getDepth(@NonNull Node node) { + int depth = -1; + while (node != null) { + depth++; + node = node.getParentNode(); + } + + return depth; + } + + /** + * Returns true if the given node has one or more element children + * + * @param node the node to test for element children + * @return true if the node has one or more element children + */ + public static boolean hasElementChildren(@NonNull Node node) { + NodeList children = node.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + return true; + } + } + + return false; + } + + /** + * Returns the DOM document for the given file + * + * @param file the XML file + * @return the document, or null if not found or not parsed properly (no + * errors are generated/thrown) + */ + @Nullable + public static Document getDocument(@NonNull IFile file) { + IModelManager modelManager = StructuredModelManager.getModelManager(); + if (modelManager == null) { + return null; + } + try { + IStructuredModel model = modelManager.getExistingModelForRead(file); + if (model == null) { + model = modelManager.getModelForRead(file); + } + if (model != null) { + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + return domModel.getDocument(); + } + try { + } finally { + model.releaseFromRead(); + } + } + } catch (Exception e) { + // Ignore exceptions. + } + + return null; + } + + /** + * Returns the DOM document for the given editor + * + * @param editor the XML editor + * @return the document, or null if not found or not parsed properly (no + * errors are generated/thrown) + */ + @Nullable + public static Document getDocument(@NonNull AndroidXmlEditor editor) { + IStructuredModel model = editor.getModelForRead(); + try { + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + return domModel.getDocument(); + } + } finally { + if (model != null) { + model.releaseFromRead(); + } + } + + return null; + } + + + /** + * Returns the XML DOM node corresponding to the given offset of the given + * document. + * + * @param document The document to look in + * @param offset The offset to look up the node for + * @return The node containing the offset, or null + */ + @Nullable + public static Node getNode(@NonNull IDocument document, int offset) { + Node node = null; + IModelManager modelManager = StructuredModelManager.getModelManager(); + if (modelManager == null) { + return null; + } + try { + IStructuredModel model = modelManager.getExistingModelForRead(document); + if (model != null) { + try { + for (; offset >= 0 && node == null; --offset) { + node = (Node) model.getIndexedRegion(offset); + } + } finally { + model.releaseFromRead(); + } + } + } catch (Exception e) { + // Ignore exceptions. + } + + return node; + } + + /** + * Returns the editing context at the given offset, as a pair of parent node and child + * node. This is not the same as just calling {@link DomUtilities#getNode} and taking + * its parent node, because special care has to be taken to return content element + * positions. + * <p> + * For example, for the XML {@code <foo>^</foo>}, if the caret ^ is inside the foo + * element, between the opening and closing tags, then the foo element is the parent, + * and the child is null which represents a potential text node. + * <p> + * If the node is inside an element tag definition (between the opening and closing + * bracket) then the child node will be the element and whatever parent (element or + * document) will be its parent. + * <p> + * If the node is in a text node, then the text node will be the child and its parent + * element or document node its parent. + * <p> + * Finally, if the caret is on a boundary of a text node, then the text node will be + * considered the child, regardless of whether it is on the left or right of the + * caret. For example, in the XML {@code <foo>^ </foo>} and in the XML + * {@code <foo> ^</foo>}, in both cases the text node is preferred over the element. + * + * @param document the document to search in + * @param offset the offset to look up + * @return a pair of parent and child elements, where either the parent or the child + * but not both can be null, and if non null the child.getParentNode() should + * return the parent. Note that the method can also return null if no + * document or model could be obtained or if the offset is invalid. + */ + @Nullable + public static Pair<Node, Node> getNodeContext(@NonNull IDocument document, int offset) { + Node node = null; + IModelManager modelManager = StructuredModelManager.getModelManager(); + if (modelManager == null) { + return null; + } + try { + IStructuredModel model = modelManager.getExistingModelForRead(document); + if (model != null) { + try { + for (; offset >= 0 && node == null; --offset) { + IndexedRegion indexedRegion = model.getIndexedRegion(offset); + if (indexedRegion != null) { + node = (Node) indexedRegion; + + if (node.getNodeType() == Node.TEXT_NODE) { + return Pair.of(node.getParentNode(), node); + } + + // Look at the structured document to see if + // we have the special case where the caret is pointing at + // a -potential- text node, e.g. <foo>^</foo> + IStructuredDocument doc = model.getStructuredDocument(); + IStructuredDocumentRegion region = + doc.getRegionAtCharacterOffset(offset); + + ITextRegion subRegion = region.getRegionAtCharacterOffset(offset); + String type = subRegion.getType(); + if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) { + // Try to return the text node if it's on the left + // of this element node, such that replace strings etc + // can be computed. + Node lastChild = node.getLastChild(); + if (lastChild != null) { + IndexedRegion previousRegion = (IndexedRegion) lastChild; + if (previousRegion.getEndOffset() == offset) { + return Pair.of(node, lastChild); + } + } + return Pair.of(node, null); + } + + return Pair.of(node.getParentNode(), node); + } + } + } finally { + model.releaseFromRead(); + } + } + } catch (Exception e) { + // Ignore exceptions. + } + + return null; + } + + /** + * Like {@link #getNode(IDocument, int)}, but has a bias parameter which lets you + * indicate whether you want the search to look forwards or backwards. + * This is vital when trying to compute a node range. Consider the following + * XML fragment: + * {@code + * <a/><b/>[<c/><d/><e/>]<f/><g/> + * } + * Suppose we want to locate the nodes in the range indicated by the brackets above. + * If we want to search for the node corresponding to the start position, should + * we pick the node on its left or the node on its right? Similarly for the end + * position. Clearly, we'll need to bias the search towards the right when looking + * for the start position, and towards the left when looking for the end position. + * The following method lets us do just that. When passed an offset which sits + * on the edge of the computed node, it will pick the neighbor based on whether + * "forward" is true or false, where forward means searching towards the right + * and not forward is obviously towards the left. + * @param document the document to search in + * @param offset the offset to search for + * @param forward if true, search forwards, otherwise search backwards when on node boundaries + * @return the node which surrounds the given offset, or the node adjacent to the offset + * where the side depends on the forward parameter + */ + @Nullable + public static Node getNode(@NonNull IDocument document, int offset, boolean forward) { + Node node = getNode(document, offset); + + if (node instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) node; + + if (!forward && offset <= region.getStartOffset()) { + Node left = node.getPreviousSibling(); + if (left == null) { + left = node.getParentNode(); + } + + node = left; + } else if (forward && offset >= region.getEndOffset()) { + Node right = node.getNextSibling(); + if (right == null) { + right = node.getParentNode(); + } + node = right; + } + } + + return node; + } + + /** + * Returns a range of elements for the given caret range. Note that the two elements + * may not be at the same level so callers may want to perform additional input + * filtering. + * + * @param document the document to search in + * @param beginOffset the beginning offset of the range + * @param endOffset the ending offset of the range + * @return a pair of begin+end elements, or null + */ + @Nullable + public static Pair<Element, Element> getElementRange(@NonNull IDocument document, + int beginOffset, int endOffset) { + Element beginElement = null; + Element endElement = null; + Node beginNode = getNode(document, beginOffset, true); + Node endNode = beginNode; + if (endOffset > beginOffset) { + endNode = getNode(document, endOffset, false); + } + + if (beginNode == null || endNode == null) { + return null; + } + + // Adjust offsets if you're pointing at text + if (beginNode.getNodeType() != Node.ELEMENT_NODE) { + // <foo> <bar1/> | <bar2/> </foo> => should pick <bar2/> + beginElement = getNextElement(beginNode); + if (beginElement == null) { + // Might be inside the end of a parent, e.g. + // <foo> <bar/> | </foo> => should pick <bar/> + beginElement = getPreviousElement(beginNode); + if (beginElement == null) { + // We must be inside an empty element, + // <foo> | </foo> + // In that case just pick the parent. + beginElement = getParentElement(beginNode); + } + } + } else { + beginElement = (Element) beginNode; + } + + if (endNode.getNodeType() != Node.ELEMENT_NODE) { + // In the following, | marks the caret position: + // <foo> <bar1/> | <bar2/> </foo> => should pick <bar1/> + endElement = getPreviousElement(endNode); + if (endElement == null) { + // Might be inside the beginning of a parent, e.g. + // <foo> | <bar/></foo> => should pick <bar/> + endElement = getNextElement(endNode); + if (endElement == null) { + // We must be inside an empty element, + // <foo> | </foo> + // In that case just pick the parent. + endElement = getParentElement(endNode); + } + } + } else { + endElement = (Element) endNode; + } + + if (beginElement != null && endElement != null) { + return Pair.of(beginElement, endElement); + } + + return null; + } + + /** + * Returns the next sibling element of the node, or null if there is no such element + * + * @param node the starting node + * @return the next sibling element, or null + */ + @Nullable + public static Element getNextElement(@NonNull Node node) { + while (node != null && node.getNodeType() != Node.ELEMENT_NODE) { + node = node.getNextSibling(); + } + + return (Element) node; // may be null as well + } + + /** + * Returns the previous sibling element of the node, or null if there is no such element + * + * @param node the starting node + * @return the previous sibling element, or null + */ + @Nullable + public static Element getPreviousElement(@NonNull Node node) { + while (node != null && node.getNodeType() != Node.ELEMENT_NODE) { + node = node.getPreviousSibling(); + } + + return (Element) node; // may be null as well + } + + /** + * Returns the closest ancestor element, or null if none + * + * @param node the starting node + * @return the closest parent element, or null + */ + @Nullable + public static Element getParentElement(@NonNull Node node) { + while (node != null && node.getNodeType() != Node.ELEMENT_NODE) { + node = node.getParentNode(); + } + + return (Element) node; // may be null as well + } + + /** Utility used by {@link #getFreeWidgetId(Element)} */ + private static void addLowercaseIds(@NonNull Element root, @NonNull Set<String> seen) { + if (root.hasAttributeNS(ANDROID_URI, ATTR_ID)) { + String id = root.getAttributeNS(ANDROID_URI, ATTR_ID); + if (id.startsWith(NEW_ID_PREFIX)) { + // See getFreeWidgetId for details on locale + seen.add(id.substring(NEW_ID_PREFIX.length()).toLowerCase(Locale.US)); + } else if (id.startsWith(ID_PREFIX)) { + seen.add(id.substring(ID_PREFIX.length()).toLowerCase(Locale.US)); + } else { + seen.add(id.toLowerCase(Locale.US)); + } + } + } + + /** + * Returns a suitable new widget id (not including the {@code @id/} prefix) for the + * given element, which is guaranteed to be unique in this document + * + * @param element the element to compute a new widget id for + * @param reserved an optional set of extra, "reserved" set of ids that should be + * considered taken + * @param prefix an optional prefix to use for the generated name, or null to get a + * default (which is currently the tag name) + * @return a unique id, never null, which does not include the {@code @id/} prefix + * @see DescriptorsUtils#getFreeWidgetId + */ + public static String getFreeWidgetId( + @NonNull Element element, + @Nullable Set<String> reserved, + @Nullable String prefix) { + Set<String> ids = new HashSet<String>(); + if (reserved != null) { + for (String id : reserved) { + // Note that we perform locale-independent lowercase checks; in "Image" we + // want the lowercase version to be "image", not "?mage" where ? is + // the char LATIN SMALL LETTER DOTLESS I. + + ids.add(id.toLowerCase(Locale.US)); + } + } + addLowercaseIds(element.getOwnerDocument().getDocumentElement(), ids); + + if (prefix == null) { + prefix = DescriptorsUtils.getBasename(element.getTagName()); + } + String generated; + int num = 1; + do { + generated = String.format("%1$s%2$d", prefix, num++); //$NON-NLS-1$ + } while (ids.contains(generated.toLowerCase(Locale.US))); + + return generated; + } + + /** + * Returns the element children of the given element + * + * @param element the parent element + * @return a list of child elements, possibly empty but never null + */ + @NonNull + public static List<Element> getChildren(@NonNull Element element) { + // Convenience to avoid lots of ugly DOM access casting + NodeList children = element.getChildNodes(); + // An iterator would have been more natural (to directly drive the child list + // iteration) but iterators can't be used in enhanced for loops... + List<Element> result = new ArrayList<Element>(children.getLength()); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + result.add(child); + } + } + + return result; + } + + /** + * Returns true iff the given elements are contiguous siblings + * + * @param elements the elements to be tested + * @return true if the elements are contiguous siblings with no gaps + */ + public static boolean isContiguous(@NonNull List<Element> elements) { + if (elements.size() > 1) { + // All elements must be siblings (e.g. same parent) + Node parent = elements.get(0).getParentNode(); + if (!(parent instanceof Element)) { + return false; + } + for (Element node : elements) { + if (parent != node.getParentNode()) { + return false; + } + } + + // Ensure that the siblings are contiguous; no gaps. + // If we've selected all the children of the parent then we don't need + // to look. + List<Element> siblings = DomUtilities.getChildren((Element) parent); + if (siblings.size() != elements.size()) { + Set<Element> nodeSet = new HashSet<Element>(elements); + boolean inRange = false; + int remaining = elements.size(); + for (Element node : siblings) { + boolean in = nodeSet.contains(node); + if (in) { + remaining--; + if (remaining == 0) { + break; + } + inRange = true; + } else if (inRange) { + return false; + } + } + } + } + + return true; + } + + /** + * Determines whether two element trees are equivalent. Two element trees are + * equivalent if they represent the same DOM structure (elements, attributes, and + * children in order). This is almost the same as simply checking whether the String + * representations of the two nodes are identical, but this allows for minor + * variations that are not semantically significant, such as variations in formatting + * or ordering of the element attribute declarations, and the text children are + * ignored (this is such that in for example layout where content is only used for + * indentation the indentation differences are ignored). Null trees are never equal. + * + * @param element1 the first element to compare + * @param element2 the second element to compare + * @return true if the two element hierarchies are logically equal + */ + public static boolean isEquivalent(@Nullable Element element1, @Nullable Element element2) { + if (element1 == null || element2 == null) { + return false; + } + + if (!element1.getTagName().equals(element2.getTagName())) { + return false; + } + + // Check attribute map + NamedNodeMap attributes1 = element1.getAttributes(); + NamedNodeMap attributes2 = element2.getAttributes(); + + List<Attr> attributeNodes1 = new ArrayList<Attr>(); + for (int i = 0, n = attributes1.getLength(); i < n; i++) { + Attr attribute = (Attr) attributes1.item(i); + // Ignore tools uri namespace attributes for equivalency test + if (TOOLS_URI.equals(attribute.getNamespaceURI())) { + continue; + } + attributeNodes1.add(attribute); + } + List<Attr> attributeNodes2 = new ArrayList<Attr>(); + for (int i = 0, n = attributes2.getLength(); i < n; i++) { + Attr attribute = (Attr) attributes2.item(i); + // Ignore tools uri namespace attributes for equivalency test + if (TOOLS_URI.equals(attribute.getNamespaceURI())) { + continue; + } + attributeNodes2.add(attribute); + } + + if (attributeNodes1.size() != attributeNodes2.size()) { + return false; + } + + if (attributes1.getLength() > 0) { + Collections.sort(attributeNodes1, ATTRIBUTE_COMPARATOR); + Collections.sort(attributeNodes2, ATTRIBUTE_COMPARATOR); + for (int i = 0; i < attributeNodes1.size(); i++) { + Attr attr1 = attributeNodes1.get(i); + Attr attr2 = attributeNodes2.get(i); + if (attr1.getLocalName() == null || attr2.getLocalName() == null) { + if (!attr1.getName().equals(attr2.getName())) { + return false; + } + } else if (!attr1.getLocalName().equals(attr2.getLocalName())) { + return false; + } + if (!attr1.getValue().equals(attr2.getValue())) { + return false; + } + if (attr1.getNamespaceURI() == null) { + if (attr2.getNamespaceURI() != null) { + return false; + } + } else if (attr2.getNamespaceURI() == null) { + return false; + } else if (!attr1.getNamespaceURI().equals(attr2.getNamespaceURI())) { + return false; + } + } + } + + NodeList children1 = element1.getChildNodes(); + NodeList children2 = element2.getChildNodes(); + int nextIndex1 = 0; + int nextIndex2 = 0; + while (true) { + while (nextIndex1 < children1.getLength() && + children1.item(nextIndex1).getNodeType() != Node.ELEMENT_NODE) { + nextIndex1++; + } + + while (nextIndex2 < children2.getLength() && + children2.item(nextIndex2).getNodeType() != Node.ELEMENT_NODE) { + nextIndex2++; + } + + Element nextElement1 = (Element) (nextIndex1 < children1.getLength() + ? children1.item(nextIndex1) : null); + Element nextElement2 = (Element) (nextIndex2 < children2.getLength() + ? children2.item(nextIndex2) : null); + if (nextElement1 == null) { + return nextElement2 == null; + } else if (nextElement2 == null) { + return false; + } else if (!isEquivalent(nextElement1, nextElement2)) { + return false; + } + nextIndex1++; + nextIndex2++; + } + } + + /** + * Finds the corresponding element in a document to a given element in another + * document. Note that this does <b>not</b> do any kind of equivalence check + * (see {@link #isEquivalent(Element, Element)}), and currently the search + * is only by id; there is no structural search. + * + * @param element the element to find an equivalent for + * @param document the document to search for an equivalent element in + * @return an equivalent element, or null + */ + @Nullable + public static Element findCorresponding(@NonNull Element element, @NonNull Document document) { + // Make sure the method is called correctly -- the element is for a different + // document than the one we are searching + assert element.getOwnerDocument() != document; + + // First search by id. This allows us to find the corresponding + String id = element.getAttributeNS(ANDROID_URI, ATTR_ID); + if (id != null && id.length() > 0) { + if (id.startsWith(ID_PREFIX)) { + id = NEW_ID_PREFIX + id.substring(ID_PREFIX.length()); + } + + return findCorresponding(document.getDocumentElement(), id); + } + + // TODO: Search by structure - look in the document and + // find a corresponding element in the same location in the structure, + // e.g. 4th child of root, 3rd child, 6th child, then pick node with tag "foo". + + return null; + } + + /** Helper method for {@link #findCorresponding(Element, Document)} */ + @Nullable + private static Element findCorresponding(@NonNull Element element, @NonNull String targetId) { + String id = element.getAttributeNS(ANDROID_URI, ATTR_ID); + if (id != null) { // Work around DOM bug + if (id.equals(targetId)) { + return element; + } else if (id.startsWith(ID_PREFIX)) { + id = NEW_ID_PREFIX + id.substring(ID_PREFIX.length()); + if (id.equals(targetId)) { + return element; + } + } + } + + NodeList children = element.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + Element match = findCorresponding(child, targetId); + if (match != null) { + return match; + } + } + } + + return null; + } + + /** + * Parses the given XML string as a DOM document, using Eclipse's structured + * XML model (which for example allows us to distinguish empty elements + * (<foo/>) from elements with no children (<foo></foo>). + * + * @param xml the XML content to be parsed (must be well formed) + * @return the DOM document, or null + */ + @Nullable + public static Document parseStructuredDocument(@NonNull String xml) { + IStructuredModel model = createStructuredModel(xml); + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + return domModel.getDocument(); + } + + return null; + } + + /** + * Parses the given XML string and builds an Eclipse structured model for it. + * + * @param xml the XML content to be parsed (must be well formed) + * @return the structured model + */ + @Nullable + public static IStructuredModel createStructuredModel(@NonNull String xml) { + IStructuredModel model = createEmptyModel(); + IStructuredDocument document = model.getStructuredDocument(); + model.aboutToChangeModel(); + document.set(xml); + model.changedModel(); + + return model; + } + + /** + * Creates an empty Eclipse XML model + * + * @return a new Eclipse XML model + */ + @NonNull + public static IStructuredModel createEmptyModel() { + IModelManager modelManager = StructuredModelManager.getModelManager(); + return modelManager.createUnManagedStructuredModelFor(ContentTypeID_XML); + } + + /** + * Creates an empty Eclipse XML document + * + * @return an empty Eclipse XML document + */ + @Nullable + public static Document createEmptyDocument() { + IStructuredModel model = createEmptyModel(); + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + return domModel.getDocument(); + } + + return null; + } + + /** + * Creates an empty non-Eclipse XML document. + * This is used when you need to use XML operations not supported by + * the Eclipse XML model (such as serialization). + * <p> + * The new document will not validate, will ignore comments, and will + * support namespace. + * + * @return the new document + */ + @Nullable + public static Document createEmptyPlainDocument() { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setValidating(false); + factory.setIgnoringComments(true); + DocumentBuilder builder; + try { + builder = factory.newDocumentBuilder(); + return builder.newDocument(); + } catch (ParserConfigurationException e) { + AdtPlugin.log(e, null); + } + + return null; + } + + /** + * Parses the given XML string as a DOM document, using the JDK parser. + * The parser does not validate, and is namespace aware. + * + * @param xml the XML content to be parsed (must be well formed) + * @param logParserErrors if true, log parser errors to the log, otherwise + * silently return null + * @return the DOM document, or null + */ + @Nullable + public static Document parseDocument(@NonNull String xml, boolean logParserErrors) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + InputSource is = new InputSource(new StringReader(xml)); + factory.setNamespaceAware(true); + factory.setValidating(false); + try { + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } catch (Exception e) { + if (logParserErrors) { + AdtPlugin.log(e, null); + } + } + + return null; + } + + /** Can be used to sort attributes by name */ + private static final Comparator<Attr> ATTRIBUTE_COMPARATOR = new Comparator<Attr>() { + @Override + public int compare(Attr a1, Attr a2) { + return a1.getName().compareTo(a2.getName()); + } + }; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DropGesture.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DropGesture.java new file mode 100644 index 000000000..bb3be7f68 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DropGesture.java @@ -0,0 +1,87 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.dnd.DropTargetEvent; +import org.eclipse.swt.dnd.DropTargetListener; + +/** + * A {@link DropGesture} is a {@link Gesture} which deals with drag and drop, so + * it has additional hooks for indicating whether the current position is + * "valid", and in general gets access to the system drag and drop data + * structures. See the {@link Gesture} documentation for more details on whether + * you should choose a plain {@link Gesture} or a {@link DropGesture}. + */ +public abstract class DropGesture extends Gesture { + /** + * The cursor has entered the drop target boundaries. + * + * @param event The {@link DropTargetEvent} for this drag and drop event + * @see DropTargetListener#dragEnter(DropTargetEvent) + */ + public void dragEnter(DropTargetEvent event) { + } + + /** + * The cursor is moving over the drop target. + * + * @param event The {@link DropTargetEvent} for this drag and drop event + * @see DropTargetListener#dragOver(DropTargetEvent) + */ + public void dragOver(DropTargetEvent event) { + } + + /** + * The operation being performed has changed (usually due to the user + * changing the selected modifier key(s) while dragging). + * + * @param event The {@link DropTargetEvent} for this drag and drop event + * @see DropTargetListener#dragOperationChanged(DropTargetEvent) + */ + public void dragOperationChanged(DropTargetEvent event) { + } + + /** + * The cursor has left the drop target boundaries OR the drop has been + * canceled OR the data is about to be dropped. + * + * @param event The {@link DropTargetEvent} for this drag and drop event + * @see DropTargetListener#dragLeave(DropTargetEvent) + */ + public void dragLeave(DropTargetEvent event) { + } + + /** + * The drop is about to be performed. The drop target is given a last chance + * to change the nature of the drop. + * + * @param event The {@link DropTargetEvent} for this drag and drop event + * @see DropTargetListener#dropAccept(DropTargetEvent) + */ + public void dropAccept(DropTargetEvent event) { + } + + /** + * The data is being dropped. The data field contains java format of the + * data being dropped. + * + * @param event The {@link DropTargetEvent} for this drag and drop event + * @see DropTargetListener#drop(DropTargetEvent) + */ + public void drop(final DropTargetEvent event) { + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DynamicContextMenu.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DynamicContextMenu.java new file mode 100644 index 000000000..fc7127278 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/DynamicContextMenu.java @@ -0,0 +1,654 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.EXPANDABLE_LIST_VIEW; +import static com.android.SdkConstants.FQCN_GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.FQCN_IMAGE_VIEW; +import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; +import static com.android.SdkConstants.FQCN_TEXT_VIEW; +import static com.android.SdkConstants.GRID_VIEW; +import static com.android.SdkConstants.LIST_VIEW; +import static com.android.SdkConstants.SPINNER; +import static com.android.SdkConstants.VIEW_FRAGMENT; + +import com.android.SdkConstants; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.RuleAction.Choices; +import com.android.ide.common.api.RuleAction.NestedAction; +import com.android.ide.common.api.RuleAction.Toggle; +import com.android.ide.common.layout.BaseViewRule; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.ChangeLayoutAction; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.ChangeViewAction; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.ExtractIncludeAction; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.ExtractStyleAction; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.UnwrapAction; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.UseCompoundDrawableAction; +import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.WrapInAction; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.ContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IContributionItem; +import org.eclipse.jface.action.IMenuListener; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Menu; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Helper class that is responsible for adding and managing the dynamic menu items + * contributed by the {@link IViewRule} instances, based on the current selection + * on the {@link LayoutCanvas}. + * <p/> + * This class is tied to a specific {@link LayoutCanvas} instance and a root {@link MenuManager}. + * <p/> + * Two instances of this are used: one created by {@link LayoutCanvas} and the other one + * created by {@link OutlinePage}. Different root {@link MenuManager}s are populated, however + * they are both linked to the current selection state of the {@link LayoutCanvas}. + */ +class DynamicContextMenu { + public static String DEFAULT_ACTION_SHORTCUT = "F2"; //$NON-NLS-1$ + public static int DEFAULT_ACTION_KEY = SWT.F2; + + /** The XML layout editor that contains the canvas that uses this menu. */ + private final LayoutEditorDelegate mEditorDelegate; + + /** The layout canvas that displays this context menu. */ + private final LayoutCanvas mCanvas; + + /** The root menu manager of the context menu. */ + private final MenuManager mMenuManager; + + /** + * Creates a new helper responsible for adding and managing the dynamic menu items + * contributed by the {@link IViewRule} instances, based on the current selection + * on the {@link LayoutCanvas}. + * @param editorDelegate the editor owning the menu + * @param canvas The {@link LayoutCanvas} providing the selection, the node factory and + * the rules engine. + * @param rootMenu The root of the context menu displayed. In practice this may be the + * context menu manager of the {@link LayoutCanvas} or the one from {@link OutlinePage}. + */ + public DynamicContextMenu( + LayoutEditorDelegate editorDelegate, + LayoutCanvas canvas, + MenuManager rootMenu) { + mEditorDelegate = editorDelegate; + mCanvas = canvas; + mMenuManager = rootMenu; + + setupDynamicMenuActions(); + } + + /** + * Setups the menu manager to receive dynamic menu contributions from the {@link IViewRule}s + * when it's about to be shown. + */ + private void setupDynamicMenuActions() { + // Remember how many static actions we have. Then each time the menu is + // shown, find dynamic contributions based on the current selection and insert + // them at the beginning of the menu. + final int numStaticActions = mMenuManager.getSize(); + mMenuManager.addMenuListener(new IMenuListener() { + @Override + public void menuAboutToShow(IMenuManager manager) { + + // Remove any previous dynamic contributions to keep only the + // default static items. + int n = mMenuManager.getSize() - numStaticActions; + if (n > 0) { + IContributionItem[] items = mMenuManager.getItems(); + for (int i = 0; i < n; i++) { + mMenuManager.remove(items[i]); + } + } + + // Now add all the dynamic menu actions depending on the current selection. + populateDynamicContextMenu(); + } + }); + + } + + /** + * This method is invoked by <code>menuAboutToShow</code> on {@link #mMenuManager}. + * All previous dynamic menu actions have been removed and this method can now insert + * any new actions that depend on the current selection. + */ + private void populateDynamicContextMenu() { + // Create the actual menu contributions + String endId = mMenuManager.getItems()[0].getId(); + + Separator sep = new Separator(); + sep.setId("-dyn-gle-sep"); //$NON-NLS-1$ + mMenuManager.insertBefore(endId, sep); + endId = sep.getId(); + + List<SelectionItem> selections = mCanvas.getSelectionManager().getSelections(); + if (selections.size() == 0) { + return; + } + List<INode> nodes = new ArrayList<INode>(selections.size()); + for (SelectionItem item : selections) { + nodes.add(item.getNode()); + } + + List<IContributionItem> menuItems = getMenuItems(nodes); + for (IContributionItem menuItem : menuItems) { + mMenuManager.insertBefore(endId, menuItem); + } + + insertTagSpecificMenus(endId); + insertVisualRefactorings(endId); + insertParentItems(endId); + } + + /** + * Returns the list of node-specific actions applicable to the given + * collection of nodes + * + * @param nodes the collection of nodes to look up actions for + * @return a list of contribution items applicable for all the nodes + */ + private List<IContributionItem> getMenuItems(List<INode> nodes) { + Map<INode, List<RuleAction>> allActions = new HashMap<INode, List<RuleAction>>(); + for (INode node : nodes) { + List<RuleAction> actionList = getMenuActions((NodeProxy) node); + allActions.put(node, actionList); + } + + Set<String> availableIds = computeApplicableActionIds(allActions); + + // +10: Make room for separators too + List<IContributionItem> items = new ArrayList<IContributionItem>(availableIds.size() + 10); + + // We'll use the actions returned by the first node. Even when there + // are multiple items selected, we'll use the first action, but pass + // the set of all selected nodes to that first action. Actions are required + // to work this way to facilitate multi selection and actions which apply + // to multiple nodes. + NodeProxy first = (NodeProxy) nodes.get(0); + List<RuleAction> firstSelectedActions = allActions.get(first); + String defaultId = getDefaultActionId(first); + for (RuleAction action : firstSelectedActions) { + if (!availableIds.contains(action.getId()) + && !(action instanceof RuleAction.Separator)) { + // This action isn't supported by all selected items. + continue; + } + + items.add(createContributionItem(action, nodes, defaultId)); + } + + return items; + } + + private void insertParentItems(String endId) { + List<SelectionItem> selection = mCanvas.getSelectionManager().getSelections(); + if (selection.size() == 1) { + mMenuManager.insertBefore(endId, new Separator()); + INode parent = selection.get(0).getNode().getParent(); + while (parent != null) { + String id = parent.getStringAttr(ANDROID_URI, ATTR_ID); + String label; + if (id != null && id.length() > 0) { + label = BaseViewRule.stripIdPrefix(id); + } else { + // Use the view name, such as "Button", as the label + label = parent.getFqcn(); + // Strip off package + label = label.substring(label.lastIndexOf('.') + 1); + } + mMenuManager.insertBefore(endId, new NestedParentMenu(label, parent)); + parent = parent.getParent(); + } + mMenuManager.insertBefore(endId, new Separator()); + } + } + + private void insertVisualRefactorings(String endId) { + // Extract As <include> refactoring, Wrap In Refactoring, etc. + List<SelectionItem> selection = mCanvas.getSelectionManager().getSelections(); + if (selection.size() == 0) { + return; + } + // Only include the menu item if you are not right clicking on a root, + // or on an included view, or on a non-contiguous selection + mMenuManager.insertBefore(endId, new Separator()); + if (selection.size() == 1 && selection.get(0).getViewInfo() != null + && selection.get(0).getViewInfo().getName().equals(FQCN_LINEAR_LAYOUT)) { + CanvasViewInfo info = selection.get(0).getViewInfo(); + List<CanvasViewInfo> children = info.getChildren(); + if (children.size() == 2) { + String first = children.get(0).getName(); + String second = children.get(1).getName(); + if ((first.equals(FQCN_IMAGE_VIEW) && second.equals(FQCN_TEXT_VIEW)) + || (first.equals(FQCN_TEXT_VIEW) && second.equals(FQCN_IMAGE_VIEW))) { + mMenuManager.insertBefore(endId, UseCompoundDrawableAction.create( + mEditorDelegate)); + } + } + } + mMenuManager.insertBefore(endId, ExtractIncludeAction.create(mEditorDelegate)); + mMenuManager.insertBefore(endId, ExtractStyleAction.create(mEditorDelegate)); + mMenuManager.insertBefore(endId, WrapInAction.create(mEditorDelegate)); + if (selection.size() == 1 && !(selection.get(0).isRoot())) { + mMenuManager.insertBefore(endId, UnwrapAction.create(mEditorDelegate)); + } + if (selection.size() == 1 && (selection.get(0).isLayout() || + selection.get(0).getViewInfo().getName().equals(FQCN_GESTURE_OVERLAY_VIEW))) { + mMenuManager.insertBefore(endId, ChangeLayoutAction.create(mEditorDelegate)); + } else { + mMenuManager.insertBefore(endId, ChangeViewAction.create(mEditorDelegate)); + } + mMenuManager.insertBefore(endId, new Separator()); + } + + /** "Preview List Content" pull-right menu for lists, "Preview Fragment" for fragments, etc. */ + private void insertTagSpecificMenus(String endId) { + + List<SelectionItem> selection = mCanvas.getSelectionManager().getSelections(); + if (selection.size() == 0) { + return; + } + for (SelectionItem item : selection) { + UiViewElementNode node = item.getViewInfo().getUiViewNode(); + String name = node.getDescriptor().getXmlLocalName(); + boolean isGrid = name.equals(GRID_VIEW); + boolean isSpinner = name.equals(SPINNER); + if (name.equals(LIST_VIEW) || name.equals(EXPANDABLE_LIST_VIEW) + || isGrid || isSpinner) { + mMenuManager.insertBefore(endId, new Separator()); + mMenuManager.insertBefore(endId, new ListViewTypeMenu(mCanvas, isGrid, isSpinner)); + return; + } else if (name.equals(VIEW_FRAGMENT) && selection.size() == 1) { + mMenuManager.insertBefore(endId, new Separator()); + mMenuManager.insertBefore(endId, new FragmentMenu(mCanvas)); + return; + } + } + } + + /** + * Given a map from selection items to list of applicable actions (produced + * by {@link #computeApplicableActions()}) this method computes the set of + * common actions and returns the action ids of these actions. + * + * @param actions a map from selection item to list of actions applicable to + * that selection item + * @return set of action ids for the actions that are present in the action + * lists for all selected items + */ + private Set<String> computeApplicableActionIds(Map<INode, List<RuleAction>> actions) { + if (actions.size() > 1) { + // More than one view is selected, so we have to filter down the available + // actions such that only those actions that are defined for all the views + // are shown + Map<String, Integer> idCounts = new HashMap<String, Integer>(); + for (Map.Entry<INode, List<RuleAction>> entry : actions.entrySet()) { + List<RuleAction> actionList = entry.getValue(); + for (RuleAction action : actionList) { + if (!action.supportsMultipleNodes()) { + continue; + } + String id = action.getId(); + if (id != null) { + assert id != null : action; + Integer count = idCounts.get(id); + if (count == null) { + idCounts.put(id, Integer.valueOf(1)); + } else { + idCounts.put(id, count + 1); + } + } + } + } + Integer selectionCount = Integer.valueOf(actions.size()); + Set<String> validIds = new HashSet<String>(idCounts.size()); + for (Map.Entry<String, Integer> entry : idCounts.entrySet()) { + Integer count = entry.getValue(); + if (selectionCount.equals(count)) { + String id = entry.getKey(); + validIds.add(id); + } + } + return validIds; + } else { + List<RuleAction> actionList = actions.values().iterator().next(); + Set<String> validIds = new HashSet<String>(actionList.size()); + for (RuleAction action : actionList) { + String id = action.getId(); + validIds.add(id); + } + return validIds; + } + } + + /** + * Returns the menu actions computed by the rule associated with this node. + * + * @param node the canvas node we need menu actions for + * @return a list of {@link RuleAction} objects applicable to the node + */ + private List<RuleAction> getMenuActions(NodeProxy node) { + List<RuleAction> actions = mCanvas.getRulesEngine().callGetContextMenu(node); + if (actions == null || actions.size() == 0) { + return null; + } + + return actions; + } + + /** + * Returns the default action id, or null + * + * @param node the node to look up the default action for + * @return the action id, or null + */ + private String getDefaultActionId(NodeProxy node) { + return mCanvas.getRulesEngine().callGetDefaultActionId(node); + } + + /** + * Creates a {@link ContributionItem} for the given {@link RuleAction}. + * + * @param action the action to create a {@link ContributionItem} for + * @param nodes the set of nodes the action should be applied to + * @param defaultId if not non null, the id of an action which should be considered default + * @return a new {@link ContributionItem} which implements the given action + * on the given nodes + */ + private ContributionItem createContributionItem(final RuleAction action, + final List<INode> nodes, final String defaultId) { + if (action instanceof RuleAction.Separator) { + return new Separator(); + } else if (action instanceof NestedAction) { + NestedAction parentAction = (NestedAction) action; + return new ActionContributionItem(new NestedActionMenu(parentAction, nodes)); + } else if (action instanceof Choices) { + Choices parentAction = (Choices) action; + return new ActionContributionItem(new NestedChoiceMenu(parentAction, nodes)); + } else if (action instanceof Toggle) { + return new ActionContributionItem(createToggleAction(action, nodes)); + } else { + return new ActionContributionItem(createPlainAction(action, nodes, defaultId)); + } + } + + private Action createToggleAction(final RuleAction action, final List<INode> nodes) { + Toggle toggleAction = (Toggle) action; + final boolean isChecked = toggleAction.isChecked(); + Action a = new Action(action.getTitle(), IAction.AS_CHECK_BOX) { + @Override + public void run() { + String label = createActionLabel(action, nodes); + mEditorDelegate.getEditor().wrapUndoEditXmlModel(label, new Runnable() { + @Override + public void run() { + action.getCallback().action(action, nodes, + null/* no valueId for a toggle */, !isChecked); + applyPendingChanges(); + } + }); + } + }; + a.setId(action.getId()); + a.setChecked(isChecked); + return a; + } + + private IAction createPlainAction(final RuleAction action, final List<INode> nodes, + final String defaultId) { + IAction a = new Action(action.getTitle(), IAction.AS_PUSH_BUTTON) { + @Override + public void run() { + String label = createActionLabel(action, nodes); + mEditorDelegate.getEditor().wrapUndoEditXmlModel(label, new Runnable() { + @Override + public void run() { + action.getCallback().action(action, nodes, null, + Boolean.TRUE); + applyPendingChanges(); + } + }); + } + }; + + String id = action.getId(); + if (defaultId != null && id.equals(defaultId)) { + a.setAccelerator(DEFAULT_ACTION_KEY); + String text = a.getText(); + text = text + '\t' + DEFAULT_ACTION_SHORTCUT; + a.setText(text); + + } else if (ATTR_ID.equals(id)) { + // Keep in sync with {@link LayoutCanvas#handleKeyPressed} + if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_DARWIN) { + a.setAccelerator('R' | SWT.MOD1 | SWT.MOD3); + // Option+Command + a.setText(a.getText().trim() + "\t\u2325\u2318R"); //$NON-NLS-1$ + } else if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_LINUX) { + a.setAccelerator('R' | SWT.MOD2 | SWT.MOD3); + a.setText(a.getText() + "\tShift+Alt+R"); //$NON-NLS-1$ + } else { + a.setAccelerator('R' | SWT.MOD2 | SWT.MOD3); + a.setText(a.getText() + "\tAlt+Shift+R"); //$NON-NLS-1$ + } + } + a.setId(id); + return a; + } + + private static String createActionLabel(final RuleAction action, final List<INode> nodes) { + String label = action.getTitle(); + if (nodes.size() > 1) { + label += String.format(" (%d elements)", nodes.size()); + } + return label; + } + + /** + * The {@link NestedParentMenu} provides submenu content which adds actions + * available on one of the selected node's parent nodes. This will be + * similar to the menu content for the selected node, except the parent + * menus will not be embedded within the nested menu. + */ + private class NestedParentMenu extends SubmenuAction { + INode mParent; + + NestedParentMenu(String title, INode parent) { + super(title); + mParent = parent; + } + + @Override + protected void addMenuItems(Menu menu) { + List<SelectionItem> selection = mCanvas.getSelectionManager().getSelections(); + if (selection.size() == 0) { + return; + } + + List<IContributionItem> menuItems = getMenuItems(Collections.singletonList(mParent)); + for (IContributionItem menuItem : menuItems) { + menuItem.fill(menu, -1); + } + } + } + + /** + * The {@link NestedActionMenu} creates a lazily populated pull-right menu + * where the children are {@link RuleAction}'s themselves. + */ + private class NestedActionMenu extends SubmenuAction { + private final NestedAction mParentAction; + private final List<INode> mNodes; + + NestedActionMenu(NestedAction parentAction, List<INode> nodes) { + super(parentAction.getTitle()); + mParentAction = parentAction; + mNodes = nodes; + + assert mNodes.size() > 0; + } + + @Override + protected void addMenuItems(Menu menu) { + Map<INode, List<RuleAction>> allActions = new HashMap<INode, List<RuleAction>>(); + for (INode node : mNodes) { + List<RuleAction> actionList = mParentAction.getNestedActions(node); + allActions.put(node, actionList); + } + + Set<String> availableIds = computeApplicableActionIds(allActions); + + NodeProxy first = (NodeProxy) mNodes.get(0); + String defaultId = getDefaultActionId(first); + List<RuleAction> firstSelectedActions = allActions.get(first); + + int count = 0; + for (RuleAction firstAction : firstSelectedActions) { + if (!availableIds.contains(firstAction.getId()) + && !(firstAction instanceof RuleAction.Separator)) { + // This action isn't supported by all selected items. + continue; + } + + createContributionItem(firstAction, mNodes, defaultId).fill(menu, -1); + count++; + } + + if (count == 0) { + addDisabledMessageItem("<Empty>"); + } + } + } + + private void applyPendingChanges() { + LayoutCanvas canvas = mEditorDelegate.getGraphicalEditor().getCanvasControl(); + CanvasViewInfo root = canvas.getViewHierarchy().getRoot(); + if (root != null) { + UiViewElementNode uiViewNode = root.getUiViewNode(); + NodeFactory nodeFactory = canvas.getNodeFactory(); + NodeProxy rootNode = nodeFactory.create(uiViewNode); + if (rootNode != null) { + rootNode.applyPendingChanges(); + } + } + } + + /** + * The {@link NestedChoiceMenu} creates a lazily populated pull-right menu + * where the items in the menu are strings + */ + private class NestedChoiceMenu extends SubmenuAction { + private final Choices mParentAction; + private final List<INode> mNodes; + + NestedChoiceMenu(Choices parentAction, List<INode> nodes) { + super(parentAction.getTitle()); + mParentAction = parentAction; + mNodes = nodes; + } + + @Override + protected void addMenuItems(Menu menu) { + List<String> titles = mParentAction.getTitles(); + List<String> ids = mParentAction.getIds(); + String current = mParentAction.getCurrent(); + assert titles.size() == ids.size(); + String[] currentValues = current != null + && current.indexOf(RuleAction.CHOICE_SEP) != -1 ? + current.split(RuleAction.CHOICE_SEP_PATTERN) : null; + for (int i = 0, n = Math.min(titles.size(), ids.size()); i < n; i++) { + final String id = ids.get(i); + if (id == null || id.equals(RuleAction.SEPARATOR)) { + new Separator().fill(menu, -1); + continue; + } + + // Find out whether this item is selected + boolean select = false; + if (current != null) { + // The current choice has a separator, so it's a flag with + // multiple values selected. Compare keys with the split + // values. + if (currentValues != null) { + if (current.indexOf(id) >= 0) { + for (String value : currentValues) { + if (id.equals(value)) { + select = true; + break; + } + } + } + } else { + // current choice has no separator, simply compare to the key + select = id.equals(current); + } + } + + String title = titles.get(i); + IAction a = new Action(title, + current != null ? IAction.AS_CHECK_BOX : IAction.AS_PUSH_BUTTON) { + @Override + public void runWithEvent(Event event) { + run(); + } + @Override + public void run() { + String label = createActionLabel(mParentAction, mNodes); + mEditorDelegate.getEditor().wrapUndoEditXmlModel(label, new Runnable() { + @Override + public void run() { + mParentAction.getCallback().action(mParentAction, mNodes, id, + Boolean.TRUE); + applyPendingChanges(); + } + }); + } + }; + a.setId(id); + a.setEnabled(true); + if (select) { + a.setChecked(true); + } + + new ActionContributionItem(a).fill(menu, -1); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/EmptyViewsOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/EmptyViewsOverlay.java new file mode 100644 index 000000000..daa3e0eae --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/EmptyViewsOverlay.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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Rectangle; + +/** + * The {@link EmptyViewsOverlay} paints bounding rectangles for any of the empty and + * invisible container views in the scene. + */ +public class EmptyViewsOverlay extends Overlay { + /** The {@link ViewHierarchy} containing visible view information. */ + private final ViewHierarchy mViewHierarchy; + + /** Border color to paint the bounding boxes with. */ + private Color mBorderColor; + + /** Vertical scaling & scrollbar information. */ + private CanvasTransform mVScale; + + /** Horizontal scaling & scrollbar information. */ + private CanvasTransform mHScale; + + /** + * Constructs a new {@link EmptyViewsOverlay} linked to the given view hierarchy. + * + * @param viewHierarchy The {@link ViewHierarchy} to render. + * @param hScale The {@link CanvasTransform} to use to transfer horizontal layout + * coordinates to screen coordinates. + * @param vScale The {@link CanvasTransform} to use to transfer vertical layout coordinates + * to screen coordinates. + */ + public EmptyViewsOverlay( + ViewHierarchy viewHierarchy, + CanvasTransform hScale, + CanvasTransform vScale) { + super(); + mViewHierarchy = viewHierarchy; + mHScale = hScale; + mVScale = vScale; + } + + @Override + public void create(Device device) { + mBorderColor = new Color(device, SwtDrawingStyle.EMPTY.getStrokeColor()); + } + + @Override + public void dispose() { + if (mBorderColor != null) { + mBorderColor.dispose(); + mBorderColor = null; + } + } + + @Override + public void paint(GC gc) { + gc.setForeground(mBorderColor); + gc.setLineDash(null); + gc.setLineStyle(SwtDrawingStyle.EMPTY.getLineStyle()); + int oldAlpha = gc.getAlpha(); + gc.setAlpha(SwtDrawingStyle.EMPTY.getStrokeAlpha()); + gc.setLineWidth(SwtDrawingStyle.EMPTY.getLineWidth()); + + for (CanvasViewInfo info : mViewHierarchy.getInvisibleViews()) { + Rectangle r = info.getAbsRect(); + + int x = mHScale.translate(r.x); + int y = mVScale.translate(r.y); + int w = mHScale.scale(r.width); + int h = mVScale.scale(r.height); + + // +1: See explanation in equivalent code in {@link OutlineOverlay#paint} + gc.drawRectangle(x, y, w + 1, h + 1); + } + + gc.setAlpha(oldAlpha); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ExportScreenshotAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ExportScreenshotAction.java new file mode 100644 index 000000000..ac3328db2 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ExportScreenshotAction.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.DOT_PNG; + +import com.android.ide.eclipse.adt.AdtPlugin; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Shell; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; + +/** Saves the current layout editor's rendered image to disk */ +class ExportScreenshotAction extends Action { + private final LayoutCanvas mCanvas; + + ExportScreenshotAction(LayoutCanvas canvas) { + super("Export Screenshot..."); + mCanvas = canvas; + } + + @Override + public void run() { + Shell shell = AdtPlugin.getShell(); + + ImageOverlay imageOverlay = mCanvas.getImageOverlay(); + BufferedImage image = imageOverlay.getAwtImage(); + if (image != null) { + FileDialog dialog = new FileDialog(shell, SWT.SAVE); + dialog.setFilterExtensions(new String[] { "*.png" }); //$NON-NLS-1$ + String path = dialog.open(); + if (path != null) { + if (!path.endsWith(DOT_PNG)) { + path = path + DOT_PNG; + } + File file = new File(path); + if (file.exists()) { + MessageDialog d = new MessageDialog(null, "File Already Exists", null, + String.format( + "%1$s already exists.\nWould you like to replace it?", + path), + MessageDialog.QUESTION, new String[] { + // Yes will be moved to the end because it's the default + "Yes", "No" + }, 0); + int result = d.open(); + if (result != 0) { + return; + } + } + try { + ImageIO.write(image, "PNG", file); //$NON-NLS-1$ + } catch (IOException e) { + AdtPlugin.log(e, null); + } + } + } else { + MessageDialog.openError(shell, "Error", "Image not available"); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/FragmentMenu.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/FragmentMenu.java new file mode 100644 index 000000000..f7085fc12 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/FragmentMenu.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_CLASS; +import static com.android.SdkConstants.ATTR_NAME; +import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata.KEY_FRAGMENT_LAYOUT; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; +import com.android.ide.eclipse.adt.internal.resources.CyclicDependencyValidator; +import com.android.ide.eclipse.adt.internal.ui.ResourceChooser; +import com.android.resources.ResourceType; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IType; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Menu; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.List; + +/** + * Fragment context menu allowing a layout to be chosen for previewing in the fragment frame. + */ +public class FragmentMenu extends SubmenuAction { + private static final String R_LAYOUT_RESOURCE_PREFIX = "R.layout."; //$NON-NLS-1$ + private static final String ANDROID_R_PREFIX = "android.R.layout"; //$NON-NLS-1$ + + /** Associated canvas */ + private final LayoutCanvas mCanvas; + + /** + * Creates a "Preview Fragment" menu + * + * @param canvas associated canvas + */ + public FragmentMenu(LayoutCanvas canvas) { + super("Fragment Layout"); + mCanvas = canvas; + } + + @Override + protected void addMenuItems(Menu menu) { + IAction action = new PickLayoutAction("Choose Layout..."); + new ActionContributionItem(action).fill(menu, -1); + + SelectionManager selectionManager = mCanvas.getSelectionManager(); + List<SelectionItem> selections = selectionManager.getSelections(); + if (selections.size() == 0) { + return; + } + + SelectionItem first = selections.get(0); + UiViewElementNode node = first.getViewInfo().getUiViewNode(); + if (node == null) { + return; + } + Element element = (Element) node.getXmlNode(); + + String selected = getSelectedLayout(); + if (selected != null) { + if (selected.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX)) { + selected = selected.substring(ANDROID_LAYOUT_RESOURCE_PREFIX.length()); + } + } + + String fqcn = getFragmentClass(element); + if (fqcn != null) { + // Look up the corresponding activity class and try to figure out + // which layouts it is referring to and list these here as reasonable + // guesses + IProject project = mCanvas.getEditorDelegate().getEditor().getProject(); + String source = null; + try { + IJavaProject javaProject = BaseProjectHelper.getJavaProject(project); + IType type = javaProject.findType(fqcn); + if (type != null) { + source = type.getSource(); + } + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + // Find layouts. This is based on just skimming the Fragment class and looking + // for layout references of the form R.layout.*. + if (source != null) { + String self = mCanvas.getLayoutResourceName(); + // Pair of <title,layout> to be displayed to the user + List<Pair<String, String>> layouts = new ArrayList<Pair<String, String>>(); + + if (source.contains("extends ListFragment")) { //$NON-NLS-1$ + layouts.add(Pair.of("list_content", //$NON-NLS-1$ + "@android:layout/list_content")); //$NON-NLS-1$ + } + + int index = 0; + while (true) { + index = source.indexOf(R_LAYOUT_RESOURCE_PREFIX, index); + if (index == -1) { + break; + } else { + index += R_LAYOUT_RESOURCE_PREFIX.length(); + int end = index; + while (end < source.length()) { + char c = source.charAt(end); + if (!Character.isJavaIdentifierPart(c)) { + break; + } + end++; + } + if (end > index) { + String title = source.substring(index, end); + String layout; + // Is this R.layout part of an android.R.layout? + int len = ANDROID_R_PREFIX.length() + 1; // prefix length to check + if (index > len && source.startsWith(ANDROID_R_PREFIX, index - len)) { + layout = ANDROID_LAYOUT_RESOURCE_PREFIX + title; + } else { + layout = LAYOUT_RESOURCE_PREFIX + title; + } + if (!self.equals(title)) { + layouts.add(Pair.of(title, layout)); + } + } + } + + index++; + } + + if (layouts.size() > 0) { + new Separator().fill(menu, -1); + for (Pair<String, String> layout : layouts) { + action = new SetFragmentLayoutAction(layout.getFirst(), + layout.getSecond(), selected); + new ActionContributionItem(action).fill(menu, -1); + } + } + } + } + + if (selected != null) { + new Separator().fill(menu, -1); + action = new SetFragmentLayoutAction("Clear", null, null); + new ActionContributionItem(action).fill(menu, -1); + } + } + + /** + * Returns the class name of the fragment associated with the given {@code <fragment>} + * element. + * + * @param element the element for the fragment tag + * @return the fully qualified fragment class name, or null + */ + @Nullable + public static String getFragmentClass(@NonNull Element element) { + String fqcn = element.getAttribute(ATTR_CLASS); + if (fqcn == null || fqcn.length() == 0) { + fqcn = element.getAttributeNS(ANDROID_URI, ATTR_NAME); + } + if (fqcn != null && fqcn.length() > 0) { + return fqcn; + } else { + return null; + } + } + + /** + * Returns the layout to be shown for the given {@code <fragment>} node. + * + * @param node the node corresponding to the {@code <fragment>} element + * @return the resource path to a layout to render for this fragment, or null + */ + @Nullable + public static String getFragmentLayout(@NonNull Node node) { + String layout = LayoutMetadata.getProperty( + node, LayoutMetadata.KEY_FRAGMENT_LAYOUT); + if (layout != null) { + return layout; + } + + return null; + } + + /** Returns the name of the currently displayed layout in the fragment, or null */ + @Nullable + private String getSelectedLayout() { + SelectionManager selectionManager = mCanvas.getSelectionManager(); + for (SelectionItem item : selectionManager.getSelections()) { + UiViewElementNode node = item.getViewInfo().getUiViewNode(); + if (node != null) { + String layout = getFragmentLayout(node.getXmlNode()); + if (layout != null) { + return layout; + } + } + } + return null; + } + + /** + * Set the given layout as the new fragment layout + * + * @param layout the layout resource name to show in this fragment + */ + public void setNewLayout(@Nullable String layout) { + LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + GraphicalEditorPart graphicalEditor = delegate.getGraphicalEditor(); + SelectionManager selectionManager = mCanvas.getSelectionManager(); + + for (SelectionItem item : selectionManager.getSnapshot()) { + UiViewElementNode node = item.getViewInfo().getUiViewNode(); + if (node != null) { + Node xmlNode = node.getXmlNode(); + LayoutMetadata.setProperty(delegate.getEditor(), xmlNode, KEY_FRAGMENT_LAYOUT, + layout); + } + } + + // Refresh + graphicalEditor.recomputeLayout(); + mCanvas.redraw(); + } + + /** Action to set the given layout as the new layout in a fragment */ + private class SetFragmentLayoutAction extends Action { + private final String mLayout; + + public SetFragmentLayoutAction(String title, String layout, String selected) { + super(title, IAction.AS_RADIO_BUTTON); + mLayout = layout; + + if (layout != null && layout.equals(selected)) { + setChecked(true); + } + } + + @Override + public void run() { + if (isChecked()) { + setNewLayout(mLayout); + } + } + } + + /** + * Action which brings up the "Create new XML File" wizard, pre-selected with the + * animation category + */ + private class PickLayoutAction extends Action { + + public PickLayoutAction(String title) { + super(title, IAction.AS_PUSH_BUTTON); + } + + @Override + public void run() { + LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + IFile file = delegate.getEditor().getInputFile(); + GraphicalEditorPart editor = delegate.getGraphicalEditor(); + ResourceChooser dlg = ResourceChooser.create(editor, ResourceType.LAYOUT) + .setInputValidator(CyclicDependencyValidator.create(file)) + .setInitialSize(85, 10) + .setCurrentResource(getSelectedLayout()); + int result = dlg.open(); + if (result == ResourceChooser.CLEAR_RETURN_CODE) { + setNewLayout(null); + } else if (result == Window.OK) { + String newType = dlg.getCurrentResource(); + setNewLayout(newType); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GCWrapper.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GCWrapper.java new file mode 100644 index 000000000..354517e76 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GCWrapper.java @@ -0,0 +1,645 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.DrawingStyle; +import com.android.ide.common.api.IColor; +import com.android.ide.common.api.IGraphics; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.Point; +import com.android.ide.common.api.Rect; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.FontMetrics; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.RGB; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Wraps an SWT {@link GC} into an {@link IGraphics} interface so that {@link IViewRule} objects + * can directly draw on the canvas. + * <p/> + * The actual wrapped GC object is only non-null during the context of a paint operation. + */ +public class GCWrapper implements IGraphics { + + /** + * The actual SWT {@link GC} being wrapped. This can change during the lifetime of the + * object. It is generally set to something during an onPaint method and then changed + * to null when not in the context of a paint. + */ + private GC mGc; + + /** + * Current style being used for drawing. + */ + private SwtDrawingStyle mCurrentStyle = SwtDrawingStyle.INVALID; + + /** + * Implementation of IColor wrapping an SWT color. + */ + private static class ColorWrapper implements IColor { + private final Color mColor; + + public ColorWrapper(Color color) { + mColor = color; + } + + public Color getColor() { + return mColor; + } + } + + /** A map of registered colors. All these colors must be disposed at the end. */ + private final HashMap<Integer, ColorWrapper> mColorMap = new HashMap<Integer, ColorWrapper>(); + + /** + * A map of the {@link SwtDrawingStyle} stroke colors that we have actually + * used (to be disposed) + */ + private final Map<DrawingStyle, Color> mStyleStrokeMap = new EnumMap<DrawingStyle, Color>( + DrawingStyle.class); + + /** + * A map of the {@link SwtDrawingStyle} fill colors that we have actually + * used (to be disposed) + */ + private final Map<DrawingStyle, Color> mStyleFillMap = new EnumMap<DrawingStyle, Color>( + DrawingStyle.class); + + /** The cached pixel height of the default current font. */ + private int mFontHeight = 0; + + /** The scaling of the canvas in X. */ + private final CanvasTransform mHScale; + /** The scaling of the canvas in Y. */ + private final CanvasTransform mVScale; + + public GCWrapper(CanvasTransform hScale, CanvasTransform vScale) { + mHScale = hScale; + mVScale = vScale; + mGc = null; + } + + void setGC(GC gc) { + mGc = gc; + } + + private GC getGc() { + return mGc; + } + + void checkGC() { + if (mGc == null) { + throw new RuntimeException("IGraphics used without a valid context."); + } + } + + void dispose() { + for (ColorWrapper c : mColorMap.values()) { + c.getColor().dispose(); + } + mColorMap.clear(); + + for (Color c : mStyleStrokeMap.values()) { + c.dispose(); + } + mStyleStrokeMap.clear(); + + for (Color c : mStyleFillMap.values()) { + c.dispose(); + } + mStyleFillMap.clear(); + } + + //------------- + + @Override + public @NonNull IColor registerColor(int rgb) { + checkGC(); + + Integer key = Integer.valueOf(rgb); + ColorWrapper c = mColorMap.get(key); + if (c == null) { + c = new ColorWrapper(new Color(getGc().getDevice(), + (rgb >> 16) & 0xFF, + (rgb >> 8) & 0xFF, + (rgb >> 0) & 0xFF)); + mColorMap.put(key, c); + } + + return c; + } + + /** Returns the (cached) pixel height of the current font. */ + @Override + public int getFontHeight() { + if (mFontHeight < 1) { + checkGC(); + FontMetrics fm = getGc().getFontMetrics(); + mFontHeight = fm.getHeight(); + } + return mFontHeight; + } + + @Override + public @NonNull IColor getForeground() { + Color c = getGc().getForeground(); + return new ColorWrapper(c); + } + + @Override + public @NonNull IColor getBackground() { + Color c = getGc().getBackground(); + return new ColorWrapper(c); + } + + @Override + public int getAlpha() { + return getGc().getAlpha(); + } + + @Override + public void setForeground(@NonNull IColor color) { + checkGC(); + getGc().setForeground(((ColorWrapper) color).getColor()); + } + + @Override + public void setBackground(@NonNull IColor color) { + checkGC(); + getGc().setBackground(((ColorWrapper) color).getColor()); + } + + @Override + public void setAlpha(int alpha) { + checkGC(); + try { + getGc().setAlpha(alpha); + } catch (SWTException e) { + // This means that we cannot set the alpha on this platform; this is + // an acceptable no-op. + } + } + + @Override + public void setLineStyle(@NonNull LineStyle style) { + int swtStyle = 0; + switch (style) { + case LINE_SOLID: + swtStyle = SWT.LINE_SOLID; + break; + case LINE_DASH: + swtStyle = SWT.LINE_DASH; + break; + case LINE_DOT: + swtStyle = SWT.LINE_DOT; + break; + case LINE_DASHDOT: + swtStyle = SWT.LINE_DASHDOT; + break; + case LINE_DASHDOTDOT: + swtStyle = SWT.LINE_DASHDOTDOT; + break; + default: + assert false : style; + break; + } + + if (swtStyle != 0) { + checkGC(); + getGc().setLineStyle(swtStyle); + } + } + + @Override + public void setLineWidth(int width) { + checkGC(); + if (width > 0) { + getGc().setLineWidth(width); + } + } + + // lines + + @Override + public void drawLine(int x1, int y1, int x2, int y2) { + checkGC(); + useStrokeAlpha(); + x1 = mHScale.translate(x1); + y1 = mVScale.translate(y1); + x2 = mHScale.translate(x2); + y2 = mVScale.translate(y2); + getGc().drawLine(x1, y1, x2, y2); + } + + @Override + public void drawLine(@NonNull Point p1, @NonNull Point p2) { + drawLine(p1.x, p1.y, p2.x, p2.y); + } + + // rectangles + + @Override + public void drawRect(int x1, int y1, int x2, int y2) { + checkGC(); + useStrokeAlpha(); + int x = mHScale.translate(x1); + int y = mVScale.translate(y1); + int w = mHScale.scale(x2 - x1); + int h = mVScale.scale(y2 - y1); + getGc().drawRectangle(x, y, w, h); + } + + @Override + public void drawRect(@NonNull Point p1, @NonNull Point p2) { + drawRect(p1.x, p1.y, p2.x, p2.y); + } + + @Override + public void drawRect(@NonNull Rect r) { + checkGC(); + useStrokeAlpha(); + int x = mHScale.translate(r.x); + int y = mVScale.translate(r.y); + int w = mHScale.scale(r.w); + int h = mVScale.scale(r.h); + getGc().drawRectangle(x, y, w, h); + } + + @Override + public void fillRect(int x1, int y1, int x2, int y2) { + checkGC(); + useFillAlpha(); + int x = mHScale.translate(x1); + int y = mVScale.translate(y1); + int w = mHScale.scale(x2 - x1); + int h = mVScale.scale(y2 - y1); + getGc().fillRectangle(x, y, w, h); + } + + @Override + public void fillRect(@NonNull Point p1, @NonNull Point p2) { + fillRect(p1.x, p1.y, p2.x, p2.y); + } + + @Override + public void fillRect(@NonNull Rect r) { + checkGC(); + useFillAlpha(); + int x = mHScale.translate(r.x); + int y = mVScale.translate(r.y); + int w = mHScale.scale(r.w); + int h = mVScale.scale(r.h); + getGc().fillRectangle(x, y, w, h); + } + + // circles (actually ovals) + + public void drawOval(int x1, int y1, int x2, int y2) { + checkGC(); + useStrokeAlpha(); + int x = mHScale.translate(x1); + int y = mVScale.translate(y1); + int w = mHScale.scale(x2 - x1); + int h = mVScale.scale(y2 - y1); + getGc().drawOval(x, y, w, h); + } + + public void drawOval(Point p1, Point p2) { + drawOval(p1.x, p1.y, p2.x, p2.y); + } + + public void drawOval(Rect r) { + checkGC(); + useStrokeAlpha(); + int x = mHScale.translate(r.x); + int y = mVScale.translate(r.y); + int w = mHScale.scale(r.w); + int h = mVScale.scale(r.h); + getGc().drawOval(x, y, w, h); + } + + public void fillOval(int x1, int y1, int x2, int y2) { + checkGC(); + useFillAlpha(); + int x = mHScale.translate(x1); + int y = mVScale.translate(y1); + int w = mHScale.scale(x2 - x1); + int h = mVScale.scale(y2 - y1); + getGc().fillOval(x, y, w, h); + } + + public void fillOval(Point p1, Point p2) { + fillOval(p1.x, p1.y, p2.x, p2.y); + } + + public void fillOval(Rect r) { + checkGC(); + useFillAlpha(); + int x = mHScale.translate(r.x); + int y = mVScale.translate(r.y); + int w = mHScale.scale(r.w); + int h = mVScale.scale(r.h); + getGc().fillOval(x, y, w, h); + } + + + // strings + + @Override + public void drawString(@NonNull String string, int x, int y) { + checkGC(); + useStrokeAlpha(); + x = mHScale.translate(x); + y = mVScale.translate(y); + // Background fill of text is not useful because it does not + // use the alpha; we instead supply a separate method (drawBoxedStrings) which + // first paints a semi-transparent mask for the text to sit on + // top of (this ensures that the text is readable regardless of + // colors of the pixels below the text) + getGc().drawString(string, x, y, true /*isTransparent*/); + } + + @Override + public void drawBoxedStrings(int x, int y, @NonNull List<?> strings) { + checkGC(); + + x = mHScale.translate(x); + y = mVScale.translate(y); + + // Compute bounds of the box by adding up the sum of the text heights + // and the max of the text widths + int width = 0; + int height = 0; + int lineHeight = getGc().getFontMetrics().getHeight(); + for (Object s : strings) { + org.eclipse.swt.graphics.Point extent = getGc().stringExtent(s.toString()); + height += extent.y; + width = Math.max(width, extent.x); + } + + // Paint a box below the text + int padding = 2; + useFillAlpha(); + getGc().fillRectangle(x - padding, y - padding, width + 2 * padding, height + 2 * padding); + + // Finally draw strings on top + useStrokeAlpha(); + int lineY = y; + for (Object s : strings) { + getGc().drawString(s.toString(), x, lineY, true /* isTransparent */); + lineY += lineHeight; + } + } + + @Override + public void drawString(@NonNull String string, @NonNull Point topLeft) { + drawString(string, topLeft.x, topLeft.y); + } + + // Styles + + @Override + public void useStyle(@NonNull DrawingStyle style) { + checkGC(); + + // Look up the specific SWT style which defines the actual + // colors and attributes to be used for the logical drawing style. + SwtDrawingStyle swtStyle = SwtDrawingStyle.of(style); + RGB stroke = swtStyle.getStrokeColor(); + if (stroke != null) { + Color color = getStrokeColor(style, stroke); + mGc.setForeground(color); + } + RGB fill = swtStyle.getFillColor(); + if (fill != null) { + Color color = getFillColor(style, fill); + mGc.setBackground(color); + } + mGc.setLineWidth(swtStyle.getLineWidth()); + mGc.setLineStyle(swtStyle.getLineStyle()); + if (swtStyle.getLineStyle() == SWT.LINE_CUSTOM) { + mGc.setLineDash(new int[] { + 8, 4 + }); + } + mCurrentStyle = swtStyle; + } + + /** Uses the stroke alpha for subsequent drawing operations. */ + private void useStrokeAlpha() { + mGc.setAlpha(mCurrentStyle.getStrokeAlpha()); + } + + /** Uses the fill alpha for subsequent drawing operations. */ + private void useFillAlpha() { + mGc.setAlpha(mCurrentStyle.getFillAlpha()); + } + + /** + * Get the SWT stroke color (foreground/border) to use for the given style, + * using the provided color description if we haven't seen this color yet. + * The color will also be placed in the {@link #mStyleStrokeMap} such that + * it can be disposed of at cleanup time. + * + * @param style The drawing style for which we want a color + * @param defaultColorDesc The RGB values to initialize the color to if we + * haven't seen this color before + * @return The color object + */ + private Color getStrokeColor(DrawingStyle style, RGB defaultColorDesc) { + return getStyleColor(style, defaultColorDesc, mStyleStrokeMap); + } + + /** + * Get the SWT fill (background/interior) color to use for the given style, + * using the provided color description if we haven't seen this color yet. + * The color will also be placed in the {@link #mStyleStrokeMap} such that + * it can be disposed of at cleanup time. + * + * @param style The drawing style for which we want a color + * @param defaultColorDesc The RGB values to initialize the color to if we + * haven't seen this color before + * @return The color object + */ + private Color getFillColor(DrawingStyle style, RGB defaultColorDesc) { + return getStyleColor(style, defaultColorDesc, mStyleFillMap); + } + + /** + * Get the SWT color to use for the given style, using the provided color + * description if we haven't seen this color yet. The color will also be + * placed in the map referenced by the map parameter such that it can be + * disposed of at cleanup time. + * + * @param style The drawing style for which we want a color + * @param defaultColorDesc The RGB values to initialize the color to if we + * haven't seen this color before + * @param map The color map to use + * @return The color object + */ + private Color getStyleColor(DrawingStyle style, RGB defaultColorDesc, + Map<DrawingStyle, Color> map) { + Color color = map.get(style); + if (color == null) { + color = new Color(getGc().getDevice(), defaultColorDesc); + map.put(style, color); + } + + return color; + } + + // dots + + @Override + public void drawPoint(int x, int y) { + checkGC(); + useStrokeAlpha(); + x = mHScale.translate(x); + y = mVScale.translate(y); + + getGc().drawPoint(x, y); + } + + // arrows + + private static final int MIN_LENGTH = 10; + + + @Override + public void drawArrow(int x1, int y1, int x2, int y2, int size) { + int arrowWidth = size; + int arrowHeight = size; + + checkGC(); + useStrokeAlpha(); + x1 = mHScale.translate(x1); + y1 = mVScale.translate(y1); + x2 = mHScale.translate(x2); + y2 = mVScale.translate(y2); + GC graphics = getGc(); + + // Make size adjustments to ensure that the arrow has enough width to be visible + if (x1 == x2 && Math.abs(y1 - y2) < MIN_LENGTH) { + int delta = (MIN_LENGTH - Math.abs(y1 - y2)) / 2; + if (y1 < y2) { + y1 -= delta; + y2 += delta; + } else { + y1 += delta; + y2-= delta; + } + + } else if (y1 == y2 && Math.abs(x1 - x2) < MIN_LENGTH) { + int delta = (MIN_LENGTH - Math.abs(x1 - x2)) / 2; + if (x1 < x2) { + x1 -= delta; + x2 += delta; + } else { + x1 += delta; + x2-= delta; + } + } + + graphics.drawLine(x1, y1, x2, y2); + + // Arrowhead: + + if (x1 == x2) { + // Vertical + if (y2 > y1) { + graphics.drawLine(x2 - arrowWidth, y2 - arrowHeight, x2, y2); + graphics.drawLine(x2 + arrowWidth, y2 - arrowHeight, x2, y2); + } else { + graphics.drawLine(x2 - arrowWidth, y2 + arrowHeight, x2, y2); + graphics.drawLine(x2 + arrowWidth, y2 + arrowHeight, x2, y2); + } + } else if (y1 == y2) { + // Horizontal + if (x2 > x1) { + graphics.drawLine(x2 - arrowHeight, y2 - arrowWidth, x2, y2); + graphics.drawLine(x2 - arrowHeight, y2 + arrowWidth, x2, y2); + } else { + graphics.drawLine(x2 + arrowHeight, y2 - arrowWidth, x2, y2); + graphics.drawLine(x2 + arrowHeight, y2 + arrowWidth, x2, y2); + } + } else { + // Compute angle: + int dy = y2 - y1; + int dx = x2 - x1; + double angle = Math.atan2(dy, dx); + double lineLength = Math.sqrt(dy * dy + dx * dx); + + // Imagine a line of the same length as the arrow, but with angle 0. + // Its two arrow lines are at (-arrowWidth, -arrowHeight) relative + // to the endpoint (x1 + lineLength, y1) stretching up to (x2,y2). + // We compute the positions of (ax,ay) for the point above and + // below this line and paint the lines to it: + double ax = x1 + lineLength - arrowHeight; + double ay = y1 - arrowWidth; + int rx = (int) (Math.cos(angle) * (ax-x1) - Math.sin(angle) * (ay-y1) + x1); + int ry = (int) (Math.sin(angle) * (ax-x1) + Math.cos(angle) * (ay-y1) + y1); + graphics.drawLine(x2, y2, rx, ry); + + ay = y1 + arrowWidth; + rx = (int) (Math.cos(angle) * (ax-x1) - Math.sin(angle) * (ay-y1) + x1); + ry = (int) (Math.sin(angle) * (ax-x1) + Math.cos(angle) * (ay-y1) + y1); + graphics.drawLine(x2, y2, rx, ry); + } + + /* TODO: Experiment with filled arrow heads? + if (x1 == x2) { + // Vertical + if (y2 > y1) { + for (int i = 0; i < arrowWidth; i++) { + graphics.drawLine(x2 - arrowWidth + i, y2 - arrowWidth + i, + x2 + arrowWidth - i, y2 - arrowWidth + i); + } + } else { + for (int i = 0; i < arrowWidth; i++) { + graphics.drawLine(x2 - arrowWidth + i, y2 + arrowWidth - i, + x2 + arrowWidth - i, y2 + arrowWidth - i); + } + } + } else if (y1 == y2) { + // Horizontal + if (x2 > x1) { + for (int i = 0; i < arrowHeight; i++) { + graphics.drawLine(x2 - arrowHeight + i, y2 - arrowHeight + i, x2 + - arrowHeight + i, y2 + arrowHeight - i); + } + } else { + for (int i = 0; i < arrowHeight; i++) { + graphics.drawLine(x2 + arrowHeight - i, y2 - arrowHeight + i, x2 + + arrowHeight - i, y2 + arrowHeight - i); + } + } + } else { + // Arbitrary angle -- need to use trig + // TODO: Implement this + } + */ + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/Gesture.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/Gesture.java new file mode 100644 index 000000000..a35d19078 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/Gesture.java @@ -0,0 +1,156 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.utils.Pair; + +import org.eclipse.swt.events.KeyEvent; + +import java.util.Collections; +import java.util.List; + +/** + * A gesture is a mouse or keyboard driven user operation, such as a + * swipe-select or a resize. It can be thought of as a session, since it is + * initiated, updated during user manipulation, and finally completed or + * canceled. A gesture is associated with a single undo transaction (although + * some gestures don't actually edit anything, such as a selection), and a + * gesture can have a number of graphics {@link Overlay}s which are added and + * cleaned up on behalf of the gesture by the system. + * <p/> + * Gestures are typically mouse oriented. If a mouse wishes to integrate + * with the native drag & drop support, it should also implement + * the {@link DropGesture} interface, which is a sub interface of this + * {@link Gesture} interface. There are pros and cons to using native drag + * & drop, so various gestures will differ in whether they use it. + * In particular, you should use drag & drop if your gesture should: + * <ul> + * <li> Show a native drag & drop cursor + * <li> Copy or move data, especially if this applies outside the canvas + * control window or even the application itself + * </ul> + * You might want to avoid using native drag & drop if your gesture should: + * <ul> + * <li> Continue updating itself even when the mouse cursor leaves the + * canvas window (in a drag & gesture, as soon as you leave the canvas + * the drag source is no longer informed of mouse updates, whereas a regular + * mouse listener is) + * <li> Respond to modifier keys (for example, if toggling the Shift key + * should constrain motion as is common during resizing, and so on) + * <li> Use no special cursor (for example, during a marquee selection gesture we + * don't want a native drag & drop cursor) + * </ul> + * <p/> + * Examples of gestures: + * <ul> + * <li>Move (dragging to reorder or change hierarchy of views or change visual + * layout attributes) + * <li>Marquee (swiping out a rectangle to make a selection) + * <li>Resize (dragging some edge or corner of a widget to change its size, for + * example to some new fixed size, or to "attach" it to some other edge.) + * <li>Inline Editing (editing the text of some text-oriented widget like a + * label or a button) + * <li>Link (associate two or more widgets in some way, such as an + * "is required" widget linked to a text field) + * </ul> + */ +public abstract class Gesture { + /** Start mouse coordinate, in control coordinates. */ + protected ControlPoint mStart; + + /** Initial SWT mask when the gesture started. */ + protected int mStartMask; + + /** + * Returns a list of overlays, from bottom to top (where the later overlays + * are painted on top of earlier ones if they overlap). + * + * @return A list of overlays to paint for this gesture, if applicable. + * Should not be null, but can be empty. + */ + public List<Overlay> createOverlays() { + return Collections.emptyList(); + } + + /** + * Handles initialization of this gesture. Called when the gesture is + * starting. + * + * @param pos The most recent mouse coordinate applicable to this + * gesture, relative to the canvas control. + * @param startMask The initial SWT mask for the gesture, if known, or + * otherwise 0. + */ + public void begin(ControlPoint pos, int startMask) { + mStart = pos; + mStartMask = startMask; + } + + /** + * Handles updating of the gesture state for a new mouse position. + * + * @param pos The most recent mouse coordinate applicable to this + * gesture, relative to the canvas control. + */ + public void update(ControlPoint pos) { + } + + /** + * Handles termination of the gesture. This method is called when the + * gesture has terminated (either through successful completion, or because + * it was canceled). + * + * @param pos The most recent mouse coordinate applicable to this + * gesture, relative to the canvas control. + * @param canceled True if the gesture was canceled, and false otherwise. + */ + public void end(ControlPoint pos, boolean canceled) { + } + + /** + * Handles a key press during the gesture. May be called repeatedly when the + * user is holding the key for several seconds. + * + * @param event The SWT event for the key press, + * @return true if this gesture consumed the key press, otherwise return false + */ + public boolean keyPressed(KeyEvent event) { + return false; + } + + /** + * Handles a key release during the gesture. + * + * @param event The SWT event for the key release, + * @return true if this gesture consumed the key press, otherwise return false + */ + public boolean keyReleased(KeyEvent event) { + return false; + } + + /** + * Returns whether tooltips should be display below and to the right of the mouse + * cursor. + * + * @return a pair of booleans, the first indicating whether the tooltip should be + * below and the second indicating whether the tooltip should be displayed to + * the right of the mouse cursor. + */ + public Pair<Boolean, Boolean> getTooltipPosition() { + return Pair.of(true, true); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GestureManager.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GestureManager.java new file mode 100644 index 000000000..98bc25e37 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GestureManager.java @@ -0,0 +1,930 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.SdkConstants; +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.SegmentType; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.utils.Pair; + +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.swt.SWT; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.DragSource; +import org.eclipse.swt.dnd.DragSourceEvent; +import org.eclipse.swt.dnd.DragSourceListener; +import org.eclipse.swt.dnd.DropTarget; +import org.eclipse.swt.dnd.DropTargetEvent; +import org.eclipse.swt.dnd.DropTargetListener; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.events.KeyListener; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseListener; +import org.eclipse.swt.events.MouseMoveListener; +import org.eclipse.swt.events.MouseTrackListener; +import org.eclipse.swt.events.TypedEvent; +import org.eclipse.swt.graphics.Cursor; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorSite; + +import java.util.ArrayList; +import java.util.List; + +/** + * The {@link GestureManager} is is the central manager of gestures; it is responsible + * for recognizing when particular gestures should begin and terminate. It + * listens to the drag, mouse and keyboard systems to find out when to start + * gestures and in order to update the gestures along the way. + */ +public class GestureManager { + /** The canvas which owns this GestureManager. */ + private final LayoutCanvas mCanvas; + + /** The currently executing gesture, or null. */ + private Gesture mCurrentGesture; + + /** A listener for drop target events. */ + private final DropTargetListener mDropListener = new CanvasDropListener(); + + /** A listener for drag source events. */ + private final DragSourceListener mDragSourceListener = new CanvasDragSourceListener(); + + /** Tooltip shown during the gesture, or null */ + private GestureToolTip mTooltip; + + /** + * The list of overlays associated with {@link #mCurrentGesture}. Will be + * null before it has been initialized lazily by the paint routine (the + * initialized value can never be null, but it can be an empty collection). + */ + private List<Overlay> mOverlays; + + /** + * Most recently seen mouse position (x coordinate). We keep a copy of this + * value since we sometimes need to know it when we aren't told about the + * mouse position (such as when a keystroke is received, such as an arrow + * key in order to tweak the current drop position) + */ + protected int mLastMouseX; + + /** + * Most recently seen mouse position (y coordinate). We keep a copy of this + * value since we sometimes need to know it when we aren't told about the + * mouse position (such as when a keystroke is received, such as an arrow + * key in order to tweak the current drop position) + */ + protected int mLastMouseY; + + /** + * Most recently seen mouse mask. We keep a copy of this since in some + * scenarios (such as on a drag gesture) we don't get access to it. + */ + protected int mLastStateMask; + + /** + * Listener for mouse motion, click and keyboard events. + */ + private Listener mListener; + + /** + * When we the drag leaves, we don't know if that's the last we'll see of + * this drag or if it's just temporarily outside the canvas and it will + * return. We want to restore it if it comes back. This is also necessary + * because even on a drop we'll receive a + * {@link DropTargetListener#dragLeave} right before the drop, and we need + * to restore it in the drop. Therefore, when we lose a {@link DropGesture} + * to a {@link DropTargetListener#dragLeave}, we store a reference to the + * current gesture as a {@link #mZombieGesture}, since the gesture is dead + * but might be brought back to life if we see a subsequent + * {@link DropTargetListener#dragEnter} before another gesture begins. + */ + private DropGesture mZombieGesture; + + /** + * Flag tracking whether we've set a message or error message on the global status + * line (since we only want to clear that message if we have set it ourselves). + * This is the actual message rather than a boolean such that (if we can get our + * hands on the global message) we can check to see if the current message is the + * one we set and only in that case clear it when it is no longer applicable. + */ + private String mDisplayingMessage; + + /** + * Constructs a new {@link GestureManager} for the given + * {@link LayoutCanvas}. + * + * @param canvas The canvas which controls this {@link GestureManager} + */ + public GestureManager(LayoutCanvas canvas) { + mCanvas = canvas; + } + + /** + * Returns the canvas associated with this GestureManager. + * + * @return The {@link LayoutCanvas} associated with this GestureManager. + * Never null. + */ + public LayoutCanvas getCanvas() { + return mCanvas; + } + + /** + * Returns the current gesture, if one is in progress, and otherwise returns + * null. + * + * @return The current gesture or null. + */ + public Gesture getCurrentGesture() { + return mCurrentGesture; + } + + /** + * Paints the overlays associated with the current gesture, if any. + * + * @param gc The graphics object to paint into. + */ + public void paint(GC gc) { + if (mCurrentGesture == null) { + return; + } + + if (mOverlays == null) { + mOverlays = mCurrentGesture.createOverlays(); + Device device = gc.getDevice(); + for (Overlay overlay : mOverlays) { + overlay.create(device); + } + } + for (Overlay overlay : mOverlays) { + overlay.paint(gc); + } + } + + /** + * Registers all the listeners needed by the {@link GestureManager}. + * + * @param dragSource The drag source in the {@link LayoutCanvas} to listen + * to. + * @param dropTarget The drop target in the {@link LayoutCanvas} to listen + * to. + */ + public void registerListeners(DragSource dragSource, DropTarget dropTarget) { + assert mListener == null; + mListener = new Listener(); + mCanvas.addMouseMoveListener(mListener); + mCanvas.addMouseListener(mListener); + mCanvas.addKeyListener(mListener); + + if (dragSource != null) { + dragSource.addDragListener(mDragSourceListener); + } + if (dropTarget != null) { + dropTarget.addDropListener(mDropListener); + } + } + + /** + * Unregisters all the listeners previously registered by + * {@link #registerListeners}. + * + * @param dragSource The drag source in the {@link LayoutCanvas} to stop + * listening to. + * @param dropTarget The drop target in the {@link LayoutCanvas} to stop + * listening to. + */ + public void unregisterListeners(DragSource dragSource, DropTarget dropTarget) { + if (mCanvas.isDisposed()) { + // If the LayoutCanvas is already disposed, we shouldn't try to unregister + // the listeners; they are already not active and an attempt to remove the + // listener will throw a widget-is-disposed exception. + mListener = null; + return; + } + + if (mListener != null) { + mCanvas.removeMouseMoveListener(mListener); + mCanvas.removeMouseListener(mListener); + mCanvas.removeKeyListener(mListener); + mListener = null; + } + + if (dragSource != null) { + dragSource.removeDragListener(mDragSourceListener); + } + if (dropTarget != null) { + dropTarget.removeDropListener(mDropListener); + } + } + + /** + * Starts the given gesture. + * + * @param mousePos The most recent mouse coordinate applicable to the new + * gesture, in control coordinates. + * @param gesture The gesture to initiate + */ + private void startGesture(ControlPoint mousePos, Gesture gesture, int mask) { + if (mCurrentGesture != null) { + finishGesture(mousePos, true); + assert mCurrentGesture == null; + } + + if (gesture != null) { + mCurrentGesture = gesture; + mCurrentGesture.begin(mousePos, mask); + } + } + + /** + * Updates the current gesture, if any, for the given event. + * + * @param mousePos The most recent mouse coordinate applicable to the new + * gesture, in control coordinates. + * @param event The event corresponding to this update. May be null. Don't + * make any assumptions about the type of this event - for + * example, it may not always be a MouseEvent, it could be a + * DragSourceEvent, etc. + */ + private void updateMouse(ControlPoint mousePos, TypedEvent event) { + if (mCurrentGesture != null) { + mCurrentGesture.update(mousePos); + } + } + + /** + * Finish the given gesture, either from successful completion or from + * cancellation. + * + * @param mousePos The most recent mouse coordinate applicable to the new + * gesture, in control coordinates. + * @param canceled True if and only if the gesture was canceled. + */ + private void finishGesture(ControlPoint mousePos, boolean canceled) { + if (mCurrentGesture != null) { + mCurrentGesture.end(mousePos, canceled); + if (mOverlays != null) { + for (Overlay overlay : mOverlays) { + overlay.dispose(); + } + mOverlays = null; + } + mCurrentGesture = null; + mZombieGesture = null; + mLastStateMask = 0; + updateMessage(null); + updateCursor(mousePos); + mCanvas.redraw(); + } + } + + /** + * Update the cursor to show the type of operation we expect on a mouse press: + * <ul> + * <li>Over a selection handle, show a directional cursor depending on the position of + * the selection handle + * <li>Over a widget, show a move (hand) cursor + * <li>Otherwise, show the default arrow cursor + * </ul> + */ + void updateCursor(ControlPoint controlPoint) { + // We don't hover on the root since it's not a widget per see and it is always there. + SelectionManager selectionManager = mCanvas.getSelectionManager(); + + if (!selectionManager.isEmpty()) { + Display display = mCanvas.getDisplay(); + Pair<SelectionItem, SelectionHandle> handlePair = + selectionManager.findHandle(controlPoint); + if (handlePair != null) { + SelectionHandle handle = handlePair.getSecond(); + int cursorType = handle.getSwtCursorType(); + Cursor cursor = display.getSystemCursor(cursorType); + if (cursor != mCanvas.getCursor()) { + mCanvas.setCursor(cursor); + } + return; + } + + // See if it's over a selected view + LayoutPoint layoutPoint = controlPoint.toLayout(); + for (SelectionItem item : selectionManager.getSelections()) { + if (item.getRect().contains(layoutPoint.x, layoutPoint.y) + && !item.isRoot()) { + Cursor cursor = display.getSystemCursor(SWT.CURSOR_HAND); + if (cursor != mCanvas.getCursor()) { + mCanvas.setCursor(cursor); + } + return; + } + } + } + + if (mCanvas.getCursor() != null) { + mCanvas.setCursor(null); + } + } + + /** + * Update the Eclipse status message with any feedback messages from the given + * {@link DropFeedback} object, or clean up if there is no more feedback to process + * @param feedback the feedback whose message we want to display, or null to clear the + * message if previously set + */ + void updateMessage(DropFeedback feedback) { + IEditorSite editorSite = mCanvas.getEditorDelegate().getEditor().getEditorSite(); + IStatusLineManager status = editorSite.getActionBars().getStatusLineManager(); + if (feedback == null) { + if (mDisplayingMessage != null) { + status.setMessage(null); + status.setErrorMessage(null); + mDisplayingMessage = null; + } + } else if (feedback.errorMessage != null) { + if (!feedback.errorMessage.equals(mDisplayingMessage)) { + mDisplayingMessage = feedback.errorMessage; + status.setErrorMessage(mDisplayingMessage); + } + } else if (feedback.message != null) { + if (!feedback.message.equals(mDisplayingMessage)) { + mDisplayingMessage = feedback.message; + status.setMessage(mDisplayingMessage); + } + } else if (mDisplayingMessage != null) { + // TODO: Can we check the existing message and only clear it if it's the + // same as the one we set? + mDisplayingMessage = null; + status.setMessage(null); + status.setErrorMessage(null); + } + + // Tooltip + if (feedback != null && feedback.tooltip != null) { + Pair<Boolean,Boolean> position = mCurrentGesture.getTooltipPosition(); + boolean below = position.getFirst(); + if (feedback.tooltipY != null) { + below = feedback.tooltipY == SegmentType.BOTTOM; + } + boolean toRightOf = position.getSecond(); + if (feedback.tooltipX != null) { + toRightOf = feedback.tooltipX == SegmentType.RIGHT; + } + if (mTooltip == null) { + mTooltip = new GestureToolTip(mCanvas, below, toRightOf); + } + mTooltip.update(feedback.tooltip, below, toRightOf); + } else if (mTooltip != null) { + mTooltip.dispose(); + mTooltip = null; + } + } + + /** + * Returns the current mouse position as a {@link ControlPoint} + * + * @return the current mouse position as a {@link ControlPoint} + */ + public ControlPoint getCurrentControlPoint() { + return ControlPoint.create(mCanvas, mLastMouseX, mLastMouseY); + } + + /** + * Returns the current SWT modifier key mask as an {@link IViewRule} modifier mask + * + * @return the current SWT modifier key mask as an {@link IViewRule} modifier mask + */ + public int getRuleModifierMask() { + int swtMask = mLastStateMask; + int modifierMask = 0; + if ((swtMask & SWT.MOD1) != 0) { + modifierMask |= DropFeedback.MODIFIER1; + } + if ((swtMask & SWT.MOD2) != 0) { + modifierMask |= DropFeedback.MODIFIER2; + } + if ((swtMask & SWT.MOD3) != 0) { + modifierMask |= DropFeedback.MODIFIER3; + } + return modifierMask; + } + + /** + * Helper class which implements the {@link MouseMoveListener}, + * {@link MouseListener} and {@link KeyListener} interfaces. + */ + private class Listener implements MouseMoveListener, MouseListener, MouseTrackListener, + KeyListener { + + // --- MouseMoveListener --- + + @Override + public void mouseMove(MouseEvent e) { + mLastMouseX = e.x; + mLastMouseY = e.y; + mLastStateMask = e.stateMask; + + ControlPoint controlPoint = ControlPoint.create(mCanvas, e); + if ((e.stateMask & SWT.BUTTON_MASK) != 0) { + if (mCurrentGesture != null) { + updateMouse(controlPoint, e); + mCanvas.redraw(); + } + } else { + updateCursor(controlPoint); + mCanvas.hover(e); + mCanvas.getPreviewManager().moved(controlPoint); + } + } + + // --- MouseListener --- + + @Override + public void mouseUp(MouseEvent e) { + ControlPoint mousePos = ControlPoint.create(mCanvas, e); + + if (mCurrentGesture == null) { + // If clicking on a configuration preview, just process it there + if (mCanvas.getPreviewManager().click(mousePos)) { + return; + } + + // Just a click, select + Pair<SelectionItem, SelectionHandle> handlePair = + mCanvas.getSelectionManager().findHandle(mousePos); + if (handlePair == null) { + mCanvas.getSelectionManager().select(e); + } + } + if (mCurrentGesture == null) { + updateCursor(mousePos); + } else if (mCurrentGesture instanceof DropGesture) { + // Mouse Up shouldn't be delivered in the middle of a drag & drop - + // but this can happen on some versions of Linux + // (see http://code.google.com/p/android/issues/detail?id=19057 ) + // and if we process the mouseUp it will abort the remainder of + // the drag & drop operation, so ignore this event! + } else { + finishGesture(mousePos, false); + } + mCanvas.redraw(); + } + + @Override + public void mouseDown(MouseEvent e) { + mLastMouseX = e.x; + mLastMouseY = e.y; + mLastStateMask = e.stateMask; + + // Not yet used. Should be, for Mac and Linux. + } + + @Override + public void mouseDoubleClick(MouseEvent e) { + // SWT delivers a double click event even if you click two different buttons + // in rapid succession. In any case, we only want to let you double click the + // first button to warp to XML: + if (e.button == 1) { + // Warp to the text editor and show the corresponding XML for the + // double-clicked widget + LayoutPoint p = ControlPoint.create(mCanvas, e).toLayout(); + CanvasViewInfo vi = mCanvas.getViewHierarchy().findViewInfoAt(p); + if (vi != null) { + mCanvas.show(vi); + } + } + } + + // --- MouseTrackListener --- + + @Override + public void mouseEnter(MouseEvent e) { + ControlPoint mousePos = ControlPoint.create(mCanvas, e); + mCanvas.getPreviewManager().enter(mousePos); + } + + @Override + public void mouseExit(MouseEvent e) { + ControlPoint mousePos = ControlPoint.create(mCanvas, e); + mCanvas.getPreviewManager().exit(mousePos); + } + + @Override + public void mouseHover(MouseEvent e) { + } + + // --- KeyListener --- + + @Override + public void keyPressed(KeyEvent e) { + mLastStateMask = e.stateMask; + // Workaround for the fact that in keyPressed the current state + // mask is not yet updated + if (e.keyCode == SWT.SHIFT) { + mLastStateMask |= SWT.MOD2; + } + if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_DARWIN) { + if (e.keyCode == SWT.COMMAND) { + mLastStateMask |= SWT.MOD1; + } + } else { + if (e.keyCode == SWT.CTRL) { + mLastStateMask |= SWT.MOD1; + } + } + + // Give gestures a first chance to see and consume the key press + if (mCurrentGesture != null) { + // unless it's "Escape", which cancels the gesture + if (e.keyCode == SWT.ESC) { + ControlPoint controlPoint = ControlPoint.create(mCanvas, + mLastMouseX, mLastMouseY); + finishGesture(controlPoint, true); + return; + } + + if (mCurrentGesture.keyPressed(e)) { + return; + } + } + + // Fall back to canvas actions for the key press + mCanvas.handleKeyPressed(e); + } + + @Override + public void keyReleased(KeyEvent e) { + mLastStateMask = e.stateMask; + // Workaround for the fact that in keyPressed the current state + // mask is not yet updated + if (e.keyCode == SWT.SHIFT) { + mLastStateMask &= ~SWT.MOD2; + } + if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_DARWIN) { + if (e.keyCode == SWT.COMMAND) { + mLastStateMask &= ~SWT.MOD1; + } + } else { + if (e.keyCode == SWT.CTRL) { + mLastStateMask &= ~SWT.MOD1; + } + } + + if (mCurrentGesture != null) { + mCurrentGesture.keyReleased(e); + } + } + } + + /** Listener for Drag & Drop events. */ + private class CanvasDropListener implements DropTargetListener { + public CanvasDropListener() { + } + + /** + * The cursor has entered the drop target boundaries. {@inheritDoc} + */ + @Override + public void dragEnter(DropTargetEvent event) { + mCanvas.showInvisibleViews(true); + mCanvas.getEditorDelegate().getGraphicalEditor().dismissHoverPalette(); + + if (mCurrentGesture == null) { + Gesture newGesture = mZombieGesture; + if (newGesture == null) { + newGesture = new MoveGesture(mCanvas); + } else { + mZombieGesture = null; + } + startGesture(ControlPoint.create(mCanvas, event), + newGesture, 0); + } + + if (mCurrentGesture instanceof DropGesture) { + ((DropGesture) mCurrentGesture).dragEnter(event); + } + } + + /** + * The cursor is moving over the drop target. {@inheritDoc} + */ + @Override + public void dragOver(DropTargetEvent event) { + if (mCurrentGesture instanceof DropGesture) { + ((DropGesture) mCurrentGesture).dragOver(event); + } + } + + /** + * The cursor has left the drop target boundaries OR data is about to be + * dropped. {@inheritDoc} + */ + @Override + public void dragLeave(DropTargetEvent event) { + if (mCurrentGesture instanceof DropGesture) { + DropGesture dropGesture = (DropGesture) mCurrentGesture; + dropGesture.dragLeave(event); + finishGesture(ControlPoint.create(mCanvas, event), true); + mZombieGesture = dropGesture; + } + + mCanvas.showInvisibleViews(false); + } + + /** + * The drop is about to be performed. The drop target is given a last + * chance to change the nature of the drop. {@inheritDoc} + */ + @Override + public void dropAccept(DropTargetEvent event) { + Gesture gesture = mCurrentGesture != null ? mCurrentGesture : mZombieGesture; + if (gesture instanceof DropGesture) { + ((DropGesture) gesture).dropAccept(event); + } + } + + /** + * The data is being dropped. {@inheritDoc} + */ + @Override + public void drop(final DropTargetEvent event) { + // See if we had a gesture just prior to the drop (we receive a dragLeave + // right before the drop which we don't know whether means the cursor has + // left the canvas for good or just before a drop) + Gesture gesture = mCurrentGesture != null ? mCurrentGesture : mZombieGesture; + mZombieGesture = null; + + if (gesture instanceof DropGesture) { + ((DropGesture) gesture).drop(event); + + finishGesture(ControlPoint.create(mCanvas, event), true); + } + } + + /** + * The operation being performed has changed (e.g. modifier key). + * {@inheritDoc} + */ + @Override + public void dragOperationChanged(DropTargetEvent event) { + if (mCurrentGesture instanceof DropGesture) { + ((DropGesture) mCurrentGesture).dragOperationChanged(event); + } + } + } + + /** + * Our canvas {@link DragSourceListener}. Handles drag being started and + * finished and generating the drag data. + */ + private class CanvasDragSourceListener implements DragSourceListener { + + /** + * The current selection being dragged. This may be a subset of the + * canvas selection due to the "sanitize" pass. Can be empty but never + * null. + */ + private final ArrayList<SelectionItem> mDragSelection = new ArrayList<SelectionItem>(); + + private SimpleElement[] mDragElements; + + /** + * The user has begun the actions required to drag the widget. + * <p/> + * Initiate a drag only if there is one or more item selected. If + * there's none, try to auto-select the one under the cursor. + * {@inheritDoc} + */ + @Override + public void dragStart(DragSourceEvent e) { + LayoutPoint p = LayoutPoint.create(mCanvas, e); + ControlPoint controlPoint = ControlPoint.create(mCanvas, e); + SelectionManager selectionManager = mCanvas.getSelectionManager(); + + // See if the mouse is over a selection handle; if so, start a resizing + // gesture. + Pair<SelectionItem, SelectionHandle> handle = + selectionManager.findHandle(controlPoint); + if (handle != null) { + startGesture(controlPoint, new ResizeGesture(mCanvas, handle.getFirst(), + handle.getSecond()), mLastStateMask); + e.detail = DND.DROP_NONE; + e.doit = false; + mCanvas.redraw(); + return; + } + + // We need a selection (simple or multiple) to do any transfer. + // If there's a selection *and* the cursor is over this selection, + // use all the currently selected elements. + // If there is no selection or the cursor is not over a selected + // element, *change* the selection to match the element under the + // cursor and use that. If nothing can be selected, abort the drag + // operation. + List<SelectionItem> selections = selectionManager.getSelections(); + mDragSelection.clear(); + SelectionItem primary = null; + + if (!selections.isEmpty()) { + // Is the cursor on top of a selected element? + boolean insideSelection = false; + + for (SelectionItem cs : selections) { + if (!cs.isRoot() && cs.getRect().contains(p.x, p.y)) { + primary = cs; + insideSelection = true; + break; + } + } + + if (!insideSelection) { + CanvasViewInfo vi = mCanvas.getViewHierarchy().findViewInfoAt(p); + if (vi != null && !vi.isRoot() && !vi.isHidden()) { + primary = selectionManager.selectSingle(vi); + insideSelection = true; + } + } + + if (insideSelection) { + // We should now have a proper selection that matches the + // cursor. Let's use this one. We make a copy of it since + // the "sanitize" pass below might remove some of the + // selected objects. + if (selections.size() == 1) { + // You are dragging just one element - this might or + // might not be the root, but if it's the root that is + // fine since we will let you drag the root if it is the + // only thing you are dragging. + mDragSelection.addAll(selections); + } else { + // Only drag non-root items. + for (SelectionItem cs : selections) { + if (!cs.isRoot() && !cs.isHidden()) { + mDragSelection.add(cs); + } else if (cs == primary) { + primary = null; + } + } + } + } + } + + // If you are dragging a non-selected item, select it + if (mDragSelection.isEmpty()) { + CanvasViewInfo vi = mCanvas.getViewHierarchy().findViewInfoAt(p); + if (vi != null && !vi.isRoot() && !vi.isHidden()) { + primary = selectionManager.selectSingle(vi); + mDragSelection.addAll(selections); + } + } + + SelectionManager.sanitize(mDragSelection); + + e.doit = !mDragSelection.isEmpty(); + int imageCount = mDragSelection.size(); + if (e.doit) { + mDragElements = SelectionItem.getAsElements(mDragSelection, primary); + GlobalCanvasDragInfo.getInstance().startDrag(mDragElements, + mDragSelection.toArray(new SelectionItem[imageCount]), + mCanvas, new Runnable() { + @Override + public void run() { + mCanvas.getClipboardSupport().deleteSelection("Remove", + mDragSelection); + } + }); + } + + // If you drag on the -background-, we make that into a marquee + // selection + if (!e.doit || (imageCount == 1 + && (mDragSelection.get(0).isRoot() || mDragSelection.get(0).isHidden()))) { + boolean toggle = (mLastStateMask & (SWT.CTRL | SWT.SHIFT | SWT.COMMAND)) != 0; + startGesture(controlPoint, + new MarqueeGesture(mCanvas, toggle), mLastStateMask); + e.detail = DND.DROP_NONE; + e.doit = false; + } else { + // Otherwise, the drag means you are moving something + mCanvas.showInvisibleViews(true); + startGesture(controlPoint, new MoveGesture(mCanvas), 0); + + // Render drag-images: Copy portions of the full screen render. + Image image = mCanvas.getImageOverlay().getImage(); + if (image != null) { + /** + * Transparency of the dragged image ([0-255]). We're using 30% + * translucency to make the image faint and not obscure the drag + * feedback below it. + */ + final byte DRAG_TRANSPARENCY = (byte) (0.3 * 255); + + List<Rectangle> rectangles = new ArrayList<Rectangle>(imageCount); + if (imageCount > 0) { + ImageData data = image.getImageData(); + Rectangle imageRectangle = new Rectangle(0, 0, data.width, data.height); + for (SelectionItem item : mDragSelection) { + Rectangle bounds = item.getRect(); + // Some bounds can be outside the rendered rectangle (for + // example, in an absolute layout, you can have negative + // coordinates), so create the intersection of these bounds. + Rectangle clippedBounds = imageRectangle.intersection(bounds); + rectangles.add(clippedBounds); + } + Rectangle boundingBox = ImageUtils.getBoundingRectangle(rectangles); + double scale = mCanvas.getHorizontalTransform().getScale(); + e.image = SwtUtils.drawRectangles(image, rectangles, boundingBox, scale, + DRAG_TRANSPARENCY); + + // Set the image offset such that we preserve the relative + // distance between the mouse pointer and the top left corner of + // the dragged view + int deltaX = (int) (scale * (boundingBox.x - p.x)); + int deltaY = (int) (scale * (boundingBox.y - p.y)); + e.offsetX = -deltaX; + e.offsetY = -deltaY; + + // View rules may need to know it as well + GlobalCanvasDragInfo dragInfo = GlobalCanvasDragInfo.getInstance(); + Rect dragBounds = null; + int width = (int) (scale * boundingBox.width); + int height = (int) (scale * boundingBox.height); + dragBounds = new Rect(deltaX, deltaY, width, height); + dragInfo.setDragBounds(dragBounds); + + // Record the baseline such that we can perform baseline alignment + // on the node as it's dragged around + NodeProxy firstNode = + mCanvas.getNodeFactory().create(mDragSelection.get(0).getViewInfo()); + dragInfo.setDragBaseline(firstNode.getBaseline()); + } + } + } + + // No hover during drag (since no mouse over events are delivered + // during a drag to keep the hovers up to date anyway) + mCanvas.clearHover(); + + mCanvas.redraw(); + } + + /** + * Callback invoked when data is needed for the event, typically right + * before drop. The drop side decides what type of transfer to use and + * this side must now provide the adequate data. {@inheritDoc} + */ + @Override + public void dragSetData(DragSourceEvent e) { + if (TextTransfer.getInstance().isSupportedType(e.dataType)) { + e.data = SelectionItem.getAsText(mCanvas, mDragSelection); + return; + } + + if (SimpleXmlTransfer.getInstance().isSupportedType(e.dataType)) { + e.data = mDragElements; + return; + } + + // otherwise we failed + e.detail = DND.DROP_NONE; + e.doit = false; + } + + /** + * Callback invoked when the drop has been finished either way. On a + * successful move, remove the originating elements. + */ + @Override + public void dragFinished(DragSourceEvent e) { + // Clear the selection + mDragSelection.clear(); + mDragElements = null; + GlobalCanvasDragInfo.getInstance().stopDrag(); + + finishGesture(ControlPoint.create(mCanvas, e), e.detail == DND.DROP_NONE); + mCanvas.showInvisibleViews(false); + mCanvas.redraw(); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GestureToolTip.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GestureToolTip.java new file mode 100644 index 000000000..a49e79cbf --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GestureToolTip.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CLabel; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; + +/** + * A dedicated tooltip used during gestures, for example to show the resize dimensions. + * <p> + * This is necessary because {@link org.eclipse.jface.window.ToolTip} causes flicker when + * used to dynamically update the position and text of the tip, and it does not seem to + * have setter methods to update the text or position without recreating the tip. + */ +public class GestureToolTip { + /** Minimum number of milliseconds to wait between alignment changes */ + private static final int TIMEOUT_MS = 750; + + /** + * The alpha to use for the tooltip window (which sadly will apply to the tooltip text + * as well.) + */ + private static final int SHELL_TRANSPARENCY = 220; + + /** The size of the font displayed in the tooltip */ + private static final int FONT_SIZE = 9; + + /** Horizontal delta from the mouse cursor to shift the tooltip by */ + private static final int OFFSET_X = 20; + + /** Vertical delta from the mouse cursor to shift the tooltip by */ + private static final int OFFSET_Y = 20; + + /** The label which displays the tooltip */ + private CLabel mLabel; + + /** The shell holding the tooltip */ + private Shell mShell; + + /** The font shown in the label; held here such that it can be disposed of after use */ + private Font mFont; + + /** Is the tooltip positioned below the given anchor? */ + private boolean mBelow; + + /** Is the tooltip positioned to the right of the given anchor? */ + private boolean mToRightOf; + + /** Is an alignment change pending? */ + private boolean mTimerPending; + + /** The new value for {@link #mBelow} when the timer expires */ + private boolean mPendingBelow; + + /** The new value for {@link #mToRightOf} when the timer expires */ + private boolean mPendingRight; + + /** The time stamp (from {@link System#currentTimeMillis()} of the last alignment change */ + private long mLastAlignmentTime; + + /** + * Creates a new tooltip over the given parent with the given relative position. + * + * @param parent the parent control + * @param below if true, display the tooltip below the mouse cursor otherwise above + * @param toRightOf if true, display the tooltip to the right of the mouse cursor, + * otherwise to the left + */ + public GestureToolTip(Composite parent, boolean below, boolean toRightOf) { + mBelow = below; + mToRightOf = toRightOf; + mLastAlignmentTime = System.currentTimeMillis(); + + mShell = new Shell(parent.getShell(), SWT.ON_TOP | SWT.TOOL | SWT.NO_FOCUS); + mShell.setLayout(new FillLayout()); + mShell.setAlpha(SHELL_TRANSPARENCY); + + Display display = parent.getDisplay(); + mLabel = new CLabel(mShell, SWT.SHADOW_NONE); + mLabel.setBackground(display.getSystemColor(SWT.COLOR_INFO_BACKGROUND)); + mLabel.setForeground(display.getSystemColor(SWT.COLOR_INFO_FOREGROUND)); + + Font systemFont = display.getSystemFont(); + FontData[] fd = systemFont.getFontData(); + for (int i = 0; i < fd.length; i++) { + fd[i].setHeight(FONT_SIZE); + } + mFont = new Font(display, fd); + mLabel.setFont(mFont); + + mShell.setVisible(false); + } + + /** + * Show the tooltip at the given position and with the given text. Note that the + * position may not be applied immediately; to prevent flicker alignment changes + * are queued up with a timer (unless it's been a while since the last change, in + * which case the update is applied immediately.) + * + * @param text the new text to be displayed + * @param below if true, display the tooltip below the mouse cursor otherwise above + * @param toRightOf if true, display the tooltip to the right of the mouse cursor, + * otherwise to the left + */ + public void update(final String text, boolean below, boolean toRightOf) { + // If the alignment has not changed recently, just apply the change immediately + // instead of within a delay + if (!mTimerPending && (below != mBelow || toRightOf != mToRightOf) + && (System.currentTimeMillis() - mLastAlignmentTime >= TIMEOUT_MS)) { + mBelow = below; + mToRightOf = toRightOf; + mLastAlignmentTime = System.currentTimeMillis(); + } + + Point location = mShell.getDisplay().getCursorLocation(); + + mLabel.setText(text); + + // Pack the label to its minimum size -- unless we are positioning the tooltip + // on the left. Because of the way SWT works (at least on the OSX) this sometimes + // creates flicker, because when we switch to a longer string (such as when + // switching from "52dp" to "wrap_content" during a resize) the window size will + // change first, and then the location will update later - so there will be a + // brief flash of the longer label before it is moved to the right position on the + // left. To work around this, we simply pass false to pack such that it will reuse + // its cached size, which in practice means that for labels on the right, the + // label will grow but not shrink. + // This workaround is disabled because it doesn't work well in Eclipse 3.5; the + // labels don't grow when they should. Re-enable when we drop 3.5 support. + //boolean changed = mToRightOf; + boolean changed = true; + + mShell.pack(changed); + Point size = mShell.getSize(); + + // Position the tooltip to the left or right, and above or below, according + // to the saved state of these flags, not the current parameters. We don't want + // to flicker, instead we react on a timer to changes in alignment below. + if (mBelow) { + location.y += OFFSET_Y; + } else { + location.y -= OFFSET_Y; + location.y -= size.y; + } + + if (mToRightOf) { + location.x += OFFSET_X; + } else { + location.x -= OFFSET_X; + location.x -= size.x; + } + + mShell.setLocation(location); + + if (!mShell.isVisible()) { + mShell.setVisible(true); + } + + // Has the orientation changed? + mPendingBelow = below; + mPendingRight = toRightOf; + if (below != mBelow || toRightOf != mToRightOf) { + // Yes, so schedule a timer (unless one is already scheduled) + if (!mTimerPending) { + mTimerPending = true; + final Runnable timer = new Runnable() { + @Override + public void run() { + mTimerPending = false; + // Check whether the alignment is still different than the target + // (since we may change back and forth repeatedly during the timeout) + if (mBelow != mPendingBelow || mToRightOf != mPendingRight) { + mBelow = mPendingBelow; + mToRightOf = mPendingRight; + mLastAlignmentTime = System.currentTimeMillis(); + if (mShell != null && mShell.isVisible()) { + update(text, mBelow, mToRightOf); + } + } + } + }; + mShell.getDisplay().timerExec(TIMEOUT_MS, timer); + } + } + } + + /** Hide the tooltip and dispose of any associated resources */ + public void dispose() { + mShell.dispose(); + mFont.dispose(); + + mShell = null; + mFont = null; + mLabel = null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GlobalCanvasDragInfo.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GlobalCanvasDragInfo.java new file mode 100644 index 000000000..b918b00bf --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GlobalCanvasDragInfo.java @@ -0,0 +1,182 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.Rect; + + +/** + * This singleton is used to keep track of drag'n'drops initiated within this + * session of Eclipse. A drag can be initiated from a palette or from a canvas + * and its content is an Android View fully-qualified class name. + * <p/> + * Overall this is a workaround: the issue is that the drag'n'drop SWT API does not + * allow us to know the transfered data during the initial drag -- only when the + * data is dropped do we know what it is about (and to be more exact there is a workaround + * to do just that which works on Windows but not on Linux/Mac SWT). + * <p/> + * In the GLE we'd like to adjust drag feedback to the data being actually dropped. + * The singleton instance of this class will be used to track the data currently dragged + * off a canvas or its palette and then set back to null when the drag'n'drop is finished. + * <p/> + * Note that when a drag starts in one instance of Eclipse and the dragOver/drop is done + * in a <em>separate</em> instance of Eclipse, the dragged FQCN won't be registered here + * and will be null. + */ +final class GlobalCanvasDragInfo { + + private static final GlobalCanvasDragInfo sInstance = new GlobalCanvasDragInfo(); + + private SimpleElement[] mCurrentElements = null; + private SelectionItem[] mCurrentSelection; + private Object mSourceCanvas = null; + private Runnable mRemoveSourceHandler; + private Rect mDragBounds; + private int mDragBaseline = -1; + + /** Private constructor. Use {@link #getInstance()} to retrieve the singleton. */ + private GlobalCanvasDragInfo() { + // pass + } + + /** Returns the singleton instance. */ + public static GlobalCanvasDragInfo getInstance() { + return sInstance; + } + + /** + * Registers the XML elements being dragged. + * + * @param elements The elements being dragged + * @param primary the "primary" element among the elements; when there is a + * single item dragged this will be the same, but in + * multi-selection it will be the element under the mouse as the + * selection was initiated + * @param selection The selection (which can be null, for example when the + * user drags from the palette) + * @param sourceCanvas An object representing the source we are dragging + * from (used for identity comparisons only) + * @param removeSourceHandler A runnable (or null) which can clean up the + * source. It should only be invoked if the drag operation is a + * move, not a copy. + */ + public void startDrag( + @NonNull SimpleElement[] elements, + @Nullable SelectionItem[] selection, + @Nullable Object sourceCanvas, + @Nullable Runnable removeSourceHandler) { + mCurrentElements = elements; + mCurrentSelection = selection; + mSourceCanvas = sourceCanvas; + mRemoveSourceHandler = removeSourceHandler; + } + + /** Unregisters elements being dragged. */ + public void stopDrag() { + mCurrentElements = null; + mCurrentSelection = null; + mSourceCanvas = null; + mRemoveSourceHandler = null; + mDragBounds = null; + } + + public boolean isDragging() { + return mCurrentElements != null; + } + + /** Returns the elements being dragged. */ + @NonNull + public SimpleElement[] getCurrentElements() { + return mCurrentElements; + } + + /** Returns the selection originally dragged. + * Can be null if the drag did not start in a canvas. + */ + public SelectionItem[] getCurrentSelection() { + return mCurrentSelection; + } + + /** + * Returns the object that call {@link #startDrag(SimpleElement[], SelectionItem[], Object)}. + * Can be null. + * This is not meant to access the object indirectly, it is just meant to compare if the + * source and the destination of the drag'n'drop are the same, so object identity + * is all what matters. + */ + public Object getSourceCanvas() { + return mSourceCanvas; + } + + /** + * Removes source of the drag. This should only be called when the drag and + * drop operation is a move (not a copy). + */ + public void removeSource() { + if (mRemoveSourceHandler != null) { + mRemoveSourceHandler.run(); + mRemoveSourceHandler = null; + } + } + + /** + * Get the bounds of the drag, relative to the starting mouse position. For example, + * if you have a rectangular view of size 100x80, and you start dragging at position + * (15,20) from the top left corner of this rectangle, then the drag bounds would be + * (-15,-20, 100x80). + * <p> + * NOTE: The coordinate units will be in SWT/control pixels, not Android view pixels. + * In other words, they are affected by the canvas zoom: If you zoom the view and the + * bounds of a view grow, the drag bounds will be larger. + * + * @return the drag bounds, or null if there are no bounds for the current drag + */ + public Rect getDragBounds() { + return mDragBounds; + } + + /** + * Set the bounds of the drag, relative to the starting mouse position. See + * {@link #getDragBounds()} for details on the semantics of the drag bounds. + * + * @param dragBounds the new drag bounds, or null if there are no drag bounds + */ + public void setDragBounds(Rect dragBounds) { + mDragBounds = dragBounds; + } + + /** + * Returns the baseline of the drag, or -1 if not applicable + * + * @return the current SWT modifier key mask as an {@link IViewRule} modifier mask + */ + public int getDragBaseline() { + return mDragBaseline; + } + + /** + * Sets the baseline of the drag + * + * @param baseline the new baseline + */ + public void setDragBaseline(int baseline) { + mDragBaseline = baseline; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GraphicalEditorPart.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GraphicalEditorPart.java new file mode 100644 index 000000000..0f5762da6 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/GraphicalEditorPart.java @@ -0,0 +1,2937 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_PKG; +import static com.android.SdkConstants.ANDROID_STRING_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_CONTEXT; +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.FD_GEN_SOURCES; +import static com.android.SdkConstants.GRID_LAYOUT; +import static com.android.SdkConstants.SCROLL_VIEW; +import static com.android.SdkConstants.STRING_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_WRAP_CONTENT; +import static com.android.ide.common.rendering.RenderSecurityManager.ENABLED_PROPERTY; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_DEVICE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_DEVICE_STATE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_FOLDER; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_TARGET; +import static com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor.viewNeedsPackage; +import static org.eclipse.wb.core.controls.flyout.IFlyoutPreferences.DOCK_EAST; +import static org.eclipse.wb.core.controls.flyout.IFlyoutPreferences.DOCK_WEST; +import static org.eclipse.wb.core.controls.flyout.IFlyoutPreferences.STATE_COLLAPSED; +import static org.eclipse.wb.core.controls.flyout.IFlyoutPreferences.STATE_OPEN; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.common.rendering.LayoutLibrary; +import com.android.ide.common.rendering.RenderSecurityException; +import com.android.ide.common.rendering.RenderSecurityManager; +import com.android.ide.common.rendering.StaticRenderSession; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.LayoutLog; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.Result; +import com.android.ide.common.rendering.api.SessionParams.RenderingMode; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.sdk.LoadStatus; +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.IPageImageProvider; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlDelegate; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutReloadMonitor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutReloadMonitor.ChangeFlags; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutReloadMonitor.ILayoutReloadListener; +import com.android.ide.eclipse.adt.internal.editors.layout.ProjectCallback; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationClient; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationMatcher; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.LayoutCreatorDialog; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.PaletteControl.PalettePage; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.editors.layout.properties.PropertyFactory; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.ide.eclipse.adt.internal.sdk.Sdk.ITargetChangeListener; +import com.android.resources.Density; +import com.android.resources.ResourceFolderType; +import com.android.resources.ResourceType; +import com.android.sdklib.IAndroidTarget; +import com.android.tools.lint.detector.api.LintUtils; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Path; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.IClasspathEntry; +import org.eclipse.jdt.core.IJavaElement; +import org.eclipse.jdt.core.IJavaModelMarker; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IPackageFragment; +import org.eclipse.jdt.core.IPackageFragmentRoot; +import org.eclipse.jdt.core.JavaCore; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.internal.ui.preferences.BuildPathsPropertyPage; +import org.eclipse.jdt.ui.actions.OpenNewClassWizardAction; +import org.eclipse.jdt.ui.wizards.NewClassWizardPage; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.source.ISourceViewer; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.ISelectionProvider; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.SashForm; +import org.eclipse.swt.custom.StyleRange; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.text.edits.MalformedTreeException; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.ui.IActionBars; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IEditorSite; +import org.eclipse.ui.INullSelectionListener; +import org.eclipse.ui.ISelectionListener; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.IWorkbenchPartSite; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.dialogs.PreferencesUtil; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.part.EditorPart; +import org.eclipse.ui.part.FileEditorInput; +import org.eclipse.ui.part.IPageSite; +import org.eclipse.ui.part.PageBookView; +import org.eclipse.wb.core.controls.flyout.FlyoutControlComposite; +import org.eclipse.wb.core.controls.flyout.IFlyoutListener; +import org.eclipse.wb.core.controls.flyout.PluginFlyoutPreferences; +import org.eclipse.wb.internal.core.editor.structure.PageSiteComposite; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Graphical layout editor part, version 2. + * <p/> + * The main component of the editor part is the {@link LayoutCanvasViewer}, which + * actually delegates its work to the {@link LayoutCanvas} control. + * <p/> + * The {@link LayoutCanvasViewer} is set as the site's {@link ISelectionProvider}: + * when the selection changes in the canvas, it is thus broadcasted to anyone listening + * on the site's selection service. + * <p/> + * This part is also an {@link ISelectionListener}. It listens to the site's selection + * service and thus receives selection changes from itself as well as the associated + * outline and property sheet (these are registered by {@link LayoutEditorDelegate#delegateGetAdapter(Class)}). + * + * @since GLE2 + */ +public class GraphicalEditorPart extends EditorPart + implements IPageImageProvider, INullSelectionListener, IFlyoutListener, + ConfigurationClient { + + /* + * Useful notes: + * To understand Drag & drop: + * http://www.eclipse.org/articles/Article-Workbench-DND/drag_drop.html + * + * To understand the site's selection listener, selection provider, and the + * confusion of different-yet-similarly-named interfaces, consult this: + * http://www.eclipse.org/articles/Article-WorkbenchSelections/article.html + * + * To summarize the selection mechanism: + * - The workbench site selection service can be seen as "centralized" + * service that registers selection providers and selection listeners. + * - The editor part and the outline are selection providers. + * - The editor part, the outline and the property sheet are listeners + * which all listen to each others indirectly. + */ + + /** Property key for the window preferences for the structure flyout */ + private static final String PREF_STRUCTURE = "design.structure"; //$NON-NLS-1$ + + /** Property key for the window preferences for the palette flyout */ + private static final String PREF_PALETTE = "design.palette"; //$NON-NLS-1$ + + /** + * Session-property on files which specifies the initial config state to be used on + * this file + */ + public final static QualifiedName NAME_INITIAL_STATE = + new QualifiedName(AdtPlugin.PLUGIN_ID, "initialstate");//$NON-NLS-1$ + + /** + * Session-property on files which specifies the inclusion-context (reference to another layout + * which should be "including" this layout) when the file is opened + */ + public final static QualifiedName NAME_INCLUDE = + new QualifiedName(AdtPlugin.PLUGIN_ID, "includer");//$NON-NLS-1$ + + /** Reference to the layout editor */ + private final LayoutEditorDelegate mEditorDelegate; + + /** Reference to the file being edited. Can also be used to access the {@link IProject}. */ + private IFile mEditedFile; + + /** The configuration chooser at the top of the layout editor. */ + private ConfigurationChooser mConfigChooser; + + /** The sash that splits the palette from the error view. + * The error view is shown only when needed. */ + private SashForm mSashError; + + /** The palette displayed on the left of the sash. */ + private PaletteControl mPalette; + + /** The layout canvas displayed to the right of the sash. */ + private LayoutCanvasViewer mCanvasViewer; + + /** The Rules Engine associated with this editor. It is project-specific. */ + private RulesEngine mRulesEngine; + + /** Styled text displaying the most recent error in the error view. */ + private StyledText mErrorLabel; + + /** + * The resource reference to a file that should surround this file (e.g. include this file + * visually), or null if not applicable + */ + private Reference mIncludedWithin; + + private Map<ResourceType, Map<String, ResourceValue>> mConfiguredFrameworkRes; + private Map<ResourceType, Map<String, ResourceValue>> mConfiguredProjectRes; + private ProjectCallback mProjectCallback; + private boolean mNeedsRecompute = false; + private TargetListener mTargetListener; + private ResourceResolver mResourceResolver; + private ReloadListener mReloadListener; + private int mMinSdkVersion; + private int mTargetSdkVersion; + private LayoutActionBar mActionBar; + private OutlinePage mOutlinePage; + private FlyoutControlComposite mStructureFlyout; + private FlyoutControlComposite mPaletteComposite; + private PropertyFactory mPropertyFactory; + private boolean mRenderedOnce; + private final Object mCredential = new Object(); + + /** + * Flags which tracks whether this editor is currently active which is set whenever + * {@link #activated()} is called and clear whenever {@link #deactivated()} is called. + * This is used to suppress repeated calls to {@link #activate()} to avoid doing + * unnecessary work. + */ + private boolean mActive; + + /** + * Constructs a new {@link GraphicalEditorPart} + * + * @param editorDelegate the associated XML editor delegate + */ + public GraphicalEditorPart(@NonNull LayoutEditorDelegate editorDelegate) { + mEditorDelegate = editorDelegate; + setPartName("Graphical Layout"); + } + + // ------------------------------------ + // Methods overridden from base classes + //------------------------------------ + + /** + * Initializes the editor part with a site and input. + * {@inheritDoc} + */ + @Override + public void init(IEditorSite site, IEditorInput input) throws PartInitException { + setSite(site); + useNewEditorInput(input); + + if (mTargetListener == null) { + mTargetListener = new TargetListener(); + AdtPlugin.getDefault().addTargetListener(mTargetListener); + + // Trigger a check to see if the SDK needs to be reloaded (which will + // invoke onSdkLoaded asynchronously as needed). + AdtPlugin.getDefault().refreshSdk(); + } + } + + private void useNewEditorInput(IEditorInput input) throws PartInitException { + // The contract of init() mentions we need to fail if we can't understand the input. + if (!(input instanceof FileEditorInput)) { + throw new PartInitException("Input is not of type FileEditorInput: " + //$NON-NLS-1$ + input == null ? "null" : input.toString()); //$NON-NLS-1$ + } + } + + @Override + public Image getPageImage() { + return IconFactory.getInstance().getIcon("editor_page_design"); //$NON-NLS-1$ + } + + @Override + public void createPartControl(Composite parent) { + + Display d = parent.getDisplay(); + + GridLayout gl = new GridLayout(1, false); + parent.setLayout(gl); + gl.marginHeight = gl.marginWidth = 0; + + // Check whether somebody has requested an initial state for the newly opened file. + // The initial state is a serialized version of the state compatible with + // {@link ConfigurationComposite#CONFIG_STATE}. + String initialState = null; + IFile file = mEditedFile; + if (file == null) { + IEditorInput input = mEditorDelegate.getEditor().getEditorInput(); + if (input instanceof FileEditorInput) { + file = ((FileEditorInput) input).getFile(); + } + } + + if (file != null) { + try { + initialState = (String) file.getSessionProperty(NAME_INITIAL_STATE); + if (initialState != null) { + // Only use once + file.setSessionProperty(NAME_INITIAL_STATE, null); + } + } catch (CoreException e) { + AdtPlugin.log(e, "Can't read session property %1$s", NAME_INITIAL_STATE); + } + } + + IPreferenceStore preferenceStore = AdtPlugin.getDefault().getPreferenceStore(); + PluginFlyoutPreferences preferences; + preferences = new PluginFlyoutPreferences(preferenceStore, PREF_PALETTE); + preferences.initializeDefaults(DOCK_WEST, STATE_OPEN, 200); + mPaletteComposite = new FlyoutControlComposite(parent, SWT.NONE, preferences); + mPaletteComposite.setTitleText("Palette"); + mPaletteComposite.setMinWidth(100); + Composite paletteParent = mPaletteComposite.getFlyoutParent(); + Composite editorParent = mPaletteComposite.getClientParent(); + mPaletteComposite.setListener(this); + + mPaletteComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); + + PageSiteComposite paletteComposite = new PageSiteComposite(paletteParent, SWT.BORDER); + paletteComposite.setTitleText("Palette"); + paletteComposite.setTitleImage(IconFactory.getInstance().getIcon("palette")); + PalettePage decor = new PalettePage(this); + paletteComposite.setPage(decor); + mPalette = (PaletteControl) decor.getControl(); + decor.createToolbarItems(paletteComposite.getToolBar()); + + // Create the shared structure+editor area + preferences = new PluginFlyoutPreferences(preferenceStore, PREF_STRUCTURE); + preferences.initializeDefaults(DOCK_EAST, STATE_OPEN, 300); + mStructureFlyout = new FlyoutControlComposite(editorParent, SWT.NONE, preferences); + mStructureFlyout.setTitleText("Structure"); + mStructureFlyout.setMinWidth(150); + mStructureFlyout.setListener(this); + + Composite layoutBarAndCanvas = new Composite(mStructureFlyout.getClientParent(), SWT.NONE); + GridLayout gridLayout = new GridLayout(1, false); + gridLayout.horizontalSpacing = 0; + gridLayout.verticalSpacing = 0; + gridLayout.marginWidth = 0; + gridLayout.marginHeight = 0; + layoutBarAndCanvas.setLayout(gridLayout); + + mConfigChooser = new ConfigurationChooser(this, layoutBarAndCanvas, initialState); + mConfigChooser.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mActionBar = new LayoutActionBar(layoutBarAndCanvas, SWT.NONE, this); + GridData detailsData = new GridData(SWT.FILL, SWT.FILL, true, false, 1, 1); + mActionBar.setLayoutData(detailsData); + if (file != null) { + mActionBar.updateErrorIndicator(file); + } + + mSashError = new SashForm(layoutBarAndCanvas, SWT.VERTICAL | SWT.BORDER); + mSashError.setLayoutData(new GridData(GridData.FILL_BOTH)); + + mCanvasViewer = new LayoutCanvasViewer(mEditorDelegate, mRulesEngine, mSashError, SWT.NONE); + mSashError.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); + + mErrorLabel = new StyledText(mSashError, SWT.READ_ONLY | SWT.WRAP | SWT.V_SCROLL); + mErrorLabel.setEditable(false); + mErrorLabel.setBackground(d.getSystemColor(SWT.COLOR_INFO_BACKGROUND)); + mErrorLabel.setForeground(d.getSystemColor(SWT.COLOR_INFO_FOREGROUND)); + mErrorLabel.addMouseListener(new ErrorLabelListener()); + + mSashError.setWeights(new int[] { 80, 20 }); + mSashError.setMaximizedControl(mCanvasViewer.getControl()); + + // Create the structure views. We really should do this *lazily*, but that + // seems to cause a bug: property sheet won't update. Track this down later. + createStructureViews(mStructureFlyout.getFlyoutParent(), false); + showStructureViews(false, false, false); + + // Initialize the state + reloadPalette(); + + IWorkbenchPartSite site = getSite(); + site.setSelectionProvider(mCanvasViewer); + site.getPage().addSelectionListener(this); + } + + private void createStructureViews(Composite parent, boolean createPropertySheet) { + mOutlinePage = new OutlinePage(this); + mOutlinePage.setShowPropertySheet(createPropertySheet); + mOutlinePage.setShowHeader(true); + + IPageSite pageSite = new IPageSite() { + + @Override + public IWorkbenchPage getPage() { + return getSite().getPage(); + } + + @Override + public ISelectionProvider getSelectionProvider() { + return getSite().getSelectionProvider(); + } + + @Override + public Shell getShell() { + return getSite().getShell(); + } + + @Override + public IWorkbenchWindow getWorkbenchWindow() { + return getSite().getWorkbenchWindow(); + } + + @Override + public void setSelectionProvider(ISelectionProvider provider) { + getSite().setSelectionProvider(provider); + } + + @Override + public Object getAdapter(Class adapter) { + return getSite().getAdapter(adapter); + } + + @Override + public Object getService(Class api) { + return getSite().getService(api); + } + + @Override + public boolean hasService(Class api) { + return getSite().hasService(api); + } + + @Override + public void registerContextMenu(String menuId, MenuManager menuManager, + ISelectionProvider selectionProvider) { + } + + @Override + public IActionBars getActionBars() { + return null; + } + }; + mOutlinePage.init(pageSite); + mOutlinePage.createControl(parent); + mOutlinePage.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + getCanvasControl().getSelectionManager().setSelection(event.getSelection()); + } + }); + } + + /** Shows the embedded (within the layout editor) outline and or properties */ + void showStructureViews(final boolean showOutline, final boolean showProperties, + final boolean updateLayout) { + Display display = mConfigChooser.getDisplay(); + if (display.getThread() != Thread.currentThread()) { + display.asyncExec(new Runnable() { + @Override + public void run() { + if (!mConfigChooser.isDisposed()) { + showStructureViews(showOutline, showProperties, updateLayout); + } + } + + }); + return; + } + + boolean show = showOutline || showProperties; + + Control[] children = mStructureFlyout.getFlyoutParent().getChildren(); + if (children.length == 0) { + if (show) { + createStructureViews(mStructureFlyout.getFlyoutParent(), showProperties); + } + return; + } + + mOutlinePage.setShowPropertySheet(showProperties); + + Control control = children[0]; + if (show != control.getVisible()) { + control.setVisible(show); + mOutlinePage.setActive(show); // disable/re-enable listeners etc + if (show) { + ISelection selection = getCanvasControl().getSelectionManager().getSelection(); + mOutlinePage.selectionChanged(getEditorDelegate().getEditor(), selection); + } + if (updateLayout) { + mStructureFlyout.layout(); + } + // TODO: *dispose* the non-showing widgets to save memory? + } + } + + /** + * Returns the property factory associated with this editor + * + * @return the factory + */ + @NonNull + public PropertyFactory getPropertyFactory() { + if (mPropertyFactory == null) { + mPropertyFactory = new PropertyFactory(this); + } + + return mPropertyFactory; + } + + /** + * Invoked by {@link LayoutCanvas} to set the model (a.k.a. the root view info). + * + * @param rootViewInfo The root of the view info hierarchy. Can be null. + */ + public void setModel(CanvasViewInfo rootViewInfo) { + if (mOutlinePage != null) { + mOutlinePage.setModel(rootViewInfo); + } + } + + /** + * Listens to workbench selections that does NOT come from {@link LayoutEditorDelegate} + * (those are generated by ourselves). + * <p/> + * Selection can be null, as indicated by this class implementing + * {@link INullSelectionListener}. + */ + @Override + public void selectionChanged(IWorkbenchPart part, ISelection selection) { + Object delegate = part instanceof IEditorPart ? + LayoutEditorDelegate.fromEditor((IEditorPart) part) : null; + if (delegate == null) { + if (part instanceof PageBookView) { + PageBookView pbv = (PageBookView) part; + org.eclipse.ui.part.IPage currentPage = pbv.getCurrentPage(); + if (currentPage instanceof OutlinePage) { + LayoutCanvas canvas = getCanvasControl(); + if (canvas != null && canvas.getOutlinePage() != currentPage) { + // The notification is not for this view; ignore + // (can happen when there are multiple pages simultaneously + // visible) + return; + } + } + } + mCanvasViewer.setSelection(selection); + } + } + + @Override + public void dispose() { + getSite().getPage().removeSelectionListener(this); + getSite().setSelectionProvider(null); + + if (mTargetListener != null) { + AdtPlugin.getDefault().removeTargetListener(mTargetListener); + mTargetListener = null; + } + + if (mReloadListener != null) { + LayoutReloadMonitor.getMonitor().removeListener(mReloadListener); + mReloadListener = null; + } + + if (mCanvasViewer != null) { + mCanvasViewer.dispose(); + mCanvasViewer = null; + } + super.dispose(); + } + + /** + * Select the visual element corresponding to the given XML node + * @param xmlNode The Node whose element we want to select + */ + public void select(Node xmlNode) { + mCanvasViewer.getCanvas().getSelectionManager().select(xmlNode); + } + + // ---- Implements ConfigurationClient ---- + @Override + public void aboutToChange(int flags) { + if ((flags & CFG_TARGET) != 0) { + IAndroidTarget oldTarget = mConfigChooser.getConfiguration().getTarget(); + preRenderingTargetChangeCleanUp(oldTarget); + } + } + + @Override + public boolean changed(int flags) { + mConfiguredFrameworkRes = mConfiguredProjectRes = null; + mResourceResolver = null; + + if (mEditedFile == null) { + return true; + } + + // Before doing the normal process, test for the following case. + // - the editor is being opened (or reset for a new input) + // - the file being opened is not the best match for any possible configuration + // - another random compatible config was chosen in the config composite. + // The result is that 'match' will not be the file being edited, but because this is not + // due to a config change, we should not trigger opening the actual best match (also, + // because the editor is still opening the MatchingStrategy woudln't answer true + // and the best match file would open in a different editor). + // So the solution is that if the editor is being created, we just call recomputeLayout + // without looking for a better matching layout file. + if (mEditorDelegate.getEditor().isCreatingPages()) { + recomputeLayout(); + } else { + boolean affectsFileSelection = (flags & Configuration.MASK_FILE_ATTRS) != 0; + IFile best = null; + // get the resources of the file's project. + if (affectsFileSelection) { + best = ConfigurationMatcher.getBestFileMatch(mConfigChooser); + } + if (best != null) { + if (!best.equals(mEditedFile)) { + try { + // tell the editor that the next replacement file is due to a config + // change. + mEditorDelegate.setNewFileOnConfigChange(true); + + boolean reuseEditor = AdtPrefs.getPrefs().isSharedLayoutEditor(); + if (!reuseEditor) { + String data = ConfigurationDescription.getDescription(best); + if (data == null) { + // Not previously opened: duplicate the current state as + // much as possible + data = mConfigChooser.getConfiguration().toPersistentString(); + ConfigurationDescription.setDescription(best, data); + } + } + + // ask the IDE to open the replacement file. + IDE.openEditor(getSite().getWorkbenchWindow().getActivePage(), best, + CommonXmlEditor.ID); + + // we're done! + return reuseEditor; + } catch (PartInitException e) { + // FIXME: do something! + } + } + + // at this point, we have not opened a new file. + + // Store the state in the current file + mConfigChooser.saveConstraints(); + + // Even though the layout doesn't change, the config changed, and referenced + // resources need to be updated. + recomputeLayout(); + } else if (affectsFileSelection) { + // display the error. + Configuration configuration = mConfigChooser.getConfiguration(); + FolderConfiguration currentConfig = configuration.getFullConfig(); + displayError( + "No resources match the configuration\n" + + " \n" + + "\t%1$s\n" + + " \n" + + "Change the configuration or create:\n" + + " \n" + + "\tres/%2$s/%3$s\n" + + " \n" + + "You can also click the 'Create New...' item in the configuration " + + "dropdown menu above.", + currentConfig.toDisplayString(), + currentConfig.getFolderName(ResourceFolderType.LAYOUT), + mEditedFile.getName()); + } else { + // Something else changed, such as the theme - just recompute existing + // layout + mConfigChooser.saveConstraints(); + recomputeLayout(); + } + } + + if ((flags & CFG_TARGET) != 0) { + Configuration configuration = mConfigChooser.getConfiguration(); + IAndroidTarget target = configuration.getTarget(); + Sdk current = Sdk.getCurrent(); + if (current != null) { + AndroidTargetData targetData = current.getTargetData(target); + updateCapabilities(targetData); + } + } + + if ((flags & (CFG_DEVICE | CFG_DEVICE_STATE)) != 0) { + // When the device changes, zoom the view to fit, but only up to 100% (e.g. zoom + // out to fit the content, or zoom back in if we were zoomed out more from the + // previous view, but only up to 100% such that we never blow up pixels + if (mActionBar.isZoomingAllowed()) { + getCanvasControl().setFitScale(true, true /*allowZoomIn*/); + } + } + + reloadPalette(); + + getCanvasControl().getPreviewManager().configurationChanged(flags); + + return true; + } + + @Override + public void setActivity(@NonNull String activity) { + ManifestInfo manifest = ManifestInfo.get(mEditedFile.getProject()); + String pkg = manifest.getPackage(); + if (activity.startsWith(pkg) && activity.length() > pkg.length() + && activity.charAt(pkg.length()) == '.') { + activity = activity.substring(pkg.length()); + } + CommonXmlEditor editor = getEditorDelegate().getEditor(); + Element element = editor.getUiRootNode().getXmlDocument().getDocumentElement(); + AdtUtils.setToolsAttribute(editor, + element, "Choose Activity", ATTR_CONTEXT, + activity, false /*reveal*/, false /*append*/); + } + + /** + * Returns a {@link ProjectResources} for the framework resources based on the current + * configuration selection. + * @return the framework resources or null if not found. + */ + @Override + @Nullable + public ResourceRepository getFrameworkResources() { + return getFrameworkResources(getRenderingTarget()); + } + + /** + * Returns a {@link ProjectResources} for the framework resources of a given + * target. + * @param target the target for which to return the framework resources. + * @return the framework resources or null if not found. + */ + @Override + @Nullable + public ResourceRepository getFrameworkResources(@Nullable IAndroidTarget target) { + if (target != null) { + AndroidTargetData data = Sdk.getCurrent().getTargetData(target); + + if (data != null) { + return data.getFrameworkResources(); + } + } + + return null; + } + + @Override + @Nullable + public ProjectResources getProjectResources() { + if (mEditedFile != null) { + ResourceManager manager = ResourceManager.getInstance(); + return manager.getProjectResources(mEditedFile.getProject()); + } + + return null; + } + + + @Override + @NonNull + public Map<ResourceType, Map<String, ResourceValue>> getConfiguredFrameworkResources() { + if (mConfiguredFrameworkRes == null && mConfigChooser != null) { + ResourceRepository frameworkRes = getFrameworkResources(); + + if (frameworkRes == null) { + AdtPlugin.log(IStatus.ERROR, "Failed to get ProjectResource for the framework"); + } else { + // get the framework resource values based on the current config + mConfiguredFrameworkRes = frameworkRes.getConfiguredResources( + mConfigChooser.getConfiguration().getFullConfig()); + } + } + + return mConfiguredFrameworkRes; + } + + @Override + @NonNull + public Map<ResourceType, Map<String, ResourceValue>> getConfiguredProjectResources() { + if (mConfiguredProjectRes == null && mConfigChooser != null) { + ProjectResources project = getProjectResources(); + + // get the project resource values based on the current config + mConfiguredProjectRes = project.getConfiguredResources( + mConfigChooser.getConfiguration().getFullConfig()); + } + + return mConfiguredProjectRes; + } + + @Override + public void createConfigFile() { + LayoutCreatorDialog dialog = new LayoutCreatorDialog(mConfigChooser.getShell(), + mEditedFile.getName(), mConfigChooser.getConfiguration().getFullConfig()); + if (dialog.open() != Window.OK) { + return; + } + + FolderConfiguration config = new FolderConfiguration(); + dialog.getConfiguration(config); + + // Creates a new layout file from the specified {@link FolderConfiguration}. + CreateNewConfigJob job = new CreateNewConfigJob(this, mEditedFile, config); + job.schedule(); + } + + /** + * Returns the resource name of the file that is including this current layout, if any + * (may be null) + * + * @return the resource name of an including layout, or null + */ + @Override + public Reference getIncludedWithin() { + return mIncludedWithin; + } + + @Override + @Nullable + public LayoutCanvas getCanvas() { + return getCanvasControl(); + } + + /** + * Listens to target changed in the current project, to trigger a new layout rendering. + */ + private class TargetListener implements ITargetChangeListener { + + @Override + public void onProjectTargetChange(IProject changedProject) { + if (changedProject != null && changedProject.equals(getProject())) { + updateEditor(); + } + } + + @Override + public void onTargetLoaded(IAndroidTarget loadedTarget) { + IAndroidTarget target = getRenderingTarget(); + if (target != null && target.equals(loadedTarget)) { + updateEditor(); + } + } + + @Override + public void onSdkLoaded() { + // get the current rendering target to unload it + IAndroidTarget oldTarget = getRenderingTarget(); + preRenderingTargetChangeCleanUp(oldTarget); + + computeSdkVersion(); + + // get the project target + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(mEditedFile.getProject()); + if (target != null) { + mConfigChooser.onSdkLoaded(target); + changed(CFG_FOLDER | CFG_TARGET); + } + } + } + + private void updateEditor() { + mEditorDelegate.getEditor().commitPages(false /* onSave */); + + // because the target changed we must reset the configured resources. + mConfiguredFrameworkRes = mConfiguredProjectRes = null; + mResourceResolver = null; + + // make sure we remove the custom view loader, since its parent class loader is the + // bridge class loader. + mProjectCallback = null; + + // recreate the ui root node always, this will also call onTargetChange + // on the config composite + mEditorDelegate.delegateInitUiRootNode(true /*force*/); + } + + private IProject getProject() { + return getEditorDelegate().getEditor().getProject(); + } + } + + /** Refresh the configured project resources associated with this editor */ + public void refreshProjectResources() { + mConfiguredProjectRes = null; + mResourceResolver = null; + } + + /** + * Returns the currently edited file + * + * @return the currently edited file, or null + */ + public IFile getEditedFile() { + return mEditedFile; + } + + /** + * Returns the project for the currently edited file, or null + * + * @return the project containing the edited file, or null + */ + public IProject getProject() { + if (mEditedFile != null) { + return mEditedFile.getProject(); + } else { + return null; + } + } + + // ---------------- + + /** + * Save operation in the Graphical Editor Part. + * <p/> + * In our workflow, the model is owned by the Structured XML Editor. + * The graphical layout editor just displays it -- thus we don't really + * save anything here. + * <p/> + * This must NOT call the parent editor part. At the contrary, the parent editor + * part will call this *after* having done the actual save operation. + * <p/> + * The only action this editor must do is mark the undo command stack as + * being no longer dirty. + */ + @Override + public void doSave(IProgressMonitor monitor) { + // TODO implement a command stack +// getCommandStack().markSaveLocation(); +// firePropertyChange(PROP_DIRTY); + } + + /** + * Save operation in the Graphical Editor Part. + * <p/> + * In our workflow, the model is owned by the Structured XML Editor. + * The graphical layout editor just displays it -- thus we don't really + * save anything here. + */ + @Override + public void doSaveAs() { + // pass + } + + /** + * In our workflow, the model is owned by the Structured XML Editor. + * The graphical layout editor just displays it -- thus we don't really + * save anything here. + */ + @Override + public boolean isDirty() { + return false; + } + + /** + * In our workflow, the model is owned by the Structured XML Editor. + * The graphical layout editor just displays it -- thus we don't really + * save anything here. + */ + @Override + public boolean isSaveAsAllowed() { + return false; + } + + @Override + public void setFocus() { + // TODO Auto-generated method stub + + } + + /** + * Responds to a page change that made the Graphical editor page the activated page. + */ + public void activated() { + if (!mActive) { + mActive = true; + + syncDockingState(); + mActionBar.updateErrorIndicator(); + + boolean changed = mConfigChooser.syncRenderState(); + if (changed) { + // Will also force recomputeLayout() + return; + } + + if (mNeedsRecompute) { + recomputeLayout(); + } + + mCanvasViewer.getCanvas().syncPreviewMode(); + } + } + + /** + * The global docking state version. This number is incremented each time + * the user customizes the window layout in any layout. + */ + private static int sDockingStateVersion; + + /** + * The window docking state version that this window is currently showing; + * when a different window is reconfigured, the global version number is + * incremented, and when this window is shown, and the current version is + * less than the global version, the window layout will be synced. + */ + private int mDockingStateVersion; + + /** + * Syncs the window docking state. + * <p> + * The layout editor lets you change the docking state -- e.g. you can minimize the + * palette, and drag the structure view to the bottom, and so on. When you restart + * the IDE, the window comes back up with your customized state. + * <p> + * <b>However</b>, when you have multiple editor files open, if you minimize the palette + * in one editor and then switch to another, the other editor will have the old window + * state. That's because each editor has its own set of windows. + * <p> + * This method fixes this. Whenever a window is shown, this method is called, and the + * docking state is synced such that the editor will match the current persistent docking + * state. + */ + private void syncDockingState() { + if (mDockingStateVersion == sDockingStateVersion) { + // No changes to apply + return; + } + mDockingStateVersion = sDockingStateVersion; + + IPreferenceStore preferenceStore = AdtPlugin.getDefault().getPreferenceStore(); + PluginFlyoutPreferences preferences; + preferences = new PluginFlyoutPreferences(preferenceStore, PREF_PALETTE); + mPaletteComposite.apply(preferences); + preferences = new PluginFlyoutPreferences(preferenceStore, PREF_STRUCTURE); + mStructureFlyout.apply(preferences); + mPaletteComposite.layout(); + mStructureFlyout.layout(); + mPaletteComposite.redraw(); // the structure view is nested within the palette + } + + /** + * Responds to a page change that made the Graphical editor page the deactivated page + */ + public void deactivated() { + mActive = false; + + LayoutCanvas canvas = getCanvasControl(); + if (canvas != null) { + canvas.deactivated(); + } + } + + /** + * Opens and initialize the editor with a new file. + * @param file the file being edited. + */ + public void openFile(IFile file) { + mEditedFile = file; + mConfigChooser.setFile(mEditedFile); + + if (mReloadListener == null) { + mReloadListener = new ReloadListener(); + LayoutReloadMonitor.getMonitor().addListener(mEditedFile.getProject(), mReloadListener); + } + + if (mRulesEngine == null) { + mRulesEngine = new RulesEngine(this, mEditedFile.getProject()); + if (mCanvasViewer != null) { + mCanvasViewer.getCanvas().setRulesEngine(mRulesEngine); + } + } + + // Pick up hand-off data: somebody requesting this file to be opened may have + // requested that it should be opened as included within another file + if (mEditedFile != null) { + try { + mIncludedWithin = (Reference) mEditedFile.getSessionProperty(NAME_INCLUDE); + if (mIncludedWithin != null) { + // Only use once + mEditedFile.setSessionProperty(NAME_INCLUDE, null); + } + } catch (CoreException e) { + AdtPlugin.log(e, "Can't access session property %1$s", NAME_INCLUDE); + } + } + + computeSdkVersion(); + } + + /** + * Resets the editor with a replacement file. + * @param file the replacement file. + */ + public void replaceFile(IFile file) { + mEditedFile = file; + mConfigChooser.replaceFile(mEditedFile); + computeSdkVersion(); + } + + /** + * Resets the editor with a replacement file coming from a config change in the config + * selector. + * @param file the replacement file. + */ + public void changeFileOnNewConfig(IFile file) { + mEditedFile = file; + mConfigChooser.changeFileOnNewConfig(mEditedFile); + } + + /** + * Responds to a target change for the project of the edited file + */ + public void onTargetChange() { + AndroidTargetData targetData = mConfigChooser.onXmlModelLoaded(); + updateCapabilities(targetData); + + changed(CFG_FOLDER | CFG_TARGET); + } + + /** Updates the capabilities for the given target data (which may be null) */ + private void updateCapabilities(AndroidTargetData targetData) { + if (targetData != null) { + LayoutLibrary layoutLib = targetData.getLayoutLibrary(); + if (mIncludedWithin != null && !layoutLib.supports(Capability.EMBEDDED_LAYOUT)) { + showIn(null); + } + } + } + + /** + * Returns the {@link CommonXmlDelegate} for this editor + * + * @return the {@link CommonXmlDelegate} for this editor + */ + @NonNull + public LayoutEditorDelegate getEditorDelegate() { + return mEditorDelegate; + } + + /** + * Returns the {@link RulesEngine} associated with this editor + * + * @return the {@link RulesEngine} associated with this editor, never null + */ + public RulesEngine getRulesEngine() { + return mRulesEngine; + } + + /** + * Return the {@link LayoutCanvas} associated with this editor + * + * @return the associated {@link LayoutCanvas} + */ + public LayoutCanvas getCanvasControl() { + if (mCanvasViewer != null) { + return mCanvasViewer.getCanvas(); + } + return null; + } + + /** + * Returns the {@link UiDocumentNode} for the XML model edited by this editor + * + * @return the associated model + */ + public UiDocumentNode getModel() { + return mEditorDelegate.getUiRootNode(); + } + + /** + * Callback for XML model changed. Only update/recompute the layout if the editor is visible + */ + public void onXmlModelChanged() { + // To optimize the rendering when the user is editing in the XML pane, we don't + // refresh the editor if it's not the active part. + // + // This behavior is acceptable when the editor is the single "full screen" part + // (as in this case active means visible.) + // Unfortunately this breaks in 2 cases: + // - when performing a drag'n'drop from one editor to another, the target is not + // properly refreshed before it becomes active. + // - when duplicating the editor window and placing both editors side by side (xml in one + // and canvas in the other one), the canvas may not be refreshed when the XML is edited. + // + // TODO find a way to really query whether the pane is visible, not just active. + + if (mEditorDelegate.isGraphicalEditorActive()) { + recomputeLayout(); + } else { + // Remember we want to recompute as soon as the editor becomes active. + mNeedsRecompute = true; + } + } + + /** + * Recomputes the layout + */ + public void recomputeLayout() { + try { + if (!ensureFileValid()) { + return; + } + + UiDocumentNode model = getModel(); + LayoutCanvas canvas = mCanvasViewer.getCanvas(); + if (!ensureModelValid(model)) { + // Although we display an error, we still treat an empty document as a + // successful layout result so that we can drop new elements in it. + // + // For that purpose, create a special LayoutScene that has no image, + // no root view yet indicates success and then update the canvas with it. + + canvas.setSession( + new StaticRenderSession( + Result.Status.SUCCESS.createResult(), + null /*rootViewInfo*/, null /*image*/), + null /*explodeNodes*/, true /* layoutlib5 */); + return; + } + + LayoutLibrary layoutLib = getReadyLayoutLib(true /*displayError*/); + + if (layoutLib != null) { + // if drawing in real size, (re)set the scaling factor. + if (mActionBar.isZoomingRealSize()) { + mActionBar.computeAndSetRealScale(false /* redraw */); + } + + IProject project = mEditedFile.getProject(); + renderWithBridge(project, model, layoutLib); + + canvas.getPreviewManager().renderPreviews(); + } + } finally { + // no matter the result, we are done doing the recompute based on the latest + // resource/code change. + mNeedsRecompute = false; + } + } + + /** + * Reloads the palette + */ + public void reloadPalette() { + if (mPalette != null) { + IAndroidTarget renderingTarget = getRenderingTarget(); + if (renderingTarget != null) { + mPalette.reloadPalette(renderingTarget); + } + } + } + + /** + * Returns the {@link LayoutLibrary} associated with this editor, if it has + * been initialized already. May return null if it has not been initialized (or has + * not finished initializing). + * + * @return The {@link LayoutLibrary}, or null + */ + public LayoutLibrary getLayoutLibrary() { + return getReadyLayoutLib(false /*displayError*/); + } + + /** + * Returns the scale to multiply pixels in the layout coordinate space with to obtain + * the corresponding dip (device independent pixel) + * + * @return the scale to multiple layout coordinates with to obtain the dip position + */ + public float getDipScale() { + float dpi = mConfigChooser.getConfiguration().getDensity().getDpiValue(); + return Density.DEFAULT_DENSITY / dpi; + } + + // --- private methods --- + + /** + * Ensure that the file associated with this editor is valid (exists and is + * synchronized). Any reasons why it is not are displayed in the editor's error area. + * + * @return True if the editor is valid, false otherwise. + */ + private boolean ensureFileValid() { + // check that the resource exists. If the file is opened but the project is closed + // or deleted for some reason (changed from outside of eclipse), then this will + // return false; + if (mEditedFile.exists() == false) { + displayError("Resource '%1$s' does not exist.", + mEditedFile.getFullPath().toString()); + return false; + } + + if (mEditedFile.isSynchronized(IResource.DEPTH_ZERO) == false) { + String message = String.format("%1$s is out of sync. Please refresh.", + mEditedFile.getName()); + + displayError(message); + + // also print it in the error console. + IProject iProject = mEditedFile.getProject(); + AdtPlugin.printErrorToConsole(iProject.getName(), message); + return false; + } + + return true; + } + + /** + * Returns a {@link LayoutLibrary} that is ready for rendering, or null if the bridge + * is not available or not ready yet (due to SDK loading still being in progress etc). + * If enabled, any reasons preventing the bridge from being returned are displayed to the + * editor's error area. + * + * @param displayError whether to display the loading error or not. + * + * @return LayoutBridge the layout bridge for rendering this editor's scene + */ + LayoutLibrary getReadyLayoutLib(boolean displayError) { + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = getRenderingTarget(); + + if (target != null) { + AndroidTargetData data = currentSdk.getTargetData(target); + if (data != null) { + LayoutLibrary layoutLib = data.getLayoutLibrary(); + + if (layoutLib.getStatus() == LoadStatus.LOADED) { + return layoutLib; + } else if (displayError) { // getBridge() == null + // SDK is loaded but not the layout library! + + // check whether the bridge managed to load, or not + if (layoutLib.getStatus() == LoadStatus.LOADING) { + displayError("Eclipse is loading framework information and the layout library from the SDK folder.\n%1$s will refresh automatically once the process is finished.", + mEditedFile.getName()); + } else { + String message = layoutLib.getLoadMessage(); + displayError("Eclipse failed to load the framework information and the layout library!" + + message != null ? "\n" + message : ""); + } + } + } else { // data == null + // It can happen that the workspace refreshes while the SDK is loading its + // data, which could trigger a redraw of the opened layout if some resources + // changed while Eclipse is closed. + // In this case data could be null, but this is not an error. + // We can just silently return, as all the opened editors are automatically + // refreshed once the SDK finishes loading. + LoadStatus targetLoadStatus = currentSdk.checkAndLoadTargetData(target, null); + + // display error is asked. + if (displayError) { + String targetName = target.getName(); + switch (targetLoadStatus) { + case LOADING: + String s; + if (currentSdk.getTarget(getProject()) == target) { + s = String.format( + "The project target (%1$s) is still loading.", + targetName); + } else { + s = String.format( + "The rendering target (%1$s) is still loading.", + targetName); + } + s += "\nThe layout will refresh automatically once the process is finished."; + displayError(s); + + break; + case FAILED: // known failure + case LOADED: // success but data isn't loaded?!?! + displayError("The project target (%s) was not properly loaded.", + targetName); + break; + } + } + } + + } else if (displayError) { // target == null + displayError("The project target is not set. Right click project, choose Properties | Android."); + } + } else if (displayError) { // currentSdk == null + displayError("Eclipse is loading the SDK.\n%1$s will refresh automatically once the process is finished.", + mEditedFile.getName()); + } + + return null; + } + + /** + * Returns the {@link IAndroidTarget} used for the rendering. + * <p/> + * This first looks for the rendering target setup in the config UI, and if nothing has + * been setup yet, returns the target of the project. + * + * @return an IAndroidTarget object or null if no target is setup and the project has no + * target set. + * + */ + public IAndroidTarget getRenderingTarget() { + // if the SDK is null no targets are loaded. + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk == null) { + return null; + } + + // attempt to get a target from the configuration selector. + IAndroidTarget renderingTarget = mConfigChooser.getConfiguration().getTarget(); + if (renderingTarget != null) { + return renderingTarget; + } + + // fall back to the project target + if (mEditedFile != null) { + return currentSdk.getTarget(mEditedFile.getProject()); + } + + return null; + } + + /** + * Returns whether the current rendering target supports the given capability + * + * @param capability the capability to be looked up + * @return true if the current rendering target supports the given capability + */ + public boolean renderingSupports(Capability capability) { + IAndroidTarget target = getRenderingTarget(); + if (target != null) { + AndroidTargetData targetData = Sdk.getCurrent().getTargetData(target); + LayoutLibrary layoutLib = targetData.getLayoutLibrary(); + return layoutLib.supports(capability); + } + + return false; + } + + private boolean ensureModelValid(UiDocumentNode model) { + // check there is actually a model (maybe the file is empty). + if (model.getUiChildren().size() == 0) { + if (mEditorDelegate.getEditor().isCreatingPages()) { + displayError("Loading editor"); + return false; + } + displayError( + "No XML content. Please add a root view or layout to your document."); + return false; + } + + return true; + } + + /** + * Creates a {@link RenderService} associated with this editor + * @return the render service + */ + @NonNull + public RenderService createRenderService() { + return RenderService.create(this, mCredential); + } + + /** + * Creates a {@link RenderLogger} associated with this editor + * @param name the name of the logger + * @return the new logger + */ + @NonNull + public RenderLogger createRenderLogger(String name) { + return new RenderLogger(name, mCredential); + } + + /** + * Creates a {@link RenderService} associated with this editor + * + * @param configuration the configuration to use (and fallback to editor for the rest) + * @param resolver a resource resolver to use to look up resources + * @return the render service + */ + @NonNull + public RenderService createRenderService(Configuration configuration, + ResourceResolver resolver) { + return RenderService.create(this, configuration, resolver, mCredential); + } + + private void renderWithBridge(IProject iProject, UiDocumentNode model, + LayoutLibrary layoutLib) { + LayoutCanvas canvas = getCanvasControl(); + Set<UiElementNode> explodeNodes = canvas.getNodesToExplode(); + RenderLogger logger = createRenderLogger(mEditedFile.getName()); + RenderingMode renderingMode = RenderingMode.NORMAL; + // FIXME set the rendering mode using ViewRule or something. + List<UiElementNode> children = model.getUiChildren(); + if (children.size() > 0 && + children.get(0).getDescriptor().getXmlLocalName().equals(SCROLL_VIEW)) { + renderingMode = RenderingMode.V_SCROLL; + } + + RenderSession session = RenderService.create(this, mCredential) + .setModel(model) + .setLog(logger) + .setRenderingMode(renderingMode) + .setIncludedWithin(mIncludedWithin) + .setNodesToExpand(explodeNodes) + .createRenderSession(); + + boolean layoutlib5 = layoutLib.supports(Capability.EMBEDDED_LAYOUT); + canvas.setSession(session, explodeNodes, layoutlib5); + + // update the UiElementNode with the layout info. + if (session != null && session.getResult().isSuccess() == false) { + // An error was generated. Print it (and any other accumulated warnings) + String errorMessage = session.getResult().getErrorMessage(); + Throwable exception = session.getResult().getException(); + if (exception != null && errorMessage == null) { + errorMessage = exception.toString(); + } + if (exception != null || (errorMessage != null && errorMessage.length() > 0)) { + logger.error(null, errorMessage, exception, null /*data*/); + } else if (!logger.hasProblems()) { + logger.error(null, "Unexpected error in rendering, no details given", + null /*data*/); + } + // These errors will be included in the log warnings which are + // displayed regardless of render success status below + } + + // We might have detected some missing classes and swapped them by a mock view, + // or run into fidelity warnings or missing resources, so emit all these + // warnings + Set<String> missingClasses = mProjectCallback.getMissingClasses(); + Set<String> brokenClasses = mProjectCallback.getUninstantiatableClasses(); + if (logger.hasProblems()) { + displayLoggerProblems(iProject, logger); + displayFailingClasses(missingClasses, brokenClasses, true); + displayUserStackTrace(logger, true); + } else if (missingClasses.size() > 0 || brokenClasses.size() > 0) { + displayFailingClasses(missingClasses, brokenClasses, false); + displayUserStackTrace(logger, true); + } else if (session != null) { + // Nope, no missing or broken classes. Clear success, congrats! + hideError(); + + // First time this layout is opened, run lint on the file (after a delay) + if (!mRenderedOnce) { + mRenderedOnce = true; + Job job = new Job("Run Lint") { + @Override + protected IStatus run(IProgressMonitor monitor) { + getEditorDelegate().delegateRunLint(); + return Status.OK_STATUS; + } + + }; + job.setSystem(true); + job.schedule(3000); // 3 seconds + } + + mConfigChooser.ensureInitialized(); + } + + model.refreshUi(); + } + + /** + * Returns the {@link ResourceResolver} for this editor + * + * @return the resolver used to resolve resources for the current configuration of + * this editor, or null + */ + public ResourceResolver getResourceResolver() { + if (mResourceResolver == null) { + String theme = mConfigChooser.getThemeName(); + if (theme == null) { + displayError("Missing theme."); + return null; + } + boolean isProjectTheme = mConfigChooser.getConfiguration().isProjectTheme(); + + Map<ResourceType, Map<String, ResourceValue>> configuredProjectRes = + getConfiguredProjectResources(); + + // Get the framework resources + Map<ResourceType, Map<String, ResourceValue>> frameworkResources = + getConfiguredFrameworkResources(); + + if (configuredProjectRes == null) { + displayError("Missing project resources for current configuration."); + return null; + } + + if (frameworkResources == null) { + displayError("Missing framework resources."); + return null; + } + + mResourceResolver = ResourceResolver.create( + configuredProjectRes, frameworkResources, + theme, isProjectTheme); + } + + return mResourceResolver; + } + + /** Returns a project callback, and optionally resets it */ + ProjectCallback getProjectCallback(boolean reset, LayoutLibrary layoutLibrary) { + // Lazily create the project callback the first time we need it + if (mProjectCallback == null) { + ResourceManager resManager = ResourceManager.getInstance(); + IProject project = getProject(); + ProjectResources projectRes = resManager.getProjectResources(project); + mProjectCallback = new ProjectCallback(layoutLibrary, projectRes, project, + mCredential, this); + } else if (reset) { + // Also clears the set of missing/broken classes prior to rendering + mProjectCallback.getMissingClasses().clear(); + mProjectCallback.getUninstantiatableClasses().clear(); + } + + return mProjectCallback; + } + + /** + * Returns the resource name of this layout, NOT including the @layout/ prefix + * + * @return the resource name of this layout, NOT including the @layout/ prefix + */ + public String getLayoutResourceName() { + return ResourceHelper.getLayoutName(mEditedFile); + } + + /** + * Cleans up when the rendering target is about to change + * @param oldTarget the old rendering target. + */ + private void preRenderingTargetChangeCleanUp(IAndroidTarget oldTarget) { + // first clear the caches related to this file in the old target + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + AndroidTargetData data = currentSdk.getTargetData(oldTarget); + if (data != null) { + LayoutLibrary layoutLib = data.getLayoutLibrary(); + + // layoutLib can never be null. + layoutLib.clearCaches(mEditedFile.getProject()); + } + } + + // Also remove the ProjectCallback as it caches custom views which must be reloaded + // with the classloader of the new LayoutLib. We also have to clear it out + // because it stores a reference to the layout library which could have changed. + mProjectCallback = null; + + // FIXME: get rid of the current LayoutScene if any. + } + + private class ReloadListener implements ILayoutReloadListener { + /** + * Called when the file changes triggered a redraw of the layout + */ + @Override + public void reloadLayout(final ChangeFlags flags, final boolean libraryChanged) { + if (mConfigChooser.isDisposed()) { + return; + } + Display display = mConfigChooser.getDisplay(); + display.asyncExec(new Runnable() { + @Override + public void run() { + reloadLayoutSwt(flags, libraryChanged); + } + }); + } + + /** Reload layout. <b>Must be called on the SWT thread</b> */ + private void reloadLayoutSwt(ChangeFlags flags, boolean libraryChanged) { + if (mConfigChooser.isDisposed()) { + return; + } + assert mConfigChooser.getDisplay().getThread() == Thread.currentThread(); + + boolean recompute = false; + // we only care about the r class of the main project. + if (flags.rClass && libraryChanged == false) { + recompute = true; + if (mEditedFile != null) { + ResourceManager manager = ResourceManager.getInstance(); + ProjectResources projectRes = manager.getProjectResources( + mEditedFile.getProject()); + + if (projectRes != null) { + projectRes.resetDynamicIds(); + } + } + } + + if (flags.localeList) { + // the locale list *potentially* changed so we update the locale in the + // config composite. + // However there's no recompute, as it could not be needed + // (for instance a new layout) + // If a resource that's not a layout changed this will trigger a recompute anyway. + mConfigChooser.updateLocales(); + } + + // if a resources was modified. + if (flags.resources) { + recompute = true; + + // TODO: differentiate between single and multi resource file changed, and whether + // the resource change affects the cache. + + // force a reparse in case a value XML file changed. + mConfiguredProjectRes = null; + mResourceResolver = null; + + // clear the cache in the bridge in case a bitmap/9-patch changed. + LayoutLibrary layoutLib = getReadyLayoutLib(true /*displayError*/); + if (layoutLib != null) { + layoutLib.clearCaches(mEditedFile.getProject()); + } + } + + if (flags.code) { + // only recompute if the custom view loader was used to load some code. + if (mProjectCallback != null && mProjectCallback.isUsed()) { + mProjectCallback = null; + recompute = true; + } + } + + if (flags.manifest) { + recompute |= computeSdkVersion(); + } + + if (recompute) { + if (mEditorDelegate.isGraphicalEditorActive()) { + recomputeLayout(); + } else { + mNeedsRecompute = true; + } + } + } + } + + // ---- Error handling ---- + + /** + * Switches the sash to display the error label. + * + * @param errorFormat The new error to display if not null. + * @param parameters String.format parameters for the error format. + */ + private void displayError(String errorFormat, Object...parameters) { + if (errorFormat != null) { + mErrorLabel.setText(String.format(errorFormat, parameters)); + } else { + mErrorLabel.setText(""); + } + mSashError.setMaximizedControl(null); + } + + /** Displays the canvas and hides the error label. */ + private void hideError() { + mErrorLabel.setText(""); + mSashError.setMaximizedControl(mCanvasViewer.getControl()); + } + + /** Display the problem list encountered during a render */ + private void displayUserStackTrace(RenderLogger logger, boolean append) { + List<Throwable> throwables = logger.getFirstTrace(); + if (throwables == null || throwables.isEmpty()) { + return; + } + + Throwable throwable = throwables.get(0); + + if (throwable instanceof RenderSecurityException) { + addActionLink(mErrorLabel, ActionLinkStyleRange.LINK_DISABLE_SANDBOX, + "\nTurn off custom view rendering sandbox\n"); + + StringBuilder builder = new StringBuilder(200); + String lastFailedPath = RenderSecurityManager.getLastFailedPath(); + if (lastFailedPath != null) { + builder.append("Diagnostic info for ADT bug report:\n"); + builder.append("Failed path: ").append(lastFailedPath).append('\n'); + String tempDir = System.getProperty("java.io.tmpdir"); + builder.append("Normal temp dir: ").append(tempDir).append('\n'); + File normalized = new File(tempDir); + builder.append("Normalized temp dir: ").append(normalized.getPath()).append('\n'); + try { + builder.append("Canonical temp dir: ").append(normalized.getCanonicalPath()) + .append('\n'); + } catch (IOException e) { + // ignore + } + builder.append("os.name: ").append(System.getProperty("os.name")).append('\n'); + builder.append("os.version: ").append(System.getProperty("os.version")); + builder.append('\n'); + builder.append("java.runtime.version: "); + builder.append(System.getProperty("java.runtime.version")); + } + if (throwable.getMessage().equals("Unable to create temporary file")) { + String javaVersion = System.getProperty("java.version"); + if (javaVersion.startsWith("1.7.0_")) { + int version = Integer + .parseInt(javaVersion.substring(javaVersion.indexOf('_') + 1)); + if (version > 0 && version < 45) { + builder.append('\n'); + builder.append("Tip: This may be caused by using an older version " + + "of JDK 1.7.0; try using at least 1.7.0_45 (you are using " + + javaVersion + ")"); + } + } + } + if (builder.length() > 0) { + addText(mErrorLabel, builder.toString()); + } + } + + StackTraceElement[] frames = throwable.getStackTrace(); + int end = -1; + boolean haveInterestingFrame = false; + for (int i = 0; i < frames.length; i++) { + StackTraceElement frame = frames[i]; + if (isInterestingFrame(frame)) { + haveInterestingFrame = true; + } + String className = frame.getClassName(); + if (className.equals( + "com.android.layoutlib.bridge.impl.RenderSessionImpl")) { //$NON-NLS-1$ + end = i; + break; + } + } + + if (end == -1 || !haveInterestingFrame) { + // Not a recognized stack trace range: just skip it + return; + } + + if (!append) { + mErrorLabel.setText("\n"); //$NON-NLS-1$ + } else { + addText(mErrorLabel, "\n\n"); //$NON-NLS-1$ + } + + addText(mErrorLabel, throwable.toString() + '\n'); + for (int i = 0; i < end; i++) { + StackTraceElement frame = frames[i]; + String className = frame.getClassName(); + String methodName = frame.getMethodName(); + addText(mErrorLabel, " at " + className + '.' + methodName + '('); + String fileName = frame.getFileName(); + if (fileName != null && !fileName.isEmpty()) { + int lineNumber = frame.getLineNumber(); + String location = fileName + ':' + lineNumber; + if (isInterestingFrame(frame)) { + addActionLink(mErrorLabel, ActionLinkStyleRange.LINK_OPEN_LINE, + location, className, methodName, fileName, lineNumber); + } else { + addText(mErrorLabel, location); + } + addText(mErrorLabel, ")\n"); //$NON-NLS-1$ + } + } + } + + private static boolean isInterestingFrame(StackTraceElement frame) { + String className = frame.getClassName(); + return !(className.startsWith("android.") //$NON-NLS-1$ + || className.startsWith("com.android.") //$NON-NLS-1$ + || className.startsWith("java.") //$NON-NLS-1$ + || className.startsWith("javax.") //$NON-NLS-1$ + || className.startsWith("sun.")); //$NON-NLS-1$ + } + + /** + * Switches the sash to display the error label to show a list of + * missing classes and give options to create them. + */ + private void displayFailingClasses(Set<String> missingClasses, Set<String> brokenClasses, + boolean append) { + if (missingClasses.size() == 0 && brokenClasses.size() == 0) { + return; + } + + if (!append) { + mErrorLabel.setText(""); //$NON-NLS-1$ + } else { + addText(mErrorLabel, "\n"); //$NON-NLS-1$ + } + + if (missingClasses.size() > 0) { + addText(mErrorLabel, "The following classes could not be found:\n"); + for (String clazz : missingClasses) { + addText(mErrorLabel, "- "); + addText(mErrorLabel, clazz); + addText(mErrorLabel, " ("); + + IProject project = getProject(); + Collection<String> customViews = getCustomViewClassNames(project); + addTypoSuggestions(clazz, customViews, false); + addTypoSuggestions(clazz, customViews, true); + addTypoSuggestions(clazz, getAndroidViewClassNames(project), false); + + addActionLink(mErrorLabel, + ActionLinkStyleRange.LINK_FIX_BUILD_PATH, "Fix Build Path", clazz); + addText(mErrorLabel, ", "); + addActionLink(mErrorLabel, + ActionLinkStyleRange.LINK_EDIT_XML, "Edit XML", clazz); + if (clazz.indexOf('.') != -1) { + // Add "Create Class" link, but only for custom views + addText(mErrorLabel, ", "); + addActionLink(mErrorLabel, + ActionLinkStyleRange.LINK_CREATE_CLASS, "Create Class", clazz); + } + addText(mErrorLabel, ")\n"); + } + } + if (brokenClasses.size() > 0) { + addText(mErrorLabel, "The following classes could not be instantiated:\n"); + + // Do we have a custom class (not an Android or add-ons class) + boolean haveCustomClass = false; + + for (String clazz : brokenClasses) { + addText(mErrorLabel, "- "); + addText(mErrorLabel, clazz); + addText(mErrorLabel, " ("); + addActionLink(mErrorLabel, + ActionLinkStyleRange.LINK_OPEN_CLASS, "Open Class", clazz); + addText(mErrorLabel, ", "); + addActionLink(mErrorLabel, + ActionLinkStyleRange.LINK_SHOW_LOG, "Show Error Log", clazz); + addText(mErrorLabel, ")\n"); + + if (!(clazz.startsWith("android.") || //$NON-NLS-1$ + clazz.startsWith("com.google."))) { //$NON-NLS-1$ + haveCustomClass = true; + } + } + + addText(mErrorLabel, "See the Error Log (Window > Show View) for more details.\n"); + + if (haveCustomClass) { + addBoldText(mErrorLabel, "Tip: Use View.isInEditMode() in your custom views " + + "to skip code when shown in Eclipse"); + } + } + + mSashError.setMaximizedControl(null); + } + + private void addTypoSuggestions(String actual, Collection<String> views, + boolean compareWithPackage) { + if (views.size() == 0) { + return; + } + + // Look for typos and try to match with custom views and android views + String actualBase = actual.substring(actual.lastIndexOf('.') + 1); + int maxDistance = actualBase.length() >= 4 ? 2 : 1; + + if (views.size() > 0) { + for (String suggested : views) { + String suggestedBase = suggested.substring(suggested.lastIndexOf('.') + 1); + + String matchWith = compareWithPackage ? suggested : suggestedBase; + if (Math.abs(actualBase.length() - matchWith.length()) > maxDistance) { + // The string lengths differ more than the allowed edit distance; + // no point in even attempting to compute the edit distance (requires + // O(n*m) storage and O(n*m) speed, where n and m are the string lengths) + continue; + } + if (LintUtils.editDistance(actualBase, matchWith) <= maxDistance) { + // Suggest this class as a typo for the given class + String labelClass = (suggestedBase.equals(actual) || actual.indexOf('.') != -1) + ? suggested : suggestedBase; + addActionLink(mErrorLabel, + ActionLinkStyleRange.LINK_CHANGE_CLASS_TO, + String.format("Change to %1$s", + // Only show full package name if class name + // is the same + labelClass), + actual, + viewNeedsPackage(suggested) ? suggested : suggestedBase); + addText(mErrorLabel, ", "); + } + } + } + } + + private static Collection<String> getCustomViewClassNames(IProject project) { + CustomViewFinder finder = CustomViewFinder.get(project); + Collection<String> views = finder.getAllViews(); + if (views == null) { + finder.refresh(); + views = finder.getAllViews(); + } + + return views; + } + + private static Collection<String> getAndroidViewClassNames(IProject project) { + Sdk currentSdk = Sdk.getCurrent(); + IAndroidTarget target = currentSdk.getTarget(project); + if (target != null) { + AndroidTargetData targetData = currentSdk.getTargetData(target); + if (targetData != null) { + LayoutDescriptors layoutDescriptors = targetData.getLayoutDescriptors(); + return layoutDescriptors.getAllViewClassNames(); + } + } + + return Collections.emptyList(); + } + + /** Add a normal line of text to the styled text widget. */ + private void addText(StyledText styledText, String...string) { + for (String s : string) { + styledText.append(s); + } + } + + /** Display the problem list encountered during a render */ + private void displayLoggerProblems(IProject project, RenderLogger logger) { + if (logger.hasProblems()) { + mErrorLabel.setText(""); + // A common source of problems is attempting to open a layout when there are + // compilation errors. In this case, may not have run (or may not be up to date) + // so resources cannot be looked up etc. Explain this situation to the user. + + boolean hasAaptErrors = false; + boolean hasJavaErrors = false; + try { + IMarker[] markers; + markers = project.findMarkers(IMarker.PROBLEM, true, IResource.DEPTH_INFINITE); + if (markers.length > 0) { + for (IMarker marker : markers) { + String markerType = marker.getType(); + if (markerType.equals(IJavaModelMarker.JAVA_MODEL_PROBLEM_MARKER)) { + int severity = marker.getAttribute(IMarker.SEVERITY, -1); + if (severity == IMarker.SEVERITY_ERROR) { + hasJavaErrors = true; + } + } else if (markerType.equals(AdtConstants.MARKER_AAPT_COMPILE)) { + int severity = marker.getAttribute(IMarker.SEVERITY, -1); + if (severity == IMarker.SEVERITY_ERROR) { + hasAaptErrors = true; + } + } + } + } + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + + if (logger.seenTagPrefix(LayoutLog.TAG_RESOURCES_RESOLVE_THEME_ATTR)) { + addBoldText(mErrorLabel, + "Missing styles. Is the correct theme chosen for this layout?\n"); + addText(mErrorLabel, + "Use the Theme combo box above the layout to choose a different layout, " + + "or fix the theme style references.\n\n"); + } + + List<Throwable> trace = logger.getFirstTrace(); + if (trace != null + && trace.toString().contains( + "java.lang.IndexOutOfBoundsException: Index: 2, Size: 2") //$NON-NLS-1$ + && mConfigChooser.getConfiguration().getDensity() == Density.TV) { + addBoldText(mErrorLabel, + "It looks like you are using a render target where the layout library " + + "does not support the tvdpi density.\n\n"); + addText(mErrorLabel, "Please try either updating to " + + "the latest available version (using the SDK manager), or if no updated " + + "version is available for this specific version of Android, try using " + + "a more recent render target version.\n\n"); + + } + + if (hasAaptErrors && logger.seenTagPrefix(LayoutLog.TAG_RESOURCES_PREFIX)) { + // Text will automatically be wrapped by the error widget so no reason + // to insert linebreaks in this error message: + String message = + "NOTE: This project contains resource errors, so aapt did not succeed, " + + "which can cause rendering failures. " + + "Fix resource problems first.\n\n"; + addBoldText(mErrorLabel, message); + } else if (hasJavaErrors && mProjectCallback != null && mProjectCallback.isUsed()) { + // Text will automatically be wrapped by the error widget so no reason + // to insert linebreaks in this error message: + String message = + "NOTE: This project contains Java compilation errors, " + + "which can cause rendering failures for custom views. " + + "Fix compilation problems first.\n\n"; + addBoldText(mErrorLabel, message); + } + + if (logger.seenTag(RenderLogger.TAG_MISSING_DIMENSION)) { + List<UiElementNode> elements = UiDocumentNode.getAllElements(getModel()); + for (UiElementNode element : elements) { + String width = element.getAttributeValue(ATTR_LAYOUT_WIDTH); + if (width == null || width.length() == 0) { + addSetAttributeLink(element, ATTR_LAYOUT_WIDTH); + } + + String height = element.getAttributeValue(ATTR_LAYOUT_HEIGHT); + if (height == null || height.length() == 0) { + addSetAttributeLink(element, ATTR_LAYOUT_HEIGHT); + } + } + } + + String problems = logger.getProblems(false /*includeFidelityWarnings*/); + addText(mErrorLabel, problems); + + List<String> fidelityWarnings = logger.getFidelityWarnings(); + if (fidelityWarnings != null && fidelityWarnings.size() > 0) { + addText(mErrorLabel, + "The graphics preview in the layout editor may not be accurate:\n"); + for (String warning : fidelityWarnings) { + addText(mErrorLabel, warning + ' '); + addActionLink(mErrorLabel, + ActionLinkStyleRange.IGNORE_FIDELITY_WARNING, + "(Ignore for this session)\n", warning); + } + } + + mSashError.setMaximizedControl(null); + } else { + mSashError.setMaximizedControl(mCanvasViewer.getControl()); + } + } + + /** Appends an action link to set the given attribute on the given value */ + private void addSetAttributeLink(UiElementNode element, String attribute) { + if (element.getXmlNode().getNodeName().equals(GRID_LAYOUT)) { + // GridLayout does not require a layout_width or layout_height to be defined + return; + } + + String fill = VALUE_FILL_PARENT; + // See whether we should offer match_parent instead of fill_parent + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(getProject()); + if (target.getVersion().getApiLevel() >= 8) { + fill = VALUE_MATCH_PARENT; + } + } + + String id = element.getAttributeValue(ATTR_ID); + if (id == null || id.length() == 0) { + id = '<' + element.getXmlNode().getNodeName() + '>'; + } else { + id = BaseLayoutRule.stripIdPrefix(id); + } + + addText(mErrorLabel, String.format("\"%1$s\" does not set the required %2$s attribute:\n", + id, attribute)); + addText(mErrorLabel, " (1) "); + addActionLink(mErrorLabel, + ActionLinkStyleRange.SET_ATTRIBUTE, + String.format("Set to \"%1$s\"", VALUE_WRAP_CONTENT), + element, attribute, VALUE_WRAP_CONTENT); + addText(mErrorLabel, "\n (2) "); + addActionLink(mErrorLabel, + ActionLinkStyleRange.SET_ATTRIBUTE, + String.format("Set to \"%1$s\"\n", fill), + element, attribute, fill); + } + + /** Appends the given text as a bold string in the given text widget */ + private void addBoldText(StyledText styledText, String text) { + String s = styledText.getText(); + int start = (s == null ? 0 : s.length()); + + styledText.append(text); + StyleRange sr = new StyleRange(); + sr.start = start; + sr.length = text.length(); + sr.fontStyle = SWT.BOLD; + styledText.setStyleRange(sr); + } + + /** + * Add a URL-looking link to the styled text widget. + * <p/> + * A mouse-click listener is setup and it interprets the link based on the + * action, corresponding to the value fields in {@link ActionLinkStyleRange}. + */ + private void addActionLink(StyledText styledText, int action, String label, + Object... data) { + String s = styledText.getText(); + int start = (s == null ? 0 : s.length()); + styledText.append(label); + + StyleRange sr = new ActionLinkStyleRange(action, data); + sr.start = start; + sr.length = label.length(); + sr.fontStyle = SWT.NORMAL; + sr.underlineStyle = SWT.UNDERLINE_LINK; + sr.underline = true; + styledText.setStyleRange(sr); + } + + /** + * Looks up the resource file corresponding to the given type + * + * @param type The type of resource to look up, such as {@link ResourceType#LAYOUT} + * @param name The name of the resource (not including ".xml") + * @param isFrameworkResource if true, the resource is a framework resource, otherwise + * it's a project resource + * @return the resource file defining the named resource, or null if not found + */ + public IPath findResourceFile(ResourceType type, String name, boolean isFrameworkResource) { + // FIXME: This code does not handle theme value resolution. + // There is code to handle this, but it's in layoutlib; we should + // expose that and use it here. + + Map<ResourceType, Map<String, ResourceValue>> map; + map = isFrameworkResource ? mConfiguredFrameworkRes : mConfiguredProjectRes; + if (map == null) { + // Not yet configured + return null; + } + + Map<String, ResourceValue> layoutMap = map.get(type); + if (layoutMap != null) { + ResourceValue value = layoutMap.get(name); + if (value != null) { + String valueStr = value.getValue(); + if (valueStr.startsWith("?")) { //$NON-NLS-1$ + // FIXME: It's a reference. We should resolve this properly. + return null; + } + return new Path(valueStr); + } + } + + return null; + } + + /** + * Looks up the path to the file corresponding to the given attribute value, such as + * @layout/foo, which will return the foo.xml file in res/layout/. (The general format + * of the resource url is {@literal @[<package_name>:]<resource_type>/<resource_name>}. + * + * @param url the attribute url + * @return the path to the file defining this attribute, or null if not found + */ + public IPath findResourceFile(String url) { + if (!url.startsWith("@")) { //$NON-NLS-1$ + return null; + } + int typeEnd = url.indexOf('/', 1); + if (typeEnd == -1) { + return null; + } + int nameBegin = typeEnd + 1; + int typeBegin = 1; + int colon = url.lastIndexOf(':', typeEnd); + boolean isFrameworkResource = false; + if (colon != -1) { + // The URL contains a package name. + // While the url format technically allows other package names, + // the platform apparently only supports @android for now (or if it does, + // there are no usages in the current code base so this is not common). + String packageName = url.substring(typeBegin, colon); + if (ANDROID_PKG.equals(packageName)) { + isFrameworkResource = true; + } + + typeBegin = colon + 1; + } + + String typeName = url.substring(typeBegin, typeEnd); + ResourceType type = ResourceType.getEnum(typeName); + if (type == null) { + return null; + } + + String name = url.substring(nameBegin); + return findResourceFile(type, name, isFrameworkResource); + } + + /** + * Resolve the given @string reference into a literal String using the current project + * configuration + * + * @param text the text resource reference to resolve + * @return the resolved string, or null + */ + public String findString(String text) { + if (text.startsWith(STRING_PREFIX)) { + return findString(text.substring(STRING_PREFIX.length()), false); + } else if (text.startsWith(ANDROID_STRING_PREFIX)) { + return findString(text.substring(ANDROID_STRING_PREFIX.length()), true); + } else { + return text; + } + } + + private String findString(String name, boolean isFrameworkResource) { + Map<ResourceType, Map<String, ResourceValue>> map; + map = isFrameworkResource ? mConfiguredFrameworkRes : mConfiguredProjectRes; + if (map == null) { + // Not yet configured + return null; + } + + Map<String, ResourceValue> layoutMap = map.get(ResourceType.STRING); + if (layoutMap != null) { + ResourceValue value = layoutMap.get(name); + if (value != null) { + // FIXME: This code does not handle theme value resolution. + // There is code to handle this, but it's in layoutlib; we should + // expose that and use it here. + return value.getValue(); + } + } + + return null; + } + + /** + * This StyleRange represents a clickable link in the render output, where various + * actions can be taken such as creating a class, opening the project chooser to + * adjust the build path, etc. + */ + private class ActionLinkStyleRange extends StyleRange { + /** Create a view class */ + private static final int LINK_CREATE_CLASS = 1; + /** Edit the build path for the current project */ + private static final int LINK_FIX_BUILD_PATH = 2; + /** Show the XML tab */ + private static final int LINK_EDIT_XML = 3; + /** Open the given class */ + private static final int LINK_OPEN_CLASS = 4; + /** Show the error log */ + private static final int LINK_SHOW_LOG = 5; + /** Change the class reference to the given fully qualified name */ + private static final int LINK_CHANGE_CLASS_TO = 6; + /** Ignore the given fidelity warning */ + private static final int IGNORE_FIDELITY_WARNING = 7; + /** Set an attribute on the given XML element to a given value */ + private static final int SET_ATTRIBUTE = 8; + /** Open the given file and line number */ + private static final int LINK_OPEN_LINE = 9; + /** Disable sandbox */ + private static final int LINK_DISABLE_SANDBOX = 10; + + /** Client data: the contents depend on the specific action */ + private final Object[] mData; + /** The action to be taken when the link is clicked */ + private final int mAction; + + private ActionLinkStyleRange(int action, Object... data) { + super(); + mAction = action; + mData = data; + } + + /** Performs the click action */ + public void onClick() { + switch (mAction) { + case LINK_CREATE_CLASS: + createNewClass((String) mData[0]); + break; + case LINK_EDIT_XML: + mEditorDelegate.getEditor().setActivePage(AndroidXmlEditor.TEXT_EDITOR_ID); + break; + case LINK_FIX_BUILD_PATH: + @SuppressWarnings("restriction") + String id = BuildPathsPropertyPage.PROP_ID; + PreferencesUtil.createPropertyDialogOn( + AdtPlugin.getShell(), + getProject(), id, null, null).open(); + break; + case LINK_OPEN_CLASS: + AdtPlugin.openJavaClass(getProject(), (String) mData[0]); + break; + case LINK_OPEN_LINE: + boolean success = AdtPlugin.openStackTraceLine( + (String) mData[0], // class + (String) mData[1], // method + (String) mData[2], // file + (Integer) mData[3]); // line + if (!success) { + MessageDialog.openError(mErrorLabel.getShell(), "Not Found", + String.format("Could not find %1$s.%2$s", mData[0], mData[1])); + } + break; + case LINK_SHOW_LOG: + IWorkbench workbench = PlatformUI.getWorkbench(); + IWorkbenchWindow workbenchWindow = workbench.getActiveWorkbenchWindow(); + try { + IWorkbenchPage page = workbenchWindow.getActivePage(); + page.showView("org.eclipse.pde.runtime.LogView"); //$NON-NLS-1$ + } catch (PartInitException e) { + AdtPlugin.log(e, null); + } + break; + case LINK_CHANGE_CLASS_TO: + // Change class reference of mData[0] to mData[1] + // TODO: run under undo lock + MultiTextEdit edits = new MultiTextEdit(); + ISourceViewer textViewer = + mEditorDelegate.getEditor().getStructuredSourceViewer(); + IDocument document = textViewer.getDocument(); + String xml = document.get(); + int index = 0; + // Replace <old with <new and </old with </new + String prefix = "<"; //$NON-NLS-1$ + String find = prefix + mData[0]; + String replaceWith = prefix + mData[1]; + while (true) { + index = xml.indexOf(find, index); + if (index == -1) { + break; + } + edits.addChild(new ReplaceEdit(index, find.length(), replaceWith)); + index += find.length(); + } + index = 0; + prefix = "</"; //$NON-NLS-1$ + find = prefix + mData[0]; + replaceWith = prefix + mData[1]; + while (true) { + index = xml.indexOf(find, index); + if (index == -1) { + break; + } + edits.addChild(new ReplaceEdit(index, find.length(), replaceWith)); + index += find.length(); + } + // Handle <view class="old"> + index = 0; + prefix = "\""; //$NON-NLS-1$ + String suffix = "\""; //$NON-NLS-1$ + find = prefix + mData[0] + suffix; + replaceWith = prefix + mData[1] + suffix; + while (true) { + index = xml.indexOf(find, index); + if (index == -1) { + break; + } + edits.addChild(new ReplaceEdit(index, find.length(), replaceWith)); + index += find.length(); + } + try { + edits.apply(document); + } catch (MalformedTreeException e) { + AdtPlugin.log(e, null); + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + break; + case IGNORE_FIDELITY_WARNING: + RenderLogger.ignoreFidelityWarning((String) mData[0]); + recomputeLayout(); + break; + case SET_ATTRIBUTE: { + final UiElementNode element = (UiElementNode) mData[0]; + final String attribute = (String) mData[1]; + final String value = (String) mData[2]; + mEditorDelegate.getEditor().wrapUndoEditXmlModel( + String.format("Set \"%1$s\" to \"%2$s\"", attribute, value), + new Runnable() { + @Override + public void run() { + element.setAttributeValue(attribute, ANDROID_URI, value, true); + element.commitDirtyAttributesToXml(); + } + }); + break; + } + case LINK_DISABLE_SANDBOX: { + RenderSecurityManager.sEnabled = false; + recomputeLayout(); + + MessageDialog.openInformation(AdtPlugin.getShell(), + "Disabled Rendering Sandbox", + "The custom view rendering sandbox was disabled for this session.\n\n" + + "You can turn it off permanently by adding\n" + + "-D" + ENABLED_PROPERTY + "=" + VALUE_FALSE + "\n" + + "as a new line in eclipse.ini."); + + break; + } + default: + assert false : mAction; + break; + } + } + + @Override + public boolean similarTo(StyleRange style) { + // Prevent adjacent link ranges from getting merged + return false; + } + } + + /** + * Returns the error label for the graphical editor (which may not be visible + * or showing errors) + * + * @return the error label, never null + */ + StyledText getErrorLabel() { + return mErrorLabel; + } + + /** + * Monitor clicks on the error label. + * If the click happens on a style range created by + * {@link GraphicalEditorPart#addClassLink(StyledText, String)}, we assume it's about + * a missing class and we then proceed to display the standard Eclipse class creator wizard. + */ + private class ErrorLabelListener extends MouseAdapter { + + @Override + public void mouseUp(MouseEvent event) { + super.mouseUp(event); + + if (event.widget != mErrorLabel) { + return; + } + + int offset = mErrorLabel.getCaretOffset(); + + StyleRange r = null; + StyleRange[] ranges = mErrorLabel.getStyleRanges(); + if (ranges != null && ranges.length > 0) { + for (StyleRange sr : ranges) { + if (sr.start <= offset && sr.start + sr.length > offset) { + r = sr; + break; + } + } + } + + if (r instanceof ActionLinkStyleRange) { + ActionLinkStyleRange range = (ActionLinkStyleRange) r; + range.onClick(); + } + + LayoutCanvas canvas = getCanvasControl(); + canvas.updateMenuActionState(); + } + } + + private void createNewClass(String fqcn) { + + int pos = fqcn.lastIndexOf('.'); + String packageName = pos < 0 ? "" : fqcn.substring(0, pos); //$NON-NLS-1$ + String className = pos <= 0 || pos >= fqcn.length() ? "" : fqcn.substring(pos + 1); //$NON-NLS-1$ + + // create the wizard page for the class creation, and configure it + NewClassWizardPage page = new NewClassWizardPage(); + + // set the parent class + page.setSuperClass(SdkConstants.CLASS_VIEW, true /* canBeModified */); + + // get the source folders as java elements. + IPackageFragmentRoot[] roots = getPackageFragmentRoots( + mEditorDelegate.getEditor().getProject(), + false /*includeContainers*/, true /*skipGenFolder*/); + + IPackageFragmentRoot currentRoot = null; + IPackageFragment currentFragment = null; + int packageMatchCount = -1; + + for (IPackageFragmentRoot root : roots) { + // Get the java element for the package. + // This method is said to always return a IPackageFragment even if the + // underlying folder doesn't exist... + IPackageFragment fragment = root.getPackageFragment(packageName); + if (fragment != null && fragment.exists()) { + // we have a perfect match! we use it. + currentRoot = root; + currentFragment = fragment; + packageMatchCount = -1; + break; + } else { + // we don't have a match. we look for the fragment with the best match + // (ie the closest parent package we can find) + try { + IJavaElement[] children; + children = root.getChildren(); + for (IJavaElement child : children) { + if (child instanceof IPackageFragment) { + fragment = (IPackageFragment)child; + if (packageName.startsWith(fragment.getElementName())) { + // its a match. get the number of segments + String[] segments = fragment.getElementName().split("\\."); //$NON-NLS-1$ + if (segments.length > packageMatchCount) { + packageMatchCount = segments.length; + currentFragment = fragment; + currentRoot = root; + } + } + } + } + } catch (JavaModelException e) { + // Couldn't get the children: we just ignore this package root. + } + } + } + + ArrayList<IPackageFragment> createdFragments = null; + + if (currentRoot != null) { + // if we have a perfect match, we set it and we're done. + if (packageMatchCount == -1) { + page.setPackageFragmentRoot(currentRoot, true /* canBeModified*/); + page.setPackageFragment(currentFragment, true /* canBeModified */); + } else { + // we have a partial match. + // create the package. We have to start with the first segment so that we + // know what to delete in case of a cancel. + try { + createdFragments = new ArrayList<IPackageFragment>(); + + int totalCount = packageName.split("\\.").length; //$NON-NLS-1$ + int count = 0; + int index = -1; + // skip the matching packages + while (count < packageMatchCount) { + index = packageName.indexOf('.', index+1); + count++; + } + + // create the rest of the segments, except for the last one as indexOf will + // return -1; + while (count < totalCount - 1) { + index = packageName.indexOf('.', index+1); + count++; + createdFragments.add(currentRoot.createPackageFragment( + packageName.substring(0, index), + true /* force*/, new NullProgressMonitor())); + } + + // create the last package + createdFragments.add(currentRoot.createPackageFragment( + packageName, true /* force*/, new NullProgressMonitor())); + + // set the root and fragment in the Wizard page + page.setPackageFragmentRoot(currentRoot, true /* canBeModified*/); + page.setPackageFragment(createdFragments.get(createdFragments.size()-1), + true /* canBeModified */); + } catch (JavaModelException e) { + // If we can't create the packages, there's a problem. + // We revert to the default package + for (IPackageFragmentRoot root : roots) { + // Get the java element for the package. + // This method is said to always return a IPackageFragment even if the + // underlying folder doesn't exist... + IPackageFragment fragment = root.getPackageFragment(packageName); + if (fragment != null && fragment.exists()) { + page.setPackageFragmentRoot(root, true /* canBeModified*/); + page.setPackageFragment(fragment, true /* canBeModified */); + break; + } + } + } + } + } else if (roots.length > 0) { + // if we haven't found a valid fragment, we set the root to the first source folder. + page.setPackageFragmentRoot(roots[0], true /* canBeModified*/); + } + + // if we have a starting class name we use it + if (className != null) { + page.setTypeName(className, true /* canBeModified*/); + } + + // create the action that will open it the wizard. + OpenNewClassWizardAction action = new OpenNewClassWizardAction(); + action.setConfiguredWizardPage(page); + action.run(); + IJavaElement element = action.getCreatedElement(); + + if (element == null) { + // lets delete the packages we created just for this. + // we need to start with the leaf and go up + if (createdFragments != null) { + try { + for (int i = createdFragments.size() - 1 ; i >= 0 ; i--) { + createdFragments.get(i).delete(true /* force*/, + new NullProgressMonitor()); + } + } catch (JavaModelException e) { + e.printStackTrace(); + } + } + } + } + + /** + * Computes and return the {@link IPackageFragmentRoot}s corresponding to the source + * folders of the specified project. + * + * @param project the project + * @param includeContainers True to include containers + * @param skipGenFolder True to skip the "gen" folder + * @return an array of IPackageFragmentRoot. + */ + private IPackageFragmentRoot[] getPackageFragmentRoots(IProject project, + boolean includeContainers, boolean skipGenFolder) { + ArrayList<IPackageFragmentRoot> result = new ArrayList<IPackageFragmentRoot>(); + try { + IJavaProject javaProject = JavaCore.create(project); + IPackageFragmentRoot[] roots = javaProject.getPackageFragmentRoots(); + for (int i = 0; i < roots.length; i++) { + if (skipGenFolder) { + IResource resource = roots[i].getResource(); + if (resource != null && resource.getName().equals(FD_GEN_SOURCES)) { + continue; + } + } + IClasspathEntry entry = roots[i].getRawClasspathEntry(); + if (entry.getEntryKind() == IClasspathEntry.CPE_SOURCE || + (includeContainers && + entry.getEntryKind() == IClasspathEntry.CPE_CONTAINER)) { + result.add(roots[i]); + } + } + } catch (JavaModelException e) { + } + + return result.toArray(new IPackageFragmentRoot[result.size()]); + } + + /** + * Reopens this file as included within the given file (this assumes that the given + * file has an include tag referencing this view, and the set of views that have this + * property can be found using the {@link IncludeFinder}. + * + * @param includeWithin reference to a file to include as a surrounding context, + * or null to show the file standalone + */ + public void showIn(Reference includeWithin) { + mIncludedWithin = includeWithin; + + if (includeWithin != null) { + IFile file = includeWithin.getFile(); + + // Update configuration + if (file != null) { + mConfigChooser.resetConfigFor(file); + } + } + recomputeLayout(); + } + + /** + * Return all resource names of a given type, either in the project or in the + * framework. + * + * @param framework if true, return all the framework resource names, otherwise return + * all the project resource names + * @param type the type of resource to look up + * @return a collection of resource names, never null but possibly empty + */ + public Collection<String> getResourceNames(boolean framework, ResourceType type) { + Map<ResourceType, Map<String, ResourceValue>> map = + framework ? mConfiguredFrameworkRes : mConfiguredProjectRes; + Map<String, ResourceValue> animations = map.get(type); + if (animations != null) { + return animations.keySet(); + } else { + return Collections.emptyList(); + } + } + + /** + * Return this editor's current configuration + * + * @return the current configuration + */ + public FolderConfiguration getConfiguration() { + return mConfigChooser.getConfiguration().getFullConfig(); + } + + /** + * Figures out the project's minSdkVersion and targetSdkVersion and return whether the values + * have changed. + */ + private boolean computeSdkVersion() { + int oldMinSdkVersion = mMinSdkVersion; + int oldTargetSdkVersion = mTargetSdkVersion; + + Pair<Integer, Integer> v = ManifestInfo.computeSdkVersions(mEditedFile.getProject()); + mMinSdkVersion = v.getFirst(); + mTargetSdkVersion = v.getSecond(); + + return oldMinSdkVersion != mMinSdkVersion || oldTargetSdkVersion != mTargetSdkVersion; + } + + /** + * Returns the associated configuration chooser + * + * @return the configuration chooser + */ + @NonNull + public ConfigurationChooser getConfigurationChooser() { + return mConfigChooser; + } + + /** + * Returns the associated layout actions bar + * + * @return the layout actions bar + */ + @NonNull + public LayoutActionBar getLayoutActionBar() { + return mActionBar; + } + + /** + * Returns the target SDK version + * + * @return the target SDK version + */ + public int getTargetSdkVersion() { + return mTargetSdkVersion; + } + + /** + * Returns the minimum SDK version + * + * @return the minimum SDK version + */ + public int getMinSdkVersion() { + return mMinSdkVersion; + } + + /** If the flyout hover is showing, dismiss it */ + public void dismissHoverPalette() { + mPaletteComposite.dismissHover(); + } + + // ---- Implements IFlyoutListener ---- + + @Override + public void stateChanged(int oldState, int newState) { + // Auto zoom the surface if you open or close flyout windows such as the palette + // or the property/outline views + if (newState == STATE_OPEN || newState == STATE_COLLAPSED && oldState == STATE_OPEN) { + getCanvasControl().setFitScale(true /*onlyZoomOut*/, true /*allowZoomIn*/); + } + + sDockingStateVersion++; + mDockingStateVersion = sDockingStateVersion; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/HoverOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/HoverOverlay.java new file mode 100644 index 000000000..2e7c559db --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/HoverOverlay.java @@ -0,0 +1,187 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.SwtDrawingStyle.HOVER; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.SwtDrawingStyle.HOVER_SELECTION; + +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Rectangle; + +import java.util.List; + +/** + * The {@link HoverOverlay} paints an optional hover on top of the layout, + * highlighting the currently hovered view. + */ +public class HoverOverlay extends Overlay { + private final LayoutCanvas mCanvas; + + /** Hover border color. Must be disposed, it's NOT a system color. */ + private Color mHoverStrokeColor; + + /** Hover fill color. Must be disposed, it's NOT a system color. */ + private Color mHoverFillColor; + + /** Hover border select color. Must be disposed, it's NOT a system color. */ + private Color mHoverSelectStrokeColor; + + /** Hover fill select color. Must be disposed, it's NOT a system color. */ + private Color mHoverSelectFillColor; + + /** Vertical scaling & scrollbar information. */ + private CanvasTransform mVScale; + + /** Horizontal scaling & scrollbar information. */ + private CanvasTransform mHScale; + + /** + * Current mouse hover border rectangle. Null when there's no mouse hover. + * The rectangle coordinates do not take account of the translation, which + * must be applied to the rectangle when drawing. + */ + private Rectangle mHoverRect; + + /** + * Constructs a new {@link HoverOverlay} linked to the given view hierarchy. + * + * @param canvas the associated canvas + * @param hScale The {@link CanvasTransform} to use to transfer horizontal layout + * coordinates to screen coordinates. + * @param vScale The {@link CanvasTransform} to use to transfer vertical layout + * coordinates to screen coordinates. + */ + public HoverOverlay(LayoutCanvas canvas, CanvasTransform hScale, CanvasTransform vScale) { + mCanvas = canvas; + mHScale = hScale; + mVScale = vScale; + } + + @Override + public void create(Device device) { + if (SwtDrawingStyle.HOVER.getStrokeColor() != null) { + mHoverStrokeColor = new Color(device, SwtDrawingStyle.HOVER.getStrokeColor()); + } + if (SwtDrawingStyle.HOVER.getFillColor() != null) { + mHoverFillColor = new Color(device, SwtDrawingStyle.HOVER.getFillColor()); + } + + if (SwtDrawingStyle.HOVER_SELECTION.getStrokeColor() != null) { + mHoverSelectStrokeColor = new Color(device, + SwtDrawingStyle.HOVER_SELECTION.getStrokeColor()); + } + if (SwtDrawingStyle.HOVER_SELECTION.getFillColor() != null) { + mHoverSelectFillColor = new Color(device, + SwtDrawingStyle.HOVER_SELECTION.getFillColor()); + } + } + + @Override + public void dispose() { + if (mHoverStrokeColor != null) { + mHoverStrokeColor.dispose(); + mHoverStrokeColor = null; + } + + if (mHoverFillColor != null) { + mHoverFillColor.dispose(); + mHoverFillColor = null; + } + + if (mHoverSelectStrokeColor != null) { + mHoverSelectStrokeColor.dispose(); + mHoverSelectStrokeColor = null; + } + + if (mHoverSelectFillColor != null) { + mHoverSelectFillColor.dispose(); + mHoverSelectFillColor = null; + } + } + + /** + * Sets the hover rectangle. The coordinates of the rectangle are in layout + * coordinates. The recipient is will own this rectangle. + * <p/> + * TODO: Consider switching input arguments to two {@link LayoutPoint}s so + * we don't have ambiguity about the coordinate system of these input + * parameters. + * <p/> + * + * @param x The top left x coordinate, in layout coordinates, of the hover. + * @param y The top left y coordinate, in layout coordinates, of the hover. + * @param w The width of the hover (in layout coordinates). + * @param h The height of the hover (in layout coordinates). + */ + public void setHover(int x, int y, int w, int h) { + mHoverRect = new Rectangle(x, y, w, h); + } + + /** + * Removes the hover for the next paint. + */ + public void clearHover() { + mHoverRect = null; + } + + @Override + public void paint(GC gc) { + if (mHoverRect != null) { + // Translate the hover rectangle (in canvas coordinates) to control + // coordinates + int x = mHScale.translate(mHoverRect.x); + int y = mVScale.translate(mHoverRect.y); + int w = mHScale.scale(mHoverRect.width); + int h = mVScale.scale(mHoverRect.height); + + + boolean hoverIsSelected = false; + List<SelectionItem> selections = mCanvas.getSelectionManager().getSelections(); + for (SelectionItem item : selections) { + if (mHoverRect.equals(item.getViewInfo().getSelectionRect())) { + hoverIsSelected = true; + break; + } + } + + Color stroke = hoverIsSelected ? mHoverSelectStrokeColor : mHoverStrokeColor; + Color fill = hoverIsSelected ? mHoverSelectFillColor : mHoverFillColor; + + if (stroke != null) { + int oldAlpha = gc.getAlpha(); + gc.setForeground(stroke); + gc.setLineStyle(hoverIsSelected ? + HOVER_SELECTION.getLineStyle() : HOVER.getLineStyle()); + gc.setAlpha(hoverIsSelected ? + HOVER_SELECTION.getStrokeAlpha() : HOVER.getStrokeAlpha()); + gc.drawRectangle(x, y, w, h); + gc.setAlpha(oldAlpha); + } + + if (fill != null) { + int oldAlpha = gc.getAlpha(); + gc.setAlpha(hoverIsSelected ? + HOVER_SELECTION.getFillAlpha() : HOVER.getFillAlpha()); + gc.setBackground(fill); + gc.fillRectangle(x, y, w, h); + gc.setAlpha(oldAlpha); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageControl.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageControl.java new file mode 100644 index 000000000..4447eebd2 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageControl.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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CLabel; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseTrackListener; +import org.eclipse.swt.events.PaintEvent; +import org.eclipse.swt.events.PaintListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Canvas; +import org.eclipse.swt.widgets.Composite; + +/** + * An ImageControl which simply renders an image, with optional margins and tooltips. This + * is useful since a {@link CLabel}, even without text, will hide the image when there is + * not enough room to fully fit it. + * <p> + * The image is always rendered left and top aligned. + */ +public class ImageControl extends Canvas implements MouseTrackListener { + private Image mImage; + private int mLeftMargin; + private int mTopMargin; + private int mRightMargin; + private int mBottomMargin; + private boolean mDisposeImage = true; + private boolean mMouseIn; + private Color mHoverColor; + private float mScale = 1.0f; + + /** + * Creates an ImageControl rendering the given image, which will be disposed when this + * control is disposed (unless the {@link #setDisposeImage} method is called to turn + * off auto dispose). + * + * @param parent the parent to add the image control to + * @param style the SWT style to use + * @param image the image to be rendered, which must not be null and should be unique + * for this image control since it will be disposed by this control when + * the control is disposed (unless the {@link #setDisposeImage} method is + * called to turn off auto dispose) + */ + public ImageControl(@NonNull Composite parent, int style, @Nullable Image image) { + super(parent, style | SWT.NO_FOCUS | SWT.DOUBLE_BUFFERED); + mImage = image; + + addPaintListener(new PaintListener() { + @Override + public void paintControl(PaintEvent event) { + onPaint(event); + } + }); + } + + @Nullable + public Image getImage() { + return mImage; + } + + public void setImage(@Nullable Image image) { + if (mDisposeImage && mImage != null) { + mImage.dispose(); + } + mImage = image; + redraw(); + } + + public void fitToWidth(int width) { + if (mImage == null) { + return; + } + Rectangle imageRect = mImage.getBounds(); + int imageWidth = imageRect.width; + if (imageWidth <= width) { + mScale = 1.0f; + return; + } + + mScale = width / (float) imageWidth; + redraw(); + } + + public void setScale(float scale) { + mScale = scale; + } + + public float getScale() { + return mScale; + } + + public void setHoverColor(@Nullable Color hoverColor) { + if (mHoverColor != null) { + removeMouseTrackListener(this); + } + mHoverColor = hoverColor; + if (hoverColor != null) { + addMouseTrackListener(this); + } + } + + @Nullable + public Color getHoverColor() { + return mHoverColor; + } + + @Override + public void dispose() { + super.dispose(); + + if (mDisposeImage && mImage != null && !mImage.isDisposed()) { + mImage.dispose(); + } + mImage = null; + } + + public void setDisposeImage(boolean disposeImage) { + mDisposeImage = disposeImage; + } + + public boolean getDisposeImage() { + return mDisposeImage; + } + + @Override + public Point computeSize(int wHint, int hHint, boolean changed) { + checkWidget(); + Point e = new Point(0, 0); + if (mImage != null) { + Rectangle r = mImage.getBounds(); + if (mScale != 1.0f) { + e.x += mScale * r.width; + e.y += mScale * r.height; + } else { + e.x += r.width; + e.y += r.height; + } + } + if (wHint == SWT.DEFAULT) { + e.x += mLeftMargin + mRightMargin; + } else { + e.x = wHint; + } + if (hHint == SWT.DEFAULT) { + e.y += mTopMargin + mBottomMargin; + } else { + e.y = hHint; + } + + return e; + } + + private void onPaint(PaintEvent event) { + Rectangle rect = getClientArea(); + if (mImage == null || rect.width == 0 || rect.height == 0) { + return; + } + + GC gc = event.gc; + Rectangle imageRect = mImage.getBounds(); + int imageHeight = imageRect.height; + int imageWidth = imageRect.width; + int destWidth = imageWidth; + int destHeight = imageHeight; + + int oldGcAlias = gc.getAntialias(); + int oldGcInterpolation = gc.getInterpolation(); + if (mScale != 1.0f) { + destWidth = (int) (mScale * destWidth); + destHeight = (int) (mScale * destHeight); + gc.setAntialias(SWT.ON); + gc.setInterpolation(SWT.HIGH); + } + + gc.drawImage(mImage, 0, 0, imageWidth, imageHeight, rect.x + mLeftMargin, rect.y + + mTopMargin, destWidth, destHeight); + + gc.setAntialias(oldGcAlias); + gc.setInterpolation(oldGcInterpolation); + + if (mHoverColor != null && mMouseIn) { + gc.setAlpha(60); + gc.setBackground(mHoverColor); + gc.setLineWidth(1); + gc.fillRectangle(0, 0, destWidth, destHeight); + } + } + + public void setMargins(int leftMargin, int topMargin, int rightMargin, int bottomMargin) { + checkWidget(); + mLeftMargin = Math.max(0, leftMargin); + mTopMargin = Math.max(0, topMargin); + mRightMargin = Math.max(0, rightMargin); + mBottomMargin = Math.max(0, bottomMargin); + redraw(); + } + + // ---- Implements MouseTrackListener ---- + + @Override + public void mouseEnter(MouseEvent e) { + mMouseIn = true; + if (mHoverColor != null) { + redraw(); + } + } + + @Override + public void mouseExit(MouseEvent e) { + mMouseIn = false; + if (mHoverColor != null) { + redraw(); + } + } + + @Override + public void mouseHover(MouseEvent e) { + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageOverlay.java new file mode 100644 index 000000000..a1363ecb1 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageOverlay.java @@ -0,0 +1,447 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SHADOW_SIZE; + +import com.android.SdkConstants; +import com.android.annotations.Nullable; +import com.android.ide.common.api.Rect; +import com.android.ide.common.rendering.api.IImageFactory; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.PaletteData; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.awt.image.WritableRaster; +import java.lang.ref.SoftReference; + +/** + * The {@link ImageOverlay} class renders an image as an overlay. + */ +public class ImageOverlay extends Overlay implements IImageFactory { + /** + * Whether the image should be pre-scaled (scaled to the zoom level) once + * instead of dynamically during each paint; this is necessary on some + * platforms (see issue #19447) + */ + private static final boolean PRESCALE = + // Currently this is necessary on Linux because the "Cairo" library + // seems to be a bottleneck + SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_LINUX + && !(Boolean.getBoolean("adt.noprescale")); //$NON-NLS-1$ + + /** Current background image. Null when there's no image. */ + private Image mImage; + + /** A pre-scaled version of the image */ + private Image mPreScaledImage; + + /** Whether the rendered image should have a drop shadow */ + private boolean mShowDropShadow; + + /** Current background AWT image. This is created by {@link #getImage()}, which is called + * by the LayoutLib. */ + private SoftReference<BufferedImage> mAwtImage = new SoftReference<BufferedImage>(null); + + /** + * Strong reference to the image in the above soft reference, to prevent + * garbage collection when {@link PRESCALE} is set, until the scaled image + * is created (lazily as part of the next paint call, where this strong + * reference is nulled out and the above soft reference becomes eligible to + * be reclaimed when memory is low.) + */ + @SuppressWarnings("unused") // Used by the garbage collector to keep mAwtImage non-soft + private BufferedImage mAwtImageStrongRef; + + /** The associated {@link LayoutCanvas}. */ + private LayoutCanvas mCanvas; + + /** Vertical scaling & scrollbar information. */ + private CanvasTransform mVScale; + + /** Horizontal scaling & scrollbar information. */ + private CanvasTransform mHScale; + + /** + * Constructs an {@link ImageOverlay} tied to the given canvas. + * + * @param canvas The {@link LayoutCanvas} to paint the overlay over. + * @param hScale The horizontal scale information. + * @param vScale The vertical scale information. + */ + public ImageOverlay(LayoutCanvas canvas, CanvasTransform hScale, CanvasTransform vScale) { + mCanvas = canvas; + mHScale = hScale; + mVScale = vScale; + } + + @Override + public void create(Device device) { + super.create(device); + } + + @Override + public void dispose() { + if (mImage != null) { + mImage.dispose(); + mImage = null; + } + if (mPreScaledImage != null) { + mPreScaledImage.dispose(); + mPreScaledImage = null; + } + } + + /** + * Sets the image to be drawn as an overlay from the passed in AWT + * {@link BufferedImage} (which will be converted to an SWT image). + * <p/> + * The image <b>can</b> be null, which is the case when we are dealing with + * an empty document. + * + * @param awtImage The AWT image to be rendered as an SWT image. + * @param isAlphaChannelImage whether the alpha channel of the image is relevant + * @return The corresponding SWT image, or null. + */ + public synchronized Image setImage(BufferedImage awtImage, boolean isAlphaChannelImage) { + mShowDropShadow = !isAlphaChannelImage; + + BufferedImage oldAwtImage = mAwtImage.get(); + if (awtImage != oldAwtImage || awtImage == null) { + mAwtImage.clear(); + mAwtImageStrongRef = null; + + if (mImage != null) { + mImage.dispose(); + } + + if (awtImage == null) { + mImage = null; + } else { + mImage = SwtUtils.convertToSwt(mCanvas.getDisplay(), awtImage, + isAlphaChannelImage, -1); + } + } else { + assert awtImage instanceof SwtReadyBufferedImage; + + if (isAlphaChannelImage) { + if (mImage != null) { + mImage.dispose(); + } + + mImage = SwtUtils.convertToSwt(mCanvas.getDisplay(), awtImage, true, -1); + } else { + Image prev = mImage; + mImage = ((SwtReadyBufferedImage)awtImage).getSwtImage(); + if (prev != mImage && prev != null) { + prev.dispose(); + } + } + } + + if (mPreScaledImage != null) { + // Force refresh on next paint + mPreScaledImage.dispose(); + mPreScaledImage = null; + } + + return mImage; + } + + /** + * Returns the currently painted image, or null if none has been set + * + * @return the currently painted image or null + */ + public Image getImage() { + return mImage; + } + + /** + * Returns the currently rendered image, or null if none has been set + * + * @return the currently rendered image or null + */ + @Nullable + BufferedImage getAwtImage() { + BufferedImage awtImage = mAwtImage.get(); + if (awtImage == null && mImage != null) { + awtImage = SwtUtils.convertToAwt(mImage); + } + + return awtImage; + } + + /** + * Returns whether this image overlay should be painted with a drop shadow. + * This is usually the case, but not for transparent themes like the dialog + * theme (Theme.*Dialog), which already provides its own shadow. + * + * @return true if the image overlay should be shown with a drop shadow. + */ + public boolean getShowDropShadow() { + return mShowDropShadow; + } + + @Override + public synchronized void paint(GC gc) { + if (mImage != null) { + boolean valid = mCanvas.getViewHierarchy().isValid(); + mCanvas.ensureZoomed(); + if (!valid) { + gc_setAlpha(gc, 128); // half-transparent + } + + CanvasTransform hi = mHScale; + CanvasTransform vi = mVScale; + + // On some platforms, dynamic image scaling is very slow (see issue #19447) so + // compute a pre-scaled version of the image once and render that instead. + // This is done lazily in paint rather than when the image changes because + // the image must be rescaled each time the zoom level changes, which varies + // independently from when the image changes. + BufferedImage awtImage = mAwtImage.get(); + if (PRESCALE && awtImage != null) { + int imageWidth = (mPreScaledImage == null) ? 0 + : mPreScaledImage.getImageData().width + - (mShowDropShadow ? SHADOW_SIZE : 0); + if (mPreScaledImage == null || imageWidth != hi.getScaledImgSize()) { + double xScale = hi.getScaledImgSize() / (double) awtImage.getWidth(); + double yScale = vi.getScaledImgSize() / (double) awtImage.getHeight(); + BufferedImage scaledAwtImage; + + // NOTE: == comparison on floating point numbers is okay + // here because we normalize the scaling factor + // to an exact 1.0 in the zooming code when the value gets + // near 1.0 to make painting more efficient in the presence + // of rounding errors. + if (xScale == 1.0 && yScale == 1.0) { + // Scaling to 100% is easy! + scaledAwtImage = awtImage; + + if (mShowDropShadow) { + // Just need to draw drop shadows + scaledAwtImage = ImageUtils.createRectangularDropShadow(awtImage); + } + } else { + if (mShowDropShadow) { + scaledAwtImage = ImageUtils.scale(awtImage, xScale, yScale, + SHADOW_SIZE, SHADOW_SIZE); + ImageUtils.drawRectangleShadow(scaledAwtImage, 0, 0, + scaledAwtImage.getWidth() - SHADOW_SIZE, + scaledAwtImage.getHeight() - SHADOW_SIZE); + } else { + scaledAwtImage = ImageUtils.scale(awtImage, xScale, yScale); + } + } + + if (mPreScaledImage != null && !mPreScaledImage.isDisposed()) { + mPreScaledImage.dispose(); + } + mPreScaledImage = SwtUtils.convertToSwt(mCanvas.getDisplay(), scaledAwtImage, + true /*transferAlpha*/, -1); + // We can't just clear the mAwtImageStrongRef here, because if the + // zooming factor changes, we may need to use it again + } + + if (mPreScaledImage != null) { + gc.drawImage(mPreScaledImage, hi.translate(0), vi.translate(0)); + } + return; + } + + // we only anti-alias when reducing the image size. + int oldAlias = -2; + if (hi.getScale() < 1.0) { + oldAlias = gc_setAntialias(gc, SWT.ON); + } + + int srcX = 0; + int srcY = 0; + int srcWidth = hi.getImgSize(); + int srcHeight = vi.getImgSize(); + int destX = hi.translate(0); + int destY = vi.translate(0); + int destWidth = hi.getScaledImgSize(); + int destHeight = vi.getScaledImgSize(); + + gc.drawImage(mImage, + srcX, srcY, srcWidth, srcHeight, + destX, destY, destWidth, destHeight); + + if (mShowDropShadow) { + SwtUtils.drawRectangleShadow(gc, destX, destY, destWidth, destHeight); + } + + if (oldAlias != -2) { + gc_setAntialias(gc, oldAlias); + } + + if (!valid) { + gc_setAlpha(gc, 255); // opaque + } + } + } + + /** + * Sets the alpha for the given GC. + * <p/> + * Alpha may not work on all platforms and may fail with an exception, which + * is hidden here (false is returned in that case). + * + * @param gc the GC to change + * @param alpha the new alpha, 0 for transparent, 255 for opaque. + * @return True if the operation worked, false if it failed with an + * exception. + * @see GC#setAlpha(int) + */ + private boolean gc_setAlpha(GC gc, int alpha) { + try { + gc.setAlpha(alpha); + return true; + } catch (SWTException e) { + return false; + } + } + + /** + * Sets the non-text antialias flag for the given GC. + * <p/> + * Antialias may not work on all platforms and may fail with an exception, + * which is hidden here (-2 is returned in that case). + * + * @param gc the GC to change + * @param alias One of {@link SWT#DEFAULT}, {@link SWT#ON}, {@link SWT#OFF}. + * @return The previous aliasing mode if the operation worked, or -2 if it + * failed with an exception. + * @see GC#setAntialias(int) + */ + private int gc_setAntialias(GC gc, int alias) { + try { + int old = gc.getAntialias(); + gc.setAntialias(alias); + return old; + } catch (SWTException e) { + return -2; + } + } + + /** + * Custom {@link BufferedImage} class able to convert itself into an SWT {@link Image} + * efficiently. + * + * The BufferedImage also contains an instance of {@link ImageData} that's kept around + * and used to create new SWT {@link Image} objects in {@link #getSwtImage()}. + * + */ + private static final class SwtReadyBufferedImage extends BufferedImage { + + private final ImageData mImageData; + private final Device mDevice; + + /** + * Creates the image with a given model, raster and SWT {@link ImageData} + * @param model the color model + * @param raster the image raster + * @param imageData the SWT image data. + * @param device the {@link Device} in which the SWT image will be painted. + */ + private SwtReadyBufferedImage(int width, int height, ImageData imageData, Device device) { + super(width, height, BufferedImage.TYPE_INT_ARGB); + mImageData = imageData; + mDevice = device; + } + + /** + * Returns a new {@link Image} object initialized with the content of the BufferedImage. + * @return the image object. + */ + private Image getSwtImage() { + // transfer the content of the bufferedImage into the image data. + WritableRaster raster = getRaster(); + int[] imageDataBuffer = ((DataBufferInt) raster.getDataBuffer()).getData(); + + mImageData.setPixels(0, 0, imageDataBuffer.length, imageDataBuffer, 0); + + return new Image(mDevice, mImageData); + } + + /** + * Creates a new {@link SwtReadyBufferedImage}. + * @param w the width of the image + * @param h the height of the image + * @param device the device in which the SWT image will be painted + * @return a new {@link SwtReadyBufferedImage} object + */ + private static SwtReadyBufferedImage createImage(int w, int h, Device device) { + // NOTE: We can't make this image bigger to accommodate the drop shadow directly + // (such that we could paint one into the image after a layoutlib render) + // since this image is in the full resolution of the device, and gets scaled + // to fit in the layout editor. This would have the net effect of causing + // the drop shadow to get zoomed/scaled along with the scene, making a tiny + // drop shadow for tablet layouts, a huge drop shadow for tiny QVGA screens, etc. + + ImageData imageData = new ImageData(w, h, 32, + new PaletteData(0x00FF0000, 0x0000FF00, 0x000000FF)); + + SwtReadyBufferedImage swtReadyImage = new SwtReadyBufferedImage(w, h, + imageData, device); + + return swtReadyImage; + } + } + + /** + * Implementation of {@link IImageFactory#getImage(int, int)}. + */ + @Override + public BufferedImage getImage(int w, int h) { + BufferedImage awtImage = mAwtImage.get(); + if (awtImage == null || + awtImage.getWidth() != w || + awtImage.getHeight() != h) { + mAwtImage.clear(); + awtImage = SwtReadyBufferedImage.createImage(w, h, getDevice()); + mAwtImage = new SoftReference<BufferedImage>(awtImage); + if (PRESCALE) { + mAwtImageStrongRef = awtImage; + } + } + + return awtImage; + } + + /** + * Returns the bounds of the current image, or null + * + * @return the bounds of the current image, or null + */ + public Rect getImageBounds() { + if (mImage == null) { + return null; + } + + return new Rect(0, 0, mImage.getImageData().width, mImage.getImageData().height); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageUtils.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageUtils.java new file mode 100644 index 000000000..b5bc9aa72 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ImageUtils.java @@ -0,0 +1,979 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.DOT_9PNG; +import static com.android.SdkConstants.DOT_BMP; +import static com.android.SdkConstants.DOT_GIF; +import static com.android.SdkConstants.DOT_JPG; +import static com.android.SdkConstants.DOT_PNG; +import static com.android.utils.SdkUtils.endsWithIgnoreCase; +import static java.awt.RenderingHints.KEY_ANTIALIASING; +import static java.awt.RenderingHints.KEY_INTERPOLATION; +import static java.awt.RenderingHints.KEY_RENDERING; +import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON; +import static java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR; +import static java.awt.RenderingHints.VALUE_RENDER_QUALITY; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.Rect; +import com.android.ide.eclipse.adt.AdtPlugin; + +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.graphics.Rectangle; + +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; +import java.util.List; + +import javax.imageio.ImageIO; + +/** + * Utilities related to image processing. + */ +public class ImageUtils { + /** + * Returns true if the given image has no dark pixels + * + * @param image the image to be checked for dark pixels + * @return true if no dark pixels were found + */ + public static boolean containsDarkPixels(BufferedImage image) { + for (int y = 0, height = image.getHeight(); y < height; y++) { + for (int x = 0, width = image.getWidth(); x < width; x++) { + int pixel = image.getRGB(x, y); + if ((pixel & 0xFF000000) != 0) { + int r = (pixel & 0xFF0000) >> 16; + int g = (pixel & 0x00FF00) >> 8; + int b = (pixel & 0x0000FF); + + // One perceived luminance formula is (0.299*red + 0.587*green + 0.114*blue) + // In order to keep this fast since we don't need a very accurate + // measure, I'll just estimate this with integer math: + long brightness = (299L*r + 587*g + 114*b) / 1000; + if (brightness < 128) { + return true; + } + } + } + } + return false; + } + + /** + * Returns the perceived brightness of the given RGB integer on a scale from 0 to 255 + * + * @param rgb the RGB triplet, 8 bits each + * @return the perceived brightness, with 0 maximally dark and 255 maximally bright + */ + public static int getBrightness(int rgb) { + if ((rgb & 0xFFFFFF) != 0) { + int r = (rgb & 0xFF0000) >> 16; + int g = (rgb & 0x00FF00) >> 8; + int b = (rgb & 0x0000FF); + // See the containsDarkPixels implementation for details + return (int) ((299L*r + 587*g + 114*b) / 1000); + } + + return 0; + } + + /** + * Converts an alpha-red-green-blue integer color into an {@link RGB} color. + * <p> + * <b>NOTE</b> - this will drop the alpha value since {@link RGB} objects do not + * contain transparency information. + * + * @param rgb the RGB integer to convert to a color description + * @return the color description corresponding to the integer + */ + public static RGB intToRgb(int rgb) { + return new RGB((rgb & 0xFF0000) >>> 16, (rgb & 0xFF00) >>> 8, rgb & 0xFF); + } + + /** + * Converts an {@link RGB} color into a alpha-red-green-blue integer + * + * @param rgb the RGB color descriptor to convert + * @param alpha the amount of alpha to add into the color integer (since the + * {@link RGB} objects do not contain an alpha channel) + * @return an integer corresponding to the {@link RGB} color + */ + public static int rgbToInt(RGB rgb, int alpha) { + return alpha << 24 | (rgb.red << 16) | (rgb.green << 8) | rgb.blue; + } + + /** + * Crops blank pixels from the edges of the image and returns the cropped result. We + * crop off pixels that are blank (meaning they have an alpha value = 0). Note that + * this is not the same as pixels that aren't opaque (an alpha value other than 255). + * + * @param image the image to be cropped + * @param initialCrop If not null, specifies a rectangle which contains an initial + * crop to continue. This can be used to crop an image where you already + * know about margins in the image + * @return a cropped version of the source image, or null if the whole image was blank + * and cropping completely removed everything + */ + @Nullable + public static BufferedImage cropBlank( + @NonNull BufferedImage image, + @Nullable Rect initialCrop) { + return cropBlank(image, initialCrop, image.getType()); + } + + /** + * Crops blank pixels from the edges of the image and returns the cropped result. We + * crop off pixels that are blank (meaning they have an alpha value = 0). Note that + * this is not the same as pixels that aren't opaque (an alpha value other than 255). + * + * @param image the image to be cropped + * @param initialCrop If not null, specifies a rectangle which contains an initial + * crop to continue. This can be used to crop an image where you already + * know about margins in the image + * @param imageType the type of {@link BufferedImage} to create + * @return a cropped version of the source image, or null if the whole image was blank + * and cropping completely removed everything + */ + public static BufferedImage cropBlank(BufferedImage image, Rect initialCrop, int imageType) { + CropFilter filter = new CropFilter() { + @Override + public boolean crop(BufferedImage bufferedImage, int x, int y) { + int rgb = bufferedImage.getRGB(x, y); + return (rgb & 0xFF000000) == 0x00000000; + // TODO: Do a threshold of 80 instead of just 0? Might give better + // visual results -- e.g. check <= 0x80000000 + } + }; + return crop(image, filter, initialCrop, imageType); + } + + /** + * Crops pixels of a given color from the edges of the image and returns the cropped + * result. + * + * @param image the image to be cropped + * @param blankArgb the color considered to be blank, as a 32 pixel integer with 8 + * bits of alpha, red, green and blue + * @param initialCrop If not null, specifies a rectangle which contains an initial + * crop to continue. This can be used to crop an image where you already + * know about margins in the image + * @return a cropped version of the source image, or null if the whole image was blank + * and cropping completely removed everything + */ + @Nullable + public static BufferedImage cropColor( + @NonNull BufferedImage image, + final int blankArgb, + @Nullable Rect initialCrop) { + return cropColor(image, blankArgb, initialCrop, image.getType()); + } + + /** + * Crops pixels of a given color from the edges of the image and returns the cropped + * result. + * + * @param image the image to be cropped + * @param blankArgb the color considered to be blank, as a 32 pixel integer with 8 + * bits of alpha, red, green and blue + * @param initialCrop If not null, specifies a rectangle which contains an initial + * crop to continue. This can be used to crop an image where you already + * know about margins in the image + * @param imageType the type of {@link BufferedImage} to create + * @return a cropped version of the source image, or null if the whole image was blank + * and cropping completely removed everything + */ + public static BufferedImage cropColor(BufferedImage image, + final int blankArgb, Rect initialCrop, int imageType) { + CropFilter filter = new CropFilter() { + @Override + public boolean crop(BufferedImage bufferedImage, int x, int y) { + return blankArgb == bufferedImage.getRGB(x, y); + } + }; + return crop(image, filter, initialCrop, imageType); + } + + /** + * Interface implemented by cropping functions that determine whether + * a pixel should be cropped or not. + */ + private static interface CropFilter { + /** + * Returns true if the pixel is should be cropped. + * + * @param image the image containing the pixel in question + * @param x the x position of the pixel + * @param y the y position of the pixel + * @return true if the pixel should be cropped (for example, is blank) + */ + boolean crop(BufferedImage image, int x, int y); + } + + private static BufferedImage crop(BufferedImage image, CropFilter filter, Rect initialCrop, + int imageType) { + if (image == null) { + return null; + } + + // First, determine the dimensions of the real image within the image + int x1, y1, x2, y2; + if (initialCrop != null) { + x1 = initialCrop.x; + y1 = initialCrop.y; + x2 = initialCrop.x + initialCrop.w; + y2 = initialCrop.y + initialCrop.h; + } else { + x1 = 0; + y1 = 0; + x2 = image.getWidth(); + y2 = image.getHeight(); + } + + // Nothing left to crop + if (x1 == x2 || y1 == y2) { + return null; + } + + // This algorithm is a bit dumb -- it just scans along the edges looking for + // a pixel that shouldn't be cropped. I could maybe try to make it smarter by + // for example doing a binary search to quickly eliminate large empty areas to + // the right and bottom -- but this is slightly tricky with components like the + // AnalogClock where I could accidentally end up finding a blank horizontal or + // vertical line somewhere in the middle of the rendering of the clock, so for now + // we do the dumb thing -- not a big deal since we tend to crop reasonably + // small images. + + // First determine top edge + topEdge: for (; y1 < y2; y1++) { + for (int x = x1; x < x2; x++) { + if (!filter.crop(image, x, y1)) { + break topEdge; + } + } + } + + if (y1 == image.getHeight()) { + // The image is blank + return null; + } + + // Next determine left edge + leftEdge: for (; x1 < x2; x1++) { + for (int y = y1; y < y2; y++) { + if (!filter.crop(image, x1, y)) { + break leftEdge; + } + } + } + + // Next determine right edge + rightEdge: for (; x2 > x1; x2--) { + for (int y = y1; y < y2; y++) { + if (!filter.crop(image, x2 - 1, y)) { + break rightEdge; + } + } + } + + // Finally determine bottom edge + bottomEdge: for (; y2 > y1; y2--) { + for (int x = x1; x < x2; x++) { + if (!filter.crop(image, x, y2 - 1)) { + break bottomEdge; + } + } + } + + // No need to crop? + if (x1 == 0 && y1 == 0 && x2 == image.getWidth() && y2 == image.getHeight()) { + return image; + } + + if (x1 == x2 || y1 == y2) { + // Nothing left after crop -- blank image + return null; + } + + int width = x2 - x1; + int height = y2 - y1; + + // Now extract the sub-image + if (imageType == -1) { + imageType = image.getType(); + } + if (imageType == BufferedImage.TYPE_CUSTOM) { + imageType = BufferedImage.TYPE_INT_ARGB; + } + BufferedImage cropped = new BufferedImage(width, height, imageType); + Graphics g = cropped.getGraphics(); + g.drawImage(image, 0, 0, width, height, x1, y1, x2, y2, null); + + g.dispose(); + + return cropped; + } + + /** + * Creates a drop shadow of a given image and returns a new image which shows the + * input image on top of its drop shadow. + * <p> + * <b>NOTE: If the shape is rectangular and opaque, consider using + * {@link #drawRectangleShadow(Graphics, int, int, int, int)} instead.</b> + * + * @param source the source image to be shadowed + * @param shadowSize the size of the shadow in pixels + * @param shadowOpacity the opacity of the shadow, with 0=transparent and 1=opaque + * @param shadowRgb the RGB int to use for the shadow color + * @return a new image with the source image on top of its shadow + */ + public static BufferedImage createDropShadow(BufferedImage source, int shadowSize, + float shadowOpacity, int shadowRgb) { + + // This code is based on + // http://www.jroller.com/gfx/entry/non_rectangular_shadow + + BufferedImage image = new BufferedImage(source.getWidth() + shadowSize * 2, + source.getHeight() + shadowSize * 2, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g2 = image.createGraphics(); + g2.drawImage(source, null, shadowSize, shadowSize); + + int dstWidth = image.getWidth(); + int dstHeight = image.getHeight(); + + int left = (shadowSize - 1) >> 1; + int right = shadowSize - left; + int xStart = left; + int xStop = dstWidth - right; + int yStart = left; + int yStop = dstHeight - right; + + shadowRgb = shadowRgb & 0x00FFFFFF; + + int[] aHistory = new int[shadowSize]; + int historyIdx = 0; + + int aSum; + + int[] dataBuffer = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + int lastPixelOffset = right * dstWidth; + float sumDivider = shadowOpacity / shadowSize; + + // horizontal pass + for (int y = 0, bufferOffset = 0; y < dstHeight; y++, bufferOffset = y * dstWidth) { + aSum = 0; + historyIdx = 0; + for (int x = 0; x < shadowSize; x++, bufferOffset++) { + int a = dataBuffer[bufferOffset] >>> 24; + aHistory[x] = a; + aSum += a; + } + + bufferOffset -= right; + + for (int x = xStart; x < xStop; x++, bufferOffset++) { + int a = (int) (aSum * sumDivider); + dataBuffer[bufferOffset] = a << 24 | shadowRgb; + + // subtract the oldest pixel from the sum + aSum -= aHistory[historyIdx]; + + // get the latest pixel + a = dataBuffer[bufferOffset + right] >>> 24; + aHistory[historyIdx] = a; + aSum += a; + + if (++historyIdx >= shadowSize) { + historyIdx -= shadowSize; + } + } + } + // vertical pass + for (int x = 0, bufferOffset = 0; x < dstWidth; x++, bufferOffset = x) { + aSum = 0; + historyIdx = 0; + for (int y = 0; y < shadowSize; y++, bufferOffset += dstWidth) { + int a = dataBuffer[bufferOffset] >>> 24; + aHistory[y] = a; + aSum += a; + } + + bufferOffset -= lastPixelOffset; + + for (int y = yStart; y < yStop; y++, bufferOffset += dstWidth) { + int a = (int) (aSum * sumDivider); + dataBuffer[bufferOffset] = a << 24 | shadowRgb; + + // subtract the oldest pixel from the sum + aSum -= aHistory[historyIdx]; + + // get the latest pixel + a = dataBuffer[bufferOffset + lastPixelOffset] >>> 24; + aHistory[historyIdx] = a; + aSum += a; + + if (++historyIdx >= shadowSize) { + historyIdx -= shadowSize; + } + } + } + + g2.drawImage(source, null, 0, 0); + g2.dispose(); + + return image; + } + + /** + * Draws a rectangular drop shadow (of size {@link #SHADOW_SIZE} by + * {@link #SHADOW_SIZE} around the given source and returns a new image with + * both combined + * + * @param source the source image + * @return the source image with a drop shadow on the bottom and right + */ + public static BufferedImage createRectangularDropShadow(BufferedImage source) { + int type = source.getType(); + if (type == BufferedImage.TYPE_CUSTOM) { + type = BufferedImage.TYPE_INT_ARGB; + } + + int width = source.getWidth(); + int height = source.getHeight(); + BufferedImage image = new BufferedImage(width + SHADOW_SIZE, height + SHADOW_SIZE, type); + Graphics g = image.getGraphics(); + g.drawImage(source, 0, 0, width, height, null); + ImageUtils.drawRectangleShadow(image, 0, 0, width, height); + g.dispose(); + + return image; + } + + /** + * Draws a drop shadow for the given rectangle into the given context. It + * will not draw anything if the rectangle is smaller than a minimum + * determined by the assets used to draw the shadow graphics. + * The size of the shadow is {@link #SHADOW_SIZE}. + * + * @param image the image to draw the shadow into + * @param x the left coordinate of the left hand side of the rectangle + * @param y the top coordinate of the top of the rectangle + * @param width the width of the rectangle + * @param height the height of the rectangle + */ + public static final void drawRectangleShadow(BufferedImage image, + int x, int y, int width, int height) { + Graphics gc = image.getGraphics(); + try { + drawRectangleShadow(gc, x, y, width, height); + } finally { + gc.dispose(); + } + } + + /** + * Draws a small drop shadow for the given rectangle into the given context. It + * will not draw anything if the rectangle is smaller than a minimum + * determined by the assets used to draw the shadow graphics. + * The size of the shadow is {@link #SMALL_SHADOW_SIZE}. + * + * @param image the image to draw the shadow into + * @param x the left coordinate of the left hand side of the rectangle + * @param y the top coordinate of the top of the rectangle + * @param width the width of the rectangle + * @param height the height of the rectangle + */ + public static final void drawSmallRectangleShadow(BufferedImage image, + int x, int y, int width, int height) { + Graphics gc = image.getGraphics(); + try { + drawSmallRectangleShadow(gc, x, y, width, height); + } finally { + gc.dispose(); + } + } + + /** + * The width and height of the drop shadow painted by + * {@link #drawRectangleShadow(Graphics, int, int, int, int)} + */ + public static final int SHADOW_SIZE = 20; // DO NOT EDIT. This corresponds to bitmap graphics + + /** + * The width and height of the drop shadow painted by + * {@link #drawSmallRectangleShadow(Graphics, int, int, int, int)} + */ + public static final int SMALL_SHADOW_SIZE = 10; // DO NOT EDIT. Corresponds to bitmap graphics + + /** + * Draws a drop shadow for the given rectangle into the given context. It + * will not draw anything if the rectangle is smaller than a minimum + * determined by the assets used to draw the shadow graphics. + * <p> + * This corresponds to + * {@link SwtUtils#drawRectangleShadow(org.eclipse.swt.graphics.GC, int, int, int, int)}, + * but applied to an AWT graphics object instead, such that no image + * conversion has to be performed. + * <p> + * Make sure to keep changes in the visual appearance here in sync with the + * AWT version in + * {@link SwtUtils#drawRectangleShadow(org.eclipse.swt.graphics.GC, int, int, int, int)}. + * + * @param gc the graphics context to draw into + * @param x the left coordinate of the left hand side of the rectangle + * @param y the top coordinate of the top of the rectangle + * @param width the width of the rectangle + * @param height the height of the rectangle + */ + public static final void drawRectangleShadow(Graphics gc, + int x, int y, int width, int height) { + if (sShadowBottomLeft == null) { + // Shadow graphics. This was generated by creating a drop shadow in + // Gimp, using the parameters x offset=10, y offset=10, blur radius=10, + // color=black, and opacity=51. These values attempt to make a shadow + // that is legible both for dark and light themes, on top of the + // canvas background (rgb(150,150,150). Darker shadows would tend to + // blend into the foreground for a dark holo screen, and lighter shadows + // would be hard to spot on the canvas background. If you make adjustments, + // make sure to check the shadow with both dark and light themes. + // + // After making the graphics, I cut out the top right, bottom left + // and bottom right corners as 20x20 images, and these are reproduced by + // painting them in the corresponding places in the target graphics context. + // I then grabbed a single horizontal gradient line from the middle of the + // right edge,and a single vertical gradient line from the bottom. These + // are then painted scaled/stretched in the target to fill the gaps between + // the three corner images. + // + // Filenames: bl=bottom left, b=bottom, br=bottom right, r=right, tr=top right + sShadowBottomLeft = readImage("shadow-bl.png"); //$NON-NLS-1$ + sShadowBottom = readImage("shadow-b.png"); //$NON-NLS-1$ + sShadowBottomRight = readImage("shadow-br.png"); //$NON-NLS-1$ + sShadowRight = readImage("shadow-r.png"); //$NON-NLS-1$ + sShadowTopRight = readImage("shadow-tr.png"); //$NON-NLS-1$ + assert sShadowBottomLeft != null; + assert sShadowBottomRight.getWidth() == SHADOW_SIZE; + assert sShadowBottomRight.getHeight() == SHADOW_SIZE; + } + + int blWidth = sShadowBottomLeft.getWidth(); + int trHeight = sShadowTopRight.getHeight(); + if (width < blWidth) { + return; + } + if (height < trHeight) { + return; + } + + gc.drawImage(sShadowBottomLeft, x, y + height, null); + gc.drawImage(sShadowBottomRight, x + width, y + height, null); + gc.drawImage(sShadowTopRight, x + width, y, null); + gc.drawImage(sShadowBottom, + x + sShadowBottomLeft.getWidth(), y + height, + x + width, y + height + sShadowBottom.getHeight(), + 0, 0, sShadowBottom.getWidth(), sShadowBottom.getHeight(), + null); + gc.drawImage(sShadowRight, + x + width, y + sShadowTopRight.getHeight(), + x + width + sShadowRight.getWidth(), y + height, + 0, 0, sShadowRight.getWidth(), sShadowRight.getHeight(), + null); + } + + /** + * Draws a small drop shadow for the given rectangle into the given context. It + * will not draw anything if the rectangle is smaller than a minimum + * determined by the assets used to draw the shadow graphics. + * <p> + * + * @param gc the graphics context to draw into + * @param x the left coordinate of the left hand side of the rectangle + * @param y the top coordinate of the top of the rectangle + * @param width the width of the rectangle + * @param height the height of the rectangle + */ + public static final void drawSmallRectangleShadow(Graphics gc, + int x, int y, int width, int height) { + if (sShadow2BottomLeft == null) { + // Shadow graphics. This was generated by creating a drop shadow in + // Gimp, using the parameters x offset=5, y offset=%, blur radius=5, + // color=black, and opacity=51. These values attempt to make a shadow + // that is legible both for dark and light themes, on top of the + // canvas background (rgb(150,150,150). Darker shadows would tend to + // blend into the foreground for a dark holo screen, and lighter shadows + // would be hard to spot on the canvas background. If you make adjustments, + // make sure to check the shadow with both dark and light themes. + // + // After making the graphics, I cut out the top right, bottom left + // and bottom right corners as 20x20 images, and these are reproduced by + // painting them in the corresponding places in the target graphics context. + // I then grabbed a single horizontal gradient line from the middle of the + // right edge,and a single vertical gradient line from the bottom. These + // are then painted scaled/stretched in the target to fill the gaps between + // the three corner images. + // + // Filenames: bl=bottom left, b=bottom, br=bottom right, r=right, tr=top right + sShadow2BottomLeft = readImage("shadow2-bl.png"); //$NON-NLS-1$ + sShadow2Bottom = readImage("shadow2-b.png"); //$NON-NLS-1$ + sShadow2BottomRight = readImage("shadow2-br.png"); //$NON-NLS-1$ + sShadow2Right = readImage("shadow2-r.png"); //$NON-NLS-1$ + sShadow2TopRight = readImage("shadow2-tr.png"); //$NON-NLS-1$ + assert sShadow2BottomLeft != null; + assert sShadow2TopRight != null; + assert sShadow2BottomRight.getWidth() == SMALL_SHADOW_SIZE; + assert sShadow2BottomRight.getHeight() == SMALL_SHADOW_SIZE; + } + + int blWidth = sShadow2BottomLeft.getWidth(); + int trHeight = sShadow2TopRight.getHeight(); + if (width < blWidth) { + return; + } + if (height < trHeight) { + return; + } + + gc.drawImage(sShadow2BottomLeft, x, y + height, null); + gc.drawImage(sShadow2BottomRight, x + width, y + height, null); + gc.drawImage(sShadow2TopRight, x + width, y, null); + gc.drawImage(sShadow2Bottom, + x + sShadow2BottomLeft.getWidth(), y + height, + x + width, y + height + sShadow2Bottom.getHeight(), + 0, 0, sShadow2Bottom.getWidth(), sShadow2Bottom.getHeight(), + null); + gc.drawImage(sShadow2Right, + x + width, y + sShadow2TopRight.getHeight(), + x + width + sShadow2Right.getWidth(), y + height, + 0, 0, sShadow2Right.getWidth(), sShadow2Right.getHeight(), + null); + } + + /** + * Reads the given image from the plugin folder + * + * @param name the name of the image (including file extension) + * @return the corresponding image, or null if something goes wrong + */ + @Nullable + public static BufferedImage readImage(@NonNull String name) { + InputStream stream = ImageUtils.class.getResourceAsStream("/icons/" + name); //$NON-NLS-1$ + if (stream != null) { + try { + return ImageIO.read(stream); + } catch (IOException e) { + AdtPlugin.log(e, "Could not read %1$s", name); + } finally { + try { + stream.close(); + } catch (IOException e) { + // Dumb API + } + } + } + + return null; + } + + // Normal drop shadow + private static BufferedImage sShadowBottomLeft; + private static BufferedImage sShadowBottom; + private static BufferedImage sShadowBottomRight; + private static BufferedImage sShadowRight; + private static BufferedImage sShadowTopRight; + + // Small drop shadow + private static BufferedImage sShadow2BottomLeft; + private static BufferedImage sShadow2Bottom; + private static BufferedImage sShadow2BottomRight; + private static BufferedImage sShadow2Right; + private static BufferedImage sShadow2TopRight; + + /** + * Returns a bounding rectangle for the given list of rectangles. If the list is + * empty, the bounding rectangle is null. + * + * @param items the list of rectangles to compute a bounding rectangle for (may not be + * null) + * @return a bounding rectangle of the passed in rectangles, or null if the list is + * empty + */ + public static Rectangle getBoundingRectangle(List<Rectangle> items) { + Iterator<Rectangle> iterator = items.iterator(); + if (!iterator.hasNext()) { + return null; + } + + Rectangle bounds = iterator.next(); + Rectangle union = new Rectangle(bounds.x, bounds.y, bounds.width, bounds.height); + while (iterator.hasNext()) { + union.add(iterator.next()); + } + + return union; + } + + /** + * Returns a new image which contains of the sub image given by the rectangle (x1,y1) + * to (x2,y2) + * + * @param source the source image + * @param x1 top left X coordinate + * @param y1 top left Y coordinate + * @param x2 bottom right X coordinate + * @param y2 bottom right Y coordinate + * @return a new image containing the pixels in the given range + */ + public static BufferedImage subImage(BufferedImage source, int x1, int y1, int x2, int y2) { + int width = x2 - x1; + int height = y2 - y1; + int imageType = source.getType(); + if (imageType == BufferedImage.TYPE_CUSTOM) { + imageType = BufferedImage.TYPE_INT_ARGB; + } + BufferedImage sub = new BufferedImage(width, height, imageType); + Graphics g = sub.getGraphics(); + g.drawImage(source, 0, 0, width, height, x1, y1, x2, y2, null); + g.dispose(); + + return sub; + } + + /** + * Returns the color value represented by the given string value + * @param value the color value + * @return the color as an int + * @throw NumberFormatException if the conversion failed. + */ + public static int getColor(String value) { + // Copied from ResourceHelper in layoutlib + if (value != null) { + if (value.startsWith("#") == false) { //$NON-NLS-1$ + throw new NumberFormatException( + String.format("Color value '%s' must start with #", value)); + } + + value = value.substring(1); + + // make sure it's not longer than 32bit + if (value.length() > 8) { + throw new NumberFormatException(String.format( + "Color value '%s' is too long. Format is either" + + "#AARRGGBB, #RRGGBB, #RGB, or #ARGB", + value)); + } + + if (value.length() == 3) { // RGB format + char[] color = new char[8]; + color[0] = color[1] = 'F'; + color[2] = color[3] = value.charAt(0); + color[4] = color[5] = value.charAt(1); + color[6] = color[7] = value.charAt(2); + value = new String(color); + } else if (value.length() == 4) { // ARGB format + char[] color = new char[8]; + color[0] = color[1] = value.charAt(0); + color[2] = color[3] = value.charAt(1); + color[4] = color[5] = value.charAt(2); + color[6] = color[7] = value.charAt(3); + value = new String(color); + } else if (value.length() == 6) { + value = "FF" + value; //$NON-NLS-1$ + } + + // this is a RRGGBB or AARRGGBB value + + // Integer.parseInt will fail to parse strings like "ff191919", so we use + // a Long, but cast the result back into an int, since we know that we're only + // dealing with 32 bit values. + return (int)Long.parseLong(value, 16); + } + + throw new NumberFormatException(); + } + + /** + * Resize the given image + * + * @param source the image to be scaled + * @param xScale x scale + * @param yScale y scale + * @return the scaled image + */ + public static BufferedImage scale(BufferedImage source, double xScale, double yScale) { + return scale(source, xScale, yScale, 0, 0); + } + + /** + * Resize the given image + * + * @param source the image to be scaled + * @param xScale x scale + * @param yScale y scale + * @param rightMargin extra margin to add on the right + * @param bottomMargin extra margin to add on the bottom + * @return the scaled image + */ + public static BufferedImage scale(BufferedImage source, double xScale, double yScale, + int rightMargin, int bottomMargin) { + int sourceWidth = source.getWidth(); + int sourceHeight = source.getHeight(); + int destWidth = Math.max(1, (int) (xScale * sourceWidth)); + int destHeight = Math.max(1, (int) (yScale * sourceHeight)); + int imageType = source.getType(); + if (imageType == BufferedImage.TYPE_CUSTOM) { + imageType = BufferedImage.TYPE_INT_ARGB; + } + if (xScale > 0.5 && yScale > 0.5) { + BufferedImage scaled = + new BufferedImage(destWidth + rightMargin, destHeight + bottomMargin, imageType); + Graphics2D g2 = scaled.createGraphics(); + g2.setComposite(AlphaComposite.Src); + g2.setColor(new Color(0, true)); + g2.fillRect(0, 0, destWidth + rightMargin, destHeight + bottomMargin); + g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR); + g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY); + g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); + g2.drawImage(source, 0, 0, destWidth, destHeight, 0, 0, sourceWidth, sourceHeight, + null); + g2.dispose(); + return scaled; + } else { + // When creating a thumbnail, using the above code doesn't work very well; + // you get some visible artifacts, especially for text. Instead use the + // technique of repeatedly scaling the image into half; this will cause + // proper averaging of neighboring pixels, and will typically (for the kinds + // of screen sizes used by this utility method in the layout editor) take + // about 3-4 iterations to get the result since we are logarithmically reducing + // the size. Besides, each successive pass in operating on much fewer pixels + // (a reduction of 4 in each pass). + // + // However, we may not be resizing to a size that can be reached exactly by + // successively diving in half. Therefore, once we're within a factor of 2 of + // the final size, we can do a resize to the exact target size. + // However, we can get even better results if we perform this final resize + // up front. Let's say we're going from width 1000 to a destination width of 85. + // The first approach would cause a resize from 1000 to 500 to 250 to 125, and + // then a resize from 125 to 85. That last resize can distort/blur a lot. + // Instead, we can start with the destination width, 85, and double it + // successfully until we're close to the initial size: 85, then 170, + // then 340, and finally 680. (The next one, 1360, is larger than 1000). + // So, now we *start* the thumbnail operation by resizing from width 1000 to + // width 680, which will preserve a lot of visual details such as text. + // Then we can successively resize the image in half, 680 to 340 to 170 to 85. + // We end up with the expected final size, but we've been doing an exact + // divide-in-half resizing operation at the end so there is less distortion. + + + int iterations = 0; // Number of halving operations to perform after the initial resize + int nearestWidth = destWidth; // Width closest to source width that = 2^x, x is integer + int nearestHeight = destHeight; + while (nearestWidth < sourceWidth / 2) { + nearestWidth *= 2; + nearestHeight *= 2; + iterations++; + } + + // If we're supposed to add in margins, we need to do it in the initial resizing + // operation if we don't have any subsequent resizing operations. + if (iterations == 0) { + nearestWidth += rightMargin; + nearestHeight += bottomMargin; + } + + BufferedImage scaled = new BufferedImage(nearestWidth, nearestHeight, imageType); + Graphics2D g2 = scaled.createGraphics(); + g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR); + g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY); + g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); + g2.drawImage(source, 0, 0, nearestWidth, nearestHeight, + 0, 0, sourceWidth, sourceHeight, null); + g2.dispose(); + + sourceWidth = nearestWidth; + sourceHeight = nearestHeight; + source = scaled; + + for (int iteration = iterations - 1; iteration >= 0; iteration--) { + int halfWidth = sourceWidth / 2; + int halfHeight = sourceHeight / 2; + if (iteration == 0) { // Last iteration: Add margins in final image + scaled = new BufferedImage(halfWidth + rightMargin, halfHeight + bottomMargin, + imageType); + } else { + scaled = new BufferedImage(halfWidth, halfHeight, imageType); + } + g2 = scaled.createGraphics(); + g2.setRenderingHint(KEY_INTERPOLATION,VALUE_INTERPOLATION_BILINEAR); + g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY); + g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); + g2.drawImage(source, 0, 0, + halfWidth, halfHeight, 0, 0, + sourceWidth, sourceHeight, + null); + g2.dispose(); + + sourceWidth = halfWidth; + sourceHeight = halfHeight; + source = scaled; + iterations--; + } + return scaled; + } + } + + /** + * Returns true if the given file path points to an image file recognized by + * Android. See http://developer.android.com/guide/appendix/media-formats.html + * for details. + * + * @param path the filename to be tested + * @return true if the file represents an image file + */ + public static boolean hasImageExtension(String path) { + return endsWithIgnoreCase(path, DOT_PNG) + || endsWithIgnoreCase(path, DOT_9PNG) + || endsWithIgnoreCase(path, DOT_GIF) + || endsWithIgnoreCase(path, DOT_JPG) + || endsWithIgnoreCase(path, DOT_BMP); + } + + /** + * Creates a new image of the given size filled with the given color + * + * @param width the width of the image + * @param height the height of the image + * @param color the color of the image + * @return a new image of the given size filled with the given color + */ + public static BufferedImage createColoredImage(int width, int height, RGB color) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics g = image.getGraphics(); + g.setColor(new Color(color.red, color.green, color.blue)); + g.fillRect(0, 0, image.getWidth(), image.getHeight()); + g.dispose(); + return image; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/IncludeFinder.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/IncludeFinder.java new file mode 100644 index 000000000..7bab914e5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/IncludeFinder.java @@ -0,0 +1,1111 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ATTR_LAYOUT; +import static com.android.SdkConstants.EXT_XML; +import static com.android.SdkConstants.FD_RESOURCES; +import static com.android.SdkConstants.FD_RES_LAYOUT; +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; +import static com.android.ide.eclipse.adt.AdtConstants.WS_LAYOUTS; +import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP; +import static com.android.resources.ResourceType.LAYOUT; +import static org.eclipse.core.resources.IResourceDelta.ADDED; +import static org.eclipse.core.resources.IResourceDelta.CHANGED; +import static org.eclipse.core.resources.IResourceDelta.CONTENT; +import static org.eclipse.core.resources.IResourceDelta.REMOVED; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.resources.ResourceFile; +import com.android.ide.common.resources.ResourceFolder; +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager.IResourceListener; +import com.android.ide.eclipse.adt.io.IFileWrapper; +import com.android.io.IAbstractFile; +import com.android.resources.ResourceType; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.swt.widgets.Display; +import org.eclipse.wst.sse.core.StructuredModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The include finder finds other XML files that are including a given XML file, and does + * so efficiently (caching results across IDE sessions etc). + */ +@SuppressWarnings("restriction") // XML model +public class IncludeFinder { + /** Qualified name for the per-project persistent property include-map */ + private final static QualifiedName CONFIG_INCLUDES = new QualifiedName(AdtPlugin.PLUGIN_ID, + "includes");//$NON-NLS-1$ + + /** + * Qualified name for the per-project non-persistent property storing the + * {@link IncludeFinder} for this project + */ + private final static QualifiedName INCLUDE_FINDER = new QualifiedName(AdtPlugin.PLUGIN_ID, + "includefinder"); //$NON-NLS-1$ + + /** Project that the include finder locates includes for */ + private final IProject mProject; + + /** Map from a layout resource name to a set of layouts included by the given resource */ + private Map<String, List<String>> mIncludes = null; + + /** + * Reverse map of {@link #mIncludes}; points to other layouts that are including a + * given layouts + */ + private Map<String, List<String>> mIncludedBy = null; + + /** Flag set during a refresh; ignore updates when this is true */ + private static boolean sRefreshing; + + /** Global (cross-project) resource listener */ + private static ResourceListener sListener; + + /** + * Constructs an {@link IncludeFinder} for the given project. Don't use this method; + * use the {@link #get} factory method instead. + * + * @param project project to create an {@link IncludeFinder} for + */ + private IncludeFinder(IProject project) { + mProject = project; + } + + /** + * Returns the {@link IncludeFinder} for the given project + * + * @param project the project the finder is associated with + * @return an {@link IncludeFinder} for the given project, never null + */ + @NonNull + public static IncludeFinder get(IProject project) { + IncludeFinder finder = null; + try { + finder = (IncludeFinder) project.getSessionProperty(INCLUDE_FINDER); + } catch (CoreException e) { + // Not a problem; we will just create a new one + } + + if (finder == null) { + finder = new IncludeFinder(project); + try { + project.setSessionProperty(INCLUDE_FINDER, finder); + } catch (CoreException e) { + AdtPlugin.log(e, "Can't store IncludeFinder"); + } + } + + return finder; + } + + /** + * Returns a list of resource names that are included by the given resource + * + * @param includer the resource name to return included layouts for + * @return the layouts included by the given resource + */ + private List<String> getIncludesFrom(String includer) { + ensureInitialized(); + + return mIncludes.get(includer); + } + + /** + * Gets the list of all other layouts that are including the given layout. + * + * @param included the file that is included + * @return the files that are including the given file, or null or empty + */ + @Nullable + public List<Reference> getIncludedBy(IResource included) { + ensureInitialized(); + String mapKey = getMapKey(included); + List<String> result = mIncludedBy.get(mapKey); + if (result == null) { + String name = getResourceName(included); + if (!name.equals(mapKey)) { + result = mIncludedBy.get(name); + } + } + + if (result != null && result.size() > 0) { + List<Reference> references = new ArrayList<Reference>(result.size()); + for (String s : result) { + references.add(new Reference(mProject, s)); + } + return references; + } else { + return null; + } + } + + /** + * Returns true if the given resource is included from some other layout in the + * project + * + * @param included the resource to check + * @return true if the file is included by some other layout + */ + public boolean isIncluded(IResource included) { + ensureInitialized(); + String mapKey = getMapKey(included); + List<String> result = mIncludedBy.get(mapKey); + if (result == null) { + String name = getResourceName(included); + if (!name.equals(mapKey)) { + result = mIncludedBy.get(name); + } + } + + return result != null && result.size() > 0; + } + + @VisibleForTesting + /* package */ List<String> getIncludedBy(String included) { + ensureInitialized(); + return mIncludedBy.get(included); + } + + /** Initialize the inclusion data structures, if not already done */ + private void ensureInitialized() { + if (mIncludes == null) { + // Initialize + if (!readSettings()) { + // Couldn't read settings: probably the first time this code is running + // so there is no known data about includes. + + // Yes, these should be multimaps! If we start using Guava replace + // these with multimaps. + mIncludes = new HashMap<String, List<String>>(); + mIncludedBy = new HashMap<String, List<String>>(); + + scanProject(); + saveSettings(); + } + } + } + + // ----- Persistence ----- + + /** + * Create a String serialization of the includes map. The map attempts to be compact; + * it strips out the @layout/ prefix, and eliminates the values for empty string + * values. The map can be restored by calling {@link #decodeMap}. The encoded String + * will have sorted keys. + * + * @param map the map to be serialized + * @return a serialization (never null) of the given map + */ + @VisibleForTesting + public static String encodeMap(Map<String, List<String>> map) { + StringBuilder sb = new StringBuilder(); + + if (map != null) { + // Process the keys in sorted order rather than just + // iterating over the entry set to ensure stable output + List<String> keys = new ArrayList<String>(map.keySet()); + Collections.sort(keys); + for (String key : keys) { + List<String> values = map.get(key); + + if (sb.length() > 0) { + sb.append(','); + } + sb.append(key); + if (values.size() > 0) { + sb.append('=').append('>'); + sb.append('{'); + boolean first = true; + for (String value : values) { + if (first) { + first = false; + } else { + sb.append(','); + } + sb.append(value); + } + sb.append('}'); + } + } + } + + return sb.toString(); + } + + /** + * Decodes the encoding (produced by {@link #encodeMap}) back into the original map, + * modulo any key sorting differences. + * + * @param encoded an encoding of a map created by {@link #encodeMap} + * @return a map corresponding to the encoded values, never null + */ + @VisibleForTesting + public static Map<String, List<String>> decodeMap(String encoded) { + HashMap<String, List<String>> map = new HashMap<String, List<String>>(); + + if (encoded.length() > 0) { + int i = 0; + int end = encoded.length(); + + while (i < end) { + + // Find key range + int keyBegin = i; + int keyEnd = i; + while (i < end) { + char c = encoded.charAt(i); + if (c == ',') { + break; + } else if (c == '=') { + i += 2; // Skip => + break; + } + i++; + keyEnd = i; + } + + List<String> values = new ArrayList<String>(); + // Find values + if (i < end && encoded.charAt(i) == '{') { + i++; + while (i < end) { + int valueBegin = i; + int valueEnd = i; + char c = 0; + while (i < end) { + c = encoded.charAt(i); + if (c == ',' || c == '}') { + valueEnd = i; + break; + } + i++; + } + if (valueEnd > valueBegin) { + values.add(encoded.substring(valueBegin, valueEnd)); + } + + if (c == '}') { + if (i < end-1 && encoded.charAt(i+1) == ',') { + i++; + } + break; + } + assert c == ','; + i++; + } + } + + String key = encoded.substring(keyBegin, keyEnd); + map.put(key, values); + i++; + } + } + + return map; + } + + /** + * Stores the settings in the persistent project storage. + */ + private void saveSettings() { + // Serialize the mIncludes map into a compact String. The mIncludedBy map can be + // inferred from it. + String encoded = encodeMap(mIncludes); + + try { + if (encoded.length() >= 2048) { + // The maximum length of a setting key is 2KB, according to the javadoc + // for the project class. It's unlikely that we'll + // hit this -- even with an average layout root name of 20 characters + // we can still store over a hundred names. But JUST IN CASE we run + // into this, we'll clear out the key in this name which means that the + // information will need to be recomputed in the next IDE session. + mProject.setPersistentProperty(CONFIG_INCLUDES, null); + } else { + String existing = mProject.getPersistentProperty(CONFIG_INCLUDES); + if (!encoded.equals(existing)) { + mProject.setPersistentProperty(CONFIG_INCLUDES, encoded); + } + } + } catch (CoreException e) { + AdtPlugin.log(e, "Can't store include settings"); + } + } + + /** + * Reads previously stored settings from the persistent project storage + * + * @return true iff settings were restored from the project + */ + private boolean readSettings() { + try { + String encoded = mProject.getPersistentProperty(CONFIG_INCLUDES); + if (encoded != null) { + mIncludes = decodeMap(encoded); + + // Set up a reverse map, pointing from included files to the files that + // included them + mIncludedBy = new HashMap<String, List<String>>(2 * mIncludes.size()); + for (Map.Entry<String, List<String>> entry : mIncludes.entrySet()) { + // File containing the <include> + String includer = entry.getKey(); + // Files being <include>'ed by the above file + List<String> included = entry.getValue(); + setIncludedBy(includer, included); + } + + return true; + } + } catch (CoreException e) { + AdtPlugin.log(e, "Can't read include settings"); + } + + return false; + } + + // ----- File scanning ----- + + /** + * Scan the whole project for XML layout resources that are performing includes. + */ + private void scanProject() { + ProjectResources resources = ResourceManager.getInstance().getProjectResources(mProject); + if (resources != null) { + Collection<ResourceItem> layouts = resources.getResourceItemsOfType(LAYOUT); + for (ResourceItem layout : layouts) { + List<ResourceFile> sources = layout.getSourceFileList(); + for (ResourceFile source : sources) { + updateFileIncludes(source, false); + } + } + + return; + } + } + + /** + * Scans the given {@link ResourceFile} and if it is a layout resource, updates the + * includes in it. + * + * @param resourceFile the {@link ResourceFile} to be scanned for includes (doesn't + * have to be only layout XML files; this method will filter the type) + * @param singleUpdate true if this is a single file being updated, false otherwise + * (e.g. during initial project scanning) + * @return true if we updated the includes for the resource file + */ + private boolean updateFileIncludes(ResourceFile resourceFile, boolean singleUpdate) { + Collection<ResourceType> resourceTypes = resourceFile.getResourceTypes(); + for (ResourceType type : resourceTypes) { + if (type == ResourceType.LAYOUT) { + ensureInitialized(); + + List<String> includes = Collections.emptyList(); + if (resourceFile.getFile() instanceof IFileWrapper) { + IFile file = ((IFileWrapper) resourceFile.getFile()).getIFile(); + + // See if we have an existing XML model for this file; if so, we can + // just look directly at the parse tree + boolean hadXmlModel = false; + IStructuredModel model = null; + try { + IModelManager modelManager = StructuredModelManager.getModelManager(); + model = modelManager.getExistingModelForRead(file); + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + Document document = domModel.getDocument(); + includes = findIncludesInDocument(document); + hadXmlModel = true; + } + } finally { + if (model != null) { + model.releaseFromRead(); + } + } + + // If no XML model we have to read the XML contents and (possibly) parse it. + // The actual file may not exist anymore (e.g. when deleting a layout file + // or when the workspace is out of sync.) + if (!hadXmlModel) { + String xml = AdtPlugin.readFile(file); + if (xml != null) { + includes = findIncludes(xml); + } + } + } else { + String xml = AdtPlugin.readFile(resourceFile); + if (xml != null) { + includes = findIncludes(xml); + } + } + + String key = getMapKey(resourceFile); + if (includes.equals(getIncludesFrom(key))) { + // Common case -- so avoid doing settings flush etc + return false; + } + + boolean detectCycles = singleUpdate; + setIncluded(key, includes, detectCycles); + + if (singleUpdate) { + saveSettings(); + } + + return true; + } + } + + return false; + } + + /** + * Finds the list of includes in the given XML content. It attempts quickly return + * empty if the file does not include any include tags; it does this by only parsing + * if it detects the string <include in the file. + */ + @VisibleForTesting + @NonNull + static List<String> findIncludes(@NonNull String xml) { + int index = xml.indexOf(ATTR_LAYOUT); + if (index != -1) { + return findIncludesInXml(xml); + } + + return Collections.emptyList(); + } + + /** + * Parses the given XML content and extracts all the included URLs and returns them + * + * @param xml layout XML content to be parsed for includes + * @return a list of included urls, or null + */ + @VisibleForTesting + @NonNull + static List<String> findIncludesInXml(@NonNull String xml) { + Document document = DomUtilities.parseDocument(xml, false /*logParserErrors*/); + if (document != null) { + return findIncludesInDocument(document); + } + + return Collections.emptyList(); + } + + /** Searches the given DOM document and returns the list of includes, if any */ + @NonNull + private static List<String> findIncludesInDocument(@NonNull Document document) { + List<String> includes = findIncludesInDocument(document, null); + if (includes == null) { + includes = Collections.emptyList(); + } + return includes; + } + + @Nullable + private static List<String> findIncludesInDocument(@NonNull Node node, + @Nullable List<String> urls) { + if (node.getNodeType() == Node.ELEMENT_NODE) { + String tag = node.getNodeName(); + boolean isInclude = tag.equals(VIEW_INCLUDE); + boolean isFragment = tag.equals(VIEW_FRAGMENT); + if (isInclude || isFragment) { + Element element = (Element) node; + String url; + if (isInclude) { + url = element.getAttribute(ATTR_LAYOUT); + } else { + url = element.getAttributeNS(TOOLS_URI, ATTR_LAYOUT); + } + if (url.length() > 0) { + String resourceName = urlToLocalResource(url); + if (resourceName != null) { + if (urls == null) { + urls = new ArrayList<String>(); + } + urls.add(resourceName); + } + } + + } + } + + NodeList children = node.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + urls = findIncludesInDocument(children.item(i), urls); + } + + return urls; + } + + + /** + * Returns the layout URL to a local resource name (provided the URL is a local + * resource, not something in @android etc.) Returns null otherwise. + */ + private static String urlToLocalResource(String url) { + if (!url.startsWith("@")) { //$NON-NLS-1$ + return null; + } + int typeEnd = url.indexOf('/', 1); + if (typeEnd == -1) { + return null; + } + int nameBegin = typeEnd + 1; + int typeBegin = 1; + int colon = url.lastIndexOf(':', typeEnd); + if (colon != -1) { + String packageName = url.substring(typeBegin, colon); + if ("android".equals(packageName)) { //$NON-NLS-1$ + // Don't want to point to non-local resources + return null; + } + + typeBegin = colon + 1; + assert "layout".equals(url.substring(typeBegin, typeEnd)); //$NON-NLS-1$ + } + + return url.substring(nameBegin); + } + + /** + * Record the list of included layouts from the given layout + * + * @param includer the layout including other layouts + * @param included the layouts that were included by the including layout + * @param detectCycles if true, check for cycles and report them as project errors + */ + @VisibleForTesting + /* package */ void setIncluded(String includer, List<String> included, boolean detectCycles) { + // Remove previously linked inverse mappings + List<String> oldIncludes = mIncludes.get(includer); + if (oldIncludes != null && oldIncludes.size() > 0) { + for (String includee : oldIncludes) { + List<String> includers = mIncludedBy.get(includee); + if (includers != null) { + includers.remove(includer); + } + } + } + + mIncludes.put(includer, included); + // Reverse mapping: for included items, point back to including file + setIncludedBy(includer, included); + + if (detectCycles) { + detectCycles(includer); + } + } + + /** Record the list of included layouts from the given layout */ + private void setIncludedBy(String includer, List<String> included) { + for (String target : included) { + List<String> list = mIncludedBy.get(target); + if (list == null) { + list = new ArrayList<String>(2); // We don't expect many includes + mIncludedBy.put(target, list); + } + if (!list.contains(includer)) { + list.add(includer); + } + } + } + + /** Start listening on project resources */ + public static void start() { + assert sListener == null; + sListener = new ResourceListener(); + ResourceManager.getInstance().addListener(sListener); + } + + /** Stop listening on project resources */ + public static void stop() { + assert sListener != null; + ResourceManager.getInstance().addListener(sListener); + } + + private static String getMapKey(ResourceFile resourceFile) { + IAbstractFile file = resourceFile.getFile(); + String name = file.getName(); + String folderName = file.getParentFolder().getName(); + return getMapKey(folderName, name); + } + + private static String getMapKey(IResource resourceFile) { + String folderName = resourceFile.getParent().getName(); + String name = resourceFile.getName(); + return getMapKey(folderName, name); + } + + private static String getResourceName(IResource resourceFile) { + String name = resourceFile.getName(); + int baseEnd = name.length() - EXT_XML.length() - 1; // -1: the dot + if (baseEnd > 0) { + name = name.substring(0, baseEnd); + } + + return name; + } + + private static String getMapKey(String folderName, String name) { + int baseEnd = name.length() - EXT_XML.length() - 1; // -1: the dot + if (baseEnd > 0) { + name = name.substring(0, baseEnd); + } + + // Create a map key for the given resource file + // This will map + // /res/layout/foo.xml => "foo" + // /res/layout-land/foo.xml => "-land/foo" + + if (FD_RES_LAYOUT.equals(folderName)) { + // Normal case -- keep just the basename + return name; + } else { + // Store the relative path from res/ on down, so + // /res/layout-land/foo.xml becomes "layout-land/foo" + //if (folderName.startsWith(FD_LAYOUT)) { + // folderName = folderName.substring(FD_LAYOUT.length()); + //} + + return folderName + WS_SEP + name; + } + } + + /** Listener of resource file saves, used to update layout inclusion data structures */ + private static class ResourceListener implements IResourceListener { + @Override + public void fileChanged(IProject project, ResourceFile file, int eventType) { + if (sRefreshing) { + return; + } + + if ((eventType & (CHANGED | ADDED | REMOVED | CONTENT)) == 0) { + return; + } + + IncludeFinder finder = get(project); + if (finder != null) { + if (finder.updateFileIncludes(file, true)) { + finder.saveSettings(); + } + } + } + + @Override + public void folderChanged(IProject project, ResourceFolder folder, int eventType) { + // We only care about layout resource files + } + } + + // ----- Cycle detection ----- + + private void detectCycles(String from) { + // Perform DFS on the include graph and look for a cycle; if we find one, produce + // a chain of includes on the way back to show to the user + if (mIncludes.size() > 0) { + Set<String> visiting = new HashSet<String>(mIncludes.size()); + String chain = dfs(from, visiting); + if (chain != null) { + addError(from, chain); + } else { + // Is there an existing error for us to clean up? + removeErrors(from); + } + } + } + + /** Format to chain include cycles in: a=>b=>c=>d etc */ + private final String CHAIN_FORMAT = "%1$s=>%2$s"; //$NON-NLS-1$ + + private String dfs(String from, Set<String> visiting) { + visiting.add(from); + + List<String> includes = mIncludes.get(from); + if (includes != null && includes.size() > 0) { + for (String include : includes) { + if (visiting.contains(include)) { + return String.format(CHAIN_FORMAT, from, include); + } + String chain = dfs(include, visiting); + if (chain != null) { + return String.format(CHAIN_FORMAT, from, chain); + } + } + } + + visiting.remove(from); + + return null; + } + + private void removeErrors(String from) { + final IResource resource = findResource(from); + if (resource != null) { + try { + final String markerId = IMarker.PROBLEM; + + IMarker[] markers = resource.findMarkers(markerId, true, IResource.DEPTH_ZERO); + + for (final IMarker marker : markers) { + String tmpMsg = marker.getAttribute(IMarker.MESSAGE, null); + if (tmpMsg == null || tmpMsg.startsWith(MESSAGE)) { + // Remove + runLater(new Runnable() { + @Override + public void run() { + try { + sRefreshing = true; + marker.delete(); + } catch (CoreException e) { + AdtPlugin.log(e, "Can't delete problem marker"); + } finally { + sRefreshing = false; + } + } + }); + } + } + } catch (CoreException e) { + // if we couldn't get the markers, then we just mark the file again + // (since markerAlreadyExists is initialized to false, we do nothing) + } + } + } + + /** Error message for cycles */ + private static final String MESSAGE = "Found cyclical <include> chain"; + + private void addError(String from, String chain) { + final IResource resource = findResource(from); + if (resource != null) { + final String markerId = IMarker.PROBLEM; + final String message = String.format("%1$s: %2$s", MESSAGE, chain); + final int lineNumber = 1; + final int severity = IMarker.SEVERITY_ERROR; + + // check if there's a similar marker already, since aapt is launched twice + boolean markerAlreadyExists = false; + try { + IMarker[] markers = resource.findMarkers(markerId, true, IResource.DEPTH_ZERO); + + for (IMarker marker : markers) { + int tmpLine = marker.getAttribute(IMarker.LINE_NUMBER, -1); + if (tmpLine != lineNumber) { + break; + } + + int tmpSeverity = marker.getAttribute(IMarker.SEVERITY, -1); + if (tmpSeverity != severity) { + break; + } + + String tmpMsg = marker.getAttribute(IMarker.MESSAGE, null); + if (tmpMsg == null || tmpMsg.equals(message) == false) { + break; + } + + // if we're here, all the marker attributes are equals, we found it + // and exit + markerAlreadyExists = true; + break; + } + + } catch (CoreException e) { + // if we couldn't get the markers, then we just mark the file again + // (since markerAlreadyExists is initialized to false, we do nothing) + } + + if (!markerAlreadyExists) { + runLater(new Runnable() { + @Override + public void run() { + try { + sRefreshing = true; + + // Adding a resource will force a refresh on the file; + // ignore these updates + BaseProjectHelper.markResource(resource, markerId, message, lineNumber, + severity); + } finally { + sRefreshing = false; + } + } + }); + } + } + } + + // FIXME: Find more standard Eclipse way to do this. + // We need to run marker registration/deletion "later", because when the include + // scanning is running it's in the middle of resource notification, so the IDE + // throws an exception + private static void runLater(Runnable runnable) { + Display display = Display.findDisplay(Thread.currentThread()); + if (display != null) { + display.asyncExec(runnable); + } else { + AdtPlugin.log(IStatus.WARNING, "Could not find display"); + } + } + + /** + * Finds the project resource for the given layout path + * + * @param from the resource name + * @return the {@link IResource}, or null if not found + */ + private IResource findResource(String from) { + final IResource resource = mProject.findMember(WS_LAYOUTS + WS_SEP + from + '.' + EXT_XML); + return resource; + } + + /** + * Creates a blank, project-less {@link IncludeFinder} <b>for use by unit tests + * only</b> + */ + @VisibleForTesting + /* package */ static IncludeFinder create() { + IncludeFinder finder = new IncludeFinder(null); + finder.mIncludes = new HashMap<String, List<String>>(); + finder.mIncludedBy = new HashMap<String, List<String>>(); + return finder; + } + + /** A reference to a particular file in the project */ + public static class Reference { + /** The unique id referencing the file, such as (for res/layout-land/main.xml) + * "layout-land/main") */ + private final String mId; + + /** The project containing the file */ + private final IProject mProject; + + /** The resource name of the file, such as (for res/layout/main.xml) "main" */ + private String mName; + + /** Creates a new include reference */ + private Reference(IProject project, String id) { + super(); + mProject = project; + mId = id; + } + + /** + * Returns the id identifying the given file within the project + * + * @return the id identifying the given file within the project + */ + public String getId() { + return mId; + } + + /** + * Returns the {@link IFile} in the project for the given file. May return null if + * there is an error in locating the file or if the file no longer exists. + * + * @return the project file, or null + */ + public IFile getFile() { + String reference = mId; + if (!reference.contains(WS_SEP)) { + reference = FD_RES_LAYOUT + WS_SEP + reference; + } + + String projectPath = FD_RESOURCES + WS_SEP + reference + '.' + EXT_XML; + IResource member = mProject.findMember(projectPath); + if (member instanceof IFile) { + return (IFile) member; + } + + return null; + } + + /** + * Returns a description of this reference, suitable to be shown to the user + * + * @return a display name for the reference + */ + public String getDisplayName() { + // The ID is deliberately kept in a pretty user-readable format but we could + // consider prepending layout/ on ids that don't have it (to make the display + // more uniform) or ripping out all layout[-constraint] prefixes out and + // instead prepending @ etc. + return mId; + } + + /** + * Returns the name of the reference, suitable for resource lookup. For example, + * for "res/layout/main.xml", as well as for "res/layout-land/main.xml", this + * would be "main". + * + * @return the resource name of the reference + */ + public String getName() { + if (mName == null) { + mName = mId; + int index = mName.lastIndexOf(WS_SEP); + if (index != -1) { + mName = mName.substring(index + 1); + } + } + + return mName; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((mId == null) ? 0 : mId.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Reference other = (Reference) obj; + if (mId == null) { + if (other.mId != null) + return false; + } else if (!mId.equals(other.mId)) + return false; + return true; + } + + @Override + public String toString() { + return "Reference [getId()=" + getId() //$NON-NLS-1$ + + ", getDisplayName()=" + getDisplayName() //$NON-NLS-1$ + + ", getName()=" + getName() //$NON-NLS-1$ + + ", getFile()=" + getFile() + "]"; //$NON-NLS-1$ + } + + /** + * Creates a reference to the given file + * + * @param file the file to create a reference for + * @return a reference to the given file + */ + public static Reference create(IFile file) { + return new Reference(file.getProject(), getMapKey(file)); + } + + /** + * Returns the resource name of this layout, such as {@code @layout/foo}. + * + * @return the resource name + */ + public String getResourceName() { + return '@' + FD_RES_LAYOUT + '/' + getName(); + } + } + + /** + * Returns a collection of layouts (expressed as resource names, such as + * {@code @layout/foo} which would be invalid includes in the given layout + * (because it would introduce a cycle) + * + * @param layout the layout file to check for cyclic dependencies from + * @return a collection of layout resources which cannot be included from + * the given layout, never null + */ + public Collection<String> getInvalidIncludes(IFile layout) { + IProject project = layout.getProject(); + Reference self = Reference.create(layout); + + // Add anyone who transitively can reach this file via includes. + LinkedList<Reference> queue = new LinkedList<Reference>(); + List<Reference> invalid = new ArrayList<Reference>(); + queue.add(self); + invalid.add(self); + Set<String> seen = new HashSet<String>(); + seen.add(self.getId()); + while (!queue.isEmpty()) { + Reference reference = queue.removeFirst(); + String refId = reference.getId(); + + // Look up both configuration specific includes as well as includes in the + // base versions + List<String> included = getIncludedBy(refId); + if (refId.indexOf('/') != -1) { + List<String> baseIncluded = getIncludedBy(reference.getName()); + if (included == null) { + included = baseIncluded; + } else if (baseIncluded != null) { + included = new ArrayList<String>(included); + included.addAll(baseIncluded); + } + } + + if (included != null && included.size() > 0) { + for (String id : included) { + if (!seen.contains(id)) { + seen.add(id); + Reference ref = new Reference(project, id); + invalid.add(ref); + queue.addLast(ref); + } + } + } + } + + List<String> result = new ArrayList<String>(); + for (Reference reference : invalid) { + result.add(reference.getResourceName()); + } + + return result; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/IncludeOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/IncludeOverlay.java new file mode 100644 index 000000000..81c03edd5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/IncludeOverlay.java @@ -0,0 +1,150 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.VisibleForTesting; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Rectangle; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * The {@link IncludeOverlay} class renders masks to -partially- hide everything outside + * an included file's own content. This overlay is in use when you are editing an included + * file shown within a different file's context (e.g. "Show In > other"). + */ +public class IncludeOverlay extends Overlay { + /** Mask transparency - 0 is transparent, 255 is opaque */ + private static final int MASK_TRANSPARENCY = 160; + + /** The associated {@link LayoutCanvas}. */ + private LayoutCanvas mCanvas; + + /** + * Constructs an {@link IncludeOverlay} tied to the given canvas. + * + * @param canvas The {@link LayoutCanvas} to paint the overlay over. + */ + public IncludeOverlay(LayoutCanvas canvas) { + mCanvas = canvas; + } + + @Override + public void paint(GC gc) { + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + List<Rectangle> includedBounds = viewHierarchy.getIncludedBounds(); + if (includedBounds == null || includedBounds.size() == 0) { + // We don't support multiple included children yet. When that works, + // this code should use a BSP tree to figure out which regions to paint + // to leave holes in the mask. + return; + } + + Image image = mCanvas.getImageOverlay().getImage(); + if (image == null) { + return; + } + + int oldAlpha = gc.getAlpha(); + gc.setAlpha(MASK_TRANSPARENCY); + Color bg = gc.getDevice().getSystemColor(SWT.COLOR_WIDGET_BACKGROUND); + gc.setBackground(bg); + + CanvasViewInfo root = viewHierarchy.getRoot(); + Rectangle whole = root.getAbsRect(); + whole = new Rectangle(whole.x, whole.y, whole.width + 1, whole.height + 1); + Collection<Rectangle> masks = subtractRectangles(whole, includedBounds); + + for (Rectangle mask : masks) { + ControlPoint topLeft = LayoutPoint.create(mCanvas, mask.x, mask.y).toControl(); + ControlPoint bottomRight = LayoutPoint.create(mCanvas, mask.x + mask.width, + mask.y + mask.height).toControl(); + int x1 = topLeft.x; + int y1 = topLeft.y; + int x2 = bottomRight.x; + int y2 = bottomRight.y; + + gc.fillRectangle(x1, y1, x2 - x1, y2 - y1); + } + + gc.setAlpha(oldAlpha); + } + + /** + * Given a Rectangle, remove holes from it (specified as a collection of Rectangles) such + * that the result is a list of rectangles that cover everything that is not a hole. + * + * @param rectangle the rectangle to subtract from + * @param holes the holes to subtract from the rectangle + * @return a list of sub rectangles that remain after subtracting out the given list of holes + */ + @VisibleForTesting + static Collection<Rectangle> subtractRectangles( + Rectangle rectangle, Collection<Rectangle> holes) { + List<Rectangle> result = new ArrayList<Rectangle>(); + result.add(rectangle); + + for (Rectangle hole : holes) { + List<Rectangle> tempResult = new ArrayList<Rectangle>(); + for (Rectangle r : result) { + if (hole.intersects(r)) { + // Clip the hole to fit the rectangle bounds + Rectangle h = hole.intersection(r); + + // Split the rectangle + + // Above (includes the NW and NE corners) + if (h.y > r.y) { + tempResult.add(new Rectangle(r.x, r.y, r.width, h.y - r.y)); + } + + // Left (not including corners) + if (h.x > r.x) { + tempResult.add(new Rectangle(r.x, h.y, h.x - r.x, h.height)); + } + + int hx2 = h.x + h.width; + int hy2 = h.y + h.height; + int rx2 = r.x + r.width; + int ry2 = r.y + r.height; + + // Below (includes the SW and SE corners) + if (hy2 < ry2) { + tempResult.add(new Rectangle(r.x, hy2, r.width, ry2 - hy2)); + } + + // Right (not including corners) + if (hx2 < rx2) { + tempResult.add(new Rectangle(hx2, h.y, rx2 - hx2, h.height)); + } + } else { + tempResult.add(r); + } + } + + result = tempResult; + } + + return result; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutActionBar.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutActionBar.java new file mode 100644 index 000000000..1b1bd23c4 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutActionBar.java @@ -0,0 +1,732 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.RuleAction.Choices; +import com.android.ide.common.api.RuleAction.Separator; +import com.android.ide.common.api.RuleAction.Toggle; +import com.android.ide.common.layout.BaseViewRule; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.lint.EclipseLintClient; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.Screen; +import com.android.sdkuilib.internal.widgets.ResolutionChooserDialog; +import com.google.common.base.Strings; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.swt.widgets.ToolItem; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Toolbar shown at the top of the layout editor, which adds a number of context-sensitive + * layout actions (as well as zooming controls on the right). + */ +public class LayoutActionBar extends Composite { + private GraphicalEditorPart mEditor; + private ToolBar mLayoutToolBar; + private ToolBar mLintToolBar; + private ToolBar mZoomToolBar; + private ToolItem mZoomRealSizeButton; + private ToolItem mZoomOutButton; + private ToolItem mZoomResetButton; + private ToolItem mZoomInButton; + private ToolItem mZoomFitButton; + private ToolItem mLintButton; + private List<RuleAction> mPrevActions; + + /** + * Creates a new {@link LayoutActionBar} and adds it to the given parent. + * + * @param parent the parent composite to add the actions bar to + * @param style the SWT style to apply + * @param editor the associated layout editor + */ + public LayoutActionBar(Composite parent, int style, GraphicalEditorPart editor) { + super(parent, style | SWT.NO_FOCUS); + mEditor = editor; + + GridLayout layout = new GridLayout(3, false); + setLayout(layout); + + mLayoutToolBar = new ToolBar(this, /*SWT.WRAP |*/ SWT.FLAT | SWT.RIGHT | SWT.HORIZONTAL); + mLayoutToolBar.setLayoutData(new GridData(SWT.FILL, SWT.BEGINNING, true, false)); + mZoomToolBar = createZoomControls(); + mZoomToolBar.setLayoutData(new GridData(SWT.END, SWT.BEGINNING, false, false)); + mLintToolBar = createLintControls(); + + GridData lintData = new GridData(SWT.END, SWT.BEGINNING, false, false); + lintData.exclude = true; + mLintToolBar.setLayoutData(lintData); + } + + @Override + public void dispose() { + super.dispose(); + mPrevActions = null; + } + + /** Updates the layout contents based on the current selection */ + void updateSelection() { + NodeProxy parent = null; + LayoutCanvas canvas = mEditor.getCanvasControl(); + SelectionManager selectionManager = canvas.getSelectionManager(); + List<SelectionItem> selections = selectionManager.getSelections(); + if (selections.size() > 0) { + // TODO: better handle multi-selection -- maybe we should disable it or + // something. + // What if you select children with different parents? Of different types? + // etc. + NodeProxy node = selections.get(0).getNode(); + if (node != null && node.getParent() != null) { + parent = (NodeProxy) node.getParent(); + } + } + + if (parent == null) { + // Show the background's properties + CanvasViewInfo root = canvas.getViewHierarchy().getRoot(); + if (root == null) { + return; + } + parent = canvas.getNodeFactory().create(root); + selections = Collections.emptyList(); + } + + RulesEngine engine = mEditor.getRulesEngine(); + List<NodeProxy> selectedNodes = new ArrayList<NodeProxy>(); + for (SelectionItem item : selections) { + selectedNodes.add(item.getNode()); + } + List<RuleAction> actions = new ArrayList<RuleAction>(); + engine.callAddLayoutActions(actions, parent, selectedNodes); + + // Place actions in the correct order (the actions may come from different + // rules and should be merged properly via sorting keys) + Collections.sort(actions); + + // Add in actions for the child as well, if there is exactly one. + // These are not merged into the parent list of actions; they are appended + // at the end. + int index = -1; + String label = null; + if (selectedNodes.size() == 1) { + List<RuleAction> itemActions = new ArrayList<RuleAction>(); + NodeProxy selectedNode = selectedNodes.get(0); + engine.callAddLayoutActions(itemActions, selectedNode, null); + if (itemActions.size() > 0) { + Collections.sort(itemActions); + + if (!(itemActions.get(0) instanceof RuleAction.Separator)) { + actions.add(RuleAction.createSeparator(0)); + } + label = selectedNode.getStringAttr(ANDROID_URI, ATTR_ID); + if (label != null) { + label = BaseViewRule.stripIdPrefix(label); + index = actions.size(); + } + actions.addAll(itemActions); + } + } + + if (!updateActions(actions)) { + updateToolbar(actions, index, label); + } + mPrevActions = actions; + } + + /** Update the toolbar widgets */ + private void updateToolbar(final List<RuleAction> actions, final int labelIndex, + final String label) { + if (mLayoutToolBar == null || mLayoutToolBar.isDisposed()) { + return; + } + for (ToolItem c : mLayoutToolBar.getItems()) { + c.dispose(); + } + mLayoutToolBar.pack(); + addActions(actions, labelIndex, label); + mLayoutToolBar.pack(); + mLayoutToolBar.layout(); + } + + /** + * Attempts to update the existing toolbar actions, if the action list is + * similar to the current list. Returns false if this cannot be done and the + * contents must be replaced. + */ + private boolean updateActions(@NonNull List<RuleAction> actions) { + List<RuleAction> before = mPrevActions; + List<RuleAction> after = actions; + + if (before == null) { + return false; + } + + if (!before.equals(after) || after.size() > mLayoutToolBar.getItemCount()) { + return false; + } + + int actionIndex = 0; + for (int i = 0, max = mLayoutToolBar.getItemCount(); i < max; i++) { + ToolItem item = mLayoutToolBar.getItem(i); + int style = item.getStyle(); + Object data = item.getData(); + if (data != null) { + // One action can result in multiple toolbar items (e.g. a choice action + // can result in multiple radio buttons), so we've have to replace all of + // them with the corresponding new action + RuleAction prevAction = before.get(actionIndex); + while (prevAction != data) { + actionIndex++; + if (actionIndex == before.size()) { + return false; + } + prevAction = before.get(actionIndex); + if (prevAction == data) { + break; + } else if (!(prevAction instanceof RuleAction.Separator)) { + return false; + } + } + RuleAction newAction = after.get(actionIndex); + assert newAction.equals(prevAction); // Maybe I can do this lazily instead? + + // Update action binding to the new action + item.setData(newAction); + + // Sync button states: the checked state is not considered part of + // RuleAction equality + if ((style & SWT.CHECK) != 0) { + assert newAction instanceof Toggle; + Toggle toggle = (Toggle) newAction; + item.setSelection(toggle.isChecked()); + } else if ((style & SWT.RADIO) != 0) { + assert newAction instanceof Choices; + Choices choices = (Choices) newAction; + String current = choices.getCurrent(); + String id = (String) item.getData(ATTR_ID); + boolean selected = Strings.nullToEmpty(current).equals(id); + item.setSelection(selected); + } + } else { + // Must be a separator, or a label (which we insert for nested widgets) + assert (style & SWT.SEPARATOR) != 0 || !item.getText().isEmpty() : item; + } + } + + return true; + } + + private void addActions(List<RuleAction> actions, int labelIndex, String label) { + if (actions.size() > 0) { + // Flag used to indicate that if there are any actions -after- this, it + // should be separated from this current action (we don't unconditionally + // add a separator at the end of these groups in case there are no more + // actions at the end so that we don't have a trailing separator) + boolean needSeparator = false; + + int index = 0; + for (RuleAction action : actions) { + if (index == labelIndex) { + final ToolItem button = new ToolItem(mLayoutToolBar, SWT.PUSH); + button.setText(label); + needSeparator = false; + } + index++; + + if (action instanceof Separator) { + addSeparator(mLayoutToolBar); + needSeparator = false; + continue; + } else if (needSeparator) { + addSeparator(mLayoutToolBar); + needSeparator = false; + } + + if (action instanceof RuleAction.Choices) { + RuleAction.Choices choices = (Choices) action; + if (!choices.isRadio()) { + addDropdown(choices); + } else { + addSeparator(mLayoutToolBar); + addRadio(choices); + needSeparator = true; + } + } else if (action instanceof RuleAction.Toggle) { + addToggle((Toggle) action); + } else { + addPlainAction(action); + } + } + } + } + + /** Add a separator to the toolbar, unless there already is one there at the end already */ + private static void addSeparator(ToolBar toolBar) { + int n = toolBar.getItemCount(); + if (n > 0 && (toolBar.getItem(n - 1).getStyle() & SWT.SEPARATOR) == 0) { + ToolItem separator = new ToolItem(toolBar, SWT.SEPARATOR); + separator.setWidth(15); + } + } + + private void addToggle(Toggle toggle) { + final ToolItem button = new ToolItem(mLayoutToolBar, SWT.CHECK); + + URL iconUrl = toggle.getIconUrl(); + String title = toggle.getTitle(); + if (iconUrl != null) { + button.setImage(IconFactory.getInstance().getIcon(iconUrl)); + button.setToolTipText(title); + } else { + button.setText(title); + } + button.setData(toggle); + + button.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Toggle toggle = (Toggle) button.getData(); + toggle.getCallback().action(toggle, getSelectedNodes(), + toggle.getId(), button.getSelection()); + updateSelection(); + } + }); + if (toggle.isChecked()) { + button.setSelection(true); + } + } + + private List<INode> getSelectedNodes() { + List<SelectionItem> selections = + mEditor.getCanvasControl().getSelectionManager().getSelections(); + List<INode> nodes = new ArrayList<INode>(selections.size()); + for (SelectionItem item : selections) { + nodes.add(item.getNode()); + } + + return nodes; + } + + + private void addPlainAction(RuleAction menuAction) { + final ToolItem button = new ToolItem(mLayoutToolBar, SWT.PUSH); + + URL iconUrl = menuAction.getIconUrl(); + String title = menuAction.getTitle(); + if (iconUrl != null) { + button.setImage(IconFactory.getInstance().getIcon(iconUrl)); + button.setToolTipText(title); + } else { + button.setText(title); + } + button.setData(menuAction); + + button.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + RuleAction menuAction = (RuleAction) button.getData(); + menuAction.getCallback().action(menuAction, getSelectedNodes(), menuAction.getId(), + false); + updateSelection(); + } + }); + } + + private void addRadio(RuleAction.Choices choices) { + List<URL> icons = choices.getIconUrls(); + List<String> titles = choices.getTitles(); + List<String> ids = choices.getIds(); + String current = choices.getCurrent() != null ? choices.getCurrent() : ""; //$NON-NLS-1$ + + assert icons != null; + assert icons.size() == titles.size(); + + for (int i = 0; i < icons.size(); i++) { + URL iconUrl = icons.get(i); + String title = titles.get(i); + final String id = ids.get(i); + final ToolItem item = new ToolItem(mLayoutToolBar, SWT.RADIO); + item.setToolTipText(title); + item.setImage(IconFactory.getInstance().getIcon(iconUrl)); + item.setData(choices); + item.setData(ATTR_ID, id); + item.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (item.getSelection()) { + RuleAction.Choices choices = (Choices) item.getData(); + choices.getCallback().action(choices, getSelectedNodes(), id, null); + updateSelection(); + } + } + }); + boolean selected = current.equals(id); + if (selected) { + item.setSelection(true); + } + } + } + + private void addDropdown(RuleAction.Choices choices) { + final ToolItem combo = new ToolItem(mLayoutToolBar, SWT.DROP_DOWN); + URL iconUrl = choices.getIconUrl(); + if (iconUrl != null) { + combo.setImage(IconFactory.getInstance().getIcon(iconUrl)); + combo.setToolTipText(choices.getTitle()); + } else { + combo.setText(choices.getTitle()); + } + combo.setData(choices); + + Listener menuListener = new Listener() { + @Override + public void handleEvent(Event event) { + Menu menu = new Menu(mLayoutToolBar.getShell(), SWT.POP_UP); + RuleAction.Choices choices = (Choices) combo.getData(); + List<URL> icons = choices.getIconUrls(); + List<String> titles = choices.getTitles(); + List<String> ids = choices.getIds(); + String current = choices.getCurrent() != null ? choices.getCurrent() : ""; //$NON-NLS-1$ + + for (int i = 0; i < titles.size(); i++) { + String title = titles.get(i); + final String id = ids.get(i); + URL itemIconUrl = icons != null && icons.size() > 0 ? icons.get(i) : null; + MenuItem item = new MenuItem(menu, SWT.CHECK); + item.setText(title); + if (itemIconUrl != null) { + Image itemIcon = IconFactory.getInstance().getIcon(itemIconUrl); + item.setImage(itemIcon); + } + + boolean selected = id.equals(current); + if (selected) { + item.setSelection(true); + } + + item.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + RuleAction.Choices choices = (Choices) combo.getData(); + choices.getCallback().action(choices, getSelectedNodes(), id, null); + updateSelection(); + } + }); + } + + Rectangle bounds = combo.getBounds(); + Point location = new Point(bounds.x, bounds.y + bounds.height); + location = combo.getParent().toDisplay(location); + menu.setLocation(location.x, location.y); + menu.setVisible(true); + } + }; + combo.addListener(SWT.Selection, menuListener); + } + + // ---- Zoom Controls ---- + + @SuppressWarnings("unused") // SWT constructors have side effects, they are not unused + private ToolBar createZoomControls() { + ToolBar toolBar = new ToolBar(this, SWT.FLAT | SWT.RIGHT | SWT.HORIZONTAL); + + IconFactory iconFactory = IconFactory.getInstance(); + mZoomRealSizeButton = new ToolItem(toolBar, SWT.CHECK); + mZoomRealSizeButton.setToolTipText("Emulate Real Size"); + mZoomRealSizeButton.setImage(iconFactory.getIcon("zoomreal")); //$NON-NLS-1$); + mZoomRealSizeButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + boolean newState = mZoomRealSizeButton.getSelection(); + if (rescaleToReal(newState)) { + mZoomOutButton.setEnabled(!newState); + mZoomResetButton.setEnabled(!newState); + mZoomInButton.setEnabled(!newState); + mZoomFitButton.setEnabled(!newState); + } else { + mZoomRealSizeButton.setSelection(!newState); + } + } + }); + + mZoomFitButton = new ToolItem(toolBar, SWT.PUSH); + mZoomFitButton.setToolTipText("Zoom to Fit (0)"); + mZoomFitButton.setImage(iconFactory.getIcon("zoomfit")); //$NON-NLS-1$); + mZoomFitButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + rescaleToFit(true); + } + }); + + mZoomResetButton = new ToolItem(toolBar, SWT.PUSH); + mZoomResetButton.setToolTipText("Reset Zoom to 100% (1)"); + mZoomResetButton.setImage(iconFactory.getIcon("zoom100")); //$NON-NLS-1$); + mZoomResetButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + resetScale(); + } + }); + + // Group zoom in/out separately + new ToolItem(toolBar, SWT.SEPARATOR); + + mZoomOutButton = new ToolItem(toolBar, SWT.PUSH); + mZoomOutButton.setToolTipText("Zoom Out (-)"); + mZoomOutButton.setImage(iconFactory.getIcon("zoomminus")); //$NON-NLS-1$); + mZoomOutButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + rescale(-1); + } + }); + + mZoomInButton = new ToolItem(toolBar, SWT.PUSH); + mZoomInButton.setToolTipText("Zoom In (+)"); + mZoomInButton.setImage(iconFactory.getIcon("zoomplus")); //$NON-NLS-1$); + mZoomInButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + rescale(+1); + } + }); + + return toolBar; + } + + @SuppressWarnings("unused") // SWT constructors have side effects, they are not unused + private ToolBar createLintControls() { + ToolBar toolBar = new ToolBar(this, SWT.FLAT | SWT.RIGHT | SWT.HORIZONTAL); + + // Separate from adjacent toolbar + new ToolItem(toolBar, SWT.SEPARATOR); + + ISharedImages sharedImages = PlatformUI.getWorkbench().getSharedImages(); + mLintButton = new ToolItem(toolBar, SWT.PUSH); + mLintButton.setToolTipText("Show Lint Warnings for this Layout"); + mLintButton.setImage(sharedImages.getImage(ISharedImages.IMG_OBJS_WARN_TSK)); + mLintButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + CommonXmlEditor editor = mEditor.getEditorDelegate().getEditor(); + IFile file = editor.getInputFile(); + if (file != null) { + EclipseLintClient.showErrors(getShell(), file, editor); + } + } + }); + + return toolBar; + } + + /** + * Updates the lint indicator state in the given layout editor + */ + public void updateErrorIndicator() { + updateErrorIndicator(mEditor.getEditedFile()); + } + + /** + * Updates the lint indicator state for the given file + * + * @param file the file to show the indicator status for + */ + public void updateErrorIndicator(IFile file) { + IMarker[] markers = EclipseLintClient.getMarkers(file); + updateErrorIndicator(markers.length); + } + + /** + * Sets whether the action bar should show the "lint warnings" button + * + * @param hasLintWarnings whether there are lint errors to be shown + */ + private void updateErrorIndicator(final int markerCount) { + Display display = getDisplay(); + if (display.getThread() != Thread.currentThread()) { + display.asyncExec(new Runnable() { + @Override + public void run() { + if (!isDisposed()) { + updateErrorIndicator(markerCount); + } + } + }); + return; + } + + GridData layoutData = (GridData) mLintToolBar.getLayoutData(); + Integer existing = (Integer) mLintToolBar.getData(); + Integer current = Integer.valueOf(markerCount); + if (!current.equals(existing)) { + mLintToolBar.setData(current); + boolean layout = false; + boolean hasLintWarnings = markerCount > 0 && AdtPrefs.getPrefs().isLintOnSave(); + if (layoutData.exclude == hasLintWarnings) { + layoutData.exclude = !hasLintWarnings; + mLintToolBar.setVisible(hasLintWarnings); + layout = true; + } + if (markerCount > 0) { + String iconName = ""; + switch (markerCount) { + case 1: iconName = "lint1"; break; //$NON-NLS-1$ + case 2: iconName = "lint2"; break; //$NON-NLS-1$ + case 3: iconName = "lint3"; break; //$NON-NLS-1$ + case 4: iconName = "lint4"; break; //$NON-NLS-1$ + case 5: iconName = "lint5"; break; //$NON-NLS-1$ + case 6: iconName = "lint6"; break; //$NON-NLS-1$ + case 7: iconName = "lint7"; break; //$NON-NLS-1$ + case 8: iconName = "lint8"; break; //$NON-NLS-1$ + case 9: iconName = "lint9"; break; //$NON-NLS-1$ + default: iconName = "lint9p"; break;//$NON-NLS-1$ + } + mLintButton.setImage(IconFactory.getInstance().getIcon(iconName)); + } + if (layout) { + layout(); + } + redraw(); + } + } + + /** + * Returns true if zooming in/out/to-fit/etc is allowed (which is not the case while + * emulating real size) + * + * @return true if zooming is allowed + */ + boolean isZoomingAllowed() { + return mZoomInButton.isEnabled(); + } + + boolean isZoomingRealSize() { + return mZoomRealSizeButton.getSelection(); + } + + /** + * Rescales canvas. + * @param direction +1 for zoom in, -1 for zoom out + */ + void rescale(int direction) { + LayoutCanvas canvas = mEditor.getCanvasControl(); + double s = canvas.getScale(); + + if (direction > 0) { + s = s * 1.2; + } else { + s = s / 1.2; + } + + // Some operations are faster if the zoom is EXACTLY 1.0 rather than ALMOST 1.0. + // (This is because there is a fast-path when image copying and the scale is 1.0; + // in that case it does not have to do any scaling). + // + // If you zoom out 10 times and then back in 10 times, small rounding errors mean + // that you end up with a scale=1.0000000000000004. In the cases, when you get close + // to 1.0, just make the zoom an exact 1.0. + if (Math.abs(s-1.0) < 0.0001) { + s = 1.0; + } + + canvas.setScale(s, true /*redraw*/); + } + + /** + * Reset the canvas scale to 100% + */ + void resetScale() { + mEditor.getCanvasControl().setScale(1, true /*redraw*/); + } + + /** + * Reset the canvas scale to best fit (so content is as large as possible without scrollbars) + */ + void rescaleToFit(boolean onlyZoomOut) { + mEditor.getCanvasControl().setFitScale(onlyZoomOut, true /*allowZoomIn*/); + } + + boolean rescaleToReal(boolean real) { + if (real) { + return computeAndSetRealScale(true /*redraw*/); + } else { + // reset the scale to 100% + mEditor.getCanvasControl().setScale(1, true /*redraw*/); + return true; + } + } + + boolean computeAndSetRealScale(boolean redraw) { + // compute average dpi of X and Y + ConfigurationChooser chooser = mEditor.getConfigurationChooser(); + Configuration config = chooser.getConfiguration(); + Device device = config.getDevice(); + Screen screen = device.getDefaultHardware().getScreen(); + double dpi = (screen.getXdpi() + screen.getYdpi()) / 2.; + + // get the monitor dpi + float monitor = AdtPrefs.getPrefs().getMonitorDensity(); + if (monitor == 0.f) { + ResolutionChooserDialog dialog = new ResolutionChooserDialog(chooser.getShell()); + if (dialog.open() == Window.OK) { + monitor = dialog.getDensity(); + AdtPrefs.getPrefs().setMonitorDensity(monitor); + } else { + return false; + } + } + + mEditor.getCanvasControl().setScale(monitor / dpi, redraw); + return true; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutCanvas.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutCanvas.java new file mode 100644 index 000000000..814b82cec --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutCanvas.java @@ -0,0 +1,1720 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IDragElement.IDragAttribute; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Point; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.lint.LintEditAction; +import com.android.resources.Density; + +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IWorkspaceRoot; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.jdt.internal.ui.javaeditor.EditorUtility; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IContributionItem; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.DragSource; +import org.eclipse.swt.dnd.DropTarget; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.events.MenuDetectEvent; +import org.eclipse.swt.events.MenuDetectListener; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.PaintEvent; +import org.eclipse.swt.events.PaintListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Canvas; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.IActionBars; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IEditorSite; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.actions.ActionFactory.IWorkbenchAction; +import org.eclipse.ui.actions.ContributionItemFactory; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.internal.ide.IDEWorkbenchMessages; +import org.eclipse.ui.texteditor.ITextEditor; +import org.w3c.dom.Node; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Displays the image rendered by the {@link GraphicalEditorPart} and handles + * the interaction with the widgets. + * <p/> + * {@link LayoutCanvas} implements the "Canvas" control. The editor part + * actually uses the {@link LayoutCanvasViewer}, which is a JFace viewer wrapper + * around this control. + * <p/> + * The LayoutCanvas contains the painting logic for the canvas. Selection, + * clipboard, view management etc. is handled in separate helper classes. + * + * @since GLE2 + */ +@SuppressWarnings("restriction") // For WorkBench "Show In" support +public class LayoutCanvas extends Canvas { + private final static QualifiedName NAME_ZOOM = + new QualifiedName(AdtPlugin.PLUGIN_ID, "zoom");//$NON-NLS-1$ + + private static final boolean DEBUG = false; + + static final String PREFIX_CANVAS_ACTION = "canvas_action_"; //$NON-NLS-1$ + + /** The layout editor that uses this layout canvas. */ + private final LayoutEditorDelegate mEditorDelegate; + + /** The Rules Engine, associated with the current project. */ + private RulesEngine mRulesEngine; + + /** GC wrapper given to the IViewRule methods. The GC itself is only defined in the + * context of {@link #onPaint(PaintEvent)}; otherwise it is null. */ + private GCWrapper mGCWrapper; + + /** Default font used on the canvas. Do not dispose, it's a system font. */ + private Font mFont; + + /** Current hover view info. Null when no mouse hover. */ + private CanvasViewInfo mHoverViewInfo; + + /** When true, always display the outline of all views. */ + private boolean mShowOutline; + + /** When true, display the outline of all empty parent views. */ + private boolean mShowInvisible; + + /** Drop target associated with this composite. */ + private DropTarget mDropTarget; + + /** Factory that can create {@link INode} proxies. */ + private final @NonNull NodeFactory mNodeFactory = new NodeFactory(this); + + /** Vertical scaling & scrollbar information. */ + private final CanvasTransform mVScale; + + /** Horizontal scaling & scrollbar information. */ + private final CanvasTransform mHScale; + + /** Drag source associated with this canvas. */ + private DragSource mDragSource; + + /** + * The current Outline Page, to set its model. + * It isn't possible to call OutlinePage2.dispose() in this.dispose(). + * this.dispose() is called from GraphicalEditorPart.dispose(), + * when page's widget is already disposed. + * Added the DisposeListener to OutlinePage2 in order to correctly dispose this page. + **/ + private OutlinePage mOutlinePage; + + /** Delete action for the Edit or context menu. */ + private Action mDeleteAction; + + /** Select-All action for the Edit or context menu. */ + private Action mSelectAllAction; + + /** Paste action for the Edit or context menu. */ + private Action mPasteAction; + + /** Cut action for the Edit or context menu. */ + private Action mCutAction; + + /** Copy action for the Edit or context menu. */ + private Action mCopyAction; + + /** Undo action: delegates to the text editor */ + private IAction mUndoAction; + + /** Redo action: delegates to the text editor */ + private IAction mRedoAction; + + /** Root of the context menu. */ + private MenuManager mMenuManager; + + /** The view hierarchy associated with this canvas. */ + private final ViewHierarchy mViewHierarchy = new ViewHierarchy(this); + + /** The selection in the canvas. */ + private final SelectionManager mSelectionManager = new SelectionManager(this); + + /** The overlay which paints the optional outline. */ + private OutlineOverlay mOutlineOverlay; + + /** The overlay which paints outlines around empty children */ + private EmptyViewsOverlay mEmptyOverlay; + + /** The overlay which paints the mouse hover. */ + private HoverOverlay mHoverOverlay; + + /** The overlay which paints the lint warnings */ + private LintOverlay mLintOverlay; + + /** The overlay which paints the selection. */ + private SelectionOverlay mSelectionOverlay; + + /** The overlay which paints the rendered layout image. */ + private ImageOverlay mImageOverlay; + + /** The overlay which paints masks hiding everything but included content. */ + private IncludeOverlay mIncludeOverlay; + + /** Configuration previews shown next to the layout */ + private final RenderPreviewManager mPreviewManager; + + /** + * Gesture Manager responsible for identifying mouse, keyboard and drag and + * drop events. + */ + private final GestureManager mGestureManager = new GestureManager(this); + + /** + * When set, performs a zoom-to-fit when the next rendering image arrives. + */ + private boolean mZoomFitNextImage; + + /** + * Native clipboard support. + */ + private ClipboardSupport mClipboardSupport; + + /** Tooltip manager for lint warnings */ + private LintTooltipManager mLintTooltipManager; + + private Color mBackgroundColor; + + /** + * Creates a new {@link LayoutCanvas} widget + * + * @param editorDelegate the associated editor delegate + * @param rulesEngine the rules engine + * @param parent parent SWT widget + * @param style the SWT style + */ + public LayoutCanvas(LayoutEditorDelegate editorDelegate, + RulesEngine rulesEngine, + Composite parent, + int style) { + super(parent, style | SWT.DOUBLE_BUFFERED | SWT.V_SCROLL | SWT.H_SCROLL); + mEditorDelegate = editorDelegate; + mRulesEngine = rulesEngine; + + mBackgroundColor = new Color(parent.getDisplay(), 150, 150, 150); + setBackground(mBackgroundColor); + + mClipboardSupport = new ClipboardSupport(this, parent); + mHScale = new CanvasTransform(this, getHorizontalBar()); + mVScale = new CanvasTransform(this, getVerticalBar()); + mPreviewManager = new RenderPreviewManager(this); + + // Unit test suite passes a null here; TODO: Replace with mocking + IFile file = editorDelegate != null ? editorDelegate.getEditor().getInputFile() : null; + if (file != null) { + String zoom = AdtPlugin.getFileProperty(file, NAME_ZOOM); + if (zoom != null) { + try { + double initialScale = Double.parseDouble(zoom); + if (initialScale > 0.1) { + mHScale.setScale(initialScale); + mVScale.setScale(initialScale); + } + } catch (NumberFormatException nfe) { + // Ignore - use zoom=100% + } + } else { + mZoomFitNextImage = true; + } + } + + mGCWrapper = new GCWrapper(mHScale, mVScale); + + Display display = getDisplay(); + mFont = display.getSystemFont(); + + // --- Set up graphic overlays + // mOutlineOverlay and mEmptyOverlay are initialized lazily + mHoverOverlay = new HoverOverlay(this, mHScale, mVScale); + mHoverOverlay.create(display); + mSelectionOverlay = new SelectionOverlay(this); + mSelectionOverlay.create(display); + mImageOverlay = new ImageOverlay(this, mHScale, mVScale); + mIncludeOverlay = new IncludeOverlay(this); + mImageOverlay.create(display); + mLintOverlay = new LintOverlay(this); + mLintOverlay.create(display); + + // --- Set up listeners + addPaintListener(new PaintListener() { + @Override + public void paintControl(PaintEvent e) { + onPaint(e); + } + }); + + addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + super.controlResized(e); + + // Check editor state: + LayoutWindowCoordinator coordinator = null; + IEditorSite editorSite = getEditorDelegate().getEditor().getEditorSite(); + IWorkbenchWindow window = editorSite.getWorkbenchWindow(); + if (window != null) { + coordinator = LayoutWindowCoordinator.get(window, false); + if (coordinator != null) { + coordinator.syncMaximizedState(editorSite.getPage()); + } + } + + updateScrollBars(); + + // Update the zoom level in the canvas when you toggle the zoom + if (coordinator != null) { + mZoomCheck.run(); + } else { + // During startup, delay updates which can trigger further layout + getDisplay().asyncExec(mZoomCheck); + + } + } + }); + + // --- setup drag'n'drop --- + // DND Reference: http://www.eclipse.org/articles/Article-SWT-DND/DND-in-SWT.html + + mDropTarget = createDropTarget(this); + mDragSource = createDragSource(this); + mGestureManager.registerListeners(mDragSource, mDropTarget); + + if (mEditorDelegate == null) { + // TODO: In another CL we should use EasyMock/objgen to provide an editor. + return; // Unit test + } + + // --- setup context menu --- + setupGlobalActionHandlers(); + createContextMenu(); + + // --- setup outline --- + // Get the outline associated with this editor, if any and of the right type. + if (editorDelegate != null) { + mOutlinePage = editorDelegate.getGraphicalOutline(); + } + + mLintTooltipManager = new LintTooltipManager(this); + mLintTooltipManager.register(); + } + + void updateScrollBars() { + Rectangle clientArea = getClientArea(); + Image image = mImageOverlay.getImage(); + if (image != null) { + ImageData imageData = image.getImageData(); + int clientWidth = clientArea.width; + int clientHeight = clientArea.height; + + int imageWidth = imageData.width; + int imageHeight = imageData.height; + + int fullWidth = imageWidth; + int fullHeight = imageHeight; + + if (mPreviewManager.hasPreviews()) { + fullHeight = Math.max(fullHeight, + (int) (mPreviewManager.getHeight() / mHScale.getScale())); + } + + if (clientWidth == 0) { + clientWidth = imageWidth; + Shell shell = getShell(); + if (shell != null) { + org.eclipse.swt.graphics.Point size = shell.getSize(); + if (size.x > 0) { + clientWidth = size.x * 70 / 100; + } + } + } + if (clientHeight == 0) { + clientHeight = imageHeight; + Shell shell = getShell(); + if (shell != null) { + org.eclipse.swt.graphics.Point size = shell.getSize(); + if (size.y > 0) { + clientWidth = size.y * 80 / 100; + } + } + } + + mHScale.setSize(imageWidth, fullWidth, clientWidth); + mVScale.setSize(imageHeight, fullHeight, clientHeight); + } + } + + private Runnable mZoomCheck = new Runnable() { + private Boolean mWasZoomed; + + @Override + public void run() { + if (isDisposed()) { + return; + } + + IEditorSite editorSite = getEditorDelegate().getEditor().getEditorSite(); + IWorkbenchWindow window = editorSite.getWorkbenchWindow(); + if (window != null) { + LayoutWindowCoordinator coordinator = LayoutWindowCoordinator.get(window, false); + if (coordinator != null) { + Boolean zoomed = coordinator.isEditorMaximized(); + if (mWasZoomed != zoomed) { + if (mWasZoomed != null) { + LayoutActionBar actionBar = getGraphicalEditor().getLayoutActionBar(); + if (actionBar.isZoomingAllowed()) { + setFitScale(true /*onlyZoomOut*/, true /*allowZoomIn*/); + } + } + mWasZoomed = zoomed; + } + } + } + } + }; + + void handleKeyPressed(KeyEvent e) { + // Set up backspace as an alias for the delete action within the canvas. + // On most Macs there is no delete key - though there IS a key labeled + // "Delete" and it sends a backspace key code! In short, for Macs we should + // treat backspace as delete, and it's harmless (and probably useful) to + // handle backspace for other platforms as well. + if (e.keyCode == SWT.BS) { + mDeleteAction.run(); + } else if (e.keyCode == SWT.ESC) { + mSelectionManager.selectParent(); + } else if (e.keyCode == DynamicContextMenu.DEFAULT_ACTION_KEY) { + mSelectionManager.performDefaultAction(); + } else if (e.keyCode == 'r') { + // Keep key bindings in sync with {@link DynamicContextMenu#createPlainAction} + // TODO: Find a way to look up the Eclipse key bindings and attempt + // to use the current keymap's rename action. + if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_DARWIN) { + // Command+Option+R + if ((e.stateMask & (SWT.MOD1 | SWT.MOD3)) == (SWT.MOD1 | SWT.MOD3)) { + mSelectionManager.performRename(); + } + } else { + // Alt+Shift+R + if ((e.stateMask & (SWT.MOD2 | SWT.MOD3)) == (SWT.MOD2 | SWT.MOD3)) { + mSelectionManager.performRename(); + } + } + } else { + // Zooming actions + char c = e.character; + LayoutActionBar actionBar = getGraphicalEditor().getLayoutActionBar(); + if (c == '1' && actionBar.isZoomingAllowed()) { + setScale(1, true); + } else if (c == '0' && actionBar.isZoomingAllowed()) { + setFitScale(true, true /*allowZoomIn*/); + } else if (e.keyCode == '0' && (e.stateMask & SWT.MOD2) != 0 + && actionBar.isZoomingAllowed()) { + setFitScale(false, true /*allowZoomIn*/); + } else if ((c == '+' || c == '=') && actionBar.isZoomingAllowed()) { + if ((e.stateMask & SWT.MOD1) != 0) { + mPreviewManager.zoomIn(); + } else { + actionBar.rescale(1); + } + } else if (c == '-' && actionBar.isZoomingAllowed()) { + if ((e.stateMask & SWT.MOD1) != 0) { + mPreviewManager.zoomOut(); + } else { + actionBar.rescale(-1); + } + } + } + } + + @Override + public void dispose() { + super.dispose(); + + mGestureManager.unregisterListeners(mDragSource, mDropTarget); + + if (mLintTooltipManager != null) { + mLintTooltipManager.unregister(); + mLintTooltipManager = null; + } + + if (mDropTarget != null) { + mDropTarget.dispose(); + mDropTarget = null; + } + + if (mRulesEngine != null) { + mRulesEngine.dispose(); + mRulesEngine = null; + } + + if (mDragSource != null) { + mDragSource.dispose(); + mDragSource = null; + } + + if (mClipboardSupport != null) { + mClipboardSupport.dispose(); + mClipboardSupport = null; + } + + if (mGCWrapper != null) { + mGCWrapper.dispose(); + mGCWrapper = null; + } + + if (mOutlineOverlay != null) { + mOutlineOverlay.dispose(); + mOutlineOverlay = null; + } + + if (mEmptyOverlay != null) { + mEmptyOverlay.dispose(); + mEmptyOverlay = null; + } + + if (mHoverOverlay != null) { + mHoverOverlay.dispose(); + mHoverOverlay = null; + } + + if (mSelectionOverlay != null) { + mSelectionOverlay.dispose(); + mSelectionOverlay = null; + } + + if (mImageOverlay != null) { + mImageOverlay.dispose(); + mImageOverlay = null; + } + + if (mIncludeOverlay != null) { + mIncludeOverlay.dispose(); + mIncludeOverlay = null; + } + + if (mLintOverlay != null) { + mLintOverlay.dispose(); + mLintOverlay = null; + } + + if (mBackgroundColor != null) { + mBackgroundColor.dispose(); + mBackgroundColor = null; + } + + mPreviewManager.disposePreviews(); + mViewHierarchy.dispose(); + } + + /** + * Returns the configuration preview manager for this canvas + * + * @return the configuration preview manager for this canvas + */ + @NonNull + public RenderPreviewManager getPreviewManager() { + return mPreviewManager; + } + + /** Returns the Rules Engine, associated with the current project. */ + RulesEngine getRulesEngine() { + return mRulesEngine; + } + + /** Sets the Rules Engine, associated with the current project. */ + void setRulesEngine(RulesEngine rulesEngine) { + mRulesEngine = rulesEngine; + } + + /** + * Returns the factory to use to convert from {@link CanvasViewInfo} or from + * {@link UiViewElementNode} to {@link INode} proxies. + * + * @return the node factory + */ + @NonNull + public NodeFactory getNodeFactory() { + return mNodeFactory; + } + + /** + * Returns the GCWrapper used to paint view rules. + * + * @return The GCWrapper used to paint view rules + */ + GCWrapper getGcWrapper() { + return mGCWrapper; + } + + /** + * Returns the {@link LayoutEditorDelegate} associated with this canvas. + * + * @return the delegate + */ + public LayoutEditorDelegate getEditorDelegate() { + return mEditorDelegate; + } + + /** + * Returns the current {@link ImageOverlay} painting the rendered result + * + * @return the image overlay responsible for painting the rendered result, never null + */ + ImageOverlay getImageOverlay() { + return mImageOverlay; + } + + /** + * Returns the current {@link SelectionOverlay} painting the selection highlights + * + * @return the selection overlay responsible for painting the selection highlights, + * never null + */ + SelectionOverlay getSelectionOverlay() { + return mSelectionOverlay; + } + + /** + * Returns the {@link GestureManager} associated with this canvas. + * + * @return the {@link GestureManager} associated with this canvas, never null. + */ + GestureManager getGestureManager() { + return mGestureManager; + } + + /** + * Returns the current {@link HoverOverlay} painting the mouse hover. + * + * @return the hover overlay responsible for painting the mouse hover, + * never null + */ + HoverOverlay getHoverOverlay() { + return mHoverOverlay; + } + + /** + * Returns the horizontal {@link CanvasTransform} transform object, which can map + * a layout point into a control point. + * + * @return A {@link CanvasTransform} for mapping between layout and control + * coordinates in the horizontal dimension. + */ + CanvasTransform getHorizontalTransform() { + return mHScale; + } + + /** + * Returns the vertical {@link CanvasTransform} transform object, which can map a + * layout point into a control point. + * + * @return A {@link CanvasTransform} for mapping between layout and control + * coordinates in the vertical dimension. + */ + CanvasTransform getVerticalTransform() { + return mVScale; + } + + /** + * Returns the {@link OutlinePage} associated with this canvas + * + * @return the {@link OutlinePage} associated with this canvas + */ + public OutlinePage getOutlinePage() { + return mOutlinePage; + } + + /** + * Returns the {@link SelectionManager} associated with this canvas. + * + * @return The {@link SelectionManager} holding the selection for this + * canvas. Never null. + */ + public SelectionManager getSelectionManager() { + return mSelectionManager; + } + + /** + * Returns the {@link ViewHierarchy} object associated with this canvas, + * holding the most recent rendered view of the scene, if valid. + * + * @return The {@link ViewHierarchy} object associated with this canvas. + * Never null. + */ + public ViewHierarchy getViewHierarchy() { + return mViewHierarchy; + } + + /** + * Returns the {@link ClipboardSupport} object associated with this canvas. + * + * @return The {@link ClipboardSupport} object for this canvas. Null only after dispose. + */ + public ClipboardSupport getClipboardSupport() { + return mClipboardSupport; + } + + /** Returns the Select All action bound to this canvas */ + Action getSelectAllAction() { + return mSelectAllAction; + } + + /** Returns the associated {@link GraphicalEditorPart} */ + GraphicalEditorPart getGraphicalEditor() { + return mEditorDelegate.getGraphicalEditor(); + } + + /** + * Sets the result of the layout rendering. The result object indicates if the layout + * rendering succeeded. If it did, it contains a bitmap and the objects rectangles. + * + * Implementation detail: the bridge's computeLayout() method already returns a newly + * allocated ILayourResult. That means we can keep this result and hold on to it + * when it is valid. + * + * @param session The new scene, either valid or not. + * @param explodedNodes The set of individual nodes the layout computer was asked to + * explode. Note that these are independent of the explode-all mode where + * all views are exploded; this is used only for the mode ( + * {@link #showInvisibleViews(boolean)}) where individual invisible nodes + * are padded during certain interactions. + */ + void setSession(RenderSession session, Set<UiElementNode> explodedNodes, + boolean layoutlib5) { + // disable any hover + clearHover(); + + mViewHierarchy.setSession(session, explodedNodes, layoutlib5); + if (mViewHierarchy.isValid() && session != null) { + Image image = mImageOverlay.setImage(session.getImage(), + session.isAlphaChannelImage()); + + mOutlinePage.setModel(mViewHierarchy.getRoot()); + getGraphicalEditor().setModel(mViewHierarchy.getRoot()); + + if (image != null) { + updateScrollBars(); + if (mZoomFitNextImage) { + // Must be run asynchronously because getClientArea() returns 0 bounds + // when the editor is being initialized + getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + if (!isDisposed()) { + ensureZoomed(); + } + } + }); + } + + // Ensure that if we have a a preview mode enabled, it's shown + syncPreviewMode(); + } + } + + redraw(); + } + + void ensureZoomed() { + if (mZoomFitNextImage && getClientArea().height > 0) { + mZoomFitNextImage = false; + LayoutActionBar actionBar = getGraphicalEditor().getLayoutActionBar(); + if (actionBar.isZoomingAllowed()) { + setFitScale(true, true /*allowZoomIn*/); + } + } + } + + void setShowOutline(boolean newState) { + mShowOutline = newState; + redraw(); + } + + /** + * Returns the zoom scale factor of the canvas (the amount the full + * resolution render of the device is zoomed before being shown on the + * canvas) + * + * @return the image scale + */ + public double getScale() { + return mHScale.getScale(); + } + + void setScale(double scale, boolean redraw) { + if (scale <= 0.0) { + scale = 1.0; + } + + if (scale == getScale()) { + return; + } + + mHScale.setScale(scale); + mVScale.setScale(scale); + if (redraw) { + redraw(); + } + + // Clear the zoom setting if it is almost identical to 1.0 + String zoomValue = (Math.abs(scale - 1.0) < 0.0001) ? null : Double.toString(scale); + IFile file = mEditorDelegate.getEditor().getInputFile(); + if (file != null) { + AdtPlugin.setFileProperty(file, NAME_ZOOM, zoomValue); + } + } + + /** + * Scales the canvas to best fit + * + * @param onlyZoomOut if true, then the zooming factor will never be larger than 1, + * which means that this function will zoom out if necessary to show the + * rendered image, but it will never zoom in. + * TODO: Rename this, it sounds like it conflicts with allowZoomIn, + * even though one is referring to the zoom level and one is referring + * to the overall act of scaling above/below 1. + * @param allowZoomIn if false, then if the computed zoom factor is smaller than + * the current zoom factor, it will be ignored. + */ + public void setFitScale(boolean onlyZoomOut, boolean allowZoomIn) { + ImageOverlay imageOverlay = getImageOverlay(); + if (imageOverlay == null) { + return; + } + Image image = imageOverlay.getImage(); + if (image != null) { + Rectangle canvasSize = getClientArea(); + int canvasWidth = canvasSize.width; + int canvasHeight = canvasSize.height; + + boolean hasPreviews = mPreviewManager.hasPreviews(); + if (hasPreviews) { + canvasWidth = 2 * canvasWidth / 3; + } else { + canvasWidth -= 4; + canvasHeight -= 4; + } + + ImageData imageData = image.getImageData(); + int sceneWidth = imageData.width; + int sceneHeight = imageData.height; + if (sceneWidth == 0.0 || sceneHeight == 0.0) { + return; + } + + if (imageOverlay.getShowDropShadow()) { + sceneWidth += 2 * ImageUtils.SHADOW_SIZE; + sceneHeight += 2 * ImageUtils.SHADOW_SIZE; + } + + // Reduce the margins if necessary + int hDelta = canvasWidth - sceneWidth; + int hMargin = 0; + if (hDelta > 2 * CanvasTransform.DEFAULT_MARGIN) { + hMargin = CanvasTransform.DEFAULT_MARGIN; + } else if (hDelta > 0) { + hMargin = hDelta / 2; + } + + int vDelta = canvasHeight - sceneHeight; + int vMargin = 0; + if (vDelta > 2 * CanvasTransform.DEFAULT_MARGIN) { + vMargin = CanvasTransform.DEFAULT_MARGIN; + } else if (vDelta > 0) { + vMargin = vDelta / 2; + } + + double hScale = (canvasWidth - 2 * hMargin) / (double) sceneWidth; + double vScale = (canvasHeight - 2 * vMargin) / (double) sceneHeight; + + double scale = Math.min(hScale, vScale); + + if (onlyZoomOut) { + scale = Math.min(1.0, scale); + } + + if (!allowZoomIn && scale > getScale()) { + return; + } + + setScale(scale, true); + } + } + + /** + * Transforms a point, expressed in layout coordinates, into "client" coordinates + * relative to the control (and not relative to the display). + * + * @param canvasX X in the canvas coordinates + * @param canvasY Y in the canvas coordinates + * @return A new {@link Point} in control client coordinates (not display coordinates) + */ + Point layoutToControlPoint(int canvasX, int canvasY) { + int x = mHScale.translate(canvasX); + int y = mVScale.translate(canvasY); + return new Point(x, y); + } + + /** + * Returns the action for the context menu corresponding to the given action id. + * <p/> + * For global actions such as copy or paste, the action id must be composed of + * the {@link #PREFIX_CANVAS_ACTION} followed by one of {@link ActionFactory}'s + * action ids. + * <p/> + * Returns null if there's no action for the given id. + */ + IAction getAction(String actionId) { + String prefix = PREFIX_CANVAS_ACTION; + if (mMenuManager == null || + actionId == null || + !actionId.startsWith(prefix)) { + return null; + } + + actionId = actionId.substring(prefix.length()); + + for (IContributionItem contrib : mMenuManager.getItems()) { + if (contrib instanceof ActionContributionItem && + actionId.equals(contrib.getId())) { + return ((ActionContributionItem) contrib).getAction(); + } + } + + return null; + } + + //--------------- + + /** + * Paints the canvas in response to paint events. + */ + private void onPaint(PaintEvent e) { + GC gc = e.gc; + gc.setFont(mFont); + mGCWrapper.setGC(gc); + try { + if (!mImageOverlay.isHiding()) { + mImageOverlay.paint(gc); + } + + mPreviewManager.paint(gc); + + if (mShowOutline) { + if (mOutlineOverlay == null) { + mOutlineOverlay = new OutlineOverlay(mViewHierarchy, mHScale, mVScale); + mOutlineOverlay.create(getDisplay()); + } + if (!mOutlineOverlay.isHiding()) { + mOutlineOverlay.paint(gc); + } + } + + if (mShowInvisible) { + if (mEmptyOverlay == null) { + mEmptyOverlay = new EmptyViewsOverlay(mViewHierarchy, mHScale, mVScale); + mEmptyOverlay.create(getDisplay()); + } + if (!mEmptyOverlay.isHiding()) { + mEmptyOverlay.paint(gc); + } + } + + if (!mHoverOverlay.isHiding()) { + mHoverOverlay.paint(gc); + } + + if (!mLintOverlay.isHiding()) { + mLintOverlay.paint(gc); + } + + if (!mIncludeOverlay.isHiding()) { + mIncludeOverlay.paint(gc); + } + + if (!mSelectionOverlay.isHiding()) { + mSelectionOverlay.paint(mSelectionManager, mGCWrapper, gc, mRulesEngine); + } + mGestureManager.paint(gc); + + } finally { + mGCWrapper.setGC(null); + } + } + + /** + * Shows or hides invisible parent views, which are views which have empty bounds and + * no children. The nodes which will be shown are provided by + * {@link #getNodesToExplode()}. + * + * @param show When true, any invisible parent nodes are padded and highlighted + * ("exploded"), and when false any formerly exploded nodes are hidden. + */ + void showInvisibleViews(boolean show) { + if (mShowInvisible == show) { + return; + } + mShowInvisible = show; + + // Optimization: Avoid doing work when we don't have invisible parents (on show) + // or formerly exploded nodes (on hide). + if (show && !mViewHierarchy.hasInvisibleParents()) { + return; + } else if (!show && !mViewHierarchy.hasExplodedParents()) { + return; + } + + mEditorDelegate.recomputeLayout(); + } + + /** + * Returns a set of nodes that should be exploded (forced non-zero padding during render), + * or null if no nodes should be exploded. (Note that this is independent of the + * explode-all mode, where all nodes are padded -- that facility does not use this + * mechanism, which is only intended to be used to expose invisible parent nodes. + * + * @return The set of invisible parents, or null if no views should be expanded. + */ + public Set<UiElementNode> getNodesToExplode() { + if (mShowInvisible) { + return mViewHierarchy.getInvisibleNodes(); + } + + // IF we have selection, and IF we have invisible nodes in the view, + // see if any of the selected items are among the invisible nodes, and if so + // add them to a lazily constructed set which we pass back for rendering. + Set<UiElementNode> result = null; + List<SelectionItem> selections = mSelectionManager.getSelections(); + if (selections.size() > 0) { + List<CanvasViewInfo> invisibleParents = mViewHierarchy.getInvisibleViews(); + if (invisibleParents.size() > 0) { + for (SelectionItem item : selections) { + CanvasViewInfo viewInfo = item.getViewInfo(); + // O(n^2) here, but both the selection size and especially the + // invisibleParents size are expected to be small + if (invisibleParents.contains(viewInfo)) { + UiViewElementNode node = viewInfo.getUiViewNode(); + if (node != null) { + if (result == null) { + result = new HashSet<UiElementNode>(); + } + result.add(node); + } + } + } + } + } + + return result; + } + + /** + * Clears the hover. + */ + void clearHover() { + mHoverOverlay.clearHover(); + } + + /** + * Hover on top of a known child. + */ + void hover(MouseEvent e) { + // Check if a button is pressed; no hovers during drags + if ((e.stateMask & SWT.BUTTON_MASK) != 0) { + clearHover(); + return; + } + + LayoutPoint p = ControlPoint.create(this, e).toLayout(); + CanvasViewInfo vi = mViewHierarchy.findViewInfoAt(p); + + // We don't hover on the root since it's not a widget per see and it is always there. + // We also skip spacers... + if (vi != null && (vi.isRoot() || vi.isHidden())) { + vi = null; + } + + boolean needsUpdate = vi != mHoverViewInfo; + mHoverViewInfo = vi; + + if (vi == null) { + clearHover(); + } else { + Rectangle r = vi.getSelectionRect(); + mHoverOverlay.setHover(r.x, r.y, r.width, r.height); + } + + if (needsUpdate) { + redraw(); + } + } + + /** + * Shows the given {@link CanvasViewInfo}, which can mean exposing its XML or if it's + * an included element, its corresponding file. + * + * @param vi the {@link CanvasViewInfo} to be shown + */ + public void show(CanvasViewInfo vi) { + String url = vi.getIncludeUrl(); + if (url != null) { + showInclude(url); + } else { + showXml(vi); + } + } + + /** + * Shows the layout file referenced by the given url in the same project. + * + * @param url The layout attribute url of the form @layout/foo + */ + private void showInclude(String url) { + GraphicalEditorPart graphicalEditor = getGraphicalEditor(); + IPath filePath = graphicalEditor.findResourceFile(url); + if (filePath == null) { + // Should not be possible - if the URL had been bad, then we wouldn't + // have been able to render the scene and you wouldn't have been able + // to click on it + return; + } + + // Save the including file, if necessary: without it, the "Show Included In" + // facility which is invoked automatically will not work properly if the <include> + // tag is not in the saved version of the file, since the outer file is read from + // disk rather than from memory. + IEditorSite editorSite = graphicalEditor.getEditorSite(); + IWorkbenchPage page = editorSite.getPage(); + page.saveEditor(mEditorDelegate.getEditor(), false); + + IWorkspaceRoot workspace = ResourcesPlugin.getWorkspace().getRoot(); + IFile xmlFile = null; + IPath workspacePath = workspace.getLocation(); + if (workspacePath.isPrefixOf(filePath)) { + IPath relativePath = filePath.makeRelativeTo(workspacePath); + xmlFile = (IFile) workspace.findMember(relativePath); + } else if (filePath.isAbsolute()) { + xmlFile = workspace.getFileForLocation(filePath); + } + if (xmlFile != null) { + IFile leavingFile = graphicalEditor.getEditedFile(); + Reference next = Reference.create(graphicalEditor.getEditedFile()); + + try { + IEditorPart openAlready = EditorUtility.isOpenInEditor(xmlFile); + + // Show the included file as included within this click source? + if (openAlready != null) { + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(openAlready); + if (delegate != null) { + GraphicalEditorPart gEditor = delegate.getGraphicalEditor(); + if (gEditor != null && + gEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) { + gEditor.showIn(next); + } + } + } else { + try { + // Set initial state of a new file + // TODO: Only set rendering target portion of the state + String state = ConfigurationDescription.getDescription(leavingFile); + xmlFile.setSessionProperty(GraphicalEditorPart.NAME_INITIAL_STATE, + state); + } catch (CoreException e) { + // pass + } + + if (graphicalEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) { + try { + xmlFile.setSessionProperty(GraphicalEditorPart.NAME_INCLUDE, next); + } catch (CoreException e) { + // pass - worst that can happen is that we don't + //start with inclusion + } + } + } + + EditorUtility.openInEditor(xmlFile, true); + return; + } catch (PartInitException ex) { + AdtPlugin.log(ex, "Can't open %$1s", url); //$NON-NLS-1$ + } + } else { + // It's not a path in the workspace; look externally + // (this is probably an @android: path) + if (filePath.isAbsolute()) { + IFileStore fileStore = EFS.getLocalFileSystem().getStore(filePath); + // fileStore = fileStore.getChild(names[i]); + if (!fileStore.fetchInfo().isDirectory() && fileStore.fetchInfo().exists()) { + try { + IDE.openEditorOnFileStore(page, fileStore); + return; + } catch (PartInitException ex) { + AdtPlugin.log(ex, "Can't open %$1s", url); //$NON-NLS-1$ + } + } + } + } + + // Failed: display message to the user + String message = String.format("Could not find resource %1$s", url); + IStatusLineManager status = editorSite.getActionBars().getStatusLineManager(); + status.setErrorMessage(message); + getDisplay().beep(); + } + + /** + * Returns the layout resource name of this layout + * + * @return the layout resource name of this layout + */ + public String getLayoutResourceName() { + GraphicalEditorPart graphicalEditor = getGraphicalEditor(); + return graphicalEditor.getLayoutResourceName(); + } + + /** + * Returns the layout resource url of the current layout + * + * @return + */ + /* + public String getMe() { + GraphicalEditorPart graphicalEditor = getGraphicalEditor(); + IFile editedFile = graphicalEditor.getEditedFile(); + return editedFile.getProjectRelativePath().toOSString(); + } + */ + + /** + * Show the XML element corresponding to the given {@link CanvasViewInfo} (unless it's + * a root). + * + * @param vi The clicked {@link CanvasViewInfo} whose underlying XML element we want + * to view + */ + private void showXml(CanvasViewInfo vi) { + // Warp to the text editor and show the corresponding XML for the + // double-clicked widget + if (vi.isRoot()) { + return; + } + + Node xmlNode = vi.getXmlNode(); + if (xmlNode != null) { + boolean found = mEditorDelegate.getEditor().show(xmlNode); + if (!found) { + getDisplay().beep(); + } + } + } + + //--------------- + + /** + * Helper to create the drag source for the given control. + * <p/> + * This is static with package-access so that {@link OutlinePage} can also + * create an exact copy of the source with the same attributes. + */ + /* package */static DragSource createDragSource(Control control) { + DragSource source = new DragSource(control, DND.DROP_COPY | DND.DROP_MOVE); + source.setTransfer(new Transfer[] { + TextTransfer.getInstance(), + SimpleXmlTransfer.getInstance() + }); + return source; + } + + /** + * Helper to create the drop target for the given control. + */ + private static DropTarget createDropTarget(Control control) { + DropTarget dropTarget = new DropTarget( + control, DND.DROP_COPY | DND.DROP_MOVE | DND.DROP_DEFAULT); + dropTarget.setTransfer(new Transfer[] { + SimpleXmlTransfer.getInstance() + }); + return dropTarget; + } + + //--------------- + + /** + * Invoked by the constructor to add our cut/copy/paste/delete/select-all + * handlers in the global action handlers of this editor's site. + * <p/> + * This will enable the menu items under the global Edit menu and make them + * invoke our actions as needed. As a benefit, the corresponding shortcut + * accelerators will do what one would expect. + */ + private void setupGlobalActionHandlers() { + mCutAction = new Action() { + @Override + public void run() { + mClipboardSupport.cutSelectionToClipboard(mSelectionManager.getSnapshot()); + updateMenuActionState(); + } + }; + + copyActionAttributes(mCutAction, ActionFactory.CUT); + + mCopyAction = new Action() { + @Override + public void run() { + mClipboardSupport.copySelectionToClipboard(mSelectionManager.getSnapshot()); + updateMenuActionState(); + } + }; + + copyActionAttributes(mCopyAction, ActionFactory.COPY); + + mPasteAction = new Action() { + @Override + public void run() { + mClipboardSupport.pasteSelection(mSelectionManager.getSnapshot()); + updateMenuActionState(); + } + }; + + copyActionAttributes(mPasteAction, ActionFactory.PASTE); + + mDeleteAction = new Action() { + @Override + public void run() { + mClipboardSupport.deleteSelection( + getDeleteLabel(), + mSelectionManager.getSnapshot()); + } + }; + + copyActionAttributes(mDeleteAction, ActionFactory.DELETE); + + mSelectAllAction = new Action() { + @Override + public void run() { + GraphicalEditorPart graphicalEditor = getEditorDelegate().getGraphicalEditor(); + StyledText errorLabel = graphicalEditor.getErrorLabel(); + if (errorLabel.isFocusControl()) { + errorLabel.selectAll(); + return; + } + + mSelectionManager.selectAll(); + } + }; + + copyActionAttributes(mSelectAllAction, ActionFactory.SELECT_ALL); + } + + String getCutLabel() { + return mCutAction.getText(); + } + + String getDeleteLabel() { + // verb "Delete" from the DELETE action's title + return mDeleteAction.getText(); + } + + /** + * Updates menu actions that depends on the selection. + */ + void updateMenuActionState() { + List<SelectionItem> selections = getSelectionManager().getSelections(); + boolean hasSelection = !selections.isEmpty(); + if (hasSelection && selections.size() == 1 && selections.get(0).isRoot()) { + hasSelection = false; + } + + StyledText errorLabel = getGraphicalEditor().getErrorLabel(); + mCutAction.setEnabled(hasSelection); + mCopyAction.setEnabled(hasSelection || errorLabel.getSelectionCount() > 0); + mDeleteAction.setEnabled(hasSelection); + // Select All should *always* be selectable, regardless of whether anything + // is currently selected. + mSelectAllAction.setEnabled(true); + + // The paste operation is only available if we can paste our custom type. + // We do not currently support pasting random text (e.g. XML). Maybe later. + boolean hasSxt = mClipboardSupport.hasSxtOnClipboard(); + mPasteAction.setEnabled(hasSxt); + } + + /** + * Update the actions when this editor is activated + * + * @param bars the action bar for this canvas + */ + public void updateGlobalActions(@NonNull IActionBars bars) { + updateMenuActionState(); + + ITextEditor editor = mEditorDelegate.getEditor().getStructuredTextEditor(); + boolean graphical = getEditorDelegate().getEditor().getActivePage() == 0; + if (graphical) { + bars.setGlobalActionHandler(ActionFactory.CUT.getId(), mCutAction); + bars.setGlobalActionHandler(ActionFactory.COPY.getId(), mCopyAction); + bars.setGlobalActionHandler(ActionFactory.PASTE.getId(), mPasteAction); + bars.setGlobalActionHandler(ActionFactory.DELETE.getId(), mDeleteAction); + bars.setGlobalActionHandler(ActionFactory.SELECT_ALL.getId(), mSelectAllAction); + + // Delegate the Undo and Redo actions to the text editor ones, but wrap them + // such that we run lint to update the results on the current page (this is + // normally done on each editor operation that goes through + // {@link AndroidXmlEditor#wrapUndoEditXmlModel}, but not undo/redo) + if (mUndoAction == null) { + IAction undoAction = editor.getAction(ActionFactory.UNDO.getId()); + mUndoAction = new LintEditAction(undoAction, getEditorDelegate().getEditor()); + } + bars.setGlobalActionHandler(ActionFactory.UNDO.getId(), mUndoAction); + if (mRedoAction == null) { + IAction redoAction = editor.getAction(ActionFactory.REDO.getId()); + mRedoAction = new LintEditAction(redoAction, getEditorDelegate().getEditor()); + } + bars.setGlobalActionHandler(ActionFactory.REDO.getId(), mRedoAction); + } else { + bars.setGlobalActionHandler(ActionFactory.CUT.getId(), + editor.getAction(ActionFactory.CUT.getId())); + bars.setGlobalActionHandler(ActionFactory.COPY.getId(), + editor.getAction(ActionFactory.COPY.getId())); + bars.setGlobalActionHandler(ActionFactory.PASTE.getId(), + editor.getAction(ActionFactory.PASTE.getId())); + bars.setGlobalActionHandler(ActionFactory.DELETE.getId(), + editor.getAction(ActionFactory.DELETE.getId())); + bars.setGlobalActionHandler(ActionFactory.SELECT_ALL.getId(), + editor.getAction(ActionFactory.SELECT_ALL.getId())); + bars.setGlobalActionHandler(ActionFactory.UNDO.getId(), + editor.getAction(ActionFactory.UNDO.getId())); + bars.setGlobalActionHandler(ActionFactory.REDO.getId(), + editor.getAction(ActionFactory.REDO.getId())); + } + + bars.updateActionBars(); + } + + /** + * Helper for {@link #setupGlobalActionHandlers()}. + * Copies the action attributes form the given {@link ActionFactory}'s action to + * our action. + * <p/> + * {@link ActionFactory} provides access to the standard global actions in Eclipse. + * <p/> + * This allows us to grab the standard labels and icons for the + * global actions such as copy, cut, paste, delete and select-all. + */ + private void copyActionAttributes(Action action, ActionFactory factory) { + IWorkbenchAction wa = factory.create( + mEditorDelegate.getEditor().getEditorSite().getWorkbenchWindow()); + action.setId(wa.getId()); + action.setText(wa.getText()); + action.setEnabled(wa.isEnabled()); + action.setDescription(wa.getDescription()); + action.setToolTipText(wa.getToolTipText()); + action.setAccelerator(wa.getAccelerator()); + action.setActionDefinitionId(wa.getActionDefinitionId()); + action.setImageDescriptor(wa.getImageDescriptor()); + action.setHoverImageDescriptor(wa.getHoverImageDescriptor()); + action.setDisabledImageDescriptor(wa.getDisabledImageDescriptor()); + action.setHelpListener(wa.getHelpListener()); + } + + /** + * Creates the context menu for the canvas. This is called once from the canvas' constructor. + * <p/> + * The menu has a static part with actions that are always available such as + * copy, cut, paste and show in > explorer. This is created by + * {@link #setupStaticMenuActions(IMenuManager)}. + * <p/> + * There's also a dynamic part that is populated by the rules of the + * selected elements, created by {@link DynamicContextMenu}. + */ + @SuppressWarnings("unused") + private void createContextMenu() { + + // This manager is the root of the context menu. + mMenuManager = new MenuManager() { + @Override + public boolean isDynamic() { + return true; + } + }; + + // Fill the menu manager with the static & dynamic actions + setupStaticMenuActions(mMenuManager); + new DynamicContextMenu(mEditorDelegate, this, mMenuManager); + Menu menu = mMenuManager.createContextMenu(this); + setMenu(menu); + + // Add listener to detect when the menu is about to be posted, such that + // we can sync the selection. Without this, you can right click on something + // in the canvas which is NOT selected, and the context menu will show items related + // to the selection, NOT the item you clicked on!! + addMenuDetectListener(new MenuDetectListener() { + @Override + public void menuDetected(MenuDetectEvent e) { + mSelectionManager.menuClick(e); + } + }); + } + + /** + * Invoked by {@link #createContextMenu()} to create our *static* context menu once. + * <p/> + * The content of the menu itself does not change. However the state of the + * various items is controlled by their associated actions. + * <p/> + * For cut/copy/paste/delete/select-all, we explicitly reuse the actions + * created by {@link #setupGlobalActionHandlers()}, so this method must be + * invoked after that one. + */ + private void setupStaticMenuActions(IMenuManager manager) { + manager.removeAll(); + + manager.add(new SelectionManager.SelectionMenu(getGraphicalEditor())); + manager.add(new Separator()); + manager.add(mCutAction); + manager.add(mCopyAction); + manager.add(mPasteAction); + manager.add(new Separator()); + manager.add(mDeleteAction); + manager.add(new Separator()); + manager.add(new PlayAnimationMenu(this)); + manager.add(new ExportScreenshotAction(this)); + manager.add(new Separator()); + + // Group "Show Included In" and "Show In" together + manager.add(new ShowWithinMenu(mEditorDelegate)); + + // Create a "Show In" sub-menu and automatically populate it using standard + // actions contributed by the workbench. + String showInLabel = IDEWorkbenchMessages.Workbench_showIn; + MenuManager showInSubMenu = new MenuManager(showInLabel); + showInSubMenu.add( + ContributionItemFactory.VIEWS_SHOW_IN.create( + mEditorDelegate.getEditor().getSite().getWorkbenchWindow())); + manager.add(showInSubMenu); + } + + /** + * Deletes the selection. Equivalent to pressing the Delete key. + */ + void delete() { + mDeleteAction.run(); + } + + /** + * Add new root in an existing empty XML layout. + * <p/> + * In case of error (unknown FQCN, document not empty), silently do nothing. + * In case of success, the new element will have some default attributes set + * (xmlns:android, layout_width and height). The edit is wrapped in a proper + * undo. + * <p/> + * This is invoked by + * {@link MoveGesture#drop(org.eclipse.swt.dnd.DropTargetEvent)}. + * + * @param root A non-null descriptor of the root element to create. + */ + void createDocumentRoot(final @NonNull SimpleElement root) { + String rootFqcn = root.getFqcn(); + + // Need a valid empty document to create the new root + final UiDocumentNode uiDoc = mEditorDelegate.getUiRootNode(); + if (uiDoc == null || uiDoc.getUiChildren().size() > 0) { + debugPrintf("Failed to create document root for %1$s: document is not empty", + rootFqcn); + return; + } + + // Find the view descriptor matching our FQCN + final ViewElementDescriptor viewDesc = mEditorDelegate.getFqcnViewDescriptor(rootFqcn); + if (viewDesc == null) { + // TODO this could happen if dropping a custom view not known in this project + debugPrintf("Failed to add document root, unknown FQCN %1$s", rootFqcn); + return; + } + + // Get the last segment of the FQCN for the undo title + String title = rootFqcn; + int pos = title.lastIndexOf('.'); + if (pos > 0 && pos < title.length() - 1) { + title = title.substring(pos + 1); + } + title = String.format("Create root %1$s in document", title); + + mEditorDelegate.getEditor().wrapUndoEditXmlModel(title, new Runnable() { + @Override + public void run() { + UiElementNode uiNew = uiDoc.appendNewUiChild(viewDesc); + + // A root node requires the Android XMLNS + uiNew.setAttributeValue( + SdkConstants.ANDROID_NS_NAME, + SdkConstants.XMLNS_URI, + SdkConstants.NS_RESOURCES, + true /*override*/); + + IDragAttribute[] attributes = root.getAttributes(); + if (attributes != null) { + for (IDragAttribute attribute : attributes) { + String uri = attribute.getUri(); + String name = attribute.getName(); + String value = attribute.getValue(); + uiNew.setAttributeValue(name, uri, value, false /*override*/); + } + } + + // Adjust the attributes + DescriptorsUtils.setDefaultLayoutAttributes(uiNew, false /*updateLayout*/); + + uiNew.createXmlNode(); + } + }); + } + + /** + * Returns the insets associated with views of the given fully qualified name, for the + * current theme and screen type. + * + * @param fqcn the fully qualified name to the widget type + * @return the insets, or null if unknown + */ + public Margins getInsets(String fqcn) { + if (ViewMetadataRepository.INSETS_SUPPORTED) { + ConfigurationChooser configComposite = getGraphicalEditor().getConfigurationChooser(); + String theme = configComposite.getThemeName(); + Density density = configComposite.getConfiguration().getDensity(); + return ViewMetadataRepository.getInsets(fqcn, density, theme); + } else { + return null; + } + } + + private void debugPrintf(String message, Object... params) { + if (DEBUG) { + AdtPlugin.printToConsole("Canvas", String.format(message, params)); + } + } + + /** The associated editor has been deactivated */ + public void deactivated() { + // Force the tooltip to be hidden. If you switch from the layout editor + // to a Java editor with the keyboard, the tooltip can stay open. + if (mLintTooltipManager != null) { + mLintTooltipManager.hide(); + } + } + + /** @see #setPreview(RenderPreview) */ + private RenderPreview mPreview; + + /** + * Sets the {@link RenderPreview} associated with the currently rendering + * configuration. + * <p> + * A {@link RenderPreview} has various additional state beyond its rendering, + * such as its display name (which can be edited by the user). When you click on + * previews, the layout editor switches to show the given configuration preview. + * The preview is then no longer shown in the list of previews and is instead rendered + * in the main editor. However, when you then switch away to some other preview, we + * want to be able to restore the preview with all its state. + * + * @param preview the preview associated with the current canvas + */ + public void setPreview(@Nullable RenderPreview preview) { + mPreview = preview; + } + + /** + * Returns the {@link RenderPreview} associated with this layout canvas. + * + * @see #setPreview(RenderPreview) + * @return the {@link RenderPreview} + */ + @Nullable + public RenderPreview getPreview() { + return mPreview; + } + + /** Ensures that the configuration previews are up to date for this canvas */ + public void syncPreviewMode() { + if (mImageOverlay != null && mImageOverlay.getImage() != null && + getGraphicalEditor().getConfigurationChooser().getResources() != null) { + if (mPreviewManager.recomputePreviews(false)) { + // Zoom when syncing modes + mZoomFitNextImage = true; + ensureZoomed(); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutCanvasViewer.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutCanvasViewer.java new file mode 100644 index 000000000..e349a1cb0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutCanvasViewer.java @@ -0,0 +1,165 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; + +import org.eclipse.core.runtime.ListenerList; +import org.eclipse.jface.util.SafeRunnable; +import org.eclipse.jface.viewers.IPostSelectionProvider; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.ISelectionProvider; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.jface.viewers.TreeSelection; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + + +/** + * JFace {@link Viewer} wrapper around {@link LayoutCanvas}. + * <p/> + * The viewer is owned by {@link GraphicalEditorPart}. + * <p/> + * The viewer is an {@link ISelectionProvider} instance and is set as the + * site's main {@link ISelectionProvider} by the editor part. Consequently + * canvas' selection changes are broadcasted to anyone listening, which includes + * the part itself as well as the associated outline and property sheet pages. + */ +class LayoutCanvasViewer extends Viewer implements IPostSelectionProvider { + + private LayoutCanvas mCanvas; + private final LayoutEditorDelegate mEditorDelegate; + + public LayoutCanvasViewer(LayoutEditorDelegate editorDelegate, + RulesEngine rulesEngine, + Composite parent, + int style) { + mEditorDelegate = editorDelegate; + mCanvas = new LayoutCanvas(editorDelegate, rulesEngine, parent, style); + + mCanvas.getSelectionManager().addSelectionChangedListener(mSelectionListener); + } + + private ISelectionChangedListener mSelectionListener = new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + fireSelectionChanged(event); + firePostSelectionChanged(event); + } + }; + + @Override + public Control getControl() { + return mCanvas; + } + + /** + * Returns the underlying {@link LayoutCanvas}. + * This is the same control as returned by {@link #getControl()} but clients + * have it already casted in the right type. + * <p/> + * This can never be null. + * @return The underlying {@link LayoutCanvas}. + */ + public LayoutCanvas getCanvas() { + return mCanvas; + } + + /** + * Returns the current layout editor's input. + */ + @Override + public Object getInput() { + return mEditorDelegate.getEditor().getEditorInput(); + } + + /** + * Unused. We don't support switching the input. + */ + @Override + public void setInput(Object input) { + } + + /** + * Returns a new {@link TreeSelection} where each {@link TreePath} item + * is a {@link CanvasViewInfo}. + */ + @Override + public ISelection getSelection() { + return mCanvas.getSelectionManager().getSelection(); + } + + /** + * Sets a new selection. <code>reveal</code> is ignored right now. + * <p/> + * The selection can be null, which is interpreted as an empty selection. + */ + @Override + public void setSelection(ISelection selection, boolean reveal) { + if (mEditorDelegate.getEditor().getIgnoreXmlUpdate()) { + return; + } + mCanvas.getSelectionManager().setSelection(selection); + } + + /** Unused. Refreshing is done solely by the owning {@link LayoutEditorDelegate}. */ + @Override + public void refresh() { + // ignore + } + + public void dispose() { + if (mSelectionListener != null) { + mCanvas.getSelectionManager().removeSelectionChangedListener(mSelectionListener); + } + if (mCanvas != null) { + mCanvas.dispose(); + mCanvas = null; + } + } + + // ---- Implements IPostSelectionProvider ---- + + private ListenerList mPostChangedListeners = new ListenerList(); + + @Override + public void addPostSelectionChangedListener(ISelectionChangedListener listener) { + mPostChangedListeners.add(listener); + } + + @Override + public void removePostSelectionChangedListener(ISelectionChangedListener listener) { + mPostChangedListeners.remove(listener); + } + + protected void firePostSelectionChanged(final SelectionChangedEvent event) { + Object[] listeners = mPostChangedListeners.getListeners(); + for (int i = 0; i < listeners.length; i++) { + final ISelectionChangedListener l = (ISelectionChangedListener) listeners[i]; + SafeRunnable.run(new SafeRunnable() { + @Override + public void run() { + l.selectionChanged(event); + } + }); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutMetadata.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutMetadata.java new file mode 100644 index 000000000..b79e3b0a1 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutMetadata.java @@ -0,0 +1,413 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_NUM_COLUMNS; +import static com.android.SdkConstants.EXPANDABLE_LIST_VIEW; +import static com.android.SdkConstants.GRID_VIEW; +import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.SdkConstants.VALUE_AUTO_FIT; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.api.AdapterBinding; +import com.android.ide.common.rendering.api.DataBindingItem; +import com.android.ide.common.rendering.api.ResourceReference; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.ProjectCallback; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.progress.WorkbenchJob; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xmlpull.v1.XmlPullParser; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Design-time metadata lookup for layouts, such as fragment and AdapterView bindings. + */ +public class LayoutMetadata { + /** The default layout to use for list items in expandable list views */ + public static final String DEFAULT_EXPANDABLE_LIST_ITEM = "simple_expandable_list_item_2"; //$NON-NLS-1$ + /** The default layout to use for list items in plain list views */ + public static final String DEFAULT_LIST_ITEM = "simple_list_item_2"; //$NON-NLS-1$ + /** The default layout to use for list items in spinners */ + public static final String DEFAULT_SPINNER_ITEM = "simple_spinner_item"; //$NON-NLS-1$ + + /** The string to start metadata comments with */ + private static final String COMMENT_PROLOGUE = " Preview: "; + /** The property key, included in comments, which references a list item layout */ + public static final String KEY_LV_ITEM = "listitem"; //$NON-NLS-1$ + /** The property key, included in comments, which references a list header layout */ + public static final String KEY_LV_HEADER = "listheader"; //$NON-NLS-1$ + /** The property key, included in comments, which references a list footer layout */ + public static final String KEY_LV_FOOTER = "listfooter"; //$NON-NLS-1$ + /** The property key, included in comments, which references a fragment layout to show */ + public static final String KEY_FRAGMENT_LAYOUT = "layout"; //$NON-NLS-1$ + // NOTE: If you add additional keys related to resources, make sure you update the + // ResourceRenameParticipant + + /** Utility class, do not create instances */ + private LayoutMetadata() { + } + + /** + * Returns the given property specified in the <b>current</b> element being + * processed by the given pull parser. + * + * @param parser the pull parser, which must be in the middle of processing + * the target element + * @param name the property name to look up + * @return the property value, or null if not defined + */ + @Nullable + public static String getProperty(@NonNull XmlPullParser parser, @NonNull String name) { + String value = parser.getAttributeValue(TOOLS_URI, name); + if (value != null && value.isEmpty()) { + value = null; + } + + return value; + } + + /** + * Clears the old metadata from the given node + * + * @param node the XML node to associate metadata with + * @deprecated this method clears metadata using the old comment-based style; + * should only be used for migration at this point + */ + @Deprecated + public static void clearLegacyComment(Node node) { + NodeList children = node.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.COMMENT_NODE) { + String text = child.getNodeValue(); + if (text.startsWith(COMMENT_PROLOGUE)) { + Node commentNode = child; + // Remove the comment, along with surrounding whitespace if applicable + Node previous = commentNode.getPreviousSibling(); + if (previous != null && previous.getNodeType() == Node.TEXT_NODE) { + if (previous.getNodeValue().trim().length() == 0) { + node.removeChild(previous); + } + } + node.removeChild(commentNode); + Node first = node.getFirstChild(); + if (first != null && first.getNextSibling() == null + && first.getNodeType() == Node.TEXT_NODE) { + if (first.getNodeValue().trim().length() == 0) { + node.removeChild(first); + } + } + } + } + } + } + + /** + * Returns the given property of the given DOM node, or null + * + * @param node the XML node to associate metadata with + * @param name the name of the property to look up + * @return the value stored with the given node and name, or null + */ + @Nullable + public static String getProperty( + @NonNull Node node, + @NonNull String name) { + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) node; + String value = element.getAttributeNS(TOOLS_URI, name); + if (value != null && value.isEmpty()) { + value = null; + } + + return value; + } + + return null; + } + + /** + * Sets the given property of the given DOM node to a given value, or if null clears + * the property. + * + * @param editor the editor associated with the property + * @param node the XML node to associate metadata with + * @param name the name of the property to set + * @param value the value to store for the given node and name, or null to remove it + */ + public static void setProperty( + @NonNull final AndroidXmlEditor editor, + @NonNull final Node node, + @NonNull final String name, + @Nullable final String value) { + // Clear out the old metadata + clearLegacyComment(node); + + if (node.getNodeType() == Node.ELEMENT_NODE) { + final Element element = (Element) node; + final String undoLabel = "Bind View"; + AdtUtils.setToolsAttribute(editor, element, undoLabel, name, value, + false /*reveal*/, false /*append*/); + + // Also apply the same layout to any corresponding elements in other configurations + // of this layout. + final IFile file = editor.getInputFile(); + if (file != null) { + final List<IFile> variations = AdtUtils.getResourceVariations(file, false); + if (variations.isEmpty()) { + return; + } + Display display = AdtPlugin.getDisplay(); + WorkbenchJob job = new WorkbenchJob(display, "Update alternate views") { + @Override + public IStatus runInUIThread(IProgressMonitor monitor) { + for (IFile variation : variations) { + if (variation.equals(file)) { + continue; + } + try { + // If the corresponding file is open in the IDE, use the + // editor version instead + if (!AdtPrefs.getPrefs().isSharedLayoutEditor()) { + if (setPropertyInEditor(undoLabel, variation, element, name, + value)) { + return Status.OK_STATUS; + } + } + + boolean old = editor.getIgnoreXmlUpdate(); + try { + editor.setIgnoreXmlUpdate(true); + setPropertyInFile(undoLabel, variation, element, name, value); + } finally { + editor.setIgnoreXmlUpdate(old); + } + } catch (Exception e) { + AdtPlugin.log(e, variation.getFullPath().toOSString()); + } + } + return Status.OK_STATUS; + } + + }; + job.setSystem(true); + job.schedule(); + } + } + } + + private static boolean setPropertyInEditor( + @NonNull String undoLabel, + @NonNull IFile variation, + @NonNull final Element equivalentElement, + @NonNull final String name, + @Nullable final String value) { + Collection<IEditorPart> editors = + AdtUtils.findEditorsFor(variation, false /*restore*/); + for (IEditorPart part : editors) { + AndroidXmlEditor editor = AdtUtils.getXmlEditor(part); + if (editor != null) { + Document doc = DomUtilities.getDocument(editor); + if (doc != null) { + Element element = DomUtilities.findCorresponding(equivalentElement, doc); + if (element != null) { + AdtUtils.setToolsAttribute(editor, element, undoLabel, name, + value, false /*reveal*/, false /*append*/); + if (part instanceof GraphicalEditorPart) { + GraphicalEditorPart g = (GraphicalEditorPart) part; + g.recomputeLayout(); + g.getCanvasControl().redraw(); + } + return true; + } + } + } + } + + return false; + } + + private static boolean setPropertyInFile( + @NonNull String undoLabel, + @NonNull IFile variation, + @NonNull final Element element, + @NonNull final String name, + @Nullable final String value) { + Document doc = DomUtilities.getDocument(variation); + if (doc != null && element.getOwnerDocument() != doc) { + Element other = DomUtilities.findCorresponding(element, doc); + if (other != null) { + AdtUtils.setToolsAttribute(variation, other, undoLabel, + name, value, false); + + return true; + } + } + + return false; + } + + /** Strips out @layout/ or @android:layout/ from the given layout reference */ + private static String stripLayoutPrefix(String layout) { + if (layout.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX)) { + layout = layout.substring(ANDROID_LAYOUT_RESOURCE_PREFIX.length()); + } else if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) { + layout = layout.substring(LAYOUT_RESOURCE_PREFIX.length()); + } + + return layout; + } + + /** + * Creates an {@link AdapterBinding} for the given view object, or null if the user + * has not yet chosen a target layout to use for the given AdapterView. + * + * @param viewObject the view object to create an adapter binding for + * @param map a map containing tools attribute metadata + * @return a binding, or null + */ + @Nullable + public static AdapterBinding getNodeBinding( + @Nullable Object viewObject, + @NonNull Map<String, String> map) { + String header = map.get(KEY_LV_HEADER); + String footer = map.get(KEY_LV_FOOTER); + String layout = map.get(KEY_LV_ITEM); + if (layout != null || header != null || footer != null) { + int count = 12; + return getNodeBinding(viewObject, header, footer, layout, count); + } + + return null; + } + + /** + * Creates an {@link AdapterBinding} for the given view object, or null if the user + * has not yet chosen a target layout to use for the given AdapterView. + * + * @param viewObject the view object to create an adapter binding for + * @param uiNode the ui node corresponding to the view object + * @return a binding, or null + */ + @Nullable + public static AdapterBinding getNodeBinding( + @Nullable Object viewObject, + @NonNull UiViewElementNode uiNode) { + Node xmlNode = uiNode.getXmlNode(); + + String header = getProperty(xmlNode, KEY_LV_HEADER); + String footer = getProperty(xmlNode, KEY_LV_FOOTER); + String layout = getProperty(xmlNode, KEY_LV_ITEM); + if (layout != null || header != null || footer != null) { + int count = 12; + // If we're dealing with a grid view, multiply the list item count + // by the number of columns to ensure we have enough items + if (xmlNode instanceof Element && xmlNode.getNodeName().endsWith(GRID_VIEW)) { + Element element = (Element) xmlNode; + String columns = element.getAttributeNS(ANDROID_URI, ATTR_NUM_COLUMNS); + int multiplier = 2; + if (columns != null && columns.length() > 0 && + !columns.equals(VALUE_AUTO_FIT)) { + try { + int c = Integer.parseInt(columns); + if (c >= 1 && c <= 10) { + multiplier = c; + } + } catch (NumberFormatException nufe) { + // some unexpected numColumns value: just stick with 2 columns for + // preview purposes + } + } + count *= multiplier; + } + + return getNodeBinding(viewObject, header, footer, layout, count); + } + + return null; + } + + private static AdapterBinding getNodeBinding(Object viewObject, + String header, String footer, String layout, int count) { + if (layout != null || header != null || footer != null) { + AdapterBinding binding = new AdapterBinding(count); + + if (header != null) { + boolean isFramework = header.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX); + binding.addHeader(new ResourceReference(stripLayoutPrefix(header), + isFramework)); + } + + if (footer != null) { + boolean isFramework = footer.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX); + binding.addFooter(new ResourceReference(stripLayoutPrefix(footer), + isFramework)); + } + + if (layout != null) { + boolean isFramework = layout.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX); + if (isFramework) { + layout = layout.substring(ANDROID_LAYOUT_RESOURCE_PREFIX.length()); + } else if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) { + layout = layout.substring(LAYOUT_RESOURCE_PREFIX.length()); + } + + binding.addItem(new DataBindingItem(layout, isFramework, 1)); + } else if (viewObject != null) { + String listFqcn = ProjectCallback.getListAdapterViewFqcn(viewObject.getClass()); + if (listFqcn != null) { + if (listFqcn.endsWith(EXPANDABLE_LIST_VIEW)) { + binding.addItem( + new DataBindingItem(DEFAULT_EXPANDABLE_LIST_ITEM, + true /* isFramework */, 1)); + } else { + binding.addItem( + new DataBindingItem(DEFAULT_LIST_ITEM, + true /* isFramework */, 1)); + } + } + } else { + binding.addItem( + new DataBindingItem(DEFAULT_LIST_ITEM, + true /* isFramework */, 1)); + } + return binding; + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutPoint.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutPoint.java new file mode 100644 index 000000000..818b2c4ef --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutPoint.java @@ -0,0 +1,156 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.api.Point; + +import org.eclipse.swt.dnd.DragSourceEvent; +import org.eclipse.swt.dnd.DragSourceListener; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseListener; + +/** + * A {@link LayoutPoint} is a coordinate in the Android canvas (in other words, + * it may differ from the canvas control mouse coordinate because the canvas may + * be zoomed and scrolled.) + */ +public final class LayoutPoint { + /** Containing canvas which the point is relative to. */ + private final LayoutCanvas mCanvas; + + /** The X coordinate of the canvas coordinate. */ + public final int x; + + /** The Y coordinate of the canvas coordinate. */ + public final int y; + + /** + * Constructs a new {@link LayoutPoint} from the given event. The event + * must be from a {@link MouseListener} associated with the + * {@link LayoutCanvas} such that the {@link MouseEvent#x} and + * {@link MouseEvent#y} fields are relative to the canvas. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param event The mouse event to construct the {@link LayoutPoint} + * from. + * @return A {@link LayoutPoint} which corresponds to the given + * {@link MouseEvent}. + */ + public static LayoutPoint create(LayoutCanvas canvas, MouseEvent event) { + // The mouse event coordinates should already be relative to the canvas + // widget. + assert event.widget == canvas : event.widget; + return ControlPoint.create(canvas, event).toLayout(); + } + + /** + * Constructs a new {@link LayoutPoint} from the given event. The event + * must be from a {@link DragSourceListener} associated with the + * {@link LayoutCanvas} such that the {@link DragSourceEvent#x} and + * {@link DragSourceEvent#y} fields are relative to the canvas. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param event The mouse event to construct the {@link LayoutPoint} + * from. + * @return A {@link LayoutPoint} which corresponds to the given + * {@link DragSourceEvent}. + */ + public static LayoutPoint create(LayoutCanvas canvas, DragSourceEvent event) { + // The drag source event coordinates should already be relative to the + // canvas widget. + return ControlPoint.create(canvas, event).toLayout(); + } + + /** + * Constructs a new {@link LayoutPoint} from the given x,y coordinates. + * + * @param canvas The {@link LayoutCanvas} this point is within. + * @param x The mouse event x coordinate relative to the canvas + * @param y The mouse event x coordinate relative to the canvas + * @return A {@link LayoutPoint} which corresponds to the given + * layout coordinates. + */ + public static LayoutPoint create(LayoutCanvas canvas, int x, int y) { + return new LayoutPoint(canvas, x, y); + } + + /** + * Constructs a new {@link LayoutPoint} with the given X and Y coordinates. + * + * @param canvas The canvas which contains this coordinate + * @param x The canvas X coordinate + * @param y The canvas Y coordinate + */ + private LayoutPoint(LayoutCanvas canvas, int x, int y) { + mCanvas = canvas; + this.x = x; + this.y = y; + } + + /** + * Returns the equivalent {@link ControlPoint} to this + * {@link LayoutPoint}. + * + * @return The equivalent {@link ControlPoint} to this + * {@link LayoutPoint} + */ + public ControlPoint toControl() { + int cx = mCanvas.getHorizontalTransform().translate(x); + int cy = mCanvas.getVerticalTransform().translate(y); + + return ControlPoint.create(mCanvas, cx, cy); + } + + /** + * Returns this {@link LayoutPoint} as a {@link Point}, in the same coordinate space. + * + * @return a new {@link Point} in the same coordinate space + */ + public Point toPoint() { + return new Point(x, y); + } + + @Override + public String toString() { + return "LayoutPoint [x=" + x + ", y=" + y + "]"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + x; + result = prime * result + y; + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + LayoutPoint other = (LayoutPoint) obj; + if (x != other.x) + return false; + if (y != other.y) + return false; + return true; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutWindowCoordinator.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutWindowCoordinator.java new file mode 100644 index 000000000..56b86aa85 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LayoutWindowCoordinator.java @@ -0,0 +1,394 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.google.common.collect.Maps; + +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IEditorReference; +import org.eclipse.ui.IPartListener2; +import org.eclipse.ui.IPartService; +import org.eclipse.ui.IViewReference; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.IWorkbenchPartReference; +import org.eclipse.ui.IWorkbenchWindow; + +import java.util.Map; + +/** + * The {@link LayoutWindowCoordinator} keeps track of Eclipse window events (opening, closing, + * fronting, etc) and uses this information to manage the propertysheet and outline + * views such that they are always(*) showing: + * <ul> + * <li> If the Property Sheet and Outline Eclipse views are showing, it does nothing. + * "Showing" means "is open", not necessary "is visible", e.g. in a tabbed view + * there could be a different view on top. + * <li> If just the outline is showing, then the property sheet is shown in a sashed + * pane below or to the right of the outline (depending on the dominant dimension + * of the window). + * <li> TBD: If just the property sheet is showing, should the outline be showed + * inside that window? Not yet done. + * <li> If the outline is *not* showing, then the outline is instead shown + * <b>inside</b> the editor area, in a right-docked view! This right docked view + * also includes the property sheet! + * <li> If the property sheet is not showing (which includes not showing in the outline + * view as well), then it will be shown inside the editor area, along with the outline + * which should also be there (since if the outline was showing outside the editor + * area, the property sheet would have docked there). + * <li> When the editor is maximized, then all views are temporarily hidden. In this + * case, the property sheet and outline will show up inside the editor. + * When the editor view is un-maximized, the view state will return to what it + * was before. + * </ul> + * </p> + * There is one coordinator per workbench window, shared between all editors in that window. + * <p> + * TODO: Rename this class to AdtWindowCoordinator. It is used for more than just layout + * window coordination now. For example, it's also used to dispatch {@code activated()} and + * {@code deactivated()} events to all the XML editors, to ensure that key bindings are + * properly dispatched to the right editors in Eclipse 4.x. + */ +public class LayoutWindowCoordinator implements IPartListener2 { + static final String PROPERTY_SHEET_PART_ID = "org.eclipse.ui.views.PropertySheet"; //$NON-NLS-1$ + static final String OUTLINE_PART_ID = "org.eclipse.ui.views.ContentOutline"; //$NON-NLS-1$ + /** The workbench window */ + private final IWorkbenchWindow mWindow; + /** Is the Eclipse property sheet ViewPart open? */ + private boolean mPropertiesOpen; + /** Is the Eclipse outline ViewPart open? */ + private boolean mOutlineOpen; + /** Is the editor maximized? */ + private boolean mEditorMaximized; + /** + * Has the coordinator been initialized? We may have to delay initialization + * and perform it lazily if the workbench window does not have an active + * page when the coordinator is first started + */ + private boolean mInitialized; + + /** Map from workbench windows to each layout window coordinator instance for that window */ + private static Map<IWorkbenchWindow, LayoutWindowCoordinator> sCoordinators = + Maps.newHashMapWithExpectedSize(2); + + /** + * Returns the coordinator for the given window. + * + * @param window the associated window + * @param create whether to create the window if it does not already exist + * @return the new coordinator, never null if {@code create} is true + */ + @Nullable + public static LayoutWindowCoordinator get(@NonNull IWorkbenchWindow window, boolean create) { + synchronized (LayoutWindowCoordinator.class){ + LayoutWindowCoordinator coordinator = sCoordinators.get(window); + if (coordinator == null && create) { + coordinator = new LayoutWindowCoordinator(window); + + IPartService service = window.getPartService(); + if (service != null) { + // What if the editor part is *already* open? How do I deal with that? + service.addPartListener(coordinator); + } + + sCoordinators.put(window, coordinator); + } + + return coordinator; + } + } + + + /** Disposes this coordinator (when a window is closed) */ + public void dispose() { + IPartService service = mWindow.getPartService(); + if (service != null) { + service.removePartListener(this); + } + + synchronized (LayoutWindowCoordinator.class){ + sCoordinators.remove(mWindow); + } + } + + /** + * Returns true if the main editor window is maximized + * + * @return true if the main editor window is maximized + */ + public boolean isEditorMaximized() { + return mEditorMaximized; + } + + private LayoutWindowCoordinator(@NonNull IWorkbenchWindow window) { + mWindow = window; + + initialize(); + } + + private void initialize() { + if (mInitialized) { + return; + } + + IWorkbenchPage activePage = mWindow.getActivePage(); + if (activePage == null) { + return; + } + + mInitialized = true; + + // Look up current state of the properties and outline windows (in case + // they have already been opened before we added our part listener) + IViewReference ref = findPropertySheetView(activePage); + if (ref != null) { + IWorkbenchPart part = ref.getPart(false /*restore*/); + if (activePage.isPartVisible(part)) { + mPropertiesOpen = true; + } + } + ref = findOutlineView(activePage); + if (ref != null) { + IWorkbenchPart part = ref.getPart(false /*restore*/); + if (activePage.isPartVisible(part)) { + mOutlineOpen = true; + } + } + if (!syncMaximizedState(activePage)) { + syncActive(); + } + } + + static IViewReference findPropertySheetView(IWorkbenchPage activePage) { + return activePage.findViewReference(PROPERTY_SHEET_PART_ID); + } + + static IViewReference findOutlineView(IWorkbenchPage activePage) { + return activePage.findViewReference(OUTLINE_PART_ID); + } + + /** + * Checks the maximized state of the page and updates internal state if + * necessary. + * <p> + * This is used in Eclipse 4.x, where the {@link IPartListener2} does not + * fire {@link IPartListener2#partHidden(IWorkbenchPartReference)} when the + * editor is maximized anymore (see issue + * https://bugs.eclipse.org/bugs/show_bug.cgi?id=382120 for details). + * Instead, the layout editor listens for resize events, and upon resize it + * looks up the part state and calls this method to ensure that the right + * maximized state is known to the layout coordinator. + * + * @param page the active workbench page + * @return true if the state changed, false otherwise + */ + public boolean syncMaximizedState(IWorkbenchPage page) { + boolean maximized = isPageZoomed(page); + if (mEditorMaximized != maximized) { + mEditorMaximized = maximized; + syncActive(); + return true; + } + return false; + } + + private boolean isPageZoomed(IWorkbenchPage page) { + IWorkbenchPartReference reference = page.getActivePartReference(); + if (reference != null && reference instanceof IEditorReference) { + int state = page.getPartState(reference); + boolean maximized = (state & IWorkbenchPage.STATE_MAXIMIZED) != 0; + return maximized; + } + + // If the active reference isn't the editor, then the editor can't be maximized + return false; + } + + /** + * Syncs the given editor's view state such that the property sheet and or + * outline are shown or hidden according to the visibility of the global + * outline and property sheet views. + * <p> + * This is typically done when a layout editor is fronted. For view updates + * when the view is already showing, the {@link LayoutWindowCoordinator} + * will automatically handle the current fronted window. + * + * @param editor the editor to sync + */ + private void sync(@Nullable GraphicalEditorPart editor) { + if (editor == null) { + return; + } + if (mEditorMaximized) { + editor.showStructureViews(true /*outline*/, true /*properties*/, true /*layout*/); + } else if (mOutlineOpen) { + editor.showStructureViews(false /*outline*/, false /*properties*/, true /*layout*/); + editor.getCanvasControl().getOutlinePage().setShowPropertySheet(!mPropertiesOpen); + } else { + editor.showStructureViews(true /*outline*/, !mPropertiesOpen /*properties*/, + true /*layout*/); + } + } + + private void sync(IWorkbenchPart part) { + if (part instanceof AndroidXmlEditor) { + LayoutEditorDelegate editor = LayoutEditorDelegate.fromEditor((IEditorPart) part); + if (editor != null) { + sync(editor.getGraphicalEditor()); + } + } + } + + private void syncActive() { + IWorkbenchPage activePage = mWindow.getActivePage(); + if (activePage != null) { + IEditorPart editor = activePage.getActiveEditor(); + sync(editor); + } + } + + private void propertySheetClosed() { + mPropertiesOpen = false; + syncActive(); + } + + private void propertySheetOpened() { + mPropertiesOpen = true; + syncActive(); + } + + private void outlineClosed() { + mOutlineOpen = false; + syncActive(); + } + + private void outlineOpened() { + mOutlineOpen = true; + syncActive(); + } + + // ---- Implements IPartListener2 ---- + + @Override + public void partOpened(IWorkbenchPartReference partRef) { + // We ignore partOpened() and partClosed() because these methods are only + // called when a view is opened in the first perspective, and closed in the + // last perspective. The outline is typically used in multiple perspectives, + // so closing it in the Java perspective does *not* fire a partClosed event. + // There is no notification for "part closed in perspective" (see issue + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=54559 for details). + // However, the workaround we can use is to listen to partVisible() and + // partHidden(). These will be called more often than we'd like (e.g. + // when the tab order causes a view to be obscured), however, we can use + // the workaround of looking up IWorkbenchPage.findViewReference(id) after + // partHidden(), which will return null if the view is closed in the current + // perspective. For partOpened, we simply look in partVisible() for whether + // our flags tracking the view state have been initialized already. + } + + @Override + public void partClosed(IWorkbenchPartReference partRef) { + // partClosed() doesn't get called when a window is closed unless it has + // been closed in *all* perspectives. See partOpened() for more. + } + + @Override + public void partHidden(IWorkbenchPartReference partRef) { + IWorkbenchPage activePage = mWindow.getActivePage(); + if (activePage == null) { + return; + } + initialize(); + + // See if this looks like the window was closed in this workspace + // See partOpened() for an explanation. + String id = partRef.getId(); + if (PROPERTY_SHEET_PART_ID.equals(id)) { + if (activePage.findViewReference(id) == null) { + propertySheetClosed(); + return; + } + } else if (OUTLINE_PART_ID.equals(id)) { + if (activePage.findViewReference(id) == null) { + outlineClosed(); + return; + } + } + + // Does this look like a window getting maximized? + syncMaximizedState(activePage); + } + + @Override + public void partVisible(IWorkbenchPartReference partRef) { + IWorkbenchPage activePage = mWindow.getActivePage(); + if (activePage == null) { + return; + } + initialize(); + + String id = partRef.getId(); + if (mEditorMaximized) { + // Return to their non-maximized state + mEditorMaximized = false; + syncActive(); + } + + IWorkbenchPart part = partRef.getPart(false /*restore*/); + sync(part); + + // See partOpened() for an explanation + if (PROPERTY_SHEET_PART_ID.equals(id)) { + if (!mPropertiesOpen) { + propertySheetOpened(); + assert mPropertiesOpen; + } + } else if (OUTLINE_PART_ID.equals(id)) { + if (!mOutlineOpen) { + outlineOpened(); + assert mOutlineOpen; + } + } + } + + @Override + public void partInputChanged(IWorkbenchPartReference partRef) { + } + + @Override + public void partActivated(IWorkbenchPartReference partRef) { + IWorkbenchPart part = partRef.getPart(false); + if (part instanceof AndroidXmlEditor) { + ((AndroidXmlEditor)part).activated(); + } + } + + @Override + public void partBroughtToTop(IWorkbenchPartReference partRef) { + } + + @Override + public void partDeactivated(IWorkbenchPartReference partRef) { + IWorkbenchPart part = partRef.getPart(false); + if (part instanceof AndroidXmlEditor) { + ((AndroidXmlEditor)part).deactivated(); + } + } +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintOverlay.java new file mode 100644 index 000000000..ca74493e8 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintOverlay.java @@ -0,0 +1,140 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.google.common.collect.Lists; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Rectangle; +import org.w3c.dom.Node; + +import java.util.Collection; + +/** + * The {@link LintOverlay} paints an icon over each view that contains at least one + * lint error (unless the view is smaller than the icon) + */ +public class LintOverlay extends Overlay { + /** Approximate size of lint overlay icons */ + static final int ICON_SIZE = 8; + /** Alpha to draw lint overlay icons with */ + private static final int ALPHA = 192; + + private final LayoutCanvas mCanvas; + private Image mWarningImage; + private Image mErrorImage; + + /** + * Constructs a new {@link LintOverlay} + * + * @param canvas the associated canvas + */ + public LintOverlay(LayoutCanvas canvas) { + mCanvas = canvas; + } + + @Override + public boolean isHiding() { + return super.isHiding() || !AdtPrefs.getPrefs().isLintOnSave(); + } + + @Override + public void paint(GC gc) { + LayoutEditorDelegate editor = mCanvas.getEditorDelegate(); + Collection<Node> nodes = editor.getLintNodes(); + if (nodes != null && !nodes.isEmpty()) { + // Copy list before iterating through it to avoid a concurrent list modification + // in case lint runs in the background while painting and updates this list + nodes = Lists.newArrayList(nodes); + ViewHierarchy hierarchy = mCanvas.getViewHierarchy(); + Image icon = getWarningIcon(); + ImageData imageData = icon.getImageData(); + int iconWidth = imageData.width; + int iconHeight = imageData.height; + CanvasTransform mHScale = mCanvas.getHorizontalTransform(); + CanvasTransform mVScale = mCanvas.getVerticalTransform(); + + // Right/bottom edges of the canvas image; don't paint overlays outside of + // that. (With for example RelativeLayouts with margins rendered on smaller + // screens than they are intended for this can happen.) + int maxX = mHScale.translate(0) + mHScale.getScaledImgSize(); + int maxY = mVScale.translate(0) + mVScale.getScaledImgSize(); + + int oldAlpha = gc.getAlpha(); + try { + gc.setAlpha(ALPHA); + for (Node node : nodes) { + CanvasViewInfo vi = hierarchy.findViewInfoFor(node); + if (vi != null) { + Rectangle bounds = vi.getAbsRect(); + int x = mHScale.translate(bounds.x); + int y = mVScale.translate(bounds.y); + int w = mHScale.scale(bounds.width); + int h = mVScale.scale(bounds.height); + if (w < iconWidth || h < iconHeight) { + // Don't draw badges on tiny widgets (including those + // that aren't tiny but are zoomed out too far) + continue; + } + + x += w - iconWidth; + y += h - iconHeight; + + if (x > maxX || y > maxY) { + continue; + } + + boolean isError = false; + IMarker marker = editor.getIssueForNode(vi.getUiViewNode()); + if (marker != null) { + int severity = marker.getAttribute(IMarker.SEVERITY, 0); + isError = severity == IMarker.SEVERITY_ERROR; + } + + icon = isError ? getErrorIcon() : getWarningIcon(); + + gc.drawImage(icon, x, y); + } + } + } finally { + gc.setAlpha(oldAlpha); + } + } + } + + private Image getWarningIcon() { + if (mWarningImage == null) { + mWarningImage = IconFactory.getInstance().getIcon("warning-badge"); //$NON-NLS-1$ + } + + return mWarningImage; + } + + private Image getErrorIcon() { + if (mErrorImage == null) { + mErrorImage = IconFactory.getInstance().getIcon("error-badge"); //$NON-NLS-1$ + } + + return mErrorImage; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintTooltip.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintTooltip.java new file mode 100644 index 000000000..cedd43659 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintTooltip.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ATTR_ID; + +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; + +import java.util.List; + +/** Actual tooltip showing multiple lines for various widgets that have lint errors */ +class LintTooltip extends Shell { + private final LayoutCanvas mCanvas; + private final List<UiViewElementNode> mNodes; + + LintTooltip(LayoutCanvas canvas, List<UiViewElementNode> nodes) { + super(canvas.getDisplay(), SWT.ON_TOP | SWT.NO_FOCUS | SWT.TOOL); + mCanvas = canvas; + mNodes = nodes; + + createContents(); + } + + protected void createContents() { + Display display = getDisplay(); + Color fg = display.getSystemColor(SWT.COLOR_INFO_FOREGROUND); + Color bg = display.getSystemColor(SWT.COLOR_INFO_BACKGROUND); + setBackground(bg); + GridLayout gridLayout = new GridLayout(2, false); + setLayout(gridLayout); + + LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + + boolean first = true; + for (UiViewElementNode node : mNodes) { + IMarker marker = delegate.getIssueForNode(node); + if (marker != null) { + String message = marker.getAttribute(IMarker.MESSAGE, null); + if (message != null) { + Label icon = new Label(this, SWT.NONE); + icon.setForeground(fg); + icon.setBackground(bg); + icon.setImage(node.getIcon()); + + Label label = new Label(this, SWT.WRAP); + if (first) { + label.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, true, false, 1, 1)); + first = false; + } + + String id = BaseLayoutRule.stripIdPrefix(node.getAttributeValue(ATTR_ID)); + if (id.isEmpty()) { + if (node.getXmlNode() != null) { + id = node.getXmlNode().getNodeName(); + } else { + id = node.getDescriptor().getUiName(); + } + } + + label.setText(String.format("%1$s: %2$s", id, message)); + } + } + } + } + + @Override + protected void checkSubclass() { + // Disable the check that prevents subclassing of SWT components + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintTooltipManager.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintTooltipManager.java new file mode 100644 index 000000000..f71935889 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/LintTooltipManager.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.LintOverlay.ICON_SIZE; + +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** Tooltip in the layout editor showing lint errors under the cursor */ +class LintTooltipManager implements Listener { + private final LayoutCanvas mCanvas; + private Shell mTip = null; + private List<UiViewElementNode> mShowingNodes; + + /** + * Sets up a custom tooltip when hovering over tree items. It currently displays the error + * message for the lint warning associated with each node, if any (and only if the hover + * is over the icon portion). + */ + LintTooltipManager(LayoutCanvas canvas) { + mCanvas = canvas; + } + + void register() { + mCanvas.addListener(SWT.Dispose, this); + mCanvas.addListener(SWT.KeyDown, this); + mCanvas.addListener(SWT.MouseMove, this); + mCanvas.addListener(SWT.MouseHover, this); + } + + void unregister() { + if (!mCanvas.isDisposed()) { + mCanvas.removeListener(SWT.Dispose, this); + mCanvas.removeListener(SWT.KeyDown, this); + mCanvas.removeListener(SWT.MouseMove, this); + mCanvas.removeListener(SWT.MouseHover, this); + } + } + + @Override + public void handleEvent(Event event) { + switch(event.type) { + case SWT.MouseMove: + // See if we're still overlapping this or *other* errors; if so, keep the + // tip up (or update it). + if (mShowingNodes != null) { + List<UiViewElementNode> nodes = computeNodes(event); + if (nodes != null && !nodes.isEmpty()) { + if (nodes.equals(mShowingNodes)) { + return; + } else { + show(nodes); + } + break; + } + } + + // If not, fall through and hide the tooltip + + //$FALL-THROUGH$ + case SWT.Dispose: + case SWT.FocusOut: + case SWT.KeyDown: + case SWT.MouseExit: + case SWT.MouseDown: + hide(); + break; + case SWT.MouseHover: + hide(); + show(event); + break; + } + } + + void hide() { + if (mTip != null) { + mTip.dispose(); + mTip = null; + } + mShowingNodes = null; + } + + private void show(Event event) { + List<UiViewElementNode> nodes = computeNodes(event); + if (nodes != null && !nodes.isEmpty()) { + show(nodes); + } + } + + /** Show a tooltip listing the lint errors for the given nodes */ + private void show(List<UiViewElementNode> nodes) { + hide(); + + if (!AdtPrefs.getPrefs().isLintOnSave()) { + return; + } + + mTip = new LintTooltip(mCanvas, nodes); + Rectangle rect = mCanvas.getBounds(); + Point size = mTip.computeSize(SWT.DEFAULT, SWT.DEFAULT); + Point pos = mCanvas.toDisplay(rect.x, rect.y + rect.height); + if (size.x > rect.width) { + size = mTip.computeSize(rect.width, SWT.DEFAULT); + } + mTip.setBounds(pos.x, pos.y, size.x, size.y); + + mShowingNodes = nodes; + mTip.setVisible(true); + } + + /** + * Compute the list of nodes which have lint warnings near the given mouse + * coordinates + * + * @param event the mouse cursor event + * @return a list of nodes, possibly empty + */ + @Nullable + private List<UiViewElementNode> computeNodes(Event event) { + LayoutPoint p = ControlPoint.create(mCanvas, event.x, event.y).toLayout(); + LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + CanvasTransform mHScale = mCanvas.getHorizontalTransform(); + CanvasTransform mVScale = mCanvas.getVerticalTransform(); + + int layoutIconSize = mHScale.inverseScale(ICON_SIZE); + int slop = mVScale.inverseScale(10); // extra space around icon where tip triggers + + Collection<Node> xmlNodes = delegate.getLintNodes(); + if (xmlNodes == null) { + return null; + } + List<UiViewElementNode> nodes = new ArrayList<UiViewElementNode>(); + for (Node xmlNode : xmlNodes) { + CanvasViewInfo v = viewHierarchy.findViewInfoFor(xmlNode); + if (v != null) { + Rectangle b = v.getAbsRect(); + int x2 = b.x + b.width; + int y2 = b.y + b.height; + if (p.x < x2 - layoutIconSize - slop + || p.x > x2 + slop + || p.y < y2 - layoutIconSize - slop + || p.y > y2 + slop) { + continue; + } + + nodes.add(v.getUiViewNode()); + } + } + + return nodes; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ListViewTypeMenu.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ListViewTypeMenu.java new file mode 100644 index 000000000..4577f8d12 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ListViewTypeMenu.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_LAYOUT_RESOURCE_PREFIX; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata.KEY_LV_FOOTER; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata.KEY_LV_HEADER; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutMetadata.KEY_LV_ITEM; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.resources.CyclicDependencyValidator; +import com.android.ide.eclipse.adt.internal.ui.ResourceChooser; +import com.android.resources.ResourceType; + +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Menu; +import org.w3c.dom.Node; + +/** + * "Preview List Content" context menu which lists available data types and layouts + * the user can choose to view the ListView as. + */ +public class ListViewTypeMenu extends SubmenuAction { + /** Associated canvas */ + private final LayoutCanvas mCanvas; + /** When true, this menu is for a grid rather than a simple list */ + private boolean mGrid; + /** When true, this menu is for a spinner rather than a simple list */ + private boolean mSpinner; + + /** + * Creates a "Preview List Content" menu + * + * @param canvas associated canvas + * @param isGrid whether the menu is for a grid rather than a list + * @param isSpinner whether the menu is for a spinner rather than a list + */ + public ListViewTypeMenu(LayoutCanvas canvas, boolean isGrid, boolean isSpinner) { + super(isGrid ? "Preview Grid Content" : isSpinner ? "Preview Spinner Layout" + : "Preview List Content"); + mCanvas = canvas; + mGrid = isGrid; + mSpinner = isSpinner; + } + + @Override + protected void addMenuItems(Menu menu) { + GraphicalEditorPart graphicalEditor = mCanvas.getEditorDelegate().getGraphicalEditor(); + if (graphicalEditor.renderingSupports(Capability.ADAPTER_BINDING)) { + IAction action = new PickLayoutAction("Choose Layout...", KEY_LV_ITEM); + new ActionContributionItem(action).fill(menu, -1); + new Separator().fill(menu, -1); + + String selected = getSelectedLayout(); + if (selected != null) { + if (selected.startsWith(ANDROID_LAYOUT_RESOURCE_PREFIX)) { + selected = selected.substring(ANDROID_LAYOUT_RESOURCE_PREFIX.length()); + } + } + + if (mSpinner) { + action = new SetListTypeAction("Spinner Item", + "simple_spinner_item", selected); //$NON-NLS-1$ + new ActionContributionItem(action).fill(menu, -1); + action = new SetListTypeAction("Spinner Dropdown Item", + "simple_spinner_dropdown_item", selected); //$NON-NLS-1$ + new ActionContributionItem(action).fill(menu, -1); + return; + } + + action = new SetListTypeAction("Simple List Item", + "simple_list_item_1", selected); //$NON-NLS-1$ + new ActionContributionItem(action).fill(menu, -1); + action = new SetListTypeAction("Simple 2-Line List Item", + "simple_list_item_2", //$NON-NLS-1$ + selected); + new ActionContributionItem(action).fill(menu, -1); + action = new SetListTypeAction("Checked List Item", + "simple_list_item_checked", //$NON-NLS-1$ + selected); + new ActionContributionItem(action).fill(menu, -1); + action = new SetListTypeAction("Single Choice List Item", + "simple_list_item_single_choice", //$NON-NLS-1$ + selected); + new ActionContributionItem(action).fill(menu, -1); + action = new SetListTypeAction("Multiple Choice List Item", + "simple_list_item_multiple_choice", //$NON-NLS-1$ + selected); + if (!mGrid) { + new Separator().fill(menu, -1); + action = new SetListTypeAction("Simple Expandable List Item", + "simple_expandable_list_item_1", selected); //$NON-NLS-1$ + new ActionContributionItem(action).fill(menu, -1); + action = new SetListTypeAction("Simple 2-Line Expandable List Item", + "simple_expandable_list_item_2", //$NON-NLS-1$ + selected); + new ActionContributionItem(action).fill(menu, -1); + + new Separator().fill(menu, -1); + action = new PickLayoutAction("Choose Header...", KEY_LV_HEADER); + new ActionContributionItem(action).fill(menu, -1); + action = new PickLayoutAction("Choose Footer...", KEY_LV_FOOTER); + new ActionContributionItem(action).fill(menu, -1); + } + } else { + // Should we just hide the menu item instead? + addDisabledMessageItem( + "Not supported for this SDK version; try changing the Render Target"); + } + } + + private class SetListTypeAction extends Action { + private final String mLayout; + + public SetListTypeAction(String title, String layout, String selected) { + super(title, IAction.AS_RADIO_BUTTON); + mLayout = layout; + + if (layout.equals(selected)) { + setChecked(true); + } + } + + @Override + public void run() { + if (isChecked()) { + setNewType(KEY_LV_ITEM, ANDROID_LAYOUT_RESOURCE_PREFIX + mLayout); + } + } + } + + /** + * Action which brings up a resource chooser to choose an arbitrary layout as the + * layout to be previewed in the list. + */ + private class PickLayoutAction extends Action { + private final String mType; + + public PickLayoutAction(String title, String type) { + super(title, IAction.AS_PUSH_BUTTON); + mType = type; + } + + @Override + public void run() { + LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + IFile file = delegate.getEditor().getInputFile(); + GraphicalEditorPart editor = delegate.getGraphicalEditor(); + ResourceChooser dlg = ResourceChooser.create(editor, ResourceType.LAYOUT) + .setInputValidator(CyclicDependencyValidator.create(file)) + .setInitialSize(85, 10) + .setCurrentResource(getSelectedLayout()); + int result = dlg.open(); + if (result == ResourceChooser.CLEAR_RETURN_CODE) { + setNewType(mType, null); + } else if (result == Window.OK) { + String newType = dlg.getCurrentResource(); + setNewType(mType, newType); + } + } + } + + @Nullable + private String getSelectedLayout() { + String layout = null; + SelectionManager selectionManager = mCanvas.getSelectionManager(); + for (SelectionItem item : selectionManager.getSelections()) { + UiViewElementNode node = item.getViewInfo().getUiViewNode(); + if (node != null) { + Node xmlNode = node.getXmlNode(); + layout = LayoutMetadata.getProperty(xmlNode, KEY_LV_ITEM); + if (layout != null) { + return layout; + } + } + } + + return null; + } + + private void setNewType(@NonNull String type, @Nullable String layout) { + LayoutEditorDelegate delegate = mCanvas.getEditorDelegate(); + GraphicalEditorPart graphicalEditor = delegate.getGraphicalEditor(); + SelectionManager selectionManager = mCanvas.getSelectionManager(); + + for (SelectionItem item : selectionManager.getSnapshot()) { + UiViewElementNode node = item.getViewInfo().getUiViewNode(); + if (node != null) { + Node xmlNode = node.getXmlNode(); + LayoutMetadata.setProperty(delegate.getEditor(), xmlNode, type, layout); + } + } + + // Refresh + graphicalEditor.recomputeLayout(); + mCanvas.redraw(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/MarqueeGesture.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/MarqueeGesture.java new file mode 100644 index 000000000..4cfd4fe3d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/MarqueeGesture.java @@ -0,0 +1,160 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Rectangle; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * A {@link MarqueeGesture} is a gesture for swiping out a selection rectangle. + * With a modifier key, items that intersect the rectangle can be toggled + * instead of added to the new selection set. + */ +public class MarqueeGesture extends Gesture { + /** The {@link Overlay} drawn for the marquee. */ + private MarqueeOverlay mOverlay; + + /** The canvas associated with this gesture. */ + private LayoutCanvas mCanvas; + + /** A copy of the initial selection, when we're toggling the marquee. */ + private Collection<CanvasViewInfo> mInitialSelection; + + /** + * Creates a new marquee selection (selection swiping). + * + * @param canvas The canvas where selection is performed. + * @param toggle If true, toggle the membership of contained elements + * instead of adding it. + */ + public MarqueeGesture(LayoutCanvas canvas, boolean toggle) { + mCanvas = canvas; + + if (toggle) { + List<SelectionItem> selection = canvas.getSelectionManager().getSelections(); + mInitialSelection = new ArrayList<CanvasViewInfo>(selection.size()); + for (SelectionItem item : selection) { + mInitialSelection.add(item.getViewInfo()); + } + } else { + mInitialSelection = Collections.emptySet(); + } + } + + @Override + public void update(ControlPoint pos) { + if (mOverlay == null) { + return; + } + + int x = Math.min(pos.x, mStart.x); + int y = Math.min(pos.y, mStart.y); + int w = Math.abs(pos.x - mStart.x); + int h = Math.abs(pos.y - mStart.y); + + mOverlay.updateSize(x, y, w, h); + + // Compute selection overlaps + LayoutPoint topLeft = ControlPoint.create(mCanvas, x, y).toLayout(); + LayoutPoint bottomRight = ControlPoint.create(mCanvas, x + w, y + h).toLayout(); + mCanvas.getSelectionManager().selectWithin(topLeft, bottomRight, mInitialSelection); + } + + @Override + public List<Overlay> createOverlays() { + mOverlay = new MarqueeOverlay(); + return Collections.<Overlay> singletonList(mOverlay); + } + + /** + * An {@link Overlay} for the {@link MarqueeGesture}; paints a selection + * overlay rectangle matching the mouse coordinate delta between gesture + * start and the current position. + */ + private static class MarqueeOverlay extends Overlay { + /** Rectangle border color. */ + private Color mStroke; + + /** Rectangle fill color. */ + private Color mFill; + + /** Current rectangle coordinates (in terms of control coordinates). */ + private Rectangle mRectangle = new Rectangle(0, 0, 0, 0); + + /** Alpha value of the fill. */ + private int mFillAlpha; + + /** Alpha value of the border. */ + private int mStrokeAlpha; + + /** Constructs a new {@link MarqueeOverlay}. */ + public MarqueeOverlay() { + } + + /** + * Updates the size of the marquee rectangle. + * + * @param x The top left corner of the rectangle, x coordinate. + * @param y The top left corner of the rectangle, y coordinate. + * @param w Rectangle width. + * @param h Rectangle height. + */ + public void updateSize(int x, int y, int w, int h) { + mRectangle.x = x; + mRectangle.y = y; + mRectangle.width = w; + mRectangle.height = h; + } + + @Override + public void create(Device device) { + // TODO: Integrate DrawingStyles with this? + mStroke = new Color(device, 255, 255, 255); + mFill = new Color(device, 128, 128, 128); + mFillAlpha = 64; + mStrokeAlpha = 255; + } + + @Override + public void dispose() { + mStroke.dispose(); + mFill.dispose(); + } + + @Override + public void paint(GC gc) { + if (mRectangle.width > 0 && mRectangle.height > 0) { + gc.setLineStyle(SWT.LINE_SOLID); + gc.setLineWidth(1); + gc.setForeground(mStroke); + gc.setBackground(mFill); + gc.setAlpha(mStrokeAlpha); + gc.drawRectangle(mRectangle); + gc.setAlpha(mFillAlpha); + gc.fillRectangle(mRectangle); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/MoveGesture.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/MoveGesture.java new file mode 100644 index 000000000..7cf3a647a --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/MoveGesture.java @@ -0,0 +1,852 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.INode; +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.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode.NodeCreationListener; + +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.jface.viewers.TreeSelection; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.DropTargetEvent; +import org.eclipse.swt.dnd.TransferData; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.widgets.Display; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The Move gesture provides the operation for moving widgets around in the canvas. + */ +public class MoveGesture extends DropGesture { + /** The associated {@link LayoutCanvas}. */ + private LayoutCanvas mCanvas; + + /** Overlay which paints the drag & drop feedback. */ + private MoveOverlay mOverlay; + + private static final boolean DEBUG = false; + + /** + * The top view right under the drag'n'drop cursor. + * This can only be null during a drag'n'drop when there is no view under the cursor + * or after the state was all cleared. + */ + private CanvasViewInfo mCurrentView; + + /** + * The elements currently being dragged. This will always be non-null for a valid + * drag'n'drop that happens within the same instance of Eclipse. + * <p/> + * In the event that the drag and drop happens between different instances of Eclipse + * this will remain null. + */ + private SimpleElement[] mCurrentDragElements; + + /** + * The first view under the cursor that responded to onDropEnter is called the "target view". + * It can differ from mCurrentView, typically because a terminal View doesn't + * accept drag'n'drop so its parent layout became the target drag'n'drop receiver. + * <p/> + * The target node is the proxy node associated with the target view. + * This can be null if no view under the cursor accepted the drag'n'drop or if the node + * factory couldn't create a proxy for it. + */ + private NodeProxy mTargetNode; + + /** + * The latest drop feedback returned by IViewRule.onDropEnter/Move. + */ + private DropFeedback mFeedback; + + /** + * {@link #dragLeave(DropTargetEvent)} is unfortunately called right before data is + * about to be dropped (between the last {@link #dragOver(DropTargetEvent)} and the + * next {@link #dropAccept(DropTargetEvent)}). That means we can't just + * trash the current DropFeedback from the current view rule in dragLeave(). + * Instead we preserve it in mLeaveTargetNode and mLeaveFeedback in case a dropAccept + * happens next. + */ + private NodeProxy mLeaveTargetNode; + + /** + * @see #mLeaveTargetNode + */ + private DropFeedback mLeaveFeedback; + + /** + * @see #mLeaveTargetNode + */ + private CanvasViewInfo mLeaveView; + + /** Singleton used to keep track of drag selection in the same Eclipse instance. */ + private final GlobalCanvasDragInfo mGlobalDragInfo; + + /** + * Constructs a new {@link MoveGesture}, tied to the given canvas. + * + * @param canvas The canvas to associate the {@link MoveGesture} with. + */ + public MoveGesture(LayoutCanvas canvas) { + mCanvas = canvas; + mGlobalDragInfo = GlobalCanvasDragInfo.getInstance(); + } + + @Override + public List<Overlay> createOverlays() { + mOverlay = new MoveOverlay(); + return Collections.<Overlay> singletonList(mOverlay); + } + + @Override + public void begin(ControlPoint pos, int startMask) { + super.begin(pos, startMask); + + // Hide selection overlays during a move drag + mCanvas.getSelectionOverlay().setHidden(true); + } + + @Override + public void end(ControlPoint pos, boolean canceled) { + super.end(pos, canceled); + + mCanvas.getSelectionOverlay().setHidden(false); + + // Ensure that the outline is back to showing the current selection, since during + // a drag gesture we temporarily set it to show the current target node instead. + mCanvas.getSelectionManager().syncOutlineSelection(); + } + + /* TODO: Pass modifier mask to drag rules as well! This doesn't work yet since + the drag & drop code seems to steal keyboard events. + @Override + public boolean keyPressed(KeyEvent event) { + update(mCanvas.getGestureManager().getCurrentControlPoint()); + mCanvas.redraw(); + return true; + } + + @Override + public boolean keyReleased(KeyEvent event) { + update(mCanvas.getGestureManager().getCurrentControlPoint()); + mCanvas.redraw(); + return true; + } + */ + + /* + * The cursor has entered the drop target boundaries. + * {@inheritDoc} + */ + @Override + public void dragEnter(DropTargetEvent event) { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "drag enter", event); + + // Make sure we don't have any residual data from an earlier operation. + clearDropInfo(); + mLeaveTargetNode = null; + mLeaveFeedback = null; + mLeaveView = null; + + // Get the dragged elements. + // + // The current transfered type can be extracted from the event. + // As described in dragOver(), this works basically works on Windows but + // not on Linux or Mac, in which case we can't get the type until we + // receive dropAccept/drop(). + // For consistency we try to use the GlobalCanvasDragInfo instance first, + // and if it fails we use the event transfer type as a backup (but as said + // before it will most likely work only on Windows.) + // In any case this can be null even for a valid transfer. + + mCurrentDragElements = mGlobalDragInfo.getCurrentElements(); + + if (mCurrentDragElements == null) { + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + if (sxt.isSupportedType(event.currentDataType)) { + mCurrentDragElements = (SimpleElement[]) sxt.nativeToJava(event.currentDataType); + } + } + + // if there is no data to transfer, invalidate the drag'n'drop. + // The assumption is that the transfer should have at least one element with a + // a non-null non-empty FQCN. Everything else is optional. + if (mCurrentDragElements == null || + mCurrentDragElements.length == 0 || + mCurrentDragElements[0] == null || + mCurrentDragElements[0].getFqcn() == null || + mCurrentDragElements[0].getFqcn().length() == 0) { + event.detail = DND.DROP_NONE; + } + + dragOperationChanged(event); + } + + /* + * The operation being performed has changed (e.g. modifier key). + * {@inheritDoc} + */ + @Override + public void dragOperationChanged(DropTargetEvent event) { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "drag changed", event); + + checkDataType(event); + recomputeDragType(event); + } + + private void recomputeDragType(DropTargetEvent event) { + if (event.detail == DND.DROP_DEFAULT) { + // Default means we can now choose the default operation, either copy or move. + // If the drag comes from the same canvas we default to move, otherwise we + // default to copy. + + if (mGlobalDragInfo.getSourceCanvas() == mCanvas && + (event.operations & DND.DROP_MOVE) != 0) { + event.detail = DND.DROP_MOVE; + } else if ((event.operations & DND.DROP_COPY) != 0) { + event.detail = DND.DROP_COPY; + } + } + + // We don't support other types than copy and move + if (event.detail != DND.DROP_COPY && event.detail != DND.DROP_MOVE) { + event.detail = DND.DROP_NONE; + } + } + + /* + * The cursor has left the drop target boundaries OR data is about to be dropped. + * {@inheritDoc} + */ + @Override + public void dragLeave(DropTargetEvent event) { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "drag leave"); + + // dragLeave is unfortunately called right before data is about to be dropped + // (between the last dropMove and the next dropAccept). That means we can't just + // trash the current DropFeedback from the current view rule, we need to preserve + // it in case a dropAccept happens next. + // See the corresponding kludge in dropAccept(). + mLeaveTargetNode = mTargetNode; + mLeaveFeedback = mFeedback; + mLeaveView = mCurrentView; + + clearDropInfo(); + } + + /* + * The cursor is moving over the drop target. + * {@inheritDoc} + */ + @Override + public void dragOver(DropTargetEvent event) { + processDropEvent(event); + } + + /* + * The drop is about to be performed. + * The drop target is given a last chance to change the nature of the drop. + * {@inheritDoc} + */ + @Override + public void dropAccept(DropTargetEvent event) { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "drop accept"); + + checkDataType(event); + + // If we have a valid target node and it matches the one we saved in + // dragLeave then we restore the DropFeedback that we saved in dragLeave. + if (mLeaveTargetNode != null) { + mTargetNode = mLeaveTargetNode; + mFeedback = mLeaveFeedback; + mCurrentView = mLeaveView; + } + + if (mFeedback != null && mFeedback.invalidTarget) { + // The script said we can't drop here. + event.detail = DND.DROP_NONE; + } + + if (mLeaveTargetNode == null || event.detail == DND.DROP_NONE) { + clearDropInfo(); + } + + mLeaveTargetNode = null; + mLeaveFeedback = null; + mLeaveView = null; + } + + /* + * The data is being dropped. + * {@inheritDoc} + */ + @Override + public void drop(final DropTargetEvent event) { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "dropped"); + + SimpleElement[] elements = null; + + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + + if (sxt.isSupportedType(event.currentDataType)) { + if (event.data instanceof SimpleElement[]) { + elements = (SimpleElement[]) event.data; + } + } + + if (elements == null || elements.length < 1) { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "drop missing drop data"); + return; + } + + if (mCurrentDragElements != null && Arrays.equals(elements, mCurrentDragElements)) { + elements = mCurrentDragElements; + } + + if (mTargetNode == null) { + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + if (viewHierarchy.isValid() && viewHierarchy.isEmpty()) { + // There is no target node because the drop happens on an empty document. + // Attempt to create a root node accordingly. + createDocumentRoot(elements); + } else { + if (DEBUG) AdtPlugin.printErrorToConsole("DEBUG", "dropped on null targetNode"); + } + return; + } + + updateDropFeedback(mFeedback, event); + + final SimpleElement[] elementsFinal = elements; + final LayoutPoint canvasPoint = getDropLocation(event).toLayout(); + String label = computeUndoLabel(mTargetNode, elements, event.detail); + + // Create node listener which (during the drop) listens for node additions + // and stores the list of added node such that they can be selected afterwards. + final List<UiElementNode> added = new ArrayList<UiElementNode>(); + // List of "index within parent" for each node + final List<Integer> indices = new ArrayList<Integer>(); + NodeCreationListener listener = new NodeCreationListener() { + @Override + public void nodeCreated(UiElementNode parent, UiElementNode child, int index) { + if (parent == mTargetNode.getNode()) { + added.add(child); + + // Adjust existing indices + for (int i = 0, n = indices.size(); i < n; i++) { + int idx = indices.get(i); + if (idx >= index) { + indices.set(i, idx + 1); + } + } + + indices.add(index); + } + } + + @Override + public void nodeDeleted(UiElementNode parent, UiElementNode child, int previousIndex) { + if (parent == mTargetNode.getNode()) { + // Adjust existing indices + for (int i = 0, n = indices.size(); i < n; i++) { + int idx = indices.get(i); + if (idx >= previousIndex) { + indices.set(i, idx - 1); + } + } + + // Make sure we aren't removing the same nodes that are being added + // No, that can happen when canceling out of a drop handler such as + // when dropping an included layout, then canceling out of the + // resource chooser. + //assert !added.contains(child); + } + } + }; + + try { + UiElementNode.addNodeCreationListener(listener); + mCanvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel(label, new Runnable() { + @Override + public void run() { + InsertType insertType = getInsertType(event, mTargetNode); + mCanvas.getRulesEngine().callOnDropped(mTargetNode, + elementsFinal, + mFeedback, + new Point(canvasPoint.x, canvasPoint.y), + insertType); + mTargetNode.applyPendingChanges(); + // Clean up drag if applicable + if (event.detail == DND.DROP_MOVE) { + GlobalCanvasDragInfo.getInstance().removeSource(); + } + mTargetNode.applyPendingChanges(); + } + }); + } finally { + UiElementNode.removeNodeCreationListener(listener); + } + + final List<INode> nodes = new ArrayList<INode>(); + NodeFactory nodeFactory = mCanvas.getNodeFactory(); + for (UiElementNode uiNode : added) { + if (uiNode instanceof UiViewElementNode) { + NodeProxy node = nodeFactory.create((UiViewElementNode) uiNode); + if (node != null) { + nodes.add(node); + } + } + } + + // Select the newly dropped nodes: + // Find out which nodes were added, and look up their corresponding + // CanvasViewInfos. + final SelectionManager selectionManager = mCanvas.getSelectionManager(); + // Don't use the indices to search for corresponding nodes yet, since a + // render may not have happened yet and we'd rather use an up to date + // view hierarchy than indices to look up the right view infos. + if (!selectionManager.selectDropped(nodes, null /* indices */)) { + // In some scenarios we can't find the actual view infos yet; this + // seems to happen when you drag from one canvas to another (see the + // related comment next to the setFocus() call below). In that case + // defer selection briefly until the view hierarchy etc is up to + // date. + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + selectionManager.selectDropped(nodes, indices); + } + }); + } + + clearDropInfo(); + mCanvas.redraw(); + // Request focus: This is *necessary* when you are dragging from one canvas editor + // to another, because without it, the redraw does not seem to be processed (the change + // is invisible until you click on the target canvas to give it focus). + mCanvas.setFocus(); + } + + /** + * Returns the right {@link InsertType} to use for the given drop target event and the + * given target node + * + * @param event the drop target event + * @param mTargetNode the node targeted by the drop + * @return the {link InsertType} to use for the drop + */ + public static InsertType getInsertType(DropTargetEvent event, NodeProxy mTargetNode) { + GlobalCanvasDragInfo dragInfo = GlobalCanvasDragInfo.getInstance(); + if (event.detail == DND.DROP_MOVE) { + SelectionItem[] selection = dragInfo.getCurrentSelection(); + if (selection != null) { + for (SelectionItem item : selection) { + if (item.getNode() != null + && item.getNode().getParent() == mTargetNode) { + return InsertType.MOVE_WITHIN; + } + } + } + + return InsertType.MOVE_INTO; + } else if (dragInfo.getSourceCanvas() != null) { + return InsertType.PASTE; + } else { + return InsertType.CREATE; + } + } + + /** + * Computes a suitable Undo label to use for a drop operation, such as + * "Drop Button in LinearLayout" and "Move Widgets in RelativeLayout". + * + * @param targetNode The target of the drop + * @param elements The dragged widgets + * @param detail The DnD mode, as used in {@link DropTargetEvent#detail}. + * @return A string suitable as an undo-label for the drop event + */ + public static String computeUndoLabel(NodeProxy targetNode, + SimpleElement[] elements, int detail) { + // Decide whether it's a move or a copy; we'll label moves specifically + // as a move and consider everything else a "Drop" + String verb = (detail == DND.DROP_MOVE) ? "Move" : "Drop"; + + // Get the type of widget being dropped/moved, IF there is only one. If + // there is more than one, just reference it as "Widgets". + String object; + if (elements != null && elements.length == 1) { + object = getSimpleName(elements[0].getFqcn()); + } else { + object = "Widgets"; + } + + String where = getSimpleName(targetNode.getFqcn()); + + // When we localize this: $1 is the verb (Move or Drop), $2 is the + // object (such as "Button"), and $3 is the place we are doing it (such + // as "LinearLayout"). + return String.format("%1$s %2$s in %3$s", verb, object, where); + } + + /** + * Returns simple name (basename, following last dot) of a fully qualified + * class name. + * + * @param fqcn The fqcn to reduce + * @return The base name of the fqcn + */ + public static String getSimpleName(String fqcn) { + // Note that the following works even when there is no dot, since + // lastIndexOf will return -1 so we get fcqn.substring(-1+1) = + // fcqn.substring(0) = fqcn + return fqcn.substring(fqcn.lastIndexOf('.') + 1); + } + + /** + * Updates the {@link DropFeedback#isCopy} and {@link DropFeedback#sameCanvas} fields + * of the given {@link DropFeedback}. This is generally called right before invoking + * one of the callOnXyz methods of GRE to refresh the fields. + * + * @param df The current {@link DropFeedback}. + * @param event An optional event to determine if the current operation is copy or move. + */ + private void updateDropFeedback(DropFeedback df, DropTargetEvent event) { + if (event != null) { + df.isCopy = event.detail == DND.DROP_COPY; + } + df.sameCanvas = mCanvas == mGlobalDragInfo.getSourceCanvas(); + df.invalidTarget = false; + df.dipScale = mCanvas.getEditorDelegate().getGraphicalEditor().getDipScale(); + df.modifierMask = mCanvas.getGestureManager().getRuleModifierMask(); + + // Set the drag bounds, after converting it from control coordinates to + // layout coordinates + GlobalCanvasDragInfo dragInfo = GlobalCanvasDragInfo.getInstance(); + Rect dragBounds = null; + Rect controlDragBounds = dragInfo.getDragBounds(); + if (controlDragBounds != null) { + CanvasTransform ht = mCanvas.getHorizontalTransform(); + CanvasTransform vt = mCanvas.getVerticalTransform(); + double horizScale = ht.getScale(); + double verticalScale = vt.getScale(); + int x = (int) (controlDragBounds.x / horizScale); + int y = (int) (controlDragBounds.y / verticalScale); + int w = (int) (controlDragBounds.w / horizScale); + int h = (int) (controlDragBounds.h / verticalScale); + dragBounds = new Rect(x, y, w, h); + } + int baseline = dragInfo.getDragBaseline(); + if (baseline != -1) { + df.dragBaseline = baseline; + } + df.dragBounds = dragBounds; + } + + /** + * Verifies that event.currentDataType is of type {@link SimpleXmlTransfer}. + * If not, try to find a valid data type. + * Otherwise set the drop to {@link DND#DROP_NONE} to cancel it. + * + * @return True if the data type is accepted. + */ + private static boolean checkDataType(DropTargetEvent event) { + + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + + TransferData current = event.currentDataType; + + if (sxt.isSupportedType(current)) { + return true; + } + + // We only support SimpleXmlTransfer and the current data type is not right. + // Let's see if we can find another one. + + for (TransferData td : event.dataTypes) { + if (td != current && sxt.isSupportedType(td)) { + // We like this type better. + event.currentDataType = td; + return true; + } + } + + // We failed to find any good transfer type. + event.detail = DND.DROP_NONE; + return false; + } + + /** + * Returns the mouse location of the drop target event. + * + * @param event the drop target event + * @return a {@link ControlPoint} location corresponding to the top left corner + */ + private ControlPoint getDropLocation(DropTargetEvent event) { + return ControlPoint.create(mCanvas, event); + } + + /** + * Called on both dragEnter and dragMove. + * Generates the onDropEnter/Move/Leave events depending on the currently + * selected target node. + */ + private void processDropEvent(DropTargetEvent event) { + if (!mCanvas.getViewHierarchy().isValid()) { + // We don't allow drop on an invalid layout, even if we have some obsolete + // layout info for it. + event.detail = DND.DROP_NONE; + clearDropInfo(); + return; + } + + LayoutPoint p = getDropLocation(event).toLayout(); + + // Is the mouse currently captured by a DropFeedback.captureArea? + boolean isCaptured = false; + if (mFeedback != null) { + Rect r = mFeedback.captureArea; + isCaptured = r != null && r.contains(p.x, p.y); + } + + // We can't switch views/nodes when the mouse is captured + CanvasViewInfo vi; + if (isCaptured) { + vi = mCurrentView; + } else { + vi = mCanvas.getViewHierarchy().findViewInfoAt(p); + + // When dragging into the canvas, if you are not over any other view, target + // the root element (since it may not "fill" the screen, e.g. if you have a linear + // layout but have layout_height wrap_content, then the layout will only extend + // to cover the children in the layout, not the whole visible screen area, which + // may be surprising + if (vi == null) { + vi = mCanvas.getViewHierarchy().getRoot(); + } + } + + boolean isMove = true; + boolean needRedraw = false; + + if (vi != mCurrentView) { + // Current view has changed. Does that also change the target node? + // Note that either mCurrentView or vi can be null. + + if (vi == null) { + // vi is null but mCurrentView is not, no view is a target anymore + // We don't need onDropMove in this case + isMove = false; + needRedraw = true; + event.detail = DND.DROP_NONE; + clearDropInfo(); // this will call callDropLeave. + + } else { + // vi is a new current view. + // Query GRE for onDropEnter on the ViewInfo hierarchy, starting from the child + // towards its parent, till we find one that returns a non-null drop feedback. + + DropFeedback df = null; + NodeProxy targetNode = null; + + for (CanvasViewInfo targetVi = vi; + targetVi != null && df == null; + targetVi = targetVi.getParent()) { + targetNode = mCanvas.getNodeFactory().create(targetVi); + df = mCanvas.getRulesEngine().callOnDropEnter(targetNode, + targetVi.getViewObject(), mCurrentDragElements); + + if (df != null) { + // We should also dispatch an onDropMove() call to the initial enter + // position, such that the view is notified of the position where + // we are within the node immediately (before we for example attempt + // to draw feedback). This is necessary since most views perform the + // guideline computations in onDropMove (since only onDropMove is handed + // the -position- of the mouse), and we want this computation to happen + // before we ask the view to draw its feedback. + updateDropFeedback(df, event); + df = mCanvas.getRulesEngine().callOnDropMove(targetNode, + mCurrentDragElements, df, new Point(p.x, p.y)); + } + + if (df != null && + event.detail == DND.DROP_MOVE && + mCanvas == mGlobalDragInfo.getSourceCanvas()) { + // You can't move an object into itself in the same canvas. + // E.g. case of moving a layout and the node under the mouse is the + // layout itself: a copy would be ok but not a move operation of the + // layout into himself. + + SelectionItem[] selection = mGlobalDragInfo.getCurrentSelection(); + if (selection != null) { + for (SelectionItem cs : selection) { + if (cs.getViewInfo() == targetVi) { + // The node that responded is one of the selection roots. + // Simply invalidate the drop feedback and move on the + // parent in the ViewInfo chain. + + updateDropFeedback(df, event); + mCanvas.getRulesEngine().callOnDropLeave( + targetNode, mCurrentDragElements, df); + df = null; + targetNode = null; + } + } + } + } + } + + if (df == null) { + // Provide visual feedback that we are refusing the drop + event.detail = DND.DROP_NONE; + clearDropInfo(); + + } else if (targetNode != mTargetNode) { + // We found a new target node for the drag'n'drop. + // Release the previous one, if any. + callDropLeave(); + + // And assign the new one + mTargetNode = targetNode; + mFeedback = df; + + // We don't need onDropMove in this case + isMove = false; + } + } + + mCurrentView = vi; + } + + if (isMove && mTargetNode != null && mFeedback != null) { + // this is a move inside the same view + com.android.ide.common.api.Point p2 = + new com.android.ide.common.api.Point(p.x, p.y); + updateDropFeedback(mFeedback, event); + DropFeedback df = mCanvas.getRulesEngine().callOnDropMove( + mTargetNode, mCurrentDragElements, mFeedback, p2); + mCanvas.getGestureManager().updateMessage(mFeedback); + + if (df == null) { + // The target is no longer interested in the drop move. + event.detail = DND.DROP_NONE; + callDropLeave(); + + } else if (df != mFeedback) { + mFeedback = df; + } + } + + if (mFeedback != null) { + if (event.detail == DND.DROP_NONE && !mFeedback.invalidTarget) { + // If we previously provided visual feedback that we were refusing + // the drop, we now need to change it to mean we're accepting it. + event.detail = DND.DROP_DEFAULT; + recomputeDragType(event); + + } else if (mFeedback.invalidTarget) { + // Provide visual feedback that we are refusing the drop + event.detail = DND.DROP_NONE; + } + } + + if (needRedraw || (mFeedback != null && mFeedback.requestPaint)) { + mCanvas.redraw(); + } + + // Update outline to show the target node there + OutlinePage outline = mCanvas.getOutlinePage(); + TreeSelection newSelection = TreeSelection.EMPTY; + if (mCurrentView != null && mTargetNode != null) { + // Find the view corresponding to the target node. The current view can be a leaf + // view whereas the target node is always a parent layout. + if (mCurrentView.getUiViewNode() != mTargetNode.getNode()) { + mCurrentView = mCurrentView.getParent(); + } + if (mCurrentView != null && mCurrentView.getUiViewNode() == mTargetNode.getNode()) { + TreePath treePath = SelectionManager.getTreePath(mCurrentView); + newSelection = new TreeSelection(treePath); + } + } + + ISelection currentSelection = outline.getSelection(); + if (currentSelection == null || !currentSelection.equals(newSelection)) { + outline.setSelection(newSelection); + } + } + + /** + * Calls onDropLeave on mTargetNode with the current mFeedback. <br/> + * Then clears mTargetNode and mFeedback. + */ + private void callDropLeave() { + if (mTargetNode != null && mFeedback != null) { + updateDropFeedback(mFeedback, null); + mCanvas.getRulesEngine().callOnDropLeave(mTargetNode, mCurrentDragElements, mFeedback); + } + + mTargetNode = null; + mFeedback = null; + } + + private void clearDropInfo() { + callDropLeave(); + mCurrentView = null; + mCanvas.redraw(); + } + + /** + * Creates a root element in an empty document. + * Only the first element's FQCN of the dragged elements is used. + * <p/> + * Actual XML handling is done by {@link LayoutCanvas#createDocumentRoot(String)}. + */ + private void createDocumentRoot(SimpleElement[] elements) { + if (elements == null || elements.length < 1 || elements[0] == null) { + return; + } + + mCanvas.createDocumentRoot(elements[0]); + } + + /** + * An {@link Overlay} to paint the move feedback. This just delegates to the + * layout rules. + */ + private class MoveOverlay extends Overlay { + @Override + public void paint(GC gc) { + if (mTargetNode != null && mFeedback != null) { + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + rulesEngine.callDropFeedbackPaint(mCanvas.getGcWrapper(), mTargetNode, mFeedback); + mFeedback.requestPaint = false; + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineDragListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineDragListener.java new file mode 100644 index 000000000..1af3053e3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineDragListener.java @@ -0,0 +1,129 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.DragSourceEvent; +import org.eclipse.swt.dnd.DragSourceListener; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.swt.widgets.TreeItem; + +import java.util.ArrayList; + +/** Drag listener for the outline page */ +/* package */ class OutlineDragListener implements DragSourceListener { + private TreeViewer mTreeViewer; + private OutlinePage mOutlinePage; + private final ArrayList<SelectionItem> mDragSelection = new ArrayList<SelectionItem>(); + private SimpleElement[] mDragElements; + + public OutlineDragListener(OutlinePage outlinePage, TreeViewer treeViewer) { + super(); + mOutlinePage = outlinePage; + mTreeViewer = treeViewer; + } + + @Override + public void dragStart(DragSourceEvent e) { + Tree tree = mTreeViewer.getTree(); + + TreeItem overTreeItem = tree.getItem(new Point(e.x, e.y)); + if (overTreeItem == null) { + // Not dragging over a tree item + e.doit = false; + return; + } + CanvasViewInfo over = getViewInfo(overTreeItem); + if (over == null) { + e.doit = false; + return; + } + + // The selection logic for the outline is much simpler than in the canvas, + // because for one thing, the tree selection is updated synchronously on mouse + // down, so it's not possible to start dragging a non-selected item. + // We also don't deliberately disallow root-element dragging since you can + // drag it into another form. + final LayoutCanvas canvas = mOutlinePage.getEditor().getCanvasControl(); + SelectionManager selectionManager = canvas.getSelectionManager(); + TreeItem[] treeSelection = tree.getSelection(); + mDragSelection.clear(); + for (TreeItem item : treeSelection) { + CanvasViewInfo viewInfo = getViewInfo(item); + if (viewInfo != null) { + mDragSelection.add(selectionManager.createSelection(viewInfo)); + } + } + SelectionManager.sanitize(mDragSelection); + + e.doit = !mDragSelection.isEmpty(); + int imageCount = mDragSelection.size(); + if (e.doit) { + mDragElements = SelectionItem.getAsElements(mDragSelection); + GlobalCanvasDragInfo.getInstance().startDrag(mDragElements, + mDragSelection.toArray(new SelectionItem[imageCount]), + canvas, new Runnable() { + @Override + public void run() { + canvas.getClipboardSupport().deleteSelection("Remove", + mDragSelection); + } + }); + return; + } + + e.detail = DND.DROP_NONE; + } + + @Override + public void dragSetData(DragSourceEvent e) { + if (TextTransfer.getInstance().isSupportedType(e.dataType)) { + LayoutCanvas canvas = mOutlinePage.getEditor().getCanvasControl(); + e.data = SelectionItem.getAsText(canvas, mDragSelection); + return; + } + + if (SimpleXmlTransfer.getInstance().isSupportedType(e.dataType)) { + e.data = mDragElements; + return; + } + + // otherwise we failed + e.detail = DND.DROP_NONE; + e.doit = false; + } + + @Override + public void dragFinished(DragSourceEvent e) { + // Unregister the dragged data. + // Clear the selection + mDragSelection.clear(); + mDragElements = null; + GlobalCanvasDragInfo.getInstance().stopDrag(); + } + + private CanvasViewInfo getViewInfo(TreeItem item) { + Object data = item.getData(); + if (data != null) { + return OutlinePage.getViewInfo(data); + } + + return null; + } +}
\ No newline at end of file diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineDropListener.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineDropListener.java new file mode 100644 index 000000000..f4a826fa2 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineDropListener.java @@ -0,0 +1,217 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.api.INode; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; + +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.ViewerDropAdapter; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.DropTargetEvent; +import org.eclipse.swt.dnd.TransferData; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Drop listener for the outline page */ +/*package*/ class OutlineDropListener extends ViewerDropAdapter { + private final OutlinePage mOutlinePage; + + public OutlineDropListener(OutlinePage outlinePage, TreeViewer treeViewer) { + super(treeViewer); + mOutlinePage = outlinePage; + } + + @Override + public void dragEnter(DropTargetEvent event) { + if (event.detail == DND.DROP_NONE && GlobalCanvasDragInfo.getInstance().isDragging()) { + // For some inexplicable reason, we get DND.DROP_NONE from the palette + // even though in its drag start we set DND.DROP_COPY, so correct that here... + int operation = DND.DROP_COPY; + event.detail = operation; + } + super.dragEnter(event); + } + + @Override + public boolean performDrop(Object data) { + final DropTargetEvent event = getCurrentEvent(); + if (event == null) { + return false; + } + int location = determineLocation(event); + if (location == LOCATION_NONE) { + return false; + } + + final SimpleElement[] elements; + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + if (sxt.isSupportedType(event.currentDataType)) { + if (data instanceof SimpleElement[]) { + elements = (SimpleElement[]) data; + } else { + return false; + } + } else { + return false; + } + if (elements.length == 0) { + return false; + } + + // Determine target: + CanvasViewInfo parent = OutlinePage.getViewInfo(event.item.getData()); + if (parent == null) { + return false; + } + + int index = -1; + UiViewElementNode parentNode = parent.getUiViewNode(); + if (location == LOCATION_BEFORE || location == LOCATION_AFTER) { + UiViewElementNode node = parentNode; + parent = parent.getParent(); + if (parent == null) { + return false; + } + parentNode = parent.getUiViewNode(); + + // Determine index + index = 0; + for (UiElementNode child : parentNode.getUiChildren()) { + if (child == node) { + break; + } + index++; + } + if (location == LOCATION_AFTER) { + index++; + } + } + + // Copy into new position. + final LayoutCanvas canvas = mOutlinePage.getEditor().getCanvasControl(); + final NodeProxy targetNode = canvas.getNodeFactory().create(parentNode); + + // Record children of the target right before the drop (such that we can + // find out after the drop which exact children were inserted) + Set<INode> children = new HashSet<INode>(); + for (INode node : targetNode.getChildren()) { + children.add(node); + } + + String label = MoveGesture.computeUndoLabel(targetNode, elements, event.detail); + final int indexFinal = index; + canvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel(label, new Runnable() { + @Override + public void run() { + InsertType insertType = MoveGesture.getInsertType(event, targetNode); + canvas.getRulesEngine().setInsertType(insertType); + + Object sourceCanvas = GlobalCanvasDragInfo.getInstance().getSourceCanvas(); + boolean createNew = event.detail == DND.DROP_COPY || sourceCanvas != canvas; + BaseLayoutRule.insertAt(targetNode, elements, createNew, indexFinal); + targetNode.applyPendingChanges(); + + // Clean up drag if applicable + if (event.detail == DND.DROP_MOVE) { + GlobalCanvasDragInfo.getInstance().removeSource(); + } + } + }); + + // Now find out which nodes were added, and look up their corresponding + // CanvasViewInfos + final List<INode> added = new ArrayList<INode>(); + for (INode node : targetNode.getChildren()) { + if (!children.contains(node)) { + added.add(node); + } + } + // Select the newly dropped nodes + final SelectionManager selectionManager = canvas.getSelectionManager(); + selectionManager.setOutlineSelection(added); + + canvas.redraw(); + + return true; + } + + @Override + public boolean validateDrop(Object target, int operation, + TransferData transferType) { + DropTargetEvent event = getCurrentEvent(); + if (event == null) { + return false; + } + int location = determineLocation(event); + if (location == LOCATION_NONE) { + return false; + } + + SimpleXmlTransfer sxt = SimpleXmlTransfer.getInstance(); + if (!sxt.isSupportedType(transferType)) { + return false; + } + + CanvasViewInfo parent = OutlinePage.getViewInfo(event.item.getData()); + if (parent == null) { + return false; + } + + UiViewElementNode parentNode = parent.getUiViewNode(); + + if (location == LOCATION_ON) { + // Targeting the middle of an item means to add it as a new child + // of the given element. This is only allowed on some types of nodes. + if (!DescriptorsUtils.canInsertChildren(parentNode.getDescriptor(), + parent.getViewObject())) { + return false; + } + } + + // Check that the drop target position is not a child or identical to + // one of the dragged items + SelectionItem[] sel = GlobalCanvasDragInfo.getInstance().getCurrentSelection(); + if (sel != null) { + for (SelectionItem item : sel) { + if (isAncestor(item.getViewInfo().getUiViewNode(), parentNode)) { + return false; + } + } + } + + return true; + } + + /** Returns true if the given parent node is an ancestor of the given child node */ + private boolean isAncestor(UiElementNode parent, UiElementNode child) { + while (child != null) { + if (child == parent) { + return true; + } + child = child.getUiParent(); + } + return false; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineOverlay.java new file mode 100644 index 000000000..e63fff7ab --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlineOverlay.java @@ -0,0 +1,107 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Rectangle; + +/** + * The {@link OutlineOverlay} paints an optional outline on top of the layout, + * showing the structure of the individual Android View elements. + */ +public class OutlineOverlay extends Overlay { + /** The {@link ViewHierarchy} this outline visualizes */ + private final ViewHierarchy mViewHierarchy; + + /** Outline color. Must be disposed, it's NOT a system color. */ + private Color mOutlineColor; + + /** Vertical scaling & scrollbar information. */ + private CanvasTransform mVScale; + + /** Horizontal scaling & scrollbar information. */ + private CanvasTransform mHScale; + + /** + * Constructs a new {@link OutlineOverlay} linked to the given view + * hierarchy. + * + * @param viewHierarchy The {@link ViewHierarchy} to render + * @param hScale The {@link CanvasTransform} to use to transfer horizontal layout + * coordinates to screen coordinates + * @param vScale The {@link CanvasTransform} to use to transfer vertical layout + * coordinates to screen coordinates + */ + public OutlineOverlay( + ViewHierarchy viewHierarchy, + CanvasTransform hScale, + CanvasTransform vScale) { + super(); + mViewHierarchy = viewHierarchy; + mHScale = hScale; + mVScale = vScale; + } + + @Override + public void create(Device device) { + mOutlineColor = new Color(device, SwtDrawingStyle.OUTLINE.getStrokeColor()); + } + + @Override + public void dispose() { + if (mOutlineColor != null) { + mOutlineColor.dispose(); + mOutlineColor = null; + } + } + + @Override + public void paint(GC gc) { + CanvasViewInfo lastRoot = mViewHierarchy.getRoot(); + if (lastRoot != null) { + gc.setForeground(mOutlineColor); + gc.setLineStyle(SwtDrawingStyle.OUTLINE.getLineStyle()); + int oldAlpha = gc.getAlpha(); + gc.setAlpha(SwtDrawingStyle.OUTLINE.getStrokeAlpha()); + drawOutline(gc, lastRoot); + gc.setAlpha(oldAlpha); + } + } + + private void drawOutline(GC gc, CanvasViewInfo info) { + Rectangle r = info.getAbsRect(); + + int x = mHScale.translate(r.x); + int y = mVScale.translate(r.y); + int w = mHScale.scale(r.width); + int h = mVScale.scale(r.height); + + // Add +1 to the width and +1 to the height such that when you have a + // series of boxes (in say a LinearLayout), instead of the bottom of one + // box and the top of the next box being -adjacent-, they -overlap-. + // This makes the outline nicer visually since you don't get + // "double thickness" lines for all adjacent boxes. + gc.drawRectangle(x, y, w + 1, h + 1); + + for (CanvasViewInfo vi : info.getChildren()) { + drawOutline(gc, vi); + } + } + +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlinePage.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlinePage.java new file mode 100644 index 000000000..8178c6871 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/OutlinePage.java @@ -0,0 +1,1439 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_URI; +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.SdkConstants.ATTR_ROW_COUNT; +import static com.android.SdkConstants.ATTR_SRC; +import static com.android.SdkConstants.ATTR_TEXT; +import static com.android.SdkConstants.AUTO_URI; +import static com.android.SdkConstants.DRAWABLE_PREFIX; +import static com.android.SdkConstants.GRID_LAYOUT; +import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.URI_PREFIX; +import static org.eclipse.jface.viewers.StyledString.COUNTER_STYLER; +import static org.eclipse.jface.viewers.StyledString.QUALIFIER_STYLER; + +import com.android.SdkConstants; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.InsertType; +import com.android.ide.common.layout.BaseLayoutRule; +import com.android.ide.common.layout.GridLayoutRule; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.properties.PropertySheetPage; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.sdk.ProjectState; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IContributionItem; +import org.eclipse.jface.action.IMenuListener; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.IToolBarManager; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.preference.JFacePreferences; +import org.eclipse.jface.viewers.DoubleClickEvent; +import org.eclipse.jface.viewers.IDoubleClickListener; +import org.eclipse.jface.viewers.IElementComparer; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.StyledCellLabelProvider; +import org.eclipse.jface.viewers.StyledString; +import org.eclipse.jface.viewers.StyledString.Styler; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.jface.viewers.TreeSelection; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.jface.viewers.ViewerCell; +import org.eclipse.swt.SWT; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.DisposeEvent; +import org.eclipse.swt.events.DisposeListener; +import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.events.KeyListener; +import org.eclipse.swt.events.MenuDetectEvent; +import org.eclipse.swt.events.MenuDetectListener; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.swt.widgets.TreeItem; +import org.eclipse.ui.IActionBars; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.INullSelectionListener; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.views.contentoutline.ContentOutlinePage; +import org.eclipse.wb.core.controls.SelfOrientingSashForm; +import org.eclipse.wb.internal.core.editor.structure.IPage; +import org.eclipse.wb.internal.core.editor.structure.PageSiteComposite; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * An outline page for the layout canvas view. + * <p/> + * The page is created by {@link LayoutEditorDelegate#delegateGetAdapter(Class)}. This means + * we have *one* instance of the outline page per open canvas editor. + * <p/> + * It sets itself as a listener on the site's selection service in order to be + * notified of the canvas' selection changes. + * The underlying page is also a selection provider (via IContentOutlinePage) + * and as such it will broadcast selection changes to the site's selection service + * (on which both the layout editor part and the property sheet page listen.) + */ +public class OutlinePage extends ContentOutlinePage + implements INullSelectionListener, IPage { + + /** Label which separates outline text from additional attributes like text prefix or url */ + private static final String LABEL_SEPARATOR = " - "; + + /** Max character count in labels, used for truncation */ + private static final int LABEL_MAX_WIDTH = 50; + + /** + * The graphical editor that created this outline. + */ + private final GraphicalEditorPart mGraphicalEditorPart; + + /** + * RootWrapper is a workaround: we can't set the input of the TreeView to its root + * element, so we introduce a fake parent. + */ + private final RootWrapper mRootWrapper = new RootWrapper(); + + /** + * Menu manager for the context menu actions. + * The actions delegate to the current GraphicalEditorPart. + */ + private MenuManager mMenuManager; + + private Composite mControl; + private PropertySheetPage mPropertySheet; + private PageSiteComposite mPropertySheetComposite; + private boolean mShowPropertySheet; + private boolean mShowHeader; + private boolean mIgnoreSelection; + private boolean mActive = true; + + /** Action to Select All in the tree */ + private final Action mTreeSelectAllAction = new Action() { + @Override + public void run() { + getTreeViewer().getTree().selectAll(); + OutlinePage.this.fireSelectionChanged(getSelection()); + } + + @Override + public String getId() { + return ActionFactory.SELECT_ALL.getId(); + } + }; + + /** Action for moving items up in the tree */ + private Action mMoveUpAction = new Action("Move Up\t-", + IconFactory.getInstance().getImageDescriptor("up")) { //$NON-NLS-1$ + + @Override + public String getId() { + return "adt.outline.moveup"; //$NON-NLS-1$ + } + + @Override + public boolean isEnabled() { + return canMove(false); + } + + @Override + public void run() { + move(false); + } + }; + + /** Action for moving items down in the tree */ + private Action mMoveDownAction = new Action("Move Down\t+", + IconFactory.getInstance().getImageDescriptor("down")) { //$NON-NLS-1$ + + @Override + public String getId() { + return "adt.outline.movedown"; //$NON-NLS-1$ + } + + @Override + public boolean isEnabled() { + return canMove(true); + } + + @Override + public void run() { + move(true); + } + }; + + /** + * Creates a new {@link OutlinePage} associated with the given editor + * + * @param graphicalEditorPart the editor associated with this outline + */ + public OutlinePage(GraphicalEditorPart graphicalEditorPart) { + super(); + mGraphicalEditorPart = graphicalEditorPart; + } + + @Override + public Control getControl() { + // We've injected some controls between the root of the outline page + // and the tree control, so return the actual root (a sash form) rather + // than the superclass' implementation which returns the tree. If we don't + // do this, various checks in the outline page which checks that getControl().getParent() + // is the outline window itself will ignore this page. + return mControl; + } + + void setActive(boolean active) { + if (active != mActive) { + mActive = active; + + // Outlines are by default active when they are created; this is intended + // for deactivating a hidden outline and later reactivating it + assert mControl != null; + if (active) { + getSite().getPage().addSelectionListener(this); + setModel(mGraphicalEditorPart.getCanvasControl().getViewHierarchy().getRoot()); + } else { + getSite().getPage().removeSelectionListener(this); + mRootWrapper.setRoot(null); + if (mPropertySheet != null) { + mPropertySheet.selectionChanged(null, TreeSelection.EMPTY); + } + } + } + } + + /** Refresh all the icon state */ + public void refreshIcons() { + TreeViewer treeViewer = getTreeViewer(); + if (treeViewer != null) { + Tree tree = treeViewer.getTree(); + if (tree != null && !tree.isDisposed()) { + treeViewer.refresh(); + } + } + } + + /** + * Set whether the outline should be shown in the header + * + * @param show whether a header should be shown + */ + public void setShowHeader(boolean show) { + mShowHeader = show; + } + + /** + * Set whether the property sheet should be shown within this outline + * + * @param show whether the property sheet should show + */ + public void setShowPropertySheet(boolean show) { + if (show != mShowPropertySheet) { + mShowPropertySheet = show; + if (mControl == null) { + return; + } + + if (show && mPropertySheet == null) { + createPropertySheet(); + } else if (!show) { + mPropertySheetComposite.dispose(); + mPropertySheetComposite = null; + mPropertySheet.dispose(); + mPropertySheet = null; + } + + mControl.layout(); + } + } + + @Override + public void createControl(Composite parent) { + mControl = new SelfOrientingSashForm(parent, SWT.VERTICAL); + + if (mShowHeader) { + PageSiteComposite mOutlineComposite = new PageSiteComposite(mControl, SWT.BORDER); + mOutlineComposite.setTitleText("Outline"); + mOutlineComposite.setTitleImage(IconFactory.getInstance().getIcon("components_view")); + mOutlineComposite.setPage(new IPage() { + @Override + public void createControl(Composite outlineParent) { + createOutline(outlineParent); + } + + @Override + public void dispose() { + } + + @Override + public Control getControl() { + return getTreeViewer().getTree(); + } + + @Override + public void setToolBar(IToolBarManager toolBarManager) { + makeContributions(null, toolBarManager, null); + toolBarManager.update(false); + } + + @Override + public void setFocus() { + getControl().setFocus(); + } + }); + } else { + createOutline(mControl); + } + + if (mShowPropertySheet) { + createPropertySheet(); + } + } + + private void createOutline(Composite parent) { + if (AdtUtils.isEclipse4()) { + // This is a workaround for the focus behavior in Eclipse 4 where + // the framework ends up calling setFocus() on the first widget in the outline + // AFTER a mouse click has been received. Specifically, if the user clicks in + // the embedded property sheet to for example give a Text property editor focus, + // then after the mouse click, the Outline window activation event is processed, + // and this event causes setFocus() to be called first on the PageBookView (which + // ends up calling setFocus on the first control, normally the TreeViewer), and + // then on the Page itself. We're dealing with the page setFocus() in the override + // of that method in the class, such that it does nothing. + // However, we have to also disable the setFocus on the first control in the + // outline page. To deal with that, we create our *own* first control in the + // outline, and make its setFocus() a no-op. We also make it invisible, since we + // don't actually want anything but the tree viewer showing in the outline. + Text text = new Text(parent, SWT.NONE) { + @Override + public boolean setFocus() { + // Focus no-op + return true; + } + + @Override + protected void checkSubclass() { + // Disable the check that prevents subclassing of SWT components + } + }; + text.setVisible(false); + } + + super.createControl(parent); + + TreeViewer tv = getTreeViewer(); + tv.setAutoExpandLevel(2); + tv.setContentProvider(new ContentProvider()); + tv.setLabelProvider(new LabelProvider()); + tv.setInput(mRootWrapper); + tv.expandToLevel(mRootWrapper.getRoot(), 2); + + int supportedOperations = DND.DROP_COPY | DND.DROP_MOVE; + Transfer[] transfers = new Transfer[] { + SimpleXmlTransfer.getInstance() + }; + + tv.addDropSupport(supportedOperations, transfers, new OutlineDropListener(this, tv)); + tv.addDragSupport(supportedOperations, transfers, new OutlineDragListener(this, tv)); + + // The tree viewer will hold CanvasViewInfo instances, however these + // change each time the canvas is reloaded. OTOH layoutlib gives us + // constant UiView keys which we can use to perform tree item comparisons. + tv.setComparer(new IElementComparer() { + @Override + public int hashCode(Object element) { + if (element instanceof CanvasViewInfo) { + UiViewElementNode key = ((CanvasViewInfo) element).getUiViewNode(); + if (key != null) { + return key.hashCode(); + } + } + if (element != null) { + return element.hashCode(); + } + return 0; + } + + @Override + public boolean equals(Object a, Object b) { + if (a instanceof CanvasViewInfo && b instanceof CanvasViewInfo) { + UiViewElementNode keyA = ((CanvasViewInfo) a).getUiViewNode(); + UiViewElementNode keyB = ((CanvasViewInfo) b).getUiViewNode(); + if (keyA != null) { + return keyA.equals(keyB); + } + } + if (a != null) { + return a.equals(b); + } + return false; + } + }); + tv.addDoubleClickListener(new IDoubleClickListener() { + @Override + public void doubleClick(DoubleClickEvent event) { + // This used to open the property view, but now that properties are docked + // let's use it for something else -- such as showing the editor source + /* + // Front properties panel; its selection is already linked + IWorkbenchPage page = getSite().getPage(); + try { + page.showView(IPageLayout.ID_PROP_SHEET, null, IWorkbenchPage.VIEW_ACTIVATE); + } catch (PartInitException e) { + AdtPlugin.log(e, "Could not activate property sheet"); + } + */ + + TreeItem[] selection = getTreeViewer().getTree().getSelection(); + if (selection.length > 0) { + CanvasViewInfo vi = getViewInfo(selection[0].getData()); + if (vi != null) { + LayoutCanvas canvas = mGraphicalEditorPart.getCanvasControl(); + canvas.show(vi); + } + } + } + }); + + setupContextMenu(); + + // Listen to selection changes from the layout editor + getSite().getPage().addSelectionListener(this); + getControl().addDisposeListener(new DisposeListener() { + + @Override + public void widgetDisposed(DisposeEvent e) { + dispose(); + } + }); + + Tree tree = tv.getTree(); + tree.addKeyListener(new KeyListener() { + + @Override + public void keyPressed(KeyEvent e) { + if (e.character == '-') { + if (mMoveUpAction.isEnabled()) { + mMoveUpAction.run(); + } + } else if (e.character == '+') { + if (mMoveDownAction.isEnabled()) { + mMoveDownAction.run(); + } + } + } + + @Override + public void keyReleased(KeyEvent e) { + } + }); + + setupTooltip(); + } + + /** + * This flag is true when the mouse button is being pressed somewhere inside + * the property sheet + */ + private boolean mPressInPropSheet; + + private void createPropertySheet() { + mPropertySheetComposite = new PageSiteComposite(mControl, SWT.BORDER); + mPropertySheetComposite.setTitleText("Properties"); + mPropertySheetComposite.setTitleImage(IconFactory.getInstance().getIcon("properties_view")); + mPropertySheet = new PropertySheetPage(mGraphicalEditorPart); + mPropertySheetComposite.setPage(mPropertySheet); + if (AdtUtils.isEclipse4()) { + mPropertySheet.getControl().addMouseListener(new MouseListener() { + @Override + public void mouseDown(MouseEvent e) { + mPressInPropSheet = true; + } + + @Override + public void mouseUp(MouseEvent e) { + mPressInPropSheet = false; + } + + @Override + public void mouseDoubleClick(MouseEvent e) { + } + }); + } + } + + @Override + public void setFocus() { + // Only call setFocus on the tree viewer if the mouse click isn't in the property + // sheet area + if (!mPressInPropSheet) { + super.setFocus(); + } + } + + @Override + public void dispose() { + mRootWrapper.setRoot(null); + + getSite().getPage().removeSelectionListener(this); + super.dispose(); + if (mPropertySheet != null) { + mPropertySheet.dispose(); + mPropertySheet = null; + } + } + + /** + * Invoked by {@link LayoutCanvas} to set the model (a.k.a. the root view info). + * + * @param rootViewInfo The root of the view info hierarchy. Can be null. + */ + public void setModel(CanvasViewInfo rootViewInfo) { + if (!mActive) { + return; + } + + mRootWrapper.setRoot(rootViewInfo); + + TreeViewer tv = getTreeViewer(); + if (tv != null && !tv.getTree().isDisposed()) { + Object[] expanded = tv.getExpandedElements(); + tv.refresh(); + tv.setExpandedElements(expanded); + // Ensure that the root is expanded + tv.expandToLevel(rootViewInfo, 2); + } + } + + /** + * Returns the current tree viewer selection. Shouldn't be null, + * although it can be {@link TreeSelection#EMPTY}. + */ + @Override + public ISelection getSelection() { + return super.getSelection(); + } + + /** + * Sets the outline selection. + * + * @param selection Only {@link ITreeSelection} will be used, otherwise the + * selection will be cleared (including a null selection). + */ + @Override + public void setSelection(ISelection selection) { + // TreeViewer should be able to deal with a null selection, but let's make it safe + if (selection == null) { + selection = TreeSelection.EMPTY; + } + if (selection.equals(TreeSelection.EMPTY)) { + return; + } + + super.setSelection(selection); + + TreeViewer tv = getTreeViewer(); + if (tv == null || !(selection instanceof ITreeSelection) || selection.isEmpty()) { + return; + } + + // auto-reveal the selection + ITreeSelection treeSel = (ITreeSelection) selection; + for (TreePath p : treeSel.getPaths()) { + tv.expandToLevel(p, 1); + } + } + + @Override + protected void fireSelectionChanged(ISelection selection) { + super.fireSelectionChanged(selection); + if (mPropertySheet != null && !mIgnoreSelection) { + mPropertySheet.selectionChanged(null, selection); + } + } + + /** + * Listens to a workbench selection. + * Only listen on selection coming from {@link LayoutEditorDelegate}, which avoid + * picking up our own selections. + */ + @Override + public void selectionChanged(IWorkbenchPart part, ISelection selection) { + if (mIgnoreSelection) { + return; + } + + if (part instanceof IEditorPart) { + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor((IEditorPart) part); + if (delegate != null) { + try { + mIgnoreSelection = true; + setSelection(selection); + + if (mPropertySheet != null) { + mPropertySheet.selectionChanged(part, selection); + } + } finally { + mIgnoreSelection = false; + } + } + } + } + + @Override + public void selectionChanged(SelectionChangedEvent event) { + if (!mIgnoreSelection) { + super.selectionChanged(event); + } + } + + // ---- + + /** + * In theory, the root of the model should be the input of the {@link TreeViewer}, + * which would be the root {@link CanvasViewInfo}. + * That means in theory {@link ContentProvider#getElements(Object)} should return + * its own input as the single root node. + * <p/> + * However as described in JFace Bug 9262, this case is not properly handled by + * a {@link TreeViewer} and leads to an infinite recursion in the tree viewer. + * See https://bugs.eclipse.org/bugs/show_bug.cgi?id=9262 + * <p/> + * The solution is to wrap the tree viewer input in a dummy root node that acts + * as a parent. This class does just that. + */ + private static class RootWrapper { + private CanvasViewInfo mRoot; + + public void setRoot(CanvasViewInfo root) { + mRoot = root; + } + + public CanvasViewInfo getRoot() { + return mRoot; + } + } + + /** Return the {@link CanvasViewInfo} associated with the given TreeItem's data field */ + /* package */ static CanvasViewInfo getViewInfo(Object viewData) { + if (viewData instanceof RootWrapper) { + return ((RootWrapper) viewData).getRoot(); + } + if (viewData instanceof CanvasViewInfo) { + return (CanvasViewInfo) viewData; + } + return null; + } + + // --- Content and Label Providers --- + + /** + * Content provider for the Outline model. + * Objects are going to be {@link CanvasViewInfo}. + */ + private static class ContentProvider implements ITreeContentProvider { + + @Override + public Object[] getChildren(Object element) { + if (element instanceof RootWrapper) { + CanvasViewInfo root = ((RootWrapper)element).getRoot(); + if (root != null) { + return new Object[] { root }; + } + } + if (element instanceof CanvasViewInfo) { + List<CanvasViewInfo> children = ((CanvasViewInfo) element).getUniqueChildren(); + if (children != null) { + return children.toArray(); + } + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + if (element instanceof CanvasViewInfo) { + return ((CanvasViewInfo) element).getParent(); + } + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof CanvasViewInfo) { + List<CanvasViewInfo> children = ((CanvasViewInfo) element).getChildren(); + if (children != null) { + return children.size() > 0; + } + } + return false; + } + + /** + * Returns the root element. + * Semantically, the root element is the single top-level XML element of the XML layout. + */ + @Override + public Object[] getElements(Object inputElement) { + return getChildren(inputElement); + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } + } + + /** + * Label provider for the Outline model. + * Objects are going to be {@link CanvasViewInfo}. + */ + private class LabelProvider extends StyledCellLabelProvider { + /** + * Returns the element's logo with a fallback on the android logo. + * + * @param element the tree element + * @return the image to be used as a logo + */ + public Image getImage(Object element) { + if (element instanceof CanvasViewInfo) { + element = ((CanvasViewInfo) element).getUiViewNode(); + } + + if (element instanceof UiViewElementNode) { + UiViewElementNode v = (UiViewElementNode) element; + return v.getIcon(); + } + + return AdtPlugin.getAndroidLogo(); + } + + /** + * Uses {@link UiElementNode#getStyledDescription} for the label for this tree item. + */ + @Override + public void update(ViewerCell cell) { + Object element = cell.getElement(); + StyledString styledString = null; + + CanvasViewInfo vi = null; + if (element instanceof CanvasViewInfo) { + vi = (CanvasViewInfo) element; + element = vi.getUiViewNode(); + } + + Image image = getImage(element); + + if (element instanceof UiElementNode) { + UiElementNode node = (UiElementNode) element; + styledString = node.getStyledDescription(); + Node xmlNode = node.getXmlNode(); + if (xmlNode instanceof Element) { + Element e = (Element) xmlNode; + + // Temporary diagnostics code when developing GridLayout + if (GridLayoutRule.sDebugGridLayout) { + + String namespace; + if (e.getNodeName().equals(GRID_LAYOUT) || + e.getParentNode() != null + && e.getParentNode().getNodeName().equals(GRID_LAYOUT)) { + namespace = ANDROID_URI; + } else { + // Else: probably a v7 gridlayout + IProject project = mGraphicalEditorPart.getProject(); + ProjectState projectState = Sdk.getProjectState(project); + if (projectState != null && projectState.isLibrary()) { + namespace = AUTO_URI; + } else { + ManifestInfo info = ManifestInfo.get(project); + namespace = URI_PREFIX + info.getPackage(); + } + } + + if (e.getNodeName() != null && e.getNodeName().endsWith(GRID_LAYOUT)) { + // Attach rowCount/columnCount info + String rowCount = e.getAttributeNS(namespace, ATTR_ROW_COUNT); + if (rowCount.length() == 0) { + rowCount = "?"; + } + String columnCount = e.getAttributeNS(namespace, ATTR_COLUMN_COUNT); + if (columnCount.length() == 0) { + columnCount = "?"; + } + + styledString.append(" - columnCount=", QUALIFIER_STYLER); + styledString.append(columnCount, QUALIFIER_STYLER); + styledString.append(", rowCount=", QUALIFIER_STYLER); + styledString.append(rowCount, QUALIFIER_STYLER); + } else if (e.getParentNode() != null + && e.getParentNode().getNodeName() != null + && e.getParentNode().getNodeName().endsWith(GRID_LAYOUT)) { + // Attach row/column info + String row = e.getAttributeNS(namespace, ATTR_LAYOUT_ROW); + if (row.length() == 0) { + row = "?"; + } + Styler colStyle = QUALIFIER_STYLER; + String column = e.getAttributeNS(namespace, ATTR_LAYOUT_COLUMN); + if (column.length() == 0) { + column = "?"; + } else { + String colCount = ((Element) e.getParentNode()).getAttributeNS( + namespace, ATTR_COLUMN_COUNT); + if (colCount.length() > 0 && Integer.parseInt(colCount) <= + Integer.parseInt(column)) { + colStyle = StyledString.createColorRegistryStyler( + JFacePreferences.ERROR_COLOR, null); + } + } + String rowSpan = e.getAttributeNS(namespace, ATTR_LAYOUT_ROW_SPAN); + String columnSpan = e.getAttributeNS(namespace, + ATTR_LAYOUT_COLUMN_SPAN); + if (rowSpan.length() == 0) { + rowSpan = "1"; + } + if (columnSpan.length() == 0) { + columnSpan = "1"; + } + + styledString.append(" - cell (row=", QUALIFIER_STYLER); + styledString.append(row, QUALIFIER_STYLER); + styledString.append(',', QUALIFIER_STYLER); + styledString.append("col=", colStyle); + styledString.append(column, colStyle); + styledString.append(')', colStyle); + styledString.append(", span=(", QUALIFIER_STYLER); + styledString.append(columnSpan, QUALIFIER_STYLER); + styledString.append(',', QUALIFIER_STYLER); + styledString.append(rowSpan, QUALIFIER_STYLER); + styledString.append(')', QUALIFIER_STYLER); + + String gravity = e.getAttributeNS(namespace, ATTR_LAYOUT_GRAVITY); + if (gravity != null && gravity.length() > 0) { + styledString.append(" : ", COUNTER_STYLER); + styledString.append(gravity, COUNTER_STYLER); + } + + } + } + + if (e.hasAttributeNS(ANDROID_URI, ATTR_TEXT)) { + // Show the text attribute + String text = e.getAttributeNS(ANDROID_URI, ATTR_TEXT); + if (text != null && text.length() > 0 + && !text.contains(node.getDescriptor().getUiName())) { + if (text.charAt(0) == '@') { + String resolved = mGraphicalEditorPart.findString(text); + if (resolved != null) { + text = resolved; + } + } + if (styledString.length() < LABEL_MAX_WIDTH - LABEL_SEPARATOR.length() + - 2) { + styledString.append(LABEL_SEPARATOR, QUALIFIER_STYLER); + + styledString.append('"', QUALIFIER_STYLER); + styledString.append(truncate(text, styledString), QUALIFIER_STYLER); + styledString.append('"', QUALIFIER_STYLER); + } + } + } else if (e.hasAttributeNS(ANDROID_URI, ATTR_SRC)) { + // Show ImageView source attributes etc + String src = e.getAttributeNS(ANDROID_URI, ATTR_SRC); + if (src != null && src.length() > 0) { + if (src.startsWith(DRAWABLE_PREFIX)) { + src = src.substring(DRAWABLE_PREFIX.length()); + } + styledString.append(LABEL_SEPARATOR, QUALIFIER_STYLER); + styledString.append(truncate(src, styledString), QUALIFIER_STYLER); + } + } else if (e.getTagName().equals(SdkConstants.VIEW_INCLUDE)) { + // Show the include reference. + + // Note: the layout attribute is NOT in the Android namespace + String src = e.getAttribute(SdkConstants.ATTR_LAYOUT); + if (src != null && src.length() > 0) { + if (src.startsWith(LAYOUT_RESOURCE_PREFIX)) { + src = src.substring(LAYOUT_RESOURCE_PREFIX.length()); + } + styledString.append(LABEL_SEPARATOR, QUALIFIER_STYLER); + styledString.append(truncate(src, styledString), QUALIFIER_STYLER); + } + } + } + } else if (element == null && vi != null) { + // It's an inclusion-context: display it + Reference includedWithin = mGraphicalEditorPart.getIncludedWithin(); + if (includedWithin != null) { + styledString = new StyledString(); + styledString.append(includedWithin.getDisplayName(), QUALIFIER_STYLER); + image = IconFactory.getInstance().getIcon(SdkConstants.VIEW_INCLUDE); + } + } + + if (styledString == null) { + styledString = new StyledString(); + styledString.append(element == null ? "(null)" : element.toString()); + } + + cell.setText(styledString.toString()); + cell.setStyleRanges(styledString.getStyleRanges()); + cell.setImage(image); + super.update(cell); + } + + @Override + public boolean isLabelProperty(Object element, String property) { + return super.isLabelProperty(element, property); + } + } + + // --- Context Menu --- + + /** + * This viewer uses its own actions that delegate to the ones given + * by the {@link LayoutCanvas}. All the processing is actually handled + * directly by the canvas and this viewer only gets refreshed as a + * consequence of the canvas changing the XML model. + */ + private void setupContextMenu() { + + mMenuManager = new MenuManager(); + mMenuManager.removeAll(); + + mMenuManager.add(mMoveUpAction); + mMenuManager.add(mMoveDownAction); + mMenuManager.add(new Separator()); + + mMenuManager.add(new SelectionManager.SelectionMenu(mGraphicalEditorPart)); + mMenuManager.add(new Separator()); + final String prefix = LayoutCanvas.PREFIX_CANVAS_ACTION; + mMenuManager.add(new DelegateAction(prefix + ActionFactory.CUT.getId())); + mMenuManager.add(new DelegateAction(prefix + ActionFactory.COPY.getId())); + mMenuManager.add(new DelegateAction(prefix + ActionFactory.PASTE.getId())); + + mMenuManager.add(new Separator()); + + mMenuManager.add(new DelegateAction(prefix + ActionFactory.DELETE.getId())); + + mMenuManager.addMenuListener(new IMenuListener() { + @Override + public void menuAboutToShow(IMenuManager manager) { + // Update all actions to match their LayoutCanvas counterparts + for (IContributionItem contrib : manager.getItems()) { + if (contrib instanceof ActionContributionItem) { + IAction action = ((ActionContributionItem) contrib).getAction(); + if (action instanceof DelegateAction) { + ((DelegateAction) action).updateFromEditorPart(mGraphicalEditorPart); + } + } + } + } + }); + + new DynamicContextMenu( + mGraphicalEditorPart.getEditorDelegate(), + mGraphicalEditorPart.getCanvasControl(), + mMenuManager); + + getTreeViewer().getTree().setMenu(mMenuManager.createContextMenu(getControl())); + + // Update Move Up/Move Down state only when the menu is opened + getTreeViewer().getTree().addMenuDetectListener(new MenuDetectListener() { + @Override + public void menuDetected(MenuDetectEvent e) { + mMenuManager.update(IAction.ENABLED); + } + }); + } + + /** + * An action that delegates its properties and behavior to a target action. + * The target action can be null or it can change overtime, typically as the + * layout canvas' editor part is activated or closed. + */ + private static class DelegateAction extends Action { + private IAction mTargetAction; + private final String mCanvasActionId; + + public DelegateAction(String canvasActionId) { + super(canvasActionId); + setId(canvasActionId); + mCanvasActionId = canvasActionId; + } + + // --- Methods form IAction --- + + /** Returns the target action's {@link #isEnabled()} if defined, or false. */ + @Override + public boolean isEnabled() { + return mTargetAction == null ? false : mTargetAction.isEnabled(); + } + + /** Returns the target action's {@link #isChecked()} if defined, or false. */ + @Override + public boolean isChecked() { + return mTargetAction == null ? false : mTargetAction.isChecked(); + } + + /** Returns the target action's {@link #isHandled()} if defined, or false. */ + @Override + public boolean isHandled() { + return mTargetAction == null ? false : mTargetAction.isHandled(); + } + + /** Runs the target action if defined. */ + @Override + public void run() { + if (mTargetAction != null) { + mTargetAction.run(); + } + super.run(); + } + + /** + * Updates this action to delegate to its counterpart in the given editor part + * + * @param editorPart The editor being updated + */ + public void updateFromEditorPart(GraphicalEditorPart editorPart) { + LayoutCanvas canvas = editorPart == null ? null : editorPart.getCanvasControl(); + if (canvas == null) { + mTargetAction = null; + } else { + mTargetAction = canvas.getAction(mCanvasActionId); + } + + if (mTargetAction != null) { + setText(mTargetAction.getText()); + setId(mTargetAction.getId()); + setDescription(mTargetAction.getDescription()); + setImageDescriptor(mTargetAction.getImageDescriptor()); + setHoverImageDescriptor(mTargetAction.getHoverImageDescriptor()); + setDisabledImageDescriptor(mTargetAction.getDisabledImageDescriptor()); + setToolTipText(mTargetAction.getToolTipText()); + setActionDefinitionId(mTargetAction.getActionDefinitionId()); + setHelpListener(mTargetAction.getHelpListener()); + setAccelerator(mTargetAction.getAccelerator()); + setChecked(mTargetAction.isChecked()); + setEnabled(mTargetAction.isEnabled()); + } else { + setEnabled(false); + } + } + } + + /** Returns the associated editor with this outline */ + /* package */GraphicalEditorPart getEditor() { + return mGraphicalEditorPart; + } + + @Override + public void setActionBars(IActionBars actionBars) { + super.setActionBars(actionBars); + + // Map Outline actions to canvas actions such that they share Undo context etc + LayoutCanvas canvas = mGraphicalEditorPart.getCanvasControl(); + canvas.updateGlobalActions(actionBars); + + // Special handling for Select All since it's different than the canvas (will + // include selecting the root etc) + actionBars.setGlobalActionHandler(mTreeSelectAllAction.getId(), mTreeSelectAllAction); + actionBars.updateActionBars(); + } + + // ---- Move Up/Down Support ---- + + /** Returns true if the current selected item can be moved */ + private boolean canMove(boolean forward) { + CanvasViewInfo viewInfo = getSingleSelectedItem(); + if (viewInfo != null) { + UiViewElementNode node = viewInfo.getUiViewNode(); + if (forward) { + return findNext(node) != null; + } else { + return findPrevious(node) != null; + } + } + + return false; + } + + /** Moves the current selected item down (forward) or up (not forward) */ + private void move(boolean forward) { + CanvasViewInfo viewInfo = getSingleSelectedItem(); + if (viewInfo != null) { + final Pair<UiViewElementNode, Integer> target; + UiViewElementNode selected = viewInfo.getUiViewNode(); + if (forward) { + target = findNext(selected); + } else { + target = findPrevious(selected); + } + if (target != null) { + final LayoutCanvas canvas = mGraphicalEditorPart.getCanvasControl(); + final SelectionManager selectionManager = canvas.getSelectionManager(); + final ArrayList<SelectionItem> dragSelection = new ArrayList<SelectionItem>(); + dragSelection.add(selectionManager.createSelection(viewInfo)); + SelectionManager.sanitize(dragSelection); + + if (!dragSelection.isEmpty()) { + final SimpleElement[] elements = SelectionItem.getAsElements(dragSelection); + UiViewElementNode parentNode = target.getFirst(); + final NodeProxy targetNode = canvas.getNodeFactory().create(parentNode); + + // Record children of the target right before the drop (such that we + // can find out after the drop which exact children were inserted) + Set<INode> children = new HashSet<INode>(); + for (INode node : targetNode.getChildren()) { + children.add(node); + } + + String label = MoveGesture.computeUndoLabel(targetNode, + elements, DND.DROP_MOVE); + canvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel(label, new Runnable() { + @Override + public void run() { + InsertType insertType = InsertType.MOVE_INTO; + if (dragSelection.get(0).getNode().getParent() == targetNode) { + insertType = InsertType.MOVE_WITHIN; + } + canvas.getRulesEngine().setInsertType(insertType); + int index = target.getSecond(); + BaseLayoutRule.insertAt(targetNode, elements, false, index); + targetNode.applyPendingChanges(); + canvas.getClipboardSupport().deleteSelection("Remove", dragSelection); + } + }); + + // Now find out which nodes were added, and look up their + // corresponding CanvasViewInfos + final List<INode> added = new ArrayList<INode>(); + for (INode node : targetNode.getChildren()) { + if (!children.contains(node)) { + added.add(node); + } + } + + selectionManager.setOutlineSelection(added); + } + } + } + } + + /** + * Returns the {@link CanvasViewInfo} for the currently selected item, or null if + * there are no or multiple selected items + * + * @return the current selected item if there is exactly one item selected + */ + private CanvasViewInfo getSingleSelectedItem() { + TreeItem[] selection = getTreeViewer().getTree().getSelection(); + if (selection.length == 1) { + return getViewInfo(selection[0].getData()); + } + + return null; + } + + + /** Returns the pair [parent,index] of the next node (when iterating forward) */ + @VisibleForTesting + /* package */ static Pair<UiViewElementNode, Integer> findNext(UiViewElementNode node) { + UiElementNode parent = node.getUiParent(); + if (parent == null) { + return null; + } + + UiElementNode next = node.getUiNextSibling(); + if (next != null) { + if (DescriptorsUtils.canInsertChildren(next.getDescriptor(), null)) { + return getFirstPosition(next); + } else { + return getPositionAfter(next); + } + } + + next = parent.getUiNextSibling(); + if (next != null) { + return getPositionBefore(next); + } else { + UiElementNode grandParent = parent.getUiParent(); + if (grandParent != null) { + return getLastPosition(grandParent); + } + } + + return null; + } + + /** Returns the pair [parent,index] of the previous node (when iterating backward) */ + @VisibleForTesting + /* package */ static Pair<UiViewElementNode, Integer> findPrevious(UiViewElementNode node) { + UiElementNode prev = node.getUiPreviousSibling(); + if (prev != null) { + UiElementNode curr = prev; + while (true) { + List<UiElementNode> children = curr.getUiChildren(); + if (children.size() > 0) { + curr = children.get(children.size() - 1); + continue; + } + if (DescriptorsUtils.canInsertChildren(curr.getDescriptor(), null)) { + return getFirstPosition(curr); + } else { + if (curr == prev) { + return getPositionBefore(curr); + } else { + return getPositionAfter(curr); + } + } + } + } + + return getPositionBefore(node.getUiParent()); + } + + /** Returns the pair [parent,index] of the position immediately before the given node */ + private static Pair<UiViewElementNode, Integer> getPositionBefore(UiElementNode node) { + if (node != null) { + UiElementNode parent = node.getUiParent(); + if (parent != null && parent instanceof UiViewElementNode) { + return Pair.of((UiViewElementNode) parent, node.getUiSiblingIndex()); + } + } + + return null; + } + + /** Returns the pair [parent,index] of the position immediately following the given node */ + private static Pair<UiViewElementNode, Integer> getPositionAfter(UiElementNode node) { + if (node != null) { + UiElementNode parent = node.getUiParent(); + if (parent != null && parent instanceof UiViewElementNode) { + return Pair.of((UiViewElementNode) parent, node.getUiSiblingIndex() + 1); + } + } + + return null; + } + + /** Returns the pair [parent,index] of the first position inside the given parent */ + private static Pair<UiViewElementNode, Integer> getFirstPosition(UiElementNode parent) { + if (parent != null && parent instanceof UiViewElementNode) { + return Pair.of((UiViewElementNode) parent, 0); + } + + return null; + } + + /** + * Returns the pair [parent,index] of the last position after the given node's + * children + */ + private static Pair<UiViewElementNode, Integer> getLastPosition(UiElementNode parent) { + if (parent != null && parent instanceof UiViewElementNode) { + return Pair.of((UiViewElementNode) parent, parent.getUiChildren().size()); + } + + return null; + } + + /** + * Truncates the given text such that it will fit into the given {@link StyledString} + * up to a maximum length of {@link #LABEL_MAX_WIDTH}. + * + * @param text the text to truncate + * @param string the existing string to be appended to + * @return the truncated string + */ + private static String truncate(String text, StyledString string) { + int existingLength = string.length(); + + if (text.length() + existingLength > LABEL_MAX_WIDTH) { + int truncatedLength = LABEL_MAX_WIDTH - existingLength - 3; + if (truncatedLength > 0) { + return String.format("%1$s...", text.substring(0, truncatedLength)); + } else { + return ""; //$NON-NLS-1$ + } + } + + return text; + } + + @Override + public void setToolBar(IToolBarManager toolBarManager) { + makeContributions(null, toolBarManager, null); + toolBarManager.update(false); + } + + /** + * Sets up a custom tooltip when hovering over tree items. It currently displays the error + * message for the lint warning associated with each node, if any (and only if the hover + * is over the icon portion). + */ + private void setupTooltip() { + final Tree tree = getTreeViewer().getTree(); + + // This is based on SWT Snippet 125 + final Listener listener = new Listener() { + Shell mTip = null; + Label mLabel = null; + + @Override + public void handleEvent(Event event) { + switch(event.type) { + case SWT.Dispose: + case SWT.KeyDown: + case SWT.MouseExit: + case SWT.MouseDown: + case SWT.MouseMove: + if (mTip != null) { + mTip.dispose(); + mTip = null; + mLabel = null; + } + break; + case SWT.MouseHover: + if (mTip != null) { + mTip.dispose(); + mTip = null; + mLabel = null; + } + + String tooltip = null; + + TreeItem item = tree.getItem(new Point(event.x, event.y)); + if (item != null) { + Rectangle rect = item.getBounds(0); + if (event.x - rect.x > 16) { // 16: Standard width of our outline icons + return; + } + + Object data = item.getData(); + if (data != null && data instanceof CanvasViewInfo) { + LayoutEditorDelegate editor = mGraphicalEditorPart.getEditorDelegate(); + CanvasViewInfo vi = (CanvasViewInfo) data; + IMarker marker = editor.getIssueForNode(vi.getUiViewNode()); + if (marker != null) { + tooltip = marker.getAttribute(IMarker.MESSAGE, null); + } + } + + if (tooltip != null) { + Shell shell = tree.getShell(); + Display display = tree.getDisplay(); + + Color fg = display.getSystemColor(SWT.COLOR_INFO_FOREGROUND); + Color bg = display.getSystemColor(SWT.COLOR_INFO_BACKGROUND); + mTip = new Shell(shell, SWT.ON_TOP | SWT.NO_FOCUS | SWT.TOOL); + mTip.setBackground(bg); + FillLayout layout = new FillLayout(); + layout.marginWidth = 1; + layout.marginHeight = 1; + mTip.setLayout(layout); + mLabel = new Label(mTip, SWT.WRAP); + mLabel.setForeground(fg); + mLabel.setBackground(bg); + mLabel.setText(tooltip); + mLabel.addListener(SWT.MouseExit, this); + mLabel.addListener(SWT.MouseDown, this); + + Point pt = tree.toDisplay(rect.x, rect.y + rect.height); + Rectangle displayBounds = display.getBounds(); + // -10: Don't extend -all- the way to the edge of the screen + // which would make it look like it has been cropped + int availableWidth = displayBounds.x + displayBounds.width - pt.x - 10; + if (availableWidth < 80) { + availableWidth = 80; + } + Point size = mTip.computeSize(SWT.DEFAULT, SWT.DEFAULT); + if (size.x > availableWidth) { + size = mTip.computeSize(availableWidth, SWT.DEFAULT); + } + mTip.setBounds(pt.x, pt.y, size.x, size.y); + + mTip.setVisible(true); + } + } + } + } + }; + + tree.addListener(SWT.Dispose, listener); + tree.addListener(SWT.KeyDown, listener); + tree.addListener(SWT.MouseMove, listener); + tree.addListener(SWT.MouseHover, listener); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/Overlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/Overlay.java new file mode 100644 index 000000000..9b7e0eb18 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/Overlay.java @@ -0,0 +1,91 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.GC; + +/** + * An Overlay is a set of graphics which can be painted on top of the visual + * editor. Different {@link Gesture}s produce context specific overlays, such as + * swiping rectangles from the {@link MarqueeGesture} and guidelines from the + * {@link MoveGesture}. + */ +public abstract class Overlay { + private Device mDevice; + + /** Whether the hover is hidden */ + private boolean mHiding; + + /** + * Construct the overlay, using the given graphics context for painting. + */ + public Overlay() { + super(); + } + + /** + * Initializes the overlay before the first use, if applicable. This is a + * good place to initialize resources like colors. + * + * @param device The device to allocate resources for; the parameter passed + * to {@link #paint} will correspond to this device. + */ + public void create(Device device) { + mDevice = device; + } + + /** + * Releases resources held by the overlay. Called by the editor when an + * overlay has been removed. + */ + public void dispose() { + } + + /** + * Paints the overlay. + * + * @param gc The SWT {@link GC} object to draw into. + */ + public void paint(GC gc) { + throw new IllegalArgumentException("paint() not implemented, probably done " + + "with specialized paint signature"); + } + + /** Returns the device associated with this overlay */ + public Device getDevice() { + return mDevice; + } + + /** + * Returns whether the overlay is hidden + * + * @return true if the selection overlay is hidden + */ + public boolean isHiding() { + return mHiding; + } + + /** + * Hides the overlay + * + * @param hiding true to hide the overlay, false to unhide it (default) + */ + public void setHiding(boolean hiding) { + mHiding = hiding; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PaletteControl.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PaletteControl.java new file mode 100644 index 000000000..46168b70f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PaletteControl.java @@ -0,0 +1,1265 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +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_TEXT; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; +import static com.android.SdkConstants.XMLNS_ANDROID; +import static com.android.SdkConstants.XMLNS_URI; + +import com.android.ide.common.api.InsertType; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.RuleAction.Toggle; +import com.android.ide.common.rendering.LayoutLibrary; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.LayoutLog; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.ViewInfo; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.CustomViewDescriptorService; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.PaletteMetadataDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository.RenderMode; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.IAndroidTarget; +import com.android.utils.Pair; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IToolBarManager; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CLabel; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.DragSource; +import org.eclipse.swt.dnd.DragSourceEvent; +import org.eclipse.swt.dnd.DragSourceListener; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.DisposeEvent; +import org.eclipse.swt.events.DisposeListener; +import org.eclipse.swt.events.MenuDetectEvent; +import org.eclipse.swt.events.MenuDetectListener; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseTrackListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.swt.widgets.ToolItem; +import org.eclipse.wb.internal.core.editor.structure.IPage; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * A palette control for the {@link GraphicalEditorPart}. + * <p/> + * The palette contains several groups, each with a UI name (e.g. layouts and views) and each + * with a list of element descriptors. + * <p/> + * + * TODO list: + * - The available items should depend on the actual GLE2 Canvas selection. Selected android + * views should force filtering on what they accept can be dropped on them (e.g. TabHost, + * TableLayout). Should enable/disable them, not hide them, to avoid shuffling around. + * - Optional: a text filter + * - Optional: have context-sensitive tools items, e.g. selection arrow tool, + * group selection tool, alignment, etc. + */ +public class PaletteControl extends Composite { + + /** + * Wrapper to create a {@link PaletteControl} + */ + static class PalettePage implements IPage { + private final GraphicalEditorPart mEditorPart; + private PaletteControl mControl; + + PalettePage(GraphicalEditorPart editor) { + mEditorPart = editor; + } + + @Override + public void createControl(Composite parent) { + mControl = new PaletteControl(parent, mEditorPart); + } + + @Override + public Control getControl() { + return mControl; + } + + @Override + public void dispose() { + mControl.dispose(); + } + + @Override + public void setToolBar(IToolBarManager toolBarManager) { + } + + /** + * Add tool bar items to the given toolbar + * + * @param toolbar the toolbar to add items into + */ + void createToolbarItems(final ToolBar toolbar) { + final ToolItem popupMenuItem = new ToolItem(toolbar, SWT.PUSH); + popupMenuItem.setToolTipText("View Menu"); + popupMenuItem.setImage(IconFactory.getInstance().getIcon("view_menu")); + popupMenuItem.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Rectangle bounds = popupMenuItem.getBounds(); + // Align menu horizontally with the toolbar button and + // vertically with the bottom of the toolbar + Point point = toolbar.toDisplay(bounds.x, bounds.y + bounds.height); + mControl.showMenu(point.x, point.y); + } + }); + } + + @Override + public void setFocus() { + mControl.setFocus(); + } + } + + /** + * The parent grid layout that contains all the {@link Toggle} and + * {@link IconTextItem} widgets. + */ + private GraphicalEditorPart mEditor; + private Color mBackground; + private Color mForeground; + + /** The palette modes control various ways to visualize and lay out the views */ + private static enum PaletteMode { + /** Show rendered previews of the views */ + PREVIEW("Show Previews", true), + /** Show rendered previews of the views, scaled down to 75% */ + SMALL_PREVIEW("Show Small Previews", true), + /** Show rendered previews of the views, scaled down to 50% */ + TINY_PREVIEW("Show Tiny Previews", true), + /** Show an icon + text label */ + ICON_TEXT("Show Icon and Text", false), + /** Show only icons, packed multiple per row */ + ICON_ONLY("Show Only Icons", true); + + PaletteMode(String actionLabel, boolean wrap) { + mActionLabel = actionLabel; + mWrap = wrap; + } + + public String getActionLabel() { + return mActionLabel; + } + + public boolean getWrap() { + return mWrap; + } + + public boolean isPreview() { + return this == PREVIEW || this == SMALL_PREVIEW || this == TINY_PREVIEW; + } + + public boolean isScaledPreview() { + return this == SMALL_PREVIEW || this == TINY_PREVIEW; + } + + private final String mActionLabel; + private final boolean mWrap; + }; + + /** Token used in preference string to record alphabetical sorting */ + private static final String VALUE_ALPHABETICAL = "alpha"; //$NON-NLS-1$ + /** Token used in preference string to record categories being turned off */ + private static final String VALUE_NO_CATEGORIES = "nocat"; //$NON-NLS-1$ + /** Token used in preference string to record auto close being turned off */ + private static final String VALUE_NO_AUTOCLOSE = "noauto"; //$NON-NLS-1$ + + private final PreviewIconFactory mPreviewIconFactory = new PreviewIconFactory(this); + private PaletteMode mPaletteMode = null; + /** Use alphabetical sorting instead of natural order? */ + private boolean mAlphabetical; + /** Use categories instead of a single large list of views? */ + private boolean mCategories = true; + /** Auto-close the previous category when new categories are opened */ + private boolean mAutoClose = true; + private AccordionControl mAccordion; + private String mCurrentTheme; + private String mCurrentDevice; + private IAndroidTarget mCurrentTarget; + private AndroidTargetData mCurrentTargetData; + + /** + * Create the composite. + * @param parent The parent composite. + * @param editor An editor associated with this palette. + */ + public PaletteControl(Composite parent, GraphicalEditorPart editor) { + super(parent, SWT.NONE); + + mEditor = editor; + } + + /** Reads UI mode from persistent store to preserve palette mode across IDE sessions */ + private void loadPaletteMode() { + String paletteModes = AdtPrefs.getPrefs().getPaletteModes(); + if (paletteModes.length() > 0) { + String[] tokens = paletteModes.split(","); //$NON-NLS-1$ + try { + mPaletteMode = PaletteMode.valueOf(tokens[0]); + } catch (Throwable t) { + mPaletteMode = PaletteMode.values()[0]; + } + mAlphabetical = paletteModes.contains(VALUE_ALPHABETICAL); + mCategories = !paletteModes.contains(VALUE_NO_CATEGORIES); + mAutoClose = !paletteModes.contains(VALUE_NO_AUTOCLOSE); + } else { + mPaletteMode = PaletteMode.SMALL_PREVIEW; + } + } + + /** + * Returns the most recently stored version of auto-close-mode; this is the last + * user-initiated setting of the auto-close mode (we programmatically switch modes when + * you enter icons-only mode, and set it back to this when going to any other mode) + */ + private boolean getSavedAutoCloseMode() { + return !AdtPrefs.getPrefs().getPaletteModes().contains(VALUE_NO_AUTOCLOSE); + } + + /** Saves UI mode to persistent store to preserve palette mode across IDE sessions */ + private void savePaletteMode() { + StringBuilder sb = new StringBuilder(); + sb.append(mPaletteMode); + if (mAlphabetical) { + sb.append(',').append(VALUE_ALPHABETICAL); + } + if (!mCategories) { + sb.append(',').append(VALUE_NO_CATEGORIES); + } + if (!mAutoClose) { + sb.append(',').append(VALUE_NO_AUTOCLOSE); + } + AdtPrefs.getPrefs().setPaletteModes(sb.toString()); + } + + private void refreshPalette() { + IAndroidTarget oldTarget = mCurrentTarget; + mCurrentTarget = null; + mCurrentTargetData = null; + mCurrentTheme = null; + mCurrentDevice = null; + reloadPalette(oldTarget); + } + + @Override + protected void checkSubclass() { + // Disable the check that prevents subclassing of SWT components + } + + @Override + public void dispose() { + if (mBackground != null) { + mBackground.dispose(); + mBackground = null; + } + if (mForeground != null) { + mForeground.dispose(); + mForeground = null; + } + + super.dispose(); + } + + /** + * Returns the currently displayed target + * + * @return the current target, or null + */ + public IAndroidTarget getCurrentTarget() { + return mCurrentTarget; + } + + /** + * Returns the currently displayed theme (in palette modes that support previewing) + * + * @return the current theme, or null + */ + public String getCurrentTheme() { + return mCurrentTheme; + } + + /** + * Returns the currently displayed device (in palette modes that support previewing) + * + * @return the current device, or null + */ + public String getCurrentDevice() { + return mCurrentDevice; + } + + /** Returns true if previews in the palette should be made available */ + private boolean previewsAvailable() { + // Not layoutlib 5 -- we require custom background support to do + // a decent job with previews + LayoutLibrary layoutLibrary = mEditor.getLayoutLibrary(); + return layoutLibrary != null && layoutLibrary.supports(Capability.CUSTOM_BACKGROUND_COLOR); + } + + /** + * Loads or reloads the palette elements by using the layout and view descriptors from the + * given target data. + * + * @param target The target that has just been loaded + */ + public void reloadPalette(IAndroidTarget target) { + ConfigurationChooser configChooser = mEditor.getConfigurationChooser(); + String theme = configChooser.getThemeName(); + String device = configChooser.getDeviceName(); + if (device == null) { + return; + } + AndroidTargetData targetData = + target != null ? Sdk.getCurrent().getTargetData(target) : null; + if (target == mCurrentTarget && targetData == mCurrentTargetData + && mCurrentTheme != null && mCurrentTheme.equals(theme) + && mCurrentDevice != null && mCurrentDevice.equals(device)) { + return; + } + mCurrentTheme = theme; + mCurrentTarget = target; + mCurrentTargetData = targetData; + mCurrentDevice = device; + mPreviewIconFactory.reset(); + + if (targetData == null) { + return; + } + + Set<String> expandedCategories = null; + if (mAccordion != null) { + expandedCategories = mAccordion.getExpandedCategories(); + // We auto-expand all categories when showing icons-only. When returning to some + // other mode we don't want to retain all categories open. + if (expandedCategories.size() > 3) { + expandedCategories = null; + } + } + + // Erase old content and recreate new + for (Control c : getChildren()) { + c.dispose(); + } + + if (mPaletteMode == null) { + loadPaletteMode(); + assert mPaletteMode != null; + } + + // Ensure that the palette mode is supported on this version of the layout library + if (!previewsAvailable()) { + if (mPaletteMode.isPreview()) { + mPaletteMode = PaletteMode.ICON_TEXT; + } + } + + if (mPaletteMode.isPreview()) { + if (mForeground != null) { + mForeground.dispose(); + mForeground = null; + } + if (mBackground != null) { + mBackground.dispose(); + mBackground = null; + } + RGB background = mPreviewIconFactory.getBackgroundColor(); + if (background != null) { + mBackground = new Color(getDisplay(), background); + } + RGB foreground = mPreviewIconFactory.getForegroundColor(); + if (foreground != null) { + mForeground = new Color(getDisplay(), foreground); + } + } + + List<String> headers = Collections.emptyList(); + final Map<String, List<ViewElementDescriptor>> categoryToItems; + categoryToItems = new HashMap<String, List<ViewElementDescriptor>>(); + headers = new ArrayList<String>(); + List<Pair<String,List<ViewElementDescriptor>>> paletteEntries = + ViewMetadataRepository.get().getPaletteEntries(targetData, + mAlphabetical, mCategories); + for (Pair<String,List<ViewElementDescriptor>> pair : paletteEntries) { + String category = pair.getFirst(); + List<ViewElementDescriptor> categoryItems = pair.getSecond(); + headers.add(category); + categoryToItems.put(category, categoryItems); + } + + headers.add("Custom & Library Views"); + + // Set the categories to expand the first item if + // (1) we don't have a previously selected category, or + // (2) there's just one category anyway, or + // (3) the set of categories have changed so our previously selected category + // doesn't exist anymore (can happen when you toggle "Show Categories") + if ((expandedCategories == null && headers.size() > 0) || headers.size() == 1 || + (expandedCategories != null && expandedCategories.size() >= 1 + && !headers.contains( + expandedCategories.iterator().next().replace("&&", "&")))) { //$NON-NLS-1$ //$NON-NLS-2$ + // Expand the first category if we don't have a previous selection (e.g. refresh) + expandedCategories = Collections.singleton(headers.get(0)); + } + + boolean wrap = mPaletteMode.getWrap(); + + // Pack icon-only view vertically; others stretch to fill palette region + boolean fillVertical = mPaletteMode != PaletteMode.ICON_ONLY; + + mAccordion = new AccordionControl(this, SWT.NONE, headers, fillVertical, wrap, + expandedCategories) { + @Override + protected Composite createChildContainer(Composite parent, Object header, int style) { + assert categoryToItems != null; + List<ViewElementDescriptor> list = categoryToItems.get(header); + final Composite composite; + if (list == null) { + assert header.equals("Custom & Library Views"); + + Composite wrapper = new Composite(parent, SWT.NONE); + GridLayout gridLayout = new GridLayout(1, false); + gridLayout.marginWidth = gridLayout.marginHeight = 0; + gridLayout.horizontalSpacing = gridLayout.verticalSpacing = 0; + gridLayout.marginBottom = 3; + wrapper.setLayout(gridLayout); + if (mPaletteMode.isPreview() && mBackground != null) { + wrapper.setBackground(mBackground); + } + composite = super.createChildContainer(wrapper, header, style); + if (mPaletteMode.isPreview() && mBackground != null) { + composite.setBackground(mBackground); + } + composite.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); + + Button refreshButton = new Button(wrapper, SWT.PUSH | SWT.FLAT); + refreshButton.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, + false, false, 1, 1)); + refreshButton.setText("Refresh"); + refreshButton.setImage(IconFactory.getInstance().getIcon("refresh")); //$NON-NLS-1$ + refreshButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + CustomViewFinder finder = CustomViewFinder.get(mEditor.getProject()); + finder.refresh(new ViewFinderListener(composite)); + } + }); + + wrapper.layout(true); + } else { + composite = super.createChildContainer(parent, header, style); + if (mPaletteMode.isPreview() && mBackground != null) { + composite.setBackground(mBackground); + } + } + addMenu(composite); + return composite; + } + @Override + protected void createChildren(Composite parent, Object header) { + assert categoryToItems != null; + List<ViewElementDescriptor> list = categoryToItems.get(header); + if (list == null) { + assert header.equals("Custom & Library Views"); + addCustomItems(parent); + return; + } else { + for (ViewElementDescriptor desc : list) { + createItem(parent, desc); + } + } + } + }; + addMenu(mAccordion); + for (CLabel headerLabel : mAccordion.getHeaderLabels()) { + addMenu(headerLabel); + } + setLayout(new FillLayout()); + + // Expand All for icon-only mode, but don't store it as the persistent auto-close mode; + // when we enter other modes it will read back whatever persistent mode. + if (mPaletteMode == PaletteMode.ICON_ONLY) { + mAccordion.expandAll(true); + mAccordion.setAutoClose(false); + } else { + mAccordion.setAutoClose(getSavedAutoCloseMode()); + } + + layout(true); + } + + protected void addCustomItems(final Composite parent) { + final CustomViewFinder finder = CustomViewFinder.get(mEditor.getProject()); + Collection<String> allViews = finder.getAllViews(); + if (allViews == null) { // Not yet initialized: trigger an async refresh + finder.refresh(new ViewFinderListener(parent)); + return; + } + + // Remove previous content + for (Control c : parent.getChildren()) { + c.dispose(); + } + + // Add new views + for (final String fqcn : allViews) { + CustomViewDescriptorService service = CustomViewDescriptorService.getInstance(); + ViewElementDescriptor desc = service.getDescriptor(mEditor.getProject(), fqcn); + if (desc == null) { + // The descriptor lookup performs validation steps of the class, and may + // in some cases determine that this is not a view and will return null; + // guard against that. + continue; + } + + Control item = createItem(parent, desc); + + // Add control-click listener on custom view items to you can warp to + // (and double click listener too -- the more discoverable, the better.) + if (item instanceof IconTextItem) { + IconTextItem it = (IconTextItem) item; + it.addMouseListener(new MouseAdapter() { + @Override + public void mouseDoubleClick(MouseEvent e) { + AdtPlugin.openJavaClass(mEditor.getProject(), fqcn); + } + + @Override + public void mouseDown(MouseEvent e) { + if ((e.stateMask & SWT.MOD1) != 0) { + AdtPlugin.openJavaClass(mEditor.getProject(), fqcn); + } + } + }); + } + } + } + + /* package */ GraphicalEditorPart getEditor() { + return mEditor; + } + + private Control createItem(Composite parent, ViewElementDescriptor desc) { + Control item = null; + switch (mPaletteMode) { + case SMALL_PREVIEW: + case TINY_PREVIEW: + case PREVIEW: { + ImageDescriptor descriptor = mPreviewIconFactory.getImageDescriptor(desc); + if (descriptor != null) { + Image image = descriptor.createImage(); + ImageControl imageControl = new ImageControl(parent, SWT.None, image); + if (mPaletteMode.isScaledPreview()) { + // Try to preserve the overall size since rendering sizes typically + // vary with the dpi - so while the scaling factor for a 160 dpi + // rendering the scaling factor should be 0.5, for a 320 dpi one the + // scaling factor should be half that, 0.25. + float scale = 1.0f; + if (mPaletteMode == PaletteMode.SMALL_PREVIEW) { + scale = 0.75f; + } else if (mPaletteMode == PaletteMode.TINY_PREVIEW) { + scale = 0.5f; + } + ConfigurationChooser chooser = mEditor.getConfigurationChooser(); + int dpi = chooser.getConfiguration().getDensity().getDpiValue(); + while (dpi > 160) { + scale = scale / 2; + dpi = dpi / 2; + } + imageControl.setScale(scale); + } + imageControl.setHoverColor(getDisplay().getSystemColor(SWT.COLOR_WHITE)); + if (mBackground != null) { + imageControl.setBackground(mBackground); + } + String toolTip = desc.getUiName(); + // It appears pretty much none of the descriptors have tooltips + //String descToolTip = desc.getTooltip(); + //if (descToolTip != null && descToolTip.length() > 0) { + // toolTip = toolTip + "\n" + descToolTip; + //} + imageControl.setToolTipText(toolTip); + + item = imageControl; + } else { + // Just use an Icon+Text item for these for now + item = new IconTextItem(parent, desc); + if (mForeground != null) { + item.setForeground(mForeground); + item.setBackground(mBackground); + } + } + break; + } + case ICON_TEXT: { + item = new IconTextItem(parent, desc); + break; + } + case ICON_ONLY: { + item = new ImageControl(parent, SWT.None, desc.getGenericIcon()); + item.setToolTipText(desc.getUiName()); + break; + } + default: + throw new IllegalArgumentException("Not yet implemented"); + } + + final DragSource source = new DragSource(item, DND.DROP_COPY); + source.setTransfer(new Transfer[] { SimpleXmlTransfer.getInstance() }); + source.addDragListener(new DescDragSourceListener(desc)); + item.addDisposeListener(new DisposeListener() { + @Override + public void widgetDisposed(DisposeEvent e) { + source.dispose(); + } + }); + addMenu(item); + + return item; + } + + /** + * An Item widget represents one {@link ElementDescriptor} that can be dropped on the + * GLE2 canvas using drag'n'drop. + */ + private static class IconTextItem extends CLabel implements MouseTrackListener { + + private boolean mMouseIn; + + public IconTextItem(Composite parent, ViewElementDescriptor desc) { + super(parent, SWT.NONE); + mMouseIn = false; + + setText(desc.getUiName()); + setImage(desc.getGenericIcon()); + setToolTipText(desc.getTooltip()); + addMouseTrackListener(this); + } + + @Override + public int getStyle() { + int style = super.getStyle(); + if (mMouseIn) { + style |= SWT.SHADOW_IN; + } + return style; + } + + @Override + public void mouseEnter(MouseEvent e) { + if (!mMouseIn) { + mMouseIn = true; + redraw(); + } + } + + @Override + public void mouseExit(MouseEvent e) { + if (mMouseIn) { + mMouseIn = false; + redraw(); + } + } + + @Override + public void mouseHover(MouseEvent e) { + // pass + } + } + + /** + * A {@link DragSourceListener} that deals with drag'n'drop of + * {@link ElementDescriptor}s. + */ + private class DescDragSourceListener implements DragSourceListener { + private final ViewElementDescriptor mDesc; + private SimpleElement[] mElements; + + public DescDragSourceListener(ViewElementDescriptor desc) { + mDesc = desc; + } + + @Override + public void dragStart(DragSourceEvent e) { + // See if we can find out the bounds of this element from a preview image. + // Preview images are created before the drag source listener is notified + // of the started drag. + Rect bounds = null; + Rect dragBounds = null; + + createDragImage(e); + if (mImage != null && !mIsPlaceholder) { + int width = mImageLayoutBounds.width; + int height = mImageLayoutBounds.height; + assert mImageLayoutBounds.x == 0; + assert mImageLayoutBounds.y == 0; + bounds = new Rect(0, 0, width, height); + double scale = mEditor.getCanvasControl().getScale(); + int scaledWidth = (int) (scale * width); + int scaledHeight = (int) (scale * height); + int x = -scaledWidth / 2; + int y = -scaledHeight / 2; + dragBounds = new Rect(x, y, scaledWidth, scaledHeight); + } + + SimpleElement se = new SimpleElement( + SimpleXmlTransfer.getFqcn(mDesc), + null /* parentFqcn */, + bounds /* bounds */, + null /* parentBounds */); + if (mDesc instanceof PaletteMetadataDescriptor) { + PaletteMetadataDescriptor pm = (PaletteMetadataDescriptor) mDesc; + pm.initializeNew(se); + } + mElements = new SimpleElement[] { se }; + + // Register this as the current dragged data + GlobalCanvasDragInfo dragInfo = GlobalCanvasDragInfo.getInstance(); + dragInfo.startDrag( + mElements, + null /* selection */, + null /* canvas */, + null /* removeSource */); + dragInfo.setDragBounds(dragBounds); + dragInfo.setDragBaseline(mBaseline); + + + e.doit = true; + } + + @Override + public void dragSetData(DragSourceEvent e) { + // Provide the data for the drop when requested by the other side. + if (SimpleXmlTransfer.getInstance().isSupportedType(e.dataType)) { + e.data = mElements; + } + } + + @Override + public void dragFinished(DragSourceEvent e) { + // Unregister the dragged data. + GlobalCanvasDragInfo.getInstance().stopDrag(); + mElements = null; + if (mImage != null) { + mImage.dispose(); + mImage = null; + } + } + + // TODO: Figure out the right dimensions to use for rendering. + // We WILL crop this after rendering, but for performance reasons it would be good + // not to make it much larger than necessary since to crop this we rely on + // actually scanning pixels. + + /** + * Width of the rendered preview image (before it is cropped), although the actual + * width may be smaller (since we also take the device screen's size into account) + */ + private static final int MAX_RENDER_HEIGHT = 400; + + /** + * Height of the rendered preview image (before it is cropped), although the + * actual width may be smaller (since we also take the device screen's size into + * account) + */ + private static final int MAX_RENDER_WIDTH = 500; + + /** Amount of alpha to multiply into the image (divided by 256) */ + private static final int IMG_ALPHA = 128; + + /** The image shown during the drag */ + private Image mImage; + /** The non-effect bounds of the drag image */ + private Rectangle mImageLayoutBounds; + private int mBaseline = -1; + + /** + * If true, the image is a preview of the view, and if not it is a "fallback" + * image of some sort, such as a rendering of the palette item itself + */ + private boolean mIsPlaceholder; + + private void createDragImage(DragSourceEvent event) { + mBaseline = -1; + Pair<Image, Rectangle> preview = renderPreview(); + if (preview != null) { + mImage = preview.getFirst(); + mImageLayoutBounds = preview.getSecond(); + } else { + mImage = null; + mImageLayoutBounds = null; + } + + mIsPlaceholder = mImage == null; + if (mIsPlaceholder) { + // Couldn't render preview (or the preview is a blank image, such as for + // example the preview of an empty layout), so instead create a placeholder + // image + // Render the palette item itself as an image + Control control = ((DragSource) event.widget).getControl(); + GC gc = new GC(control); + Point size = control.getSize(); + Display display = getDisplay(); + final Image image = new Image(display, size.x, size.y); + gc.copyArea(image, 0, 0); + gc.dispose(); + + BufferedImage awtImage = SwtUtils.convertToAwt(image); + if (awtImage != null) { + awtImage = ImageUtils.createDropShadow(awtImage, 3 /* shadowSize */, + 0.7f /* shadowAlpha */, 0x000000 /* shadowRgb */); + mImage = SwtUtils.convertToSwt(display, awtImage, true, IMG_ALPHA); + } else { + ImageData data = image.getImageData(); + data.alpha = IMG_ALPHA; + + // Changing the ImageData -after- constructing an image on it + // has no effect, so we have to construct a new image. Luckily these + // are tiny images. + mImage = new Image(display, data); + } + image.dispose(); + } + + event.image = mImage; + + if (!mIsPlaceholder) { + // Shift the drag feedback image up such that it's centered under the + // mouse pointer + double scale = mEditor.getCanvasControl().getScale(); + event.offsetX = (int) (scale * mImageLayoutBounds.width / 2); + event.offsetY = (int) (scale * mImageLayoutBounds.height / 2); + } + } + + /** + * Performs the actual rendering of the descriptor into an image and returns the + * image as well as the layout bounds of the image (not including drop shadow etc) + */ + private Pair<Image, Rectangle> renderPreview() { + ViewMetadataRepository repository = ViewMetadataRepository.get(); + RenderMode renderMode = repository.getRenderMode(mDesc.getFullClassName()); + if (renderMode == RenderMode.SKIP) { + return null; + } + + // Create blank XML document + Document document = DomUtilities.createEmptyDocument(); + + // Insert our target view's XML into it as a node + GraphicalEditorPart editor = getEditor(); + LayoutEditorDelegate layoutEditorDelegate = editor.getEditorDelegate(); + + String viewName = mDesc.getXmlLocalName(); + Element element = document.createElement(viewName); + + // Set up a proper name space + Attr attr = document.createAttributeNS(XMLNS_URI, XMLNS_ANDROID); + attr.setValue(ANDROID_URI); + element.getAttributes().setNamedItemNS(attr); + + element.setAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH, VALUE_WRAP_CONTENT); + element.setAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT, VALUE_WRAP_CONTENT); + + // This doesn't apply to all, but doesn't seem to cause harm and makes for a + // better experience with text-oriented views like buttons and texts + element.setAttributeNS(ANDROID_URI, ATTR_TEXT, + DescriptorsUtils.getBasename(mDesc.getUiName())); + + // Is this a palette variation? + if (mDesc instanceof PaletteMetadataDescriptor) { + PaletteMetadataDescriptor pm = (PaletteMetadataDescriptor) mDesc; + pm.initializeNew(element); + } + + document.appendChild(element); + + // Construct UI model from XML + AndroidTargetData data = layoutEditorDelegate.getEditor().getTargetData(); + DocumentDescriptor documentDescriptor; + if (data == null) { + documentDescriptor = new DocumentDescriptor("temp", null/*children*/);//$NON-NLS-1$ + } else { + documentDescriptor = data.getLayoutDescriptors().getDescriptor(); + } + UiDocumentNode model = (UiDocumentNode) documentDescriptor.createUiNode(); + model.setEditor(layoutEditorDelegate.getEditor()); + model.setUnknownDescriptorProvider(editor.getModel().getUnknownDescriptorProvider()); + model.loadFromXmlNode(document); + + // Call the create-hooks such that we for example insert mandatory + // children into views like the DialerFilter, apply image source attributes + // to ImageButtons, etc. + LayoutCanvas canvas = editor.getCanvasControl(); + NodeFactory nodeFactory = canvas.getNodeFactory(); + UiElementNode parent = model.getUiRoot(); + UiElementNode child = parent.getUiChildren().get(0); + if (child instanceof UiViewElementNode) { + UiViewElementNode childUiNode = (UiViewElementNode) child; + NodeProxy childNode = nodeFactory.create(childUiNode); + + // Applying create hooks as part of palette render should + // not trigger model updates + layoutEditorDelegate.getEditor().setIgnoreXmlUpdate(true); + try { + canvas.getRulesEngine().callCreateHooks(layoutEditorDelegate.getEditor(), + null, childNode, InsertType.CREATE_PREVIEW); + childNode.applyPendingChanges(); + } catch (Throwable t) { + AdtPlugin.log(t, "Failed calling creation hooks for widget %1$s", viewName); + } finally { + layoutEditorDelegate.getEditor().setIgnoreXmlUpdate(false); + } + } + + Integer overrideBgColor = null; + boolean hasTransparency = false; + LayoutLibrary layoutLibrary = editor.getLayoutLibrary(); + if (layoutLibrary != null && + layoutLibrary.supports(Capability.CUSTOM_BACKGROUND_COLOR)) { + // It doesn't matter what the background color is as long as the alpha + // is 0 (fully transparent). We're using red to make it more obvious if + // for some reason the background is painted when it shouldn't be. + overrideBgColor = new Integer(0x00FF0000); + } + + RenderSession session = null; + try { + // Use at most the size of the screen for the preview render. + // This is important since when we fill the size of certain views (like + // a SeekBar), we want it to at most be the width of the screen, and for small + // screens the RENDER_WIDTH was wider. + LayoutLog silentLogger = new LayoutLog(); + + session = RenderService.create(editor) + .setModel(model) + .setMaxRenderSize(MAX_RENDER_WIDTH, MAX_RENDER_HEIGHT) + .setLog(silentLogger) + .setOverrideBgColor(overrideBgColor) + .setDecorations(false) + .createRenderSession(); + } catch (Throwable t) { + // Previews can fail for a variety of reasons -- let's not bug + // the user with it + return null; + } + + if (session != null) { + if (session.getResult().isSuccess()) { + BufferedImage image = session.getImage(); + if (image != null) { + BufferedImage cropped; + Rect initialCrop = null; + ViewInfo viewInfo = null; + + List<ViewInfo> viewInfoList = session.getRootViews(); + + if (viewInfoList != null && viewInfoList.size() > 0) { + viewInfo = viewInfoList.get(0); + mBaseline = viewInfo.getBaseLine(); + } + + if (viewInfo != null) { + int x1 = viewInfo.getLeft(); + int x2 = viewInfo.getRight(); + int y2 = viewInfo.getBottom(); + int y1 = viewInfo.getTop(); + initialCrop = new Rect(x1, y1, x2 - x1, y2 - y1); + } + + if (hasTransparency) { + cropped = ImageUtils.cropBlank(image, initialCrop); + } else { + // Find out what the "background" color is such that we can properly + // crop it out of the image. To do this we pick out a pixel in the + // bottom right unpainted area. Rather than pick the one in the far + // bottom corner, we pick one as close to the bounds of the view as + // possible (but still outside of the bounds), such that we can + // deal with themes like the dialog theme. + int edgeX = image.getWidth() -1; + int edgeY = image.getHeight() -1; + if (viewInfo != null) { + if (viewInfo.getRight() < image.getWidth()-1) { + edgeX = viewInfo.getRight()+1; + } + if (viewInfo.getBottom() < image.getHeight()-1) { + edgeY = viewInfo.getBottom()+1; + } + } + int edgeColor = image.getRGB(edgeX, edgeY); + cropped = ImageUtils.cropColor(image, edgeColor, initialCrop); + } + + if (cropped != null) { + int width = initialCrop != null ? initialCrop.w : cropped.getWidth(); + int height = initialCrop != null ? initialCrop.h : cropped.getHeight(); + boolean needsContrast = hasTransparency + && !ImageUtils.containsDarkPixels(cropped); + cropped = ImageUtils.createDropShadow(cropped, + hasTransparency ? 3 : 5 /* shadowSize */, + !hasTransparency ? 0.6f : needsContrast ? 0.8f : 0.7f/*alpha*/, + 0x000000 /* shadowRgb */); + + double scale = canvas.getScale(); + if (scale != 1L) { + cropped = ImageUtils.scale(cropped, scale, scale); + } + + Display display = getDisplay(); + int alpha = (!hasTransparency || !needsContrast) ? IMG_ALPHA : -1; + Image swtImage = SwtUtils.convertToSwt(display, cropped, true, alpha); + Rectangle imageBounds = new Rectangle(0, 0, width, height); + return Pair.of(swtImage, imageBounds); + } + } + } + + session.dispose(); + } + + return null; + } + + /** + * Utility method to print out the contents of the given XML document. This is + * really useful when working on the preview code above. I'm including all the + * code inside a constant false, which means the compiler will omit all the code, + * but I'd like to leave it in the code base and by doing it this way rather than + * as commented out code the code won't be accidentally broken. + */ + @SuppressWarnings("all") + private void dumpDocument(Document document) { + // Diagnostics: print out the XML that we're about to render + if (false) { // Will be omitted by the compiler + org.apache.xml.serialize.OutputFormat outputFormat = + new org.apache.xml.serialize.OutputFormat( + "XML", "ISO-8859-1", true); //$NON-NLS-1$ //$NON-NLS-2$ + outputFormat.setIndent(2); + outputFormat.setLineWidth(100); + outputFormat.setIndenting(true); + outputFormat.setOmitXMLDeclaration(true); + outputFormat.setOmitDocumentType(true); + StringWriter stringWriter = new StringWriter(); + // Using FQN here to avoid having an import above, which will result + // in a deprecation warning, and there isn't a way to annotate a single + // import element with a SuppressWarnings. + org.apache.xml.serialize.XMLSerializer serializer = + new org.apache.xml.serialize.XMLSerializer(stringWriter, outputFormat); + serializer.setNamespaces(true); + try { + serializer.serialize(document.getDocumentElement()); + System.out.println(stringWriter.toString()); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + /** Action for switching view modes via radio buttons */ + private class PaletteModeAction extends Action { + private final PaletteMode mMode; + + PaletteModeAction(PaletteMode mode) { + super(mode.getActionLabel(), IAction.AS_RADIO_BUTTON); + mMode = mode; + boolean selected = mMode == mPaletteMode; + setChecked(selected); + setEnabled(!selected); + } + + @Override + public void run() { + if (isEnabled()) { + mPaletteMode = mMode; + refreshPalette(); + savePaletteMode(); + } + } + } + + /** Action for toggling various checkbox view modes - categories, sorting, etc */ + private class ToggleViewOptionAction extends Action { + private final int mAction; + final static int TOGGLE_CATEGORY = 1; + final static int TOGGLE_ALPHABETICAL = 2; + final static int TOGGLE_AUTO_CLOSE = 3; + final static int REFRESH = 4; + final static int RESET = 5; + + ToggleViewOptionAction(String title, int action, boolean checked) { + super(title, (action == REFRESH || action == RESET) ? IAction.AS_PUSH_BUTTON + : IAction.AS_CHECK_BOX); + mAction = action; + if (checked) { + setChecked(checked); + } + } + + @Override + public void run() { + switch (mAction) { + case TOGGLE_CATEGORY: + mCategories = !mCategories; + refreshPalette(); + break; + case TOGGLE_ALPHABETICAL: + mAlphabetical = !mAlphabetical; + refreshPalette(); + break; + case TOGGLE_AUTO_CLOSE: + mAutoClose = !mAutoClose; + mAccordion.setAutoClose(mAutoClose); + break; + case REFRESH: + mPreviewIconFactory.refresh(); + refreshPalette(); + break; + case RESET: + mAlphabetical = false; + mCategories = true; + mAutoClose = true; + mPaletteMode = PaletteMode.SMALL_PREVIEW; + refreshPalette(); + break; + } + savePaletteMode(); + } + } + + private void addMenu(Control control) { + control.addMenuDetectListener(new MenuDetectListener() { + @Override + public void menuDetected(MenuDetectEvent e) { + showMenu(e.x, e.y); + } + }); + } + + private void showMenu(int x, int y) { + MenuManager manager = new MenuManager() { + @Override + public boolean isDynamic() { + return true; + } + }; + boolean previews = previewsAvailable(); + for (PaletteMode mode : PaletteMode.values()) { + if (mode.isPreview() && !previews) { + continue; + } + manager.add(new PaletteModeAction(mode)); + } + if (mPaletteMode.isPreview()) { + manager.add(new Separator()); + manager.add(new ToggleViewOptionAction("Refresh Previews", + ToggleViewOptionAction.REFRESH, + false)); + } + manager.add(new Separator()); + manager.add(new ToggleViewOptionAction("Show Categories", + ToggleViewOptionAction.TOGGLE_CATEGORY, + mCategories)); + manager.add(new ToggleViewOptionAction("Sort Alphabetically", + ToggleViewOptionAction.TOGGLE_ALPHABETICAL, + mAlphabetical)); + manager.add(new Separator()); + manager.add(new ToggleViewOptionAction("Auto Close Previous", + ToggleViewOptionAction.TOGGLE_AUTO_CLOSE, + mAutoClose)); + manager.add(new Separator()); + manager.add(new ToggleViewOptionAction("Reset", + ToggleViewOptionAction.RESET, + false)); + + Menu menu = manager.createContextMenu(PaletteControl.this); + menu.setLocation(x, y); + menu.setVisible(true); + } + + private final class ViewFinderListener implements CustomViewFinder.Listener { + private final Composite mParent; + + private ViewFinderListener(Composite parent) { + mParent = parent; + } + + @Override + public void viewsUpdated(Collection<String> customViews, + Collection<String> thirdPartyViews) { + addCustomItems(mParent); + mParent.layout(true); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PlayAnimationMenu.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PlayAnimationMenu.java new file mode 100644 index 000000000..629a42f18 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PlayAnimationMenu.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.FD_RESOURCES; +import static com.android.SdkConstants.FD_RES_ANIMATOR; +import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP; + +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.IAnimationListener; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.Result; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.wizards.newxmlfile.NewXmlFileWizard; +import com.android.resources.ResourceType; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.StructuredSelection; +import org.eclipse.jface.wizard.WizardDialog; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchWindow; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * "Play Animation" context menu which lists available animations in the project and in + * the framework, as well as a "Create Animation" shortcut, and allows the animation to be + * run on the selection + * <p/> + * TODO: Add transport controls for play/rewind/pause/loop, and (if possible) scrubbing + */ +public class PlayAnimationMenu extends SubmenuAction { + /** Associated canvas */ + private final LayoutCanvas mCanvas; + /** Whether this menu is showing local animations or framework animations */ + private boolean mFramework; + + /** + * Creates a "Play Animation" menu + * + * @param canvas associated canvas + */ + public PlayAnimationMenu(LayoutCanvas canvas) { + this(canvas, "Play Animation", false); + } + + /** + * Creates an animation menu; this can be used either for the outer Play animation + * menu, or the inner frameworks-animations list + * + * @param canvas the associated canvas + * @param title menu item name + * @param framework true to show the framework animations, false for the project (and + * nested framework-animation-menu) animations + */ + private PlayAnimationMenu(LayoutCanvas canvas, String title, boolean framework) { + super(title); + mCanvas = canvas; + mFramework = framework; + } + + @Override + protected void addMenuItems(Menu menu) { + SelectionManager selectionManager = mCanvas.getSelectionManager(); + List<SelectionItem> selection = selectionManager.getSelections(); + if (selection.size() != 1) { + addDisabledMessageItem("Select exactly one widget"); + return; + } + + GraphicalEditorPart graphicalEditor = mCanvas.getEditorDelegate().getGraphicalEditor(); + if (graphicalEditor.renderingSupports(Capability.PLAY_ANIMATION)) { + // List of animations + Collection<String> animationNames = graphicalEditor.getResourceNames(mFramework, + ResourceType.ANIMATOR); + if (animationNames.size() > 0) { + // Sort alphabetically + List<String> sortedNames = new ArrayList<String>(animationNames); + Collections.sort(sortedNames); + + for (String animation : sortedNames) { + String title = animation; + IAction action = new PlayAnimationAction(title, animation, mFramework); + new ActionContributionItem(action).fill(menu, -1); + } + + new Separator().fill(menu, -1); + } + + if (!mFramework) { + // Not in the framework submenu: include recent list and create new actions + + // "Create New" action + new ActionContributionItem(new CreateAnimationAction()).fill(menu, -1); + + // Framework resources submenu + new Separator().fill(menu, -1); + PlayAnimationMenu sub = new PlayAnimationMenu(mCanvas, "Android Builtin", true); + new ActionContributionItem(sub).fill(menu, -1); + } + } else { + addDisabledMessageItem( + "Not supported for this SDK version; try changing the Render Target"); + } + } + + private class PlayAnimationAction extends Action { + private final String mAnimationName; + private final boolean mIsFrameworkAnim; + + public PlayAnimationAction(String title, String animationName, boolean isFrameworkAnim) { + super(title, IAction.AS_PUSH_BUTTON); + mAnimationName = animationName; + mIsFrameworkAnim = isFrameworkAnim; + } + + @Override + public void run() { + SelectionManager selectionManager = mCanvas.getSelectionManager(); + List<SelectionItem> selection = selectionManager.getSelections(); + SelectionItem canvasSelection = selection.get(0); + CanvasViewInfo info = canvasSelection.getViewInfo(); + + Object viewObject = info.getViewObject(); + if (viewObject != null) { + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + RenderSession session = viewHierarchy.getSession(); + Result r = session.animate(viewObject, mAnimationName, mIsFrameworkAnim, + new IAnimationListener() { + private boolean mPendingDrawing = false; + + @Override + public void onNewFrame(RenderSession s) { + SelectionOverlay selectionOverlay = mCanvas.getSelectionOverlay(); + if (!selectionOverlay.isHiding()) { + selectionOverlay.setHiding(true); + } + HoverOverlay hoverOverlay = mCanvas.getHoverOverlay(); + if (!hoverOverlay.isHiding()) { + hoverOverlay.setHiding(true); + } + + ImageOverlay imageOverlay = mCanvas.getImageOverlay(); + imageOverlay.setImage(s.getImage(), s.isAlphaChannelImage()); + synchronized (this) { + if (mPendingDrawing == false) { + mCanvas.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + synchronized (this) { + mPendingDrawing = false; + } + mCanvas.redraw(); + } + }); + mPendingDrawing = true; + } + } + } + + @Override + public boolean isCanceled() { + return false; + } + + @Override + public void done(Result result) { + SelectionOverlay selectionOverlay = mCanvas.getSelectionOverlay(); + selectionOverlay.setHiding(false); + HoverOverlay hoverOverlay = mCanvas.getHoverOverlay(); + hoverOverlay.setHiding(false); + + // Must refresh view hierarchy to force objects back to + // their original positions in case animations have left + // them elsewhere + mCanvas.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + GraphicalEditorPart graphicalEditor = mCanvas + .getEditorDelegate().getGraphicalEditor(); + graphicalEditor.recomputeLayout(); + } + }); + } + }); + + if (!r.isSuccess()) { + if (r.getErrorMessage() != null) { + AdtPlugin.log(r.getException(), r.getErrorMessage()); + } + } + } + } + } + + /** + * Action which brings up the "Create new XML File" wizard, pre-selected with the + * animation category + */ + private class CreateAnimationAction extends Action { + public CreateAnimationAction() { + super("Create...", IAction.AS_PUSH_BUTTON); + } + + @Override + public void run() { + Shell parent = mCanvas.getShell(); + NewXmlFileWizard wizard = new NewXmlFileWizard(); + LayoutEditorDelegate editor = mCanvas.getEditorDelegate(); + IWorkbenchWindow workbenchWindow = + editor.getEditor().getEditorSite().getWorkbenchWindow(); + IWorkbench workbench = workbenchWindow.getWorkbench(); + String animationDir = FD_RESOURCES + WS_SEP + FD_RES_ANIMATOR; + Pair<IProject, String> pair = Pair.of(editor.getEditor().getProject(), animationDir); + IStructuredSelection selection = new StructuredSelection(pair); + wizard.init(workbench, selection); + WizardDialog dialog = new WizardDialog(parent, wizard); + dialog.create(); + dialog.open(); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PreviewIconFactory.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PreviewIconFactory.java new file mode 100644 index 000000000..5661b2919 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/PreviewIconFactory.java @@ -0,0 +1,642 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.DOT_PNG; +import static com.android.SdkConstants.FQCN_DATE_PICKER; +import static com.android.SdkConstants.FQCN_EXPANDABLE_LIST_VIEW; +import static com.android.SdkConstants.FQCN_LIST_VIEW; +import static com.android.SdkConstants.FQCN_TIME_PICKER; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.LayoutLibrary; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.SessionParams.RenderingMode; +import com.android.ide.common.rendering.api.StyleResourceValue; +import com.android.ide.common.rendering.api.ViewInfo; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.PaletteMetadataDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository.RenderMode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.sdklib.IAndroidTarget; +import com.android.utils.Pair; + +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.swt.graphics.RGB; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.awt.image.BufferedImage; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +import javax.imageio.ImageIO; + +/** + * Factory which can provide preview icons for android views of a particular SDK and + * editor's configuration chooser + */ +public class PreviewIconFactory { + private PaletteControl mPalette; + private RGB mBackground; + private RGB mForeground; + private File mImageDir; + + private static final String PREVIEW_INFO_FILE = "preview.properties"; //$NON-NLS-1$ + + public PreviewIconFactory(PaletteControl palette) { + mPalette = palette; + } + + /** + * Resets the state in the preview icon factory such that it will re-fetch information + * like the theme and SDK (the icons themselves are cached in a directory across IDE + * session though) + */ + public void reset() { + mImageDir = null; + mBackground = null; + mForeground = null; + } + + /** + * Deletes all the persistent state for the current settings such that it will be regenerated + */ + public void refresh() { + File imageDir = getImageDir(false); + if (imageDir != null && imageDir.exists()) { + File[] files = imageDir.listFiles(); + for (File file : files) { + file.delete(); + } + imageDir.delete(); + reset(); + } + } + + /** + * Returns an image descriptor for the given element descriptor, or null if no image + * could be computed. The rendering parameters (SDK, theme etc) correspond to those + * stored in the associated palette. + * + * @param desc the element descriptor to get an image for + * @return an image descriptor, or null if no image could be rendered + */ + public ImageDescriptor getImageDescriptor(ElementDescriptor desc) { + File imageDir = getImageDir(false); + if (!imageDir.exists()) { + render(); + } + File file = new File(imageDir, getFileName(desc)); + if (file.exists()) { + try { + return ImageDescriptor.createFromURL(file.toURI().toURL()); + } catch (MalformedURLException e) { + AdtPlugin.log(e, "Could not create image descriptor for %s", file); + } + } + + return null; + } + + /** + * Partition the elements in the document according to their rendering preferences; + * elements that should be skipped are removed, elements that should be rendered alone + * are placed in their own list, etc + * + * @param document the document containing render fragments for the various elements + * @return + */ + private List<List<Element>> partitionRenderElements(Document document) { + List<List<Element>> elements = new ArrayList<List<Element>>(); + + List<Element> shared = new ArrayList<Element>(); + Element root = document.getDocumentElement(); + elements.add(shared); + + ViewMetadataRepository repository = ViewMetadataRepository.get(); + + NodeList children = root.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) node; + String fqn = repository.getFullClassName(element); + assert fqn.length() > 0 : element.getNodeName(); + RenderMode renderMode = repository.getRenderMode(fqn); + + // Temporary special cases + if (fqn.equals(FQCN_LIST_VIEW) || fqn.equals(FQCN_EXPANDABLE_LIST_VIEW)) { + if (!mPalette.getEditor().renderingSupports(Capability.ADAPTER_BINDING)) { + renderMode = RenderMode.SKIP; + } + } else if (fqn.equals(FQCN_DATE_PICKER) || fqn.equals(FQCN_TIME_PICKER)) { + IAndroidTarget renderingTarget = mPalette.getEditor().getRenderingTarget(); + // In Honeycomb, these widgets only render properly in the Holo themes. + int apiLevel = renderingTarget.getVersion().getApiLevel(); + if (apiLevel == 11) { + String themeName = mPalette.getCurrentTheme(); + if (themeName == null || !themeName.startsWith("Theme.Holo")) { //$NON-NLS-1$ + // Note - it's possible that the the theme is some other theme + // such as a user theme which inherits from Theme.Holo and that + // the render -would- have worked, but it's harder to detect that + // scenario, so we err on the side of caution and just show an + // icon + name for the time widgets. + renderMode = RenderMode.SKIP; + } + } else if (apiLevel >= 12) { + // Currently broken, even for Holo. + renderMode = RenderMode.SKIP; + } // apiLevel <= 10 is fine + } + + if (renderMode == RenderMode.ALONE) { + elements.add(Collections.singletonList(element)); + } else if (renderMode == RenderMode.NORMAL) { + shared.add(element); + } else { + assert renderMode == RenderMode.SKIP; + } + } + } + + return elements; + } + + /** + * Renders ALL the widgets and then extracts image data for each view and saves it on + * disk + */ + private boolean render() { + File imageDir = getImageDir(true); + + GraphicalEditorPart editor = mPalette.getEditor(); + LayoutEditorDelegate layoutEditorDelegate = editor.getEditorDelegate(); + LayoutLibrary layoutLibrary = editor.getLayoutLibrary(); + Integer overrideBgColor = null; + if (layoutLibrary != null) { + if (layoutLibrary.supports(Capability.CUSTOM_BACKGROUND_COLOR)) { + Pair<RGB, RGB> themeColors = getColorsFromTheme(); + RGB bg = themeColors.getFirst(); + RGB fg = themeColors.getSecond(); + if (bg != null) { + storeBackground(imageDir, bg, fg); + overrideBgColor = Integer.valueOf(ImageUtils.rgbToInt(bg, 0xFF)); + } + } + } + + ViewMetadataRepository repository = ViewMetadataRepository.get(); + Document document = repository.getRenderingConfigDoc(); + + if (document == null) { + return false; + } + + // Construct UI model from XML + AndroidTargetData data = layoutEditorDelegate.getEditor().getTargetData(); + DocumentDescriptor documentDescriptor; + if (data == null) { + documentDescriptor = new DocumentDescriptor("temp", null/*children*/);//$NON-NLS-1$ + } else { + documentDescriptor = data.getLayoutDescriptors().getDescriptor(); + } + UiDocumentNode model = (UiDocumentNode) documentDescriptor.createUiNode(); + model.setEditor(layoutEditorDelegate.getEditor()); + model.setUnknownDescriptorProvider(editor.getModel().getUnknownDescriptorProvider()); + + Element documentElement = document.getDocumentElement(); + List<List<Element>> elements = partitionRenderElements(document); + for (List<Element> elementGroup : elements) { + // Replace the document elements with the current element group + while (documentElement.getFirstChild() != null) { + documentElement.removeChild(documentElement.getFirstChild()); + } + for (Element element : elementGroup) { + documentElement.appendChild(element); + } + + model.loadFromXmlNode(document); + + RenderSession session = null; + NodeList childNodes = documentElement.getChildNodes(); + try { + // Important to get these sizes large enough for clients that don't support + // RenderMode.FULL_EXPAND such as 1.6 + int width = 200; + int height = childNodes.getLength() == 1 ? 400 : 1600; + + session = RenderService.create(editor) + .setModel(model) + .setOverrideRenderSize(width, height) + .setRenderingMode(RenderingMode.FULL_EXPAND) + .setLog(editor.createRenderLogger("palette")) + .setOverrideBgColor(overrideBgColor) + .setDecorations(false) + .createRenderSession(); + } catch (Throwable t) { + // If there are internal errors previewing the components just revert to plain + // icons and labels + continue; + } + + if (session != null) { + if (session.getResult().isSuccess()) { + BufferedImage image = session.getImage(); + if (image != null && image.getWidth() > 0 && image.getHeight() > 0) { + + // Fallback for older platforms where we couldn't do background rendering + // at the beginning of this method + if (mBackground == null) { + Pair<RGB, RGB> themeColors = getColorsFromTheme(); + RGB bg = themeColors.getFirst(); + RGB fg = themeColors.getSecond(); + + if (bg == null) { + // Just use a pixel from the rendering instead. + int p = image.getRGB(image.getWidth() - 1, image.getHeight() - 1); + // However, in this case we don't trust the foreground color + // even if one was found in the themes; pick one that is guaranteed + // to contrast with the background + bg = ImageUtils.intToRgb(p); + if (ImageUtils.getBrightness(ImageUtils.rgbToInt(bg, 255)) < 128) { + fg = new RGB(255, 255, 255); + } else { + fg = new RGB(0, 0, 0); + } + } + storeBackground(imageDir, bg, fg); + assert mBackground != null; + } + + List<ViewInfo> viewInfoList = session.getRootViews(); + if (viewInfoList != null && viewInfoList.size() > 0) { + // We don't render previews under a <merge> so there should + // only be one root. + ViewInfo firstRoot = viewInfoList.get(0); + int parentX = firstRoot.getLeft(); + int parentY = firstRoot.getTop(); + List<ViewInfo> infos = firstRoot.getChildren(); + for (ViewInfo info : infos) { + Object cookie = info.getCookie(); + if (!(cookie instanceof UiElementNode)) { + continue; + } + UiElementNode node = (UiElementNode) cookie; + String fileName = getFileName(node); + File file = new File(imageDir, fileName); + if (file.exists()) { + // On Windows, perhaps we need to rename instead? + file.delete(); + } + int x1 = parentX + info.getLeft(); + int y1 = parentY + info.getTop(); + int x2 = parentX + info.getRight(); + int y2 = parentY + info.getBottom(); + if (x1 != x2 && y1 != y2) { + savePreview(file, image, x1, y1, x2, y2); + } + } + } + } + } else { + StringBuilder sb = new StringBuilder(); + for (int i = 0, n = childNodes.getLength(); i < n; i++) { + Node node = childNodes.item(i); + if (node instanceof Element) { + Element e = (Element) node; + String fqn = repository.getFullClassName(e); + fqn = fqn.substring(fqn.lastIndexOf('.') + 1); + if (sb.length() > 0) { + sb.append(", "); //$NON-NLS-1$ + } + sb.append(fqn); + } + } + AdtPlugin.log(IStatus.WARNING, "Failed to render set of icons for %1$s", + sb.toString()); + + if (session.getResult().getException() != null) { + AdtPlugin.log(session.getResult().getException(), + session.getResult().getErrorMessage()); + } else if (session.getResult().getErrorMessage() != null) { + AdtPlugin.log(IStatus.WARNING, session.getResult().getErrorMessage()); + } + } + + session.dispose(); + } + } + + mPalette.getEditor().recomputeLayout(); + + return true; + } + + /** + * Look up the background and foreground colors from the theme. May not find either + * the background or foreground or both, but will always return a pair of possibly + * null colors. + * + * @return a pair of possibly null color descriptions + */ + @NonNull + private Pair<RGB, RGB> getColorsFromTheme() { + RGB background = null; + RGB foreground = null; + + ResourceResolver resources = mPalette.getEditor().getResourceResolver(); + if (resources == null) { + return Pair.of(background, foreground); + } + StyleResourceValue theme = resources.getCurrentTheme(); + if (theme != null) { + background = resolveThemeColor(resources, "windowBackground"); //$NON-NLS-1$ + if (background == null) { + background = renderDrawableResource("windowBackground"); //$NON-NLS-1$ + // This causes some harm with some themes: We'll find a color, say black, + // that isn't actually rendered in the theme. Better to use null here, + // which will cause the caller to pick a pixel from the observed background + // instead. + //if (background == null) { + // background = resolveThemeColor(resources, "colorBackground"); //$NON-NLS-1$ + //} + } + foreground = resolveThemeColor(resources, "textColorPrimary"); //$NON-NLS-1$ + } + + // Ensure that the foreground color is suitably distinct from the background color + if (background != null) { + int bgRgb = ImageUtils.rgbToInt(background, 0xFF); + int backgroundBrightness = ImageUtils.getBrightness(bgRgb); + if (foreground == null) { + if (backgroundBrightness < 128) { + foreground = new RGB(255, 255, 255); + } else { + foreground = new RGB(0, 0, 0); + } + } else { + int fgRgb = ImageUtils.rgbToInt(foreground, 0xFF); + int foregroundBrightness = ImageUtils.getBrightness(fgRgb); + if (Math.abs(backgroundBrightness - foregroundBrightness) < 64) { + if (backgroundBrightness < 128) { + foreground = new RGB(255, 255, 255); + } else { + foreground = new RGB(0, 0, 0); + } + } + } + } + + return Pair.of(background, foreground); + } + + /** + * Renders the given resource which should refer to a drawable and returns a + * representative color value for the drawable (such as the color in the center) + * + * @param themeItemName the item in the theme to be looked up and rendered + * @return a color representing a typical color in the drawable + */ + private RGB renderDrawableResource(String themeItemName) { + GraphicalEditorPart editor = mPalette.getEditor(); + ResourceResolver resources = editor.getResourceResolver(); + ResourceValue resourceValue = resources.findItemInTheme(themeItemName); + BufferedImage image = RenderService.create(editor) + .setOverrideRenderSize(100, 100) + .renderDrawable(resourceValue); + if (image != null) { + // Use the middle pixel as the color since that works better for gradients; + // solid colors work too. + int rgb = image.getRGB(image.getWidth() / 2, image.getHeight() / 2); + return ImageUtils.intToRgb(rgb); + } + + return null; + } + + private static RGB resolveThemeColor(ResourceResolver resources, String resourceName) { + ResourceValue textColor = resources.findItemInTheme(resourceName); + return ResourceHelper.resolveColor(resources, textColor); + } + + private String getFileName(ElementDescriptor descriptor) { + if (descriptor instanceof PaletteMetadataDescriptor) { + PaletteMetadataDescriptor pmd = (PaletteMetadataDescriptor) descriptor; + StringBuilder sb = new StringBuilder(); + String name = pmd.getUiName(); + // Strip out whitespace, parentheses, etc. + for (int i = 0, n = name.length(); i < n; i++) { + char c = name.charAt(i); + if (Character.isLetter(c)) { + sb.append(c); + } + } + return sb.toString() + DOT_PNG; + } + return descriptor.getUiName() + DOT_PNG; + } + + private String getFileName(UiElementNode node) { + ViewMetadataRepository repository = ViewMetadataRepository.get(); + String fqn = repository.getFullClassName((Element) node.getXmlNode()); + return fqn.substring(fqn.lastIndexOf('.') + 1) + DOT_PNG; + } + + /** + * Cleans up a name by removing punctuation and whitespace etc to make + * it a better filename + * @param name the name to clean + * @return a cleaned up name + */ + @NonNull + private static String cleanup(@Nullable String name) { + if (name == null) { + return ""; + } + + // Extract just the characters (no whitespace, parentheses, punctuation etc) + // to ensure that the filename is pretty portable + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isJavaIdentifierPart(c)) { + sb.append(Character.toLowerCase(c)); + } + } + + return sb.toString(); + } + + /** Returns the location of a directory containing image previews (which may not exist) */ + private File getImageDir(boolean create) { + if (mImageDir == null) { + // Location for plugin-related state data + IPath pluginState = AdtPlugin.getDefault().getStateLocation(); + + // We have multiple directories - one for each combination of SDK, theme and device + // (and later, possibly other qualifiers). + // These are created -lazily-. + String targetName = mPalette.getCurrentTarget().hashString(); + String androidTargetNamePrefix = "android-"; + String themeNamePrefix = "Theme."; + if (targetName.startsWith(androidTargetNamePrefix)) { + targetName = targetName.substring(androidTargetNamePrefix.length()); + } + String themeName = mPalette.getCurrentTheme(); + if (themeName == null) { + themeName = "Theme"; //$NON-NLS-1$ + } + if (themeName.startsWith(themeNamePrefix)) { + themeName = themeName.substring(themeNamePrefix.length()); + } + targetName = cleanup(targetName); + themeName = cleanup(themeName); + String deviceName = cleanup(mPalette.getCurrentDevice()); + String dirName = String.format("palette-preview-r16b-%s-%s-%s", targetName, + themeName, deviceName); + IPath dirPath = pluginState.append(dirName); + + mImageDir = new File(dirPath.toOSString()); + } + + if (create && !mImageDir.exists()) { + mImageDir.mkdirs(); + } + + return mImageDir; + } + + private void savePreview(File output, BufferedImage image, + int left, int top, int right, int bottom) { + try { + BufferedImage im = ImageUtils.subImage(image, left, top, right, bottom); + ImageIO.write(im, "PNG", output); //$NON-NLS-1$ + } catch (IOException e) { + AdtPlugin.log(e, "Failed writing palette file"); + } + } + + private void storeBackground(File imageDir, RGB bg, RGB fg) { + mBackground = bg; + mForeground = fg; + File file = new File(imageDir, PREVIEW_INFO_FILE); + String colors = String.format( + "background=#%02x%02x%02x\nforeground=#%02x%02x%02x\n", //$NON-NLS-1$ + bg.red, bg.green, bg.blue, + fg.red, fg.green, fg.blue); + AdtPlugin.writeFile(file, colors); + } + + public RGB getBackgroundColor() { + if (mBackground == null) { + initColors(); + } + + return mBackground; + } + + public RGB getForegroundColor() { + if (mForeground == null) { + initColors(); + } + + return mForeground; + } + + public void initColors() { + try { + // Already initialized? Foreground can be null which would call + // initColors again and again, but background is never null after + // initialization so we use it as the have-initialized flag. + if (mBackground != null) { + return; + } + + File imageDir = getImageDir(false); + if (!imageDir.exists()) { + render(); + + // Initialized as part of the render + if (mBackground != null) { + return; + } + } + + File file = new File(imageDir, PREVIEW_INFO_FILE); + if (file.exists()) { + Properties properties = new Properties(); + InputStream is = null; + try { + is = new BufferedInputStream(new FileInputStream(file)); + properties.load(is); + } catch (IOException e) { + AdtPlugin.log(e, "Can't read preview properties"); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Nothing useful can be done. + } + } + } + + String colorString = (String) properties.get("background"); //$NON-NLS-1$ + if (colorString != null) { + int rgb = ImageUtils.getColor(colorString.trim()); + mBackground = ImageUtils.intToRgb(rgb); + } + colorString = (String) properties.get("foreground"); //$NON-NLS-1$ + if (colorString != null) { + int rgb = ImageUtils.getColor(colorString.trim()); + mForeground = ImageUtils.intToRgb(rgb); + } + } + + if (mBackground == null) { + mBackground = new RGB(0, 0, 0); + } + // mForeground is allowed to be null. + } catch (Throwable t) { + AdtPlugin.log(t, "Cannot initialize preview color settings"); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderLogger.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderLogger.java new file mode 100644 index 000000000..8548830bd --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderLogger.java @@ -0,0 +1,327 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.RenderSecurityManager; +import com.android.ide.common.rendering.api.LayoutLog; +import com.android.ide.eclipse.adt.AdtPlugin; + +import org.eclipse.core.runtime.IStatus; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A {@link LayoutLog} which records the problems it encounters and offers them as a + * single summary at the end + */ +public class RenderLogger extends LayoutLog { + static final String TAG_MISSING_DIMENSION = "missing.dimension"; //$NON-NLS-1$ + + private final String mName; + private List<String> mFidelityWarnings; + private List<String> mWarnings; + private List<String> mErrors; + private boolean mHaveExceptions; + private List<String> mTags; + private List<Throwable> mTraces; + private static Set<String> sIgnoredFidelityWarnings; + private final Object mCredential; + + /** Construct a logger for the given named layout */ + RenderLogger(String name, Object credential) { + mName = name; + mCredential = credential; + } + + /** + * Are there any logged errors or warnings during the render? + * + * @return true if there were problems during the render + */ + public boolean hasProblems() { + return mFidelityWarnings != null || mErrors != null || mWarnings != null || + mHaveExceptions; + } + + /** + * Returns a list of traces encountered during rendering, or null if none + * + * @return a list of traces encountered during rendering, or null if none + */ + @Nullable + public List<Throwable> getFirstTrace() { + return mTraces; + } + + /** + * Returns a (possibly multi-line) description of all the problems + * + * @param includeFidelityWarnings if true, include fidelity warnings in the problem + * summary + * @return a string describing the rendering problems + */ + @NonNull + public String getProblems(boolean includeFidelityWarnings) { + StringBuilder sb = new StringBuilder(); + + if (mErrors != null) { + for (String error : mErrors) { + sb.append(error).append('\n'); + } + } + + if (mWarnings != null) { + for (String warning : mWarnings) { + sb.append(warning).append('\n'); + } + } + + if (includeFidelityWarnings && mFidelityWarnings != null) { + sb.append("The graphics preview in the layout editor may not be accurate:\n"); + for (String warning : mFidelityWarnings) { + sb.append("* "); + sb.append(warning).append('\n'); + } + } + + if (mHaveExceptions) { + sb.append("Exception details are logged in Window > Show View > Error Log"); + } + + return sb.toString(); + } + + /** + * Returns the fidelity warnings + * + * @return the fidelity warnings + */ + @Nullable + public List<String> getFidelityWarnings() { + return mFidelityWarnings; + } + + // ---- extends LayoutLog ---- + + @Override + public void error(String tag, String message, Object data) { + String description = describe(message); + + appendToIdeLog(null, IStatus.ERROR, description); + + // Workaround: older layout libraries don't provide a tag for this error + if (tag == null && message != null + && message.startsWith("Failed to find style ")) { //$NON-NLS-1$ + tag = LayoutLog.TAG_RESOURCES_RESOLVE_THEME_ATTR; + } + + addError(tag, description); + } + + @Override + public void error(String tag, String message, Throwable throwable, Object data) { + String description = describe(message); + appendToIdeLog(throwable, IStatus.ERROR, description); + + if (throwable != null) { + if (throwable instanceof ClassNotFoundException) { + // The project callback is given a chance to resolve classes, + // and when it fails, it will record it in its own list which + // is displayed in a special way (with action hyperlinks etc). + // Therefore, include these messages in the visible render log, + // especially since the user message from a ClassNotFoundException + // is really not helpful (it just lists the class name without + // even mentioning that it is a class-not-found exception.) + return; + } + + if (description.equals(throwable.getLocalizedMessage()) || + description.equals(throwable.getMessage())) { + description = "Exception raised during rendering: " + description; + } + recordThrowable(throwable); + mHaveExceptions = true; + } + + addError(tag, description); + } + + /** + * Record that the given exception was encountered during rendering + * + * @param throwable the exception that was raised + */ + public void recordThrowable(@NonNull Throwable throwable) { + if (mTraces == null) { + mTraces = new ArrayList<Throwable>(); + } + mTraces.add(throwable); + } + + @Override + public void warning(String tag, String message, Object data) { + String description = describe(message); + + boolean log = true; + if (TAG_RESOURCES_FORMAT.equals(tag)) { + if (description.equals("You must supply a layout_width attribute.") //$NON-NLS-1$ + || description.equals("You must supply a layout_height attribute.")) {//$NON-NLS-1$ + tag = TAG_MISSING_DIMENSION; + log = false; + } + } + + if (log) { + appendToIdeLog(null, IStatus.WARNING, description); + } + + addWarning(tag, description); + } + + @Override + public void fidelityWarning(String tag, String message, Throwable throwable, Object data) { + if (sIgnoredFidelityWarnings != null && sIgnoredFidelityWarnings.contains(message)) { + return; + } + + String description = describe(message); + appendToIdeLog(throwable, IStatus.ERROR, description); + + if (throwable != null) { + mHaveExceptions = true; + } + + addFidelityWarning(tag, description); + } + + /** + * Ignore the given render fidelity warning for the current session + * + * @param message the message to be ignored for this session + */ + public static void ignoreFidelityWarning(String message) { + if (sIgnoredFidelityWarnings == null) { + sIgnoredFidelityWarnings = new HashSet<String>(); + } + sIgnoredFidelityWarnings.add(message); + } + + @NonNull + private String describe(@Nullable String message) { + if (message == null) { + return ""; + } else { + return message; + } + } + + private void addWarning(String tag, String description) { + if (mWarnings == null) { + mWarnings = new ArrayList<String>(); + } else if (mWarnings.contains(description)) { + // Avoid duplicates + return; + } + mWarnings.add(description); + addTag(tag); + } + + private void addError(String tag, String description) { + if (mErrors == null) { + mErrors = new ArrayList<String>(); + } else if (mErrors.contains(description)) { + // Avoid duplicates + return; + } + mErrors.add(description); + addTag(tag); + } + + private void addFidelityWarning(String tag, String description) { + if (mFidelityWarnings == null) { + mFidelityWarnings = new ArrayList<String>(); + } else if (mFidelityWarnings.contains(description)) { + // Avoid duplicates + return; + } + mFidelityWarnings.add(description); + addTag(tag); + } + + // ---- Tags ---- + + private void addTag(String tag) { + if (tag != null) { + if (mTags == null) { + mTags = new ArrayList<String>(); + } + mTags.add(tag); + } + } + + /** + * Returns true if the given tag prefix has been seen + * + * @param prefix the tag prefix to look for + * @return true iff any tags with the given prefix was seen during the render + */ + public boolean seenTagPrefix(String prefix) { + if (mTags != null) { + for (String tag : mTags) { + if (tag.startsWith(prefix)) { + return true; + } + } + } + + return false; + } + + /** + * Returns true if the given tag has been seen + * + * @param tag the tag to look for + * @return true iff the tag was seen during the render + */ + public boolean seenTag(String tag) { + if (mTags != null) { + return mTags.contains(tag); + } else { + return false; + } + } + + // Append the given message to the ADT log. Bypass the sandbox if necessary + // such that we can write to the log file. + private void appendToIdeLog(Throwable throwable, int severity, String description) { + boolean token = RenderSecurityManager.enterSafeRegion(mCredential); + try { + if (throwable != null) { + AdtPlugin.log(throwable, "%1$s: %2$s", mName, description); + } else { + AdtPlugin.log(severity, "%1$s: %2$s", mName, description); + } + } finally { + RenderSecurityManager.exitSafeRegion(token); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreview.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreview.java new file mode 100644 index 000000000..5621d5f17 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreview.java @@ -0,0 +1,1333 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.MASK_RENDERING; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SHADOW_SIZE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SMALL_SHADOW_SIZE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.DEFAULT; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.INCLUDES; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.Result; +import com.android.ide.common.rendering.api.Result.Status; +import com.android.ide.common.resources.ResourceFile; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.ScreenOrientationQualifier; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DocumentDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationClient; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Locale; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.NestedConfiguration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.VaryingConfiguration; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.ide.eclipse.adt.io.IFileWrapper; +import com.android.io.IAbstractFile; +import com.android.resources.Density; +import com.android.resources.ResourceType; +import com.android.resources.ScreenOrientation; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.Screen; +import com.android.sdklib.devices.State; +import com.android.utils.SdkUtils; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.jobs.IJobChangeEvent; +import org.eclipse.core.runtime.jobs.IJobChangeListener; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Region; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.progress.UIJob; +import org.w3c.dom.Document; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.lang.ref.SoftReference; +import java.util.Comparator; +import java.util.Map; + +/** + * Represents a preview rendering of a given configuration + */ +public class RenderPreview implements IJobChangeListener { + /** Whether previews should use large shadows */ + static final boolean LARGE_SHADOWS = false; + + /** + * Still doesn't work; get exceptions from layoutlib: + * java.lang.IllegalStateException: After scene creation, #init() must be called + * at com.android.layoutlib.bridge.impl.RenderAction.acquire(RenderAction.java:151) + * <p> + * TODO: Investigate. + */ + private static final boolean RENDER_ASYNC = false; + + /** + * Height of the toolbar shown over a preview during hover. Needs to be + * large enough to accommodate icons below. + */ + private static final int HEADER_HEIGHT = 20; + + /** Whether to dump out rendering failures of the previews to the log */ + private static final boolean DUMP_RENDER_DIAGNOSTICS = false; + + /** Extra error checking in debug mode */ + private static final boolean DEBUG = false; + + private static final Image EDIT_ICON; + private static final Image ZOOM_IN_ICON; + private static final Image ZOOM_OUT_ICON; + private static final Image CLOSE_ICON; + private static final int EDIT_ICON_WIDTH; + private static final int ZOOM_IN_ICON_WIDTH; + private static final int ZOOM_OUT_ICON_WIDTH; + private static final int CLOSE_ICON_WIDTH; + static { + ISharedImages sharedImages = PlatformUI.getWorkbench().getSharedImages(); + IconFactory icons = IconFactory.getInstance(); + CLOSE_ICON = sharedImages.getImage(ISharedImages.IMG_ETOOL_DELETE); + EDIT_ICON = icons.getIcon("editPreview"); //$NON-NLS-1$ + ZOOM_IN_ICON = icons.getIcon("zoomplus"); //$NON-NLS-1$ + ZOOM_OUT_ICON = icons.getIcon("zoomminus"); //$NON-NLS-1$ + CLOSE_ICON_WIDTH = CLOSE_ICON.getImageData().width; + EDIT_ICON_WIDTH = EDIT_ICON.getImageData().width; + ZOOM_IN_ICON_WIDTH = ZOOM_IN_ICON.getImageData().width; + ZOOM_OUT_ICON_WIDTH = ZOOM_OUT_ICON.getImageData().width; + } + + /** The configuration being previewed */ + private @NonNull Configuration mConfiguration; + + /** Configuration to use if we have an alternate input to be rendered */ + private @NonNull Configuration mAlternateConfiguration; + + /** The associated manager */ + private final @NonNull RenderPreviewManager mManager; + private final @NonNull LayoutCanvas mCanvas; + + private @NonNull SoftReference<ResourceResolver> mResourceResolver = + new SoftReference<ResourceResolver>(null); + private @Nullable Job mJob; + private @Nullable Image mThumbnail; + private @Nullable String mDisplayName; + private int mWidth; + private int mHeight; + private int mX; + private int mY; + private int mTitleHeight; + private double mScale = 1.0; + private double mAspectRatio; + + /** If non null, points to a separate file containing the source */ + private @Nullable IFile mAlternateInput; + + /** If included within another layout, the name of that outer layout */ + private @Nullable Reference mIncludedWithin; + + /** Whether the mouse is actively hovering over this preview */ + private boolean mActive; + + /** + * Whether this preview cannot be rendered because of a model error - such + * as an invalid configuration, a missing resource, an error in the XML + * markup, etc. If non null, contains the error message (or a blank string + * if not known), and null if the render was successful. + */ + private String mError; + + /** Whether in the current layout, this preview is visible */ + private boolean mVisible; + + /** Whether the configuration has changed and needs to be refreshed the next time + * this preview made visible. This corresponds to the change flags in + * {@link ConfigurationClient}. */ + private int mDirty; + + /** + * Creates a new {@linkplain RenderPreview} + * + * @param manager the manager + * @param canvas canvas where preview is painted + * @param configuration the associated configuration + * @param width the initial width to use for the preview + * @param height the initial height to use for the preview + */ + private RenderPreview( + @NonNull RenderPreviewManager manager, + @NonNull LayoutCanvas canvas, + @NonNull Configuration configuration) { + mManager = manager; + mCanvas = canvas; + mConfiguration = configuration; + updateSize(); + + // Should only attempt to create configurations for fully configured devices + assert mConfiguration.getDevice() != null + && mConfiguration.getDeviceState() != null + && mConfiguration.getLocale() != null + && mConfiguration.getTarget() != null + && mConfiguration.getTheme() != null + && mConfiguration.getFullConfig() != null + && mConfiguration.getFullConfig().getScreenSizeQualifier() != null : + mConfiguration; + } + + /** + * Sets the configuration to use for this preview + * + * @param configuration the new configuration + */ + public void setConfiguration(@NonNull Configuration configuration) { + mConfiguration = configuration; + } + + /** + * Gets the scale being applied to the thumbnail + * + * @return the scale being applied to the thumbnail + */ + public double getScale() { + return mScale; + } + + /** + * Sets the scale to apply to the thumbnail + * + * @param scale the factor to scale the thumbnail picture by + */ + public void setScale(double scale) { + disposeThumbnail(); + mScale = scale; + } + + /** + * Returns the aspect ratio of this render preview + * + * @return the aspect ratio + */ + public double getAspectRatio() { + return mAspectRatio; + } + + /** + * Returns whether the preview is actively hovered + * + * @return whether the mouse is hovering over the preview + */ + public boolean isActive() { + return mActive; + } + + /** + * Sets whether the preview is actively hovered + * + * @param active if the mouse is hovering over the preview + */ + public void setActive(boolean active) { + mActive = active; + } + + /** + * Returns whether the preview is visible. Previews that are off + * screen are typically marked invisible during layout, which means we don't + * have to expend effort computing preview thumbnails etc + * + * @return true if the preview is visible + */ + public boolean isVisible() { + return mVisible; + } + + /** + * Returns whether this preview represents a forked layout + * + * @return true if this preview represents a separate file + */ + public boolean isForked() { + return mAlternateInput != null || mIncludedWithin != null; + } + + /** + * Returns the file to be used for this preview, or null if this is not a + * forked layout meaning that the file is the one used in the chooser + * + * @return the file or null for non-forked layouts + */ + @Nullable + public IFile getAlternateInput() { + if (mAlternateInput != null) { + return mAlternateInput; + } else if (mIncludedWithin != null) { + return mIncludedWithin.getFile(); + } + + return null; + } + + /** + * Returns the area of this render preview, PRIOR to scaling + * + * @return the area (width times height without scaling) + */ + int getArea() { + return mWidth * mHeight; + } + + /** + * Sets whether the preview is visible. Previews that are off + * screen are typically marked invisible during layout, which means we don't + * have to expend effort computing preview thumbnails etc + * + * @param visible whether this preview is visible + */ + public void setVisible(boolean visible) { + if (visible != mVisible) { + mVisible = visible; + if (mVisible) { + if (mDirty != 0) { + // Just made the render preview visible: + configurationChanged(mDirty); // schedules render + } else { + updateForkStatus(); + mManager.scheduleRender(this); + } + } else { + dispose(); + } + } + } + + /** + * Sets the layout position relative to the top left corner of the preview + * area, in control coordinates + */ + void setPosition(int x, int y) { + mX = x; + mY = y; + } + + /** + * Gets the layout X position relative to the top left corner of the preview + * area, in control coordinates + */ + int getX() { + return mX; + } + + /** + * Gets the layout Y position relative to the top left corner of the preview + * area, in control coordinates + */ + int getY() { + return mY; + } + + /** Determine whether this configuration has a better match in a different layout file */ + private void updateForkStatus() { + ConfigurationChooser chooser = mManager.getChooser(); + FolderConfiguration config = mConfiguration.getFullConfig(); + if (mAlternateInput != null && chooser.isBestMatchFor(mAlternateInput, config)) { + return; + } + + mAlternateInput = null; + IFile editedFile = chooser.getEditedFile(); + if (editedFile != null) { + if (!chooser.isBestMatchFor(editedFile, config)) { + ProjectResources resources = chooser.getResources(); + if (resources != null) { + ResourceFile best = resources.getMatchingFile(editedFile.getName(), + ResourceType.LAYOUT, config); + if (best != null) { + IAbstractFile file = best.getFile(); + if (file instanceof IFileWrapper) { + mAlternateInput = ((IFileWrapper) file).getIFile(); + } else if (file instanceof File) { + mAlternateInput = AdtUtils.fileToIFile(((File) file)); + } + } + } + if (mAlternateInput != null) { + mAlternateConfiguration = Configuration.create(mConfiguration, + mAlternateInput); + } + } + } + } + + /** + * Creates a new {@linkplain RenderPreview} + * + * @param manager the manager + * @param configuration the associated configuration + * @return a new configuration + */ + @NonNull + public static RenderPreview create( + @NonNull RenderPreviewManager manager, + @NonNull Configuration configuration) { + LayoutCanvas canvas = manager.getCanvas(); + return new RenderPreview(manager, canvas, configuration); + } + + /** + * Throws away this preview: cancels any pending rendering jobs and disposes + * of image resources etc + */ + public void dispose() { + disposeThumbnail(); + + if (mJob != null) { + mJob.cancel(); + mJob = null; + } + } + + /** Disposes the thumbnail rendering. */ + void disposeThumbnail() { + if (mThumbnail != null) { + mThumbnail.dispose(); + mThumbnail = null; + } + } + + /** + * Returns the display name of this preview + * + * @return the name of the preview + */ + @NonNull + public String getDisplayName() { + if (mDisplayName == null) { + String displayName = getConfiguration().getDisplayName(); + if (displayName == null) { + // No display name: this must be the configuration used by default + // for the view which is originally displayed (before adding thumbnails), + // and you've switched away to something else; now we need to display a name + // for this original configuration. For now, just call it "Original" + return "Original"; + } + + return displayName; + } + + return mDisplayName; + } + + /** + * Sets the display name of this preview. By default, the display name is + * the display name of the configuration, but it can be overridden by calling + * this setter (which only sets the preview name, without editing the configuration.) + * + * @param displayName the new display name + */ + public void setDisplayName(@NonNull String displayName) { + mDisplayName = displayName; + } + + /** + * Sets an inclusion context to use for this layout, if any. This will render + * the configuration preview as the outer layout with the current layout + * embedded within. + * + * @param includedWithin a reference to a layout which includes this one + */ + public void setIncludedWithin(Reference includedWithin) { + mIncludedWithin = includedWithin; + } + + /** + * Request a new render after the given delay + * + * @param delay the delay to wait before starting the render job + */ + public void render(long delay) { + Job job = mJob; + if (job != null) { + job.cancel(); + } + if (RENDER_ASYNC) { + job = new AsyncRenderJob(); + } else { + job = new RenderJob(); + } + job.schedule(delay); + job.addJobChangeListener(this); + mJob = job; + } + + /** Render immediately */ + private void renderSync() { + GraphicalEditorPart editor = mCanvas.getEditorDelegate().getGraphicalEditor(); + if (editor.getReadyLayoutLib(false /*displayError*/) == null) { + // Don't attempt to render when there is no ready layout library: most likely + // the targets are loading/reloading. + return; + } + + disposeThumbnail(); + + Configuration configuration = + mAlternateInput != null && mAlternateConfiguration != null + ? mAlternateConfiguration : mConfiguration; + ResourceResolver resolver = getResourceResolver(configuration); + RenderService renderService = RenderService.create(editor, configuration, resolver); + + if (mIncludedWithin != null) { + renderService.setIncludedWithin(mIncludedWithin); + } + + if (mAlternateInput != null) { + IAndroidTarget target = editor.getRenderingTarget(); + AndroidTargetData data = null; + if (target != null) { + Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + data = sdk.getTargetData(target); + } + } + + // Construct UI model from XML + DocumentDescriptor documentDescriptor; + if (data == null) { + documentDescriptor = new DocumentDescriptor("temp", null);//$NON-NLS-1$ + } else { + documentDescriptor = data.getLayoutDescriptors().getDescriptor(); + } + UiDocumentNode model = (UiDocumentNode) documentDescriptor.createUiNode(); + model.setEditor(mCanvas.getEditorDelegate().getEditor()); + model.setUnknownDescriptorProvider(editor.getModel().getUnknownDescriptorProvider()); + + Document document = DomUtilities.getDocument(mAlternateInput); + if (document == null) { + mError = "No document"; + createErrorThumbnail(); + return; + } + model.loadFromXmlNode(document); + renderService.setModel(model); + } else { + renderService.setModel(editor.getModel()); + } + RenderLogger log = editor.createRenderLogger(getDisplayName()); + renderService.setLog(log); + RenderSession session = renderService.createRenderSession(); + Result render = session.render(1000); + + if (DUMP_RENDER_DIAGNOSTICS) { + if (log.hasProblems() || !render.isSuccess()) { + AdtPlugin.log(IStatus.ERROR, "Found problems rendering preview " + + getDisplayName() + ": " + + render.getErrorMessage() + " : " + + log.getProblems(false)); + Throwable exception = render.getException(); + if (exception != null) { + AdtPlugin.log(exception, "Failure rendering preview " + getDisplayName()); + } + } + } + + if (render.isSuccess()) { + mError = null; + } else { + mError = render.getErrorMessage(); + if (mError == null) { + mError = ""; + } + } + + if (render.getStatus() == Status.ERROR_TIMEOUT) { + // TODO: Special handling? schedule update again later + return; + } + if (render.isSuccess()) { + BufferedImage image = session.getImage(); + if (image != null) { + createThumbnail(image); + } + } + + if (mError != null) { + createErrorThumbnail(); + } + } + + private ResourceResolver getResourceResolver(Configuration configuration) { + ResourceResolver resourceResolver = mResourceResolver.get(); + if (resourceResolver != null) { + return resourceResolver; + } + + GraphicalEditorPart graphicalEditor = mCanvas.getEditorDelegate().getGraphicalEditor(); + String theme = configuration.getTheme(); + if (theme == null) { + return null; + } + + Map<ResourceType, Map<String, ResourceValue>> configuredFrameworkRes = null; + Map<ResourceType, Map<String, ResourceValue>> configuredProjectRes = null; + + FolderConfiguration config = configuration.getFullConfig(); + IAndroidTarget target = graphicalEditor.getRenderingTarget(); + ResourceRepository frameworkRes = null; + if (target != null) { + Sdk sdk = Sdk.getCurrent(); + if (sdk == null) { + return null; + } + AndroidTargetData data = sdk.getTargetData(target); + + if (data != null) { + // TODO: SHARE if possible + frameworkRes = data.getFrameworkResources(); + configuredFrameworkRes = frameworkRes.getConfiguredResources(config); + } else { + return null; + } + } else { + return null; + } + assert configuredFrameworkRes != null; + + + // get the resources of the file's project. + ProjectResources projectRes = ResourceManager.getInstance().getProjectResources( + graphicalEditor.getProject()); + configuredProjectRes = projectRes.getConfiguredResources(config); + + if (!theme.startsWith(PREFIX_RESOURCE_REF)) { + if (frameworkRes.hasResourceItem(ANDROID_STYLE_RESOURCE_PREFIX + theme)) { + theme = ANDROID_STYLE_RESOURCE_PREFIX + theme; + } else { + theme = STYLE_RESOURCE_PREFIX + theme; + } + } + + resourceResolver = ResourceResolver.create( + configuredProjectRes, configuredFrameworkRes, + ResourceHelper.styleToTheme(theme), + ResourceHelper.isProjectStyle(theme)); + mResourceResolver = new SoftReference<ResourceResolver>(resourceResolver); + return resourceResolver; + } + + /** + * Sets the new image of the preview and generates a thumbnail + * + * @param image the full size image + */ + void createThumbnail(BufferedImage image) { + if (image == null) { + mThumbnail = null; + return; + } + + ImageOverlay imageOverlay = mCanvas.getImageOverlay(); + boolean drawShadows = imageOverlay == null || imageOverlay.getShowDropShadow(); + double scale = getWidth() / (double) image.getWidth(); + int shadowSize; + if (LARGE_SHADOWS) { + shadowSize = drawShadows ? SHADOW_SIZE : 0; + } else { + shadowSize = drawShadows ? SMALL_SHADOW_SIZE : 0; + } + if (scale < 1.0) { + if (LARGE_SHADOWS) { + image = ImageUtils.scale(image, scale, scale, + shadowSize, shadowSize); + if (drawShadows) { + ImageUtils.drawRectangleShadow(image, 0, 0, + image.getWidth() - shadowSize, + image.getHeight() - shadowSize); + } + } else { + image = ImageUtils.scale(image, scale, scale, + shadowSize, shadowSize); + if (drawShadows) { + ImageUtils.drawSmallRectangleShadow(image, 0, 0, + image.getWidth() - shadowSize, + image.getHeight() - shadowSize); + } + } + } + + mThumbnail = SwtUtils.convertToSwt(mCanvas.getDisplay(), image, + true /* transferAlpha */, -1); + } + + void createErrorThumbnail() { + int shadowSize = LARGE_SHADOWS ? SHADOW_SIZE : SMALL_SHADOW_SIZE; + int width = getWidth(); + int height = getHeight(); + BufferedImage image = new BufferedImage(width + shadowSize, height + shadowSize, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D g = image.createGraphics(); + g.setColor(new java.awt.Color(0xfffbfcc6)); + g.fillRect(0, 0, width, height); + + g.dispose(); + + ImageOverlay imageOverlay = mCanvas.getImageOverlay(); + boolean drawShadows = imageOverlay == null || imageOverlay.getShowDropShadow(); + if (drawShadows) { + if (LARGE_SHADOWS) { + ImageUtils.drawRectangleShadow(image, 0, 0, + image.getWidth() - SHADOW_SIZE, + image.getHeight() - SHADOW_SIZE); + } else { + ImageUtils.drawSmallRectangleShadow(image, 0, 0, + image.getWidth() - SMALL_SHADOW_SIZE, + image.getHeight() - SMALL_SHADOW_SIZE); + } + } + + mThumbnail = SwtUtils.convertToSwt(mCanvas.getDisplay(), image, + true /* transferAlpha */, -1); + } + + private static double getScale(int width, int height) { + int maxWidth = RenderPreviewManager.getMaxWidth(); + int maxHeight = RenderPreviewManager.getMaxHeight(); + if (width > 0 && height > 0 + && (width > maxWidth || height > maxHeight)) { + if (width >= height) { // landscape + return maxWidth / (double) width; + } else { // portrait + return maxHeight / (double) height; + } + } + + return 1.0; + } + + /** + * Returns the width of the preview, in pixels + * + * @return the width in pixels + */ + public int getWidth() { + return (int) (mWidth * mScale * RenderPreviewManager.getScale()); + } + + /** + * Returns the height of the preview, in pixels + * + * @return the height in pixels + */ + public int getHeight() { + return (int) (mHeight * mScale * RenderPreviewManager.getScale()); + } + + /** + * Handles clicks within the preview (x and y are positions relative within the + * preview + * + * @param x the x coordinate within the preview where the click occurred + * @param y the y coordinate within the preview where the click occurred + * @return true if this preview handled (and therefore consumed) the click + */ + public boolean click(int x, int y) { + if (y >= mTitleHeight && y < mTitleHeight + HEADER_HEIGHT) { + int left = 0; + left += CLOSE_ICON_WIDTH; + if (x <= left) { + // Delete + mManager.deletePreview(this); + return true; + } + left += ZOOM_IN_ICON_WIDTH; + if (x <= left) { + // Zoom in + mScale = mScale * (1 / 0.5); + if (Math.abs(mScale-1.0) < 0.0001) { + mScale = 1.0; + } + + render(0); + mManager.layout(true); + mCanvas.redraw(); + return true; + } + left += ZOOM_OUT_ICON_WIDTH; + if (x <= left) { + // Zoom out + mScale = mScale * (0.5 / 1); + if (Math.abs(mScale-1.0) < 0.0001) { + mScale = 1.0; + } + render(0); + + mManager.layout(true); + mCanvas.redraw(); + return true; + } + left += EDIT_ICON_WIDTH; + if (x <= left) { + // Edit. For now, just rename + InputDialog d = new InputDialog( + AdtPlugin.getShell(), + "Rename Preview", // title + "Name:", + getDisplayName(), + null); + if (d.open() == Window.OK) { + String newName = d.getValue(); + mConfiguration.setDisplayName(newName); + if (mDescription != null) { + mManager.rename(mDescription, newName); + } + mCanvas.redraw(); + } + + return true; + } + + // Clicked anywhere else on header + // Perhaps open Edit dialog here? + } + + mManager.switchTo(this); + return true; + } + + /** + * Paints the preview at the given x/y position + * + * @param gc the graphics context to paint it into + * @param x the x coordinate to paint the preview at + * @param y the y coordinate to paint the preview at + */ + void paint(GC gc, int x, int y) { + mTitleHeight = paintTitle(gc, x, y, true /*showFile*/); + y += mTitleHeight; + y += 2; + + int width = getWidth(); + int height = getHeight(); + if (mThumbnail != null && mError == null) { + gc.drawImage(mThumbnail, x, y); + + if (mActive) { + int oldWidth = gc.getLineWidth(); + gc.setLineWidth(3); + gc.setForeground(gc.getDevice().getSystemColor(SWT.COLOR_LIST_SELECTION)); + gc.drawRectangle(x - 1, y - 1, width + 2, height + 2); + gc.setLineWidth(oldWidth); + } + } else if (mError != null) { + if (mThumbnail != null) { + gc.drawImage(mThumbnail, x, y); + } else { + gc.setBackground(gc.getDevice().getSystemColor(SWT.COLOR_WIDGET_BORDER)); + gc.drawRectangle(x, y, width, height); + } + + gc.setClipping(x, y, width, height); + Image icon = IconFactory.getInstance().getIcon("renderError"); //$NON-NLS-1$ + ImageData data = icon.getImageData(); + int prevAlpha = gc.getAlpha(); + int alpha = 96; + if (mThumbnail != null) { + alpha -= 32; + } + gc.setAlpha(alpha); + gc.drawImage(icon, x + (width - data.width) / 2, y + (height - data.height) / 2); + + String msg = mError; + Density density = mConfiguration.getDensity(); + if (density == Density.TV || density == Density.LOW) { + msg = "Broken rendering library; unsupported DPI. Try using the SDK manager " + + "to get updated layout libraries."; + } + int charWidth = gc.getFontMetrics().getAverageCharWidth(); + int charsPerLine = (width - 10) / charWidth; + msg = SdkUtils.wrap(msg, charsPerLine, null); + gc.setAlpha(255); + gc.setForeground(gc.getDevice().getSystemColor(SWT.COLOR_BLACK)); + gc.drawText(msg, x + 5, y + HEADER_HEIGHT, true); + gc.setAlpha(prevAlpha); + gc.setClipping((Region) null); + } else { + gc.setBackground(gc.getDevice().getSystemColor(SWT.COLOR_WIDGET_BORDER)); + gc.drawRectangle(x, y, width, height); + + Image icon = IconFactory.getInstance().getIcon("refreshPreview"); //$NON-NLS-1$ + ImageData data = icon.getImageData(); + int prevAlpha = gc.getAlpha(); + gc.setAlpha(96); + gc.drawImage(icon, x + (width - data.width) / 2, + y + (height - data.height) / 2); + gc.setAlpha(prevAlpha); + } + + if (mActive) { + int left = x ; + int prevAlpha = gc.getAlpha(); + gc.setAlpha(208); + Color bg = mCanvas.getDisplay().getSystemColor(SWT.COLOR_WHITE); + gc.setBackground(bg); + gc.fillRectangle(left, y, x + width - left, HEADER_HEIGHT); + gc.setAlpha(prevAlpha); + + y += 2; + + // Paint icons + gc.drawImage(CLOSE_ICON, left, y); + left += CLOSE_ICON_WIDTH; + + gc.drawImage(ZOOM_IN_ICON, left, y); + left += ZOOM_IN_ICON_WIDTH; + + gc.drawImage(ZOOM_OUT_ICON, left, y); + left += ZOOM_OUT_ICON_WIDTH; + + gc.drawImage(EDIT_ICON, left, y); + left += EDIT_ICON_WIDTH; + } + } + + /** + * Paints the preview title at the given position (and returns the required + * height) + * + * @param gc the graphics context to paint into + * @param x the left edge of the preview rectangle + * @param y the top edge of the preview rectangle + */ + private int paintTitle(GC gc, int x, int y, boolean showFile) { + String displayName = getDisplayName(); + return paintTitle(gc, x, y, showFile, displayName); + } + + /** + * Paints the preview title at the given position (and returns the required + * height) + * + * @param gc the graphics context to paint into + * @param x the left edge of the preview rectangle + * @param y the top edge of the preview rectangle + * @param displayName the title string to be used + */ + int paintTitle(GC gc, int x, int y, boolean showFile, String displayName) { + int titleHeight = 0; + + if (showFile && mIncludedWithin != null) { + if (mManager.getMode() != INCLUDES) { + displayName = "<include>"; + } else { + // Skip: just paint footer instead + displayName = null; + } + } + + int width = getWidth(); + int labelTop = y + 1; + gc.setClipping(x, labelTop, width, 100); + + // Use font height rather than extent height since we want two adjacent + // previews (which may have different display names and therefore end + // up with slightly different extent heights) to have identical title + // heights such that they are aligned identically + int fontHeight = gc.getFontMetrics().getHeight(); + + if (displayName != null && displayName.length() > 0) { + gc.setForeground(gc.getDevice().getSystemColor(SWT.COLOR_WHITE)); + Point extent = gc.textExtent(displayName); + int labelLeft = Math.max(x, x + (width - extent.x) / 2); + Image icon = null; + Locale locale = mConfiguration.getLocale(); + if (locale != null && (locale.hasLanguage() || locale.hasRegion()) + && (!(mConfiguration instanceof NestedConfiguration) + || ((NestedConfiguration) mConfiguration).isOverridingLocale())) { + icon = locale.getFlagImage(); + } + + if (icon != null) { + int flagWidth = icon.getImageData().width; + int flagHeight = icon.getImageData().height; + labelLeft = Math.max(x + flagWidth / 2, labelLeft); + gc.drawImage(icon, labelLeft - flagWidth / 2 - 1, labelTop); + labelLeft += flagWidth / 2 + 1; + gc.drawText(displayName, labelLeft, + labelTop - (extent.y - flagHeight) / 2, true); + } else { + gc.drawText(displayName, labelLeft, labelTop, true); + } + + labelTop += extent.y; + titleHeight += fontHeight; + } + + if (showFile && (mAlternateInput != null || mIncludedWithin != null)) { + // Draw file flag, and parent folder name + IFile file = mAlternateInput != null + ? mAlternateInput : mIncludedWithin.getFile(); + String fileName = file.getParent().getName() + File.separator + + file.getName(); + Point extent = gc.textExtent(fileName); + Image icon = IconFactory.getInstance().getIcon("android_file"); //$NON-NLS-1$ + int flagWidth = icon.getImageData().width; + int flagHeight = icon.getImageData().height; + + int labelLeft = Math.max(x, x + (width - extent.x - flagWidth - 1) / 2); + + gc.drawImage(icon, labelLeft, labelTop); + + gc.setForeground(gc.getDevice().getSystemColor(SWT.COLOR_GRAY)); + labelLeft += flagWidth + 1; + labelTop -= (extent.y - flagHeight) / 2; + gc.drawText(fileName, labelLeft, labelTop, true); + + titleHeight += Math.max(titleHeight, icon.getImageData().height); + } + + gc.setClipping((Region) null); + + return titleHeight; + } + + /** + * Notifies that the preview's configuration has changed. + * + * @param flags the change flags, a bitmask corresponding to the + * {@code CHANGE_} constants in {@link ConfigurationClient} + */ + public void configurationChanged(int flags) { + if (!mVisible) { + mDirty |= flags; + return; + } + + if ((flags & MASK_RENDERING) != 0) { + mResourceResolver.clear(); + // Handle inheritance + mConfiguration.syncFolderConfig(); + updateForkStatus(); + updateSize(); + } + + // Sanity check to make sure things are working correctly + if (DEBUG) { + RenderPreviewMode mode = mManager.getMode(); + if (mode == DEFAULT) { + assert mConfiguration instanceof VaryingConfiguration; + VaryingConfiguration config = (VaryingConfiguration) mConfiguration; + int alternateFlags = config.getAlternateFlags(); + switch (alternateFlags) { + case Configuration.CFG_DEVICE_STATE: { + State configState = config.getDeviceState(); + State chooserState = mManager.getChooser().getConfiguration() + .getDeviceState(); + assert configState != null && chooserState != null; + assert !configState.getName().equals(chooserState.getName()) + : configState.toString() + ':' + chooserState; + + Device configDevice = config.getDevice(); + Device chooserDevice = mManager.getChooser().getConfiguration() + .getDevice(); + assert configDevice != null && chooserDevice != null; + assert configDevice == chooserDevice + : configDevice.toString() + ':' + chooserDevice; + + break; + } + case Configuration.CFG_DEVICE: { + Device configDevice = config.getDevice(); + Device chooserDevice = mManager.getChooser().getConfiguration() + .getDevice(); + assert configDevice != null && chooserDevice != null; + assert configDevice != chooserDevice + : configDevice.toString() + ':' + chooserDevice; + + State configState = config.getDeviceState(); + State chooserState = mManager.getChooser().getConfiguration() + .getDeviceState(); + assert configState != null && chooserState != null; + assert configState.getName().equals(chooserState.getName()) + : configState.toString() + ':' + chooserState; + + break; + } + case Configuration.CFG_LOCALE: { + Locale configLocale = config.getLocale(); + Locale chooserLocale = mManager.getChooser().getConfiguration() + .getLocale(); + assert configLocale != null && chooserLocale != null; + assert configLocale != chooserLocale + : configLocale.toString() + ':' + chooserLocale; + break; + } + default: { + // Some other type of override I didn't anticipate + assert false : alternateFlags; + } + } + } + } + + mDirty = 0; + mManager.scheduleRender(this); + } + + private void updateSize() { + Device device = mConfiguration.getDevice(); + if (device == null) { + return; + } + Screen screen = device.getDefaultHardware().getScreen(); + if (screen == null) { + return; + } + + FolderConfiguration folderConfig = mConfiguration.getFullConfig(); + ScreenOrientationQualifier qualifier = folderConfig.getScreenOrientationQualifier(); + ScreenOrientation orientation = qualifier == null + ? ScreenOrientation.PORTRAIT : qualifier.getValue(); + + // compute width and height to take orientation into account. + int x = screen.getXDimension(); + int y = screen.getYDimension(); + int screenWidth, screenHeight; + + if (x > y) { + if (orientation == ScreenOrientation.LANDSCAPE) { + screenWidth = x; + screenHeight = y; + } else { + screenWidth = y; + screenHeight = x; + } + } else { + if (orientation == ScreenOrientation.LANDSCAPE) { + screenWidth = y; + screenHeight = x; + } else { + screenWidth = x; + screenHeight = y; + } + } + + int width = RenderPreviewManager.getMaxWidth(); + int height = RenderPreviewManager.getMaxHeight(); + if (screenWidth > 0) { + double scale = getScale(screenWidth, screenHeight); + width = (int) (screenWidth * scale); + height = (int) (screenHeight * scale); + } + + if (width != mWidth || height != mHeight) { + mWidth = width; + mHeight = height; + + Image thumbnail = mThumbnail; + mThumbnail = null; + if (thumbnail != null) { + thumbnail.dispose(); + } + if (mHeight != 0) { + mAspectRatio = mWidth / (double) mHeight; + } + } + } + + /** + * Returns the configuration associated with this preview + * + * @return the configuration + */ + @NonNull + public Configuration getConfiguration() { + return mConfiguration; + } + + // ---- Implements IJobChangeListener ---- + + @Override + public void aboutToRun(IJobChangeEvent event) { + } + + @Override + public void awake(IJobChangeEvent event) { + } + + @Override + public void done(IJobChangeEvent event) { + mJob = null; + } + + @Override + public void running(IJobChangeEvent event) { + } + + @Override + public void scheduled(IJobChangeEvent event) { + } + + @Override + public void sleeping(IJobChangeEvent event) { + } + + // ---- Delayed Rendering ---- + + private final class RenderJob extends UIJob { + public RenderJob() { + super("RenderPreview"); + setSystem(true); + setUser(false); + } + + @Override + public IStatus runInUIThread(IProgressMonitor monitor) { + mJob = null; + if (!mCanvas.isDisposed()) { + renderSync(); + mCanvas.redraw(); + return org.eclipse.core.runtime.Status.OK_STATUS; + } + + return org.eclipse.core.runtime.Status.CANCEL_STATUS; + } + + @Override + public Display getDisplay() { + if (mCanvas.isDisposed()) { + return null; + } + return mCanvas.getDisplay(); + } + } + + private final class AsyncRenderJob extends Job { + public AsyncRenderJob() { + super("RenderPreview"); + setSystem(true); + setUser(false); + } + + @Override + protected IStatus run(IProgressMonitor monitor) { + mJob = null; + + if (mCanvas.isDisposed()) { + return org.eclipse.core.runtime.Status.CANCEL_STATUS; + } + + renderSync(); + + // Update display + mCanvas.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + mCanvas.redraw(); + } + }); + + return org.eclipse.core.runtime.Status.OK_STATUS; + } + } + + /** + * Sets the input file to use for rendering. If not set, this will just be + * the same file as the configuration chooser. This is used to render other + * layouts, such as variations of the currently edited layout, which are + * not kept in sync with the main layout. + * + * @param file the file to set as input + */ + public void setAlternateInput(@Nullable IFile file) { + mAlternateInput = file; + } + + /** Corresponding description for this preview if it is a manually added preview */ + private @Nullable ConfigurationDescription mDescription; + + /** + * Sets the description of this preview, if this preview is a manually added preview + * + * @param description the description of this preview + */ + public void setDescription(@Nullable ConfigurationDescription description) { + mDescription = description; + } + + /** + * Returns the description of this preview, if this preview is a manually added preview + * + * @return the description + */ + @Nullable + public ConfigurationDescription getDescription() { + return mDescription; + } + + @Override + public String toString() { + return getDisplayName() + ':' + mConfiguration; + } + + /** Sorts render previews into increasing aspect ratio order */ + static Comparator<RenderPreview> INCREASING_ASPECT_RATIO = new Comparator<RenderPreview>() { + @Override + public int compare(RenderPreview preview1, RenderPreview preview2) { + return (int) Math.signum(preview1.mAspectRatio - preview2.mAspectRatio); + } + }; + /** Sorts render previews into visual order: row by row, column by column */ + static Comparator<RenderPreview> VISUAL_ORDER = new Comparator<RenderPreview>() { + @Override + public int compare(RenderPreview preview1, RenderPreview preview2) { + int delta = preview1.mY - preview2.mY; + if (delta == 0) { + delta = preview1.mX - preview2.mX; + } + return delta; + } + }; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewList.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewList.java new file mode 100644 index 000000000..2bcdba382 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewList.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlPrettyPrinter; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +import com.android.sdklib.devices.Device; +import com.google.common.base.Charsets; +import com.google.common.collect.Lists; +import com.google.common.io.Files; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.QualifiedName; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** A list of render previews */ +class RenderPreviewList { + /** Name of file saved in project directory storing previews */ + private static final String PREVIEW_FILE_NAME = "previews.xml"; //$NON-NLS-1$ + + /** Qualified name for the per-project persistent property include-map */ + private final static QualifiedName PREVIEW_LIST = new QualifiedName(AdtPlugin.PLUGIN_ID, + "previewlist");//$NON-NLS-1$ + + private final IProject mProject; + private final List<ConfigurationDescription> mList = Lists.newArrayList(); + + private RenderPreviewList(@NonNull IProject project) { + mProject = project; + } + + /** + * Returns the {@link RenderPreviewList} for the given project + * + * @param project the project the list is associated with + * @return a {@link RenderPreviewList} for the given project, never null + */ + @NonNull + public static RenderPreviewList get(@NonNull IProject project) { + RenderPreviewList list = null; + try { + list = (RenderPreviewList) project.getSessionProperty(PREVIEW_LIST); + } catch (CoreException e) { + // Not a problem; we will just create a new one + } + + if (list == null) { + list = new RenderPreviewList(project); + try { + project.setSessionProperty(PREVIEW_LIST, list); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + } + + return list; + } + + private File getManualFile() { + return new File(AdtUtils.getAbsolutePath(mProject).toFile(), PREVIEW_FILE_NAME); + } + + void load(Collection<Device> deviceList) throws IOException { + File file = getManualFile(); + if (file.exists()) { + load(file, deviceList); + } + } + + void save() throws IOException { + deleteFile(); + if (!mList.isEmpty()) { + File file = getManualFile(); + save(file); + } + } + + private void save(File file) throws IOException { + //Document document = DomUtilities.createEmptyPlainDocument(); + Document document = DomUtilities.createEmptyDocument(); + if (document != null) { + for (ConfigurationDescription description : mList) { + description.toXml(document); + } + String xml = EclipseXmlPrettyPrinter.prettyPrint(document, true); + Files.write(xml, file, Charsets.UTF_8); + } + } + + void load(File file, Collection<Device> deviceList) throws IOException { + mList.clear(); + + String xml = Files.toString(file, Charsets.UTF_8); + Document document = DomUtilities.parseDocument(xml, true); + if (document == null || document.getDocumentElement() == null) { + return; + } + List<Element> elements = DomUtilities.getChildren(document.getDocumentElement()); + for (Element element : elements) { + ConfigurationDescription description = ConfigurationDescription.fromXml( + mProject, element, deviceList); + if (description != null) { + mList.add(description); + } + } + } + + /** + * Create a list of previews for the given canvas that matches the internal + * configuration preview list + * + * @param canvas the associated canvas + * @return a new list of previews linked to the given canvas + */ + @NonNull + List<RenderPreview> createPreviews(LayoutCanvas canvas) { + if (mList.isEmpty()) { + return new ArrayList<RenderPreview>(); + } + List<RenderPreview> previews = Lists.newArrayList(); + RenderPreviewManager manager = canvas.getPreviewManager(); + ConfigurationChooser chooser = canvas.getEditorDelegate().getGraphicalEditor() + .getConfigurationChooser(); + + Configuration chooserConfig = chooser.getConfiguration(); + for (ConfigurationDescription description : mList) { + Configuration configuration = Configuration.create(chooser); + configuration.setDisplayName(description.displayName); + configuration.setActivity(description.activity); + configuration.setLocale( + description.locale != null ? description.locale : chooserConfig.getLocale(), + true); + // TODO: Make sure this layout isn't in some v-folder which is incompatible + // with this target! + configuration.setTarget( + description.target != null ? description.target : chooserConfig.getTarget(), + true); + configuration.setTheme( + description.theme != null ? description.theme : chooserConfig.getTheme()); + configuration.setDevice( + description.device != null ? description.device : chooserConfig.getDevice(), + true); + configuration.setDeviceState( + description.state != null ? description.state : chooserConfig.getDeviceState(), + true); + configuration.setNightMode( + description.nightMode != null ? description.nightMode + : chooserConfig.getNightMode(), true); + configuration.setUiMode( + description.uiMode != null ? description.uiMode : chooserConfig.getUiMode(), true); + + //configuration.syncFolderConfig(); + configuration.getFullConfig().set(description.folder); + + RenderPreview preview = RenderPreview.create(manager, configuration); + + preview.setDescription(description); + previews.add(preview); + } + + return previews; + } + + void remove(@NonNull RenderPreview preview) { + ConfigurationDescription description = preview.getDescription(); + if (description != null) { + mList.remove(description); + } + } + + boolean isEmpty() { + return mList.isEmpty(); + } + + void add(@NonNull RenderPreview preview) { + Configuration configuration = preview.getConfiguration(); + ConfigurationDescription description = + ConfigurationDescription.fromConfiguration(mProject, configuration); + // RenderPreviews can have display names that aren't reflected in the configuration + description.displayName = preview.getDisplayName(); + mList.add(description); + preview.setDescription(description); + } + + void delete() { + mList.clear(); + deleteFile(); + } + + private void deleteFile() { + File file = getManualFile(); + if (file.exists()) { + file.delete(); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewManager.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewManager.java new file mode 100644 index 000000000..98dde86e0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewManager.java @@ -0,0 +1,1696 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_DEVICE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.CFG_DEVICE_STATE; +import static com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration.MASK_ALL; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SHADOW_SIZE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SMALL_SHADOW_SIZE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreview.LARGE_SHADOWS; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.CUSTOM; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.NONE; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderPreviewMode.SCREENS; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.Rect; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.resources.configuration.DensityQualifier; +import com.android.ide.common.resources.configuration.DeviceConfigHelper; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.common.resources.configuration.LocaleQualifier; +import com.android.ide.common.resources.configuration.ScreenSizeQualifier; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationClient; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Locale; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.NestedConfiguration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.VaryingConfiguration; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.resources.Density; +import com.android.resources.ScreenSize; +import com.android.sdklib.devices.Device; +import com.android.sdklib.devices.Screen; +import com.android.sdklib.devices.State; +import com.google.common.collect.Lists; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.ui.IWorkbenchPartSite; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.ide.IDE; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +/** + * Manager for the configuration previews, which handles layout computations, + * managing the image buffer cache, etc + */ +public class RenderPreviewManager { + private static double sScale = 1.0; + private static final int RENDER_DELAY = 150; + private static final int PREVIEW_VGAP = 18; + private static final int PREVIEW_HGAP = 12; + private static final int MAX_WIDTH = 200; + private static final int MAX_HEIGHT = MAX_WIDTH; + private static final int ZOOM_ICON_WIDTH = 16; + private static final int ZOOM_ICON_HEIGHT = 16; + private @Nullable List<RenderPreview> mPreviews; + private @Nullable RenderPreviewList mManualList; + private final @NonNull LayoutCanvas mCanvas; + private final @NonNull CanvasTransform mVScale; + private final @NonNull CanvasTransform mHScale; + private int mPrevCanvasWidth; + private int mPrevCanvasHeight; + private int mPrevImageWidth; + private int mPrevImageHeight; + private @NonNull RenderPreviewMode mMode = NONE; + private @Nullable RenderPreview mActivePreview; + private @Nullable ScrollBarListener mListener; + private int mLayoutHeight; + /** Last seen state revision in this {@link RenderPreviewManager}. If less + * than {@link #sRevision}, the previews need to be updated on next exposure */ + private static int mRevision; + /** Current global revision count */ + private static int sRevision; + private boolean mNeedLayout; + private boolean mNeedRender; + private boolean mNeedZoom; + private SwapAnimation mAnimation; + + /** + * Creates a {@link RenderPreviewManager} associated with the given canvas + * + * @param canvas the canvas to manage previews for + */ + public RenderPreviewManager(@NonNull LayoutCanvas canvas) { + mCanvas = canvas; + mHScale = canvas.getHorizontalTransform(); + mVScale = canvas.getVerticalTransform(); + } + + /** + * Revise the global state revision counter. This will cause all layout + * preview managers to refresh themselves to the latest revision when they + * are next exposed. + */ + public static void bumpRevision() { + sRevision++; + } + + /** + * Returns the associated chooser + * + * @return the associated chooser + */ + @NonNull + ConfigurationChooser getChooser() { + GraphicalEditorPart editor = mCanvas.getEditorDelegate().getGraphicalEditor(); + return editor.getConfigurationChooser(); + } + + /** + * Returns the associated canvas + * + * @return the canvas + */ + @NonNull + public LayoutCanvas getCanvas() { + return mCanvas; + } + + /** Zooms in (grows all previews) */ + public void zoomIn() { + sScale = sScale * (1 / 0.9); + if (Math.abs(sScale-1.0) < 0.0001) { + sScale = 1.0; + } + + updatedZoom(); + } + + /** Zooms out (shrinks all previews) */ + public void zoomOut() { + sScale = sScale * (0.9 / 1); + if (Math.abs(sScale-1.0) < 0.0001) { + sScale = 1.0; + } + updatedZoom(); + } + + /** Zooms to 100 (resets zoom) */ + public void zoomReset() { + sScale = 1.0; + updatedZoom(); + mNeedZoom = mNeedLayout = true; + mCanvas.redraw(); + } + + private void updatedZoom() { + if (hasPreviews()) { + for (RenderPreview preview : mPreviews) { + preview.disposeThumbnail(); + } + RenderPreview preview = mCanvas.getPreview(); + if (preview != null) { + preview.disposeThumbnail(); + } + } + + mNeedLayout = mNeedRender = true; + mCanvas.redraw(); + } + + static int getMaxWidth() { + return (int) (sScale * MAX_WIDTH); + } + + static int getMaxHeight() { + return (int) (sScale * MAX_HEIGHT); + } + + static double getScale() { + return sScale; + } + + /** + * Returns whether there are any manual preview items (provided the current + * mode is manual previews + * + * @return true if there are items in the manual preview list + */ + public boolean hasManualPreviews() { + assert mMode == CUSTOM; + return mManualList != null && !mManualList.isEmpty(); + } + + /** Delete all the previews */ + public void deleteManualPreviews() { + disposePreviews(); + selectMode(NONE); + mCanvas.setFitScale(true /* onlyZoomOut */, true /*allowZoomIn*/); + + if (mManualList != null) { + mManualList.delete(); + } + } + + /** Dispose all the previews */ + public void disposePreviews() { + if (mPreviews != null) { + List<RenderPreview> old = mPreviews; + mPreviews = null; + for (RenderPreview preview : old) { + preview.dispose(); + } + } + } + + /** + * Deletes the given preview + * + * @param preview the preview to be deleted + */ + public void deletePreview(RenderPreview preview) { + mPreviews.remove(preview); + preview.dispose(); + layout(true); + mCanvas.redraw(); + + if (mManualList != null) { + mManualList.remove(preview); + saveList(); + } + } + + /** + * Compute the total width required for the previews, including internal padding + * + * @return total width in pixels + */ + public int computePreviewWidth() { + int maxPreviewWidth = 0; + if (hasPreviews()) { + for (RenderPreview preview : mPreviews) { + maxPreviewWidth = Math.max(maxPreviewWidth, preview.getWidth()); + } + + if (maxPreviewWidth > 0) { + maxPreviewWidth += 2 * PREVIEW_HGAP; // 2x for left and right side + maxPreviewWidth += LARGE_SHADOWS ? SHADOW_SIZE : SMALL_SHADOW_SIZE; + } + + return maxPreviewWidth; + } + + return 0; + } + + /** + * Layout Algorithm. This sets the {@link RenderPreview#getX()} and + * {@link RenderPreview#getY()} coordinates of all the previews. It also + * marks previews as visible or invisible via + * {@link RenderPreview#setVisible(boolean)} according to their position and + * the current visible view port in the layout canvas. Finally, it also sets + * the {@code mLayoutHeight} field, such that the scrollbars can compute the + * right scrolled area, and that scrolling can cause render refreshes on + * views that are made visible. + * <p> + * This is not a traditional bin packing problem, because the objects to be + * packaged do not have a fixed size; we can scale them up and down in order + * to provide an "optimal" size. + * <p> + * See http://en.wikipedia.org/wiki/Packing_problem See + * http://en.wikipedia.org/wiki/Bin_packing_problem + */ + void layout(boolean refresh) { + mNeedLayout = false; + + if (mPreviews == null || mPreviews.isEmpty()) { + return; + } + + int scaledImageWidth = mHScale.getScaledImgSize(); + int scaledImageHeight = mVScale.getScaledImgSize(); + Rectangle clientArea = mCanvas.getClientArea(); + + if (!refresh && + (scaledImageWidth == mPrevImageWidth + && scaledImageHeight == mPrevImageHeight + && clientArea.width == mPrevCanvasWidth + && clientArea.height == mPrevCanvasHeight)) { + // No change + return; + } + + mPrevImageWidth = scaledImageWidth; + mPrevImageHeight = scaledImageHeight; + mPrevCanvasWidth = clientArea.width; + mPrevCanvasHeight = clientArea.height; + + if (mListener == null) { + mListener = new ScrollBarListener(); + mCanvas.getVerticalBar().addSelectionListener(mListener); + } + + beginRenderScheduling(); + + mLayoutHeight = 0; + + if (previewsHaveIdenticalSize() || fixedOrder()) { + // If all the preview boxes are of identical sizes, or if the order is predetermined, + // just lay them out in rows. + rowLayout(); + } else if (previewsFit()) { + layoutFullFit(); + } else { + rowLayout(); + } + + mCanvas.updateScrollBars(); + } + + /** + * Performs a simple layout where the views are laid out in a row, wrapping + * around the top left canvas image. + */ + private void rowLayout() { + // TODO: Separate layout heuristics for portrait and landscape orientations (though + // it also depends on the dimensions of the canvas window, which determines the + // shape of the leftover space) + + int scaledImageWidth = mHScale.getScaledImgSize(); + int scaledImageHeight = mVScale.getScaledImgSize(); + Rectangle clientArea = mCanvas.getClientArea(); + + int availableWidth = clientArea.x + clientArea.width - getX(); + int availableHeight = clientArea.y + clientArea.height - getY(); + int maxVisibleY = clientArea.y + clientArea.height; + + int bottomBorder = scaledImageHeight; + int rightHandSide = scaledImageWidth + PREVIEW_HGAP; + int nextY = 0; + + // First lay out images across the top right hand side + int x = rightHandSide; + int y = 0; + boolean wrapped = false; + + int vgap = PREVIEW_VGAP; + for (RenderPreview preview : mPreviews) { + // If we have forked previews, double the vgap to allow space for two labels + if (preview.isForked()) { + vgap *= 2; + break; + } + } + + List<RenderPreview> aspectOrder; + if (!fixedOrder()) { + aspectOrder = new ArrayList<RenderPreview>(mPreviews); + Collections.sort(aspectOrder, RenderPreview.INCREASING_ASPECT_RATIO); + } else { + aspectOrder = mPreviews; + } + + for (RenderPreview preview : aspectOrder) { + if (x > 0 && x + preview.getWidth() > availableWidth) { + x = rightHandSide; + int prevY = y; + y = nextY; + if ((prevY <= bottomBorder || + y <= bottomBorder) + && Math.max(nextY, y + preview.getHeight()) > bottomBorder) { + // If there's really no visible room below, don't bother + // Similarly, don't wrap individually scaled views + if (bottomBorder < availableHeight - 40 && preview.getScale() < 1.2) { + // If it's closer to the top row than the bottom, just + // mark the next row for left justify instead + if (bottomBorder - y > y + preview.getHeight() - bottomBorder) { + rightHandSide = 0; + wrapped = true; + } else if (!wrapped) { + y = nextY = Math.max(nextY, bottomBorder + vgap); + x = rightHandSide = 0; + wrapped = true; + } + } + } + } + if (x > 0 && y <= bottomBorder + && Math.max(nextY, y + preview.getHeight()) > bottomBorder) { + if (clientArea.height - bottomBorder < preview.getHeight()) { + // No room below the device on the left; just continue on the + // bottom row + } else if (preview.getScale() < 1.2) { + if (bottomBorder - y > y + preview.getHeight() - bottomBorder) { + rightHandSide = 0; + wrapped = true; + } else { + y = nextY = Math.max(nextY, bottomBorder + vgap); + x = rightHandSide = 0; + wrapped = true; + } + } + } + + preview.setPosition(x, y); + + if (y > maxVisibleY && maxVisibleY > 0) { + preview.setVisible(false); + } else if (!preview.isVisible()) { + preview.setVisible(true); + } + + x += preview.getWidth(); + x += PREVIEW_HGAP; + nextY = Math.max(nextY, y + preview.getHeight() + vgap); + } + + mLayoutHeight = nextY; + } + + private boolean fixedOrder() { + return mMode == SCREENS; + } + + /** Returns true if all the previews have the same identical size */ + private boolean previewsHaveIdenticalSize() { + if (!hasPreviews()) { + return true; + } + + Iterator<RenderPreview> iterator = mPreviews.iterator(); + RenderPreview first = iterator.next(); + int width = first.getWidth(); + int height = first.getHeight(); + + while (iterator.hasNext()) { + RenderPreview preview = iterator.next(); + if (width != preview.getWidth() || height != preview.getHeight()) { + return false; + } + } + + return true; + } + + /** Returns true if all the previews can fully fit in the available space */ + private boolean previewsFit() { + int scaledImageWidth = mHScale.getScaledImgSize(); + int scaledImageHeight = mVScale.getScaledImgSize(); + Rectangle clientArea = mCanvas.getClientArea(); + int availableWidth = clientArea.x + clientArea.width - getX(); + int availableHeight = clientArea.y + clientArea.height - getY(); + int bottomBorder = scaledImageHeight; + int rightHandSide = scaledImageWidth + PREVIEW_HGAP; + + // First see if we can fit everything; if so, we can try to make the layouts + // larger such that they fill up all the available space + long availableArea = rightHandSide * bottomBorder + + availableWidth * (Math.max(0, availableHeight - bottomBorder)); + + long requiredArea = 0; + for (RenderPreview preview : mPreviews) { + // Note: This does not include individual preview scale; the layout + // algorithm itself may be tweaking the scales to fit elements within + // the layout + requiredArea += preview.getArea(); + } + + return requiredArea * sScale < availableArea; + } + + private void layoutFullFit() { + int scaledImageWidth = mHScale.getScaledImgSize(); + int scaledImageHeight = mVScale.getScaledImgSize(); + Rectangle clientArea = mCanvas.getClientArea(); + int availableWidth = clientArea.x + clientArea.width - getX(); + int availableHeight = clientArea.y + clientArea.height - getY(); + int maxVisibleY = clientArea.y + clientArea.height; + int bottomBorder = scaledImageHeight; + int rightHandSide = scaledImageWidth + PREVIEW_HGAP; + + int minWidth = Integer.MAX_VALUE; + int minHeight = Integer.MAX_VALUE; + for (RenderPreview preview : mPreviews) { + minWidth = Math.min(minWidth, preview.getWidth()); + minHeight = Math.min(minHeight, preview.getHeight()); + } + + BinPacker packer = new BinPacker(minWidth, minHeight); + + // TODO: Instead of this, just start with client area and occupy scaled image size! + + // Add in gap on right and bottom since we'll add that requirement on the width and + // height rectangles too (for spacing) + packer.addSpace(new Rect(rightHandSide, 0, + availableWidth - rightHandSide + PREVIEW_HGAP, + availableHeight + PREVIEW_VGAP)); + if (maxVisibleY > bottomBorder) { + packer.addSpace(new Rect(0, bottomBorder + PREVIEW_VGAP, + availableWidth + PREVIEW_HGAP, maxVisibleY - bottomBorder + PREVIEW_VGAP)); + } + + // TODO: Sort previews first before attempting to position them? + + ArrayList<RenderPreview> aspectOrder = new ArrayList<RenderPreview>(mPreviews); + Collections.sort(aspectOrder, RenderPreview.INCREASING_ASPECT_RATIO); + + for (RenderPreview preview : aspectOrder) { + int previewWidth = preview.getWidth(); + int previewHeight = preview.getHeight(); + previewHeight += PREVIEW_VGAP; + if (preview.isForked()) { + previewHeight += PREVIEW_VGAP; + } + previewWidth += PREVIEW_HGAP; + // title height? how do I account for that? + Rect position = packer.occupy(previewWidth, previewHeight); + if (position != null) { + preview.setPosition(position.x, position.y); + preview.setVisible(true); + } else { + // Can't fit: give up and do plain row layout + rowLayout(); + return; + } + } + + mLayoutHeight = availableHeight; + } + /** + * Paints the configuration previews + * + * @param gc the graphics context to paint into + */ + void paint(GC gc) { + if (hasPreviews()) { + // Ensure up to date at all times; consider moving if it's too expensive + layout(mNeedLayout); + if (mNeedRender) { + renderPreviews(); + } + if (mNeedZoom) { + boolean allowZoomIn = true /*mMode == NONE*/; + mCanvas.setFitScale(false /*onlyZoomOut*/, allowZoomIn); + mNeedZoom = false; + } + int rootX = getX(); + int rootY = getY(); + + for (RenderPreview preview : mPreviews) { + if (preview.isVisible()) { + int x = rootX + preview.getX(); + int y = rootY + preview.getY(); + preview.paint(gc, x, y); + } + } + + RenderPreview preview = mCanvas.getPreview(); + if (preview != null) { + String displayName = null; + Configuration configuration = preview.getConfiguration(); + if (configuration instanceof VaryingConfiguration) { + // Use override flags from stashed preview, but configuration + // data from live (not varying) configured configuration + VaryingConfiguration cfg = (VaryingConfiguration) configuration; + int flags = cfg.getAlternateFlags() | cfg.getOverrideFlags(); + displayName = NestedConfiguration.computeDisplayName(flags, + getChooser().getConfiguration()); + } else if (configuration instanceof NestedConfiguration) { + int flags = ((NestedConfiguration) configuration).getOverrideFlags(); + displayName = NestedConfiguration.computeDisplayName(flags, + getChooser().getConfiguration()); + } else { + displayName = configuration.getDisplayName(); + } + if (displayName != null) { + CanvasTransform hi = mHScale; + CanvasTransform vi = mVScale; + + int destX = hi.translate(0); + int destY = vi.translate(0); + int destWidth = hi.getScaledImgSize(); + int destHeight = vi.getScaledImgSize(); + + int x = destX + destWidth / 2 - preview.getWidth() / 2; + int y = destY + destHeight; + + preview.paintTitle(gc, x, y, false /*showFile*/, displayName); + } + } + + // Zoom overlay + int x = getZoomX(); + if (x > 0) { + int y = getZoomY(); + int oldAlpha = gc.getAlpha(); + + // Paint background oval rectangle behind the zoom and close icons + gc.setBackground(gc.getDevice().getSystemColor(SWT.COLOR_GRAY)); + gc.setAlpha(128); + int padding = 3; + int arc = 5; + gc.fillRoundRectangle(x - padding, y - padding, + ZOOM_ICON_WIDTH + 2 * padding, + 4 * ZOOM_ICON_HEIGHT + 2 * padding, arc, arc); + + gc.setAlpha(255); + IconFactory iconFactory = IconFactory.getInstance(); + Image zoomOut = iconFactory.getIcon("zoomminus"); //$NON-NLS-1$); + Image zoomIn = iconFactory.getIcon("zoomplus"); //$NON-NLS-1$); + Image zoom100 = iconFactory.getIcon("zoom100"); //$NON-NLS-1$); + Image close = iconFactory.getIcon("close"); //$NON-NLS-1$); + + gc.drawImage(zoomIn, x, y); + y += ZOOM_ICON_HEIGHT; + gc.drawImage(zoomOut, x, y); + y += ZOOM_ICON_HEIGHT; + gc.drawImage(zoom100, x, y); + y += ZOOM_ICON_HEIGHT; + gc.drawImage(close, x, y); + y += ZOOM_ICON_HEIGHT; + gc.setAlpha(oldAlpha); + } + } else if (mMode == CUSTOM) { + int rootX = getX(); + rootX += mHScale.getScaledImgSize(); + rootX += 2 * PREVIEW_HGAP; + int rootY = getY(); + rootY += 20; + gc.setFont(mCanvas.getFont()); + gc.setForeground(mCanvas.getDisplay().getSystemColor(SWT.COLOR_BLACK)); + gc.drawText("Add previews with \"Add as Thumbnail\"\nin the configuration menu", + rootX, rootY, true); + } + + if (mAnimation != null) { + mAnimation.tick(gc); + } + } + + private void addPreview(@NonNull RenderPreview preview) { + if (mPreviews == null) { + mPreviews = Lists.newArrayList(); + } + mPreviews.add(preview); + } + + /** Adds the current configuration as a new configuration preview */ + public void addAsThumbnail() { + ConfigurationChooser chooser = getChooser(); + String name = chooser.getConfiguration().getDisplayName(); + if (name == null || name.isEmpty()) { + name = getUniqueName(); + } + InputDialog d = new InputDialog( + AdtPlugin.getShell(), + "Add as Thumbnail Preview", // title + "Name of thumbnail:", + name, + null); + if (d.open() == Window.OK) { + selectMode(CUSTOM); + + String newName = d.getValue(); + // Create a new configuration from the current settings in the composite + Configuration configuration = Configuration.copy(chooser.getConfiguration()); + configuration.setDisplayName(newName); + + RenderPreview preview = RenderPreview.create(this, configuration); + addPreview(preview); + + layout(true); + beginRenderScheduling(); + scheduleRender(preview); + mCanvas.setFitScale(true /* onlyZoomOut */, false /*allowZoomIn*/); + + if (mManualList == null) { + loadList(); + } + if (mManualList != null) { + mManualList.add(preview); + saveList(); + } + } + } + + /** + * Computes a unique new name for a configuration preview that represents + * the current, default configuration + * + * @return a unique name + */ + private String getUniqueName() { + if (mPreviews == null || mPreviews.isEmpty()) { + // NO, not for the first preview! + return "Config1"; + } + + Set<String> names = new HashSet<String>(mPreviews.size()); + for (RenderPreview preview : mPreviews) { + names.add(preview.getDisplayName()); + } + + int index = 2; + while (true) { + String name = String.format("Config%1$d", index); + if (!names.contains(name)) { + return name; + } + index++; + } + } + + /** Generates a bunch of default configuration preview thumbnails */ + public void addDefaultPreviews() { + ConfigurationChooser chooser = getChooser(); + Configuration parent = chooser.getConfiguration(); + if (parent instanceof NestedConfiguration) { + parent = ((NestedConfiguration) parent).getParent(); + } + if (mCanvas.getImageOverlay().getImage() != null) { + // Create Language variation + createLocaleVariation(chooser, parent); + + // Vary screen size + // TODO: Be smarter here: Pick a screen that is both as differently as possible + // from the current screen as well as also supported. So consider + // things like supported screens, targetSdk etc. + createScreenVariations(parent); + + // Vary orientation + createStateVariation(chooser, parent); + + // Vary render target + createRenderTargetVariation(chooser, parent); + } + + // Also add in include-context previews, if any + addIncludedInPreviews(); + + // Make a placeholder preview for the current screen, in case we switch from it + RenderPreview preview = RenderPreview.create(this, parent); + mCanvas.setPreview(preview); + + sortPreviewsByOrientation(); + } + + private void createRenderTargetVariation(ConfigurationChooser chooser, Configuration parent) { + /* This is disabled for now: need to load multiple versions of layoutlib. + When I did this, there seemed to be some drug interactions between + them, and I would end up with NPEs in layoutlib code which normally works. + VaryingConfiguration configuration = + VaryingConfiguration.create(chooser, parent); + configuration.setAlternatingTarget(true); + configuration.syncFolderConfig(); + addPreview(RenderPreview.create(this, configuration)); + */ + } + + private void createStateVariation(ConfigurationChooser chooser, Configuration parent) { + State currentState = parent.getDeviceState(); + State nextState = parent.getNextDeviceState(currentState); + if (nextState != currentState) { + VaryingConfiguration configuration = + VaryingConfiguration.create(chooser, parent); + configuration.setAlternateDeviceState(true); + configuration.syncFolderConfig(); + addPreview(RenderPreview.create(this, configuration)); + } + } + + private void createLocaleVariation(ConfigurationChooser chooser, Configuration parent) { + LocaleQualifier currentLanguage = parent.getLocale().qualifier; + for (Locale locale : chooser.getLocaleList()) { + LocaleQualifier qualifier = locale.qualifier; + if (!qualifier.getLanguage().equals(currentLanguage.getLanguage())) { + VaryingConfiguration configuration = + VaryingConfiguration.create(chooser, parent); + configuration.setAlternateLocale(true); + configuration.syncFolderConfig(); + addPreview(RenderPreview.create(this, configuration)); + break; + } + } + } + + private void createScreenVariations(Configuration parent) { + ConfigurationChooser chooser = getChooser(); + VaryingConfiguration configuration; + + configuration = VaryingConfiguration.create(chooser, parent); + configuration.setVariation(0); + configuration.setAlternateDevice(true); + configuration.syncFolderConfig(); + addPreview(RenderPreview.create(this, configuration)); + + configuration = VaryingConfiguration.create(chooser, parent); + configuration.setVariation(1); + configuration.setAlternateDevice(true); + configuration.syncFolderConfig(); + addPreview(RenderPreview.create(this, configuration)); + } + + /** + * Returns the current mode as seen by this {@link RenderPreviewManager}. + * Note that it may not yet have been synced with the global mode kept in + * {@link AdtPrefs#getRenderPreviewMode()}. + * + * @return the current preview mode + */ + @NonNull + public RenderPreviewMode getMode() { + return mMode; + } + + /** + * Update the set of previews for the current mode + * + * @param force force a refresh even if the preview type has not changed + * @return true if the views were recomputed, false if the previews were + * already showing and the mode not changed + */ + public boolean recomputePreviews(boolean force) { + RenderPreviewMode newMode = AdtPrefs.getPrefs().getRenderPreviewMode(); + if (newMode == mMode && !force + && (mRevision == sRevision + || mMode == NONE + || mMode == CUSTOM)) { + return false; + } + + RenderPreviewMode oldMode = mMode; + mMode = newMode; + mRevision = sRevision; + + sScale = 1.0; + disposePreviews(); + + switch (mMode) { + case DEFAULT: + addDefaultPreviews(); + break; + case INCLUDES: + addIncludedInPreviews(); + break; + case LOCALES: + addLocalePreviews(); + break; + case SCREENS: + addScreenSizePreviews(); + break; + case VARIATIONS: + addVariationPreviews(); + break; + case CUSTOM: + addManualPreviews(); + break; + case NONE: + // Can't just set mNeedZoom because with no previews, the paint + // method does nothing + mCanvas.setFitScale(false /*onlyZoomOut*/, true /*allowZoomIn*/); + break; + default: + assert false : mMode; + } + + // We schedule layout for the next redraw rather than process it here immediately; + // not only does this let us avoid doing work for windows where the tab is in the + // background, but when a file is opened we may not know the size of the canvas + // yet, and the layout methods need it in order to do a good job. By the time + // the canvas is painted, we have accurate bounds. + mNeedLayout = mNeedRender = true; + mCanvas.redraw(); + + if (oldMode != mMode && (oldMode == NONE || mMode == NONE)) { + // If entering or exiting preview mode: updating padding which is compressed + // only in preview mode. + mCanvas.getHorizontalTransform().refresh(); + mCanvas.getVerticalTransform().refresh(); + } + + return true; + } + + /** + * Sets the new render preview mode to use + * + * @param mode the new mode + */ + public void selectMode(@NonNull RenderPreviewMode mode) { + if (mode != mMode) { + AdtPrefs.getPrefs().setPreviewMode(mode); + recomputePreviews(false); + } + } + + /** Similar to {@link #addDefaultPreviews()} but for locales */ + public void addLocalePreviews() { + + ConfigurationChooser chooser = getChooser(); + List<Locale> locales = chooser.getLocaleList(); + Configuration parent = chooser.getConfiguration(); + + for (Locale locale : locales) { + if (!locale.hasLanguage() && !locale.hasRegion()) { + continue; + } + NestedConfiguration configuration = NestedConfiguration.create(chooser, parent); + configuration.setOverrideLocale(true); + configuration.setLocale(locale, false); + + String displayName = ConfigurationChooser.getLocaleLabel(chooser, locale, false); + assert displayName != null; // it's never non null when locale is non null + configuration.setDisplayName(displayName); + + addPreview(RenderPreview.create(this, configuration)); + } + + // Make a placeholder preview for the current screen, in case we switch from it + Configuration configuration = parent; + Locale locale = configuration.getLocale(); + String label = ConfigurationChooser.getLocaleLabel(chooser, locale, false); + if (label == null) { + label = "default"; + } + configuration.setDisplayName(label); + RenderPreview preview = RenderPreview.create(this, parent); + if (preview != null) { + mCanvas.setPreview(preview); + } + + // No need to sort: they should all be identical + } + + /** Similar to {@link #addDefaultPreviews()} but for screen sizes */ + public void addScreenSizePreviews() { + ConfigurationChooser chooser = getChooser(); + Collection<Device> devices = chooser.getDevices(); + Configuration configuration = chooser.getConfiguration(); + boolean canScaleNinePatch = configuration.supports(Capability.FIXED_SCALABLE_NINE_PATCH); + + // Rearrange the devices a bit such that the most interesting devices bubble + // to the front + // 10" tablet, 7" tablet, reference phones, tiny phone, and in general the first + // version of each seen screen size + List<Device> sorted = new ArrayList<Device>(devices); + Set<ScreenSize> seenSizes = new HashSet<ScreenSize>(); + State currentState = configuration.getDeviceState(); + String currentStateName = currentState != null ? currentState.getName() : ""; + + for (int i = 0, n = sorted.size(); i < n; i++) { + Device device = sorted.get(i); + boolean interesting = false; + + State state = device.getState(currentStateName); + if (state == null) { + state = device.getAllStates().get(0); + } + + if (device.getName().startsWith("Nexus ") //$NON-NLS-1$ + || device.getName().endsWith(" Nexus")) { //$NON-NLS-1$ + // Not String#contains("Nexus") because that would also pick up all the generic + // entries ("3.7in WVGA (Nexus One)") so we'd have them duplicated + interesting = true; + } + + FolderConfiguration c = DeviceConfigHelper.getFolderConfig(state); + if (c != null) { + ScreenSizeQualifier sizeQualifier = c.getScreenSizeQualifier(); + if (sizeQualifier != null) { + ScreenSize size = sizeQualifier.getValue(); + if (!seenSizes.contains(size)) { + seenSizes.add(size); + interesting = true; + } + } + + // Omit LDPI, not really used anymore + DensityQualifier density = c.getDensityQualifier(); + if (density != null) { + Density d = density.getValue(); + if (d == Density.LOW) { + interesting = false; + } + + if (!canScaleNinePatch && d == Density.TV) { + interesting = false; + } + } + } + + if (interesting) { + NestedConfiguration screenConfig = NestedConfiguration.create(chooser, + configuration); + screenConfig.setOverrideDevice(true); + screenConfig.setDevice(device, true); + screenConfig.syncFolderConfig(); + screenConfig.setDisplayName(ConfigurationChooser.getDeviceLabel(device, true)); + addPreview(RenderPreview.create(this, screenConfig)); + } + } + + // Sorted by screen size, in decreasing order + sortPreviewsByScreenSize(); + } + + /** + * Previews this layout as included in other layouts + */ + public void addIncludedInPreviews() { + ConfigurationChooser chooser = getChooser(); + IProject project = chooser.getProject(); + if (project == null) { + return; + } + IncludeFinder finder = IncludeFinder.get(project); + + final List<Reference> includedBy = finder.getIncludedBy(chooser.getEditedFile()); + + if (includedBy == null || includedBy.isEmpty()) { + // TODO: Generate some useful defaults, such as including it in a ListView + // as the list item layout? + return; + } + + for (final Reference reference : includedBy) { + String title = reference.getDisplayName(); + Configuration config = Configuration.create(chooser.getConfiguration(), + reference.getFile()); + RenderPreview preview = RenderPreview.create(this, config); + preview.setDisplayName(title); + preview.setIncludedWithin(reference); + + addPreview(preview); + } + + sortPreviewsByOrientation(); + } + + /** + * Previews this layout as included in other layouts + */ + public void addVariationPreviews() { + ConfigurationChooser chooser = getChooser(); + + IFile file = chooser.getEditedFile(); + List<IFile> variations = AdtUtils.getResourceVariations(file, false /*includeSelf*/); + + // Sort by parent folder + Collections.sort(variations, new Comparator<IFile>() { + @Override + public int compare(IFile file1, IFile file2) { + return file1.getParent().getName().compareTo(file2.getParent().getName()); + } + }); + + Configuration currentConfig = chooser.getConfiguration(); + + for (IFile variation : variations) { + String title = variation.getParent().getName(); + Configuration config = Configuration.create(chooser.getConfiguration(), variation); + config.setTheme(currentConfig.getTheme()); + config.setActivity(currentConfig.getActivity()); + RenderPreview preview = RenderPreview.create(this, config); + preview.setDisplayName(title); + preview.setAlternateInput(variation); + + addPreview(preview); + } + + sortPreviewsByOrientation(); + } + + /** + * Previews this layout using a custom configured set of layouts + */ + public void addManualPreviews() { + if (mManualList == null) { + loadList(); + } else { + mPreviews = mManualList.createPreviews(mCanvas); + } + } + + private void loadList() { + IProject project = getChooser().getProject(); + if (project == null) { + return; + } + + if (mManualList == null) { + mManualList = RenderPreviewList.get(project); + } + + try { + mManualList.load(getChooser().getDevices()); + mPreviews = mManualList.createPreviews(mCanvas); + } catch (IOException e) { + AdtPlugin.log(e, null); + } + } + + private void saveList() { + if (mManualList != null) { + try { + mManualList.save(); + } catch (IOException e) { + AdtPlugin.log(e, null); + } + } + } + + void rename(ConfigurationDescription description, String newName) { + IProject project = getChooser().getProject(); + if (project == null) { + return; + } + + if (mManualList == null) { + mManualList = RenderPreviewList.get(project); + } + description.displayName = newName; + saveList(); + } + + + /** + * Notifies that the main configuration has changed. + * + * @param flags the change flags, a bitmask corresponding to the + * {@code CHANGE_} constants in {@link ConfigurationClient} + */ + public void configurationChanged(int flags) { + // Similar to renderPreviews, but only acts on incomplete previews + if (hasPreviews()) { + // Do zoomed images first + beginRenderScheduling(); + for (RenderPreview preview : mPreviews) { + if (preview.getScale() > 1.2) { + preview.configurationChanged(flags); + } + } + for (RenderPreview preview : mPreviews) { + if (preview.getScale() <= 1.2) { + preview.configurationChanged(flags); + } + } + RenderPreview preview = mCanvas.getPreview(); + if (preview != null) { + preview.configurationChanged(flags); + preview.dispose(); + } + mNeedLayout = true; + mCanvas.redraw(); + } + } + + /** Updates the configuration preview thumbnails */ + public void renderPreviews() { + if (hasPreviews()) { + beginRenderScheduling(); + + // Process in visual order + ArrayList<RenderPreview> visualOrder = new ArrayList<RenderPreview>(mPreviews); + Collections.sort(visualOrder, RenderPreview.VISUAL_ORDER); + + // Do zoomed images first + for (RenderPreview preview : visualOrder) { + if (preview.getScale() > 1.2 && preview.isVisible()) { + scheduleRender(preview); + } + } + // Non-zoomed images + for (RenderPreview preview : visualOrder) { + if (preview.getScale() <= 1.2 && preview.isVisible()) { + scheduleRender(preview); + } + } + } + + mNeedRender = false; + } + + private int mPendingRenderCount; + + /** + * Reset rendering scheduling. The next render request will be scheduled + * after a single delay unit. + */ + public void beginRenderScheduling() { + mPendingRenderCount = 0; + } + + /** + * Schedule rendering the given preview. Each successive call will add an additional + * delay unit to the schedule from the previous {@link #scheduleRender(RenderPreview)} + * call, until {@link #beginRenderScheduling()} is called again. + * + * @param preview the preview to render + */ + public void scheduleRender(@NonNull RenderPreview preview) { + mPendingRenderCount++; + preview.render(mPendingRenderCount * RENDER_DELAY); + } + + /** + * Switch to the given configuration preview + * + * @param preview the preview to switch to + */ + public void switchTo(@NonNull RenderPreview preview) { + IFile input = preview.getAlternateInput(); + if (input != null) { + IWorkbenchPartSite site = mCanvas.getEditorDelegate().getEditor().getSite(); + try { + // This switches to the given file, but the file might not have + // an identical configuration to what was shown in the preview. + // For example, while viewing a 10" layout-xlarge file, it might + // show a preview for a 5" version tied to the default layout. If + // you click on it, it will open the default layout file, but it might + // be using a different screen size; any of those that match the + // default layout, say a 3.8". + // + // Thus, we need to also perform a screen size sync first + Configuration configuration = preview.getConfiguration(); + boolean setSize = false; + if (configuration instanceof NestedConfiguration) { + NestedConfiguration nestedConfig = (NestedConfiguration) configuration; + setSize = nestedConfig.isOverridingDevice(); + if (configuration instanceof VaryingConfiguration) { + VaryingConfiguration c = (VaryingConfiguration) configuration; + setSize |= c.isAlternatingDevice(); + } + + if (setSize) { + ConfigurationChooser chooser = getChooser(); + IFile editedFile = chooser.getEditedFile(); + if (editedFile != null) { + chooser.syncToVariations(CFG_DEVICE|CFG_DEVICE_STATE, + editedFile, configuration, false, false); + } + } + } + + IDE.openEditor(site.getWorkbenchWindow().getActivePage(), input, + CommonXmlEditor.ID); + } catch (PartInitException e) { + AdtPlugin.log(e, null); + } + return; + } + + GraphicalEditorPart editor = mCanvas.getEditorDelegate().getGraphicalEditor(); + ConfigurationChooser chooser = editor.getConfigurationChooser(); + + Configuration originalConfiguration = chooser.getConfiguration(); + + // The new configuration is the configuration which will become the configuration + // in the layout editor's chooser + Configuration previewConfiguration = preview.getConfiguration(); + Configuration newConfiguration = previewConfiguration; + if (newConfiguration instanceof NestedConfiguration) { + // Should never use a complementing configuration for the main + // rendering's configuration; instead, create a new configuration + // with a snapshot of the configuration's current values + newConfiguration = Configuration.copy(previewConfiguration); + + // Remap all the previews to be parented to this new copy instead + // of the old one (which is no longer controlled by the chooser) + for (RenderPreview p : mPreviews) { + Configuration configuration = p.getConfiguration(); + if (configuration instanceof NestedConfiguration) { + NestedConfiguration nested = (NestedConfiguration) configuration; + nested.setParent(newConfiguration); + } + } + } + + // Make a preview for the configuration which *was* showing in the + // chooser up until this point: + RenderPreview newPreview = mCanvas.getPreview(); + if (newPreview == null) { + newPreview = RenderPreview.create(this, originalConfiguration); + } + + // Update its configuration such that it is complementing or inheriting + // from the new chosen configuration + if (previewConfiguration instanceof VaryingConfiguration) { + VaryingConfiguration varying = VaryingConfiguration.create( + (VaryingConfiguration) previewConfiguration, + newConfiguration); + varying.updateDisplayName(); + originalConfiguration = varying; + newPreview.setConfiguration(originalConfiguration); + } else if (previewConfiguration instanceof NestedConfiguration) { + NestedConfiguration nested = NestedConfiguration.create( + (NestedConfiguration) previewConfiguration, + originalConfiguration, + newConfiguration); + nested.setDisplayName(nested.computeDisplayName()); + originalConfiguration = nested; + newPreview.setConfiguration(originalConfiguration); + } + + // Replace clicked preview with preview of the formerly edited main configuration + // This doesn't work yet because the image overlay has had its image + // replaced by the configuration previews! I should make a list of them + //newPreview.setFullImage(mImageOverlay.getAwtImage()); + for (int i = 0, n = mPreviews.size(); i < n; i++) { + if (preview == mPreviews.get(i)) { + mPreviews.set(i, newPreview); + break; + } + } + + // Stash the corresponding preview (not active) on the canvas so we can + // retrieve it if clicking to some other preview later + mCanvas.setPreview(preview); + preview.setVisible(false); + + // Switch to the configuration from the clicked preview (though it's + // most likely a copy, see above) + chooser.setConfiguration(newConfiguration); + editor.changed(MASK_ALL); + + // Scroll to the top again, if necessary + mCanvas.getVerticalBar().setSelection(mCanvas.getVerticalBar().getMinimum()); + + mNeedLayout = mNeedZoom = true; + mCanvas.redraw(); + mAnimation = new SwapAnimation(preview, newPreview); + } + + /** + * Gets the preview at the given location, or null if none. This is + * currently deeply tied to where things are painted in onPaint(). + */ + RenderPreview getPreview(ControlPoint mousePos) { + if (hasPreviews()) { + int rootX = getX(); + if (mousePos.x < rootX) { + return null; + } + int rootY = getY(); + + for (RenderPreview preview : mPreviews) { + int x = rootX + preview.getX(); + int y = rootY + preview.getY(); + if (mousePos.x >= x && mousePos.x <= x + preview.getWidth()) { + if (mousePos.y >= y && mousePos.y <= y + preview.getHeight()) { + return preview; + } + } + } + } + + return null; + } + + private int getX() { + return mHScale.translate(0); + } + + private int getY() { + return mVScale.translate(0); + } + + private int getZoomX() { + Rectangle clientArea = mCanvas.getClientArea(); + int x = clientArea.x + clientArea.width - ZOOM_ICON_WIDTH; + if (x < mHScale.getScaledImgSize() + PREVIEW_HGAP) { + // No visible previews because the main image is zoomed too far + return -1; + } + + return x - 6; + } + + private int getZoomY() { + Rectangle clientArea = mCanvas.getClientArea(); + return clientArea.y + 5; + } + + /** + * Returns the height of the layout + * + * @return the height + */ + public int getHeight() { + return mLayoutHeight; + } + + /** + * Notifies that preview manager that the mouse cursor has moved to the + * given control position within the layout canvas + * + * @param mousePos the mouse position, relative to the layout canvas + */ + public void moved(ControlPoint mousePos) { + RenderPreview hovered = getPreview(mousePos); + if (hovered != mActivePreview) { + if (mActivePreview != null) { + mActivePreview.setActive(false); + } + mActivePreview = hovered; + if (mActivePreview != null) { + mActivePreview.setActive(true); + } + mCanvas.redraw(); + } + } + + /** + * Notifies that preview manager that the mouse cursor has entered the layout canvas + * + * @param mousePos the mouse position, relative to the layout canvas + */ + public void enter(ControlPoint mousePos) { + moved(mousePos); + } + + /** + * Notifies that preview manager that the mouse cursor has exited the layout canvas + * + * @param mousePos the mouse position, relative to the layout canvas + */ + public void exit(ControlPoint mousePos) { + if (mActivePreview != null) { + mActivePreview.setActive(false); + } + mActivePreview = null; + mCanvas.redraw(); + } + + /** + * Process a mouse click, and return true if it was handled by this manager + * (e.g. the click was on a preview) + * + * @param mousePos the mouse position where the click occurred + * @return true if the click occurred over a preview and was handled, false otherwise + */ + public boolean click(ControlPoint mousePos) { + // Clicked zoom? + int x = getZoomX(); + if (x > 0) { + if (mousePos.x >= x && mousePos.x <= x + ZOOM_ICON_WIDTH) { + int y = getZoomY(); + if (mousePos.y >= y && mousePos.y <= y + 4 * ZOOM_ICON_HEIGHT) { + if (mousePos.y < y + ZOOM_ICON_HEIGHT) { + zoomIn(); + } else if (mousePos.y < y + 2 * ZOOM_ICON_HEIGHT) { + zoomOut(); + } else if (mousePos.y < y + 3 * ZOOM_ICON_HEIGHT) { + zoomReset(); + } else { + selectMode(NONE); + } + return true; + } + } + } + + RenderPreview preview = getPreview(mousePos); + if (preview != null) { + boolean handled = preview.click(mousePos.x - getX() - preview.getX(), + mousePos.y - getY() - preview.getY()); + if (handled) { + // In case layout was performed, there could be a new preview + // under this coordinate now, so make sure it's hover etc + // shows up + moved(mousePos); + return true; + } + } + + return false; + } + + /** + * Returns true if there are thumbnail previews + * + * @return true if thumbnails are being shown + */ + public boolean hasPreviews() { + return mPreviews != null && !mPreviews.isEmpty(); + } + + + private void sortPreviewsByScreenSize() { + if (mPreviews != null) { + Collections.sort(mPreviews, new Comparator<RenderPreview>() { + @Override + public int compare(RenderPreview preview1, RenderPreview preview2) { + Configuration config1 = preview1.getConfiguration(); + Configuration config2 = preview2.getConfiguration(); + Device device1 = config1.getDevice(); + Device device2 = config1.getDevice(); + if (device1 != null && device2 != null) { + Screen screen1 = device1.getDefaultHardware().getScreen(); + Screen screen2 = device2.getDefaultHardware().getScreen(); + if (screen1 != null && screen2 != null) { + double delta = screen1.getDiagonalLength() + - screen2.getDiagonalLength(); + if (delta != 0.0) { + return (int) Math.signum(delta); + } else { + if (screen1.getPixelDensity() != screen2.getPixelDensity()) { + return screen1.getPixelDensity().compareTo( + screen2.getPixelDensity()); + } + } + } + + } + State state1 = config1.getDeviceState(); + State state2 = config2.getDeviceState(); + if (state1 != state2 && state1 != null && state2 != null) { + return state1.getName().compareTo(state2.getName()); + } + + return preview1.getDisplayName().compareTo(preview2.getDisplayName()); + } + }); + } + } + + private void sortPreviewsByOrientation() { + if (mPreviews != null) { + Collections.sort(mPreviews, new Comparator<RenderPreview>() { + @Override + public int compare(RenderPreview preview1, RenderPreview preview2) { + Configuration config1 = preview1.getConfiguration(); + Configuration config2 = preview2.getConfiguration(); + State state1 = config1.getDeviceState(); + State state2 = config2.getDeviceState(); + if (state1 != state2 && state1 != null && state2 != null) { + return state1.getName().compareTo(state2.getName()); + } + + return preview1.getDisplayName().compareTo(preview2.getDisplayName()); + } + }); + } + } + + /** + * Vertical scrollbar listener which updates render previews which are not + * visible and triggers a redraw + */ + private class ScrollBarListener implements SelectionListener { + @Override + public void widgetSelected(SelectionEvent e) { + if (mPreviews == null) { + return; + } + + ScrollBar bar = mCanvas.getVerticalBar(); + int selection = bar.getSelection(); + int thumb = bar.getThumb(); + int maxY = selection + thumb; + beginRenderScheduling(); + for (RenderPreview preview : mPreviews) { + if (!preview.isVisible() && preview.getY() <= maxY) { + preview.setVisible(true); + } + } + } + + @Override + public void widgetDefaultSelected(SelectionEvent e) { + } + } + + /** Animation overlay shown briefly after swapping two previews */ + private class SwapAnimation implements Runnable { + private long begin; + private long end; + private static final long DURATION = 400; // ms + private Rect initialRect1; + private Rect targetRect1; + private Rect initialRect2; + private Rect targetRect2; + private RenderPreview preview; + + SwapAnimation(RenderPreview preview1, RenderPreview preview2) { + begin = System.currentTimeMillis(); + end = begin + DURATION; + + initialRect1 = new Rect(preview1.getX(), preview1.getY(), + preview1.getWidth(), preview1.getHeight()); + + CanvasTransform hi = mCanvas.getHorizontalTransform(); + CanvasTransform vi = mCanvas.getVerticalTransform(); + initialRect2 = new Rect(hi.translate(0), vi.translate(0), + hi.getScaledImgSize(), vi.getScaledImgSize()); + preview = preview2; + } + + void tick(GC gc) { + long now = System.currentTimeMillis(); + if (now > end || mCanvas.isDisposed()) { + mAnimation = null; + return; + } + + CanvasTransform hi = mCanvas.getHorizontalTransform(); + CanvasTransform vi = mCanvas.getVerticalTransform(); + if (targetRect1 == null) { + targetRect1 = new Rect(hi.translate(0), vi.translate(0), + hi.getScaledImgSize(), vi.getScaledImgSize()); + } + double portion = (now - begin) / (double) DURATION; + Rect rect1 = new Rect( + (int) (portion * (targetRect1.x - initialRect1.x) + initialRect1.x), + (int) (portion * (targetRect1.y - initialRect1.y) + initialRect1.y), + (int) (portion * (targetRect1.w - initialRect1.w) + initialRect1.w), + (int) (portion * (targetRect1.h - initialRect1.h) + initialRect1.h)); + + if (targetRect2 == null) { + targetRect2 = new Rect(preview.getX(), preview.getY(), + preview.getWidth(), preview.getHeight()); + } + portion = (now - begin) / (double) DURATION; + Rect rect2 = new Rect( + (int) (portion * (targetRect2.x - initialRect2.x) + initialRect2.x), + (int) (portion * (targetRect2.y - initialRect2.y) + initialRect2.y), + (int) (portion * (targetRect2.w - initialRect2.w) + initialRect2.w), + (int) (portion * (targetRect2.h - initialRect2.h) + initialRect2.h)); + + gc.setForeground(gc.getDevice().getSystemColor(SWT.COLOR_GRAY)); + gc.drawRectangle(rect1.x, rect1.y, rect1.w, rect1.h); + gc.drawRectangle(rect2.x, rect2.y, rect2.w, rect2.h); + + mCanvas.getDisplay().timerExec(5, this); + } + + @Override + public void run() { + mCanvas.redraw(); + } + } + + /** + * Notifies the {@linkplain RenderPreviewManager} that the configuration used + * in the main chooser has been changed. This may require updating parent references + * in the preview configurations inheriting from it. + * + * @param oldConfiguration the previous configuration + * @param newConfiguration the new configuration in the chooser + */ + public void updateChooserConfig( + @NonNull Configuration oldConfiguration, + @NonNull Configuration newConfiguration) { + if (hasPreviews()) { + for (RenderPreview preview : mPreviews) { + Configuration configuration = preview.getConfiguration(); + if (configuration instanceof NestedConfiguration) { + NestedConfiguration nestedConfig = (NestedConfiguration) configuration; + if (nestedConfig.getParent() == oldConfiguration) { + nestedConfig.setParent(newConfiguration); + } + } + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewMode.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewMode.java new file mode 100644 index 000000000..0f06d7f8a --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderPreviewMode.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +/** + * The {@linkplain RenderPreviewMode} records what type of configurations to + * render in the layout editor + */ +public enum RenderPreviewMode { + /** Generate a set of default previews with maximum variation */ + DEFAULT, + + /** Preview all the locales */ + LOCALES, + + /** Preview all the screen sizes */ + SCREENS, + + /** Preview layout as included in other layouts */ + INCLUDES, + + /** Preview all the variations of this layout */ + VARIATIONS, + + /** Show a manually configured set of previews */ + CUSTOM, + + /** No previews */ + NONE; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderService.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderService.java new file mode 100644 index 000000000..3b9e2fc0f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/RenderService.java @@ -0,0 +1,668 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX; + +import com.android.annotations.NonNull; +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.rendering.HardwareConfigHelper; +import com.android.ide.common.rendering.LayoutLibrary; +import com.android.ide.common.rendering.RenderSecurityManager; +import com.android.ide.common.rendering.api.AssetRepository; +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.common.rendering.api.DrawableParams; +import com.android.ide.common.rendering.api.HardwareConfig; +import com.android.ide.common.rendering.api.IImageFactory; +import com.android.ide.common.rendering.api.ILayoutPullParser; +import com.android.ide.common.rendering.api.LayoutLog; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.rendering.api.Result; +import com.android.ide.common.rendering.api.SessionParams; +import com.android.ide.common.rendering.api.SessionParams.RenderingMode; +import com.android.ide.common.rendering.api.ViewInfo; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.common.resources.configuration.FolderConfiguration; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.ContextPullParser; +import com.android.ide.eclipse.adt.internal.editors.layout.ProjectCallback; +import com.android.ide.eclipse.adt.internal.editors.layout.UiElementPullParser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Configuration; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.Locale; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo.ActivityAttributes; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.IAndroidTarget; +import com.android.sdklib.devices.Device; +import com.google.common.base.Charsets; +import com.google.common.io.Files; + +import org.eclipse.core.resources.IProject; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.awt.Toolkit; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The {@link RenderService} provides rendering and layout information for + * Android layouts. This is a wrapper around the layout library. + */ +public class RenderService { + private static final Object RENDERING_LOCK = new Object(); + + /** Reference to the file being edited. Can also be used to access the {@link IProject}. */ + private final GraphicalEditorPart mEditor; + + // The following fields are inferred from the editor and not customizable by the + // client of the render service: + + private final IProject mProject; + private final ProjectCallback mProjectCallback; + private final ResourceResolver mResourceResolver; + private final int mMinSdkVersion; + private final int mTargetSdkVersion; + private final LayoutLibrary mLayoutLib; + private final IImageFactory mImageFactory; + private final HardwareConfigHelper mHardwareConfigHelper; + private final Locale mLocale; + + // The following fields are optional or configurable using the various chained + // setters: + + private UiDocumentNode mModel; + private Reference mIncludedWithin; + private RenderingMode mRenderingMode = RenderingMode.NORMAL; + private LayoutLog mLogger; + private Integer mOverrideBgColor; + private boolean mShowDecorations = true; + private Set<UiElementNode> mExpandNodes = Collections.<UiElementNode>emptySet(); + private final Object mCredential; + + /** Use the {@link #create} factory instead */ + private RenderService(GraphicalEditorPart editor, Object credential) { + mEditor = editor; + mCredential = credential; + + mProject = editor.getProject(); + LayoutCanvas canvas = editor.getCanvasControl(); + mImageFactory = canvas.getImageOverlay(); + ConfigurationChooser chooser = editor.getConfigurationChooser(); + Configuration config = chooser.getConfiguration(); + FolderConfiguration folderConfig = config.getFullConfig(); + + Device device = config.getDevice(); + assert device != null; // Should only attempt render with configuration that has device + mHardwareConfigHelper = new HardwareConfigHelper(device); + mHardwareConfigHelper.setOrientation( + folderConfig.getScreenOrientationQualifier().getValue()); + + mLayoutLib = editor.getReadyLayoutLib(true /*displayError*/); + mResourceResolver = editor.getResourceResolver(); + mProjectCallback = editor.getProjectCallback(true /*reset*/, mLayoutLib); + mMinSdkVersion = editor.getMinSdkVersion(); + mTargetSdkVersion = editor.getTargetSdkVersion(); + mLocale = config.getLocale(); + } + + private RenderService(GraphicalEditorPart editor, + Configuration configuration, ResourceResolver resourceResolver, + Object credential) { + mEditor = editor; + mCredential = credential; + + mProject = editor.getProject(); + LayoutCanvas canvas = editor.getCanvasControl(); + mImageFactory = canvas.getImageOverlay(); + FolderConfiguration folderConfig = configuration.getFullConfig(); + + Device device = configuration.getDevice(); + assert device != null; + mHardwareConfigHelper = new HardwareConfigHelper(device); + mHardwareConfigHelper.setOrientation( + folderConfig.getScreenOrientationQualifier().getValue()); + + mLayoutLib = editor.getReadyLayoutLib(true /*displayError*/); + mResourceResolver = resourceResolver != null ? resourceResolver : editor.getResourceResolver(); + mProjectCallback = editor.getProjectCallback(true /*reset*/, mLayoutLib); + mMinSdkVersion = editor.getMinSdkVersion(); + mTargetSdkVersion = editor.getTargetSdkVersion(); + mLocale = configuration.getLocale(); + } + + private RenderSecurityManager createSecurityManager() { + String projectPath = null; + String sdkPath = null; + if (RenderSecurityManager.RESTRICT_READS) { + projectPath = AdtUtils.getAbsolutePath(mProject).toFile().getPath(); + Sdk sdk = Sdk.getCurrent(); + sdkPath = sdk != null ? sdk.getSdkOsLocation() : null; + } + RenderSecurityManager securityManager = new RenderSecurityManager(sdkPath, projectPath); + securityManager.setLogger(AdtPlugin.getDefault()); + + // Make sure this is initialized before we attempt to use it from layoutlib + Toolkit.getDefaultToolkit(); + + return securityManager; + } + + /** + * Returns true if this configuration supports the given rendering + * capability + * + * @param target the target to look up the layout library for + * @param capability the capability to check + * @return true if the capability is supported + */ + public static boolean supports( + @NonNull IAndroidTarget target, + @NonNull Capability capability) { + Sdk sdk = Sdk.getCurrent(); + if (sdk != null) { + AndroidTargetData targetData = sdk.getTargetData(target); + if (targetData != null) { + LayoutLibrary layoutLib = targetData.getLayoutLibrary(); + if (layoutLib != null) { + return layoutLib.supports(capability); + } + } + } + + return false; + } + + /** + * Creates a new {@link RenderService} associated with the given editor. + * + * @param editor the editor to provide configuration data such as the render target + * @return a {@link RenderService} which can perform rendering services + */ + public static RenderService create(GraphicalEditorPart editor) { + // Delegate to editor such that it can pass its credential to the service + return editor.createRenderService(); + } + + /** + * Creates a new {@link RenderService} associated with the given editor. + * + * @param editor the editor to provide configuration data such as the render target + * @param credential the sandbox credential + * @return a {@link RenderService} which can perform rendering services + */ + @NonNull + public static RenderService create(GraphicalEditorPart editor, Object credential) { + return new RenderService(editor, credential); + } + + /** + * Creates a new {@link RenderService} associated with the given editor. + * + * @param editor the editor to provide configuration data such as the render target + * @param configuration the configuration to use (and fallback to editor for the rest) + * @param resolver a resource resolver to use to look up resources + * @return a {@link RenderService} which can perform rendering services + */ + public static RenderService create(GraphicalEditorPart editor, + Configuration configuration, ResourceResolver resolver) { + // Delegate to editor such that it can pass its credential to the service + return editor.createRenderService(configuration, resolver); + } + + /** + * Creates a new {@link RenderService} associated with the given editor. + * + * @param editor the editor to provide configuration data such as the render target + * @param configuration the configuration to use (and fallback to editor for the rest) + * @param resolver a resource resolver to use to look up resources + * @param credential the sandbox credential + * @return a {@link RenderService} which can perform rendering services + */ + public static RenderService create(GraphicalEditorPart editor, + Configuration configuration, ResourceResolver resolver, Object credential) { + return new RenderService(editor, configuration, resolver, credential); + } + + /** + * Renders the given model, using this editor's theme and screen settings, and returns + * the result as a {@link RenderSession}. + * + * @param model the model to be rendered, which can be different than the editor's own + * {@link #getModel()}. + * @param width the width to use for the layout, or -1 to use the width of the screen + * associated with this editor + * @param height the height to use for the layout, or -1 to use the height of the screen + * associated with this editor + * @param explodeNodes a set of nodes to explode, or null for none + * @param overrideBgColor If non-null, use the given color as a background to render over + * rather than the normal background requested by the theme + * @param noDecor If true, don't draw window decorations like the system bar + * @param logger a logger where rendering errors are reported + * @param renderingMode the {@link RenderingMode} to use for rendering + * @return the resulting rendered image wrapped in an {@link RenderSession} + */ + + /** + * Sets the {@link LayoutLog} to be used during rendering. If none is specified, a + * silent logger will be used. + * + * @param logger the log to be used + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setLog(LayoutLog logger) { + mLogger = logger; + return this; + } + + /** + * Sets the model to be rendered, which can be different than the editor's own + * {@link GraphicalEditorPart#getModel()}. + * + * @param model the model to be rendered + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setModel(UiDocumentNode model) { + mModel = model; + return this; + } + + /** + * Overrides the width and height to be used during rendering (which might be adjusted if + * the {@link #setRenderingMode(RenderingMode)} is {@link RenderingMode#FULL_EXPAND}. + * + * A value of -1 will make the rendering use the normal width and height coming from the + * {@link Configuration#getDevice()} object. + * + * @param overrideRenderWidth the width in pixels of the layout to be rendered + * @param overrideRenderHeight the height in pixels of the layout to be rendered + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setOverrideRenderSize(int overrideRenderWidth, int overrideRenderHeight) { + mHardwareConfigHelper.setOverrideRenderSize(overrideRenderWidth, overrideRenderHeight); + return this; + } + + /** + * Sets the max width and height to be used during rendering (which might be adjusted if + * the {@link #setRenderingMode(RenderingMode)} is {@link RenderingMode#FULL_EXPAND}. + * + * A value of -1 will make the rendering use the normal width and height coming from the + * {@link Configuration#getDevice()} object. + * + * @param maxRenderWidth the max width in pixels of the layout to be rendered + * @param maxRenderHeight the max height in pixels of the layout to be rendered + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setMaxRenderSize(int maxRenderWidth, int maxRenderHeight) { + mHardwareConfigHelper.setMaxRenderSize(maxRenderWidth, maxRenderHeight); + return this; + } + + /** + * Sets the {@link RenderingMode} to be used during rendering. If none is specified, + * the default is {@link RenderingMode#NORMAL}. + * + * @param renderingMode the rendering mode to be used + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setRenderingMode(RenderingMode renderingMode) { + mRenderingMode = renderingMode; + return this; + } + + /** + * Sets the overriding background color to be used, if any. The color should be a + * bitmask of AARRGGBB. The default is null. + * + * @param overrideBgColor the overriding background color to be used in the rendering, + * in the form of a AARRGGBB bitmask, or null to use no custom background. + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setOverrideBgColor(Integer overrideBgColor) { + mOverrideBgColor = overrideBgColor; + return this; + } + + /** + * Sets whether the rendering should include decorations such as a system bar, an + * application bar etc depending on the SDK target and theme. The default is true. + * + * @param showDecorations true if the rendering should include system bars etc. + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setDecorations(boolean showDecorations) { + mShowDecorations = showDecorations; + return this; + } + + /** + * Sets the nodes to expand during rendering. These will be padded with approximately + * 20 pixels and also highlighted by the {@link EmptyViewsOverlay}. The default is an + * empty collection. + * + * @param nodesToExpand the nodes to be expanded + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setNodesToExpand(Set<UiElementNode> nodesToExpand) { + mExpandNodes = nodesToExpand; + return this; + } + + /** + * Sets the {@link Reference} to an outer layout that this layout should be rendered + * within. The outer layout <b>must</b> contain an include tag which points to this + * layout. The default is null. + * + * @param includedWithin a reference to an outer layout to render this layout within + * @return this (such that chains of setters can be stringed together) + */ + public RenderService setIncludedWithin(Reference includedWithin) { + mIncludedWithin = includedWithin; + return this; + } + + /** Initializes any remaining optional fields after all setters have been called */ + private void finishConfiguration() { + if (mLogger == null) { + // Silent logging + mLogger = new LayoutLog(); + } + } + + /** + * Renders the model and returns the result as a {@link RenderSession}. + * @return the {@link RenderSession} resulting from rendering the current model + */ + public RenderSession createRenderSession() { + assert mModel != null : "Incomplete service config"; + finishConfiguration(); + + if (mResourceResolver == null) { + // Abort the rendering if the resources are not found. + return null; + } + + HardwareConfig hardwareConfig = mHardwareConfigHelper.getConfig(); + + UiElementPullParser modelParser = new UiElementPullParser(mModel, + false, mExpandNodes, hardwareConfig.getDensity(), mProject); + ILayoutPullParser topParser = modelParser; + + // Code to support editing included layout + // first reset the layout parser just in case. + mProjectCallback.setLayoutParser(null, null); + + if (mIncludedWithin != null) { + // Outer layout name: + String contextLayoutName = mIncludedWithin.getName(); + + // Find the layout file. + ResourceValue contextLayout = mResourceResolver.findResValue( + LAYOUT_RESOURCE_PREFIX + contextLayoutName, false /* forceFrameworkOnly*/); + if (contextLayout != null) { + File layoutFile = new File(contextLayout.getValue()); + if (layoutFile.isFile()) { + try { + // Get the name of the layout actually being edited, without the extension + // as it's what IXmlPullParser.getParser(String) will receive. + String queryLayoutName = mEditor.getLayoutResourceName(); + mProjectCallback.setLayoutParser(queryLayoutName, modelParser); + topParser = new ContextPullParser(mProjectCallback, layoutFile); + topParser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + String xmlText = Files.toString(layoutFile, Charsets.UTF_8); + topParser.setInput(new StringReader(xmlText)); + } catch (IOException e) { + AdtPlugin.log(e, null); + } catch (XmlPullParserException e) { + AdtPlugin.log(e, null); + } + } + } + } + + SessionParams params = new SessionParams( + topParser, + mRenderingMode, + mProject /* projectKey */, + hardwareConfig, + mResourceResolver, + mProjectCallback, + mMinSdkVersion, + mTargetSdkVersion, + mLogger); + + // Request margin and baseline information. + // TODO: Be smarter about setting this; start without it, and on the first request + // for an extended view info, re-render in the same session, and then set a flag + // which will cause this to create extended view info each time from then on in the + // same session + params.setExtendedViewInfoMode(true); + + params.setLocale(mLocale.toLocaleId()); + params.setAssetRepository(new AssetRepository()); + + ManifestInfo manifestInfo = ManifestInfo.get(mProject); + try { + params.setRtlSupport(manifestInfo.isRtlSupported()); + } catch (Exception e) { + // ignore. + } + if (!mShowDecorations) { + params.setForceNoDecor(); + } else { + try { + params.setAppLabel(manifestInfo.getApplicationLabel()); + params.setAppIcon(manifestInfo.getApplicationIcon()); + String activity = mEditor.getConfigurationChooser().getConfiguration().getActivity(); + if (activity != null) { + ActivityAttributes info = manifestInfo.getActivityAttributes(activity); + if (info != null) { + if (info.getLabel() != null) { + params.setAppLabel(info.getLabel()); + } + if (info.getIcon() != null) { + params.setAppIcon(info.getIcon()); + } + } + } + } catch (Exception e) { + // ignore. + } + } + + if (mOverrideBgColor != null) { + params.setOverrideBgColor(mOverrideBgColor.intValue()); + } + + // set the Image Overlay as the image factory. + params.setImageFactory(mImageFactory); + + mProjectCallback.setLogger(mLogger); + mProjectCallback.setResourceResolver(mResourceResolver); + RenderSecurityManager securityManager = createSecurityManager(); + try { + securityManager.setActive(true, mCredential); + synchronized (RENDERING_LOCK) { + return mLayoutLib.createSession(params); + } + } catch (RuntimeException t) { + // Exceptions from the bridge + mLogger.error(null, t.getLocalizedMessage(), t, null); + throw t; + } finally { + securityManager.dispose(mCredential); + mProjectCallback.setLogger(null); + mProjectCallback.setResourceResolver(null); + } + } + + /** + * Renders the given resource value (which should refer to a drawable) and returns it + * as an image + * + * @param drawableResourceValue the drawable resource value to be rendered, or null + * @return the image, or null if something went wrong + */ + public BufferedImage renderDrawable(ResourceValue drawableResourceValue) { + if (drawableResourceValue == null) { + return null; + } + + finishConfiguration(); + + HardwareConfig hardwareConfig = mHardwareConfigHelper.getConfig(); + + DrawableParams params = new DrawableParams(drawableResourceValue, mProject, hardwareConfig, + mResourceResolver, mProjectCallback, mMinSdkVersion, + mTargetSdkVersion, mLogger); + params.setAssetRepository(new AssetRepository()); + params.setForceNoDecor(); + Result result = mLayoutLib.renderDrawable(params); + if (result != null && result.isSuccess()) { + Object data = result.getData(); + if (data instanceof BufferedImage) { + return (BufferedImage) data; + } + } + + return null; + } + + /** + * Measure the children of the given parent node, applying the given filter to the + * pull parser's attribute values. + * + * @param parent the parent node to measure children for + * @param filter the filter to apply to the attribute values + * @return a map from node children of the parent to new bounds of the nodes + */ + public Map<INode, Rect> measureChildren(INode parent, + final IClientRulesEngine.AttributeFilter filter) { + finishConfiguration(); + HardwareConfig hardwareConfig = mHardwareConfigHelper.getConfig(); + + final NodeFactory mNodeFactory = mEditor.getCanvasControl().getNodeFactory(); + UiElementNode parentNode = ((NodeProxy) parent).getNode(); + UiElementPullParser topParser = new UiElementPullParser(parentNode, + false, Collections.<UiElementNode>emptySet(), hardwareConfig.getDensity(), + mProject) { + @Override + public String getAttributeValue(String namespace, String localName) { + if (filter != null) { + Object cookie = getViewCookie(); + if (cookie instanceof UiViewElementNode) { + NodeProxy node = mNodeFactory.create((UiViewElementNode) cookie); + if (node != null) { + String value = filter.getAttribute(node, namespace, localName); + if (value != null) { + return value; + } + // null means no preference, not "unset". + } + } + } + + return super.getAttributeValue(namespace, localName); + } + + /** + * The parser usually assumes that the top level node is a document node that + * should be skipped, and that's not the case when we render in the middle of + * the tree, so override {@link UiElementPullParser#onNextFromStartDocument} + * to change this behavior + */ + @Override + public void onNextFromStartDocument() { + mParsingState = START_TAG; + } + }; + + SessionParams params = new SessionParams( + topParser, + RenderingMode.FULL_EXPAND, + mProject /* projectKey */, + hardwareConfig, + mResourceResolver, + mProjectCallback, + mMinSdkVersion, + mTargetSdkVersion, + mLogger); + params.setLayoutOnly(); + params.setForceNoDecor(); + params.setAssetRepository(new AssetRepository()); + + RenderSession session = null; + mProjectCallback.setLogger(mLogger); + mProjectCallback.setResourceResolver(mResourceResolver); + RenderSecurityManager securityManager = createSecurityManager(); + try { + securityManager.setActive(true, mCredential); + synchronized (RENDERING_LOCK) { + session = mLayoutLib.createSession(params); + } + if (session.getResult().isSuccess()) { + assert session.getRootViews().size() == 1; + ViewInfo root = session.getRootViews().get(0); + List<ViewInfo> children = root.getChildren(); + Map<INode, Rect> map = new HashMap<INode, Rect>(children.size()); + for (ViewInfo info : children) { + if (info.getCookie() instanceof UiViewElementNode) { + UiViewElementNode uiNode = (UiViewElementNode) info.getCookie(); + NodeProxy node = mNodeFactory.create(uiNode); + map.put(node, new Rect(info.getLeft(), info.getTop(), + info.getRight() - info.getLeft(), + info.getBottom() - info.getTop())); + } + } + + return map; + } + } catch (RuntimeException t) { + // Exceptions from the bridge + mLogger.error(null, t.getLocalizedMessage(), t, null); + throw t; + } finally { + securityManager.dispose(mCredential); + mProjectCallback.setLogger(null); + mProjectCallback.setResourceResolver(null); + if (session != null) { + session.dispose(); + } + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ResizeGesture.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ResizeGesture.java new file mode 100644 index 000000000..4d51c07de --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ResizeGesture.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.api.DropFeedback; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.ResizePolicy; +import com.android.ide.common.api.SegmentType; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionHandle.Position; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.utils.Pair; + +import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.graphics.GC; + +import java.util.Collections; +import java.util.List; + +/** + * A {@link ResizeGesture} is a gesture for resizing a selected widget. It is initiated + * by a drag of a {@link SelectionHandle}. + */ +public class ResizeGesture extends Gesture { + /** The {@link Overlay} drawn for the gesture feedback. */ + private ResizeOverlay mOverlay; + + /** The canvas associated with this gesture. */ + private LayoutCanvas mCanvas; + + /** The selection handle we're dragging to perform this resize */ + private SelectionHandle mHandle; + + private NodeProxy mParentNode; + private NodeProxy mChildNode; + private DropFeedback mFeedback; + private ResizePolicy mResizePolicy; + private SegmentType mHorizontalEdge; + private SegmentType mVerticalEdge; + + /** + * Creates a new marquee selection (selection swiping). + * + * @param canvas The canvas where selection is performed. + * @param item The selected item the handle corresponds to + * @param handle The handle being dragged to perform the resize + */ + public ResizeGesture(LayoutCanvas canvas, SelectionItem item, SelectionHandle handle) { + mCanvas = canvas; + mHandle = handle; + + mChildNode = item.getNode(); + mParentNode = (NodeProxy) mChildNode.getParent(); + mResizePolicy = item.getResizePolicy(); + mHorizontalEdge = getHorizontalEdgeType(mHandle); + mVerticalEdge = getVerticalEdgeType(mHandle); + } + + @Override + public void begin(ControlPoint pos, int startMask) { + super.begin(pos, startMask); + + mCanvas.getSelectionOverlay().setHidden(true); + + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + Rect newBounds = getNewBounds(pos); + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + CanvasViewInfo childInfo = viewHierarchy.findViewInfoFor(mChildNode); + CanvasViewInfo parentInfo = viewHierarchy.findViewInfoFor(mParentNode); + Object childView = childInfo != null ? childInfo.getViewObject() : null; + Object parentView = parentInfo != null ? parentInfo.getViewObject() : null; + mFeedback = rulesEngine.callOnResizeBegin(mChildNode, mParentNode, newBounds, + mHorizontalEdge, mVerticalEdge, childView, parentView); + update(pos); + mCanvas.getGestureManager().updateMessage(mFeedback); + } + + @Override + public boolean keyPressed(KeyEvent event) { + update(mCanvas.getGestureManager().getCurrentControlPoint()); + mCanvas.redraw(); + return true; + } + + @Override + public boolean keyReleased(KeyEvent event) { + update(mCanvas.getGestureManager().getCurrentControlPoint()); + mCanvas.redraw(); + return true; + } + + @Override + public void update(ControlPoint pos) { + super.update(pos); + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + Rect newBounds = getNewBounds(pos); + int modifierMask = mCanvas.getGestureManager().getRuleModifierMask(); + rulesEngine.callOnResizeUpdate(mFeedback, mChildNode, mParentNode, newBounds, + modifierMask); + mCanvas.getGestureManager().updateMessage(mFeedback); + } + + @Override + public void end(ControlPoint pos, boolean canceled) { + super.end(pos, canceled); + + if (!canceled) { + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + Rect newBounds = getNewBounds(pos); + rulesEngine.callOnResizeEnd(mFeedback, mChildNode, mParentNode, newBounds); + } + + mCanvas.getSelectionOverlay().setHidden(false); + } + + @Override + public Pair<Boolean, Boolean> getTooltipPosition() { + return Pair.of(mHorizontalEdge != SegmentType.TOP, mVerticalEdge != SegmentType.LEFT); + } + + /** + * For the new mouse position, compute the resized bounds (the bounding rectangle that + * the view should be resized to). This is not just a width or height, since in some + * cases resizing will change the x/y position of the view as well (for example, in + * RelativeLayout or in AbsoluteLayout). + */ + private Rect getNewBounds(ControlPoint pos) { + LayoutPoint p = pos.toLayout(); + LayoutPoint start = mStart.toLayout(); + Rect b = mChildNode.getBounds(); + Position direction = mHandle.getPosition(); + + int x = b.x; + int y = b.y; + int w = b.w; + int h = b.h; + int deltaX = p.x - start.x; + int deltaY = p.y - start.y; + + if (deltaX == 0 && deltaY == 0) { + // No move - just use the existing bounds + return b; + } + + if (mResizePolicy.isAspectPreserving() && w != 0 && h != 0) { + double aspectRatio = w / (double) h; + int newW = Math.abs(b.w + (direction.isLeft() ? -deltaX : deltaX)); + int newH = Math.abs(b.h + (direction.isTop() ? -deltaY : deltaY)); + double newAspectRatio = newW / (double) newH; + if (newH == 0 || newAspectRatio > aspectRatio) { + deltaY = (int) (deltaX / aspectRatio); + } else { + deltaX = (int) (deltaY * aspectRatio); + } + } + if (direction.isLeft()) { + // The user is dragging the left edge, so the position is anchored on the + // right. + int x2 = b.x + b.w; + int nx1 = b.x + deltaX; + if (nx1 <= x2) { + x = nx1; + w = x2 - x; + } else { + w = 0; + x = x2; + } + } else if (direction.isRight()) { + // The user is dragging the right edge, so the position is anchored on the + // left. + int nx2 = b.x + b.w + deltaX; + if (nx2 >= b.x) { + w = nx2 - b.x; + } else { + w = 0; + } + } else { + assert direction == Position.BOTTOM_MIDDLE || direction == Position.TOP_MIDDLE; + } + + if (direction.isTop()) { + // The user is dragging the top edge, so the position is anchored on the + // bottom. + int y2 = b.y + b.h; + int ny1 = b.y + deltaY; + if (ny1 < y2) { + y = ny1; + h = y2 - y; + } else { + h = 0; + y = y2; + } + } else if (direction.isBottom()) { + // The user is dragging the bottom edge, so the position is anchored on the + // top. + int ny2 = b.y + b.h + deltaY; + if (ny2 >= b.y) { + h = ny2 - b.y; + } else { + h = 0; + } + } else { + assert direction == Position.LEFT_MIDDLE || direction == Position.RIGHT_MIDDLE; + } + + return new Rect(x, y, w, h); + } + + private static SegmentType getHorizontalEdgeType(SelectionHandle handle) { + switch (handle.getPosition()) { + case BOTTOM_LEFT: + case BOTTOM_RIGHT: + case BOTTOM_MIDDLE: + return SegmentType.BOTTOM; + case LEFT_MIDDLE: + case RIGHT_MIDDLE: + return null; + case TOP_LEFT: + case TOP_MIDDLE: + case TOP_RIGHT: + return SegmentType.TOP; + default: assert false : handle.getPosition(); + } + return null; + } + + private static SegmentType getVerticalEdgeType(SelectionHandle handle) { + switch (handle.getPosition()) { + case TOP_LEFT: + case LEFT_MIDDLE: + case BOTTOM_LEFT: + return SegmentType.LEFT; + case BOTTOM_MIDDLE: + case TOP_MIDDLE: + return null; + case TOP_RIGHT: + case RIGHT_MIDDLE: + case BOTTOM_RIGHT: + return SegmentType.RIGHT; + default: assert false : handle.getPosition(); + } + return null; + } + + + @Override + public List<Overlay> createOverlays() { + mOverlay = new ResizeOverlay(); + return Collections.<Overlay> singletonList(mOverlay); + } + + /** + * An {@link Overlay} to paint the resize feedback. This just delegates to the + * layout rule for the parent which is handling the resizing. + */ + private class ResizeOverlay extends Overlay { + @Override + public void paint(GC gc) { + if (mChildNode != null && mFeedback != null) { + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + rulesEngine.callDropFeedbackPaint(mCanvas.getGcWrapper(), mChildNode, mFeedback); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionHandle.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionHandle.java new file mode 100644 index 000000000..c2db2431c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionHandle.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.swt.SWT; + +/** + * A selection handle is a small rectangle on the border of a selected view which lets you + * change the size of the view by dragging it. + */ +public class SelectionHandle { + /** + * Size of the selection handle radius, in control coordinates. Note that this isn't + * necessarily a <b>circular</b> radius; in the case of a rectangular handle, the + * width and the height are both equal to this radius. + * Note also that this radius is in <b>control</b> coordinates, whereas the rest + * of the class operates in layout coordinates. This is because we do not want the + * selection handles to grow or shrink along with the screen zoom; they are always + * at the given pixel size in the control. + */ + public final static int PIXEL_RADIUS = 3; + + /** + * Extra number of pixels to look beyond the actual radius of the selection handle + * when matching mouse positions to handles + */ + public final static int PIXEL_MARGIN = 2; + + /** The position of the handle in the selection rectangle */ + enum Position { + TOP_MIDDLE(SWT.CURSOR_SIZEN), + TOP_RIGHT(SWT.CURSOR_SIZENE), + RIGHT_MIDDLE(SWT.CURSOR_SIZEE), + BOTTOM_RIGHT(SWT.CURSOR_SIZESE), + BOTTOM_MIDDLE(SWT.CURSOR_SIZES), + BOTTOM_LEFT(SWT.CURSOR_SIZESW), + LEFT_MIDDLE(SWT.CURSOR_SIZEW), + TOP_LEFT(SWT.CURSOR_SIZENW); + + /** Corresponding SWT cursor value */ + private int mSwtCursor; + + private Position(int swtCursor) { + mSwtCursor = swtCursor; + } + + private int getCursorType() { + return mSwtCursor; + } + + /** Is the {@link SelectionHandle} somewhere on the left edge? */ + boolean isLeft() { + return this == TOP_LEFT || this == LEFT_MIDDLE || this == BOTTOM_LEFT; + } + + /** Is the {@link SelectionHandle} somewhere on the right edge? */ + boolean isRight() { + return this == TOP_RIGHT || this == RIGHT_MIDDLE || this == BOTTOM_RIGHT; + } + + /** Is the {@link SelectionHandle} somewhere on the top edge? */ + boolean isTop() { + return this == TOP_LEFT || this == TOP_MIDDLE || this == TOP_RIGHT; + } + + /** Is the {@link SelectionHandle} somewhere on the bottom edge? */ + boolean isBottom() { + return this == BOTTOM_LEFT || this == BOTTOM_MIDDLE || this == BOTTOM_RIGHT; + } + }; + + /** The x coordinate of the center of the selection handle */ + public final int centerX; + /** The y coordinate of the center of the selection handle */ + public final int centerY; + /** The position of the handle in the selection rectangle */ + private final Position mPosition; + + /** + * Constructs a new {@link SelectionHandle} at the given layout coordinate + * corresponding to a handle at the given {@link Position}. + * + * @param centerX the x coordinate of the center of the selection handle + * @param centerY y coordinate of the center of the selection handle + * @param position the position of the handle in the selection rectangle + */ + public SelectionHandle(int centerX, int centerY, Position position) { + mPosition = position; + this.centerX = centerX; + this.centerY = centerY; + } + + /** + * Determines whether the given {@link LayoutPoint} is within the given distance in + * layout coordinates. The distance should incorporate at least the equivalent + * distance to the control coordinate space {@link #PIXEL_RADIUS}, but usually with a + * few extra pixels added in to make the corners easier to target. + * + * @param point the mouse position in layout coordinates + * @param distance the distance from the center of the handle to check whether the + * point fits within + * @return true if the given point is within the given distance of this handle + */ + public boolean contains(LayoutPoint point, int distance) { + return (point.x >= centerX - distance + && point.x <= centerX + distance + && point.y >= centerY - distance + && point.y <= centerY + distance); + } + + /** + * Returns the position of the handle in the selection rectangle + * + * @return the position of the handle in the selection rectangle + */ + public Position getPosition() { + return mPosition; + } + + /** + * Returns the SWT cursor type to use for this selection handle + * + * @return the position of the handle in the selection rectangle + */ + public int getSwtCursorType() { + return mPosition.getCursorType(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionHandles.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionHandles.java new file mode 100644 index 000000000..6d7f34a66 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionHandles.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.api.ResizePolicy; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionHandle.Position; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +/** + * The {@link SelectionHandles} of a {@link SelectionItem} are the set of + * {@link SelectionHandle} objects (possibly empty, for non-resizable objects) the user + * can manipulate to resize a widget. + */ +public class SelectionHandles implements Iterable<SelectionHandle> { + private final SelectionItem mItem; + private List<SelectionHandle> mHandles; + + /** + * Constructs a new {@link SelectionHandles} object for the given {link + * {@link SelectionItem} + * @param item the item to create {@link SelectionHandles} for + */ + public SelectionHandles(SelectionItem item) { + mItem = item; + + createHandles(item.getCanvas()); + } + + /** + * Find a specific {@link SelectionHandle} from this set of {@link SelectionHandles}, + * which is within the given distance (in layout coordinates) from the center of the + * {@link SelectionHandle}. + * + * @param point the mouse position (in layout coordinates) to test + * @param distance the maximum distance from the handle center to accept + * @return a {@link SelectionHandle} under the point, or null if not found + */ + public SelectionHandle findHandle(LayoutPoint point, int distance) { + for (SelectionHandle handle : mHandles) { + if (handle.contains(point, distance)) { + return handle; + } + } + + return null; + } + + /** + * Create the {@link SelectionHandle} objects for the selection item, according to its + * {@link ResizePolicy}. + */ + private void createHandles(LayoutCanvas canvas) { + NodeProxy selectedNode = mItem.getNode(); + Rect r = selectedNode.getBounds(); + if (!r.isValid()) { + mHandles = Collections.emptyList(); + return; + } + + ResizePolicy resizability = mItem.getResizePolicy(); + if (resizability.isResizable()) { + mHandles = new ArrayList<SelectionHandle>(8); + boolean left = resizability.leftAllowed(); + boolean right = resizability.rightAllowed(); + boolean top = resizability.topAllowed(); + boolean bottom = resizability.bottomAllowed(); + int x1 = r.x; + int y1 = r.y; + int w = r.w; + int h = r.h; + int x2 = x1 + w; + int y2 = y1 + h; + + Margins insets = canvas.getInsets(mItem.getNode().getFqcn()); + if (insets != null) { + x1 += insets.left; + x2 -= insets.right; + y1 += insets.top; + y2 -= insets.bottom; + } + + int mx = (x1 + x2) / 2; + int my = (y1 + y2) / 2; + + if (left) { + mHandles.add(new SelectionHandle(x1, my, Position.LEFT_MIDDLE)); + if (top) { + mHandles.add(new SelectionHandle(x1, y1, Position.TOP_LEFT)); + } + if (bottom) { + mHandles.add(new SelectionHandle(x1, y2, Position.BOTTOM_LEFT)); + } + } + if (right) { + mHandles.add(new SelectionHandle(x2, my, Position.RIGHT_MIDDLE)); + if (top) { + mHandles.add(new SelectionHandle(x2, y1, Position.TOP_RIGHT)); + } + if (bottom) { + mHandles.add(new SelectionHandle(x2, y2, Position.BOTTOM_RIGHT)); + } + } + if (top) { + mHandles.add(new SelectionHandle(mx, y1, Position.TOP_MIDDLE)); + } + if (bottom) { + mHandles.add(new SelectionHandle(mx, y2, Position.BOTTOM_MIDDLE)); + } + } else { + mHandles = Collections.emptyList(); + } + } + + // Implements Iterable<SelectionHandle> + @Override + public Iterator<SelectionHandle> iterator() { + return mHandles.iterator(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionItem.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionItem.java new file mode 100644 index 000000000..d104e379e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionItem.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.ResizePolicy; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; + +import org.eclipse.swt.graphics.Rectangle; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents one selection in {@link LayoutCanvas}. + */ +class SelectionItem { + + /** The associated {@link LayoutCanvas} */ + private LayoutCanvas mCanvas; + + /** Current selected view info. Can be null. */ + private final CanvasViewInfo mCanvasViewInfo; + + /** Current selection border rectangle. Null when mCanvasViewInfo is null . */ + private final Rectangle mRect; + + /** The node proxy for drawing the selection. Null when mCanvasViewInfo is null. */ + private final NodeProxy mNodeProxy; + + /** The resize policy for this selection item */ + private ResizePolicy mResizePolicy; + + /** The selection handles for this item */ + private SelectionHandles mHandles; + + /** + * Creates a new {@link SelectionItem} object. + * @param canvas the associated canvas + * @param canvasViewInfo The view info being selected. Must not be null. + */ + public SelectionItem(LayoutCanvas canvas, CanvasViewInfo canvasViewInfo) { + assert canvasViewInfo != null; + + mCanvas = canvas; + mCanvasViewInfo = canvasViewInfo; + + if (canvasViewInfo == null) { + mRect = null; + mNodeProxy = null; + } else { + Rectangle r = canvasViewInfo.getSelectionRect(); + mRect = new Rectangle(r.x, r.y, r.width, r.height); + mNodeProxy = mCanvas.getNodeFactory().create(canvasViewInfo); + } + } + + /** + * Returns true when this selection item represents the root, the top level + * layout element in the editor. + * + * @return True if and only if this element is at the root of the hierarchy + */ + public boolean isRoot() { + return mCanvasViewInfo.isRoot(); + } + + /** + * Returns true if this item represents a widget that should not be manipulated by the + * user. + * + * @return True if this widget should not be manipulated directly by the user + */ + public boolean isHidden() { + return mCanvasViewInfo.isHidden(); + } + + /** + * Returns the selected view info. Cannot be null. + * + * @return the selected view info. Cannot be null. + */ + @NonNull + public CanvasViewInfo getViewInfo() { + return mCanvasViewInfo; + } + + /** + * Returns the selected node. + * + * @return the selected node, or null + */ + @Nullable + public UiViewElementNode getUiNode() { + return mCanvasViewInfo.getUiViewNode(); + } + + /** + * Returns the selection border rectangle. Cannot be null. + * + * @return the selection border rectangle, never null + */ + public Rectangle getRect() { + return mRect; + } + + /** Returns the node associated with this selection (may be null) */ + @Nullable + NodeProxy getNode() { + return mNodeProxy; + } + + /** Returns the canvas associated with this selection (never null) */ + @NonNull + LayoutCanvas getCanvas() { + return mCanvas; + } + + //---- + + /** + * Gets the XML text from the given selection for a text transfer. + * The returned string can be empty but not null. + */ + @NonNull + static String getAsText(LayoutCanvas canvas, List<SelectionItem> selection) { + StringBuilder sb = new StringBuilder(); + + LayoutEditorDelegate layoutEditorDelegate = canvas.getEditorDelegate(); + for (SelectionItem cs : selection) { + CanvasViewInfo vi = cs.getViewInfo(); + UiViewElementNode key = vi.getUiViewNode(); + Node node = key.getXmlNode(); + String t = layoutEditorDelegate.getEditor().getXmlText(node); + if (t != null) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(t); + } + } + + return sb.toString(); + } + + /** + * Returns elements representing the given selection of canvas items. + * + * @param items Items to wrap in elements + * @return An array of wrapper elements. Never null. + */ + @NonNull + static SimpleElement[] getAsElements(@NonNull List<SelectionItem> items) { + return getAsElements(items, null); + } + + /** + * Returns elements representing the given selection of canvas items. + * + * @param items Items to wrap in elements + * @param primary The primary selected item which should be listed first + * @return An array of wrapper elements. Never null. + */ + @NonNull + static SimpleElement[] getAsElements( + @NonNull List<SelectionItem> items, + @Nullable SelectionItem primary) { + List<SimpleElement> elements = new ArrayList<SimpleElement>(); + + if (primary != null) { + CanvasViewInfo vi = primary.getViewInfo(); + SimpleElement e = vi.toSimpleElement(); + e.setSelectionItem(primary); + elements.add(e); + } + + for (SelectionItem cs : items) { + if (cs == primary) { + // Already handled + continue; + } + + CanvasViewInfo vi = cs.getViewInfo(); + SimpleElement e = vi.toSimpleElement(); + e.setSelectionItem(cs); + elements.add(e); + } + + return elements.toArray(new SimpleElement[elements.size()]); + } + + /** + * Returns true if this selection item is a layout + * + * @return true if this selection item is a layout + */ + public boolean isLayout() { + UiViewElementNode node = mCanvasViewInfo.getUiViewNode(); + if (node != null) { + return node.getDescriptor().hasChildren(); + } else { + return false; + } + } + + /** + * Returns the {@link SelectionHandles} for this {@link SelectionItem}. Never null. + * + * @return the {@link SelectionHandles} for this {@link SelectionItem}, never null + */ + @NonNull + public SelectionHandles getSelectionHandles() { + if (mHandles == null) { + mHandles = new SelectionHandles(this); + } + + return mHandles; + } + + /** + * Returns the {@link ResizePolicy} for this item + * + * @return the {@link ResizePolicy} for this item, never null + */ + @NonNull + public ResizePolicy getResizePolicy() { + if (mResizePolicy == null && mNodeProxy != null) { + mResizePolicy = ViewMetadataRepository.get().getResizePolicy(mNodeProxy.getFqcn()); + } + + return mResizePolicy; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionManager.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionManager.java new file mode 100644 index 000000000..eb3d6f290 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionManager.java @@ -0,0 +1,1262 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.FQCN_SPACE; +import static com.android.SdkConstants.FQCN_SPACE_V7; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionHandle.PIXEL_MARGIN; +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionHandle.PIXEL_RADIUS; + +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.RuleAction; +import com.android.ide.common.layout.BaseViewRule; +import com.android.ide.common.layout.GridLayoutRule; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResourceWizard; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResult; +import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; +import com.android.resources.ResourceType; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.ListenerList; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.util.SafeRunnable; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.ISelectionProvider; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.jface.viewers.TreeSelection; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.MenuDetectEvent; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.ui.IWorkbenchPartSite; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; +import java.util.Set; + +/** + * The {@link SelectionManager} manages the selection in the canvas editor. + * It holds (and can be asked about) the set of selected items, and it also has + * operations for manipulating the selection - such as toggling items, copying + * the selection to the clipboard, etc. + * <p/> + * This class implements {@link ISelectionProvider} so that it can delegate + * the selection provider from the {@link LayoutCanvasViewer}. + * <p/> + * Note that {@link LayoutCanvasViewer} sets a selection change listener on this + * manager so that it can invoke its own fireSelectionChanged when the canvas' + * selection changes. + */ +public class SelectionManager implements ISelectionProvider { + + private LayoutCanvas mCanvas; + + /** The current selection list. The list is never null, however it can be empty. */ + private final LinkedList<SelectionItem> mSelections = new LinkedList<SelectionItem>(); + + /** An unmodifiable view of {@link #mSelections}. */ + private final List<SelectionItem> mUnmodifiableSelection = + Collections.unmodifiableList(mSelections); + + /** Barrier set when updating the selection to prevent from recursively + * invoking ourselves. */ + private boolean mInsideUpdateSelection; + + /** + * The <em>current</em> alternate selection, if any, which changes when the Alt key is + * used during a selection. Can be null. + */ + private CanvasAlternateSelection mAltSelection; + + /** List of clients listening to selection changes. */ + private final ListenerList mSelectionListeners = new ListenerList(); + + /** + * Constructs a new {@link SelectionManager} associated with the given layout canvas. + * + * @param layoutCanvas The layout canvas to create a {@link SelectionManager} for. + */ + public SelectionManager(LayoutCanvas layoutCanvas) { + mCanvas = layoutCanvas; + } + + @Override + public void addSelectionChangedListener(ISelectionChangedListener listener) { + mSelectionListeners.add(listener); + } + + @Override + public void removeSelectionChangedListener(ISelectionChangedListener listener) { + mSelectionListeners.remove(listener); + } + + /** + * Returns the native {@link SelectionItem} list. + * + * @return An immutable list of {@link SelectionItem}. Can be empty but not null. + */ + @NonNull + List<SelectionItem> getSelections() { + return mUnmodifiableSelection; + } + + /** + * Return a snapshot/copy of the selection. Useful for clipboards etc where we + * don't want the returned copy to be affected by future edits to the selection. + * + * @return A copy of the current selection. Never null. + */ + @NonNull + public List<SelectionItem> getSnapshot() { + if (mSelectionListeners.isEmpty()) { + return Collections.emptyList(); + } + + return new ArrayList<SelectionItem>(mSelections); + } + + /** + * Returns a {@link TreeSelection} where each {@link TreePath} item is + * actually a {@link CanvasViewInfo}. + */ + @Override + public ISelection getSelection() { + if (mSelections.isEmpty()) { + return TreeSelection.EMPTY; + } + + ArrayList<TreePath> paths = new ArrayList<TreePath>(); + + for (SelectionItem cs : mSelections) { + CanvasViewInfo vi = cs.getViewInfo(); + if (vi != null) { + paths.add(getTreePath(vi)); + } + } + + return new TreeSelection(paths.toArray(new TreePath[paths.size()])); + } + + /** + * Create a {@link TreePath} from the given view info + * + * @param viewInfo the view info to look up a tree path for + * @return a {@link TreePath} for the given view info + */ + public static TreePath getTreePath(CanvasViewInfo viewInfo) { + ArrayList<Object> segments = new ArrayList<Object>(); + while (viewInfo != null) { + segments.add(0, viewInfo); + viewInfo = viewInfo.getParent(); + } + + return new TreePath(segments.toArray()); + } + + /** + * Sets the selection. It must be an {@link ITreeSelection} where each segment + * of the tree path is a {@link CanvasViewInfo}. A null selection is considered + * as an empty selection. + * <p/> + * This method is invoked by {@link LayoutCanvasViewer#setSelection(ISelection)} + * in response to an <em>outside</em> selection (compatible with ours) that has + * changed. Typically it means the outline selection has changed and we're + * synchronizing ours to match. + */ + @Override + public void setSelection(ISelection selection) { + if (mInsideUpdateSelection) { + return; + } + + boolean changed = false; + try { + mInsideUpdateSelection = true; + + if (selection == null) { + selection = TreeSelection.EMPTY; + } + + if (selection instanceof ITreeSelection) { + ITreeSelection treeSel = (ITreeSelection) selection; + + if (treeSel.isEmpty()) { + // Clear existing selection, if any + if (!mSelections.isEmpty()) { + mSelections.clear(); + mAltSelection = null; + updateActionsFromSelection(); + redraw(); + } + return; + } + + boolean redoLayout = false; + + // Create a list of all currently selected view infos + Set<CanvasViewInfo> oldSelected = new HashSet<CanvasViewInfo>(); + for (SelectionItem cs : mSelections) { + oldSelected.add(cs.getViewInfo()); + } + + // Go thru new selection and take care of selecting new items + // or marking those which are the same as in the current selection + for (TreePath path : treeSel.getPaths()) { + Object seg = path.getLastSegment(); + if (seg instanceof CanvasViewInfo) { + CanvasViewInfo newVi = (CanvasViewInfo) seg; + if (oldSelected.contains(newVi)) { + // This view info is already selected. Remove it from the + // oldSelected list so that we don't deselect it later. + oldSelected.remove(newVi); + } else { + // This view info is not already selected. Select it now. + + // reset alternate selection if any + mAltSelection = null; + // otherwise add it. + mSelections.add(createSelection(newVi)); + changed = true; + } + if (newVi.isInvisible()) { + redoLayout = true; + } + } else { + // Unrelated selection (e.g. user clicked in the Project Explorer + // or something) -- just ignore these + return; + } + } + + // Deselect old selected items that are not in the new one + for (CanvasViewInfo vi : oldSelected) { + if (vi.isExploded()) { + redoLayout = true; + } + deselect(vi); + changed = true; + } + + if (redoLayout) { + mCanvas.getEditorDelegate().recomputeLayout(); + } + } + } finally { + mInsideUpdateSelection = false; + } + + if (changed) { + redraw(); + fireSelectionChanged(); + updateActionsFromSelection(); + } + } + + /** + * The menu has been activated; ensure that the menu click is over the existing + * selection, and if not, update the selection. + * + * @param e the {@link MenuDetectEvent} which triggered the menu + */ + public void menuClick(MenuDetectEvent e) { + LayoutPoint p = ControlPoint.create(mCanvas, e).toLayout(); + + // Right click button is used to display a context menu. + // If there's an existing selection and the click is anywhere in this selection + // and there are no modifiers being used, we don't want to change the selection. + // Otherwise we select the item under the cursor. + + for (SelectionItem cs : mSelections) { + if (cs.isRoot()) { + continue; + } + if (cs.getRect().contains(p.x, p.y)) { + // The cursor is inside the selection. Don't change anything. + return; + } + } + + CanvasViewInfo vi = mCanvas.getViewHierarchy().findViewInfoAt(p); + selectSingle(vi); + } + + /** + * Performs selection for a mouse event. + * <p/> + * Shift key (or Command on the Mac) is used to toggle in multi-selection. + * Alt key is used to cycle selection through objects at the same level than + * the one pointed at (i.e. click on an object then alt-click to cycle). + * + * @param e The mouse event which triggered the selection. Cannot be null. + * The modifier key mask will be used to determine whether this + * is a plain select or a toggle, etc. + */ + public void select(MouseEvent e) { + boolean isMultiClick = (e.stateMask & SWT.SHIFT) != 0 || + // On Mac, the Command key is the normal toggle accelerator + ((SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_DARWIN) && + (e.stateMask & SWT.COMMAND) != 0); + boolean isCycleClick = (e.stateMask & SWT.ALT) != 0; + + LayoutPoint p = ControlPoint.create(mCanvas, e).toLayout(); + + if (e.button == 3) { + // Right click button is used to display a context menu. + // If there's an existing selection and the click is anywhere in this selection + // and there are no modifiers being used, we don't want to change the selection. + // Otherwise we select the item under the cursor. + + if (!isCycleClick && !isMultiClick) { + for (SelectionItem cs : mSelections) { + if (cs.getRect().contains(p.x, p.y)) { + // The cursor is inside the selection. Don't change anything. + return; + } + } + } + + } else if (e.button != 1) { + // Click was done with something else than the left button for normal selection + // or the right button for context menu. + // We don't use mouse button 2 yet (middle mouse, or scroll wheel?) for + // anything, so let's not change the selection. + return; + } + + CanvasViewInfo vi = mCanvas.getViewHierarchy().findViewInfoAt(p); + + if (vi != null && vi.isHidden()) { + vi = vi.getParent(); + } + + if (isMultiClick && !isCycleClick) { + // Case where shift is pressed: pointed object is toggled. + + // reset alternate selection if any + mAltSelection = null; + + // If nothing has been found at the cursor, assume it might be a user error + // and avoid clearing the existing selection. + + if (vi != null) { + // toggle this selection on-off: remove it if already selected + if (deselect(vi)) { + if (vi.isExploded()) { + mCanvas.getEditorDelegate().recomputeLayout(); + } + + redraw(); + return; + } + + // otherwise add it. + mSelections.add(createSelection(vi)); + fireSelectionChanged(); + redraw(); + } + + } else if (isCycleClick) { + // Case where alt is pressed: select or cycle the object pointed at. + + // Note: if shift and alt are pressed, shift is ignored. The alternate selection + // mechanism does not reset the current multiple selection unless they intersect. + + // We need to remember the "origin" of the alternate selection, to be + // able to continue cycling through it later. If there's no alternate selection, + // create one. If there's one but not for the same origin object, create a new + // one too. + if (mAltSelection == null || mAltSelection.getOriginatingView() != vi) { + mAltSelection = new CanvasAlternateSelection( + vi, mCanvas.getViewHierarchy().findAltViewInfoAt(p)); + + // deselect them all, in case they were partially selected + deselectAll(mAltSelection.getAltViews()); + + // select the current one + CanvasViewInfo vi2 = mAltSelection.getCurrent(); + if (vi2 != null) { + mSelections.addFirst(createSelection(vi2)); + fireSelectionChanged(); + } + } else { + // We're trying to cycle through the current alternate selection. + // First remove the current object. + CanvasViewInfo vi2 = mAltSelection.getCurrent(); + deselect(vi2); + + // Now select the next one. + vi2 = mAltSelection.getNext(); + if (vi2 != null) { + mSelections.addFirst(createSelection(vi2)); + fireSelectionChanged(); + } + } + redraw(); + + } else { + // Case where no modifier is pressed: either select or reset the selection. + selectSingle(vi); + } + } + + /** + * Removes all the currently selected item and only select the given item. + * Issues a redraw() if the selection changes. + * + * @param vi The new selected item if non-null. Selection becomes empty if null. + * @return the item selected, or null if the selection was cleared (e.g. vi was null) + */ + @Nullable + SelectionItem selectSingle(CanvasViewInfo vi) { + SelectionItem item = null; + + // reset alternate selection if any + mAltSelection = null; + + if (vi == null) { + // The user clicked outside the bounds of the root element; in that case, just + // select the root element. + vi = mCanvas.getViewHierarchy().getRoot(); + } + + boolean redoLayout = hasExplodedItems(); + + // reset (multi)selection if any + if (!mSelections.isEmpty()) { + if (mSelections.size() == 1 && mSelections.getFirst().getViewInfo() == vi) { + // CanvasSelection remains the same, don't touch it. + return mSelections.getFirst(); + } + mSelections.clear(); + } + + if (vi != null) { + item = createSelection(vi); + mSelections.add(item); + if (vi.isInvisible()) { + redoLayout = true; + } + } + fireSelectionChanged(); + + if (redoLayout) { + mCanvas.getEditorDelegate().recomputeLayout(); + } + + redraw(); + + return item; + } + + /** Returns true if the view hierarchy is showing exploded items. */ + private boolean hasExplodedItems() { + for (SelectionItem item : mSelections) { + if (item.getViewInfo().isExploded()) { + return true; + } + } + + return false; + } + + /** + * Selects the given set of {@link CanvasViewInfo}s. This is similar to + * {@link #selectSingle} but allows you to make a multi-selection. Issues a + * {@link #redraw()}. + * + * @param viewInfos A collection of {@link CanvasViewInfo} objects to be + * selected, or null or empty to clear the selection. + */ + /* package */ void selectMultiple(Collection<CanvasViewInfo> viewInfos) { + // reset alternate selection if any + mAltSelection = null; + + boolean redoLayout = hasExplodedItems(); + + mSelections.clear(); + if (viewInfos != null) { + for (CanvasViewInfo viewInfo : viewInfos) { + mSelections.add(createSelection(viewInfo)); + if (viewInfo.isInvisible()) { + redoLayout = true; + } + } + } + + fireSelectionChanged(); + + if (redoLayout) { + mCanvas.getEditorDelegate().recomputeLayout(); + } + + redraw(); + } + + public void select(Collection<INode> nodes) { + List<CanvasViewInfo> infos = new ArrayList<CanvasViewInfo>(nodes.size()); + for (INode node : nodes) { + CanvasViewInfo info = mCanvas.getViewHierarchy().findViewInfoFor(node); + if (info != null) { + infos.add(info); + } + } + selectMultiple(infos); + } + + /** + * Selects the visual element corresponding to the given XML node + * @param xmlNode The Node whose element we want to select. + */ + /* package */ void select(Node xmlNode) { + if (xmlNode == null) { + return; + } else if (xmlNode.getNodeType() == Node.TEXT_NODE) { + xmlNode = xmlNode.getParentNode(); + } + + CanvasViewInfo vi = mCanvas.getViewHierarchy().findViewInfoFor(xmlNode); + if (vi != null && !vi.isRoot()) { + selectSingle(vi); + } + } + + /** + * Selects any views that overlap the given selection rectangle. + * + * @param topLeft The top left corner defining the selection rectangle. + * @param bottomRight The bottom right corner defining the selection + * rectangle. + * @param toggled A set of {@link CanvasViewInfo}s that should be toggled + * rather than just added. + */ + public void selectWithin(LayoutPoint topLeft, LayoutPoint bottomRight, + Collection<CanvasViewInfo> toggled) { + // reset alternate selection if any + mAltSelection = null; + + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + Collection<CanvasViewInfo> viewInfos = viewHierarchy.findWithin(topLeft, bottomRight); + + if (toggled.size() > 0) { + // Copy; we're not allowed to touch the passed in collection + Set<CanvasViewInfo> result = new HashSet<CanvasViewInfo>(toggled); + for (CanvasViewInfo viewInfo : viewInfos) { + if (toggled.contains(viewInfo)) { + result.remove(viewInfo); + } else { + result.add(viewInfo); + } + } + viewInfos = result; + } + + mSelections.clear(); + for (CanvasViewInfo viewInfo : viewInfos) { + if (viewInfo.isHidden()) { + continue; + } + mSelections.add(createSelection(viewInfo)); + } + + fireSelectionChanged(); + redraw(); + } + + /** + * Clears the selection and then selects everything (all views and all their + * children). + */ + public void selectAll() { + // First clear the current selection, if any. + mSelections.clear(); + mAltSelection = null; + + // Now select everything if there's a valid layout + for (CanvasViewInfo vi : mCanvas.getViewHierarchy().findAllViewInfos(false)) { + mSelections.add(createSelection(vi)); + } + + fireSelectionChanged(); + redraw(); + } + + /** Clears the selection */ + public void selectNone() { + mSelections.clear(); + mAltSelection = null; + fireSelectionChanged(); + redraw(); + } + + /** Selects the parent of the current selection */ + public void selectParent() { + if (mSelections.size() == 1) { + CanvasViewInfo parent = mSelections.get(0).getViewInfo().getParent(); + if (parent != null) { + selectSingle(parent); + } + } + } + + /** Finds all widgets in the layout that have the same type as the primary */ + public void selectSameType() { + // Find all + if (mSelections.size() == 1) { + CanvasViewInfo viewInfo = mSelections.get(0).getViewInfo(); + ElementDescriptor descriptor = viewInfo.getUiViewNode().getDescriptor(); + mSelections.clear(); + mAltSelection = null; + addSameType(mCanvas.getViewHierarchy().getRoot(), descriptor); + fireSelectionChanged(); + redraw(); + } + } + + /** Helper for {@link #selectSameType} */ + private void addSameType(CanvasViewInfo root, ElementDescriptor descriptor) { + if (root.getUiViewNode().getDescriptor() == descriptor) { + mSelections.add(createSelection(root)); + } + + for (CanvasViewInfo child : root.getChildren()) { + addSameType(child, descriptor); + } + } + + /** Selects the siblings of the primary */ + public void selectSiblings() { + // Find all + if (mSelections.size() == 1) { + CanvasViewInfo vi = mSelections.get(0).getViewInfo(); + mSelections.clear(); + mAltSelection = null; + CanvasViewInfo parent = vi.getParent(); + if (parent == null) { + selectNone(); + } else { + for (CanvasViewInfo child : parent.getChildren()) { + mSelections.add(createSelection(child)); + } + fireSelectionChanged(); + redraw(); + } + } + } + + /** + * Returns true if and only if there is currently more than one selected + * item. + * + * @return True if more than one item is selected + */ + public boolean hasMultiSelection() { + return mSelections.size() > 1; + } + + /** + * Deselects a view info. Returns true if the object was actually selected. + * Callers are responsible for calling redraw() and updateOulineSelection() + * after. + * @param canvasViewInfo The item to deselect. + * @return True if the object was successfully removed from the selection. + */ + public boolean deselect(CanvasViewInfo canvasViewInfo) { + if (canvasViewInfo == null) { + return false; + } + + for (ListIterator<SelectionItem> it = mSelections.listIterator(); it.hasNext(); ) { + SelectionItem s = it.next(); + if (canvasViewInfo == s.getViewInfo()) { + it.remove(); + return true; + } + } + + return false; + } + + /** + * Deselects multiple view infos. + * Callers are responsible for calling redraw() and updateOulineSelection() after. + */ + private void deselectAll(List<CanvasViewInfo> canvasViewInfos) { + for (ListIterator<SelectionItem> it = mSelections.listIterator(); it.hasNext(); ) { + SelectionItem s = it.next(); + if (canvasViewInfos.contains(s.getViewInfo())) { + it.remove(); + } + } + } + + /** Sync the selection with an updated view info tree */ + void sync() { + // Check if the selection is still the same (based on the object keys) + // and eventually recompute their bounds. + for (ListIterator<SelectionItem> it = mSelections.listIterator(); it.hasNext(); ) { + SelectionItem s = it.next(); + + // Check if the selected object still exists + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + UiViewElementNode key = s.getViewInfo().getUiViewNode(); + CanvasViewInfo vi = viewHierarchy.findViewInfoFor(key); + + // Remove the previous selection -- if the selected object still exists + // we need to recompute its bounds in case it moved so we'll insert a new one + // at the same place. + it.remove(); + if (vi == null) { + vi = findCorresponding(s.getViewInfo(), viewHierarchy.getRoot()); + } + if (vi != null) { + it.add(createSelection(vi)); + } + } + fireSelectionChanged(); + + // remove the current alternate selection views + mAltSelection = null; + } + + /** Finds the corresponding {@link CanvasViewInfo} in the new hierarchy */ + private CanvasViewInfo findCorresponding(CanvasViewInfo old, CanvasViewInfo newRoot) { + CanvasViewInfo oldParent = old.getParent(); + if (oldParent != null) { + CanvasViewInfo newParent = findCorresponding(oldParent, newRoot); + if (newParent == null) { + return null; + } + + List<CanvasViewInfo> oldSiblings = oldParent.getChildren(); + List<CanvasViewInfo> newSiblings = newParent.getChildren(); + Iterator<CanvasViewInfo> oldIterator = oldSiblings.iterator(); + Iterator<CanvasViewInfo> newIterator = newSiblings.iterator(); + while (oldIterator.hasNext() && newIterator.hasNext()) { + CanvasViewInfo oldSibling = oldIterator.next(); + CanvasViewInfo newSibling = newIterator.next(); + + if (oldSibling.getName().equals(newSibling.getName())) { + // Structure has changed: can't do a proper search + return null; + } + + if (oldSibling == old) { + return newSibling; + } + } + } else { + return newRoot; + } + + return null; + } + + /** + * Notifies listeners that the selection has changed. + */ + private void fireSelectionChanged() { + if (mInsideUpdateSelection) { + return; + } + try { + mInsideUpdateSelection = true; + + final SelectionChangedEvent event = new SelectionChangedEvent(this, getSelection()); + + SafeRunnable.run(new SafeRunnable() { + @Override + public void run() { + for (Object listener : mSelectionListeners.getListeners()) { + ((ISelectionChangedListener) listener).selectionChanged(event); + } + } + }); + + updateActionsFromSelection(); + } finally { + mInsideUpdateSelection = false; + } + } + + /** + * Updates menu actions and the layout action bar after a selection change - these are + * actions that depend on the selection + */ + private void updateActionsFromSelection() { + LayoutEditorDelegate editor = mCanvas.getEditorDelegate(); + if (editor != null) { + // Update menu actions that depend on the selection + mCanvas.updateMenuActionState(); + + // Update the layout actions bar + LayoutActionBar layoutActionBar = editor.getGraphicalEditor().getLayoutActionBar(); + layoutActionBar.updateSelection(); + } + } + + /** + * Sanitizes the selection for a copy/cut or drag operation. + * <p/> + * Sanitizes the list to make sure all elements have a valid XML attached to it, + * that is remove element that have no XML to avoid having to make repeated such + * checks in various places after. + * <p/> + * In case of multiple selection, we also need to remove all children when their + * parent is already selected since parents will always be added with all their + * children. + * <p/> + * + * @param selection The selection list to be sanitized <b>in-place</b>. + * The <code>selection</code> argument should not be {@link #mSelections} -- the + * given list is going to be altered and we should never alter the user-made selection. + * Instead the caller should provide its own copy. + */ + /* package */ static void sanitize(List<SelectionItem> selection) { + if (selection.isEmpty()) { + return; + } + + for (Iterator<SelectionItem> it = selection.iterator(); it.hasNext(); ) { + SelectionItem cs = it.next(); + CanvasViewInfo vi = cs.getViewInfo(); + UiViewElementNode key = vi == null ? null : vi.getUiViewNode(); + Node node = key == null ? null : key.getXmlNode(); + if (node == null) { + // Missing ViewInfo or view key or XML, discard this. + it.remove(); + continue; + } + + if (vi != null) { + for (Iterator<SelectionItem> it2 = selection.iterator(); + it2.hasNext(); ) { + SelectionItem cs2 = it2.next(); + if (cs != cs2) { + CanvasViewInfo vi2 = cs2.getViewInfo(); + if (vi.isParent(vi2)) { + // vi2 is a parent for vi. Remove vi. + it.remove(); + break; + } + } + } + } + } + } + + /** + * Selects the given list of nodes in the canvas, and returns true iff the + * attempt to select was successful. + * + * @param nodes The collection of nodes to be selected + * @param indices A list of indices within the parent for each node, or null + * @return True if and only if all nodes were successfully selected + */ + public boolean selectDropped(List<INode> nodes, List<Integer> indices) { + assert indices == null || nodes.size() == indices.size(); + + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + + // Look up a list of view infos which correspond to the nodes. + final Collection<CanvasViewInfo> newChildren = new ArrayList<CanvasViewInfo>(); + for (int i = 0, n = nodes.size(); i < n; i++) { + INode node = nodes.get(i); + + CanvasViewInfo viewInfo = viewHierarchy.findViewInfoFor(node); + + // There are two scenarios where looking up a view info fails. + // The first one is that the node was just added and the render has not yet + // happened, so the ViewHierarchy has no record of the node. In this case + // there is nothing we can do, and the method will return false (which the + // caller will use to schedule a second attempt later). + // The second scenario is where the nodes *change identity*. This isn't + // common, but when a drop handler makes a lot of changes to its children, + // for example when dropping into a GridLayout where attributes are adjusted + // on nearly all the other children to update row or column attributes + // etc, then in some cases Eclipse's DOM model changes the identities of + // the nodes when applying all the edits, so the new Node we created (as + // well as possibly other nodes) are no longer the children we observe + // after the edit, and there are new copies there instead. In this case + // the UiViewModel also fails to map the nodes. To work around this, + // we track the *indices* (within the parent) during a drop, such that we + // know which children (according to their positions) the given nodes + // are supposed to map to, and then we use these view infos instead. + if (viewInfo == null && node instanceof NodeProxy && indices != null) { + INode parent = node.getParent(); + CanvasViewInfo parentViewInfo = viewHierarchy.findViewInfoFor(parent); + if (parentViewInfo != null) { + UiViewElementNode parentUiNode = parentViewInfo.getUiViewNode(); + if (parentUiNode != null) { + List<UiElementNode> children = parentUiNode.getUiChildren(); + int index = indices.get(i); + if (index >= 0 && index < children.size()) { + UiElementNode replacedNode = children.get(index); + viewInfo = viewHierarchy.findViewInfoFor(replacedNode); + } + } + } + } + + if (viewInfo != null) { + if (nodes.size() > 1 && viewInfo.isHidden()) { + // Skip spacers - unless you're dropping just one + continue; + } + if (GridLayoutRule.sDebugGridLayout && (viewInfo.getName().equals(FQCN_SPACE) + || viewInfo.getName().equals(FQCN_SPACE_V7))) { + // In debug mode they might not be marked as hidden but we never never + // want to select these guys + continue; + } + newChildren.add(viewInfo); + } + } + boolean found = nodes.size() == newChildren.size(); + + if (found || newChildren.size() > 0) { + mCanvas.getSelectionManager().selectMultiple(newChildren); + } + + return found; + } + + /** + * Update the outline selection to select the given nodes, asynchronously. + * @param nodes The nodes to be selected + */ + public void setOutlineSelection(final List<INode> nodes) { + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + selectDropped(nodes, null /* indices */); + syncOutlineSelection(); + } + }); + } + + /** + * Syncs the current selection to the outline, synchronously. + */ + public void syncOutlineSelection() { + OutlinePage outlinePage = mCanvas.getOutlinePage(); + IWorkbenchPartSite site = outlinePage.getEditor().getSite(); + ISelectionProvider selectionProvider = site.getSelectionProvider(); + ISelection selection = selectionProvider.getSelection(); + if (selection != null) { + outlinePage.setSelection(selection); + } + } + + private void redraw() { + mCanvas.redraw(); + } + + SelectionItem createSelection(CanvasViewInfo vi) { + return new SelectionItem(mCanvas, vi); + } + + /** + * Returns true if there is nothing selected + * + * @return true if there is nothing selected + */ + public boolean isEmpty() { + return mSelections.size() == 0; + } + + /** + * "Select" context menu which lists various menu options related to selection: + * <ul> + * <li> Select All + * <li> Select Parent + * <li> Select None + * <li> Select Siblings + * <li> Select Same Type + * </ul> + * etc. + */ + public static class SelectionMenu extends SubmenuAction { + private final GraphicalEditorPart mEditor; + + public SelectionMenu(GraphicalEditorPart editor) { + super("Select"); + mEditor = editor; + } + + @Override + public String getId() { + return "-selectionmenu"; //$NON-NLS-1$ + } + + @Override + protected void addMenuItems(Menu menu) { + LayoutCanvas canvas = mEditor.getCanvasControl(); + SelectionManager selectionManager = canvas.getSelectionManager(); + List<SelectionItem> selections = selectionManager.getSelections(); + boolean selectedOne = selections.size() == 1; + boolean notRoot = selectedOne && !selections.get(0).isRoot(); + boolean haveSelection = selections.size() > 0; + + Action a; + a = selectionManager.new SelectAction("Select Parent\tEsc", SELECT_PARENT); + new ActionContributionItem(a).fill(menu, -1); + a.setEnabled(notRoot); + a.setAccelerator(SWT.ESC); + + a = selectionManager.new SelectAction("Select Siblings", SELECT_SIBLINGS); + new ActionContributionItem(a).fill(menu, -1); + a.setEnabled(notRoot); + + a = selectionManager.new SelectAction("Select Same Type", SELECT_SAME_TYPE); + new ActionContributionItem(a).fill(menu, -1); + a.setEnabled(selectedOne); + + new Separator().fill(menu, -1); + + // Special case for Select All: Use global action + a = canvas.getSelectAllAction(); + new ActionContributionItem(a).fill(menu, -1); + a.setEnabled(true); + + a = selectionManager.new SelectAction("Deselect All", SELECT_NONE); + new ActionContributionItem(a).fill(menu, -1); + a.setEnabled(haveSelection); + } + } + + private static final int SELECT_PARENT = 1; + private static final int SELECT_SIBLINGS = 2; + private static final int SELECT_SAME_TYPE = 3; + private static final int SELECT_NONE = 4; // SELECT_ALL is handled separately + + private class SelectAction extends Action { + private final int mType; + + public SelectAction(String title, int type) { + super(title, IAction.AS_PUSH_BUTTON); + mType = type; + } + + @Override + public void run() { + switch (mType) { + case SELECT_NONE: + selectNone(); + break; + case SELECT_PARENT: + selectParent(); + break; + case SELECT_SAME_TYPE: + selectSameType(); + break; + case SELECT_SIBLINGS: + selectSiblings(); + break; + } + + List<INode> nodes = new ArrayList<INode>(); + for (SelectionItem item : getSelections()) { + nodes.add(item.getNode()); + } + setOutlineSelection(nodes); + } + } + + public Pair<SelectionItem, SelectionHandle> findHandle(ControlPoint controlPoint) { + if (!isEmpty()) { + LayoutPoint layoutPoint = controlPoint.toLayout(); + int distance = (int) ((PIXEL_MARGIN + PIXEL_RADIUS) / mCanvas.getScale()); + + for (SelectionItem item : getSelections()) { + SelectionHandles handles = item.getSelectionHandles(); + // See if it's over the selection handles + SelectionHandle handle = handles.findHandle(layoutPoint, distance); + if (handle != null) { + return Pair.of(item, handle); + } + } + + } + return null; + } + + /** Performs the default action provided by the currently selected view */ + public void performDefaultAction() { + final List<SelectionItem> selections = getSelections(); + if (selections.size() > 0) { + NodeProxy primary = selections.get(0).getNode(); + if (primary != null) { + RulesEngine rulesEngine = mCanvas.getRulesEngine(); + final String id = rulesEngine.callGetDefaultActionId(primary); + if (id == null) { + return; + } + final List<RuleAction> actions = rulesEngine.callGetContextMenu(primary); + if (actions == null) { + return; + } + RuleAction matching = null; + for (RuleAction a : actions) { + if (id.equals(a.getId())) { + matching = a; + break; + } + } + if (matching == null) { + return; + } + final List<INode> selectedNodes = new ArrayList<INode>(); + for (SelectionItem item : selections) { + NodeProxy n = item.getNode(); + if (n != null) { + selectedNodes.add(n); + } + } + final RuleAction action = matching; + mCanvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel(action.getTitle(), + new Runnable() { + @Override + public void run() { + action.getCallback().action(action, selectedNodes, + action.getId(), null); + LayoutCanvas canvas = mCanvas; + CanvasViewInfo root = canvas.getViewHierarchy().getRoot(); + if (root != null) { + UiViewElementNode uiViewNode = root.getUiViewNode(); + NodeFactory nodeFactory = canvas.getNodeFactory(); + NodeProxy rootNode = nodeFactory.create(uiViewNode); + if (rootNode != null) { + rootNode.applyPendingChanges(); + } + } + } + }); + } + } + } + + /** Performs renaming the selected views */ + public void performRename() { + final List<SelectionItem> selections = getSelections(); + if (selections.size() > 0) { + NodeProxy primary = selections.get(0).getNode(); + if (primary != null) { + performRename(primary, selections); + } + } + } + + /** + * Performs renaming the given node. + * + * @param primary the node to be renamed, or the primary node (to get the + * current value from if more than one node should be renamed) + * @param selections if not null, a list of nodes to apply the setting to + * (which should include the primary) + * @return the result of the renaming operation + */ + @NonNull + public RenameResult performRename( + final @NonNull INode primary, + final @Nullable List<SelectionItem> selections) { + String id = primary.getStringAttr(ANDROID_URI, ATTR_ID); + if (id != null && !id.isEmpty()) { + RenameResult result = RenameResourceWizard.renameResource( + mCanvas.getShell(), + mCanvas.getEditorDelegate().getGraphicalEditor().getProject(), + ResourceType.ID, BaseViewRule.stripIdPrefix(id), null, true /*canClear*/); + if (result.isCanceled()) { + return result; + } else if (!result.isUnavailable()) { + return result; + } + } + String currentId = primary.getStringAttr(ANDROID_URI, ATTR_ID); + currentId = BaseViewRule.stripIdPrefix(currentId); + InputDialog d = new InputDialog( + AdtPlugin.getDisplay().getActiveShell(), + "Set ID", + "New ID:", + currentId, + ResourceNameValidator.create(false, (IProject) null, ResourceType.ID)); + if (d.open() == Window.OK) { + final String s = d.getValue(); + mCanvas.getEditorDelegate().getEditor().wrapUndoEditXmlModel("Set ID", + new Runnable() { + @Override + public void run() { + String newId = s; + newId = NEW_ID_PREFIX + BaseViewRule.stripIdPrefix(s); + if (selections != null) { + for (SelectionItem item : selections) { + NodeProxy node = item.getNode(); + if (node != null) { + node.setAttribute(ANDROID_URI, ATTR_ID, newId); + } + } + } else { + primary.setAttribute(ANDROID_URI, ATTR_ID, newId); + } + + LayoutCanvas canvas = mCanvas; + CanvasViewInfo root = canvas.getViewHierarchy().getRoot(); + if (root != null) { + UiViewElementNode uiViewNode = root.getUiViewNode(); + NodeFactory nodeFactory = canvas.getNodeFactory(); + NodeProxy rootNode = nodeFactory.create(uiViewNode); + if (rootNode != null) { + rootNode.applyPendingChanges(); + } + } + } + }); + return RenameResult.name(BaseViewRule.stripIdPrefix(s)); + } else { + return RenameResult.canceled(); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionOverlay.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionOverlay.java new file mode 100644 index 000000000..97d048108 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SelectionOverlay.java @@ -0,0 +1,247 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +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.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.RulesEngine; + +import org.eclipse.swt.graphics.GC; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * The {@link SelectionOverlay} paints the current selection as an overlay. + */ +public class SelectionOverlay extends Overlay { + private final LayoutCanvas mCanvas; + private boolean mHidden; + + /** + * Constructs a new {@link SelectionOverlay} tied to the given canvas. + * + * @param canvas the associated canvas + */ + public SelectionOverlay(LayoutCanvas canvas) { + mCanvas = canvas; + } + + /** + * Set whether the selection overlay should be hidden. This is done during some + * gestures like resize where the new bounds could be confused with the current + * selection bounds. + * + * @param hidden when true, hide the selection bounds, when false, unhide. + */ + public void setHidden(boolean hidden) { + mHidden = hidden; + } + + /** + * Paints the selection. + * + * @param selectionManager The {@link SelectionManager} holding the + * selection. + * @param gcWrapper The graphics context wrapper for the layout rules to use. + * @param gc The SWT graphics object + * @param rulesEngine The {@link RulesEngine} holding the rules. + */ + public void paint(SelectionManager selectionManager, GCWrapper gcWrapper, + GC gc, RulesEngine rulesEngine) { + if (mHidden) { + return; + } + + List<SelectionItem> selections = selectionManager.getSelections(); + int n = selections.size(); + if (n > 0) { + List<NodeProxy> selectedNodes = new ArrayList<NodeProxy>(); + boolean isMultipleSelection = n > 1; + for (SelectionItem s : selections) { + if (s.isRoot()) { + // The root selection is never painted + continue; + } + + NodeProxy node = s.getNode(); + if (node != null) { + paintSelection(gcWrapper, gc, s, isMultipleSelection); + selectedNodes.add(node); + } + } + + if (selectedNodes.size() > 0) { + paintSelectionFeedback(gcWrapper, selectedNodes, rulesEngine); + } else { + CanvasViewInfo root = mCanvas.getViewHierarchy().getRoot(); + if (root != null) { + NodeProxy parent = mCanvas.getNodeFactory().create(root); + rulesEngine.callPaintSelectionFeedback(gcWrapper, + parent, Collections.<INode>emptyList(), root.getViewObject()); + } + } + + if (n == 1) { + NodeProxy node = selections.get(0).getNode(); + if (node != null) { + paintHints(gcWrapper, node, rulesEngine); + } + } + } else { + CanvasViewInfo root = mCanvas.getViewHierarchy().getRoot(); + if (root != null) { + NodeProxy parent = mCanvas.getNodeFactory().create(root); + rulesEngine.callPaintSelectionFeedback(gcWrapper, + parent, Collections.<INode>emptyList(), root.getViewObject()); + } + } + } + + /** Paint hint for current selection */ + private void paintHints(GCWrapper gcWrapper, NodeProxy node, RulesEngine rulesEngine) { + INode parent = node.getParent(); + if (parent instanceof NodeProxy) { + NodeProxy parentNode = (NodeProxy) parent; + List<String> infos = rulesEngine.callGetSelectionHint(parentNode, node); + if (infos != null && infos.size() > 0) { + gcWrapper.useStyle(DrawingStyle.HELP); + + Rect b = mCanvas.getImageOverlay().getImageBounds(); + if (b == null) { + return; + } + + // Compute the location to display the help. This is done in + // layout coordinates, so we need to apply the scale in reverse + // when making pixel margins + // TODO: We could take the Canvas dimensions into account to see + // where there is more room. + // TODO: The scrollbars should take the presence of hint text + // into account. + double scale = mCanvas.getScale(); + int x, y; + if (b.w > b.h) { + x = (int) (b.x + 3 / scale); + y = (int) (b.y + b.h + 6 / scale); + } else { + x = (int) (b.x + b.w + 6 / scale); + y = (int) (b.y + 3 / scale); + } + gcWrapper.drawBoxedStrings(x, y, infos); + } + } + } + + private void paintSelectionFeedback(GCWrapper gcWrapper, List<NodeProxy> nodes, + RulesEngine rulesEngine) { + // Add fastpath for n=1 + + // Group nodes into parent/child groups + Set<INode> parents = new HashSet<INode>(); + for (INode node : nodes) { + INode parent = node.getParent(); + if (/*parent == null || */parent instanceof NodeProxy) { + NodeProxy parentNode = (NodeProxy) parent; + parents.add(parentNode); + } + } + ViewHierarchy viewHierarchy = mCanvas.getViewHierarchy(); + for (INode parent : parents) { + List<INode> children = new ArrayList<INode>(); + for (INode node : nodes) { + INode nodeParent = node.getParent(); + if (nodeParent == parent) { + children.add(node); + } + } + CanvasViewInfo viewInfo = viewHierarchy.findViewInfoFor((NodeProxy) parent); + Object view = viewInfo != null ? viewInfo.getViewObject() : null; + + rulesEngine.callPaintSelectionFeedback(gcWrapper, + (NodeProxy) parent, children, view); + } + } + + /** Called by the canvas when a view is being selected. */ + private void paintSelection(IGraphics gc, GC swtGc, SelectionItem item, + boolean isMultipleSelection) { + CanvasViewInfo view = item.getViewInfo(); + if (view.isHidden()) { + return; + } + + NodeProxy selectedNode = item.getNode(); + Rect r = selectedNode.getBounds(); + if (!r.isValid()) { + return; + } + + gc.useStyle(DrawingStyle.SELECTION); + + Margins insets = mCanvas.getInsets(selectedNode.getFqcn()); + int x1 = r.x; + int y1 = r.y; + int x2 = r.x2() + 1; + int y2 = r.y2() + 1; + + if (insets != null) { + x1 += insets.left; + x2 -= insets.right; + y1 += insets.top; + y2 -= insets.bottom; + } + + gc.drawRect(x1, y1, x2, y2); + + // Paint sibling rectangles, if applicable + List<CanvasViewInfo> siblings = view.getNodeSiblings(); + if (siblings != null) { + for (CanvasViewInfo sibling : siblings) { + if (sibling != view) { + r = SwtUtils.toRect(sibling.getSelectionRect()); + gc.fillRect(r); + gc.drawRect(r); + } + } + } + + // Paint selection handles. These are painted in control coordinates on the + // real SWT GC object rather than in layout coordinates on the GCWrapper, + // since we want them to have a fixed size that is independent of the + // screen zoom. + CanvasTransform horizontalTransform = mCanvas.getHorizontalTransform(); + CanvasTransform verticalTransform = mCanvas.getVerticalTransform(); + int radius = SelectionHandle.PIXEL_RADIUS; + int doubleRadius = 2 * radius; + for (SelectionHandle handle : item.getSelectionHandles()) { + int cx = horizontalTransform.translate(handle.centerX); + int cy = verticalTransform.translate(handle.centerY); + + SwtDrawingStyle style = SwtDrawingStyle.of(DrawingStyle.SELECTION); + gc.setAlpha(style.getStrokeAlpha()); + swtGc.fillRectangle(cx - radius, cy - radius, doubleRadius, doubleRadius); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ShowWithinMenu.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ShowWithinMenu.java new file mode 100644 index 000000000..d1d529e5a --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ShowWithinMenu.java @@ -0,0 +1,82 @@ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.rendering.api.Capability; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.IncludeFinder.Reference; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.Separator; +import org.eclipse.swt.widgets.Menu; + +import java.util.List; + +/** + * Action which creates a submenu for the "Show Included In" action + */ +class ShowWithinMenu extends SubmenuAction { + private LayoutEditorDelegate mEditorDelegate; + + ShowWithinMenu(LayoutEditorDelegate editorDelegate) { + super("Show Included In"); + mEditorDelegate = editorDelegate; + } + + @Override + protected void addMenuItems(Menu menu) { + GraphicalEditorPart graphicalEditor = mEditorDelegate.getGraphicalEditor(); + IFile file = graphicalEditor.getEditedFile(); + if (graphicalEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) { + IProject project = file.getProject(); + IncludeFinder finder = IncludeFinder.get(project); + final List<Reference> includedBy = finder.getIncludedBy(file); + + if (includedBy != null && includedBy.size() > 0) { + for (final Reference reference : includedBy) { + String title = reference.getDisplayName(); + IAction action = new ShowWithinAction(title, reference); + new ActionContributionItem(action).fill(menu, -1); + } + new Separator().fill(menu, -1); + } + IAction action = new ShowWithinAction("Nothing", null); + if (includedBy == null || includedBy.size() == 0) { + action.setEnabled(false); + } + new ActionContributionItem(action).fill(menu, -1); + } else { + addDisabledMessageItem("Not supported on platform"); + } + } + + /** Action to select one particular include-context */ + private class ShowWithinAction extends Action { + private Reference mReference; + + public ShowWithinAction(String title, Reference reference) { + super(title, IAction.AS_RADIO_BUTTON); + mReference = reference; + } + + @Override + public boolean isChecked() { + Reference within = mEditorDelegate.getGraphicalEditor().getIncludedWithin(); + if (within == null) { + return mReference == null; + } else { + return within.equals(mReference); + } + } + + @Override + public void run() { + if (!isChecked()) { + mEditorDelegate.getGraphicalEditor().showIn(mReference); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleAttribute.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleAttribute.java new file mode 100644 index 000000000..198c16484 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleAttribute.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.INode.IAttribute; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represents one XML attribute in a {@link SimpleElement}. + * <p/> + * The attribute is always represented by a namespace URI, a name and a value. + * The name cannot be empty. + * The namespace URI can be empty for an attribute without a namespace but is never null. + * The value can be empty but cannot be null. + * <p/> + * For a more detailed explanation of the purpose of this class, + * please see {@link SimpleXmlTransfer}. + */ +public class SimpleAttribute implements IAttribute { + private final String mName; + private final String mValue; + private final String mUri; + + /** + * Creates a new {@link SimpleAttribute}. + * <p/> + * Any null value will be converted to an empty non-null string. + * However it is a semantic error to use an empty name -- no assertion is done though. + * + * @param uri The URI of the attribute. + * @param name The XML local name of the attribute. + * @param value The value of the attribute. + */ + public SimpleAttribute(String uri, String name, String value) { + mUri = uri == null ? "" : uri; + mName = name == null ? "" : name; + mValue = value == null ? "" : value; + } + + /** + * Returns the namespace URI of the attribute. + * Can be empty for an attribute without a namespace but is never null. + */ + @Override + public @NonNull String getUri() { + return mUri; + } + + /** Returns the XML local name of the attribute. Cannot be null nor empty. */ + @Override + public @NonNull String getName() { + return mName; + } + + /** Returns the value of the attribute. Cannot be null. Can be empty. */ + @Override + public @NonNull String getValue() { + return mValue; + } + + // reader and writer methods + + @Override + public String toString() { + return String.format("@%s:%s=%s\n", //$NON-NLS-1$ + mName, + mUri, + mValue); + } + + private static final Pattern REGEXP = + Pattern.compile("[^@]*@([^:]+):([^=]*)=([^\n]*)\n*"); //$NON-NLS-1$ + + static SimpleAttribute parseString(String value) { + Matcher m = REGEXP.matcher(value); + if (m.matches()) { + return new SimpleAttribute(m.group(2), m.group(1), m.group(3)); + } + + return null; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof SimpleAttribute) { + SimpleAttribute sa = (SimpleAttribute) obj; + + return mName.equals(sa.mName) && + mUri.equals(sa.mUri) && + mValue.equals(sa.mValue); + } + return false; + } + + @Override + public int hashCode() { + long c = mName.hashCode(); + // uses the formula defined in java.util.List.hashCode() + c = 31*c + mUri.hashCode(); + c = 31*c + mValue.hashCode(); + if (c > 0x0FFFFFFFFL) { + // wrap any overflow + c = c ^ (c >> 32); + } + return (int)(c & 0x0FFFFFFFFL); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleElement.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleElement.java new file mode 100644 index 000000000..9acc8c25e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleElement.java @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IDragElement; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.Rect; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents an XML element with a name, attributes and inner elements. + * <p/> + * The semantic of the element name is to be a fully qualified class name of a View to inflate. + * The element name is not expected to have a name space. + * <p/> + * For a more detailed explanation of the purpose of this class, + * please see {@link SimpleXmlTransfer}. + */ +public class SimpleElement implements IDragElement { + + /** Version number of the internal serialized string format. */ + private static final String FORMAT_VERSION = "3"; + + private final String mFqcn; + private final String mParentFqcn; + private final Rect mBounds; + private final Rect mParentBounds; + private final List<IDragAttribute> mAttributes = new ArrayList<IDragAttribute>(); + private final List<IDragElement> mElements = new ArrayList<IDragElement>(); + + private IDragAttribute[] mCachedAttributes = null; + private IDragElement[] mCachedElements = null; + private SelectionItem mSelectionItem; + + /** + * Creates a new {@link SimpleElement} with the specified element name. + * + * @param fqcn A fully qualified class name of a View to inflate, e.g. + * "android.view.Button". Must not be null nor empty. + * @param parentFqcn The fully qualified class name of the parent of this element. + * Can be null but not empty. + * @param bounds The canvas bounds of the originating canvas node of the element. + * If null, a non-null invalid rectangle will be assigned. + * @param parentBounds The canvas bounds of the parent of this element. Can be null. + */ + public SimpleElement(String fqcn, String parentFqcn, Rect bounds, Rect parentBounds) { + mFqcn = fqcn; + mParentFqcn = parentFqcn; + mBounds = bounds == null ? new Rect() : bounds.copy(); + mParentBounds = parentBounds == null ? new Rect() : parentBounds.copy(); + } + + /** + * Returns the element name, which must match a fully qualified class name of + * a View to inflate. + */ + @Override + public @NonNull String getFqcn() { + return mFqcn; + } + + /** + * Returns the bounds of the element's node, if it originated from an existing + * canvas. The rectangle is invalid and non-null when the element originated + * from the object palette (unless it successfully rendered a preview) + */ + @Override + public @NonNull Rect getBounds() { + return mBounds; + } + + /** + * Returns the fully qualified class name of the parent, if the element originated + * from an existing canvas. Returns null if the element has no parent, such as a top + * level element or an element originating from the object palette. + */ + @Override + public String getParentFqcn() { + return mParentFqcn; + } + + /** + * Returns the bounds of the element's parent, absolute for the canvas, or null if there + * is no suitable parent. This is null when {@link #getParentFqcn()} is null. + */ + @Override + public @NonNull Rect getParentBounds() { + return mParentBounds; + } + + @Override + public @NonNull IDragAttribute[] getAttributes() { + if (mCachedAttributes == null) { + mCachedAttributes = mAttributes.toArray(new IDragAttribute[mAttributes.size()]); + } + return mCachedAttributes; + } + + @Override + public IDragAttribute getAttribute(@Nullable String uri, @NonNull String localName) { + for (IDragAttribute attr : mAttributes) { + if (attr.getUri().equals(uri) && attr.getName().equals(localName)) { + return attr; + } + } + + return null; + } + + @Override + public @NonNull IDragElement[] getInnerElements() { + if (mCachedElements == null) { + mCachedElements = mElements.toArray(new IDragElement[mElements.size()]); + } + return mCachedElements; + } + + public void addAttribute(SimpleAttribute attr) { + mCachedAttributes = null; + mAttributes.add(attr); + } + + public void addInnerElement(SimpleElement e) { + mCachedElements = null; + mElements.add(e); + } + + @Override + public boolean isSame(@NonNull INode node) { + if (mSelectionItem != null) { + return node == mSelectionItem.getNode(); + } else { + return node.getBounds().equals(mBounds); + } + } + + void setSelectionItem(@Nullable SelectionItem selectionItem) { + mSelectionItem = selectionItem; + } + + @Nullable + SelectionItem getSelectionItem() { + return mSelectionItem; + } + + @Nullable + static SimpleElement findPrimary(SimpleElement[] elements, SelectionItem primary) { + if (elements == null || elements.length == 0) { + return null; + } + + if (elements.length == 1 || primary == null) { + return elements[0]; + } + + for (SimpleElement element : elements) { + if (element.getSelectionItem() == primary) { + return element; + } + } + + return elements[0]; + } + + // reader and writer methods + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("{V=").append(FORMAT_VERSION); + sb.append(",N=").append(mFqcn); + if (mParentFqcn != null) { + sb.append(",P=").append(mParentFqcn); + } + if (mBounds != null && mBounds.isValid()) { + sb.append(String.format(",R=%d %d %d %d", mBounds.x, mBounds.y, mBounds.w, mBounds.h)); + } + if (mParentBounds != null && mParentBounds.isValid()) { + sb.append(String.format(",Q=%d %d %d %d", + mParentBounds.x, mParentBounds.y, mParentBounds.w, mParentBounds.h)); + } + sb.append('\n'); + for (IDragAttribute a : mAttributes) { + sb.append(a.toString()); + } + for (IDragElement e : mElements) { + sb.append(e.toString()); + } + sb.append("}\n"); //$NON-NLS-1$ + return sb.toString(); + } + + /** Parses a string containing one or more elements. */ + static SimpleElement[] parseString(String value) { + ArrayList<SimpleElement> elements = new ArrayList<SimpleElement>(); + String[] lines = value.split("\n"); + int[] index = new int[] { 0 }; + SimpleElement element = null; + while ((element = parseLines(lines, index)) != null) { + elements.add(element); + } + return elements.toArray(new SimpleElement[elements.size()]); + } + + /** + * Parses one element from the input lines array, starting at the inOutIndex + * and updating the inOutIndex to match the next unread line on output. + */ + private static SimpleElement parseLines(String[] lines, int[] inOutIndex) { + SimpleElement e = null; + int index = inOutIndex[0]; + while (index < lines.length) { + String line = lines[index++]; + String s = line.trim(); + if (s.startsWith("{")) { //$NON-NLS-1$ + if (e == null) { + // This is the element's header, it should have + // the format "key=value,key=value,..." + String version = null; + String fqcn = null; + String parent = null; + Rect bounds = null; + Rect pbounds = null; + + for (String s2 : s.substring(1).split(",")) { //$NON-NLS-1$ + int pos = s2.indexOf('='); + if (pos <= 0 || pos == s2.length() - 1) { + continue; + } + String key = s2.substring(0, pos).trim(); + String value = s2.substring(pos + 1).trim(); + + if (key.equals("V")) { //$NON-NLS-1$ + version = value; + if (!value.equals(FORMAT_VERSION)) { + // Wrong format version. Don't even try to process anything + // else and just give up everything. + inOutIndex[0] = index; + return null; + } + + } else if (key.equals("N")) { //$NON-NLS-1$ + fqcn = value; + + } else if (key.equals("P")) { //$NON-NLS-1$ + parent = value; + + } else if (key.equals("R") || key.equals("Q")) { //$NON-NLS-1$ //$NON-NLS-2$ + // Parse the canvas bounds + String[] sb = value.split(" +"); //$NON-NLS-1$ + if (sb != null && sb.length == 4) { + Rect r = null; + try { + r = new Rect(); + r.x = Integer.parseInt(sb[0]); + r.y = Integer.parseInt(sb[1]); + r.w = Integer.parseInt(sb[2]); + r.h = Integer.parseInt(sb[3]); + + if (key.equals("R")) { + bounds = r; + } else { + pbounds = r; + } + } catch (NumberFormatException ignore) { + } + } + } + } + + // We need at least a valid name to recreate an element + if (version != null && fqcn != null && fqcn.length() > 0) { + e = new SimpleElement(fqcn, parent, bounds, pbounds); + } + } else { + // This is an inner element... need to parse the { line again. + inOutIndex[0] = index - 1; + SimpleElement e2 = SimpleElement.parseLines(lines, inOutIndex); + if (e2 != null) { + e.addInnerElement(e2); + } + index = inOutIndex[0]; + } + + } else if (e != null && s.startsWith("@")) { //$NON-NLS-1$ + SimpleAttribute a = SimpleAttribute.parseString(line); + if (a != null) { + e.addAttribute(a); + } + + } else if (e != null && s.startsWith("}")) { //$NON-NLS-1$ + // We're done with this element + inOutIndex[0] = index; + return e; + } + } + inOutIndex[0] = index; + return null; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof SimpleElement) { + SimpleElement se = (SimpleElement) obj; + + // Bounds and parentFqcn must be null on both sides or equal. + if ((mBounds == null && se.mBounds != null) || + (mBounds != null && !mBounds.equals(se.mBounds))) { + return false; + } + if ((mParentFqcn == null && se.mParentFqcn != null) || + (mParentFqcn != null && !mParentFqcn.equals(se.mParentFqcn))) { + return false; + } + if ((mParentBounds == null && se.mParentBounds != null) || + (mParentBounds != null && !mParentBounds.equals(se.mParentBounds))) { + return false; + } + + return mFqcn.equals(se.mFqcn) && + mAttributes.size() == se.mAttributes.size() && + mElements.size() == se.mElements.size() && + mAttributes.equals(se.mAttributes) && + mElements.equals(se.mElements); + } + return false; + } + + @Override + public int hashCode() { + long c = mFqcn.hashCode(); + // uses the formula defined in java.util.List.hashCode() + c = 31*c + mAttributes.hashCode(); + c = 31*c + mElements.hashCode(); + if (mParentFqcn != null) { + c = 31*c + mParentFqcn.hashCode(); + } + if (mBounds != null && mBounds.isValid()) { + c = 31*c + mBounds.hashCode(); + } + if (mParentBounds != null && mParentBounds.isValid()) { + c = 31*c + mParentBounds.hashCode(); + } + + if (c > 0x0FFFFFFFFL) { + // wrap any overflow + c = c ^ (c >> 32); + } + return (int)(c & 0x0FFFFFFFFL); + } +} + diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleXmlTransfer.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleXmlTransfer.java new file mode 100644 index 000000000..20ac2033e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SimpleXmlTransfer.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; + +import org.eclipse.swt.dnd.ByteArrayTransfer; +import org.eclipse.swt.dnd.TransferData; + +import java.io.UnsupportedEncodingException; + +/** + * A d'n'd {@link Transfer} class that can transfer a <em>simplified</em> XML fragment + * to transfer elements and their attributes between {@link LayoutCanvas}. + * <p/> + * The implementation is based on the {@link ByteArrayTransfer} and what we transfer + * is text with the following fixed format: + * <p/> + * <pre> + * {element-name element-property ... + * attrib_name="attrib_value" + * attrib2="..." + * {...inner elements... + * } + * } + * {...next element... + * } + * + * </pre> + * The format has nothing to do with XML per se, except for the fact that the + * transfered content represents XML elements and XML attributes. + * + * <p/> + * The detailed syntax is: + * <pre> + * - ELEMENT := {NAME PROPERTY*\nATTRIB_LINE*ELEMENT*}\n + * - PROPERTY := $[A-Z]=[^ ]* + * - NAME := [^\n=]+ + * - ATTRIB_LINE := @URI:NAME=[^\n]*\n + * </pre> + * + * Elements are represented by {@link SimpleElement}s and their attributes by + * {@link SimpleAttribute}s, all of which have very specific properties that are + * specifically limited to our needs for drag'n'drop. + */ +final class SimpleXmlTransfer extends ByteArrayTransfer { + + // Reference: http://www.eclipse.org/articles/Article-SWT-DND/DND-in-SWT.html + + private static final String TYPE_NAME = "android.ADT.simple.xml.transfer.1"; //$NON-NLS-1$ + private static final int TYPE_ID = registerType(TYPE_NAME); + private static final SimpleXmlTransfer sInstance = new SimpleXmlTransfer(); + + /** Private constructor. Use {@link #getInstance()} to retrieve the singleton instance. */ + private SimpleXmlTransfer() { + // pass + } + + /** Returns the singleton instance. */ + public static SimpleXmlTransfer getInstance() { + return sInstance; + } + + /** + * Helper method that returns the FQCN transfered for the given {@link ElementDescriptor}. + * <p/> + * If the descriptor is a {@link ViewElementDescriptor}, the transfered data is the FQCN + * of the Android View class represented (e.g. "android.widget.Button"). + * For any other non-null descriptor, the XML name is used. + * Otherwise it is null. + * + * @param desc The {@link ElementDescriptor} to transfer. + * @return The FQCN, XML name or null. + */ + public static String getFqcn(ElementDescriptor desc) { + if (desc instanceof ViewElementDescriptor) { + return ((ViewElementDescriptor) desc).getFullClassName(); + } else if (desc != null) { + return desc.getXmlName(); + } + + return null; + } + + @Override + protected int[] getTypeIds() { + return new int[] { TYPE_ID }; + } + + @Override + protected String[] getTypeNames() { + return new String[] { TYPE_NAME }; + } + + /** Transforms a array of {@link SimpleElement} into a native data transfer. */ + @Override + protected void javaToNative(Object object, TransferData transferData) { + if (object == null || !(object instanceof SimpleElement[])) { + return; + } + + if (isSupportedType(transferData)) { + StringBuilder sb = new StringBuilder(); + for (SimpleElement e : (SimpleElement[]) object) { + sb.append(e.toString()); + } + String data = sb.toString(); + + try { + byte[] buf = data.getBytes("UTF-8"); //$NON-NLS-1$ + super.javaToNative(buf, transferData); + } catch (UnsupportedEncodingException e) { + // unlikely; ignore + } + } + } + + /** + * Recreates an array of {@link SimpleElement} from a native data transfer. + * + * @return An array of {@link SimpleElement} or null. The array may be empty. + */ + @Override + protected Object nativeToJava(TransferData transferData) { + if (isSupportedType(transferData)) { + byte[] buf = (byte[]) super.nativeToJava(transferData); + if (buf != null && buf.length > 0) { + try { + String s = new String(buf, "UTF-8"); //$NON-NLS-1$ + return SimpleElement.parseString(s); + } catch (UnsupportedEncodingException e) { + // unlikely to happen, but still possible + } + } + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SubmenuAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SubmenuAction.java new file mode 100644 index 000000000..0923dda79 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SubmenuAction.java @@ -0,0 +1,75 @@ + +package com.android.ide.eclipse.adt.internal.editors.layout.gle2; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.ActionContributionItem; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IMenuCreator; +import org.eclipse.swt.events.MenuEvent; +import org.eclipse.swt.events.MenuListener; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; + +/** + * Action which creates a submenu that is dynamically populated by subclasses + */ +public abstract class SubmenuAction extends Action implements MenuListener, IMenuCreator { + private Menu mMenu; + + public SubmenuAction(String title) { + super(title, IAction.AS_DROP_DOWN_MENU); + } + + @Override + public IMenuCreator getMenuCreator() { + return this; + } + + @Override + public void dispose() { + if (mMenu != null) { + mMenu.dispose(); + mMenu = null; + } + } + + @Override + public Menu getMenu(Control parent) { + return null; + } + + @Override + public Menu getMenu(Menu parent) { + mMenu = new Menu(parent); + mMenu.addMenuListener(this); + return mMenu; + } + + @Override + public void menuHidden(MenuEvent e) { + } + + protected abstract void addMenuItems(Menu menu); + + @Override + public void menuShown(MenuEvent e) { + // TODO: Replace this stuff with manager.setRemoveAllWhenShown(true); + MenuItem[] menuItems = mMenu.getItems(); + for (int i = 0; i < menuItems.length; i++) { + menuItems[i].dispose(); + } + addMenuItems(mMenu); + } + + protected void addDisabledMessageItem(String message) { + IAction action = new Action(message, IAction.AS_PUSH_BUTTON) { + @Override + public void run() { + } + }; + action.setEnabled(false); + new ActionContributionItem(action).fill(mMenu, -1); + + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SwtDrawingStyle.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SwtDrawingStyle.java new file mode 100644 index 000000000..93a33283c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SwtDrawingStyle.java @@ -0,0 +1,319 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import com.android.ide.common.api.DrawingStyle; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.RGB; + +/** + * Description of the drawing styles with specific color, line style and alpha + * definitions. This class corresponds to the more generic {@link DrawingStyle} + * class which defines the drawing styles but does not introduce any specific + * SWT values to the API clients. + * <p> + * TODO: This class should eventually be replaced by a scheme where the color + * constants are instead coming from the theme. + */ +public enum SwtDrawingStyle { + /** + * The style definition corresponding to {@link DrawingStyle#SELECTION} + */ + SELECTION(new RGB(0x00, 0x99, 0xFF), 192, new RGB(0x00, 0x99, 0xFF), 192, 1, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#GUIDELINE} + */ + GUIDELINE(new RGB(0x00, 0xAA, 0x00), 192, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#GUIDELINE} + */ + GUIDELINE_SHADOW(new RGB(0x00, 0xAA, 0x00), 192, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#GUIDELINE_DASHED} + */ + GUIDELINE_DASHED(new RGB(0x00, 0xAA, 0x00), 192, SWT.LINE_CUSTOM), + + /** + * The style definition corresponding to {@link DrawingStyle#DISTANCE} + */ + DISTANCE(new RGB(0xFF, 0x00, 0x00), 192 - 32, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#GRID} + */ + GRID(new RGB(0xAA, 0xAA, 0xAA), 128, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#HOVER} + */ + HOVER(null, 0, new RGB(0xFF, 0xFF, 0xFF), 40, 1, SWT.LINE_DOT), + + /** + * The style definition corresponding to {@link DrawingStyle#HOVER} + */ + HOVER_SELECTION(null, 0, new RGB(0xFF, 0xFF, 0xFF), 10, 1, SWT.LINE_DOT), + + /** + * The style definition corresponding to {@link DrawingStyle#ANCHOR} + */ + ANCHOR(new RGB(0x00, 0x99, 0xFF), 96, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#OUTLINE} + */ + OUTLINE(new RGB(0x88, 0xFF, 0x88), 160, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#DROP_RECIPIENT} + */ + DROP_RECIPIENT(new RGB(0xFF, 0x99, 0x00), 255, new RGB(0xFF, 0x99, 0x00), 160, 2, + SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#DROP_ZONE} + */ + DROP_ZONE(new RGB(0x00, 0xAA, 0x00), 220, new RGB(0x55, 0xAA, 0x00), 200, 1, SWT.LINE_SOLID), + + /** + * The style definition corresponding to + * {@link DrawingStyle#DROP_ZONE_ACTIVE} + */ + DROP_ZONE_ACTIVE(new RGB(0x00, 0xAA, 0x00), 220, new RGB(0x00, 0xAA, 0x00), 128, 2, + SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#DROP_PREVIEW} + */ + DROP_PREVIEW(new RGB(0xFF, 0x99, 0x00), 255, null, 0, 2, SWT.LINE_CUSTOM), + + /** + * The style definition corresponding to {@link DrawingStyle#RESIZE_PREVIEW} + */ + RESIZE_PREVIEW(new RGB(0xFF, 0x99, 0x00), 255, null, 0, 2, SWT.LINE_SOLID), + + /** + * The style used to show a proposed resize bound which is being rejected (for example, + * because there is no near edge to attach to in a RelativeLayout). + */ + RESIZE_FAIL(new RGB(0xFF, 0x99, 0x00), 255, null, 0, 2, SWT.LINE_CUSTOM), + + /** + * The style definition corresponding to {@link DrawingStyle#HELP} + */ + HELP(new RGB(0xFF, 0xFF, 0xFF), 255, new RGB(0x00, 0x00, 0x00), 128, 1, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#INVALID} + */ + INVALID(new RGB(0xFF, 0xFF, 0xFF), 255, new RGB(0xFF, 0x00, 0x00), 64, 2, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#DEPENDENCY} + */ + DEPENDENCY(new RGB(0xFF, 0xFF, 0xFF), 255, new RGB(0xFF, 0xFF, 0x00), 24, 2, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#CYCLE} + */ + CYCLE(new RGB(0xFF, 0x00, 0x00), 192, null, 0, 1, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#DRAGGED} + */ + DRAGGED(new RGB(0xFF, 0xFF, 0xFF), 255, new RGB(0x00, 0xFF, 0x00), 16, 2, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#EMPTY} + */ + EMPTY(new RGB(0xFF, 0xFF, 0x55), 255, new RGB(0xFF, 0xFF, 0x55), 255, 1, SWT.LINE_DASH), + + /** + * The style definition corresponding to {@link DrawingStyle#CUSTOM1} + */ + CUSTOM1(new RGB(0xFF, 0x00, 0xFF), 255, null, 0, 1, SWT.LINE_SOLID), + + /** + * The style definition corresponding to {@link DrawingStyle#CUSTOM2} + */ + CUSTOM2(new RGB(0x00, 0xFF, 0xFF), 255, null, 0, 1, SWT.LINE_DOT); + + /** + * Construct a new style value with the given foreground, background, width, + * linestyle and transparency. + * + * @param stroke A color descriptor for the foreground color, or null if no + * foreground color should be set + * @param fill A color descriptor for the background color, or null if no + * foreground color should be set + * @param lineWidth The line width, in pixels, or 0 if no line width should + * be set + * @param lineStyle The SWT line style - such as {@link SWT#LINE_SOLID}. + * @param strokeAlpha The alpha value of the stroke, an integer in the range 0 to 255 + * where 0 is fully transparent and 255 is fully opaque. + * @param fillAlpha The alpha value of the fill, an integer in the range 0 to 255 + * where 0 is fully transparent and 255 is fully opaque. + */ + private SwtDrawingStyle(RGB stroke, int strokeAlpha, RGB fill, int fillAlpha, int lineWidth, + int lineStyle) { + mStroke = stroke; + mFill = fill; + mLineWidth = lineWidth; + mLineStyle = lineStyle; + mStrokeAlpha = strokeAlpha; + mFillAlpha = fillAlpha; + } + + /** + * Convenience constructor for typical drawing styles, which do not specify + * a fill and use a standard thickness line + * + * @param stroke Stroke color to be used (e.g. for the border/foreground) + * @param strokeAlpha Transparency to use for the stroke; 0 is transparent + * and 255 is fully opaque. + * @param lineStyle The SWT line style - such as {@link SWT#LINE_SOLID}. + */ + private SwtDrawingStyle(RGB stroke, int strokeAlpha, int lineStyle) { + this(stroke, strokeAlpha, null, 255, 1, lineStyle); + } + + /** + * Return the stroke/foreground/border RGB color description to be used for + * this style, or null if none + */ + public RGB getStrokeColor() { + return mStroke; + } + + /** + * Return the fill/background/interior RGB color description to be used for + * this style, or null if none + */ + public RGB getFillColor() { + return mFill; + } + + /** Return the line width to be used for this style */ + public int getLineWidth() { + return mLineWidth; + } + + /** Return the SWT line style to be used for this style */ + public int getLineStyle() { + return mLineStyle; + } + + /** + * Return the stroke alpha value (in the range 0,255) to be used for this + * style + */ + public int getStrokeAlpha() { + return mStrokeAlpha; + } + + /** + * Return the fill alpha value (in the range 0,255) to be used for this + * style + */ + public int getFillAlpha() { + return mFillAlpha; + } + + /** + * Return the corresponding SwtDrawingStyle for the given + * {@link DrawingStyle} + * @param style The style to convert from a {@link DrawingStyle} to a {@link SwtDrawingStyle}. + * @return A corresponding {@link SwtDrawingStyle}. + */ + public static SwtDrawingStyle of(DrawingStyle style) { + switch (style) { + case SELECTION: + return SELECTION; + case GUIDELINE: + return GUIDELINE; + case GUIDELINE_SHADOW: + return GUIDELINE_SHADOW; + case GUIDELINE_DASHED: + return GUIDELINE_DASHED; + case DISTANCE: + return DISTANCE; + case GRID: + return GRID; + case HOVER: + return HOVER; + case HOVER_SELECTION: + return HOVER_SELECTION; + case ANCHOR: + return ANCHOR; + case OUTLINE: + return OUTLINE; + case DROP_ZONE: + return DROP_ZONE; + case DROP_ZONE_ACTIVE: + return DROP_ZONE_ACTIVE; + case DROP_RECIPIENT: + return DROP_RECIPIENT; + case DROP_PREVIEW: + return DROP_PREVIEW; + case RESIZE_PREVIEW: + return RESIZE_PREVIEW; + case RESIZE_FAIL: + return RESIZE_FAIL; + case HELP: + return HELP; + case INVALID: + return INVALID; + case DEPENDENCY: + return DEPENDENCY; + case CYCLE: + return CYCLE; + case DRAGGED: + return DRAGGED; + case EMPTY: + return EMPTY; + case CUSTOM1: + return CUSTOM1; + case CUSTOM2: + return CUSTOM2; + + // Internal error + default: + throw new IllegalArgumentException("Unknown style " + style); + } + } + + /** RGB description of the stroke/foreground/border color */ + private final RGB mStroke; + + /** RGB description of the fill/foreground/interior color */ + private final RGB mFill; + + /** Pixel thickness of the stroke/border */ + private final int mLineWidth; + + /** SWT line style of the border/stroke */ + private final int mLineStyle; + + /** Alpha (in the range 0-255) of the stroke/border */ + private final int mStrokeAlpha; + + /** Alpha (in the range 0-255) of the fill/interior */ + private final int mFillAlpha; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SwtUtils.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SwtUtils.java new file mode 100644 index 000000000..64e91bedf --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/SwtUtils.java @@ -0,0 +1,457 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils.SHADOW_SIZE; + +import com.android.ide.common.api.Rect; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; + +import org.eclipse.swt.SWTException; +import org.eclipse.swt.graphics.Device; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontMetrics; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.PaletteData; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; + +import java.awt.Graphics; +import java.awt.image.BufferedImage; +import java.awt.image.DataBuffer; +import java.awt.image.DataBufferByte; +import java.awt.image.DataBufferInt; +import java.awt.image.WritableRaster; +import java.util.List; + +/** + * Various generic SWT utilities such as image conversion. + */ +public class SwtUtils { + + private SwtUtils() { + } + + /** + * Returns the {@link PaletteData} describing the ARGB ordering expected from integers + * representing pixels for AWT {@link BufferedImage}. + * + * @param imageType the {@link BufferedImage#getType()} type + * @return A new {@link PaletteData} suitable for AWT images. + */ + public static PaletteData getAwtPaletteData(int imageType) { + switch (imageType) { + case BufferedImage.TYPE_INT_RGB: + case BufferedImage.TYPE_INT_ARGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + return new PaletteData(0x00FF0000, 0x0000FF00, 0x000000FF); + + case BufferedImage.TYPE_3BYTE_BGR: + case BufferedImage.TYPE_4BYTE_ABGR: + case BufferedImage.TYPE_4BYTE_ABGR_PRE: + return new PaletteData(0x000000FF, 0x0000FF00, 0x00FF0000); + + default: + throw new UnsupportedOperationException("RGB type not supported yet."); + } + } + + /** + * Returns true if the given type of {@link BufferedImage} is supported for + * conversion. For unsupported formats, use + * {@link #convertToCompatibleFormat(BufferedImage)} first. + * + * @param imageType the {@link BufferedImage#getType()} + * @return true if we can convert the given buffered image format + */ + private static boolean isSupportedPaletteType(int imageType) { + switch (imageType) { + case BufferedImage.TYPE_INT_RGB: + case BufferedImage.TYPE_INT_ARGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + case BufferedImage.TYPE_3BYTE_BGR: + case BufferedImage.TYPE_4BYTE_ABGR: + case BufferedImage.TYPE_4BYTE_ABGR_PRE: + return true; + default: + return false; + } + } + + /** Converts the given arbitrary {@link BufferedImage} to another {@link BufferedImage} + * in a format that is supported (see {@link #isSupportedPaletteType(int)}) + * + * @param image the image to be converted + * @return a new image that is in a guaranteed compatible format + */ + private static BufferedImage convertToCompatibleFormat(BufferedImage image) { + BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), + BufferedImage.TYPE_INT_ARGB); + Graphics graphics = converted.getGraphics(); + graphics.drawImage(image, 0, 0, null); + graphics.dispose(); + + return converted; + } + + /** + * Converts an AWT {@link BufferedImage} into an equivalent SWT {@link Image}. Whether + * the transparency data is transferred is optional, and this method can also apply an + * alpha adjustment during the conversion. + * <p/> + * Implementation details: on Windows, the returned {@link Image} will have an ordering + * matching the Windows DIB (e.g. RGBA, not ARGB). Callers must make sure to use + * <code>Image.getImageData().paletteData</code> to get the right pixels out of the image. + * + * @param display The display where the SWT image will be shown + * @param awtImage The AWT {@link BufferedImage} + * @param transferAlpha If true, copy alpha data out of the source image + * @param globalAlpha If -1, do nothing, otherwise adjust the alpha of the final image + * by the given amount in the range [0,255] + * @return A new SWT {@link Image} with the same contents as the source + * {@link BufferedImage} + */ + public static Image convertToSwt(Device display, BufferedImage awtImage, + boolean transferAlpha, int globalAlpha) { + if (!isSupportedPaletteType(awtImage.getType())) { + awtImage = convertToCompatibleFormat(awtImage); + } + + int width = awtImage.getWidth(); + int height = awtImage.getHeight(); + + WritableRaster raster = awtImage.getRaster(); + DataBuffer dataBuffer = raster.getDataBuffer(); + ImageData imageData = + new ImageData(width, height, 32, getAwtPaletteData(awtImage.getType())); + + if (dataBuffer instanceof DataBufferInt) { + int[] imageDataBuffer = ((DataBufferInt) dataBuffer).getData(); + imageData.setPixels(0, 0, imageDataBuffer.length, imageDataBuffer, 0); + } else if (dataBuffer instanceof DataBufferByte) { + byte[] imageDataBuffer = ((DataBufferByte) dataBuffer).getData(); + try { + imageData.setPixels(0, 0, imageDataBuffer.length, imageDataBuffer, 0); + } catch (SWTException se) { + // Unsupported depth + return convertToSwt(display, convertToCompatibleFormat(awtImage), + transferAlpha, globalAlpha); + } + } + + if (transferAlpha) { + byte[] alphaData = new byte[height * width]; + for (int y = 0; y < height; y++) { + byte[] alphaRow = new byte[width]; + for (int x = 0; x < width; x++) { + int alpha = awtImage.getRGB(x, y) >>> 24; + + // We have to multiply in the alpha now since if we + // set ImageData.alpha, it will ignore the alphaData. + if (globalAlpha != -1) { + alpha = alpha * globalAlpha >> 8; + } + + alphaRow[x] = (byte) alpha; + } + System.arraycopy(alphaRow, 0, alphaData, y * width, width); + } + + imageData.alphaData = alphaData; + } else if (globalAlpha != -1) { + imageData.alpha = globalAlpha; + } + + return new Image(display, imageData); + } + + /** + * Converts a direct-color model SWT image to an equivalent AWT image. If the image + * does not have a supported color model, returns null. This method does <b>NOT</b> + * preserve alpha in the source image. + * + * @param swtImage the SWT image to be converted to AWT + * @return an AWT image representing the source SWT image + */ + public static BufferedImage convertToAwt(Image swtImage) { + ImageData swtData = swtImage.getImageData(); + BufferedImage awtImage = + new BufferedImage(swtData.width, swtData.height, BufferedImage.TYPE_INT_ARGB); + PaletteData swtPalette = swtData.palette; + if (swtPalette.isDirect) { + PaletteData awtPalette = getAwtPaletteData(awtImage.getType()); + + if (swtPalette.equals(awtPalette)) { + // No color conversion needed. + for (int y = 0; y < swtData.height; y++) { + for (int x = 0; x < swtData.width; x++) { + int pixel = swtData.getPixel(x, y); + awtImage.setRGB(x, y, 0xFF000000 | pixel); + } + } + } else { + // We need to remap the colors + int sr = -awtPalette.redShift + swtPalette.redShift; + int sg = -awtPalette.greenShift + swtPalette.greenShift; + int sb = -awtPalette.blueShift + swtPalette.blueShift; + + for (int y = 0; y < swtData.height; y++) { + for (int x = 0; x < swtData.width; x++) { + int pixel = swtData.getPixel(x, y); + + int r = pixel & swtPalette.redMask; + int g = pixel & swtPalette.greenMask; + int b = pixel & swtPalette.blueMask; + r = (sr < 0) ? r >>> -sr : r << sr; + g = (sg < 0) ? g >>> -sg : g << sg; + b = (sb < 0) ? b >>> -sb : b << sb; + + pixel = 0xFF000000 | r | g | b; + awtImage.setRGB(x, y, pixel); + } + } + } + } else { + return null; + } + + return awtImage; + } + + /** + * Creates a new image from a source image where the contents from a given set of + * bounding boxes are copied into the new image and the rest is left transparent. A + * scale can be applied to make the resulting image larger or smaller than the source + * image. Note that the alpha channel in the original image is ignored, and the alpha + * values for the painted rectangles will be set to a specific value passed into this + * function. + * + * @param image the source image + * @param rectangles the set of rectangles (bounding boxes) to copy from the source + * image + * @param boundingBox the bounding rectangle of the rectangle list, which can be + * computed by {@link ImageUtils#getBoundingRectangle} + * @param scale a scale factor to apply to the result, e.g. 0.5 to shrink the + * destination down 50%, 1.0 to leave it alone and 2.0 to zoom in by + * doubling the image size + * @param alpha the alpha (in the range 0-255) that painted bits should be set to + * @return a pair of the rendered cropped image, and the location within the source + * image that the crop begins (multiplied by the scale). May return null if + * there are no selected items. + */ + public static Image drawRectangles(Image image, + List<Rectangle> rectangles, Rectangle boundingBox, double scale, byte alpha) { + + if (rectangles.size() == 0 || boundingBox == null || boundingBox.isEmpty()) { + return null; + } + + ImageData srcData = image.getImageData(); + int destWidth = (int) (scale * boundingBox.width); + int destHeight = (int) (scale * boundingBox.height); + + ImageData destData = new ImageData(destWidth, destHeight, srcData.depth, srcData.palette); + byte[] alphaData = new byte[destHeight * destWidth]; + destData.alphaData = alphaData; + + for (Rectangle bounds : rectangles) { + int dx1 = bounds.x - boundingBox.x; + int dy1 = bounds.y - boundingBox.y; + int dx2 = dx1 + bounds.width; + int dy2 = dy1 + bounds.height; + + dx1 *= scale; + dy1 *= scale; + dx2 *= scale; + dy2 *= scale; + + int sx1 = bounds.x; + int sy1 = bounds.y; + int sx2 = sx1 + bounds.width; + int sy2 = sy1 + bounds.height; + + if (scale == 1.0d) { + for (int dy = dy1, sy = sy1; dy < dy2; dy++, sy++) { + for (int dx = dx1, sx = sx1; dx < dx2; dx++, sx++) { + destData.setPixel(dx, dy, srcData.getPixel(sx, sy)); + alphaData[dy * destWidth + dx] = alpha; + } + } + } else { + // Scaled copy. + int sxDelta = sx2 - sx1; + int dxDelta = dx2 - dx1; + int syDelta = sy2 - sy1; + int dyDelta = dy2 - dy1; + for (int dy = dy1, sy = sy1; dy < dy2; dy++, sy = (dy - dy1) * syDelta / dyDelta + + sy1) { + for (int dx = dx1, sx = sx1; dx < dx2; dx++, sx = (dx - dx1) * sxDelta + / dxDelta + sx1) { + assert sx < sx2 && sy < sy2; + destData.setPixel(dx, dy, srcData.getPixel(sx, sy)); + alphaData[dy * destWidth + dx] = alpha; + } + } + } + } + + return new Image(image.getDevice(), destData); + } + + /** + * Creates a new empty/blank image of the given size + * + * @param display the display to associate the image with + * @param width the width of the image + * @param height the height of the image + * @return a new blank image of the given size + */ + public static Image createEmptyImage(Display display, int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + return SwtUtils.convertToSwt(display, image, false, 0); + } + + /** + * Converts the given SWT {@link Rectangle} into an ADT {@link Rect} + * + * @param swtRect the SWT {@link Rectangle} + * @return an equivalent {@link Rect} + */ + public static Rect toRect(Rectangle swtRect) { + return new Rect(swtRect.x, swtRect.y, swtRect.width, swtRect.height); + } + + /** + * Sets the values of the given ADT {@link Rect} to the values of the given SWT + * {@link Rectangle} + * + * @param target the ADT {@link Rect} to modify + * @param source the SWT {@link Rectangle} to read values from + */ + public static void set(Rect target, Rectangle source) { + target.set(source.x, source.y, source.width, source.height); + } + + /** + * Compares an ADT {@link Rect} with an SWT {@link Rectangle} and returns true if they + * are equivalent + * + * @param r1 the ADT {@link Rect} + * @param r2 the SWT {@link Rectangle} + * @return true if the two rectangles are equivalent + */ + public static boolean equals(Rect r1, Rectangle r2) { + return r1.x == r2.x && r1.y == r2.y && r1.w == r2.width && r1.h == r2.height; + + } + + /** + * Get the average width of the font used by the given control + * + * @param display the display associated with the font usage + * @param font the font to look up the average character width for + * @return the average width, in pixels, of the given font + */ + public static final int getAverageCharWidth(Display display, Font font) { + GC gc = new GC(display); + gc.setFont(font); + FontMetrics fontMetrics = gc.getFontMetrics(); + int width = fontMetrics.getAverageCharWidth(); + gc.dispose(); + return width; + } + + /** + * Get the average width of the given font + * + * @param control the control to look up the default font for + * @return the average width, in pixels, of the current font in the control + */ + public static final int getAverageCharWidth(Control control) { + GC gc = new GC(control.getDisplay()); + int width = gc.getFontMetrics().getAverageCharWidth(); + gc.dispose(); + return width; + } + + /** + * Draws a drop shadow for the given rectangle into the given context. It + * will not draw anything if the rectangle is smaller than a minimum + * determined by the assets used to draw the shadow graphics. + * <p> + * This corresponds to {@link ImageUtils#drawRectangleShadow(Graphics, int, int, int, int)}, + * but applied directly to an SWT graphics context instead, such that no image conversion + * has to be performed. + * <p> + * Make sure to keep changes in the visual appearance here in sync with the + * AWT version in {@link ImageUtils#drawRectangleShadow(Graphics, int, int, int, int)}. + * + * @param gc the graphics context to draw into + * @param x the left coordinate of the left hand side of the rectangle + * @param y the top coordinate of the top of the rectangle + * @param width the width of the rectangle + * @param height the height of the rectangle + */ + public static final void drawRectangleShadow(GC gc, int x, int y, int width, int height) { + if (sShadowBottomLeft == null) { + IconFactory icons = IconFactory.getInstance(); + // See ImageUtils.drawRectangleShadow for an explanation of the assets. + sShadowBottomLeft = icons.getIcon("shadow-bl"); //$NON-NLS-1$ + sShadowBottom = icons.getIcon("shadow-b"); //$NON-NLS-1$ + sShadowBottomRight = icons.getIcon("shadow-br"); //$NON-NLS-1$ + sShadowRight = icons.getIcon("shadow-r"); //$NON-NLS-1$ + sShadowTopRight = icons.getIcon("shadow-tr"); //$NON-NLS-1$ + assert sShadowBottomRight.getImageData().width == SHADOW_SIZE; + assert sShadowBottomRight.getImageData().height == SHADOW_SIZE; + } + + ImageData bottomLeftData = sShadowBottomLeft.getImageData(); + ImageData topRightData = sShadowTopRight.getImageData(); + ImageData bottomData = sShadowBottom.getImageData(); + ImageData rightData = sShadowRight.getImageData(); + int blWidth = bottomLeftData.width; + int trHeight = topRightData.height; + if (width < blWidth) { + return; + } + if (height < trHeight) { + return; + } + + gc.drawImage(sShadowBottomLeft, x, y + height); + gc.drawImage(sShadowBottomRight, x + width, y + height); + gc.drawImage(sShadowTopRight, x + width, y); + gc.drawImage(sShadowBottom, + 0, 0, + bottomData.width, bottomData.height, + x + bottomLeftData.width, y + height, + width - bottomLeftData.width, bottomData.height); + gc.drawImage(sShadowRight, + 0, 0, + rightData.width, rightData.height, + x + width, y + topRightData.height, + rightData.width, height - topRightData.height); + } + + private static Image sShadowBottomLeft; + private static Image sShadowBottom; + private static Image sShadowBottomRight; + private static Image sShadowRight; + private static Image sShadowTopRight; +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ViewHierarchy.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ViewHierarchy.java new file mode 100644 index 000000000..d247e28d7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2/ViewHierarchy.java @@ -0,0 +1,771 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gle2; + +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.VIEW_MERGE; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.INode; +import com.android.ide.common.rendering.api.RenderSession; +import com.android.ide.common.rendering.api.ViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.utils.Pair; + +import org.eclipse.swt.graphics.Rectangle; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +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.RandomAccess; +import java.util.Set; + +/** + * The view hierarchy class manages a set of view info objects and performs find + * operations on this set. + */ +public class ViewHierarchy { + private static final boolean DUMP_INFO = false; + + private LayoutCanvas mCanvas; + + /** + * Constructs a new {@link ViewHierarchy} tied to the given + * {@link LayoutCanvas}. + * + * @param canvas The {@link LayoutCanvas} to create a {@link ViewHierarchy} + * for. + */ + public ViewHierarchy(LayoutCanvas canvas) { + mCanvas = canvas; + } + + /** + * The CanvasViewInfo root created by the last call to {@link #setSession} + * with a valid layout. + * <p/> + * This <em>can</em> be null to indicate we're dealing with an empty document with + * no root node. Null here does not mean the result was invalid, merely that the XML + * had no content to display -- we need to treat an empty document as valid so that + * we can drop new items in it. + */ + private CanvasViewInfo mLastValidViewInfoRoot; + + /** + * True when the last {@link #setSession} provided a valid {@link LayoutScene}. + * <p/> + * When false this means the canvas is displaying an out-dated result image & bounds and some + * features should be disabled accordingly such a drag'n'drop. + * <p/> + * Note that an empty document (with a null {@link #mLastValidViewInfoRoot}) is considered + * valid since it is an acceptable drop target. + */ + private boolean mIsResultValid; + + /** + * A list of invisible parents (see {@link CanvasViewInfo#isInvisible()} for + * details) in the current view hierarchy. + */ + private final List<CanvasViewInfo> mInvisibleParents = new ArrayList<CanvasViewInfo>(); + + /** + * A read-only view of {@link #mInvisibleParents}; note that this is NOT a copy so it + * reflects updates to the underlying {@link #mInvisibleParents} list. + */ + private final List<CanvasViewInfo> mInvisibleParentsReadOnly = + Collections.unmodifiableList(mInvisibleParents); + + /** + * Flag which records whether or not we have any exploded parent nodes in this + * view hierarchy. This is used to track whether or not we need to recompute the + * layout when we exit show-all-invisible-parents mode (see + * {@link LayoutCanvas#showInvisibleViews}). + */ + private boolean mExplodedParents; + + /** + * Bounds of included views in the current view hierarchy when rendered in other context + */ + private List<Rectangle> mIncludedBounds; + + /** The render session for the current view hierarchy */ + private RenderSession mSession; + + /** Map from nodes to canvas view infos */ + private Map<UiViewElementNode, CanvasViewInfo> mNodeToView = Collections.emptyMap(); + + /** Map from DOM nodes to canvas view infos */ + private Map<Node, CanvasViewInfo> mDomNodeToView = Collections.emptyMap(); + + /** + * Disposes the view hierarchy content. + */ + public void dispose() { + if (mSession != null) { + mSession.dispose(); + mSession = null; + } + } + + + /** + * Sets the result of the layout rendering. The result object indicates if the layout + * rendering succeeded. If it did, it contains a bitmap and the objects rectangles. + * + * Implementation detail: the bridge's computeLayout() method already returns a newly + * allocated ILayourResult. That means we can keep this result and hold on to it + * when it is valid. + * + * @param session The new session, either valid or not. + * @param explodedNodes The set of individual nodes the layout computer was asked to + * explode. Note that these are independent of the explode-all mode where + * all views are exploded; this is used only for the mode ( + * {@link LayoutCanvas#showInvisibleViews}) where individual invisible + * nodes are padded during certain interactions. + */ + /* package */ void setSession(RenderSession session, Set<UiElementNode> explodedNodes, + boolean layoutlib5) { + // replace the previous scene, so the previous scene must be disposed. + if (mSession != null) { + mSession.dispose(); + } + + mSession = session; + mIsResultValid = (session != null && session.getResult().isSuccess()); + mExplodedParents = false; + mNodeToView = new HashMap<UiViewElementNode, CanvasViewInfo>(50); + if (mIsResultValid && session != null) { + List<ViewInfo> rootList = session.getRootViews(); + + Pair<CanvasViewInfo,List<Rectangle>> infos = null; + + if (rootList == null || rootList.size() == 0) { + // Special case: Look to see if this is really an empty <merge> view, + // which shows up without any ViewInfos in the merge. In that case we + // want to manufacture an empty view, such that we can target the view + // via drag & drop, etc. + if (hasMergeRoot()) { + ViewInfo mergeRoot = createMergeInfo(session); + infos = CanvasViewInfo.create(mergeRoot, layoutlib5); + } else { + infos = null; + } + } else { + if (rootList.size() > 1 && hasMergeRoot()) { + ViewInfo mergeRoot = createMergeInfo(session); + mergeRoot.setChildren(rootList); + infos = CanvasViewInfo.create(mergeRoot, layoutlib5); + } else { + ViewInfo root = rootList.get(0); + + if (root != null) { + infos = CanvasViewInfo.create(root, layoutlib5); + if (DUMP_INFO) { + dump(session, root, 0); + } + } else { + infos = null; + } + } + } + if (infos != null) { + mLastValidViewInfoRoot = infos.getFirst(); + mIncludedBounds = infos.getSecond(); + + if (mLastValidViewInfoRoot.getUiViewNode() == null && + mLastValidViewInfoRoot.getChildren().isEmpty()) { + GraphicalEditorPart editor = mCanvas.getEditorDelegate().getGraphicalEditor(); + if (editor.getIncludedWithin() != null) { + // Somehow, this view was supposed to be rendered within another + // view, yet this view was rendered as part of the other view. + // In that case, abort attempting to show included in; clear the + // include context and trigger a standalone re-render. + editor.showIn(null); + return; + } + } + + } else { + mLastValidViewInfoRoot = null; + mIncludedBounds = null; + } + + updateNodeProxies(mLastValidViewInfoRoot); + + // Update the data structures related to tracking invisible and exploded nodes. + // We need to find the {@link CanvasViewInfo} objects that correspond to + // the passed in {@link UiElementNode} keys that were re-rendered, and mark + // them as exploded and store them in a list for rendering. + mExplodedParents = false; + mInvisibleParents.clear(); + addInvisibleParents(mLastValidViewInfoRoot, explodedNodes); + + mDomNodeToView = new HashMap<Node, CanvasViewInfo>(mNodeToView.size()); + for (Map.Entry<UiViewElementNode, CanvasViewInfo> entry : mNodeToView.entrySet()) { + mDomNodeToView.put(entry.getKey().getXmlNode(), entry.getValue()); + } + + // Update the selection + mCanvas.getSelectionManager().sync(); + } else { + mIncludedBounds = null; + mInvisibleParents.clear(); + mDomNodeToView = Collections.emptyMap(); + } + } + + private ViewInfo createMergeInfo(RenderSession session) { + BufferedImage image = session.getImage(); + ControlPoint imageSize = ControlPoint.create(mCanvas, + mCanvas.getHorizontalTransform().getMargin() + image.getWidth(), + mCanvas.getVerticalTransform().getMargin() + image.getHeight()); + LayoutPoint layoutSize = imageSize.toLayout(); + UiDocumentNode model = mCanvas.getEditorDelegate().getUiRootNode(); + List<UiElementNode> children = model.getUiChildren(); + return new ViewInfo(VIEW_MERGE, children.get(0), 0, 0, layoutSize.x, layoutSize.y); + } + + /** + * Returns true if this view hierarchy corresponds to an editor that has a {@code + * <merge>} tag at the root + * + * @return true if there is a {@code <merge>} at the root of this editor's document + */ + private boolean hasMergeRoot() { + UiDocumentNode model = mCanvas.getEditorDelegate().getUiRootNode(); + if (model != null) { + List<UiElementNode> children = model.getUiChildren(); + if (children != null && children.size() > 0 + && VIEW_MERGE.equals(children.get(0).getDescriptor().getXmlName())) { + return true; + } + } + + return false; + } + + /** + * Creates or updates the node proxy for this canvas view info. + * <p/> + * Since proxies are reused, this will update the bounds of an existing proxy when the + * canvas is refreshed and a view changes position or size. + * <p/> + * This is a recursive call that updates the whole hierarchy starting at the given + * view info. + */ + private void updateNodeProxies(CanvasViewInfo vi) { + if (vi == null) { + return; + } + + UiViewElementNode key = vi.getUiViewNode(); + + if (key != null) { + mCanvas.getNodeFactory().create(vi); + mNodeToView.put(key, vi); + } + + for (CanvasViewInfo child : vi.getChildren()) { + updateNodeProxies(child); + } + } + + /** + * Make a pass over the view hierarchy and look for two things: + * <ol> + * <li>Invisible parents. These are nodes that can hold children and have empty + * bounds. These are then added to the {@link #mInvisibleParents} list. + * <li>Exploded nodes. These are nodes that were previously marked as invisible, and + * subsequently rendered by a recomputed layout. They now no longer have empty bounds, + * but should be specially marked via {@link CanvasViewInfo#setExploded} such that we + * for example in selection operations can determine if we need to recompute the + * layout. + * </ol> + * + * @param vi + * @param invisibleNodes + */ + private void addInvisibleParents(CanvasViewInfo vi, Set<UiElementNode> invisibleNodes) { + if (vi == null) { + return; + } + + if (vi.isInvisible()) { + mInvisibleParents.add(vi); + } else if (invisibleNodes != null) { + UiViewElementNode key = vi.getUiViewNode(); + + if (key != null && invisibleNodes.contains(key)) { + vi.setExploded(true); + mExplodedParents = true; + mInvisibleParents.add(vi); + } + } + + for (CanvasViewInfo child : vi.getChildren()) { + addInvisibleParents(child, invisibleNodes); + } + } + + /** + * Returns the current {@link RenderSession}. + * @return the session or null if none have been set. + */ + public RenderSession getSession() { + return mSession; + } + + /** + * Returns true when the last {@link #setSession} provided a valid + * {@link RenderSession}. + * <p/> + * When false this means the canvas is displaying an out-dated result image & bounds and some + * features should be disabled accordingly such a drag'n'drop. + * <p/> + * Note that an empty document (with a null {@link #getRoot()}) is considered + * valid since it is an acceptable drop target. + * @return True when this {@link ViewHierarchy} contains a valid hierarchy of views. + */ + public boolean isValid() { + return mIsResultValid; + } + + /** + * Returns true if the last valid content of the canvas represents an empty document. + * @return True if the last valid content of the canvas represents an empty document. + */ + public boolean isEmpty() { + return mLastValidViewInfoRoot == null; + } + + /** + * Returns true if we have parents in this hierarchy that are invisible (e.g. because + * they have no children and zero layout bounds). + * + * @return True if we have invisible parents. + */ + public boolean hasInvisibleParents() { + return mInvisibleParents.size() > 0; + } + + /** + * Returns true if we have views that were exploded during rendering + * @return True if we have exploded parents + */ + public boolean hasExplodedParents() { + return mExplodedParents; + } + + /** Locates and return any views that overlap the given selection rectangle. + * @param topLeft The top left corner of the selection rectangle. + * @param bottomRight The bottom right corner of the selection rectangle. + * @return A collection of {@link CanvasViewInfo} objects that overlap the + * rectangle. + */ + public Collection<CanvasViewInfo> findWithin( + LayoutPoint topLeft, + LayoutPoint bottomRight) { + Rectangle selectionRectangle = new Rectangle(topLeft.x, topLeft.y, bottomRight.x + - topLeft.x, bottomRight.y - topLeft.y); + List<CanvasViewInfo> infos = new ArrayList<CanvasViewInfo>(); + addWithin(mLastValidViewInfoRoot, selectionRectangle, infos); + return infos; + } + + /** + * Recursive internal version of {@link #findViewInfoAt(int, int)}. Please don't use directly. + * <p/> + * Tries to find the inner most child matching the given x,y coordinates in the view + * info sub-tree. This uses the potentially-expanded selection bounds. + * + * Returns null if not found. + */ + private void addWithin( + CanvasViewInfo canvasViewInfo, + Rectangle canvasRectangle, + List<CanvasViewInfo> infos) { + if (canvasViewInfo == null) { + return; + } + Rectangle r = canvasViewInfo.getSelectionRect(); + if (canvasRectangle.intersects(r)) { + + // try to find a matching child first + for (CanvasViewInfo child : canvasViewInfo.getChildren()) { + addWithin(child, canvasRectangle, infos); + } + + if (canvasViewInfo != mLastValidViewInfoRoot) { + infos.add(canvasViewInfo); + } + } + } + + /** + * Locates and returns the {@link CanvasViewInfo} corresponding to the given + * node, or null if it cannot be found. + * + * @param node The node we want to find a corresponding + * {@link CanvasViewInfo} for. + * @return The {@link CanvasViewInfo} corresponding to the given node, or + * null if no match was found. + */ + @Nullable + public CanvasViewInfo findViewInfoFor(@Nullable Node node) { + CanvasViewInfo vi = mDomNodeToView.get(node); + + if (vi == null) { + if (node == null) { + return null; + } else if (node.getNodeType() == Node.TEXT_NODE) { + return mDomNodeToView.get(node.getParentNode()); + } else if (node.getNodeType() == Node.ATTRIBUTE_NODE) { + return mDomNodeToView.get(((Attr) node).getOwnerElement()); + } else if (node.getNodeType() == Node.DOCUMENT_NODE) { + return mDomNodeToView.get(((Document) node).getDocumentElement()); + } + } + + return vi; + } + + /** + * Tries to find the inner most child matching the given x,y coordinates in + * the view info sub-tree, starting at the last know view info root. This + * uses the potentially-expanded selection bounds. + * <p/> + * Returns null if not found or if there's no view info root. + * + * @param p The point at which to look for the deepest match in the view + * hierarchy + * @return A {@link CanvasViewInfo} that intersects the given point, or null + * if nothing was found. + */ + public CanvasViewInfo findViewInfoAt(LayoutPoint p) { + if (mLastValidViewInfoRoot == null) { + return null; + } + + return findViewInfoAt_Recursive(p, mLastValidViewInfoRoot); + } + + /** + * Recursive internal version of {@link #findViewInfoAt(int, int)}. Please don't use directly. + * <p/> + * Tries to find the inner most child matching the given x,y coordinates in the view + * info sub-tree. This uses the potentially-expanded selection bounds. + * + * Returns null if not found. + */ + private CanvasViewInfo findViewInfoAt_Recursive(LayoutPoint p, CanvasViewInfo canvasViewInfo) { + if (canvasViewInfo == null) { + return null; + } + Rectangle r = canvasViewInfo.getSelectionRect(); + if (r.contains(p.x, p.y)) { + + // try to find a matching child first + // Iterate in REVERSE z order such that siblings on top + // are checked before earlier siblings (this matters in layouts like + // FrameLayout and in <merge> contexts where the views are sitting on top + // of each other and we want to select the same view as the one drawn + // on top of the others + List<CanvasViewInfo> children = canvasViewInfo.getChildren(); + assert children instanceof RandomAccess; + for (int i = children.size() - 1; i >= 0; i--) { + CanvasViewInfo child = children.get(i); + CanvasViewInfo v = findViewInfoAt_Recursive(p, child); + if (v != null) { + return v; + } + } + + // if no children matched, this is the view that we're looking for + return canvasViewInfo; + } + + return null; + } + + /** + * Returns a list of all the possible alternatives for a given view at the given + * position. This is used to build and manage the "alternate" selection that cycles + * around the parents or children of the currently selected element. + */ + /* package */ List<CanvasViewInfo> findAltViewInfoAt(LayoutPoint p) { + if (mLastValidViewInfoRoot != null) { + return findAltViewInfoAt_Recursive(p, mLastValidViewInfoRoot, null); + } + + return null; + } + + /** + * Internal recursive version of {@link #findAltViewInfoAt(int, int, CanvasViewInfo)}. + * Please don't use directly. + */ + private List<CanvasViewInfo> findAltViewInfoAt_Recursive( + LayoutPoint p, CanvasViewInfo parent, List<CanvasViewInfo> outList) { + Rectangle r; + + if (outList == null) { + outList = new ArrayList<CanvasViewInfo>(); + + if (parent != null) { + // add the parent root only once + r = parent.getSelectionRect(); + if (r.contains(p.x, p.y)) { + outList.add(parent); + } + } + } + + if (parent != null && !parent.getChildren().isEmpty()) { + // then add all children that match the position + for (CanvasViewInfo child : parent.getChildren()) { + r = child.getSelectionRect(); + if (r.contains(p.x, p.y)) { + outList.add(child); + } + } + + // finally recurse in the children + for (CanvasViewInfo child : parent.getChildren()) { + r = child.getSelectionRect(); + if (r.contains(p.x, p.y)) { + findAltViewInfoAt_Recursive(p, child, outList); + } + } + } + + return outList; + } + + /** + * Locates and returns the {@link CanvasViewInfo} corresponding to the given + * node, or null if it cannot be found. + * + * @param node The node we want to find a corresponding + * {@link CanvasViewInfo} for. + * @return The {@link CanvasViewInfo} corresponding to the given node, or + * null if no match was found. + */ + public CanvasViewInfo findViewInfoFor(INode node) { + return findViewInfoFor((NodeProxy) node); + } + + /** + * Tries to find a child with the same view key in the view info sub-tree. + * Returns null if not found. + * + * @param viewKey The view key that a matching {@link CanvasViewInfo} should + * have as its key. + * @return A {@link CanvasViewInfo} matching the given key, or null if not + * found. + */ + public CanvasViewInfo findViewInfoFor(UiElementNode viewKey) { + return mNodeToView.get(viewKey); + } + + /** + * Tries to find a child with the given node proxy as the view key. + * Returns null if not found. + * + * @param proxy The view key that a matching {@link CanvasViewInfo} should + * have as its key. + * @return A {@link CanvasViewInfo} matching the given key, or null if not + * found. + */ + @Nullable + public CanvasViewInfo findViewInfoFor(@Nullable NodeProxy proxy) { + if (proxy == null) { + return null; + } + return mNodeToView.get(proxy.getNode()); + } + + /** + * Returns a list of ALL ViewInfos (possibly excluding the root, depending + * on the parameter for that). + * + * @param includeRoot If true, include the root in the list, otherwise + * exclude it (but include all its children) + * @return A list of canvas view infos. + */ + public List<CanvasViewInfo> findAllViewInfos(boolean includeRoot) { + List<CanvasViewInfo> infos = new ArrayList<CanvasViewInfo>(); + if (mIsResultValid && mLastValidViewInfoRoot != null) { + findAllViewInfos(infos, mLastValidViewInfoRoot, includeRoot); + } + + return infos; + } + + private void findAllViewInfos(List<CanvasViewInfo> result, CanvasViewInfo canvasViewInfo, + boolean includeRoot) { + if (canvasViewInfo != null) { + if (includeRoot || !canvasViewInfo.isRoot()) { + result.add(canvasViewInfo); + } + for (CanvasViewInfo child : canvasViewInfo.getChildren()) { + findAllViewInfos(result, child, true); + } + } + } + + /** + * Returns the root of the view hierarchy, if any (could be null, for example + * on rendering failure). + * + * @return The current view hierarchy, or null + */ + public CanvasViewInfo getRoot() { + return mLastValidViewInfoRoot; + } + + /** + * Returns a collection of views that have zero bounds and that correspond to empty + * parents. Note that the views may not actually have zero bounds; in particular, if + * they are exploded ({@link CanvasViewInfo#isExploded()}, then they will have the + * bounds of a shown invisible node. Therefore, this method returns the views that + * would be invisible in a real rendering of the scene. + * + * @return A collection of empty parent views. + */ + public List<CanvasViewInfo> getInvisibleViews() { + return mInvisibleParentsReadOnly; + } + + /** + * Returns the invisible nodes (the {@link UiElementNode} objects corresponding + * to the {@link CanvasViewInfo} objects returned from {@link #getInvisibleViews()}. + * We are pulling out the nodes since they preserve their identity across layout + * rendering, and in particular we return it as a set such that the layout renderer + * can perform quick identity checks when looking up attribute values during the + * rendering process. + * + * @return A set of the invisible nodes. + */ + public Set<UiElementNode> getInvisibleNodes() { + if (mInvisibleParents.size() == 0) { + return Collections.emptySet(); + } + + Set<UiElementNode> nodes = new HashSet<UiElementNode>(mInvisibleParents.size()); + for (CanvasViewInfo info : mInvisibleParents) { + UiViewElementNode node = info.getUiViewNode(); + if (node != null) { + nodes.add(node); + } + } + + return nodes; + } + + /** + * Returns the list of bounds for included views in the current view hierarchy. Can be null + * when there are no included views. + * + * @return a list of included view bounds, or null + */ + public List<Rectangle> getIncludedBounds() { + return mIncludedBounds; + } + + /** + * Returns a map of the default properties for the given view object in this session + * + * @param viewObject the object to look up the properties map for + * @return the map of properties, or null if not found + */ + @Nullable + public Map<String, String> getDefaultProperties(@NonNull Object viewObject) { + if (mSession != null) { + return mSession.getDefaultProperties(viewObject); + } + + return null; + } + + /** + * Dumps a {@link ViewInfo} hierarchy to stdout + * + * @param session the corresponding session, if any + * @param info the {@link ViewInfo} object to dump + * @param depth the depth to indent it to + */ + public static void dump(RenderSession session, ViewInfo info, int depth) { + if (DUMP_INFO) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < depth; i++) { + sb.append(" "); //$NON-NLS-1$ + } + sb.append(info.getClassName()); + sb.append(" ["); //$NON-NLS-1$ + sb.append(info.getLeft()); + sb.append(","); //$NON-NLS-1$ + sb.append(info.getTop()); + sb.append(","); //$NON-NLS-1$ + sb.append(info.getRight()); + sb.append(","); //$NON-NLS-1$ + sb.append(info.getBottom()); + sb.append("]"); //$NON-NLS-1$ + Object cookie = info.getCookie(); + if (cookie instanceof UiViewElementNode) { + sb.append(" "); //$NON-NLS-1$ + UiViewElementNode node = (UiViewElementNode) cookie; + sb.append("<"); //$NON-NLS-1$ + sb.append(node.getDescriptor().getXmlName()); + sb.append(">"); //$NON-NLS-1$ + + String id = node.getAttributeValue(ATTR_ID); + if (id != null && !id.isEmpty()) { + sb.append(" "); + sb.append(id); + } + } else if (cookie != null) { + sb.append(" " + cookie); //$NON-NLS-1$ + } + /* Display defaults? + if (info.getViewObject() != null) { + Map<String, String> defaults = session.getDefaultProperties(info.getCookie()); + sb.append(" - defaults: "); //$NON-NLS-1$ + sb.append(defaults); + sb.append('\n'); + } + */ + + System.out.println(sb.toString()); + + for (ViewInfo child : info.getChildren()) { + dump(session, child, depth + 1); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/ClientRulesEngine.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/ClientRulesEngine.java new file mode 100644 index 000000000..388907a46 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/ClientRulesEngine.java @@ -0,0 +1,762 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gre; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.AUTO_URI; +import static com.android.SdkConstants.CLASS_FRAGMENT; +import static com.android.SdkConstants.CLASS_V4_FRAGMENT; +import static com.android.SdkConstants.CLASS_VIEW; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.URI_PREFIX; + +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.IValidator; +import com.android.ide.common.api.IViewMetadata; +import com.android.ide.common.api.IViewRule; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.layout.BaseViewRule; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.actions.AddSupportJarAction; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationChooser; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderService; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionManager; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.ViewHierarchy; +import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResult; +import com.android.ide.eclipse.adt.internal.resources.CyclicDependencyValidator; +import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.ProjectState; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.ide.eclipse.adt.internal.ui.MarginChooser; +import com.android.ide.eclipse.adt.internal.ui.ReferenceChooserDialog; +import com.android.ide.eclipse.adt.internal.ui.ResourceChooser; +import com.android.ide.eclipse.adt.internal.ui.ResourcePreviewHelper; +import com.android.resources.ResourceType; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jdt.core.Flags; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IPackageFragment; +import org.eclipse.jdt.core.IPackageFragmentRoot; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.ITypeHierarchy; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.search.IJavaSearchScope; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.ui.IJavaElementSearchConstants; +import org.eclipse.jdt.ui.JavaUI; +import org.eclipse.jdt.ui.actions.OpenNewClassWizardAction; +import org.eclipse.jdt.ui.dialogs.ITypeInfoFilterExtension; +import org.eclipse.jdt.ui.dialogs.ITypeInfoRequestor; +import org.eclipse.jdt.ui.dialogs.TypeSelectionExtension; +import org.eclipse.jdt.ui.wizards.NewClassWizardPage; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.dialogs.ProgressMonitorDialog; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.dialogs.SelectionDialog; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Implementation of {@link IClientRulesEngine}. This provides {@link IViewRule} clients + * with a few methods they can use to access functionality from this {@link RulesEngine}. + */ +class ClientRulesEngine implements IClientRulesEngine { + /** The return code from the dialog for the user choosing "Clear" */ + public static final int CLEAR_RETURN_CODE = -5; + /** The dialog button ID for the user choosing "Clear" */ + private static final int CLEAR_BUTTON_ID = CLEAR_RETURN_CODE; + + private final RulesEngine mRulesEngine; + private final String mFqcn; + + public ClientRulesEngine(RulesEngine rulesEngine, String fqcn) { + mRulesEngine = rulesEngine; + mFqcn = fqcn; + } + + @Override + public @NonNull String getFqcn() { + return mFqcn; + } + + @Override + public void debugPrintf(@NonNull String msg, Object... params) { + AdtPlugin.printToConsole( + mFqcn == null ? "<unknown>" : mFqcn, + String.format(msg, params) + ); + } + + @Override + public IViewRule loadRule(@NonNull String fqcn) { + return mRulesEngine.loadRule(fqcn, fqcn); + } + + @Override + public void displayAlert(@NonNull String message) { + MessageDialog.openInformation( + AdtPlugin.getShell(), + mFqcn, // title + message); + } + + @Override + public boolean rename(INode node) { + GraphicalEditorPart editor = mRulesEngine.getEditor(); + SelectionManager manager = editor.getCanvasControl().getSelectionManager(); + RenameResult result = manager.performRename(node, null); + + return !result.isCanceled() && !result.isUnavailable(); + } + + @Override + public String displayInput(@NonNull String message, @Nullable String value, + final @Nullable IValidator filter) { + IInputValidator validator = null; + if (filter != null) { + validator = new IInputValidator() { + @Override + public String isValid(String newText) { + // IValidator has the same interface as SWT's IInputValidator + try { + return filter.validate(newText); + } catch (Exception e) { + AdtPlugin.log(e, "Custom validator failed: %s", e.toString()); + return ""; //$NON-NLS-1$ + } + } + }; + } + + InputDialog d = new InputDialog( + AdtPlugin.getShell(), + mFqcn, // title + message, + value == null ? "" : value, //$NON-NLS-1$ + validator) { + @Override + protected void createButtonsForButtonBar(Composite parent) { + createButton(parent, CLEAR_BUTTON_ID, "Clear", false /*defaultButton*/); + super.createButtonsForButtonBar(parent); + } + + @Override + protected void buttonPressed(int buttonId) { + super.buttonPressed(buttonId); + + if (buttonId == CLEAR_BUTTON_ID) { + assert CLEAR_RETURN_CODE != Window.OK && CLEAR_RETURN_CODE != Window.CANCEL; + setReturnCode(CLEAR_RETURN_CODE); + close(); + } + } + }; + int result = d.open(); + if (result == ResourceChooser.CLEAR_RETURN_CODE) { + return ""; + } else if (result == Window.OK) { + return d.getValue(); + } + return null; + } + + @Override + @Nullable + public Object getViewObject(@NonNull INode node) { + ViewHierarchy views = mRulesEngine.getEditor().getCanvasControl().getViewHierarchy(); + CanvasViewInfo vi = views.findViewInfoFor(node); + if (vi != null) { + return vi.getViewObject(); + } + + return null; + } + + @Override + public @NonNull IViewMetadata getMetadata(final @NonNull String fqcn) { + return new IViewMetadata() { + @Override + public @NonNull String getDisplayName() { + // This also works when there is no "." + return fqcn.substring(fqcn.lastIndexOf('.') + 1); + } + + @Override + public @NonNull FillPreference getFillPreference() { + return ViewMetadataRepository.get().getFillPreference(fqcn); + } + + @Override + public @NonNull Margins getInsets() { + return mRulesEngine.getEditor().getCanvasControl().getInsets(fqcn); + } + + @Override + public @NonNull List<String> getTopAttributes() { + return ViewMetadataRepository.get().getTopAttributes(fqcn); + } + }; + } + + @Override + public int getMinApiLevel() { + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(mRulesEngine.getEditor().getProject()); + if (target != null) { + return target.getVersion().getApiLevel(); + } + } + + return -1; + } + + @Override + public IValidator getResourceValidator( + @NonNull final String resourceTypeName, final boolean uniqueInProject, + final boolean uniqueInLayout, final boolean exists, final String... allowed) { + return new IValidator() { + private ResourceNameValidator mValidator; + + @Override + public String validate(@NonNull String text) { + if (mValidator == null) { + ResourceType type = ResourceType.getEnum(resourceTypeName); + if (uniqueInLayout) { + assert !uniqueInProject; + assert !exists; + Set<String> existing = new HashSet<String>(); + Document doc = mRulesEngine.getEditor().getModel().getXmlDocument(); + if (doc != null) { + addIds(doc, existing); + } + for (String s : allowed) { + existing.remove(s); + } + mValidator = ResourceNameValidator.create(false, existing, type); + } else { + assert allowed.length == 0; + IProject project = mRulesEngine.getEditor().getProject(); + mValidator = ResourceNameValidator.create(false, project, type); + if (uniqueInProject) { + mValidator.unique(); + } + } + if (exists) { + mValidator.exist(); + } + } + + return mValidator.isValid(text); + } + }; + } + + /** Find declared ids under the given DOM node */ + private static void addIds(Node node, Set<String> ids) { + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) node; + String id = element.getAttributeNS(ANDROID_URI, ATTR_ID); + if (id != null && id.startsWith(NEW_ID_PREFIX)) { + ids.add(BaseViewRule.stripIdPrefix(id)); + } + } + + NodeList children = node.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node child = children.item(i); + addIds(child, ids); + } + } + + @Override + public String displayReferenceInput(@Nullable String currentValue) { + GraphicalEditorPart graphicalEditor = mRulesEngine.getEditor(); + LayoutEditorDelegate delegate = graphicalEditor.getEditorDelegate(); + IProject project = delegate.getEditor().getProject(); + if (project != null) { + // get the resource repository for this project and the system resources. + ResourceRepository projectRepository = + ResourceManager.getInstance().getProjectResources(project); + Shell shell = AdtPlugin.getShell(); + if (shell == null) { + return null; + } + ReferenceChooserDialog dlg = new ReferenceChooserDialog( + project, + projectRepository, + shell); + dlg.setPreviewHelper(new ResourcePreviewHelper(dlg, graphicalEditor)); + + dlg.setCurrentResource(currentValue); + + if (dlg.open() == Window.OK) { + return dlg.getCurrentResource(); + } + } + + return null; + } + + @Override + public String displayResourceInput(@NonNull String resourceTypeName, + @Nullable String currentValue) { + return displayResourceInput(resourceTypeName, currentValue, null); + } + + private String displayResourceInput(String resourceTypeName, String currentValue, + IInputValidator validator) { + ResourceType type = ResourceType.getEnum(resourceTypeName); + GraphicalEditorPart graphicalEditor = mRulesEngine.getEditor(); + return ResourceChooser.chooseResource(graphicalEditor, type, currentValue, validator); + } + + @Override + public String[] displayMarginInput(@Nullable String all, @Nullable String left, + @Nullable String right, @Nullable String top, @Nullable String bottom) { + GraphicalEditorPart editor = mRulesEngine.getEditor(); + IProject project = editor.getProject(); + if (project != null) { + Shell shell = AdtPlugin.getShell(); + if (shell == null) { + return null; + } + AndroidTargetData data = editor.getEditorDelegate().getEditor().getTargetData(); + MarginChooser dialog = new MarginChooser(shell, editor, data, all, left, right, + top, bottom); + if (dialog.open() == Window.OK) { + return dialog.getMargins(); + } + } + + return null; + } + + @Override + public String displayIncludeSourceInput() { + AndroidXmlEditor editor = mRulesEngine.getEditor().getEditorDelegate().getEditor(); + IInputValidator validator = CyclicDependencyValidator.create(editor.getInputFile()); + return displayResourceInput(ResourceType.LAYOUT.getName(), null, validator); + } + + @Override + public void select(final @NonNull Collection<INode> nodes) { + LayoutCanvas layoutCanvas = mRulesEngine.getEditor().getCanvasControl(); + final SelectionManager selectionManager = layoutCanvas.getSelectionManager(); + selectionManager.select(nodes); + // ALSO run an async select since immediately after nodes are created they + // may not be selectable. We can't ONLY run an async exec since + // code may depend on operating on the selection. + layoutCanvas.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + selectionManager.select(nodes); + } + }); + } + + @Override + public String displayFragmentSourceInput() { + try { + // Compute a search scope: We need to merge all the subclasses + // android.app.Fragment and android.support.v4.app.Fragment + IJavaSearchScope scope = SearchEngine.createWorkspaceScope(); + IProject project = mRulesEngine.getProject(); + final IJavaProject javaProject = BaseProjectHelper.getJavaProject(project); + if (javaProject != null) { + IType oldFragmentType = javaProject.findType(CLASS_V4_FRAGMENT); + + // First check to make sure fragments are available, and if not, + // warn the user. + IAndroidTarget target = Sdk.getCurrent().getTarget(project); + // No, this should be using the min SDK instead! + if (target.getVersion().getApiLevel() < 11 && oldFragmentType == null) { + // Compatibility library must be present + MessageDialog dialog = + new MessageDialog( + Display.getCurrent().getActiveShell(), + "Fragment Warning", + null, + "Fragments require API level 11 or higher, or a compatibility " + + "library for older versions.\n\n" + + " Do you want to install the compatibility library?", + MessageDialog.QUESTION, + new String[] { "Install", "Cancel" }, + 1 /* default button: Cancel */); + int answer = dialog.open(); + if (answer == 0) { + if (!AddSupportJarAction.install(project)) { + return null; + } + } else { + return null; + } + } + + // Look up sub-types of each (new fragment class and compatibility fragment + // class, if any) and merge the two arrays - then create a scope from these + // elements. + IType[] fragmentTypes = new IType[0]; + IType[] oldFragmentTypes = new IType[0]; + if (oldFragmentType != null) { + ITypeHierarchy hierarchy = + oldFragmentType.newTypeHierarchy(new NullProgressMonitor()); + oldFragmentTypes = hierarchy.getAllSubtypes(oldFragmentType); + } + IType fragmentType = javaProject.findType(CLASS_FRAGMENT); + if (fragmentType != null) { + ITypeHierarchy hierarchy = + fragmentType.newTypeHierarchy(new NullProgressMonitor()); + fragmentTypes = hierarchy.getAllSubtypes(fragmentType); + } + IType[] subTypes = new IType[fragmentTypes.length + oldFragmentTypes.length]; + System.arraycopy(fragmentTypes, 0, subTypes, 0, fragmentTypes.length); + System.arraycopy(oldFragmentTypes, 0, subTypes, fragmentTypes.length, + oldFragmentTypes.length); + scope = SearchEngine.createJavaSearchScope(subTypes, IJavaSearchScope.SOURCES); + } + + Shell parent = AdtPlugin.getShell(); + final AtomicReference<String> returnValue = + new AtomicReference<String>(); + final AtomicReference<SelectionDialog> dialogHolder = + new AtomicReference<SelectionDialog>(); + final SelectionDialog dialog = JavaUI.createTypeDialog( + parent, + new ProgressMonitorDialog(parent), + scope, + IJavaElementSearchConstants.CONSIDER_CLASSES, false, + // Use ? as a default filter to fill dialog with matches + "?", //$NON-NLS-1$ + new TypeSelectionExtension() { + @Override + public Control createContentArea(Composite parentComposite) { + Composite composite = new Composite(parentComposite, SWT.NONE); + composite.setLayout(new GridLayout(1, false)); + Button button = new Button(composite, SWT.PUSH); + button.setText("Create New..."); + button.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + String fqcn = createNewFragmentClass(javaProject); + if (fqcn != null) { + returnValue.set(fqcn); + dialogHolder.get().close(); + } + } + }); + return composite; + } + + @Override + public ITypeInfoFilterExtension getFilterExtension() { + return new ITypeInfoFilterExtension() { + @Override + public boolean select(ITypeInfoRequestor typeInfoRequestor) { + int modifiers = typeInfoRequestor.getModifiers(); + if (!Flags.isPublic(modifiers) + || Flags.isInterface(modifiers) + || Flags.isEnum(modifiers) + || Flags.isAbstract(modifiers)) { + return false; + } + return true; + } + }; + } + }); + dialogHolder.set(dialog); + + dialog.setTitle("Choose Fragment Class"); + dialog.setMessage("Select a Fragment class (? = any character, * = any string):"); + if (dialog.open() == IDialogConstants.CANCEL_ID) { + return null; + } + if (returnValue.get() != null) { + return returnValue.get(); + } + + Object[] types = dialog.getResult(); + if (types != null && types.length > 0) { + return ((IType) types[0]).getFullyQualifiedName(); + } + } catch (JavaModelException e) { + AdtPlugin.log(e, null); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + return null; + } + + @Override + public String displayCustomViewClassInput() { + try { + IJavaSearchScope scope = SearchEngine.createWorkspaceScope(); + IProject project = mRulesEngine.getProject(); + final IJavaProject javaProject = BaseProjectHelper.getJavaProject(project); + if (javaProject != null) { + // Look up sub-types of each (new fragment class and compatibility fragment + // class, if any) and merge the two arrays - then create a scope from these + // elements. + IType[] viewTypes = new IType[0]; + IType fragmentType = javaProject.findType(CLASS_VIEW); + if (fragmentType != null) { + ITypeHierarchy hierarchy = + fragmentType.newTypeHierarchy(new NullProgressMonitor()); + viewTypes = hierarchy.getAllSubtypes(fragmentType); + } + scope = SearchEngine.createJavaSearchScope(viewTypes, IJavaSearchScope.SOURCES); + } + + Shell parent = AdtPlugin.getShell(); + final AtomicReference<String> returnValue = + new AtomicReference<String>(); + final AtomicReference<SelectionDialog> dialogHolder = + new AtomicReference<SelectionDialog>(); + final SelectionDialog dialog = JavaUI.createTypeDialog( + parent, + new ProgressMonitorDialog(parent), + scope, + IJavaElementSearchConstants.CONSIDER_CLASSES, false, + // Use ? as a default filter to fill dialog with matches + "?", //$NON-NLS-1$ + new TypeSelectionExtension() { + @Override + public Control createContentArea(Composite parentComposite) { + Composite composite = new Composite(parentComposite, SWT.NONE); + composite.setLayout(new GridLayout(1, false)); + Button button = new Button(composite, SWT.PUSH); + button.setText("Create New..."); + button.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + String fqcn = createNewCustomViewClass(javaProject); + if (fqcn != null) { + returnValue.set(fqcn); + dialogHolder.get().close(); + } + } + }); + return composite; + } + + @Override + public ITypeInfoFilterExtension getFilterExtension() { + return new ITypeInfoFilterExtension() { + @Override + public boolean select(ITypeInfoRequestor typeInfoRequestor) { + int modifiers = typeInfoRequestor.getModifiers(); + if (!Flags.isPublic(modifiers) + || Flags.isInterface(modifiers) + || Flags.isEnum(modifiers) + || Flags.isAbstract(modifiers)) { + return false; + } + return true; + } + }; + } + }); + dialogHolder.set(dialog); + + dialog.setTitle("Choose Custom View Class"); + dialog.setMessage("Select a Custom View class (? = any character, * = any string):"); + if (dialog.open() == IDialogConstants.CANCEL_ID) { + return null; + } + if (returnValue.get() != null) { + return returnValue.get(); + } + + Object[] types = dialog.getResult(); + if (types != null && types.length > 0) { + return ((IType) types[0]).getFullyQualifiedName(); + } + } catch (JavaModelException e) { + AdtPlugin.log(e, null); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + return null; + } + + @Override + public void redraw() { + mRulesEngine.getEditor().getCanvasControl().redraw(); + } + + @Override + public void layout() { + mRulesEngine.getEditor().recomputeLayout(); + } + + @Override + public Map<INode, Rect> measureChildren(@NonNull INode parent, + @Nullable IClientRulesEngine.AttributeFilter filter) { + RenderService renderService = RenderService.create(mRulesEngine.getEditor()); + Map<INode, Rect> map = renderService.measureChildren(parent, filter); + if (map == null) { + map = Collections.emptyMap(); + } + return map; + } + + @Override + public int pxToDp(int px) { + ConfigurationChooser chooser = mRulesEngine.getEditor().getConfigurationChooser(); + float dpi = chooser.getConfiguration().getDensity().getDpiValue(); + return (int) (px * 160 / dpi); + } + + @Override + public int dpToPx(int dp) { + ConfigurationChooser chooser = mRulesEngine.getEditor().getConfigurationChooser(); + float dpi = chooser.getConfiguration().getDensity().getDpiValue(); + return (int) (dp * dpi / 160); + } + + @Override + public int screenToLayout(int pixels) { + return (int) (pixels / mRulesEngine.getEditor().getCanvasControl().getScale()); + } + + private String createNewFragmentClass(IJavaProject javaProject) { + NewClassWizardPage page = new NewClassWizardPage(); + + IProject project = mRulesEngine.getProject(); + Sdk sdk = Sdk.getCurrent(); + if (sdk == null) { + return null; + } + IAndroidTarget target = sdk.getTarget(project); + String superClass; + if (target == null || target.getVersion().getApiLevel() < 11) { + superClass = CLASS_V4_FRAGMENT; + } else { + superClass = CLASS_FRAGMENT; + } + page.setSuperClass(superClass, true /* canBeModified */); + IPackageFragmentRoot root = ManifestInfo.getSourcePackageRoot(javaProject); + if (root != null) { + page.setPackageFragmentRoot(root, true /* canBeModified */); + } + ManifestInfo manifestInfo = ManifestInfo.get(project); + IPackageFragment pkg = manifestInfo.getPackageFragment(); + if (pkg != null) { + page.setPackageFragment(pkg, true /* canBeModified */); + } + OpenNewClassWizardAction action = new OpenNewClassWizardAction(); + action.setConfiguredWizardPage(page); + action.run(); + IType createdType = page.getCreatedType(); + if (createdType != null) { + return createdType.getFullyQualifiedName(); + } else { + return null; + } + } + + private String createNewCustomViewClass(IJavaProject javaProject) { + NewClassWizardPage page = new NewClassWizardPage(); + + IProject project = mRulesEngine.getProject(); + String superClass = CLASS_VIEW; + page.setSuperClass(superClass, true /* canBeModified */); + IPackageFragmentRoot root = ManifestInfo.getSourcePackageRoot(javaProject); + if (root != null) { + page.setPackageFragmentRoot(root, true /* canBeModified */); + } + ManifestInfo manifestInfo = ManifestInfo.get(project); + IPackageFragment pkg = manifestInfo.getPackageFragment(); + if (pkg != null) { + page.setPackageFragment(pkg, true /* canBeModified */); + } + OpenNewClassWizardAction action = new OpenNewClassWizardAction(); + action.setConfiguredWizardPage(page); + action.run(); + IType createdType = page.getCreatedType(); + if (createdType != null) { + return createdType.getFullyQualifiedName(); + } else { + return null; + } + } + + @Override + public @NonNull String getUniqueId(@NonNull String fqcn) { + UiDocumentNode root = mRulesEngine.getEditor().getModel(); + String prefix = fqcn.substring(fqcn.lastIndexOf('.') + 1); + prefix = Character.toLowerCase(prefix.charAt(0)) + prefix.substring(1); + return DescriptorsUtils.getFreeWidgetId(root, prefix); + } + + @Override + public @NonNull String getAppNameSpace() { + IProject project = mRulesEngine.getEditor().getProject(); + + ProjectState projectState = Sdk.getProjectState(project); + if (projectState != null && projectState.isLibrary()) { + return AUTO_URI; + } + + ManifestInfo info = ManifestInfo.get(project); + return URI_PREFIX + info.getPackage(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/NodeFactory.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/NodeFactory.java new file mode 100644 index 000000000..b0b9971ba --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/NodeFactory.java @@ -0,0 +1,86 @@ +/* + * 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.eclipse.adt.internal.editors.layout.gre; + +import com.android.ide.common.api.INode; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SwtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; + +import org.eclipse.swt.graphics.Rectangle; + +import java.util.Map; +import java.util.WeakHashMap; + +/** + * An object that can create {@link INode} proxies. + * This also keeps references to objects already created and tries to reuse them. + */ +public class NodeFactory { + + private final Map<UiViewElementNode, NodeProxy> mNodeMap = + new WeakHashMap<UiViewElementNode, NodeProxy>(); + private LayoutCanvas mCanvas; + + public NodeFactory(LayoutCanvas canvas) { + mCanvas = canvas; + } + + /** + * Returns an {@link INode} proxy based on the view key of the given + * {@link CanvasViewInfo}. The bounds of the node are set to the canvas view bounds. + */ + public NodeProxy create(CanvasViewInfo canvasViewInfo) { + return create(canvasViewInfo.getUiViewNode(), canvasViewInfo.getAbsRect()); + } + + /** + * Returns an {@link INode} proxy based on a given {@link UiViewElementNode} that + * is not yet part of the canvas, typically those created by layout rules + * when generating new XML. + */ + public NodeProxy create(UiViewElementNode uiNode) { + return create(uiNode, null /*bounds*/); + } + + public void clear() { + mNodeMap.clear(); + } + + public LayoutCanvas getCanvas() { + return mCanvas; + } + + //---- + + private NodeProxy create(UiViewElementNode uiNode, Rectangle bounds) { + NodeProxy proxy = mNodeMap.get(uiNode); + + if (proxy == null) { + // Create a new proxy if the key doesn't exist + proxy = new NodeProxy(uiNode, bounds, this); + mNodeMap.put(uiNode, proxy); + + } else if (bounds != null && !SwtUtils.equals(proxy.getBounds(), bounds)) { + // Update the bounds if necessary + proxy.setBounds(bounds); + } + + return proxy; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/NodeProxy.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/NodeProxy.java new file mode 100644 index 000000000..19d5e16b0 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/NodeProxy.java @@ -0,0 +1,517 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gre; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.common.api.INode; +import com.android.ide.common.api.INodeHandler; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.Rect; +import com.android.ide.common.resources.platform.AttributeInfo; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SimpleAttribute; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SwtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.ViewHierarchy; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.project.SupportLibraryHelper; + +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.graphics.Rectangle; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * + */ +public class NodeProxy implements INode { + private static final Margins NO_MARGINS = new Margins(0, 0, 0, 0); + private final UiViewElementNode mNode; + private final Rect mBounds; + private final NodeFactory mFactory; + /** Map from URI to Map(key=>value) (where no namespace uses "" as a key) */ + private Map<String, Map<String, String>> mPendingAttributes; + + /** + * Creates a new {@link INode} that wraps an {@link UiViewElementNode} that is + * actually valid in the current UI/XML model. The view may not be part of the canvas + * yet (e.g. if it has just been dynamically added and the canvas hasn't reloaded yet.) + * <p/> + * This method is package protected. To create a node, please use {@link NodeFactory} instead. + * + * @param uiNode The node to wrap. + * @param bounds The bounds of a the view in the canvas. Must be either: <br/> + * - a valid rect for a view that is actually in the canvas <br/> + * - <b>*or*</b> null (or an invalid rect) for a view that has just been added dynamically + * to the model. We never store a null bounds rectangle in the node, a null rectangle + * will be converted to an invalid rectangle. + * @param factory A {@link NodeFactory} to create unique children nodes. + */ + /*package*/ NodeProxy(UiViewElementNode uiNode, Rectangle bounds, NodeFactory factory) { + mNode = uiNode; + mFactory = factory; + if (bounds == null) { + mBounds = new Rect(); + } else { + mBounds = SwtUtils.toRect(bounds); + } + } + + @Override + public @NonNull Rect getBounds() { + return mBounds; + } + + @Override + public @NonNull Margins getMargins() { + ViewHierarchy viewHierarchy = mFactory.getCanvas().getViewHierarchy(); + CanvasViewInfo view = viewHierarchy.findViewInfoFor(this); + if (view != null) { + Margins margins = view.getMargins(); + if (margins != null) { + return margins; + } + } + + return NO_MARGINS; + } + + + @Override + public int getBaseline() { + ViewHierarchy viewHierarchy = mFactory.getCanvas().getViewHierarchy(); + CanvasViewInfo view = viewHierarchy.findViewInfoFor(this); + if (view != null) { + return view.getBaseline(); + } + + return -1; + } + + /** + * Updates the bounds of this node proxy. Bounds cannot be null, but it can be invalid. + * This is a package-protected method, only the {@link NodeFactory} uses this method. + */ + /*package*/ void setBounds(Rectangle bounds) { + SwtUtils.set(mBounds, bounds); + } + + /** + * Returns the {@link UiViewElementNode} corresponding to this + * {@link NodeProxy}. + * + * @return The {@link UiViewElementNode} corresponding to this + * {@link NodeProxy} + */ + public UiViewElementNode getNode() { + return mNode; + } + + @Override + public @NonNull String getFqcn() { + if (mNode != null) { + ElementDescriptor desc = mNode.getDescriptor(); + if (desc instanceof ViewElementDescriptor) { + return ((ViewElementDescriptor) desc).getFullClassName(); + } + } + + return ""; + } + + + // ---- Hierarchy handling ---- + + + @Override + public INode getRoot() { + if (mNode != null) { + UiElementNode p = mNode.getUiRoot(); + // The node root should be a document. Instead what we really mean to + // return is the top level view element. + if (p instanceof UiDocumentNode) { + List<UiElementNode> children = p.getUiChildren(); + if (children.size() > 0) { + p = children.get(0); + } + } + + // Cope with a badly structured XML layout + while (p != null && !(p instanceof UiViewElementNode)) { + p = p.getUiNextSibling(); + } + + if (p == mNode) { + return this; + } + if (p instanceof UiViewElementNode) { + return mFactory.create((UiViewElementNode) p); + } + } + + return null; + } + + @Override + public INode getParent() { + if (mNode != null) { + UiElementNode p = mNode.getUiParent(); + if (p instanceof UiViewElementNode) { + return mFactory.create((UiViewElementNode) p); + } + } + + return null; + } + + @Override + public @NonNull INode[] getChildren() { + if (mNode != null) { + List<UiElementNode> uiChildren = mNode.getUiChildren(); + List<INode> nodes = new ArrayList<INode>(uiChildren.size()); + for (UiElementNode uiChild : uiChildren) { + if (uiChild instanceof UiViewElementNode) { + nodes.add(mFactory.create((UiViewElementNode) uiChild)); + } + } + + return nodes.toArray(new INode[nodes.size()]); + } + + return new INode[0]; + } + + + // ---- XML Editing --- + + @Override + public void editXml(@NonNull String undoName, final @NonNull INodeHandler c) { + final AndroidXmlEditor editor = mNode.getEditor(); + + if (editor != null) { + // Create an undo edit XML wrapper, which takes a runnable + editor.wrapUndoEditXmlModel( + undoName, + new Runnable() { + @Override + public void run() { + // Here editor.isEditXmlModelPending returns true and it + // is safe to edit the model using any method from INode. + + // Finally execute the closure that will act on the XML + c.handle(NodeProxy.this); + applyPendingChanges(); + } + }); + } + } + + private void checkEditOK() { + final AndroidXmlEditor editor = mNode.getEditor(); + if (!editor.isEditXmlModelPending()) { + throw new RuntimeException("Error: XML edit call without using INode.editXml!"); + } + } + + @Override + public @NonNull INode appendChild(@NonNull String viewFqcn) { + return insertOrAppend(viewFqcn, -1); + } + + @Override + public @NonNull INode insertChildAt(@NonNull String viewFqcn, int index) { + return insertOrAppend(viewFqcn, index); + } + + @Override + public void removeChild(@NonNull INode node) { + checkEditOK(); + + ((NodeProxy) node).mNode.deleteXmlNode(); + } + + private INode insertOrAppend(String viewFqcn, int index) { + checkEditOK(); + + AndroidXmlEditor editor = mNode.getEditor(); + if (editor != null) { + // Possibly replace the tag with a compatibility version if the + // minimum SDK requires it + IProject project = editor.getProject(); + if (project != null) { + viewFqcn = SupportLibraryHelper.getTagFor(project, viewFqcn); + } + } + + // Find the descriptor for this FQCN + ViewElementDescriptor vd = getFqcnViewDescriptor(viewFqcn); + if (vd == null) { + warnPrintf("Can't create a new %s element", viewFqcn); + return null; + } + + final UiElementNode uiNew; + if (index == -1) { + // Append at the end. + uiNew = mNode.appendNewUiChild(vd); + } else { + // Insert at the requested position or at the end. + int n = mNode.getUiChildren().size(); + if (index < 0 || index >= n) { + uiNew = mNode.appendNewUiChild(vd); + } else { + uiNew = mNode.insertNewUiChild(index, vd); + } + } + + // Set default attributes -- but only for new widgets (not when moving or copying) + RulesEngine engine = null; + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(editor); + if (delegate != null) { + engine = delegate.getRulesEngine(); + } + if (engine == null || engine.getInsertType().isCreate()) { + // TODO: This should probably use IViewRule#getDefaultAttributes() at some point + DescriptorsUtils.setDefaultLayoutAttributes(uiNew, false /*updateLayout*/); + } + + Node xmlNode = uiNew.createXmlNode(); + + if (!(uiNew instanceof UiViewElementNode) || xmlNode == null) { + // Both things are not supposed to happen. When they do, we're in big trouble. + // We don't really know how to revert the state at this point and the UI model is + // now out of sync with the XML model. + // Panic ensues. + // The best bet is to abort now. The edit wrapper will release the edit and the + // XML/UI should get reloaded properly (with a likely invalid XML.) + warnPrintf("Failed to create a new %s element", viewFqcn); + throw new RuntimeException("XML node creation failed."); //$NON-NLS-1$ + } + + UiViewElementNode uiNewView = (UiViewElementNode) uiNew; + NodeProxy newNode = mFactory.create(uiNewView); + + if (engine != null) { + engine.callCreateHooks(editor, this, newNode, null); + } + + return newNode; + } + + @Override + public boolean setAttribute( + @Nullable String uri, + @NonNull String name, + @Nullable String value) { + checkEditOK(); + UiAttributeNode attr = mNode.setAttributeValue(name, uri, value, true /* override */); + + if (uri == null) { + uri = ""; //$NON-NLS-1$ + } + + Map<String, String> map = null; + if (mPendingAttributes == null) { + // Small initial size: we don't expect many different namespaces + mPendingAttributes = new HashMap<String, Map<String, String>>(3); + } else { + map = mPendingAttributes.get(uri); + } + if (map == null) { + map = new HashMap<String, String>(); + mPendingAttributes.put(uri, map); + } + map.put(name, value); + + return attr != null; + } + + @Override + public String getStringAttr(@Nullable String uri, @NonNull String attrName) { + UiElementNode uiNode = mNode; + + if (attrName == null) { + return null; + } + + if (mPendingAttributes != null) { + Map<String, String> map = mPendingAttributes.get(uri == null ? "" : uri); //$NON-NLS-1$ + if (map != null) { + String value = map.get(attrName); + if (value != null) { + return value; + } + } + } + + if (uiNode.getXmlNode() != null) { + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode != null) { + NamedNodeMap nodeAttributes = xmlNode.getAttributes(); + if (nodeAttributes != null) { + Node attr = nodeAttributes.getNamedItemNS(uri, attrName); + if (attr != null) { + return attr.getNodeValue(); + } + } + } + } + return null; + } + + @Override + public IAttributeInfo getAttributeInfo(@Nullable String uri, @NonNull String attrName) { + UiElementNode uiNode = mNode; + + if (attrName == null) { + return null; + } + + for (AttributeDescriptor desc : uiNode.getAttributeDescriptors()) { + String dUri = desc.getNamespaceUri(); + String dName = desc.getXmlLocalName(); + if ((uri == null && dUri == null) || (uri != null && uri.equals(dUri))) { + if (attrName.equals(dName)) { + return desc.getAttributeInfo(); + } + } + } + + return null; + } + + @Override + public @NonNull IAttributeInfo[] getDeclaredAttributes() { + + AttributeDescriptor[] descs = mNode.getAttributeDescriptors(); + int n = descs.length; + IAttributeInfo[] infos = new AttributeInfo[n]; + + for (int i = 0; i < n; i++) { + infos[i] = descs[i].getAttributeInfo(); + } + + return infos; + } + + @Override + public @NonNull List<String> getAttributeSources() { + ElementDescriptor descriptor = mNode.getDescriptor(); + if (descriptor instanceof ViewElementDescriptor) { + return ((ViewElementDescriptor) descriptor).getAttributeSources(); + } else { + return Collections.emptyList(); + } + } + + @Override + public @NonNull IAttribute[] getLiveAttributes() { + UiElementNode uiNode = mNode; + + if (uiNode.getXmlNode() != null) { + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode != null) { + NamedNodeMap nodeAttributes = xmlNode.getAttributes(); + if (nodeAttributes != null) { + + int n = nodeAttributes.getLength(); + IAttribute[] result = new IAttribute[n]; + for (int i = 0; i < n; i++) { + Node attr = nodeAttributes.item(i); + String uri = attr.getNamespaceURI(); + String name = attr.getLocalName(); + String value = attr.getNodeValue(); + + result[i] = new SimpleAttribute(uri, name, value); + } + return result; + } + } + } + + return new IAttribute[0]; + + } + + @Override + public String toString() { + return "NodeProxy [node=" + mNode + ", bounds=" + mBounds + "]"; + } + + // --- internal helpers --- + + /** + * Helper methods that returns a {@link ViewElementDescriptor} for the requested FQCN. + * Will return null if we can't find that FQCN or we lack the editor/data/descriptors info + * (which shouldn't really happen since at this point the SDK should be fully loaded and + * isn't reloading, or we wouldn't be here editing XML for a layout rule.) + */ + private ViewElementDescriptor getFqcnViewDescriptor(String fqcn) { + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(mNode.getEditor()); + if (delegate != null) { + return delegate.getFqcnViewDescriptor(fqcn); + } + + return null; + } + + private void warnPrintf(String msg, Object...params) { + AdtPlugin.printToConsole( + mNode == null ? "" : mNode.getDescriptor().getXmlLocalName(), + String.format(msg, params) + ); + } + + /** + * If there are any pending changes in these nodes, apply them now + * + * @return true if any modifications were made + */ + public boolean applyPendingChanges() { + boolean modified = false; + + // Flush all pending attributes + if (mPendingAttributes != null) { + mNode.commitDirtyAttributesToXml(); + modified = true; + mPendingAttributes = null; + + } + for (INode child : getChildren()) { + modified |= ((NodeProxy) child).applyPendingChanges(); + } + + return modified; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/PaletteMetadataDescriptor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/PaletteMetadataDescriptor.java new file mode 100644 index 000000000..884cb077a --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/PaletteMetadataDescriptor.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.gre; + +import static com.android.SdkConstants.ANDROID_NS_NAME_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; + +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SimpleAttribute; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SimpleElement; + +import org.eclipse.swt.graphics.Image; +import org.w3c.dom.Element; + +/** + * Special version of {@link ViewElementDescriptor} which is initialized by the palette + * with specific metadata for how to instantiate particular variations of an existing + * {@link ViewElementDescriptor} with initial values. + */ +public class PaletteMetadataDescriptor extends ViewElementDescriptor { + private String mInitString; + private String mIconName; + + public PaletteMetadataDescriptor(ViewElementDescriptor descriptor, String displayName, + String initString, String iconName) { + super(descriptor.getXmlName(), + displayName, + descriptor.getFullClassName(), + descriptor.getTooltip(), + descriptor.getSdkUrl(), + descriptor.getAttributes(), + descriptor.getLayoutAttributes(), + descriptor.getChildren(), descriptor.getMandatory() == Mandatory.MANDATORY); + mInitString = initString; + mIconName = iconName; + setSuperClass(descriptor.getSuperClassDesc()); + } + + /** + * Returns a String which contains a comma separated list of name=value tokens, + * where the name can start with "android:" to indicate a property in the android namespace, + * or no prefix for plain attributes. + * + * @return the initialization string, which can be empty but never null + */ + public String getInitializedAttributes() { + return mInitString != null ? mInitString : ""; //$NON-NLS-1$ + } + + @Override + public Image getGenericIcon() { + if (mIconName != null) { + IconFactory factory = IconFactory.getInstance(); + Image icon = factory.getIcon(mIconName); + if (icon != null) { + return icon; + } + } + + return super.getGenericIcon(); + } + + /** + * Initializes a new {@link SimpleElement} with the palette initialization + * configuration + * + * @param element the new element to initialize + */ + public void initializeNew(SimpleElement element) { + initializeNew(element, null); + } + + /** + * Initializes a new {@link Element} with the palette initialization configuration + * + * @param element the new element to initialize + */ + public void initializeNew(Element element) { + initializeNew(null, element); + } + + private void initializeNew(SimpleElement simpleElement, Element domElement) { + String initializedAttributes = mInitString; + if (initializedAttributes != null && initializedAttributes.length() > 0) { + for (String s : initializedAttributes.split(",")) { //$NON-NLS-1$ + String[] nameValue = s.split("="); //$NON-NLS-1$ + String name = nameValue[0]; + String value = nameValue[1]; + String nameSpace = ""; //$NON-NLS-1$ + if (name.startsWith(ANDROID_NS_NAME_PREFIX)) { + name = name.substring(ANDROID_NS_NAME_PREFIX.length()); + nameSpace = ANDROID_URI; + } + + if (simpleElement != null) { + SimpleAttribute attr = new SimpleAttribute(nameSpace, name, value); + simpleElement.addAttribute(attr); + } + + if (domElement != null) { + domElement.setAttributeNS(nameSpace, name, value); + } + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/RuleLoader.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/RuleLoader.java new file mode 100644 index 000000000..4f49a7545 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/RuleLoader.java @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gre; + +import com.android.ide.common.api.IViewRule; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.sdk.ProjectState; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.internal.project.ProjectProperties; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.QualifiedName; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.List; + +/** + * The {@link RuleLoader} is responsible for loading (and unloading) + * {@link IViewRule} classes. There is typically one {@link RuleLoader} + * per project. + */ +public class RuleLoader { + /** + * Qualified name for the per-project non-persistent property storing the + * {@link RuleLoader} for this project + */ + private final static QualifiedName RULE_LOADER = new QualifiedName(AdtPlugin.PLUGIN_ID, + "ruleloader"); //$NON-NLS-1$ + + private final IProject mProject; + private ClassLoader mUserClassLoader; + private List<Pair<File, Long>> mUserJarTimeStamps; + private long mLastCheckTimeStamp; + + /** + * Flag set when we've attempted to initialize the {@link #mUserClassLoader} + * already + */ + private boolean mUserClassLoaderInited; + + /** + * Returns the {@link RuleLoader} for the given project + * + * @param project the project the loader is associated with + * @return an {@RuleLoader} for the given project, + * never null + */ + public static RuleLoader get(IProject project) { + RuleLoader loader = null; + try { + loader = (RuleLoader) project.getSessionProperty(RULE_LOADER); + } catch (CoreException e) { + // Not a problem; we will just create a new one + } + if (loader == null) { + loader = new RuleLoader(project); + try { + project.setSessionProperty(RULE_LOADER, loader); + } catch (CoreException e) { + AdtPlugin.log(e, "Can't store RuleLoader"); + } + } + return loader; + } + + /** Do not call; use the {@link #get} factory method instead. */ + private RuleLoader(IProject project) { + mProject = project; + } + + /** + * Find out whether the given project has 3rd party ViewRules, and if so + * return a ClassLoader which can locate them. If not, return null. + * @param project The project to load user rules from + * @return A class loader which can user view rules, or otherwise null + */ + private ClassLoader computeUserClassLoader(IProject project) { + // Default place to locate layout rules. The user may also add to this + // path by defining a config property specifying + // additional .jar files to search via a the layoutrules.jars property. + ProjectState state = Sdk.getProjectState(project); + ProjectProperties projectProperties = state.getProperties(); + + // Ensure we have the latest & greatest version of the properties. + // This allows users to reopen editors in a running Eclipse instance + // to get updated view rule jars + projectProperties.reload(); + + String path = projectProperties.getProperty( + ProjectProperties.PROPERTY_RULES_PATH); + + if (path != null && path.length() > 0) { + + mUserJarTimeStamps = new ArrayList<Pair<File, Long>>(); + mLastCheckTimeStamp = System.currentTimeMillis(); + + List<URL> urls = new ArrayList<URL>(); + String[] pathElements = path.split(File.pathSeparator); + for (String pathElement : pathElements) { + pathElement = pathElement.trim(); // Avoid problems with trailing whitespace etc + File pathFile = new File(pathElement); + if (!pathFile.isAbsolute()) { + pathFile = new File(project.getLocation().toFile(), pathElement); + } + // Directories and jar files are okay. Do we need to + // validate the files here as .jar files? + if (pathFile.isFile() || pathFile.isDirectory()) { + URL url; + try { + url = pathFile.toURI().toURL(); + urls.add(url); + + mUserJarTimeStamps.add(Pair.of(pathFile, pathFile.lastModified())); + } catch (MalformedURLException e) { + AdtPlugin.log(IStatus.WARNING, + "Invalid URL: %1$s", //$NON-NLS-1$ + e.toString()); + } + } + } + + if (urls.size() > 0) { + return new URLClassLoader(urls.toArray(new URL[urls.size()]), + RulesEngine.class.getClassLoader()); + } + } + + return null; + } + + /** + * Return the class loader to use for custom views, or null if no custom + * view rules are registered for the project. Note that this class loader + * can change over time (if the jar files are updated), so callers should be + * prepared to unload previous instances. + * + * @return a class loader to use for custom view rules, or null + */ + public ClassLoader getClassLoader() { + if (mUserClassLoader == null) { + // Only attempt to load rule paths once. + // TODO: Check the timestamp on the project.properties file so we can dynamically + // pick up cases where the user edits the path + if (!mUserClassLoaderInited) { + mUserClassLoaderInited = true; + mUserClassLoader = computeUserClassLoader(mProject); + } + } else { + // Check the timestamp on the jar files in the custom view path to see if we + // need to reload the classes (but only do this at most every 3 seconds) + if (mUserJarTimeStamps != null) { + long time = System.currentTimeMillis(); + if (time - mLastCheckTimeStamp > 3000) { + mLastCheckTimeStamp = time; + for (Pair<File, Long> pair : mUserJarTimeStamps) { + File file = pair.getFirst(); + Long prevModified = pair.getSecond(); + long modified = file.lastModified(); + if (prevModified.longValue() != modified) { + mUserClassLoaderInited = true; + mUserJarTimeStamps = null; + mUserClassLoader = computeUserClassLoader(mProject); + } + } + } + } + } + + return mUserClassLoader; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/RulesEngine.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/RulesEngine.java new file mode 100644 index 000000000..8f9923749 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/RulesEngine.java @@ -0,0 +1,876 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gre; + +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.VIEW_MERGE; +import static com.android.SdkConstants.VIEW_TAG; + +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.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; +import com.android.ide.common.api.RuleAction; +import com.android.ide.common.api.SegmentType; +import com.android.ide.common.layout.ViewRule; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GCWrapper; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SimpleElement; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.core.resources.IProject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +/** + * The rule engine manages the layout rules and interacts with them. + * There's one {@link RulesEngine} instance per layout editor. + * Each instance has 2 sets of rules: the static ADT rules (shared across all instances) + * and the project specific rules (local to the current instance / layout editor). + */ +public class RulesEngine { + private final IProject mProject; + private final Map<Object, IViewRule> mRulesCache = new HashMap<Object, IViewRule>(); + + /** + * The type of any upcoming node manipulations performed by the {@link IViewRule}s. + * When actions are performed in the tool (like a paste action, or a drag from palette, + * or a drag move within the canvas, etc), these are different types of inserts, + * and we don't want to have the rules track them closely (and pass them back to us + * in the {@link INode#insertChildAt} methods etc), so instead we track the state + * here on behalf of the currently executing rule. + */ + private InsertType mInsertType = InsertType.CREATE; + + /** + * Per-project loader for custom view rules + */ + private RuleLoader mRuleLoader; + private ClassLoader mUserClassLoader; + + /** + * The editor which owns this {@link RulesEngine} + */ + private final GraphicalEditorPart mEditor; + + /** + * Creates a new {@link RulesEngine} associated with the selected project. + * <p/> + * The rules engine will look in the project for a tools jar to load custom view rules. + * + * @param editor the editor which owns this {@link RulesEngine} + * @param project A non-null open project. + */ + public RulesEngine(GraphicalEditorPart editor, IProject project) { + mProject = project; + mEditor = editor; + + mRuleLoader = RuleLoader.get(project); + } + + /** + * Returns the {@link IProject} on which the {@link RulesEngine} was created. + */ + public IProject getProject() { + return mProject; + } + + /** + * Returns the {@link GraphicalEditorPart} for which the {@link RulesEngine} was + * created. + * + * @return the associated editor + */ + public GraphicalEditorPart getEditor() { + return mEditor; + } + + /** + * Called by the owner of the {@link RulesEngine} when it is going to be disposed. + * This frees some resources, such as the project's folder monitor. + */ + public void dispose() { + clearCache(); + } + + /** + * Invokes {@link IViewRule#getDisplayName()} on the rule matching the specified element. + * + * @param element The view element to target. Can be null. + * @return Null if the rule failed, there's no rule or the rule does not want to override + * the display name. Otherwise, a string as returned by the rule. + */ + public String callGetDisplayName(UiViewElementNode element) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(element); + + if (rule != null) { + try { + return rule.getDisplayName(); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.getDisplayName() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + /** + * Invokes {@link IViewRule#addContextMenuActions(List, INode)} on the rule matching the specified element. + * + * @param selectedNode The node selected. Never null. + * @return Null if the rule failed, there's no rule or the rule does not provide + * any custom menu actions. Otherwise, a list of {@link RuleAction}. + */ + @Nullable + public List<RuleAction> callGetContextMenu(NodeProxy selectedNode) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(selectedNode.getNode()); + + if (rule != null) { + try { + mInsertType = InsertType.CREATE; + List<RuleAction> actions = new ArrayList<RuleAction>(); + rule.addContextMenuActions(actions, selectedNode); + Collections.sort(actions); + + return actions; + } catch (Exception e) { + AdtPlugin.log(e, "%s.getContextMenu() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + /** + * Calls the selected node to return its default action + * + * @param selectedNode the node to apply the action to + * @return the default action id + */ + public String callGetDefaultActionId(@NonNull NodeProxy selectedNode) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(selectedNode.getNode()); + + if (rule != null) { + try { + mInsertType = InsertType.CREATE; + return rule.getDefaultActionId(selectedNode); + } catch (Exception e) { + AdtPlugin.log(e, "%s.getDefaultAction() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + /** + * Invokes {@link IViewRule#addLayoutActions(List, INode, List)} on the rule + * matching the specified element. + * + * @param actions The list of actions to add layout actions into + * @param parentNode The layout node + * @param children The selected children of the node, if any (used to + * initialize values of child layout controls, if applicable) + * @return Null if the rule failed, there's no rule or the rule does not + * provide any custom menu actions. Otherwise, a list of + * {@link RuleAction}. + */ + public List<RuleAction> callAddLayoutActions(List<RuleAction> actions, + NodeProxy parentNode, List<NodeProxy> children ) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(parentNode.getNode()); + + if (rule != null) { + try { + mInsertType = InsertType.CREATE; + rule.addLayoutActions(actions, parentNode, children); + } catch (Exception e) { + AdtPlugin.log(e, "%s.getContextMenu() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + /** + * Invokes {@link IViewRule#getSelectionHint(INode, INode)} + * on the rule matching the specified element. + * + * @param parentNode The parent of the node selected. Never null. + * @param childNode The child node that was selected. Never null. + * @return a list of strings to be displayed, or null or empty to display nothing + */ + public List<String> callGetSelectionHint(NodeProxy parentNode, NodeProxy childNode) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(parentNode.getNode()); + + if (rule != null) { + try { + return rule.getSelectionHint(parentNode, childNode); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.getSelectionHint() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + public void callPaintSelectionFeedback(GCWrapper gcWrapper, NodeProxy parentNode, + List<? extends INode> childNodes, Object view) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(parentNode.getNode()); + + if (rule != null) { + try { + rule.paintSelectionFeedback(gcWrapper, parentNode, childNodes, view); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.callPaintSelectionFeedback() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + } + + /** + * Called when the d'n'd starts dragging over the target node. + * If interested, returns a DropFeedback passed to onDrop/Move/Leave/Paint. + * If not interested in drop, return false. + * Followed by a paint. + */ + public DropFeedback callOnDropEnter(NodeProxy targetNode, + Object targetView, IDragElement[] elements) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(targetNode.getNode()); + + if (rule != null) { + try { + return rule.onDropEnter(targetNode, targetView, elements); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.onDropEnter() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + /** + * Called after onDropEnter. + * Returns a DropFeedback passed to onDrop/Move/Leave/Paint (typically same + * as input one). + */ + public DropFeedback callOnDropMove(NodeProxy targetNode, + IDragElement[] elements, + DropFeedback feedback, + Point where) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(targetNode.getNode()); + + if (rule != null) { + try { + return rule.onDropMove(targetNode, elements, feedback, where); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.onDropMove() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + /** + * Called when drop leaves the target without actually dropping + */ + public void callOnDropLeave(NodeProxy targetNode, + IDragElement[] elements, + DropFeedback feedback) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(targetNode.getNode()); + + if (rule != null) { + try { + rule.onDropLeave(targetNode, elements, feedback); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.onDropLeave() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + } + + /** + * Called when drop is released over the target to perform the actual drop. + */ + public void callOnDropped(NodeProxy targetNode, + IDragElement[] elements, + DropFeedback feedback, + Point where, + InsertType insertType) { + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(targetNode.getNode()); + + if (rule != null) { + try { + mInsertType = insertType; + rule.onDropped(targetNode, elements, feedback, where); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.onDropped() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + } + + /** + * Called when a paint has been requested via DropFeedback. + */ + public void callDropFeedbackPaint(IGraphics gc, + NodeProxy targetNode, + DropFeedback feedback) { + if (gc != null && feedback != null && feedback.painter != null) { + try { + feedback.painter.paint(gc, targetNode, feedback); + } catch (Exception e) { + AdtPlugin.log(e, "DropFeedback.painter failed: %s", + e.toString()); + } + } + } + + /** + * Called when pasting elements in an existing document on the selected target. + * + * @param targetNode The first node selected. + * @param targetView The view object for the target node, or null if not known + * @param pastedElements The elements being pasted. + * @return the parent node the paste was applied into + */ + public NodeProxy callOnPaste(NodeProxy targetNode, Object targetView, + SimpleElement[] pastedElements) { + + // Find a target which accepts children. If you for example select a button + // and attempt to paste, this will reselect the parent of the button as the paste + // target. (This is a loop rather than just checking the direct parent since + // we will soon ask each child whether they are *willing* to accept the new child. + // A ScrollView for example, which only accepts one child, might also say no + // and delegate to its parent in turn. + INode parent = targetNode; + while (parent instanceof NodeProxy) { + NodeProxy np = (NodeProxy) parent; + if (np.getNode() != null && np.getNode().getDescriptor() != null) { + ElementDescriptor descriptor = np.getNode().getDescriptor(); + if (descriptor.hasChildren()) { + targetNode = np; + break; + } + } + parent = parent.getParent(); + } + + // try to find a rule for this element's FQCN + IViewRule rule = loadRule(targetNode.getNode()); + + if (rule != null) { + try { + mInsertType = InsertType.PASTE; + rule.onPaste(targetNode, targetView, pastedElements); + + } catch (Exception e) { + AdtPlugin.log(e, "%s.onPaste() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + + return targetNode; + } + + // ---- Resize operations ---- + + public DropFeedback callOnResizeBegin(NodeProxy child, NodeProxy parent, Rect newBounds, + SegmentType horizontalEdge, SegmentType verticalEdge, Object childView, + Object parentView) { + IViewRule rule = loadRule(parent.getNode()); + + if (rule != null) { + try { + return rule.onResizeBegin(child, parent, horizontalEdge, verticalEdge, + childView, parentView); + } catch (Exception e) { + AdtPlugin.log(e, "%s.onResizeBegin() failed: %s", rule.getClass().getSimpleName(), + e.toString()); + } + } + + return null; + } + + public void callOnResizeUpdate(DropFeedback feedback, NodeProxy child, NodeProxy parent, + Rect newBounds, int modifierMask) { + IViewRule rule = loadRule(parent.getNode()); + + if (rule != null) { + try { + rule.onResizeUpdate(feedback, child, parent, newBounds, modifierMask); + } catch (Exception e) { + AdtPlugin.log(e, "%s.onResizeUpdate() failed: %s", rule.getClass().getSimpleName(), + e.toString()); + } + } + } + + public void callOnResizeEnd(DropFeedback feedback, NodeProxy child, NodeProxy parent, + Rect newBounds) { + IViewRule rule = loadRule(parent.getNode()); + + if (rule != null) { + try { + rule.onResizeEnd(feedback, child, parent, newBounds); + } catch (Exception e) { + AdtPlugin.log(e, "%s.onResizeEnd() failed: %s", rule.getClass().getSimpleName(), + e.toString()); + } + } + } + + // ---- Creation customizations ---- + + /** + * Invokes the create hooks ({@link IViewRule#onCreate}, + * {@link IViewRule#onChildInserted} when a new child has been created/pasted/moved, and + * is inserted into a given parent. The parent may be null (for example when rendering + * top level items for preview). + * + * @param editor the XML editor to apply edits to the model for (performed by view + * rules) + * @param parentNode the parent XML node, or null if unknown + * @param childNode the XML node of the new node, never null + * @param overrideInsertType If not null, specifies an explicit insert type to use for + * edits made during the customization + */ + public void callCreateHooks( + AndroidXmlEditor editor, + NodeProxy parentNode, NodeProxy childNode, + InsertType overrideInsertType) { + IViewRule parentRule = null; + + if (parentNode != null) { + UiViewElementNode parentUiNode = parentNode.getNode(); + parentRule = loadRule(parentUiNode); + } + + if (overrideInsertType != null) { + mInsertType = overrideInsertType; + } + + UiViewElementNode newUiNode = childNode.getNode(); + IViewRule childRule = loadRule(newUiNode); + if (childRule != null || parentRule != null) { + callCreateHooks(editor, mInsertType, parentRule, parentNode, + childRule, childNode); + } + } + + private static void callCreateHooks( + final AndroidXmlEditor editor, final InsertType insertType, + final IViewRule parentRule, final INode parentNode, + final IViewRule childRule, final INode newNode) { + // Notify the parent about the new child in case it wants to customize it + // (For example, a ScrollView parent can go and set all its children's layout params to + // fill the parent.) + if (!editor.isEditXmlModelPending()) { + editor.wrapEditXmlModel(new Runnable() { + @Override + public void run() { + callCreateHooks(editor, insertType, + parentRule, parentNode, childRule, newNode); + } + }); + return; + } + + if (parentRule != null) { + parentRule.onChildInserted(newNode, parentNode, insertType); + } + + // Look up corresponding IViewRule, and notify the rule about + // this create action in case it wants to customize the new object. + // (For example, a rule for TabHosts can go and create a default child tab + // when you create it.) + if (childRule != null) { + childRule.onCreate(newNode, parentNode, insertType); + } + + if (parentNode != null) { + ((NodeProxy) parentNode).applyPendingChanges(); + } + } + + /** + * Set the type of insert currently in progress + * + * @param insertType the insert type to use for the next operation + */ + public void setInsertType(InsertType insertType) { + mInsertType = insertType; + } + + /** + * Return the type of insert currently in progress + * + * @return the type of insert currently in progress + */ + public InsertType getInsertType() { + return mInsertType; + } + + // ---- Deletion ---- + + public void callOnRemovingChildren(NodeProxy parentNode, + List<INode> children) { + if (parentNode != null) { + UiViewElementNode parentUiNode = parentNode.getNode(); + IViewRule parentRule = loadRule(parentUiNode); + if (parentRule != null) { + try { + parentRule.onRemovingChildren(children, parentNode, + mInsertType == InsertType.MOVE_WITHIN); + } catch (Exception e) { + AdtPlugin.log(e, "%s.onDispose() failed: %s", + parentRule.getClass().getSimpleName(), + e.toString()); + } + } + } + } + + // ---- private --- + + /** + * Returns the descriptor for the base View class. + * This could be null if the SDK or the given platform target hasn't loaded yet. + */ + private ViewElementDescriptor getBaseViewDescriptor() { + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(mProject); + if (target != null) { + AndroidTargetData data = currentSdk.getTargetData(target); + return data.getLayoutDescriptors().getBaseViewDescriptor(); + } + } + return null; + } + + /** + * Clear the Rules cache. Calls onDispose() on each rule. + */ + private void clearCache() { + // The cache can contain multiple times the same rule instance for different + // keys (e.g. the UiViewElementNode key vs. the FQCN string key.) So transfer + // all values to a unique set. + HashSet<IViewRule> rules = new HashSet<IViewRule>(mRulesCache.values()); + + mRulesCache.clear(); + + for (IViewRule rule : rules) { + if (rule != null) { + try { + rule.onDispose(); + } catch (Exception e) { + AdtPlugin.log(e, "%s.onDispose() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + } + } + } + + /** + * Checks whether the project class loader has changed, and if so + * unregisters any view rules that use classes from the old class loader. It + * then returns the class loader to be used. + */ + private ClassLoader updateClassLoader() { + ClassLoader classLoader = mRuleLoader.getClassLoader(); + if (mUserClassLoader != null && classLoader != mUserClassLoader) { + // We have to unload all the IViewRules from the old class + List<Object> dispose = new ArrayList<Object>(); + for (Map.Entry<Object, IViewRule> entry : mRulesCache.entrySet()) { + IViewRule rule = entry.getValue(); + if (rule.getClass().getClassLoader() == mUserClassLoader) { + dispose.add(entry.getKey()); + } + } + for (Object object : dispose) { + mRulesCache.remove(object); + } + } + + mUserClassLoader = classLoader; + return mUserClassLoader; + } + + /** + * Load a rule using its descriptor. This will try to first load the rule using its + * actual FQCN and if that fails will find the first parent that works in the view + * hierarchy. + */ + private IViewRule loadRule(UiViewElementNode element) { + if (element == null) { + return null; + } + + String targetFqcn = null; + ViewElementDescriptor targetDesc = null; + + ElementDescriptor d = element.getDescriptor(); + if (d instanceof ViewElementDescriptor) { + targetDesc = (ViewElementDescriptor) d; + } + if (d == null || !(d instanceof ViewElementDescriptor)) { + // This should not happen. All views should have some kind of *view* element + // descriptor. Maybe the project is not complete and doesn't build or something. + // In this case, we'll use the descriptor of the base android View class. + targetDesc = getBaseViewDescriptor(); + } + + // Check whether any of the custom view .jar files have changed and if so + // unregister previously cached view rules to force a new view rule to be loaded. + updateClassLoader(); + + // Return the rule if we find it in the cache, even if it was stored as null + // (which means we didn't find it earlier, so don't look for it again) + IViewRule rule = mRulesCache.get(targetDesc); + if (rule != null || mRulesCache.containsKey(targetDesc)) { + return rule; + } + + // Get the descriptor and loop through the super class hierarchy + for (ViewElementDescriptor desc = targetDesc; + desc != null; + desc = desc.getSuperClassDesc()) { + + // Get the FQCN of this View + String fqcn = desc.getFullClassName(); + if (fqcn == null) { + // Shouldn't be happening. + return null; + } + + // The first time we keep the FQCN around as it's the target class we were + // initially trying to load. After, as we move through the hierarchy, the + // target FQCN remains constant. + if (targetFqcn == null) { + targetFqcn = fqcn; + } + + if (fqcn.indexOf('.') == -1) { + // Deal with unknown descriptors; these lack the full qualified path and + // elements in the layout without a package are taken to be in the + // android.widget package. + fqcn = ANDROID_WIDGET_PREFIX + fqcn; + } + + // Try to find a rule matching the "real" FQCN. If we find it, we're done. + // If not, the for loop will move to the parent descriptor. + rule = loadRule(fqcn, targetFqcn); + if (rule != null) { + // We found one. + // As a side effect, loadRule() also cached the rule using the target FQCN. + return rule; + } + } + + // Memorize in the cache that we couldn't find a rule for this descriptor + mRulesCache.put(targetDesc, null); + return null; + } + + /** + * Try to load a rule given a specific FQCN. This looks for an exact match in either + * the ADT scripts or the project scripts and does not look at parent hierarchy. + * <p/> + * Once a rule is found (or not), it is stored in a cache using its target FQCN + * so we don't try to reload it. + * <p/> + * The real FQCN is the actual rule class we're loading, e.g. "android.view.View" + * where target FQCN is the class we were initially looking for, which might be the same as + * the real FQCN or might be a derived class, e.g. "android.widget.TextView". + * + * @param realFqcn The FQCN of the rule class actually being loaded. + * @param targetFqcn The FQCN of the class actually processed, which might be different from + * the FQCN of the rule being loaded. + */ + IViewRule loadRule(String realFqcn, String targetFqcn) { + if (realFqcn == null || targetFqcn == null) { + return null; + } + + // Return the rule if we find it in the cache, even if it was stored as null + // (which means we didn't find it earlier, so don't look for it again) + IViewRule rule = mRulesCache.get(realFqcn); + if (rule != null || mRulesCache.containsKey(realFqcn)) { + return rule; + } + + // Look for class via reflection + try { + // For now, we package view rules for the builtin Android views and + // widgets with the tool in a special package, so look there rather + // than in the same package as the widgets. + String ruleClassName; + ClassLoader classLoader; + if (realFqcn.startsWith("android.") || //$NON-NLS-1$ + realFqcn.equals(VIEW_MERGE) || + realFqcn.endsWith(".GridLayout") || //$NON-NLS-1$ // Temporary special case + // FIXME: Remove this special case as soon as we pull + // the MapViewRule out of this code base and bundle it + // with the add ons + realFqcn.startsWith("com.google.android.maps.")) { //$NON-NLS-1$ + // This doesn't handle a case where there are name conflicts + // (e.g. where there are multiple different views with the same + // class name and only differing in package names, but that's a + // really bad practice in the first place, and if that situation + // should come up in the API we can enhance this algorithm. + String packageName = ViewRule.class.getName(); + packageName = packageName.substring(0, packageName.lastIndexOf('.')); + classLoader = RulesEngine.class.getClassLoader(); + int dotIndex = realFqcn.lastIndexOf('.'); + String baseName = realFqcn.substring(dotIndex+1); + // Capitalize rule class name to match naming conventions, if necessary (<merge>) + if (Character.isLowerCase(baseName.charAt(0))) { + if (baseName.equals(VIEW_TAG)) { + // Hack: ViewRule is generic for the "View" class, so we can't use it + // for the special XML "view" tag (lowercase); instead, the rule is + // named "ViewTagRule" instead. + baseName = "ViewTag"; //$NON-NLS-1$ + } + baseName = Character.toUpperCase(baseName.charAt(0)) + baseName.substring(1); + } + ruleClassName = packageName + "." + //$NON-NLS-1$ + baseName + "Rule"; //$NON-NLS-1$ + } else { + // Initialize the user-classpath for 3rd party IViewRules, if necessary + classLoader = updateClassLoader(); + if (classLoader == null) { + // The mUserClassLoader can be null; this is the typical scenario, + // when the user is only using builtin layout rules. + // This means however we can't resolve this fqcn since it's not + // in the name space of the builtin rules. + mRulesCache.put(realFqcn, null); + return null; + } + + // For other (3rd party) widgets, look in the same package (though most + // likely not in the same jar!) + ruleClassName = realFqcn + "Rule"; //$NON-NLS-1$ + } + + Class<?> clz = Class.forName(ruleClassName, true, classLoader); + rule = (IViewRule) clz.newInstance(); + return initializeRule(rule, targetFqcn); + } catch (ClassNotFoundException ex) { + // Not an unexpected error - this means that there isn't a helper for this + // class. + } catch (InstantiationException e) { + // This is NOT an expected error: fail. + AdtPlugin.log(e, "load rule error (%s): %s", realFqcn, e.toString()); + } catch (IllegalAccessException e) { + // This is NOT an expected error: fail. + AdtPlugin.log(e, "load rule error (%s): %s", realFqcn, e.toString()); + } + + // Memorize in the cache that we couldn't find a rule for this real FQCN + mRulesCache.put(realFqcn, null); + return null; + } + + /** + * Initialize a rule we just loaded. The rule has a chance to examine the target FQCN + * and bail out. + * <p/> + * Contract: the rule is not in the {@link #mRulesCache} yet and this method will + * cache it using the target FQCN if the rule is accepted. + * <p/> + * The real FQCN is the actual rule class we're loading, e.g. "android.view.View" + * where target FQCN is the class we were initially looking for, which might be the same as + * the real FQCN or might be a derived class, e.g. "android.widget.TextView". + * + * @param rule A rule freshly loaded. + * @param targetFqcn The FQCN of the class actually processed, which might be different from + * the FQCN of the rule being loaded. + * @return The rule if accepted, or null if the rule can't handle that FQCN. + */ + private IViewRule initializeRule(IViewRule rule, String targetFqcn) { + + try { + if (rule.onInitialize(targetFqcn, new ClientRulesEngine(this, targetFqcn))) { + // Add it to the cache and return it + mRulesCache.put(targetFqcn, rule); + return rule; + } else { + rule.onDispose(); + } + } catch (Exception e) { + AdtPlugin.log(e, "%s.onInit() failed: %s", + rule.getClass().getSimpleName(), + e.toString()); + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/ViewMetadataRepository.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/ViewMetadataRepository.java new file mode 100644 index 000000000..5f2659ef2 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/ViewMetadataRepository.java @@ -0,0 +1,856 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.gre; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.FQCN_BUTTON; +import static com.android.SdkConstants.FQCN_SPINNER; +import static com.android.SdkConstants.FQCN_TOGGLE_BUTTON; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; + +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.api.IViewMetadata.FillPreference; +import com.android.ide.common.api.Margins; +import com.android.ide.common.api.ResizePolicy; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.resources.Density; +import com.android.utils.Pair; +import com.google.common.base.Splitter; +import com.google.common.io.Closeables; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import java.io.BufferedInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +/** + * The {@link ViewMetadataRepository} contains additional metadata for Android view + * classes + */ +public class ViewMetadataRepository { + private static final String PREVIEW_CONFIG_FILENAME = "rendering-configs.xml"; //$NON-NLS-1$ + private static final String METADATA_FILENAME = "extra-view-metadata.xml"; //$NON-NLS-1$ + + /** Singleton instance */ + private static ViewMetadataRepository sInstance = new ViewMetadataRepository(); + + /** + * Returns the singleton instance + * + * @return the {@link ViewMetadataRepository} + */ + public static ViewMetadataRepository get() { + return sInstance; + } + + /** + * Ever increasing counter used to assign natural ordering numbers to views and + * categories + */ + private static int sNextOrdinal = 0; + + /** + * List of categories (which contain views); constructed lazily so use + * {@link #getCategories()} + */ + private List<CategoryData> mCategories; + + /** + * Map from class names to view data objects; constructed lazily so use + * {@link #getClassToView} + */ + private Map<String, ViewData> mClassToView; + + /** Hidden constructor: Create via factory {@link #get()} instead */ + private ViewMetadataRepository() { + } + + /** Returns a map from class fully qualified names to {@link ViewData} objects */ + private Map<String, ViewData> getClassToView() { + if (mClassToView == null) { + int initialSize = 75; + mClassToView = new HashMap<String, ViewData>(initialSize); + List<CategoryData> categories = getCategories(); + for (CategoryData category : categories) { + for (ViewData view : category) { + mClassToView.put(view.getFcqn(), view); + } + } + assert mClassToView.size() <= initialSize; + } + + return mClassToView; + } + + /** + * Returns an XML document containing rendering configurations for the various Android + * views. The FQN of each view can be obtained via the + * {@link #getFullClassName(Element)} method + * + * @return an XML document containing rendering elements + */ + public Document getRenderingConfigDoc() { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + Class<ViewMetadataRepository> clz = ViewMetadataRepository.class; + InputStream paletteStream = clz.getResourceAsStream(PREVIEW_CONFIG_FILENAME); + InputSource is = new InputSource(paletteStream); + try { + factory.setNamespaceAware(true); + factory.setValidating(false); + factory.setIgnoringComments(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } catch (Exception e) { + AdtPlugin.log(e, "Parsing palette file failed"); + return null; + } finally { + Closeables.closeQuietly(paletteStream); + } + } + + /** + * Returns a fully qualified class name for an element in the rendering document + * returned by {@link #getRenderingConfigDoc()} + * + * @param element the element to look up the fqcn for + * @return the fqcn of the view the element represents a preview for + */ + public String getFullClassName(Element element) { + // We don't use the element tag name, because in some cases we have + // an outer element to render some interesting inner element, such as a tab widget + // (which must be rendered inside a tab host). + // + // Therefore, we instead use the convention that the id is the fully qualified + // class name, with .'s replaced with _'s. + + // Special case: for tab host we aren't allowed to mess with the id + String id = element.getAttributeNS(ANDROID_URI, ATTR_ID); + + if ("@android:id/tabhost".equals(id)) { + // Special case to distinguish TabHost and TabWidget + NodeList children = element.getChildNodes(); + if (children.getLength() > 1 && (children.item(1) instanceof Element)) { + Element child = (Element) children.item(1); + String childId = child.getAttributeNS(ANDROID_URI, ATTR_ID); + if ("@+id/android_widget_TabWidget".equals(childId)) { + return "android.widget.TabWidget"; // TODO: Tab widget! + } + } + return "android.widget.TabHost"; // TODO: Tab widget! + } + + StringBuilder sb = new StringBuilder(); + int i = 0; + if (id.startsWith(NEW_ID_PREFIX)) { + i = NEW_ID_PREFIX.length(); + } else if (id.startsWith(ID_PREFIX)) { + i = ID_PREFIX.length(); + } + + for (; i < id.length(); i++) { + char c = id.charAt(i); + if (c == '_') { + sb.append('.'); + } else { + sb.append(c); + } + } + + return sb.toString(); + } + + /** Returns an ordered list of categories and views, parsed from a metadata file */ + @SuppressWarnings("resource") // streams passed to parser InputSource closed by parser + private List<CategoryData> getCategories() { + if (mCategories == null) { + mCategories = new ArrayList<CategoryData>(); + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + Class<ViewMetadataRepository> clz = ViewMetadataRepository.class; + InputStream inputStream = clz.getResourceAsStream(METADATA_FILENAME); + InputSource is = new InputSource(new BufferedInputStream(inputStream)); + try { + factory.setNamespaceAware(true); + factory.setValidating(false); + factory.setIgnoringComments(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(is); + Map<String, FillPreference> fillTypes = new HashMap<String, FillPreference>(); + for (FillPreference pref : FillPreference.values()) { + fillTypes.put(pref.toString().toLowerCase(Locale.US), pref); + } + + NodeList categoryNodes = document.getDocumentElement().getChildNodes(); + for (int i = 0, n = categoryNodes.getLength(); i < n; i++) { + Node node = categoryNodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element element = (Element) node; + if (element.getNodeName().equals("category")) { //$NON-NLS-1$ + String name = element.getAttribute("name"); //$NON-NLS-1$ + CategoryData category = new CategoryData(name); + NodeList children = element.getChildNodes(); + for (int j = 0, m = children.getLength(); j < m; j++) { + Node childNode = children.item(j); + if (childNode.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) childNode; + ViewData view = createViewData(fillTypes, child, + null, FillPreference.NONE, RenderMode.NORMAL, null); + category.addView(view); + } + } + mCategories.add(category); + } + } + } + } catch (Exception e) { + AdtPlugin.log(e, "Invalid palette metadata"); //$NON-NLS-1$ + } + } + + return mCategories; + } + + private ViewData createViewData(Map<String, FillPreference> fillTypes, + Element child, String defaultFqcn, FillPreference defaultFill, + RenderMode defaultRender, String defaultSize) { + String fqcn = child.getAttribute("class"); //$NON-NLS-1$ + if (fqcn.length() == 0) { + fqcn = defaultFqcn; + } + String fill = child.getAttribute("fill"); //$NON-NLS-1$ + FillPreference fillPreference = null; + if (fill.length() > 0) { + fillPreference = fillTypes.get(fill); + } + if (fillPreference == null) { + fillPreference = defaultFill; + } + String skip = child.getAttribute("skip"); //$NON-NLS-1$ + RenderMode renderMode = defaultRender; + String render = child.getAttribute("render"); //$NON-NLS-1$ + if (render.length() > 0) { + renderMode = RenderMode.get(render); + } + String displayName = child.getAttribute("name"); //$NON-NLS-1$ + if (displayName.length() == 0) { + displayName = null; + } + + String relatedTo = child.getAttribute("relatedTo"); //$NON-NLS-1$ + String topAttrs = child.getAttribute("topAttrs"); //$NON-NLS-1$ + String resize = child.getAttribute("resize"); //$NON-NLS-1$ + ViewData view = new ViewData(fqcn, displayName, fillPreference, + skip.length() == 0 ? false : Boolean.valueOf(skip), + renderMode, relatedTo, resize, topAttrs); + + String init = child.getAttribute("init"); //$NON-NLS-1$ + String icon = child.getAttribute("icon"); //$NON-NLS-1$ + + view.setInitString(init); + if (icon.length() > 0) { + view.setIconName(icon); + } + + // Nested variations? + if (child.hasChildNodes()) { + // Palette variations + NodeList childNodes = child.getChildNodes(); + for (int k = 0, kl = childNodes.getLength(); k < kl; k++) { + Node variationNode = childNodes.item(k); + if (variationNode.getNodeType() == Node.ELEMENT_NODE) { + Element variation = (Element) variationNode; + ViewData variationView = createViewData(fillTypes, variation, + fqcn, fillPreference, renderMode, resize); + view.addVariation(variationView); + } + } + } + + return view; + } + + /** + * Computes the palette entries for the given {@link AndroidTargetData}, looking up the + * available node descriptors, categorizing and sorting them. + * + * @param targetData the target data for which to compute palette entries + * @param alphabetical if true, sort all items in alphabetical order + * @param createCategories if true, organize the items into categories + * @return a list of pairs where each pair contains of the category label and an + * ordered list of elements to be included in that category + */ + public List<Pair<String, List<ViewElementDescriptor>>> getPaletteEntries( + AndroidTargetData targetData, boolean alphabetical, boolean createCategories) { + List<Pair<String, List<ViewElementDescriptor>>> result = + new ArrayList<Pair<String, List<ViewElementDescriptor>>>(); + + List<List<ViewElementDescriptor>> lists = new ArrayList<List<ViewElementDescriptor>>(2); + LayoutDescriptors layoutDescriptors = targetData.getLayoutDescriptors(); + lists.add(layoutDescriptors.getViewDescriptors()); + lists.add(layoutDescriptors.getLayoutDescriptors()); + + // First record map of FQCN to ViewElementDescriptor such that we can quickly + // determine if a particular palette entry is available + Map<String, ViewElementDescriptor> fqcnToDescriptor = + new HashMap<String, ViewElementDescriptor>(); + for (List<ViewElementDescriptor> list : lists) { + for (ViewElementDescriptor view : list) { + String fqcn = view.getFullClassName(); + if (fqcn == null) { + // <view> and <merge> tags etc + fqcn = view.getUiName(); + } + fqcnToDescriptor.put(fqcn, view); + } + } + + Set<ViewElementDescriptor> remaining = new HashSet<ViewElementDescriptor>( + layoutDescriptors.getViewDescriptors().size() + + layoutDescriptors.getLayoutDescriptors().size()); + remaining.addAll(layoutDescriptors.getViewDescriptors()); + remaining.addAll(layoutDescriptors.getLayoutDescriptors()); + + // Now iterate in palette metadata order over the items in the palette and include + // any that also appear as a descriptor + List<ViewElementDescriptor> categoryItems = new ArrayList<ViewElementDescriptor>(); + for (CategoryData category : getCategories()) { + if (createCategories) { + categoryItems = new ArrayList<ViewElementDescriptor>(); + } + for (ViewData view : category) { + String fqcn = view.getFcqn(); + ViewElementDescriptor descriptor = fqcnToDescriptor.get(fqcn); + if (descriptor != null) { + remaining.remove(descriptor); + if (view.getSkip()) { + continue; + } + + if (view.getDisplayName() != null || view.getInitString().length() > 0) { + categoryItems.add(new PaletteMetadataDescriptor(descriptor, + view.getDisplayName(), view.getInitString(), view.getIconName())); + } else { + categoryItems.add(descriptor); + } + + if (view.hasVariations()) { + for (ViewData variation : view.getVariations()) { + String init = variation.getInitString(); + String icon = variation.getIconName(); + ViewElementDescriptor desc = new PaletteMetadataDescriptor(descriptor, + variation.getDisplayName(), init, icon); + categoryItems.add(desc); + } + } + } + } + + if (createCategories && categoryItems.size() > 0) { + if (alphabetical) { + Collections.sort(categoryItems); + } + result.add(Pair.of(category.getName(), categoryItems)); + } + } + + if (remaining.size() > 0) { + List<ViewElementDescriptor> otherItems = + new ArrayList<ViewElementDescriptor>(remaining); + // Always sorted, we don't have a natural order for these unknowns + Collections.sort(otherItems); + if (createCategories) { + result.add(Pair.of("Other", otherItems)); + } else { + categoryItems.addAll(otherItems); + } + } + + if (!createCategories) { + if (alphabetical) { + Collections.sort(categoryItems); + } + result.add(Pair.of("Views", categoryItems)); + } + + return result; + } + + @VisibleForTesting + Collection<String> getAllFqcns() { + return getClassToView().keySet(); + } + + /** + * Metadata holder for a particular category - contains the name of the category, its + * ordinal (for natural/logical sorting order) and views contained in the category + */ + private static class CategoryData implements Iterable<ViewData>, Comparable<CategoryData> { + /** Category name */ + private final String mName; + /** Views included in this category */ + private final List<ViewData> mViews = new ArrayList<ViewData>(); + /** Natural ordering rank */ + private final int mOrdinal = sNextOrdinal++; + + /** Constructs a new category with the given name */ + private CategoryData(String name) { + super(); + mName = name; + } + + /** Adds a new view into this category */ + private void addView(ViewData view) { + mViews.add(view); + } + + private String getName() { + return mName; + } + + // Implements Iterable<ViewData> such that we can use for-each on the category to + // enumerate its views + @Override + public Iterator<ViewData> iterator() { + return mViews.iterator(); + } + + // Implements Comparable<CategoryData> such that categories can be naturally sorted + @Override + public int compareTo(CategoryData other) { + return mOrdinal - other.mOrdinal; + } + } + + /** Metadata holder for a view of a given fully qualified class name */ + private static class ViewData implements Comparable<ViewData> { + /** The fully qualified class name of the view */ + private final String mFqcn; + /** Fill preference of the view */ + private final FillPreference mFillPreference; + /** Skip this item in the palette? */ + private final boolean mSkip; + /** Must this item be rendered alone? skipped? etc */ + private final RenderMode mRenderMode; + /** Related views */ + private final String mRelatedTo; + /** The relative rank of the view for natural ordering */ + private final int mOrdinal = sNextOrdinal++; + /** List of optional variations */ + private List<ViewData> mVariations; + /** Display name. Can be null. */ + private String mDisplayName; + /** + * Optional initialization string - a comma separate set of name/value pairs to + * initialize the element with + */ + private String mInitString; + /** The name of an icon (known to the {@link IconFactory} to show for this view */ + private String mIconName; + /** The resize preference of this view */ + private String mResize; + /** The most commonly set attributes of this view */ + private String mTopAttrs; + + /** Constructs a new view data for the given class */ + private ViewData(String fqcn, String displayName, + FillPreference fillPreference, boolean skip, RenderMode renderMode, + String relatedTo, String resize, String topAttrs) { + super(); + mFqcn = fqcn; + mDisplayName = displayName; + mFillPreference = fillPreference; + mSkip = skip; + mRenderMode = renderMode; + mRelatedTo = relatedTo; + mResize = resize; + mTopAttrs = topAttrs; + } + + /** Returns the {@link FillPreference} for views of this type */ + private FillPreference getFillPreference() { + return mFillPreference; + } + + /** Fully qualified class name of views of this type */ + private String getFcqn() { + return mFqcn; + } + + private String getDisplayName() { + return mDisplayName; + } + + private String getResize() { + return mResize; + } + + // Implements Comparable<ViewData> such that views can be sorted naturally + @Override + public int compareTo(ViewData other) { + return mOrdinal - other.mOrdinal; + } + + public RenderMode getRenderMode() { + return mRenderMode; + } + + public boolean getSkip() { + return mSkip; + } + + public List<String> getRelatedTo() { + if (mRelatedTo == null || mRelatedTo.length() == 0) { + return Collections.emptyList(); + } else { + List<String> result = new ArrayList<String>(); + ViewMetadataRepository repository = ViewMetadataRepository.get(); + Map<String, ViewData> classToView = repository.getClassToView(); + + List<String> fqns = new ArrayList<String>(classToView.keySet()); + for (String basename : Splitter.on(',').split(mRelatedTo)) { + boolean found = false; + for (String fqcn : fqns) { + String suffix = '.' + basename; + if (fqcn.endsWith(suffix)) { + result.add(fqcn); + found = true; + break; + } + } + if (basename.equals(VIEW_FRAGMENT) || basename.equals(VIEW_INCLUDE)) { + result.add(basename); + } else { + assert found : basename; + } + } + + return result; + } + } + + public List<String> getTopAttributes() { + // "id" is a top attribute for all views, so it is not included in the XML, we just + // add it in dynamically here + if (mTopAttrs == null || mTopAttrs.length() == 0) { + return Collections.singletonList(ATTR_ID); + } else { + String[] split = mTopAttrs.split(","); //$NON-NLS-1$ + List<String> topAttributes = new ArrayList<String>(split.length + 1); + topAttributes.add(ATTR_ID); + for (int i = 0, n = split.length; i < n; i++) { + topAttributes.add(split[i]); + } + return Collections.<String>unmodifiableList(topAttributes); + } + } + + void addVariation(ViewData variation) { + if (mVariations == null) { + mVariations = new ArrayList<ViewData>(4); + } + mVariations.add(variation); + } + + List<ViewData> getVariations() { + return mVariations; + } + + boolean hasVariations() { + return mVariations != null && mVariations.size() > 0; + } + + private void setInitString(String initString) { + this.mInitString = initString; + } + + private String getInitString() { + return mInitString; + } + + private void setIconName(String iconName) { + this.mIconName = iconName; + } + + private String getIconName() { + return mIconName; + } + } + + /** + * Returns the {@link FillPreference} for classes with the given fully qualified class + * name + * + * @param fqcn the fully qualified class name of the view + * @return a suitable {@link FillPreference} for the given view type + */ + public FillPreference getFillPreference(String fqcn) { + ViewData view = getClassToView().get(fqcn); + if (view != null) { + return view.getFillPreference(); + } + + return FillPreference.NONE; + } + + /** + * Returns the {@link RenderMode} for classes with the given fully qualified class + * name + * + * @param fqcn the fully qualified class name + * @return the {@link RenderMode} to use for previews of the given view type + */ + public RenderMode getRenderMode(String fqcn) { + ViewData view = getClassToView().get(fqcn); + if (view != null) { + return view.getRenderMode(); + } + + return RenderMode.NORMAL; + } + + /** + * Returns the {@link ResizePolicy} for the given class. + * + * @param fqcn the fully qualified class name of the target widget + * @return the {@link ResizePolicy} for the widget, which will never be null (but may + * be the default of {@link ResizePolicy#full()} if no metadata is found for + * the given widget) + */ + public ResizePolicy getResizePolicy(String fqcn) { + ViewData view = getClassToView().get(fqcn); + if (view != null) { + String resize = view.getResize(); + if (resize != null && resize.length() > 0) { + if ("full".equals(resize)) { //$NON-NLS-1$ + return ResizePolicy.full(); + } else if ("none".equals(resize)) { //$NON-NLS-1$ + return ResizePolicy.none(); + } else if ("horizontal".equals(resize)) { //$NON-NLS-1$ + return ResizePolicy.horizontal(); + } else if ("vertical".equals(resize)) { //$NON-NLS-1$ + return ResizePolicy.vertical(); + } else if ("scaled".equals(resize)) { //$NON-NLS-1$ + return ResizePolicy.scaled(); + } else { + assert false : resize; + } + } + } + + return ResizePolicy.full(); + } + + /** + * Returns true if classes with the given fully qualified class name should be hidden + * or skipped from the palette + * + * @param fqcn the fully qualified class name + * @return true if views of the given type should be hidden from the palette + */ + public boolean getSkip(String fqcn) { + ViewData view = getClassToView().get(fqcn); + if (view != null) { + return view.getSkip(); + } + + return false; + } + + /** + * Returns a list of the top (most commonly set) attributes of the given + * view. + * + * @param fqcn the fully qualified class name + * @return a list, never null but possibly empty, of popular attribute names + * (not including a namespace prefix) + */ + public List<String> getTopAttributes(String fqcn) { + ViewData view = getClassToView().get(fqcn); + if (view != null) { + return view.getTopAttributes(); + } + + return Collections.singletonList(ATTR_ID); + } + + /** + * Returns a set of fully qualified names for views that are closely related to the + * given view + * + * @param fqcn the fully qualified class name + * @return a list, never null but possibly empty, of views that are related to the + * view of the given type + */ + public List<String> getRelatedTo(String fqcn) { + ViewData view = getClassToView().get(fqcn); + if (view != null) { + return view.getRelatedTo(); + } + + return Collections.emptyList(); + } + + /** Render mode for palette preview */ + public enum RenderMode { + /** + * Render previews, and it can be rendered as a sibling of many other views in a + * big linear layout + */ + NORMAL, + /** This view needs to be rendered alone */ + ALONE, + /** + * Skip this element; it doesn't work or does not produce any visible artifacts + * (such as the basic layouts) + */ + SKIP; + + /** + * Returns the {@link RenderMode} for the given render XML attribute + * value + * + * @param render the attribute value in the metadata XML file + * @return a corresponding {@link RenderMode}, never null + */ + public static RenderMode get(String render) { + if ("alone".equals(render)) { //$NON-NLS-1$ + return ALONE; + } else if ("skip".equals(render)) { //$NON-NLS-1$ + return SKIP; + } else { + return NORMAL; + } + } + } + + /** + * Are insets supported yet? This flag indicates whether the {@link #getInsets} method + * can return valid data, such that clients can avoid doing any work computing the + * current theme or density if there's no chance that valid insets will be returned + */ + public static final boolean INSETS_SUPPORTED = false; + + /** + * Returns the insets of widgets with the given fully qualified name, in the given + * theme and the given screen density. + * + * @param fqcn the fully qualified name of the view + * @param density the screen density + * @param theme the theme name + * @return the insets of the visual bounds relative to the view info bounds, or null + * if not known or if there are no insets + */ + public static Margins getInsets(String fqcn, Density density, String theme) { + if (INSETS_SUPPORTED) { + // Some sample data measured manually for common themes and widgets. + if (fqcn.equals(FQCN_BUTTON)) { + if (density == Density.HIGH) { + if (theme.startsWith(HOLO_PREFIX)) { + // Theme.Holo, Theme.Holo.Light, WVGA + return new Margins(5, 5, 5, 5); + } else { + // Theme.Light, WVGA + return new Margins(4, 4, 0, 7); + } + } else if (density == Density.MEDIUM) { + if (theme.startsWith(HOLO_PREFIX)) { + // Theme.Holo, Theme.Holo.Light, WVGA + return new Margins(3, 3, 3, 3); + } else { + // Theme.Light, HVGA + return new Margins(2, 2, 0, 4); + } + } else if (density == Density.LOW) { + if (theme.startsWith(HOLO_PREFIX)) { + // Theme.Holo, Theme.Holo.Light, QVGA + return new Margins(2, 2, 2, 2); + } else { + // Theme.Light, QVGA + return new Margins(1, 3, 0, 4); + } + } + } else if (fqcn.equals(FQCN_TOGGLE_BUTTON)) { + if (density == Density.HIGH) { + if (theme.startsWith(HOLO_PREFIX)) { + // Theme.Holo, Theme.Holo.Light, WVGA + return new Margins(5, 5, 5, 5); + } else { + // Theme.Light, WVGA + return new Margins(2, 2, 0, 5); + } + } else if (density == Density.MEDIUM) { + if (theme.startsWith(HOLO_PREFIX)) { + // Theme.Holo, Theme.Holo.Light, WVGA + return new Margins(3, 3, 3, 3); + } else { + // Theme.Light, HVGA + return new Margins(0, 1, 0, 3); + } + } else if (density == Density.LOW) { + if (theme.startsWith(HOLO_PREFIX)) { + // Theme.Holo, Theme.Holo.Light, QVGA + return new Margins(2, 2, 2, 2); + } else { + // Theme.Light, QVGA + return new Margins(2, 2, 0, 4); + } + } + } else if (fqcn.equals(FQCN_SPINNER)) { + if (density == Density.HIGH) { + if (!theme.startsWith(HOLO_PREFIX)) { + // Theme.Light, WVGA + return new Margins(3, 4, 2, 8); + } // Doesn't render on Holo! + } else if (density == Density.MEDIUM) { + if (!theme.startsWith(HOLO_PREFIX)) { + // Theme.Light, HVGA + return new Margins(1, 1, 0, 4); + } + } + } + } + + return null; + } + + private static final String HOLO_PREFIX = "Theme.Holo"; //$NON-NLS-1$ +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/extra-view-metadata.xml b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/extra-view-metadata.xml new file mode 100644 index 000000000..6a67b1db4 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/extra-view-metadata.xml @@ -0,0 +1,452 @@ +<?xml version="1.0" encoding="UTF-8"?> + <!-- + Palette Metadata + + This document provides additional designtime metadata for various Android views, such as + logical palette categories (as well as a natural ordering of the views within their + categories, fill-preferences (how a view will sets its width and height attributes when + dropped into other views), and so on. + --> +<!DOCTYPE metadata [ +<!--- The metadata consists of a series of category definitions --> +<!ELEMENT metadata (category)*> +<!--- Each category has a name and contains a list of views in order --> +<!ELEMENT category (view)*> +<!ATTLIST category name CDATA #IMPLIED> +<!--- Each view is identified by its full class name and has various + other attributes such as a fill preference --> +<!ELEMENT view (view)*> +<!ATTLIST view + class CDATA #IMPLIED + name CDATA #IMPLIED + init CDATA #IMPLIED + icon CDATA #IMPLIED + relatedTo CDATA #IMPLIED + skip (true|false) "false" + render (alone|skip|normal) "normal" + fill (none|both|width|height|opposite|width_in_vertical|height_in_horizontal) "none" + resize (full|none|horizontal|vertical|scaled) "full" + topAttrs CDATA #IMPLIED +> +]> +<metadata> + <category + name="Form Widgets"> + <view + class="android.widget.TextView" + topAttrs="text,textAppearance,textColor,textSize" + name="TextView" + init="" + relatedTo="EditText,AutoCompleteTextView,MultiAutoCompleteTextView"> + <view + name="Large Text" + init="android:textAppearance=?android:attr/textAppearanceLarge,android:text=Large Text" /> + <view + name="Medium Text" + init="android:textAppearance=?android:attr/textAppearanceMedium,android:text=Medium Text" /> + <view + name="Small Text" + init="android:textAppearance=?android:attr/textAppearanceSmall,android:text=Small Text" /> + </view> + <view + class="android.widget.Button" + topAttrs="text,style" + name="Button" + init="" + relatedTo="ImageButton"> + <view + name="Small Button" + init="style=?android:attr/buttonStyleSmall,android:text=Button" /> + </view> + <view + class="android.widget.ToggleButton" + topAttrs="textOff,textOn,style,background" + relatedTo="CheckBox" /> + <view + class="android.widget.CheckBox" + topAttrs="text" + relatedTo="RadioButton,ToggleButton,CheckedTextView" /> + <view + class="android.widget.RadioButton" + topAttrs="text,style" + relatedTo="CheckBox,ToggleButton" /> + <view + class="android.widget.CheckedTextView" + topAttrs="gravity,paddingLeft,paddingRight,checkMark,textAppearance" + relatedTo="TextView,CheckBox" /> + <view + class="android.widget.Spinner" + topAttrs="prompt,entries,style" + relatedTo="EditText" + fill="width_in_vertical" /> + <view + class="android.widget.ProgressBar" + topAttrs="style,visibility,indeterminate,max" + relatedTo="SeekBar" + name="ProgressBar (Large)" + init="style=?android:attr/progressBarStyleLarge" + resize="scaled" > + <view + name="ProgressBar (Normal)" + init="" + resize="scaled" /> + <view + name="ProgressBar (Small)" + init="style=?android:attr/progressBarStyleSmall" + resize="scaled" /> + <view + name="ProgressBar (Horizontal)" + init="style=?android:attr/progressBarStyleHorizontal" + resize="horizontal" /> + </view> + <view + class="android.widget.SeekBar" + topAttrs="paddingLeft,paddingRight,progressDrawable,thumb" + relatedTo="ProgressBar" + resize="horizontal" + fill="width_in_vertical" /> + <view + class="android.widget.QuickContactBadge" + topAttrs="src,style,gravity" + resize="scaled" /> + <view + class="android.widget.RadioGroup" + topAttrs="orientation,paddingBottom,paddingTop,style" /> + <view + class="android.widget.RatingBar" + topAttrs="numStars,stepSize,style,isIndicator" + resize="horizontal" /> + <view + class="android.widget.Switch" + topAttrs="text,textOff,textOn,style,checked" + relatedTo="CheckBox,ToggleButton" + render="alone" /> + </category> + <category + name="Text Fields"> + <view + class="android.widget.EditText" + topAttrs="hint,inputType,singleLine" + name="Plain Text" + init="" + resize="full" + relatedTo="Spinner,TextView,AutoCompleteTextView,MultiAutoCompleteTextView" + fill="width_in_vertical"> + <view + name="Person Name" + init="android:inputType=textPersonName" /> + <view + name="Password" + init="android:inputType=textPassword" /> + <view + name="Password (Numeric)" + init="android:inputType=numberPassword" /> + <view + name="E-mail" + init="android:inputType=textEmailAddress" /> + <view + name="Phone" + init="android:inputType=phone" /> + <view + name="Postal Address" + resize="full" + init="android:inputType=textPostalAddress" /> + <view + name="Multiline Text" + resize="full" + init="android:inputType=textMultiLine" /> + <view + name="Time" + init="android:inputType=time" /> + <view + name="Date" + init="android:inputType=date" /> + <view + name="Number" + init="android:inputType=number" /> + <view + name="Number (Signed)" + init="android:inputType=numberSigned" /> + <view + name="Number (Decimal)" + init="android:inputType=numberDecimal" /> + </view> + <view + class="android.widget.AutoCompleteTextView" + topAttrs="singleLine,autoText" + fill="width_in_vertical" /> + <view + class="android.widget.MultiAutoCompleteTextView" + topAttrs="background,hint,imeOptions,inputType,style,textColor" + fill="width_in_vertical" /> + </category> + <category + name="Layouts"> + <view + class="android.widget.GridLayout" + fill="opposite" + render="skip" /> + <view + class="android.widget.LinearLayout" + topAttrs="orientation,gravity" + name="LinearLayout (Vertical)" + init="android:orientation=vertical" + icon="VerticalLinearLayout" + fill="opposite" + render="skip"> + <view + name="LinearLayout (Horizontal)" /> + </view> + <view + class="android.widget.RelativeLayout" + topAttrs="background,orientation,paddingLeft" + fill="opposite" + render="skip" /> + <view + class="android.widget.FrameLayout" + topAttrs="background" + fill="opposite" + render="skip" /> + <view + class="include" + topAttrs="layout" + name="Include Other Layout" + render="skip" + relatedTo="fragment" /> + <view + class="fragment" + topAttrs="class,name" + name="Fragment" + fill="opposite" + render="skip" + relatedTo="include" /> + <view + class="android.widget.TableLayout" + topAttrs="stretchColumns,shrinkColumns,orientation" + fill="opposite" + render="skip" /> + <view + class="android.widget.TableRow" + topAttrs="paddingTop,focusable,gravity,visibility" + fill="opposite" + resize="vertical" + render="skip" /> + <view + class="android.widget.Space" + fill="opposite" + render="skip" /> + </category> + <category + name="Composite"> + <view + class="android.widget.ListView" + topAttrs="drawSelectorOnTop,cacheColorHint,divider,background" + relatedTo="ExpandableListView" + fill="width_in_vertical" /> + <view + class="android.widget.ExpandableListView" + topAttrs="drawSelectorOnTop,cacheColorHint,indicatorLeft,indicatorRight,scrollbars,textSize" + relatedTo="ListView" + fill="width_in_vertical" /> + <view + class="android.widget.GridView" + topAttrs="numColumns,verticalSpacing,horizontalSpacing" + fill="opposite" + render="skip" /> + <view + class="android.widget.ScrollView" + topAttrs="fillViewport,orientation,scrollbars" + relatedTo="HorizontalScrollView" + fill="opposite" + render="skip" /> + <view + class="android.widget.HorizontalScrollView" + topAttrs="scrollbars,fadingEdgeLength,fadingEdge" + relatedTo="ScrollView" + render="skip" /> + <view + class="android.widget.SearchView" + topAttrs="iconifiedByDefault,queryHint,maxWidth,minWidth,visibility" + render="skip" /> + <view + class="android.widget.SlidingDrawer" + render="skip" + topAttrs="allowSingleTap,bottomOffset,content,handle,topOffset,visibility" /> + <view + class="android.widget.TabHost" + topAttrs="paddingTop,background,duplicateParentState,visibility" + fill="width_in_vertical" + render="alone" /> + <view + class="android.widget.TabWidget" + topAttrs="background,paddingLeft,tabStripEnabled,gravity" + render="alone" /> + <view + class="android.webkit.WebView" + topAttrs="background,visibility,textAppearance" + fill="opposite" + render="skip" /> + </category> + <category + name="Images & Media"> + <view + class="android.widget.ImageView" + topAttrs="src,scaleType" + resize="scaled" + render="skip" + relatedTo="ImageButton,VideoView" /> + <view + class="android.widget.ImageButton" + topAttrs="src,background,style" + resize="scaled" + render="skip" + relatedTo="Button,ImageView" /> + <view + class="android.widget.Gallery" + topAttrs="gravity,spacing,background" + fill="width_in_vertical" + render="skip" /> + <view + class="android.widget.MediaController" + render="skip" /> + <view + class="android.widget.VideoView" + relatedTo="ImageView" + fill="opposite" + render="skip" /> + </category> + <category + name="Time & Date"> + <view + class="android.widget.TimePicker" + topAttrs="visibility" + relatedTo="DatePicker,CalendarView" + render="alone" /> + <view + class="android.widget.DatePicker" + relatedTo="TimePicker" + render="alone" /> + <view + class="android.widget.CalendarView" + topAttrs="focusable,focusableInTouchMode,visibility" + fill="both" + relatedTo="TimePicker,DatePicker" /> + <view + class="android.widget.Chronometer" + topAttrs="textSize,gravity,visibility" + render="skip" /> + <view + class="android.widget.AnalogClock" + topAttrs="dial,hand_hour,hand_minute" + relatedTo="DigitalClock" /> + <view + class="android.widget.DigitalClock" + relatedTo="AnalogClock" /> + </category> + <category + name="Transitions"> + <view + class="android.widget.ImageSwitcher" + topAttrs="inAnimation,outAnimation,cropToPadding,padding,scaleType" + relatedTo="ViewFlipper,ViewSwitcher,TextSwitcher" + render="skip" /> + <view + class="android.widget.AdapterViewFlipper" + topAttrs="autoStart,flipInterval,inAnimation,outAnimation" + fill="opposite" + render="skip" /> + <view + class="android.widget.StackView" + topAttrs="loopViews,gravity" + fill="opposite" + render="skip" /> + <view + class="android.widget.TextSwitcher" + relatedTo="ViewFlipper,ImageSwitcher,ViewSwitcher" + fill="opposite" + render="skip" /> + <view + class="android.widget.ViewAnimator" + topAttrs="inAnimation,outAnimation" + fill="opposite" + render="skip" /> + <view + class="android.widget.ViewFlipper" + topAttrs="flipInterval,inAnimation,outAnimation,addStatesFromChildren,measureAllChildren" + relatedTo="ViewSwitcher,ImageSwitcher,TextSwitcher" + fill="opposite" + render="skip" /> + <view + class="android.widget.ViewSwitcher" + topAttrs="inAnimation,outAnimation" + relatedTo="ViewFlipper,ImageSwitcher,TextSwitcher" + fill="opposite" + render="skip" /> + </category> + <category + name="Advanced"> + <view + class="requestFocus" + render="skip" /> + <view + class="android.view.View" + topAttrs="background,visibility,style" + render="skip" /> + <view + class="android.view.ViewStub" + topAttrs="layout,inflatedId,visibility" + render="skip" /> + <view + class="view" + topAttrs="class" + render="skip" /> + <view + class="android.gesture.GestureOverlayView" + topAttrs="gestureStrokeType,uncertainGestureColor,eventsInterceptionEnabled,gestureColor,orientation" + render="skip" /> + <view + class="android.view.TextureView" + render="skip" /> + <view + class="android.view.SurfaceView" + render="skip" /> + <view + class="android.widget.NumberPicker" + topAttrs="focusable,focusableInTouchMode" + relatedTo="TimePicker,DatePicker" + render="alone" /> + <view + class="android.widget.ZoomButton" + topAttrs="background" + relatedTo="Button,ZoomControls" /> + <view + class="android.widget.ZoomControls" + topAttrs="style,background,gravity" + relatedTo="ZoomButton" + resize="none" /> + <view + class="merge" + topAttrs="orientation,gravity,style" + skip="true" + render="skip" /> + <view + class="android.widget.DialerFilter" + fill="width_in_vertical" + render="skip" /> + <view + class="android.widget.TwoLineListItem" + topAttrs="mode,paddingBottom,paddingTop,minHeight,paddingLeft" + render="skip" /> + <view + class="android.widget.AbsoluteLayout" + topAttrs="background,orientation,paddingBottom,paddingLeft,paddingRight,paddingTop" + name="AbsoluteLayout (Deprecated)" + fill="opposite" + render="skip" /> + </category> + <category + name="Other"> + <!-- This is the catch-all category which contains unknown views if we encounter any --> + </category> + <!-- TODO: Add-ons? --> +</metadata> diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/rendering-configs.xml b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/rendering-configs.xml new file mode 100644 index 000000000..96c7fe7d2 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gre/rendering-configs.xml @@ -0,0 +1,382 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Default configuration for various views to be rendered + TODO: Remove views that don't have custom configuration + TODO: Parameterize the custom width (200dip) in the below? +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <AnalogClock + android:layout_width="wrap_content" + android:id="@+id/android_widget_AnalogClock" + android:layout_height="75dip"> + </AnalogClock> + <AutoCompleteTextView + android:layout_height="wrap_content" + android:layout_width="200dip" + android:text="AutoComplete" + android:id="@+id/android_widget_AutoCompleteTextView"> + </AutoCompleteTextView> + <Button + android:text="Button" + android:id="@+id/android_widget_Button" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </Button> + <Button + android:text="Small" + style="?android:attr/buttonStyleSmall" + android:id="@+id/android_widget_SmallButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </Button> + <CheckBox + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:text="CheckBox" + android:id="@+id/android_widget_CheckBox" + android:checked="true"> + </CheckBox> + <CheckedTextView + android:text="CheckedTextView" + android:id="@+id/android_widget_CheckedTextView" + android:layout_height="wrap_content" + android:layout_width="wrap_content"> + </CheckedTextView> + <!-- + <Chronometer + android:text="Chronometer" + android:id="@+id/android_widget_Chronometer" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </Chronometer> + --> + <DigitalClock + android:text="DigitalClock" + android:id="@+id/android_widget_DigitalClock" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </DigitalClock> + + <EditText + android:id="@+id/PlainText" + android:text="abc" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/Password" + android:inputType="textPassword" + android:text="••••••••" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <!-- android:inputType="numberPassword" not used here to allow digits in preview only --> + <EditText + android:id="@+id/PasswordNumeric" + android:text="1•••2•••3" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/PersonName" + android:inputType="textPersonName" + android:text="Firstname Lastname" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/Phone" + android:inputType="phone" + android:text="(555) 0100" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/PostalAddress" + android:inputType="textPostalAddress" + android:text="Address" + android:layout_width="200dip" + android:layout_height="100dip"> + </EditText> + + <EditText + android:id="@+id/MultilineText" + android:inputType="textMultiLine" + android:text="Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor" + android:layout_width="200dip" + android:layout_height="100dip"> + </EditText> + + <EditText + android:id="@+id/Date" + android:inputType="date" + android:text="1/1/2011" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/Time" + android:inputType="time" + android:text="12:00am" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/Email" + android:inputType="textEmailAddress" + android:text="user@domain" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/Number" + android:inputType="number" + android:text="42" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/NumberSigned" + android:inputType="numberSigned" + android:text="-42" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <EditText + android:id="@+id/NumberDecimal" + android:inputType="numberDecimal" + android:text="42.0" + android:layout_width="200dip" + android:layout_height="wrap_content"> + </EditText> + + <TextView + android:text="Large" + android:id="@+id/LargeText" + android:textAppearance="?android:attr/textAppearanceLarge" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </TextView> + + <TextView + android:text="Medium" + android:id="@+id/MediumText" + android:textAppearance="?android:attr/textAppearanceMedium" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </TextView> + + <TextView + android:text="Small" + android:id="@+id/SmallText" + android:textAppearance="?android:attr/textAppearanceSmall" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </TextView> + + <MultiAutoCompleteTextView + android:layout_height="wrap_content" + android:layout_width="200dip" + android:text="MultiAutoComplete" + android:id="@+id/android_widget_MultiAutoCompleteTextView"> + </MultiAutoCompleteTextView> + <ProgressBar + android:id="@+id/android_widget_ProgressBarNormal" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </ProgressBar> + <ProgressBar + android:id="@+id/android_widget_ProgressBarHorizontal" + android:layout_width="200dip" + android:layout_height="wrap_content" + android:progress="30" + style="?android:attr/progressBarStyleHorizontal"> + </ProgressBar> + <ProgressBar + android:id="@+id/android_widget_ProgressBarLarge" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + style="?android:attr/progressBarStyleLarge"> + </ProgressBar> + <ProgressBar + android:id="@+id/android_widget_ProgressBarSmall" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + style="?android:attr/progressBarStyleSmall"> + </ProgressBar> + <QuickContactBadge + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:id="@+id/android_widget_QuickContactBadge"> + </QuickContactBadge> + <RadioButton + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:id="@+id/android_widget_RadioButton" + android:text="RadioButton" + android:checked="true"> + </RadioButton> + <RatingBar + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:id="@+id/android_widget_RatingBar" + android:rating="1"> + </RatingBar> + <SeekBar + android:layout_height="wrap_content" + android:id="@+id/android_widget_SeekBar" + android:layout_width="200dip" + android:progress="30"> + </SeekBar> + <ListView + android:id="@+id/android_widget_ListView" + android:layout_width="200dip" + android:layout_height="60dip" + android:divider="#333333" + android:dividerHeight="1px" + > + </ListView> + <ExpandableListView + android:id="@+id/android_widget_ExpandableListView" + android:layout_width="200dip" + android:layout_height="60dip" + android:divider="#333333" + android:dividerHeight="1px" + > + </ExpandableListView> + <Spinner + android:layout_height="wrap_content" + android:id="@+id/android_widget_Spinner" + android:layout_width="200dip"> + </Spinner> + <TextView + android:text="TextView" + android:id="@+id/android_widget_TextView" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </TextView> + <ToggleButton + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:checked="false" + android:id="@+id/android_widget_ToggleButton" + android:text="ToggleButton"> + </ToggleButton> + <ZoomButton + android:id="@+id/android_widget_ZoomButton" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:src="@android:drawable/btn_plus"> + </ZoomButton> + <ZoomControls + android:id="@+id/android_widget_ZoomControls" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </ZoomControls> + <Switch + android:id="@+id/android_widget_Switch" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + <TimePicker + android:id="@+id/android_widget_TimePicker" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </TimePicker> + <DatePicker + android:id="@+id/android_widget_DatePicker" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + </DatePicker> + <CalendarView + android:id="@+id/android_widget_CalendarView" + android:layout_width="200dip" + android:layout_height="200dip"> + </CalendarView> + <RadioGroup + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:orientation="horizontal" + android:id="@+id/android_widget_RadioGroup"> + <RadioButton + android:checked="true"> + </RadioButton> + <RadioButton></RadioButton> + <RadioButton></RadioButton> + </RadioGroup> + <TabHost + android:id="@android:id/tabhost" + android:layout_width="200dip" + android:layout_height="100dip"> + <LinearLayout + android:id="@+id/linearLayout1" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <TabWidget + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:id="@android:id/tabs"> + </TabWidget> + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@android:id/tabcontent"> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/Tab1"> + </LinearLayout> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/Tab2"> + </LinearLayout> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/Tab3"> + </LinearLayout> + </FrameLayout> + </LinearLayout> + </TabHost> + <TabHost + android:id="@android:id/tabhost" + android:layout_width="70dip" + android:layout_height="100dip"> + <LinearLayout + android:id="@+id/android_widget_TabWidget" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <TabWidget + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:id="@android:id/tabs"> + </TabWidget> + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@android:id/tabcontent"> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/Tab1"> + </LinearLayout> + </FrameLayout> + </LinearLayout> + </TabHost> +</LinearLayout> diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/BooleanXmlPropertyEditor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/BooleanXmlPropertyEditor.java new file mode 100644 index 000000000..d6ff4d51d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/BooleanXmlPropertyEditor.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import static com.android.SdkConstants.VALUE_FALSE; +import static com.android.SdkConstants.VALUE_TRUE; + +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.wb.internal.core.DesignerPlugin; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.table.PropertyTable; +import org.eclipse.wb.internal.core.utils.ui.DrawUtils; + +/** + * Handle an XML property which represents booleans. + * + * Similar to the WindowBuilder PropertyEditor, but operates on Strings rather + * than Booleans (which means it is a tri-state boolean: true, false, not set) + */ +public class BooleanXmlPropertyEditor extends XmlPropertyEditor { + public static final BooleanXmlPropertyEditor INSTANCE = new BooleanXmlPropertyEditor(); + + private static final Image mTrueImage = DesignerPlugin.getImage("properties/true.png"); + private static final Image mFalseImage = DesignerPlugin.getImage("properties/false.png"); + private static final Image mNullImage = + DesignerPlugin.getImage("properties/BooleanNull.png"); + private static final Image mUnknownImage = + DesignerPlugin.getImage("properties/BooleanUnknown.png"); + + private BooleanXmlPropertyEditor() { + } + + @Override + public void paint(Property property, GC gc, int x, int y, int width, int height) + throws Exception { + Object value = property.getValue(); + assert value == null || value instanceof String; + if (value == null || value instanceof String) { + String text = (String) value; + Image image; + if (VALUE_TRUE.equals(text)) { + image = mTrueImage; + } else if (VALUE_FALSE.equals(text)) { + image = mFalseImage; + } else if (text == null) { + image = mNullImage; + } else { + // Probably something like a reference, e.g. @boolean/foo + image = mUnknownImage; + } + + // draw image + DrawUtils.drawImageCV(gc, image, x, y, height); + + // prepare new position/width + int imageWidth = image.getBounds().width + 2; + width -= imageWidth; + + // draw text + if (text != null) { + x += imageWidth; + DrawUtils.drawStringCV(gc, text, x, y, width, height); + } + } + } + + @Override + public boolean activate(PropertyTable propertyTable, Property property, Point location) + throws Exception { + // check that user clicked on image + if (location == null || location.x < mTrueImage.getBounds().width + 2) { + cycleValue(property); + } + // don't activate + return false; + } + + @Override + public void doubleClick(Property property, Point location) throws Exception { + cycleValue(property); + } + + /** + * Cycles through the values + */ + private void cycleValue(Property property) throws Exception { + Object value = property.getValue(); + if (value == null || value instanceof String) { + // Cycle null => true => false => null + String text = (String) value; + if (VALUE_TRUE.equals(text)) { + property.setValue(VALUE_FALSE); + } else if (VALUE_FALSE.equals(text)) { + property.setValue(null); + } else { + property.setValue(VALUE_TRUE); + } + } else { + assert false; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/EnumXmlPropertyEditor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/EnumXmlPropertyEditor.java new file mode 100644 index 000000000..f1a3f2aaa --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/EnumXmlPropertyEditor.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ListAttributeDescriptor; + +import org.eclipse.wb.core.controls.CCombo3; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.editor.AbstractComboPropertyEditor; +import org.eclipse.wb.internal.core.model.property.editor.ITextValuePropertyEditor; + +class EnumXmlPropertyEditor extends AbstractComboPropertyEditor implements + ITextValuePropertyEditor { + public static final EnumXmlPropertyEditor INSTANCE = new EnumXmlPropertyEditor(); + + private EnumXmlPropertyEditor() { + } + + @Override + protected String getText(Property property) throws Exception { + Object value = property.getValue(); + if (value == null) { + return ""; + } else if (value instanceof String) { + return (String) value; + } else if (value == Property.UNKNOWN_VALUE) { + return "<varies>"; + } else { + return ""; + } + } + + private String[] getItems(Property property) { + XmlProperty xmlProperty = (XmlProperty) property; + AttributeDescriptor descriptor = xmlProperty.getDescriptor(); + assert descriptor instanceof ListAttributeDescriptor; + ListAttributeDescriptor list = (ListAttributeDescriptor) descriptor; + return list.getValues(); + } + + @Override + protected void addItems(Property property, CCombo3 combo) throws Exception { + for (String item : getItems(property)) { + combo.add(item); + } + } + + @Override + protected void selectItem(Property property, CCombo3 combo) throws Exception { + combo.setText(getText(property)); + } + + @Override + protected void toPropertyEx(Property property, CCombo3 combo, int index) throws Exception { + property.setValue(getItems(property)[index]); + } + + @Override + public void setText(Property property, String text) throws Exception { + property.setValue(text); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/FlagXmlPropertyDialog.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/FlagXmlPropertyDialog.java new file mode 100644 index 000000000..5e1e7029f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/FlagXmlPropertyDialog.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import com.android.annotations.NonNull; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.google.common.base.Splitter; + +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.viewers.CheckStateChangedEvent; +import org.eclipse.jface.viewers.CheckboxTableViewer; +import org.eclipse.jface.viewers.ICheckStateListener; +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.events.KeyListener; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableItem; +import org.eclipse.wb.internal.core.utils.execution.ExecutionUtils; +import org.eclipse.wb.internal.core.utils.execution.RunnableEx; +import org.eclipse.wb.internal.core.utils.ui.dialogs.ResizableDialog; + +import java.util.ArrayList; +import java.util.List; + +class FlagXmlPropertyDialog extends ResizableDialog +implements IStructuredContentProvider, ICheckStateListener, SelectionListener, KeyListener { + private final String mTitle; + private final XmlProperty mProperty; + private final String[] mFlags; + private final boolean mIsRadio; + + private Table mTable; + private CheckboxTableViewer mViewer; + + FlagXmlPropertyDialog( + @NonNull Shell parentShell, + @NonNull String title, + boolean isRadio, + @NonNull String[] flags, + @NonNull XmlProperty property) { + super(parentShell, AdtPlugin.getDefault()); + mTitle = title; + mIsRadio = isRadio; + mFlags = flags; + mProperty = property; + } + + @Override + protected void configureShell(Shell newShell) { + super.configureShell(newShell); + newShell.setText(mTitle); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite container = (Composite) super.createDialogArea(parent); + + mViewer = CheckboxTableViewer.newCheckList(container, + SWT.BORDER | SWT.FULL_SELECTION | SWT.HIDE_SELECTION); + mTable = mViewer.getTable(); + mTable.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 1, 1)); + + Composite workaround = PropertyFactory.addWorkaround(container); + if (workaround != null) { + workaround.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 1, 1)); + } + + mViewer.setContentProvider(this); + mViewer.setInput(mFlags); + + String current = mProperty.getStringValue(); + if (current != null) { + Object[] checked = null; + if (mIsRadio) { + checked = new String[] { current }; + } else { + List<String> flags = new ArrayList<String>(); + for (String s : Splitter.on('|').omitEmptyStrings().trimResults().split(current)) { + flags.add(s); + } + checked = flags.toArray(new String[flags.size()]); + } + mViewer.setCheckedElements(checked); + } + if (mFlags.length > 0) { + mTable.setSelection(0); + } + + if (mIsRadio) { + // Enforce single-item selection + mViewer.addCheckStateListener(this); + } + mTable.addSelectionListener(this); + mTable.addKeyListener(this); + + return container; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + createButton(parent, IDialogConstants.OK_ID, IDialogConstants.OK_LABEL, true); + createButton(parent, IDialogConstants.CANCEL_ID, IDialogConstants.CANCEL_LABEL, false); + } + + @Override + protected Point getDefaultSize() { + return new Point(450, 400); + } + + @Override + protected void okPressed() { + // Apply the value + ExecutionUtils.runLog(new RunnableEx() { + @Override + public void run() throws Exception { + StringBuilder sb = new StringBuilder(30); + for (Object o : mViewer.getCheckedElements()) { + if (sb.length() > 0) { + sb.append('|'); + } + sb.append((String) o); + } + String value = sb.length() > 0 ? sb.toString() : null; + mProperty.setValue(value); + } + }); + + // close dialog + super.okPressed(); + } + + // ---- Implements IStructuredContentProvider ---- + + @Override + public Object[] getElements(Object inputElement) { + return (Object []) inputElement; + } + + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + } + + // ---- Implements ICheckStateListener ---- + + @Override + public void checkStateChanged(CheckStateChangedEvent event) { + // Try to disable other elements that conflict with this + boolean isChecked = event.getChecked(); + if (isChecked) { + Object selected = event.getElement(); + for (Object other : mViewer.getCheckedElements()) { + if (other != selected) { + mViewer.setChecked(other, false); + } + } + } else { + + } + } + + // ---- Implements SelectionListener ---- + + @Override + public void widgetSelected(SelectionEvent e) { + } + + @Override + public void widgetDefaultSelected(SelectionEvent e) { + if (e.item instanceof TableItem) { + TableItem item = (TableItem) e.item; + item.setChecked(!item.getChecked()); + } + } + + // ---- Implements KeyListener ---- + + @Override + public void keyPressed(KeyEvent e) { + // Let space toggle checked state + if (e.keyCode == ' ' /* SWT.SPACE requires Eclipse 3.7 */) { + if (mTable.getSelectionCount() == 1) { + TableItem item = mTable.getSelection()[0]; + item.setChecked(!item.getChecked()); + } + } + } + + @Override + public void keyReleased(KeyEvent e) { + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyFactory.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyFactory.java new file mode 100644 index 000000000..2b8cfbf43 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyFactory.java @@ -0,0 +1,750 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +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 com.android.annotations.Nullable; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.common.api.IAttributeInfo.Format; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.descriptors.SeparatorAttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.XmlnsAttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.tools.lint.detector.api.LintUtils; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Multimap; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Link; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.browser.IWebBrowser; +import org.eclipse.wb.internal.core.editor.structure.property.PropertyListIntersector; +import org.eclipse.wb.internal.core.model.property.ComplexProperty; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.category.PropertyCategory; +import org.eclipse.wb.internal.core.model.property.editor.PropertyEditor; +import org.eclipse.wb.internal.core.model.property.editor.presentation.ButtonPropertyEditorPresentation; + +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * The {@link PropertyFactory} creates (and caches) the set of {@link Property} + * instances applicable to a given node. It's also responsible for ordering + * these, and sometimes combining them into {@link ComplexProperty} category + * nodes. + * <p> + * TODO: For any properties that are *set* in XML, they should NOT be labeled as + * advanced (which would make them disappear) + */ +public class PropertyFactory { + /** Disable cache during development only */ + @SuppressWarnings("unused") + private static final boolean CACHE_ENABLED = true || !LintUtils.assertionsEnabled(); + static { + if (!CACHE_ENABLED) { + System.err.println("WARNING: The property cache is disabled"); + } + } + + private static final Property[] NO_PROPERTIES = new Property[0]; + + private static final int PRIO_FIRST = -100000; + private static final int PRIO_SECOND = PRIO_FIRST + 10; + private static final int PRIO_LAST = 100000; + + private final GraphicalEditorPart mGraphicalEditorPart; + private Map<UiViewElementNode, Property[]> mCache = + new WeakHashMap<UiViewElementNode, Property[]>(); + private UiViewElementNode mCurrentViewCookie; + + /** Sorting orders for the properties */ + public enum SortingMode { + NATURAL, + BY_ORIGIN, + ALPHABETICAL; + } + + /** The default sorting mode */ + public static final SortingMode DEFAULT_MODE = SortingMode.BY_ORIGIN; + + private SortingMode mSortMode = DEFAULT_MODE; + private SortingMode mCacheSortMode; + + public PropertyFactory(GraphicalEditorPart graphicalEditorPart) { + mGraphicalEditorPart = graphicalEditorPart; + } + + /** + * Get the properties for the given list of selection items. + * + * @param items the {@link CanvasViewInfo} instances to get an intersected + * property list for + * @return the properties for the given items + */ + public Property[] getProperties(List<CanvasViewInfo> items) { + mCurrentViewCookie = null; + + if (items == null || items.size() == 0) { + return NO_PROPERTIES; + } else if (items.size() == 1) { + CanvasViewInfo item = items.get(0); + mCurrentViewCookie = item.getUiViewNode(); + + return getProperties(item); + } else { + // intersect properties + PropertyListIntersector intersector = new PropertyListIntersector(); + for (CanvasViewInfo node : items) { + intersector.intersect(getProperties(node)); + } + + return intersector.getProperties(); + } + } + + private Property[] getProperties(CanvasViewInfo item) { + UiViewElementNode node = item.getUiViewNode(); + if (node == null) { + return NO_PROPERTIES; + } + + if (mCacheSortMode != mSortMode) { + mCacheSortMode = mSortMode; + mCache.clear(); + } + + Property[] properties = mCache.get(node); + if (!CACHE_ENABLED) { + properties = null; + } + if (properties == null) { + Collection<? extends Property> propertyList = getProperties(node); + if (propertyList == null) { + properties = new Property[0]; + } else { + properties = propertyList.toArray(new Property[propertyList.size()]); + } + mCache.put(node, properties); + } + return properties; + } + + + protected Collection<? extends Property> getProperties(UiViewElementNode node) { + ViewMetadataRepository repository = ViewMetadataRepository.get(); + ViewElementDescriptor viewDescriptor = (ViewElementDescriptor) node.getDescriptor(); + String fqcn = viewDescriptor.getFullClassName(); + Set<String> top = new HashSet<String>(repository.getTopAttributes(fqcn)); + AttributeDescriptor[] attributeDescriptors = node.getAttributeDescriptors(); + + List<XmlProperty> properties = new ArrayList<XmlProperty>(attributeDescriptors.length); + int priority = 0; + for (final AttributeDescriptor descriptor : attributeDescriptors) { + // TODO: Filter out non-public properties!! + // (They shouldn't be in the descriptors at all) + + assert !(descriptor instanceof SeparatorAttributeDescriptor); // No longer inserted + if (descriptor instanceof XmlnsAttributeDescriptor) { + continue; + } + + PropertyEditor editor = XmlPropertyEditor.INSTANCE; + IAttributeInfo info = descriptor.getAttributeInfo(); + if (info != null) { + EnumSet<Format> formats = info.getFormats(); + if (formats.contains(Format.BOOLEAN)) { + editor = BooleanXmlPropertyEditor.INSTANCE; + } else if (formats.contains(Format.ENUM)) { + // We deliberately don't use EnumXmlPropertyEditor.INSTANCE here, + // since some attributes (such as layout_width) can have not just one + // of the enum values but custom values such as "42dp" as well. And + // furthermore, we don't even bother limiting this to formats.size()==1, + // since the editing experience with the enum property editor is + // more limited than the text editor plus enum completer anyway + // (for example, you can't type to filter the values, and clearing + // the value is harder.) + } + } + + XmlProperty property = new XmlProperty(editor, this, node, descriptor); + // Assign ids sequentially. This ensures that the properties will mostly keep their + // relative order (such as placing width before height), even though we will regroup + // some (such as properties in the same category, and the layout params etc) + priority += 10; + + PropertyCategory category = PropertyCategory.NORMAL; + String name = descriptor.getXmlLocalName(); + if (top.contains(name) || PropertyMetadata.isPreferred(name)) { + category = PropertyCategory.PREFERRED; + property.setPriority(PRIO_FIRST + priority); + } else { + property.setPriority(priority); + + // Prefer attributes defined on the specific type of this + // widget + // NOTE: This doesn't work very well for TextViews + /* IAttributeInfo attributeInfo = descriptor.getAttributeInfo(); + if (attributeInfo != null && fqcn.equals(attributeInfo.getDefinedBy())) { + category = PropertyCategory.PREFERRED; + } else*/ if (PropertyMetadata.isAdvanced(name)) { + category = PropertyCategory.ADVANCED; + } + } + if (category != null) { + property.setCategory(category); + } + properties.add(property); + } + + switch (mSortMode) { + case BY_ORIGIN: + return sortByOrigin(node, properties); + + case ALPHABETICAL: + return sortAlphabetically(node, properties); + + default: + case NATURAL: + return sortNatural(node, properties); + } + } + + protected Collection<? extends Property> sortAlphabetically( + UiViewElementNode node, + List<XmlProperty> properties) { + Collections.sort(properties, Property.ALPHABETICAL); + return properties; + } + + protected Collection<? extends Property> sortByOrigin( + UiViewElementNode node, + List<XmlProperty> properties) { + List<Property> collapsed = new ArrayList<Property>(properties.size()); + List<Property> layoutProperties = Lists.newArrayListWithExpectedSize(20); + List<Property> marginProperties = null; + List<Property> deprecatedProperties = null; + Map<String, ComplexProperty> categoryToProperty = new HashMap<String, ComplexProperty>(); + Multimap<String, Property> categoryToProperties = ArrayListMultimap.create(); + + if (properties.isEmpty()) { + return properties; + } + + ViewElementDescriptor parent = (ViewElementDescriptor) properties.get(0).getDescriptor() + .getParent(); + Map<String, Integer> categoryPriorities = Maps.newHashMap(); + int nextCategoryPriority = 100; + while (parent != null) { + categoryPriorities.put(parent.getFullClassName(), nextCategoryPriority += 100); + parent = parent.getSuperClassDesc(); + } + + for (int i = 0, max = properties.size(); i < max; i++) { + XmlProperty property = properties.get(i); + + AttributeDescriptor descriptor = property.getDescriptor(); + if (descriptor.isDeprecated()) { + if (deprecatedProperties == null) { + deprecatedProperties = Lists.newArrayListWithExpectedSize(10); + } + deprecatedProperties.add(property); + continue; + } + + String firstName = descriptor.getXmlLocalName(); + if (firstName.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + if (firstName.startsWith(ATTR_LAYOUT_MARGIN)) { + if (marginProperties == null) { + marginProperties = Lists.newArrayListWithExpectedSize(5); + } + marginProperties.add(property); + } else { + layoutProperties.add(property); + } + continue; + } + + if (firstName.equals(ATTR_ID)) { + // Add id to the front (though the layout parameters will be added to + // the front of this at the end) + property.setPriority(PRIO_FIRST); + collapsed.add(property); + continue; + } + + if (property.getCategory() == PropertyCategory.PREFERRED) { + collapsed.add(property); + // Fall through: these are *duplicated* inside their defining categories! + // However, create a new instance of the property, such that the propertysheet + // doesn't see the same property instance twice (when selected, it will highlight + // both, etc.) Also, set the category to Normal such that we don't draw attention + // to it again. We want it to appear in both places such that somebody looking + // within a category will always find it there, even if for this specific + // view type it's a common attribute and replicated up at the top. + XmlProperty oldProperty = property; + property = new XmlProperty(oldProperty.getEditor(), this, node, + oldProperty.getDescriptor()); + property.setPriority(oldProperty.getPriority()); + } + + IAttributeInfo attributeInfo = descriptor.getAttributeInfo(); + if (attributeInfo != null && attributeInfo.getDefinedBy() != null) { + String category = attributeInfo.getDefinedBy(); + ComplexProperty complex = categoryToProperty.get(category); + if (complex == null) { + complex = new ComplexProperty( + category.substring(category.lastIndexOf('.') + 1), + "[]", + null /* properties */); + categoryToProperty.put(category, complex); + Integer categoryPriority = categoryPriorities.get(category); + if (categoryPriority != null) { + complex.setPriority(categoryPriority); + } else { + // Descriptor for an attribute whose definedBy does *not* + // correspond to one of the known superclasses of this widget. + // This sometimes happens; for example, a RatingBar will pull in + // an ImageView's minWidth attribute. Probably an error in the + // metadata, but deal with it gracefully here. + categoryPriorities.put(category, nextCategoryPriority += 100); + complex.setPriority(nextCategoryPriority); + } + } + categoryToProperties.put(category, property); + continue; + } else { + collapsed.add(property); + } + } + + // Update the complex properties + for (String category : categoryToProperties.keySet()) { + Collection<Property> subProperties = categoryToProperties.get(category); + if (subProperties.size() > 1) { + ComplexProperty complex = categoryToProperty.get(category); + assert complex != null : category; + Property[] subArray = new Property[subProperties.size()]; + complex.setProperties(subProperties.toArray(subArray)); + //complex.setPriority(subArray[0].getPriority()); + + collapsed.add(complex); + + boolean allAdvanced = true; + boolean isPreferred = false; + for (Property p : subProperties) { + PropertyCategory c = p.getCategory(); + if (c != PropertyCategory.ADVANCED) { + allAdvanced = false; + } + if (c == PropertyCategory.PREFERRED) { + isPreferred = true; + } + } + if (isPreferred) { + complex.setCategory(PropertyCategory.PREFERRED); + } else if (allAdvanced) { + complex.setCategory(PropertyCategory.ADVANCED); + } + } else if (subProperties.size() == 1) { + collapsed.add(subProperties.iterator().next()); + } + } + + if (layoutProperties.size() > 0 || marginProperties != null) { + if (marginProperties != null) { + XmlProperty[] m = + marginProperties.toArray(new XmlProperty[marginProperties.size()]); + Property marginProperty = new ComplexProperty( + "Margins", + "[]", + m); + layoutProperties.add(marginProperty); + marginProperty.setPriority(PRIO_LAST); + + for (XmlProperty p : m) { + p.setParent(marginProperty); + } + } + Property[] l = layoutProperties.toArray(new Property[layoutProperties.size()]); + Arrays.sort(l, Property.PRIORITY); + Property property = new ComplexProperty( + "Layout Parameters", + "[]", + l); + for (Property p : l) { + if (p instanceof XmlProperty) { + ((XmlProperty) p).setParent(property); + } + } + property.setCategory(PropertyCategory.PREFERRED); + collapsed.add(property); + property.setPriority(PRIO_SECOND); + } + + if (deprecatedProperties != null && deprecatedProperties.size() > 0) { + Property property = new ComplexProperty( + "Deprecated", + "(Deprecated Properties)", + deprecatedProperties.toArray(new Property[deprecatedProperties.size()])); + property.setPriority(PRIO_LAST); + collapsed.add(property); + } + + Collections.sort(collapsed, Property.PRIORITY); + + return collapsed; + } + + protected Collection<? extends Property> sortNatural( + UiViewElementNode node, + List<XmlProperty> properties) { + Collections.sort(properties, Property.ALPHABETICAL); + List<Property> collapsed = new ArrayList<Property>(properties.size()); + List<Property> layoutProperties = Lists.newArrayListWithExpectedSize(20); + List<Property> marginProperties = null; + List<Property> deprecatedProperties = null; + Map<String, ComplexProperty> categoryToProperty = new HashMap<String, ComplexProperty>(); + Multimap<String, Property> categoryToProperties = ArrayListMultimap.create(); + + for (int i = 0, max = properties.size(); i < max; i++) { + XmlProperty property = properties.get(i); + + AttributeDescriptor descriptor = property.getDescriptor(); + if (descriptor.isDeprecated()) { + if (deprecatedProperties == null) { + deprecatedProperties = Lists.newArrayListWithExpectedSize(10); + } + deprecatedProperties.add(property); + continue; + } + + String firstName = descriptor.getXmlLocalName(); + if (firstName.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + if (firstName.startsWith(ATTR_LAYOUT_MARGIN)) { + if (marginProperties == null) { + marginProperties = Lists.newArrayListWithExpectedSize(5); + } + marginProperties.add(property); + } else { + layoutProperties.add(property); + } + continue; + } + + if (firstName.equals(ATTR_ID)) { + // Add id to the front (though the layout parameters will be added to + // the front of this at the end) + property.setPriority(PRIO_FIRST); + collapsed.add(property); + continue; + } + + String category = PropertyMetadata.getCategory(firstName); + if (category != null) { + ComplexProperty complex = categoryToProperty.get(category); + if (complex == null) { + complex = new ComplexProperty( + category, + "[]", + null /* properties */); + categoryToProperty.put(category, complex); + complex.setPriority(property.getPriority()); + } + categoryToProperties.put(category, property); + continue; + } + + // Index of second word in the first name, so in fooBar it's 3 (index of 'B') + int firstNameIndex = firstName.length(); + for (int k = 0, kn = firstName.length(); k < kn; k++) { + if (Character.isUpperCase(firstName.charAt(k))) { + firstNameIndex = k; + break; + } + } + + // Scout forwards and see how many properties we can combine + int j = i + 1; + if (property.getCategory() != PropertyCategory.PREFERRED + && !property.getDescriptor().isDeprecated()) { + for (; j < max; j++) { + XmlProperty next = properties.get(j); + String nextName = next.getName(); + if (nextName.regionMatches(0, firstName, 0, firstNameIndex) + // Also make sure we begin the second word at the next + // character; if not, we could have something like + // scrollBar + // scrollingBehavior + && nextName.length() > firstNameIndex + && Character.isUpperCase(nextName.charAt(firstNameIndex))) { + + // Deprecated attributes, and preferred attributes, should not + // be pushed into normal clusters (preferred stay top-level + // and sort to the top, deprecated are all put in the same cluster at + // the end) + + if (next.getCategory() == PropertyCategory.PREFERRED) { + break; + } + if (next.getDescriptor().isDeprecated()) { + break; + } + + // This property should be combined with the previous + // property + } else { + break; + } + } + } + if (j - i > 1) { + // Combining multiple properties: all the properties from i + // through j inclusive + XmlProperty[] subprops = new XmlProperty[j - i]; + for (int k = i, index = 0; k < j; k++, index++) { + subprops[index] = properties.get(k); + } + Arrays.sort(subprops, Property.PRIORITY); + + // See if we can compute a LONGER base than just the first word. + // For example, if we have "lineSpacingExtra" and "lineSpacingMultiplier" + // we'd like the base to be "lineSpacing", not "line". + int common = firstNameIndex; + for (int k = firstNameIndex + 1, n = firstName.length(); k < n; k++) { + if (Character.isUpperCase(firstName.charAt(k))) { + common = k; + break; + } + } + if (common > firstNameIndex) { + for (int k = 0, n = subprops.length; k < n; k++) { + String nextName = subprops[k].getName(); + if (nextName.regionMatches(0, firstName, 0, common) + // Also make sure we begin the second word at the next + // character; if not, we could have something like + // scrollBar + // scrollingBehavior + && nextName.length() > common + && Character.isUpperCase(nextName.charAt(common))) { + // New prefix is okay + } else { + common = firstNameIndex; + break; + } + } + firstNameIndex = common; + } + + String base = firstName.substring(0, firstNameIndex); + base = DescriptorsUtils.capitalize(base); + Property complexProperty = new ComplexProperty( + base, + "[]", + subprops); + complexProperty.setPriority(subprops[0].getPriority()); + //complexProperty.setCategory(PropertyCategory.PREFERRED); + collapsed.add(complexProperty); + boolean allAdvanced = true; + boolean isPreferred = false; + for (XmlProperty p : subprops) { + p.setParent(complexProperty); + PropertyCategory c = p.getCategory(); + if (c != PropertyCategory.ADVANCED) { + allAdvanced = false; + } + if (c == PropertyCategory.PREFERRED) { + isPreferred = true; + } + } + if (isPreferred) { + complexProperty.setCategory(PropertyCategory.PREFERRED); + } else if (allAdvanced) { + complexProperty.setCategory(PropertyCategory.PREFERRED); + } + } else { + // Add the individual properties (usually 1, sometimes 2 + for (int k = i; k < j; k++) { + collapsed.add(properties.get(k)); + } + } + + i = j - 1; // -1: compensate in advance for the for-loop adding 1 + } + + // Update the complex properties + for (String category : categoryToProperties.keySet()) { + Collection<Property> subProperties = categoryToProperties.get(category); + if (subProperties.size() > 1) { + ComplexProperty complex = categoryToProperty.get(category); + assert complex != null : category; + Property[] subArray = new Property[subProperties.size()]; + complex.setProperties(subProperties.toArray(subArray)); + complex.setPriority(subArray[0].getPriority()); + collapsed.add(complex); + + boolean allAdvanced = true; + boolean isPreferred = false; + for (Property p : subProperties) { + PropertyCategory c = p.getCategory(); + if (c != PropertyCategory.ADVANCED) { + allAdvanced = false; + } + if (c == PropertyCategory.PREFERRED) { + isPreferred = true; + } + } + if (isPreferred) { + complex.setCategory(PropertyCategory.PREFERRED); + } else if (allAdvanced) { + complex.setCategory(PropertyCategory.ADVANCED); + } + } else if (subProperties.size() == 1) { + collapsed.add(subProperties.iterator().next()); + } + } + + if (layoutProperties.size() > 0 || marginProperties != null) { + if (marginProperties != null) { + XmlProperty[] m = + marginProperties.toArray(new XmlProperty[marginProperties.size()]); + Property marginProperty = new ComplexProperty( + "Margins", + "[]", + m); + layoutProperties.add(marginProperty); + marginProperty.setPriority(PRIO_LAST); + + for (XmlProperty p : m) { + p.setParent(marginProperty); + } + } + Property[] l = layoutProperties.toArray(new Property[layoutProperties.size()]); + Arrays.sort(l, Property.PRIORITY); + Property property = new ComplexProperty( + "Layout Parameters", + "[]", + l); + for (Property p : l) { + if (p instanceof XmlProperty) { + ((XmlProperty) p).setParent(property); + } + } + property.setCategory(PropertyCategory.PREFERRED); + collapsed.add(property); + property.setPriority(PRIO_SECOND); + } + + if (deprecatedProperties != null && deprecatedProperties.size() > 0) { + Property property = new ComplexProperty( + "Deprecated", + "(Deprecated Properties)", + deprecatedProperties.toArray(new Property[deprecatedProperties.size()])); + property.setPriority(PRIO_LAST); + collapsed.add(property); + } + + Collections.sort(collapsed, Property.PRIORITY); + + return collapsed; + } + + @Nullable + GraphicalEditorPart getGraphicalEditor() { + return mGraphicalEditorPart; + } + + // HACK: This should be passed into each property instead + public Object getCurrentViewObject() { + return mCurrentViewCookie; + } + + public void setSortingMode(SortingMode sortingMode) { + mSortMode = sortingMode; + } + + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=388574 + public static Composite addWorkaround(Composite parent) { + if (ButtonPropertyEditorPresentation.isInWorkaround) { + Composite top = new Composite(parent, SWT.NONE); + top.setLayout(new GridLayout(1, false)); + Label label = new Label(top, SWT.WRAP); + label.setText( + "This dialog is shown instead of an inline text editor as a\n" + + "workaround for an Eclipse bug specific to OSX Mountain Lion.\n" + + "It should be fixed in Eclipse 4.3."); + label.setForeground(top.getDisplay().getSystemColor(SWT.COLOR_RED)); + GridData data = new GridData(); + data.grabExcessVerticalSpace = false; + data.grabExcessHorizontalSpace = false; + data.horizontalAlignment = GridData.FILL; + data.verticalAlignment = GridData.BEGINNING; + label.setLayoutData(data); + + Link link = new Link(top, SWT.NO_FOCUS); + link.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 1, 1)); + link.setText("<a>https://bugs.eclipse.org/bugs/show_bug.cgi?id=388574</a>"); + link.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + try { + IWorkbench workbench = PlatformUI.getWorkbench(); + IWebBrowser browser = workbench.getBrowserSupport().getExternalBrowser(); + browser.openURL(new URL(event.text)); + } catch (Exception e) { + String message = String.format( + "Could not open browser. Vist\n%1$s\ninstead.", + event.text); + MessageDialog.openError(((Link)event.getSource()).getShell(), + "Browser Error", message); + } + } + }); + + return top; + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyMetadata.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyMetadata.java new file mode 100644 index 000000000..b230aa99d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyMetadata.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import static com.android.SdkConstants.ATTR_CONTENT_DESCRIPTION; +import static com.android.SdkConstants.ATTR_HINT; +import static com.android.SdkConstants.ATTR_TEXT; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; + +import java.util.HashSet; +import java.util.Set; + +/** Extra metadata about properties not available from the descriptors (yet) */ +class PropertyMetadata { + static boolean isAdvanced(@NonNull String name) { + return sAdvanced.contains(name); + } + + static boolean isPreferred(@NonNull String name) { + return sPreferred.contains(name); + } + + @Nullable + static String getCategory(@NonNull String name) { + //return sCategories.get(name); + assert false : "Disabled to save memory since this method is not currently used."; + return null; + } + + private static final int ADVANCED_MAP_SIZE = 134; + private static final Set<String> sAdvanced = new HashSet<String>(ADVANCED_MAP_SIZE); + static { + // This metadata about which attributes are "advanced" was generated as follows: + // First, I ran the sdk/attribute_stats project with the --list argument to dump out + // *all* referenced XML attributes found in layouts, run against a bunch of + // sample Android code (development/samples, packages/apps, vendor, etc. + // + // Then I iterated over the LayoutDescriptors' ViewElementDescriptors' + // AttributeDescriptors, and basically diffed the two: any attribute descriptor name + // which was *not* found in any of the representative layouts is added here + // as an advanced property. + // + // Then I manually edited in some attributes that were referenced in the sample + // layouts but which I still consider to be advanced: + // -- nothing right now + + // I also manually *removed* some entries from the below list: + // drawableBottom (the others, drawableTop, drawableLeft and drawableRight were all + // NOT on the list so keep bottom off for symmetry) + // rating (useful when you deal with a RatingsBar component) + + + // Automatically generated, see above: + sAdvanced.add("alwaysDrawnWithCache"); + sAdvanced.add("animationCache"); + sAdvanced.add("animationDuration"); + sAdvanced.add("animationResolution"); + sAdvanced.add("baseline"); + sAdvanced.add("bufferType"); + sAdvanced.add("calendarViewShown"); + sAdvanced.add("completionHint"); + sAdvanced.add("completionHintView"); + sAdvanced.add("completionThreshold"); + sAdvanced.add("cursorVisible"); + sAdvanced.add("dateTextAppearance"); + sAdvanced.add("dial"); + sAdvanced.add("digits"); + sAdvanced.add("disableChildrenWhenDisabled"); + sAdvanced.add("disabledAlpha"); + sAdvanced.add("drawableAlpha"); + sAdvanced.add("drawableEnd"); + sAdvanced.add("drawableStart"); + sAdvanced.add("drawingCacheQuality"); + sAdvanced.add("dropDownAnchor"); + sAdvanced.add("dropDownHeight"); + sAdvanced.add("dropDownHorizontalOffset"); + sAdvanced.add("dropDownSelector"); + sAdvanced.add("dropDownVerticalOffset"); + sAdvanced.add("dropDownWidth"); + sAdvanced.add("editorExtras"); + sAdvanced.add("ems"); + sAdvanced.add("endYear"); + sAdvanced.add("eventsInterceptionEnabled"); + sAdvanced.add("fadeDuration"); + sAdvanced.add("fadeEnabled"); + sAdvanced.add("fadeOffset"); + sAdvanced.add("fadeScrollbars"); + sAdvanced.add("filterTouchesWhenObscured"); + sAdvanced.add("firstDayOfWeek"); + sAdvanced.add("flingable"); + sAdvanced.add("focusedMonthDateColor"); + sAdvanced.add("foregroundInsidePadding"); + sAdvanced.add("format"); + sAdvanced.add("gestureColor"); + sAdvanced.add("gestureStrokeAngleThreshold"); + sAdvanced.add("gestureStrokeLengthThreshold"); + sAdvanced.add("gestureStrokeSquarenessThreshold"); + sAdvanced.add("gestureStrokeType"); + sAdvanced.add("gestureStrokeWidth"); + sAdvanced.add("hand_hour"); + sAdvanced.add("hand_minute"); + sAdvanced.add("hapticFeedbackEnabled"); + sAdvanced.add("id"); + sAdvanced.add("imeActionId"); + sAdvanced.add("imeActionLabel"); + sAdvanced.add("indeterminateDrawable"); + sAdvanced.add("indeterminateDuration"); + sAdvanced.add("inputMethod"); + sAdvanced.add("interpolator"); + sAdvanced.add("isScrollContainer"); + sAdvanced.add("keepScreenOn"); + sAdvanced.add("layerType"); + sAdvanced.add("layoutDirection"); + sAdvanced.add("maxDate"); + sAdvanced.add("minDate"); + sAdvanced.add("mode"); + sAdvanced.add("numeric"); + sAdvanced.add("paddingEnd"); + sAdvanced.add("paddingStart"); + sAdvanced.add("persistentDrawingCache"); + sAdvanced.add("phoneNumber"); + sAdvanced.add("popupBackground"); + sAdvanced.add("popupPromptView"); + sAdvanced.add("privateImeOptions"); + sAdvanced.add("quickContactWindowSize"); + //sAdvanced.add("rating"); + sAdvanced.add("requiresFadingEdge"); + sAdvanced.add("rotation"); + sAdvanced.add("rotationX"); + sAdvanced.add("rotationY"); + sAdvanced.add("saveEnabled"); + sAdvanced.add("scaleX"); + sAdvanced.add("scaleY"); + sAdvanced.add("scrollX"); + sAdvanced.add("scrollY"); + sAdvanced.add("scrollbarAlwaysDrawHorizontalTrack"); + sAdvanced.add("scrollbarDefaultDelayBeforeFade"); + sAdvanced.add("scrollbarFadeDuration"); + sAdvanced.add("scrollbarSize"); + sAdvanced.add("scrollbarThumbHorizontal"); + sAdvanced.add("scrollbarThumbVertical"); + sAdvanced.add("scrollbarTrackHorizontal"); + sAdvanced.add("scrollbarTrackVertical"); + sAdvanced.add("secondaryProgress"); + sAdvanced.add("selectedDateVerticalBar"); + sAdvanced.add("selectedWeekBackgroundColor"); + sAdvanced.add("selectionDivider"); + sAdvanced.add("selectionDividerHeight"); + sAdvanced.add("showWeekNumber"); + sAdvanced.add("shownWeekCount"); + sAdvanced.add("solidColor"); + sAdvanced.add("soundEffectsEnabled"); + sAdvanced.add("spinnerMode"); + sAdvanced.add("spinnersShown"); + sAdvanced.add("startYear"); + sAdvanced.add("switchMinWidth"); + sAdvanced.add("switchPadding"); + sAdvanced.add("switchTextAppearance"); + sAdvanced.add("textColorHighlight"); + sAdvanced.add("textCursorDrawable"); + sAdvanced.add("textDirection"); + sAdvanced.add("textEditNoPasteWindowLayout"); + sAdvanced.add("textEditPasteWindowLayout"); + sAdvanced.add("textEditSideNoPasteWindowLayout"); + sAdvanced.add("textEditSidePasteWindowLayout"); + sAdvanced.add("textEditSuggestionItemLayout"); + sAdvanced.add("textIsSelectable"); + sAdvanced.add("textOff"); + sAdvanced.add("textOn"); + sAdvanced.add("textScaleX"); + sAdvanced.add("textSelectHandle"); + sAdvanced.add("textSelectHandleLeft"); + sAdvanced.add("textSelectHandleRight"); + sAdvanced.add("thumbOffset"); + sAdvanced.add("thumbTextPadding"); + sAdvanced.add("tint"); + sAdvanced.add("track"); + sAdvanced.add("transformPivotX"); + sAdvanced.add("transformPivotY"); + sAdvanced.add("translationX"); + sAdvanced.add("translationY"); + sAdvanced.add("uncertainGestureColor"); + sAdvanced.add("unfocusedMonthDateColor"); + sAdvanced.add("unselectedAlpha"); + sAdvanced.add("verticalScrollbarPosition"); + sAdvanced.add("weekDayTextAppearance"); + sAdvanced.add("weekNumberColor"); + sAdvanced.add("weekSeparatorLineColor"); + + assert sAdvanced.size() == ADVANCED_MAP_SIZE : sAdvanced.size(); + + } + + private static final int PREFERRED_MAP_SIZE = 7; + private static final Set<String> sPreferred = new HashSet<String>(PREFERRED_MAP_SIZE); + static { + // Manual registrations of attributes that should be treated as preferred if + // they are available on a widget even if they don't show up in the top 10% of + // usages (which the view metadata provides) + sPreferred.add(ATTR_TEXT); + sPreferred.add(ATTR_CONTENT_DESCRIPTION); + sPreferred.add(ATTR_HINT); + sPreferred.add("indeterminate"); + sPreferred.add("progress"); + sPreferred.add("rating"); + sPreferred.add("max"); + assert sPreferred.size() == PREFERRED_MAP_SIZE : sPreferred.size(); + } + + /* + private static final int CATEGORY_MAP_SIZE = 62; + private static final Map<String, String> sCategories = + new HashMap<String, String>(CATEGORY_MAP_SIZE); + static { + sCategories.put("requiresFadingEdge", "Scrolling"); + sCategories.put("fadingEdgeLength", "Scrolling"); + sCategories.put("scrollbarSize", "Scrolling"); + sCategories.put("scrollbarThumbVertical", "Scrolling"); + sCategories.put("scrollbarThumbHorizontal", "Scrolling"); + sCategories.put("scrollbarTrackHorizontal", "Scrolling"); + sCategories.put("scrollbarTrackVertical", "Scrolling"); + sCategories.put("scrollbarAlwaysDrawHorizontalTrack", "Scrolling"); + sCategories.put("scrollbarAlwaysDrawVerticalTrack", "Scrolling"); + sCategories.put("scrollViewStyle", "Scrolling"); + sCategories.put("scrollbars", "Scrolling"); + sCategories.put("scrollingCache", "Scrolling"); + sCategories.put("scrollHorizontally", "Scrolling"); + sCategories.put("scrollbarFadeDuration", "Scrolling"); + sCategories.put("scrollbarDefaultDelayBeforeFade", "Scrolling"); + sCategories.put("fastScrollEnabled", "Scrolling"); + sCategories.put("smoothScrollbar", "Scrolling"); + sCategories.put("isScrollContainer", "Scrolling"); + sCategories.put("fadeScrollbars", "Scrolling"); + sCategories.put("overScrollMode", "Scrolling"); + sCategories.put("overScrollHeader", "Scrolling"); + sCategories.put("overScrollFooter", "Scrolling"); + sCategories.put("verticalScrollbarPosition", "Scrolling"); + sCategories.put("fastScrollAlwaysVisible", "Scrolling"); + sCategories.put("fastScrollThumbDrawable", "Scrolling"); + sCategories.put("fastScrollPreviewBackgroundLeft", "Scrolling"); + sCategories.put("fastScrollPreviewBackgroundRight", "Scrolling"); + sCategories.put("fastScrollTrackDrawable", "Scrolling"); + sCategories.put("fastScrollOverlayPosition", "Scrolling"); + sCategories.put("horizontalScrollViewStyle", "Scrolling"); + sCategories.put("fastScrollTextColor", "Scrolling"); + sCategories.put("scrollbarSize", "Scrolling"); + sCategories.put("scrollbarSize", "Scrolling"); + sCategories.put("scrollbarSize", "Scrolling"); + sCategories.put("scrollbarSize", "Scrolling"); + sCategories.put("scrollbarSize", "Scrolling"); + + // TODO: All the styles: radioButtonStyle, ratingBarStyle, progressBarStyle, ... + + sCategories.put("focusable", "Focus"); + sCategories.put("focusableInTouchMode", "Focus"); + sCategories.put("nextFocusLeft", "Focus"); + sCategories.put("nextFocusRight", "Focus"); + sCategories.put("nextFocusUp", "Focus"); + sCategories.put("nextFocusDown", "Focus"); + sCategories.put("descendantFocusability", "Focus"); + sCategories.put("selectAllOnFocus", "Focus"); + sCategories.put("nextFocusForward", "Focus"); + sCategories.put("colorFocusedHighlight", "Focus"); + + sCategories.put("rotation", "Transforms"); + sCategories.put("scrollX", "Transforms"); + sCategories.put("scrollY", "Transforms"); + sCategories.put("rotationX", "Transforms"); + sCategories.put("rotationY", "Transforms"); + sCategories.put("transformPivotX", "Transforms"); + sCategories.put("transformPivotY", "Transforms"); + sCategories.put("translationX", "Transforms"); + sCategories.put("translationY", "Transforms"); + sCategories.put("scaleX", "Transforms"); + sCategories.put("scaleY", "Transforms"); + + sCategories.put("width", "Size"); + sCategories.put("height", "Size"); + sCategories.put("minWidth", "Size"); + sCategories.put("minHeight", "Size"); + + sCategories.put("longClickable", "Clicks"); + sCategories.put("onClick", "Clicks"); + sCategories.put("clickable", "Clicks"); + sCategories.put("hapticFeedbackEnabled", "Clicks"); + + sCategories.put("duplicateParentState", "State"); + sCategories.put("addStatesFromChildren", "State"); + + assert sCategories.size() == CATEGORY_MAP_SIZE : sCategories.size(); + } + */ + +// private static final int PRIO_CLZ_LAYOUT = 1000; +// private static final int PRIO_CLZ_TEXT = 2000; +// private static final int PRIO_CLZ_DRAWABLE = 3000; +// private static final int PRIO_CLZ_ANIMATION = 4000; +// private static final int PRIO_CLZ_FOCUS = 5000; +// +// private static final int PRIORITY_MAP_SIZE = 100; +// private static final Map<String, Integer> sPriorities = +// new HashMap<String, Integer>(PRIORITY_MAP_SIZE); +// static { +// // TODO: I should put all the properties roughly based on their original order: this +// // will correspond to the rough order they came in with +// // TODO: How can I make similar complex properties show up adjacent; e.g. min and max +// sPriorities.put("min", PRIO_CLZ_LAYOUT); +// sPriorities.put("max", PRIO_CLZ_LAYOUT); +// +// assert sPriorities.size() == PRIORITY_MAP_SIZE : sPriorities.size(); +// } + + // TODO: Emit metadata into a file +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertySheetPage.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertySheetPage.java new file mode 100644 index 000000000..58fddc0ee --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertySheetPage.java @@ -0,0 +1,403 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.properties.PropertyFactory.SortingMode; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.IUiUpdateListener; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IMenuListener; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.jface.action.IToolBarManager; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.StructuredSelection; +import org.eclipse.jface.viewers.TreeSelection; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.MenuItem; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.IWorkbenchPart; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.part.Page; +import org.eclipse.ui.views.properties.IPropertySheetPage; +import org.eclipse.wb.internal.core.editor.structure.IPage; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.table.IPropertyExceptionHandler; +import org.eclipse.wb.internal.core.model.property.table.PropertyTable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +/** + * Property sheet page used when the graphical layout editor is chosen + */ +public class PropertySheetPage extends Page + implements IPropertySheetPage, IUiUpdateListener, IPage { + private PropertyTable mPropertyTable; + private final GraphicalEditorPart mEditor; + private Property mActiveProperty; + private Action mDefaultValueAction; + private Action mShowAdvancedPropertiesAction; + private Action mSortAlphaAction; + private Action mCollapseAll; + private Action mExpandAll; + private List<CanvasViewInfo> mSelection; + + private static final String EXPAND_DISABLED_ICON = "expandall-disabled"; //$NON-NLS-1$ + private static final String EXPAND_ICON = "expandall"; //$NON-NLS-1$ + private static final String DEFAULT_ICON = "properties_default"; //$NON-NLS-1$ + private static final String ADVANCED_ICON = "filter_advanced_properties"; //$NON-NLS-1$ + private static final String ALPHA_ICON = "sort_alpha"; //$NON-NLS-1$ + // TODO: goto-definition.png + + /** + * Constructs a new {@link PropertySheetPage} associated with the given + * editor + * + * @param editor the editor associated with this property sheet page + */ + public PropertySheetPage(GraphicalEditorPart editor) { + mEditor = editor; + } + + private PropertyFactory getPropertyFactory() { + return mEditor.getPropertyFactory(); + } + + @Override + public void createControl(Composite parent) { + assert parent != null; + mPropertyTable = new PropertyTable(parent, SWT.NONE); + mPropertyTable.setExceptionHandler(new IPropertyExceptionHandler() { + @Override + public void handle(Throwable e) { + AdtPlugin.log(e, null); + } + }); + mPropertyTable.setDefaultCollapsedNames(Arrays.asList( + "Deprecated", + "Layout Parameters", + "Layout Parameters|Margins")); + + createActions(); + setPropertyTableContextMenu(); + } + + @Override + public void selectionChanged(IWorkbenchPart part, ISelection selection) { + if (selection instanceof TreeSelection + && mPropertyTable != null && !mPropertyTable.isDisposed()) { + TreeSelection treeSelection = (TreeSelection) selection; + + // We get a lot of repeated selection requests for the same selection + // as before, so try to eliminate these + if (mSelection != null) { + if (mSelection.isEmpty()) { + if (treeSelection.isEmpty()) { + return; + } + } else { + int selectionCount = treeSelection.size(); + if (selectionCount == mSelection.size()) { + boolean same = true; + Iterator<?> iterator = treeSelection.iterator(); + for (int i = 0, n = selectionCount; i < n && iterator.hasNext(); i++) { + Object next = iterator.next(); + if (next instanceof CanvasViewInfo) { + CanvasViewInfo info = (CanvasViewInfo) next; + if (info != mSelection.get(i)) { + same = false; + break; + } + } else { + same = false; + break; + } + } + if (same) { + return; + } + } + } + } + + stopTrackingSelection(); + + if (treeSelection.isEmpty()) { + mSelection = Collections.emptyList(); + } else { + int selectionCount = treeSelection.size(); + List<CanvasViewInfo> newSelection = new ArrayList<CanvasViewInfo>(selectionCount); + Iterator<?> iterator = treeSelection.iterator(); + while (iterator.hasNext()) { + Object next = iterator.next(); + if (next instanceof CanvasViewInfo) { + CanvasViewInfo info = (CanvasViewInfo) next; + newSelection.add(info); + } + } + mSelection = newSelection; + } + + startTrackingSelection(); + + refreshProperties(); + } + } + + @Override + public void dispose() { + stopTrackingSelection(); + super.dispose(); + } + + private void startTrackingSelection() { + if (mSelection != null && !mSelection.isEmpty()) { + for (CanvasViewInfo item : mSelection) { + UiViewElementNode node = item.getUiViewNode(); + if (node != null) { + node.addUpdateListener(this); + } + } + } + } + + private void stopTrackingSelection() { + if (mSelection != null && !mSelection.isEmpty()) { + for (CanvasViewInfo item : mSelection) { + UiViewElementNode node = item.getUiViewNode(); + if (node != null) { + node.removeUpdateListener(this); + } + } + } + mSelection = null; + } + + // Implements IUiUpdateListener + @Override + public void uiElementNodeUpdated(UiElementNode node, UiUpdateState state) { + refreshProperties(); + } + + @Override + public Control getControl() { + return mPropertyTable; + } + + @Override + public void setFocus() { + mPropertyTable.setFocus(); + } + + @Override + public void makeContributions(IMenuManager menuManager, + IToolBarManager toolBarManager, IStatusLineManager statusLineManager) { + toolBarManager.add(mShowAdvancedPropertiesAction); + toolBarManager.add(new Separator()); + toolBarManager.add(mSortAlphaAction); + toolBarManager.add(new Separator()); + toolBarManager.add(mDefaultValueAction); + toolBarManager.add(new Separator()); + toolBarManager.add(mExpandAll); + toolBarManager.add(mCollapseAll); + toolBarManager.add(new Separator()); + } + + private void createActions() { + ISharedImages sharedImages = PlatformUI.getWorkbench().getSharedImages(); + IconFactory iconFactory = IconFactory.getInstance(); + + mExpandAll = new PropertySheetAction( + IAction.AS_PUSH_BUTTON, + "Expand All", + ACTION_EXPAND, + iconFactory.getImageDescriptor(EXPAND_ICON), + iconFactory.getImageDescriptor(EXPAND_DISABLED_ICON)); + + mCollapseAll = new PropertySheetAction( + IAction.AS_PUSH_BUTTON, + "Collapse All", + ACTION_COLLAPSE, + sharedImages.getImageDescriptor(ISharedImages.IMG_ELCL_COLLAPSEALL), + sharedImages.getImageDescriptor(ISharedImages.IMG_ELCL_COLLAPSEALL_DISABLED)); + + mShowAdvancedPropertiesAction = new PropertySheetAction( + IAction.AS_CHECK_BOX, + "Show Advanced Properties", + ACTION_SHOW_ADVANCED, + iconFactory.getImageDescriptor(ADVANCED_ICON), + null); + + mSortAlphaAction = new PropertySheetAction( + IAction.AS_CHECK_BOX, + "Sort Alphabetically", + ACTION_SORT_ALPHA, + iconFactory.getImageDescriptor(ALPHA_ICON), + null); + + mDefaultValueAction = new PropertySheetAction( + IAction.AS_PUSH_BUTTON, + "Restore Default Value", + ACTION_DEFAULT_VALUE, + iconFactory.getImageDescriptor(DEFAULT_ICON), + null); + + // Listen on the selection in the property sheet so we can update the + // Restore Default Value action + ISelectionChangedListener listener = new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + StructuredSelection selection = (StructuredSelection) event.getSelection(); + mActiveProperty = (Property) selection.getFirstElement(); + updateDefaultValueAction(); + } + }; + mPropertyTable.addSelectionChangedListener(listener); + } + + /** + * Updates the state of {@link #mDefaultValueAction}. + */ + private void updateDefaultValueAction() { + if (mActiveProperty != null) { + try { + mDefaultValueAction.setEnabled(mActiveProperty.isModified()); + } catch (Exception e) { + AdtPlugin.log(e, null); + } + } else { + mDefaultValueAction.setEnabled(false); + } + } + + /** + * Sets the context menu for {@link #mPropertyTable}. + */ + private void setPropertyTableContextMenu() { + final MenuManager manager = new MenuManager(); + manager.setRemoveAllWhenShown(true); + manager.addMenuListener(new IMenuListener() { + @Override + public void menuAboutToShow(IMenuManager m) { + // dispose items to avoid caching + for (MenuItem item : manager.getMenu().getItems()) { + item.dispose(); + } + // apply new items + fillContextMenu(); + } + + private void fillContextMenu() { + manager.add(mDefaultValueAction); + manager.add(mSortAlphaAction); + manager.add(mShowAdvancedPropertiesAction); + } + }); + + mPropertyTable.setMenu(manager.createContextMenu(mPropertyTable)); + } + + /** + * Shows {@link Property}'s of current objects. + */ + private void refreshProperties() { + PropertyFactory factory = getPropertyFactory(); + mPropertyTable.setInput(factory.getProperties(mSelection)); + updateDefaultValueAction(); + } + + // ---- Actions ---- + + private static final int ACTION_DEFAULT_VALUE = 1; + private static final int ACTION_SHOW_ADVANCED = 2; + private static final int ACTION_COLLAPSE = 3; + private static final int ACTION_EXPAND = 4; + private static final int ACTION_SORT_ALPHA = 5; + + private class PropertySheetAction extends Action { + private final int mAction; + + private PropertySheetAction(int style, String label, int action, + ImageDescriptor imageDesc, ImageDescriptor disabledImageDesc) { + super(label, style); + mAction = action; + setImageDescriptor(imageDesc); + if (disabledImageDesc != null) { + setDisabledImageDescriptor(disabledImageDesc); + } + setToolTipText(label); + } + + @Override + public void run() { + switch (mAction) { + case ACTION_COLLAPSE: { + mPropertyTable.collapseAll(); + break; + } + case ACTION_EXPAND: { + mPropertyTable.expandAll(); + break; + } + case ACTION_SHOW_ADVANCED: { + boolean show = mShowAdvancedPropertiesAction.isChecked(); + mPropertyTable.setShowAdvancedProperties(show); + break; + } + case ACTION_SORT_ALPHA: { + boolean isAlphabetical = mSortAlphaAction.isChecked(); + getPropertyFactory().setSortingMode( + isAlphabetical ? SortingMode.ALPHABETICAL : PropertyFactory.DEFAULT_MODE); + refreshProperties(); + break; + } + case ACTION_DEFAULT_VALUE: + try { + mActiveProperty.setValue(Property.UNKNOWN_VALUE); + } catch (Exception e) { + // Ignore warnings from setters + } + break; + default: + assert false : mAction; + } + } + } + + @Override + public void setToolBar(IToolBarManager toolBarManager) { + makeContributions(null, toolBarManager, null); + toolBarManager.update(false); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyValueCompleter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyValueCompleter.java new file mode 100644 index 000000000..f2bf07312 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/PropertyValueCompleter.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; + +class PropertyValueCompleter extends ValueCompleter { + private final XmlProperty mProperty; + + PropertyValueCompleter(XmlProperty property) { + mProperty = property; + } + + @Override + @Nullable + protected CommonXmlEditor getEditor() { + return mProperty.getXmlEditor(); + } + + @Override + @NonNull + protected AttributeDescriptor getDescriptor() { + return mProperty.getDescriptor(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/ResourceValueCompleter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/ResourceValueCompleter.java new file mode 100644 index 000000000..081ec8069 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/ResourceValueCompleter.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import static com.android.SdkConstants.ANDROID_PKG; +import static com.android.SdkConstants.ANDROID_PREFIX; +import static com.android.SdkConstants.ANDROID_THEME_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.PREFIX_THEME_REF; + +import com.android.ide.common.resources.ResourceItem; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiResourceAttributeNode; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.resources.ResourceType; +import com.android.utils.SdkUtils; + +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.fieldassist.ContentProposal; +import org.eclipse.jface.fieldassist.IContentProposal; +import org.eclipse.jface.fieldassist.IContentProposalProvider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Resource value completion for the given property + * <p> + * TODO: + * <ul> + * <li>also offer other values seen in the app + * <li>also offer previously set values for this property + * <li>also complete on properties + * </ul> + */ +class ResourceValueCompleter implements IContentProposalProvider { + protected final XmlProperty xmlProperty; + + ResourceValueCompleter(XmlProperty xmlProperty) { + this.xmlProperty = xmlProperty; + } + + @Override + public IContentProposal[] getProposals(String contents, int position) { + if (contents.startsWith(PREFIX_RESOURCE_REF)) { + CommonXmlEditor editor = this.xmlProperty.getXmlEditor(); + if (editor != null) { + String[] matches = computeResourceStringMatches( + editor, + this.xmlProperty.mDescriptor, contents.substring(0, position)); + List<IContentProposal> proposals = null; + if (matches != null && matches.length > 0) { + proposals = new ArrayList<IContentProposal>(matches.length); + for (String match : matches) { + proposals.add(new ContentProposal(match)); + } + return proposals.toArray(new IContentProposal[proposals.size()]); + } + } + } + + return new IContentProposal[0]; + } + + /** + * Similar to {@link UiResourceAttributeNode#computeResourceStringMatches} + * but computes complete results up front rather than dividing it up into + * smaller chunks like @{code @android:}, {@code string/}, and {@code ok}. + */ + static String[] computeResourceStringMatches(AndroidXmlEditor editor, + AttributeDescriptor attributeDescriptor, String prefix) { + List<String> results = new ArrayList<String>(200); + + // System matches: only do this if the value already matches at least @a, + // and doesn't start with something that can't possibly be @android + if (prefix.startsWith("@a") && //$NON-NLS-1$ + prefix.regionMatches(true /* ignoreCase */, 0, ANDROID_PREFIX, 0, + Math.min(prefix.length() - 1, ANDROID_PREFIX.length()))) { + AndroidTargetData data = editor.getTargetData(); + if (data != null) { + ResourceRepository repository = data.getFrameworkResources(); + addMatches(repository, prefix, true /* isSystem */, results); + } + } else if (prefix.startsWith("?") && //$NON-NLS-1$ + prefix.regionMatches(true /* ignoreCase */, 0, ANDROID_THEME_PREFIX, 0, + Math.min(prefix.length() - 1, ANDROID_THEME_PREFIX.length()))) { + AndroidTargetData data = editor.getTargetData(); + if (data != null) { + ResourceRepository repository = data.getFrameworkResources(); + addMatches(repository, prefix, true /* isSystem */, results); + } + } + + + // When completing project resources skip framework resources unless + // the prefix possibly completes both, such as "@an" which can match + // both the project resource @animator as well as @android:string + if (!prefix.startsWith("@and") && !prefix.startsWith("?and")) { //$NON-NLS-1$ //$NON-NLS-2$ + IProject project = editor.getProject(); + if (project != null) { + // get the resource repository for this project and the system resources. + ResourceManager manager = ResourceManager.getInstance(); + ResourceRepository repository = manager.getProjectResources(project); + if (repository != null) { + // We have a style name and a repository. Find all resources that match this + // type and recreate suggestions out of them. + addMatches(repository, prefix, false /* isSystem */, results); + } + + } + } + + if (attributeDescriptor != null) { + UiResourceAttributeNode.sortAttributeChoices(attributeDescriptor, results); + } else { + Collections.sort(results); + } + + return results.toArray(new String[results.size()]); + } + + private static void addMatches(ResourceRepository repository, String prefix, boolean isSystem, + List<String> results) { + int typeStart = isSystem + ? ANDROID_PREFIX.length() : PREFIX_RESOURCE_REF.length(); + + for (ResourceType type : repository.getAvailableResourceTypes()) { + if (prefix.regionMatches(typeStart, type.getName(), 0, + Math.min(type.getName().length(), prefix.length() - typeStart))) { + StringBuilder sb = new StringBuilder(); + if (prefix.length() == 0 || prefix.startsWith(PREFIX_RESOURCE_REF)) { + sb.append(PREFIX_RESOURCE_REF); + } else { + if (type != ResourceType.ATTR) { + continue; + } + sb.append(PREFIX_THEME_REF); + } + + if (type == ResourceType.ID && prefix.startsWith(NEW_ID_PREFIX)) { + sb.append('+'); + } + + if (isSystem) { + sb.append(ANDROID_PKG).append(':'); + } + + sb.append(type.getName()).append('/'); + String base = sb.toString(); + + int nameStart = typeStart + type.getName().length() + 1; // +1: add "/" divider + String namePrefix = + prefix.length() <= nameStart ? "" : prefix.substring(nameStart); + for (ResourceItem item : repository.getResourceItemsOfType(type)) { + String name = item.getName(); + if (SdkUtils.startsWithIgnoreCase(name, namePrefix)) { + results.add(base + name); + } + } + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/StringXmlPropertyDialog.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/StringXmlPropertyDialog.java new file mode 100644 index 000000000..fb7e45902 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/StringXmlPropertyDialog.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.editor.string.StringPropertyDialog; + +class StringXmlPropertyDialog extends StringPropertyDialog { + StringXmlPropertyDialog(Shell parentShell, Property property) throws Exception { + super(parentShell, property); + } + + @Override + protected boolean isMultiLine() { + return false; + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + + Composite workaround = PropertyFactory.addWorkaround(area); + if (workaround != null) { + workaround.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 1, 1)); + } + + return area; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/ValueCompleter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/ValueCompleter.java new file mode 100644 index 000000000..5559349fc --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/ValueCompleter.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import static com.android.SdkConstants.ATTR_TEXT_SIZE; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.PREFIX_THEME_REF; +import static com.android.SdkConstants.UNIT_DP; +import static com.android.SdkConstants.UNIT_SP; +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.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.INTEGER; +import static com.android.ide.common.api.IAttributeInfo.Format.REFERENCE; +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.api.IAttributeInfo.Format; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.utils.SdkUtils; + +import org.eclipse.jface.fieldassist.ContentProposal; +import org.eclipse.jface.fieldassist.IContentProposal; +import org.eclipse.jface.fieldassist.IContentProposalProvider; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +/** + * An {@link IContentProposalProvider} which completes possible property values + * for Android properties, completing resource strings, flag values, enum + * values, as well as dimension units. + */ +abstract class ValueCompleter implements IContentProposalProvider { + @Nullable + protected abstract CommonXmlEditor getEditor(); + + @NonNull + protected abstract AttributeDescriptor getDescriptor(); + + @Override + public IContentProposal[] getProposals(String contents, int position) { + AttributeDescriptor descriptor = getDescriptor(); + IAttributeInfo info = descriptor.getAttributeInfo(); + EnumSet<Format> formats = info.getFormats(); + + List<IContentProposal> proposals = new ArrayList<IContentProposal>(); + + String prefix = contents; // TODO: Go back to position inside the array? + + // TODO: If the user is typing in a number, or a number plus a prefix of a dimension unit, + // then propose that number plus the completed dimension unit (using sp for text, dp + // for other properties and maybe both if I'm not sure) + if (formats.contains(STRING) + && !contents.isEmpty() + && (formats.size() > 1 && formats.contains(REFERENCE) || + formats.size() > 2) + && !contents.startsWith(PREFIX_RESOURCE_REF) + && !contents.startsWith(PREFIX_THEME_REF)) { + proposals.add(new ContentProposal(contents)); + } + + if (!contents.isEmpty() && Character.isDigit(contents.charAt(0)) + && (formats.contains(DIMENSION) + || formats.contains(INTEGER) + || formats.contains(FLOAT))) { + StringBuilder sb = new StringBuilder(); + for (int i = 0, n = contents.length(); i < n; i++) { + char c = contents.charAt(i); + if (Character.isDigit(c)) { + sb.append(c); + } else { + break; + } + } + + String number = sb.toString(); + if (formats.contains(Format.DIMENSION)) { + if (descriptor.getXmlLocalName().equals(ATTR_TEXT_SIZE)) { + proposals.add(new ContentProposal(number + UNIT_SP)); + } + proposals.add(new ContentProposal(number + UNIT_DP)); + } else if (formats.contains(Format.INTEGER)) { + proposals.add(new ContentProposal(number)); + } + // Perhaps offer other units too -- see AndroidContentAssist.sDimensionUnits + } + + if (formats.contains(REFERENCE) || contents.startsWith(PREFIX_RESOURCE_REF) + || contents.startsWith(PREFIX_THEME_REF)) { + CommonXmlEditor editor = getEditor(); + if (editor != null) { + String[] matches = ResourceValueCompleter.computeResourceStringMatches( + editor, + descriptor, contents.substring(0, position)); + for (String match : matches) { + proposals.add(new ContentProposal(match)); + } + } + } + + if (formats.contains(FLAG)) { + String[] values = info.getFlagValues(); + if (values != null) { + // Flag completion + int flagStart = prefix.lastIndexOf('|'); + String prepend = null; + if (flagStart != -1) { + prepend = prefix.substring(0, flagStart + 1); + prefix = prefix.substring(flagStart + 1).trim(); + } + + boolean exactMatch = false; + for (String value : values) { + if (prefix.equals(value)) { + exactMatch = true; + proposals.add(new ContentProposal(contents)); + + break; + } + } + + if (exactMatch) { + prepend = contents + '|'; + prefix = ""; + } + + for (String value : values) { + if (SdkUtils.startsWithIgnoreCase(value, prefix)) { + if (prepend != null && prepend.contains(value)) { + continue; + } + String match; + if (prepend != null) { + match = prepend + value; + } else { + match = value; + } + proposals.add(new ContentProposal(match)); + } + } + } + } else if (formats.contains(ENUM)) { + String[] values = info.getEnumValues(); + if (values != null) { + for (String value : values) { + if (SdkUtils.startsWithIgnoreCase(value, prefix)) { + proposals.add(new ContentProposal(value)); + } + } + + for (String value : values) { + if (!SdkUtils.startsWithIgnoreCase(value, prefix)) { + proposals.add(new ContentProposal(value)); + } + } + } + } else if (formats.contains(BOOLEAN)) { + proposals.add(new ContentProposal(VALUE_TRUE)); + proposals.add(new ContentProposal(VALUE_FALSE)); + } + + return proposals.toArray(new IContentProposal[proposals.size()]); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlProperty.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlProperty.java new file mode 100644 index 000000000..a320b682d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlProperty.java @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.ViewHierarchy; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; + +import org.eclipse.jface.fieldassist.IContentProposal; +import org.eclipse.jface.fieldassist.IContentProposalProvider; +import org.eclipse.jface.viewers.ILabelProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.graphics.Image; +import org.eclipse.ui.views.properties.IPropertyDescriptor; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.editor.PropertyEditor; +import org.eclipse.wb.internal.core.model.property.table.PropertyTooltipProvider; +import org.eclipse.wb.internal.core.model.property.table.PropertyTooltipTextProvider; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; + +import java.util.Map; + +/** + * An Android XML property + */ +class XmlProperty extends Property { + private PropertyFactory mFactory; + final AttributeDescriptor mDescriptor; + private UiViewElementNode mNode; + private Property mParent; + + XmlProperty( + @NonNull PropertyEditor editor, + @NonNull PropertyFactory factory, + @NonNull UiViewElementNode node, + @NonNull AttributeDescriptor descriptor) { + super(editor); + mFactory = factory; + mNode = node; + mDescriptor = descriptor; + } + + @NonNull + public PropertyFactory getFactory() { + return mFactory; + } + + @NonNull + public UiViewElementNode getNode() { + return mNode; + } + + @NonNull + public AttributeDescriptor getDescriptor() { + return mDescriptor; + } + + @Override + @NonNull + public String getName() { + return mDescriptor.getXmlLocalName(); + } + + @Override + @NonNull + public String getTitle() { + String name = mDescriptor.getXmlLocalName(); + int nameLength = name.length(); + + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + if (name.startsWith(ATTR_LAYOUT_MARGIN) + && nameLength > ATTR_LAYOUT_MARGIN.length()) { + name = name.substring(ATTR_LAYOUT_MARGIN.length()); + } else { + name = name.substring(ATTR_LAYOUT_RESOURCE_PREFIX.length()); + } + } + + // Capitalize + name = DescriptorsUtils.capitalize(name); + + // If we're nested within a complex property, say "Line Spacing", don't + // include "Line Spacing " as a prefix for each property here + if (mParent != null) { + String parentTitle = mParent.getTitle(); + if (name.startsWith(parentTitle)) { + int parentTitleLength = parentTitle.length(); + if (parentTitleLength < nameLength) { + if (nameLength > parentTitleLength && + Character.isWhitespace(name.charAt(parentTitleLength))) { + parentTitleLength++; + } + name = name.substring(parentTitleLength); + } + } + } + + return name; + } + + @Override + public <T> T getAdapter(Class<T> adapter) { + // tooltip + if (adapter == PropertyTooltipProvider.class) { + return adapter.cast(new PropertyTooltipTextProvider() { + @Override + protected String getText(Property p) throws Exception { + if (mDescriptor instanceof IPropertyDescriptor) { + IPropertyDescriptor d = (IPropertyDescriptor) mDescriptor; + return d.getDescription(); + } + + return null; + } + }); + } else if (adapter == IContentProposalProvider.class) { + IAttributeInfo info = mDescriptor.getAttributeInfo(); + if (info != null) { + return adapter.cast(new PropertyValueCompleter(this)); + } + // Fallback: complete values on resource values + return adapter.cast(new ResourceValueCompleter(this)); + } else if (adapter == ILabelProvider.class) { + return adapter.cast(new LabelProvider() { + @Override + public Image getImage(Object element) { + return AdtPlugin.getAndroidLogo(); + } + + @Override + public String getText(Object element) { + return ((IContentProposal) element).getLabel(); + } + }); + } + return super.getAdapter(adapter); + } + + @Override + public boolean isModified() throws Exception { + Object s = null; + try { + Element element = (Element) mNode.getXmlNode(); + if (element == null) { + return false; + } + String name = mDescriptor.getXmlLocalName(); + String uri = mDescriptor.getNamespaceUri(); + if (uri != null) { + return element.hasAttributeNS(uri, name); + } else { + return element.hasAttribute(name); + } + } catch (Exception e) { + // pass + } + return s != null && s.toString().length() > 0; + } + + @Nullable + public String getStringValue() { + Element element = (Element) mNode.getXmlNode(); + if (element == null) { + return null; + } + String name = mDescriptor.getXmlLocalName(); + String uri = mDescriptor.getNamespaceUri(); + Attr attr; + if (uri != null) { + attr = element.getAttributeNodeNS(uri, name); + } else { + attr = element.getAttributeNode(name); + } + if (attr != null) { + return attr.getValue(); + } + + Object viewObject = getFactory().getCurrentViewObject(); + if (viewObject != null) { + GraphicalEditorPart graphicalEditor = getGraphicalEditor(); + if (graphicalEditor == null) { + return null; + } + ViewHierarchy views = graphicalEditor.getCanvasControl().getViewHierarchy(); + Map<String, String> defaultProperties = views.getDefaultProperties(viewObject); + if (defaultProperties != null) { + return defaultProperties.get(name); + } + } + + return null; + } + + @Override + @Nullable + public Object getValue() throws Exception { + return getStringValue(); + } + + @Override + public void setValue(Object value) throws Exception { + CommonXmlEditor editor = getXmlEditor(); + if (editor == null) { + return; + } + final String attribute = mDescriptor.getXmlLocalName(); + final String xmlValue = value != null && value != UNKNOWN_VALUE ? value.toString() : null; + editor.wrapUndoEditXmlModel( + String.format("Set \"%1$s\" to \"%2$s\"", attribute, xmlValue), + new Runnable() { + @Override + public void run() { + mNode.setAttributeValue(attribute, + mDescriptor.getNamespaceUri(), xmlValue, true /*override*/); + mNode.commitDirtyAttributesToXml(); + } + }); + } + + @Override + @NonNull + public Property getComposite(Property[] properties) { + return XmlPropertyComposite.create(properties); + } + + @Nullable + GraphicalEditorPart getGraphicalEditor() { + return mFactory.getGraphicalEditor(); + } + + @Nullable + CommonXmlEditor getXmlEditor() { + GraphicalEditorPart graphicalEditor = getGraphicalEditor(); + if (graphicalEditor != null) { + return graphicalEditor.getEditorDelegate().getEditor(); + } + + return null; + } + + @Nullable + public Property getParent() { + return mParent; + } + + public void setParent(@Nullable Property parent) { + mParent = parent; + } + + @Override + public String toString() { + return getName() + ":" + getPriority(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlPropertyComposite.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlPropertyComposite.java new file mode 100644 index 000000000..af9e13b3e --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlPropertyComposite.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import com.android.annotations.NonNull; +import com.google.common.base.Objects; + +import org.eclipse.wb.internal.core.model.property.Property; + +import java.util.Arrays; + +/** + * Property holding multiple instances of the same {@link XmlProperty} (but + * bound to difference objects. This is used when multiple objects are selected + * in the layout editor and the common properties are shown; editing a value + * will (via {@link #setValue(Object)}) set it on all selected objects. + * <p> + * Similar to + * org.eclipse.wb.internal.core.model.property.GenericPropertyComposite + */ +class XmlPropertyComposite extends XmlProperty { + private static final Object NO_VALUE = new Object(); + + private final XmlProperty[] mProperties; + + public XmlPropertyComposite(XmlProperty primary, XmlProperty[] properties) { + super( + primary.getEditor(), + primary.getFactory(), + primary.getNode(), + primary.getDescriptor()); + mProperties = properties; + } + + @Override + @NonNull + public String getTitle() { + return mProperties[0].getTitle(); + } + + @Override + public int hashCode() { + return mProperties.length; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + + if (obj instanceof XmlPropertyComposite) { + XmlPropertyComposite property = (XmlPropertyComposite) obj; + return Arrays.equals(mProperties, property.mProperties); + } + + return false; + } + + @Override + public boolean isModified() throws Exception { + for (Property property : mProperties) { + if (property.isModified()) { + return true; + } + } + + return false; + } + + @Override + public Object getValue() throws Exception { + Object value = NO_VALUE; + for (Property property : mProperties) { + Object propertyValue = property.getValue(); + if (value == NO_VALUE) { + value = propertyValue; + } else if (!Objects.equal(value, propertyValue)) { + return UNKNOWN_VALUE; + } + } + + return value; + } + + @Override + public void setValue(final Object value) throws Exception { + // TBD: Wrap in ExecutionUtils.run? + for (Property property : mProperties) { + property.setValue(value); + } + } + + @NonNull + public static XmlPropertyComposite create(Property... properties) { + // Cast from Property into XmlProperty + XmlProperty[] xmlProperties = new XmlProperty[properties.length]; + for (int i = 0; i < properties.length; i++) { + Property property = properties[i]; + xmlProperties[i] = (XmlProperty) property; + } + + XmlPropertyComposite composite = new XmlPropertyComposite(xmlProperties[0], xmlProperties); + composite.setCategory(xmlProperties[0].getCategory()); + return composite; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlPropertyEditor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlPropertyEditor.java new file mode 100644 index 000000000..87fb0e6ed --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/properties/XmlPropertyEditor.java @@ -0,0 +1,548 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.properties; + +import static com.android.SdkConstants.ANDROID_PREFIX; +import static com.android.SdkConstants.ANDROID_THEME_PREFIX; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.DOT_PNG; +import static com.android.SdkConstants.DOT_XML; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.PREFIX_THEME_REF; +import static com.android.ide.common.layout.BaseViewRule.stripIdPrefix; + +import com.android.annotations.NonNull; +import com.android.ide.common.api.IAttributeInfo; +import com.android.ide.common.api.IAttributeInfo.Format; +import com.android.ide.common.layout.BaseViewRule; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.resources.ResourceRepository; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.common.CommonXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.ImageUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.RenderService; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SelectionManager; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.SwtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.NodeProxy; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResourceWizard; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResult; +import com.android.ide.eclipse.adt.internal.resources.ResourceHelper; +import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager; +import com.android.ide.eclipse.adt.internal.ui.ReferenceChooserDialog; +import com.android.ide.eclipse.adt.internal.ui.ResourceChooser; +import com.android.ide.eclipse.adt.internal.ui.ResourcePreviewHelper; +import com.android.resources.ResourceType; +import com.google.common.collect.Maps; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.dialogs.MessageDialogWithToggle; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.wb.draw2d.IColorConstants; +import org.eclipse.wb.internal.core.model.property.Property; +import org.eclipse.wb.internal.core.model.property.editor.AbstractTextPropertyEditor; +import org.eclipse.wb.internal.core.model.property.editor.presentation.ButtonPropertyEditorPresentation; +import org.eclipse.wb.internal.core.model.property.editor.presentation.PropertyEditorPresentation; +import org.eclipse.wb.internal.core.model.property.table.PropertyTable; +import org.eclipse.wb.internal.core.utils.ui.DrawUtils; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import javax.imageio.ImageIO; + +/** + * Special property editor used for the {@link XmlProperty} instances which handles + * editing the XML properties, rendering defaults by looking up the actual colors and images, + */ +class XmlPropertyEditor extends AbstractTextPropertyEditor { + public static final XmlPropertyEditor INSTANCE = new XmlPropertyEditor(); + private static final int SAMPLE_SIZE = 10; + private static final int SAMPLE_MARGIN = 3; + + protected XmlPropertyEditor() { + } + + private final PropertyEditorPresentation mPresentation = + new ButtonPropertyEditorPresentation() { + @Override + protected void onClick(PropertyTable propertyTable, Property property) throws Exception { + openDialog(propertyTable, property); + } + }; + + @Override + public PropertyEditorPresentation getPresentation() { + return mPresentation; + } + + @Override + public String getText(Property property) throws Exception { + Object value = property.getValue(); + if (value instanceof String) { + return (String) value; + } + return null; + } + + @Override + protected String getEditorText(Property property) throws Exception { + return getText(property); + } + + @Override + public void paint(Property property, GC gc, int x, int y, int width, int height) + throws Exception { + String text = getText(property); + if (text != null) { + ResourceValue resValue = null; + String resolvedText = null; + + // TODO: Use the constants for @, ?, @android: etc + if (text.startsWith("@") || text.startsWith("?")) { //$NON-NLS-1$ //$NON-NLS-2$ + // Yes, try to resolve it in order to show better info + XmlProperty xmlProperty = (XmlProperty) property; + GraphicalEditorPart graphicalEditor = xmlProperty.getGraphicalEditor(); + if (graphicalEditor != null) { + ResourceResolver resolver = graphicalEditor.getResourceResolver(); + boolean isFramework = text.startsWith(ANDROID_PREFIX) + || text.startsWith(ANDROID_THEME_PREFIX); + resValue = resolver.findResValue(text, isFramework); + while (resValue != null && resValue.getValue() != null) { + String value = resValue.getValue(); + if (value.startsWith(PREFIX_RESOURCE_REF) + || value.startsWith(PREFIX_THEME_REF)) { + // TODO: do I have to strip off the @ too? + isFramework = isFramework + || value.startsWith(ANDROID_PREFIX) + || value.startsWith(ANDROID_THEME_PREFIX); + ResourceValue v = resolver.findResValue(text, isFramework); + if (v != null && !value.equals(v.getValue())) { + resValue = v; + } else { + break; + } + } else { + break; + } + } + } + } else if (text.startsWith("#") && text.matches("#\\p{XDigit}+")) { //$NON-NLS-1$ + resValue = new ResourceValue(ResourceType.COLOR, property.getName(), text, false); + } + + if (resValue != null && resValue.getValue() != null) { + String value = resValue.getValue(); + // Decide whether it's a color, an image, a nine patch etc + // and decide how to render it + if (value.startsWith("#") || value.endsWith(DOT_XML) //$NON-NLS-1$ + && value.contains("res/color")) { //$NON-NLS-1$ // TBD: File.separator? + XmlProperty xmlProperty = (XmlProperty) property; + GraphicalEditorPart graphicalEditor = xmlProperty.getGraphicalEditor(); + if (graphicalEditor != null) { + ResourceResolver resolver = graphicalEditor.getResourceResolver(); + RGB rgb = ResourceHelper.resolveColor(resolver, resValue); + if (rgb != null) { + Color color = new Color(gc.getDevice(), rgb); + // draw color sample + Color oldBackground = gc.getBackground(); + Color oldForeground = gc.getForeground(); + try { + int width_c = SAMPLE_SIZE; + int height_c = SAMPLE_SIZE; + int x_c = x; + int y_c = y + (height - height_c) / 2; + // update rest bounds + int delta = SAMPLE_SIZE + SAMPLE_MARGIN; + x += delta; + width -= delta; + // fill + gc.setBackground(color); + gc.fillRectangle(x_c, y_c, width_c, height_c); + // draw line + gc.setForeground(IColorConstants.gray); + gc.drawRectangle(x_c, y_c, width_c, height_c); + } finally { + gc.setBackground(oldBackground); + gc.setForeground(oldForeground); + } + color.dispose(); + } + } + } else { + Image swtImage = null; + if (value.endsWith(DOT_XML) && value.contains("res/drawable")) { // TBD: Filesep? + Map<String, Image> cache = getImageCache(property); + swtImage = cache.get(value); + if (swtImage == null) { + XmlProperty xmlProperty = (XmlProperty) property; + GraphicalEditorPart graphicalEditor = xmlProperty.getGraphicalEditor(); + RenderService service = RenderService.create(graphicalEditor); + service.setOverrideRenderSize(SAMPLE_SIZE, SAMPLE_SIZE); + BufferedImage drawable = service.renderDrawable(resValue); + if (drawable != null) { + swtImage = SwtUtils.convertToSwt(gc.getDevice(), drawable, + true /*transferAlpha*/, -1); + cache.put(value, swtImage); + } + } + } else if (value.endsWith(DOT_PNG)) { + // TODO: 9-patch handling? + //if (text.endsWith(DOT_9PNG)) { + // // 9-patch image: How do we paint this? + // URL url = new File(text).toURI().toURL(); + // NinePatch ninepatch = NinePatch.load(url, false /* ?? */); + // BufferedImage image = ninepatch.getImage(); + //} + Map<String, Image> cache = getImageCache(property); + swtImage = cache.get(value); + if (swtImage == null) { + File file = new File(value); + if (file.exists()) { + try { + BufferedImage awtImage = ImageIO.read(file); + if (awtImage != null && awtImage.getWidth() > 0 + && awtImage.getHeight() > 0) { + awtImage = ImageUtils.cropBlank(awtImage, null); + if (awtImage != null) { + // Scale image + int imageWidth = awtImage.getWidth(); + int imageHeight = awtImage.getHeight(); + int maxWidth = 3 * height; + + if (imageWidth > maxWidth || imageHeight > height) { + double scale = height / (double) imageHeight; + int scaledWidth = (int) (imageWidth * scale); + if (scaledWidth > maxWidth) { + scale = maxWidth / (double) imageWidth; + } + awtImage = ImageUtils.scale(awtImage, scale, + scale); + } + swtImage = SwtUtils.convertToSwt(gc.getDevice(), + awtImage, true /*transferAlpha*/, -1); + } + } + } catch (IOException e) { + AdtPlugin.log(e, value); + } + } + cache.put(value, swtImage); + } + + } else if (value != null) { + // It's a normal string: if different from the text, paint + // it in parentheses, e.g. + // @string/foo: Foo Bar (probably cropped) + if (!value.equals(text) && !value.equals("@null")) { //$NON-NLS-1$ + resolvedText = value; + } + } + + if (swtImage != null) { + // Make a square the size of the height + ImageData imageData = swtImage.getImageData(); + int imageWidth = imageData.width; + int imageHeight = imageData.height; + if (imageWidth > 0 && imageHeight > 0) { + gc.drawImage(swtImage, x, y + (height - imageHeight) / 2); + int delta = imageWidth + SAMPLE_MARGIN; + x += delta; + width -= delta; + } + } + } + } + + DrawUtils.drawStringCV(gc, text, x, y, width, height); + + if (resolvedText != null && resolvedText.length() > 0) { + Point size = gc.stringExtent(text); + x += size.x; + width -= size.x; + + x += SAMPLE_MARGIN; + width -= SAMPLE_MARGIN; + + if (width > 0) { + Color oldForeground = gc.getForeground(); + try { + gc.setForeground(PropertyTable.COLOR_PROPERTY_FG_DEFAULT); + DrawUtils.drawStringCV(gc, '(' + resolvedText + ')', x, y, width, height); + } finally { + gc.setForeground(oldForeground); + } + } + } + } + } + + @Override + protected boolean setEditorText(Property property, String text) throws Exception { + Object oldValue = property.getValue(); + String old = oldValue != null ? oldValue.toString() : null; + + // If users enters a new id without specifying the @id/@+id prefix, insert it + boolean isId = isIdProperty(property); + if (isId && !text.startsWith(PREFIX_RESOURCE_REF)) { + text = NEW_ID_PREFIX + text; + } + + // Handle id refactoring: if you change an id, may want to update references too. + // Ask user. + if (isId && property instanceof XmlProperty + && old != null && !old.isEmpty() + && text != null && !text.isEmpty() + && !text.equals(old)) { + XmlProperty xmlProperty = (XmlProperty) property; + IPreferenceStore store = AdtPlugin.getDefault().getPreferenceStore(); + String refactorPref = store.getString(AdtPrefs.PREFS_REFACTOR_IDS); + boolean performRefactor = false; + Shell shell = AdtPlugin.getShell(); + if (refactorPref == null + || refactorPref.isEmpty() + || refactorPref.equals(MessageDialogWithToggle.PROMPT)) { + MessageDialogWithToggle dialog = + MessageDialogWithToggle.openYesNoCancelQuestion( + shell, + "Update References?", + "Update all references as well? " + + "This will update all XML references and Java R field references.", + "Do not show again", + false, + store, + AdtPrefs.PREFS_REFACTOR_IDS); + switch (dialog.getReturnCode()) { + case IDialogConstants.CANCEL_ID: + return false; + case IDialogConstants.YES_ID: + performRefactor = true; + break; + case IDialogConstants.NO_ID: + performRefactor = false; + break; + } + } else { + performRefactor = refactorPref.equals(MessageDialogWithToggle.ALWAYS); + } + if (performRefactor) { + CommonXmlEditor xmlEditor = xmlProperty.getXmlEditor(); + if (xmlEditor != null) { + IProject project = xmlEditor.getProject(); + if (project != null && shell != null) { + RenameResourceWizard.renameResource(shell, project, + ResourceType.ID, stripIdPrefix(old), stripIdPrefix(text), false); + } + } + } + } + + property.setValue(text); + + return true; + } + + private static boolean isIdProperty(Property property) { + XmlProperty xmlProperty = (XmlProperty) property; + return xmlProperty.getDescriptor().getXmlLocalName().equals(ATTR_ID); + } + + private void openDialog(PropertyTable propertyTable, Property property) throws Exception { + XmlProperty xmlProperty = (XmlProperty) property; + IAttributeInfo attributeInfo = xmlProperty.getDescriptor().getAttributeInfo(); + + if (isIdProperty(property)) { + Object value = xmlProperty.getValue(); + if (value != null && !value.toString().isEmpty()) { + GraphicalEditorPart editor = xmlProperty.getGraphicalEditor(); + if (editor != null) { + LayoutCanvas canvas = editor.getCanvasControl(); + SelectionManager manager = canvas.getSelectionManager(); + + NodeProxy primary = canvas.getNodeFactory().create(xmlProperty.getNode()); + if (primary != null) { + RenameResult result = manager.performRename(primary, null); + if (result.isCanceled()) { + return; + } else if (!result.isUnavailable()) { + String name = result.getName(); + String id = NEW_ID_PREFIX + BaseViewRule.stripIdPrefix(name); + xmlProperty.setValue(id); + return; + } + } + } + } + + // When editing the id attribute, don't offer a resource chooser: usually + // you want to enter a *new* id here + attributeInfo = null; + } + + boolean referenceAllowed = false; + if (attributeInfo != null) { + EnumSet<Format> formats = attributeInfo.getFormats(); + ResourceType type = null; + List<ResourceType> types = null; + if (formats.contains(Format.FLAG)) { + String[] flagValues = attributeInfo.getFlagValues(); + if (flagValues != null) { + FlagXmlPropertyDialog dialog = + new FlagXmlPropertyDialog(propertyTable.getShell(), + "Select Flag Values", false /* radio */, + flagValues, xmlProperty); + + dialog.open(); + return; + } + } else if (formats.contains(Format.ENUM)) { + String[] enumValues = attributeInfo.getEnumValues(); + if (enumValues != null) { + FlagXmlPropertyDialog dialog = + new FlagXmlPropertyDialog(propertyTable.getShell(), + "Select Enum Value", true /* radio */, + enumValues, xmlProperty); + dialog.open(); + return; + } + } else { + for (Format format : formats) { + ResourceType t = format.getResourceType(); + if (t != null) { + if (type != null) { + if (types == null) { + types = new ArrayList<ResourceType>(); + types.add(type); + } + types.add(t); + } + type = t; + } else if (format == Format.REFERENCE) { + referenceAllowed = true; + } + } + } + if (types != null || referenceAllowed) { + // Multiple resource types (such as string *and* boolean): + // just use a reference chooser + GraphicalEditorPart graphicalEditor = xmlProperty.getGraphicalEditor(); + if (graphicalEditor != null) { + LayoutEditorDelegate delegate = graphicalEditor.getEditorDelegate(); + IProject project = delegate.getEditor().getProject(); + if (project != null) { + // get the resource repository for this project and the system resources. + ResourceRepository projectRepository = + ResourceManager.getInstance().getProjectResources(project); + Shell shell = AdtPlugin.getShell(); + ReferenceChooserDialog dlg = new ReferenceChooserDialog( + project, + projectRepository, + shell); + dlg.setPreviewHelper(new ResourcePreviewHelper(dlg, graphicalEditor)); + + String currentValue = (String) property.getValue(); + dlg.setCurrentResource(currentValue); + + if (dlg.open() == Window.OK) { + String resource = dlg.getCurrentResource(); + if (resource != null) { + // Returns null for cancel, "" for clear and otherwise a new value + if (resource.length() > 0) { + property.setValue(resource); + } else { + property.setValue(null); + } + } + } + + return; + } + } + } else if (type != null) { + // Single resource type: use a resource chooser + GraphicalEditorPart graphicalEditor = xmlProperty.getGraphicalEditor(); + if (graphicalEditor != null) { + String currentValue = (String) property.getValue(); + // TODO: Add validator factory? + String resource = ResourceChooser.chooseResource(graphicalEditor, + type, currentValue, null /* validator */); + // Returns null for cancel, "" for clear and otherwise a new value + if (resource != null) { + if (resource.length() > 0) { + property.setValue(resource); + } else { + property.setValue(null); + } + } + } + + return; + } + } + + // Fallback: Just use a plain string editor + StringXmlPropertyDialog dialog = + new StringXmlPropertyDialog(propertyTable.getShell(), property); + if (dialog.open() == Window.OK) { + // TODO: Do I need to activate? + } + } + + /** Qualified name for the per-project persistent property include-map */ + private final static QualifiedName CACHE_NAME = new QualifiedName(AdtPlugin.PLUGIN_ID, + "property-images");//$NON-NLS-1$ + + @NonNull + private static Map<String, Image> getImageCache(@NonNull Property property) { + XmlProperty xmlProperty = (XmlProperty) property; + GraphicalEditorPart graphicalEditor = xmlProperty.getGraphicalEditor(); + IProject project = graphicalEditor.getProject(); + try { + Map<String, Image> cache = (Map<String, Image>) project.getSessionProperty(CACHE_NAME); + if (cache == null) { + cache = Maps.newHashMap(); + project.setSessionProperty(CACHE_NAME, cache); + } + + return cache; + } catch (CoreException e) { + AdtPlugin.log(e, null); + return Maps.newHashMap(); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutAction.java new file mode 100644 index 000000000..306dd68c8 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutAction.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Convert Layout" menu item is invoked. + */ +public class ChangeLayoutAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + ChangeLayoutRefactoring ref = new ChangeLayoutRefactoring(mFile, mDelegate, + mTextSelection, mTreeSelection); + RefactoringWizard wizard = new ChangeLayoutWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Change Layout...", editorDelegate, ChangeLayoutAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutContribution.java new file mode 100644 index 000000000..c508b7e92 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutContribution.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import org.eclipse.ltk.core.refactoring.RefactoringContribution; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; + +import java.util.Map; + +public class ChangeLayoutContribution extends RefactoringContribution { + + @SuppressWarnings("unchecked") + @Override + public RefactoringDescriptor createDescriptor(String id, String project, String description, + String comment, Map arguments, int flags) throws IllegalArgumentException { + return new ChangeLayoutRefactoring.Descriptor(project, description, comment, arguments); + } + + @SuppressWarnings("unchecked") + @Override + public Map retrieveArgumentMap(RefactoringDescriptor descriptor) { + if (descriptor instanceof ChangeLayoutRefactoring.Descriptor) { + return ((ChangeLayoutRefactoring.Descriptor) descriptor).getArguments(); + } + return super.retrieveArgumentMap(descriptor); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutRefactoring.java new file mode 100644 index 000000000..d8c85aab5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutRefactoring.java @@ -0,0 +1,657 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.ATTR_BASELINE_ALIGNED; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; +import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.EXT_XML; +import static com.android.SdkConstants.FQCN_GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.FQCN_GRID_LAYOUT; +import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; +import static com.android.SdkConstants.FQCN_RELATIVE_LAYOUT; +import static com.android.SdkConstants.FQCN_TABLE_LAYOUT; +import static com.android.SdkConstants.GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.LINEAR_LAYOUT; +import static com.android.SdkConstants.TABLE_ROW; +import static com.android.SdkConstants.VALUE_FALSE; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; + +import com.android.SdkConstants; +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.LayoutCanvas; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.ViewHierarchy; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.MalformedTreeException; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Converts the selected layout into a layout of a different type. + */ +@SuppressWarnings("restriction") // XML model +public class ChangeLayoutRefactoring extends VisualRefactoring { + private static final String KEY_TYPE = "type"; //$NON-NLS-1$ + private static final String KEY_FLATTEN = "flatten"; //$NON-NLS-1$ + + private String mTypeFqcn; + private String mInitializedAttributes; + private boolean mFlatten; + + /** + * This constructor is solely used by {@link Descriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + ChangeLayoutRefactoring(Map<String, String> arguments) { + super(arguments); + mTypeFqcn = arguments.get(KEY_TYPE); + mFlatten = Boolean.parseBoolean(arguments.get(KEY_FLATTEN)); + } + + @VisibleForTesting + ChangeLayoutRefactoring(List<Element> selectedElements, LayoutEditorDelegate delegate) { + super(selectedElements, delegate); + } + + public ChangeLayoutRefactoring( + IFile file, + LayoutEditorDelegate delegate, + ITextSelection selection, + ITreeSelection treeSelection) { + super(file, delegate, selection, treeSelection); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 2); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("No selection to convert"); + return status; + } + + if (mElements.size() != 1) { + status.addFatalError("Select precisely one layout to convert"); + return status; + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + protected Map<String, String> createArgumentMap() { + Map<String, String> args = super.createArgumentMap(); + args.put(KEY_TYPE, mTypeFqcn); + args.put(KEY_FLATTEN, Boolean.toString(mFlatten)); + + return args; + } + + @Override + public String getName() { + return "Change Layout"; + } + + void setType(String typeFqcn) { + mTypeFqcn = typeFqcn; + } + + void setInitializedAttributes(String initializedAttributes) { + mInitializedAttributes = initializedAttributes; + } + + void setFlatten(boolean flatten) { + mFlatten = flatten; + } + + @Override + protected List<Element> initElements() { + List<Element> elements = super.initElements(); + + // Don't convert a root GestureOverlayView; convert its child. This looks for + // gesture overlays, and if found, it generates a new child list where the gesture + // overlay children are replaced by their first element children + for (Element element : elements) { + String tagName = element.getTagName(); + if (tagName.equals(GESTURE_OVERLAY_VIEW) + || tagName.equals(FQCN_GESTURE_OVERLAY_VIEW)) { + List<Element> replacement = new ArrayList<Element>(elements.size()); + for (Element e : elements) { + tagName = e.getTagName(); + if (tagName.equals(GESTURE_OVERLAY_VIEW) + || tagName.equals(FQCN_GESTURE_OVERLAY_VIEW)) { + NodeList children = e.getChildNodes(); + Element first = null; + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + first = (Element) node; + break; + } + } + if (first != null) { + e = first; + } + } + replacement.add(e); + } + return replacement; + } + } + + return elements; + } + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + String name = getViewClass(mTypeFqcn); + + IFile file = mDelegate.getEditor().getInputFile(); + List<Change> changes = new ArrayList<Change>(); + if (file == null) { + return changes; + } + TextFileChange change = new TextFileChange(file.getName(), file); + MultiTextEdit rootEdit = new MultiTextEdit(); + change.setTextType(EXT_XML); + changes.add(change); + + String text = getText(mSelectionStart, mSelectionEnd); + Element layout = getPrimaryElement(); + String oldName = layout.getNodeName(); + int open = text.indexOf(oldName); + int close = text.lastIndexOf(oldName); + + if (open != -1 && close != -1) { + int oldLength = oldName.length(); + rootEdit.addChild(new ReplaceEdit(mSelectionStart + open, oldLength, name)); + if (close != open) { // Gracefully handle <FooLayout/> + rootEdit.addChild(new ReplaceEdit(mSelectionStart + close, oldLength, name)); + } + } + + String oldId = getId(layout); + String newId = ensureIdMatchesType(layout, mTypeFqcn, rootEdit); + // Update any layout references to the old id with the new id + if (oldId != null && newId != null) { + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null) { + List<TextEdit> replaceIds = replaceIds(getAndroidNamespacePrefix(), doc, + mSelectionStart, + mSelectionEnd, oldId, newId); + for (TextEdit edit : replaceIds) { + rootEdit.addChild(edit); + } + } + } finally { + model.releaseFromRead(); + } + } + + String oldType = getOldType(); + String newType = mTypeFqcn; + + if (newType.equals(FQCN_RELATIVE_LAYOUT)) { + if (oldType.equals(FQCN_LINEAR_LAYOUT) && !mFlatten) { + // Hand-coded conversion specifically tailored for linear to relative, provided + // there is no hierarchy flattening + // TODO: use the RelativeLayoutConversionHelper for this; it does a better job + // analyzing gravities etc. + convertLinearToRelative(rootEdit); + removeUndefinedAttrs(rootEdit, layout); + addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); + } else { + // Generic conversion to relative - can also flatten the hierarchy + convertAnyToRelative(rootEdit, oldType, newType); + // This already handles removing undefined layout attributes -- right? + //removeUndefinedLayoutAttrs(rootEdit, layout); + } + } else if (newType.equals(FQCN_GRID_LAYOUT)) { + convertAnyToGridLayout(rootEdit); + // Layout attributes on children have already been removed as part of conversion + // during the flattening + removeUndefinedAttrs(rootEdit, layout, false /*removeLayoutAttrs*/); + } else if (oldType.equals(FQCN_RELATIVE_LAYOUT) && newType.equals(FQCN_LINEAR_LAYOUT)) { + convertRelativeToLinear(rootEdit); + removeUndefinedAttrs(rootEdit, layout); + addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); + } else if (oldType.equals(FQCN_LINEAR_LAYOUT) && newType.equals(FQCN_TABLE_LAYOUT)) { + convertLinearToTable(rootEdit); + removeUndefinedAttrs(rootEdit, layout); + addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); + } else { + convertGeneric(rootEdit, oldType, newType, layout); + } + + if (mInitializedAttributes != null && mInitializedAttributes.length() > 0) { + String namespace = getAndroidNamespacePrefix(); + for (String s : mInitializedAttributes.split(",")) { //$NON-NLS-1$ + String[] nameValue = s.split("="); //$NON-NLS-1$ + String attribute = nameValue[0]; + String value = nameValue[1]; + String prefix = null; + String namespaceUri = null; + if (attribute.startsWith(SdkConstants.ANDROID_NS_NAME_PREFIX)) { + prefix = namespace; + namespaceUri = ANDROID_URI; + attribute = attribute.substring(SdkConstants.ANDROID_NS_NAME_PREFIX.length()); + } + setAttribute(rootEdit, layout, namespaceUri, + prefix, attribute, value); + } + } + + if (AdtPrefs.getPrefs().getFormatGuiXml()) { + MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT); + if (formatted != null) { + rootEdit = formatted; + } + } + change.setEdit(rootEdit); + + return changes; + } + + /** Checks whether we need to add any missing attributes on the elements */ + private void addMissingWrapContentAttributes(MultiTextEdit rootEdit, Element layout, + String oldType, String newType, Set<Element> skip) { + if (oldType.equals(FQCN_GRID_LAYOUT) && !newType.equals(FQCN_GRID_LAYOUT)) { + String namespace = getAndroidNamespacePrefix(); + + for (Element child : DomUtilities.getChildren(layout)) { + if (skip != null && skip.contains(child)) { + continue; + } + + if (!child.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH)) { + setAttribute(rootEdit, child, ANDROID_URI, + namespace, ATTR_LAYOUT_WIDTH, VALUE_WRAP_CONTENT); + } + if (!child.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT)) { + setAttribute(rootEdit, child, ANDROID_URI, + namespace, ATTR_LAYOUT_HEIGHT, VALUE_WRAP_CONTENT); + } + } + } + } + + /** Hand coded conversion from a LinearLayout to a TableLayout */ + private void convertLinearToTable(MultiTextEdit rootEdit) { + // This is pretty easy; just switch the root tag (already done by the initial generic + // conversion) and then convert all the children into <TableRow> elements. + // Finally, get rid of the orientation attribute, if any. + Element layout = getPrimaryElement(); + removeOrientationAttribute(rootEdit, layout); + + NodeList children = layout.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + if (node instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) node; + int start = region.getStartOffset(); + int end = region.getEndOffset(); + String text = getText(start, end); + String oldName = child.getNodeName(); + if (oldName.equals(LINEAR_LAYOUT)) { + removeOrientationAttribute(rootEdit, child); + int open = text.indexOf(oldName); + int close = text.lastIndexOf(oldName); + + if (open != -1 && close != -1) { + int oldLength = oldName.length(); + rootEdit.addChild(new ReplaceEdit(mSelectionStart + open, oldLength, + TABLE_ROW)); + if (close != open) { // Gracefully handle <FooLayout/> + rootEdit.addChild(new ReplaceEdit(mSelectionStart + close, + oldLength, TABLE_ROW)); + } + } + } // else: WRAP in TableLayout! + } + } + } + } + + /** Hand coded conversion from a LinearLayout to a RelativeLayout */ + private void convertLinearToRelative(MultiTextEdit rootEdit) { + // This can be done accurately. + Element layout = getPrimaryElement(); + // Horizontal is the default, so if no value is specified it is horizontal. + boolean isVertical = VALUE_VERTICAL.equals(layout.getAttributeNS(ANDROID_URI, + ATTR_ORIENTATION)); + + String attributePrefix = getAndroidNamespacePrefix(); + + // TODO: Consider gravity of each element + // TODO: Consider weight of each element + // Right now it simply makes a single attachment to keep the order. + + if (isVertical) { + // Align each child to the bottom and left of its parent + NodeList children = layout.getChildNodes(); + String prevId = null; + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String id = ensureHasId(rootEdit, child, null); + if (prevId != null) { + setAttribute(rootEdit, child, ANDROID_URI, attributePrefix, + ATTR_LAYOUT_BELOW, prevId); + } + prevId = id; + } + } + } else { + // Align each child to the left + NodeList children = layout.getChildNodes(); + boolean isBaselineAligned = + !VALUE_FALSE.equals(layout.getAttributeNS(ANDROID_URI, ATTR_BASELINE_ALIGNED)); + + String prevId = null; + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String id = ensureHasId(rootEdit, child, null); + if (prevId != null) { + setAttribute(rootEdit, child, ANDROID_URI, attributePrefix, + ATTR_LAYOUT_TO_RIGHT_OF, prevId); + if (isBaselineAligned) { + setAttribute(rootEdit, child, ANDROID_URI, attributePrefix, + ATTR_LAYOUT_ALIGN_BASELINE, prevId); + } + } + prevId = id; + } + } + } + } + + /** Strips out the android:orientation attribute from the given linear layout element */ + private void removeOrientationAttribute(MultiTextEdit rootEdit, Element layout) { + assert layout.getTagName().equals(LINEAR_LAYOUT); + removeAttribute(rootEdit, layout, ANDROID_URI, ATTR_ORIENTATION); + } + + /** + * Hand coded conversion from a RelativeLayout to a LinearLayout + * + * @param rootEdit the root multi text edit to add edits to + */ + private void convertRelativeToLinear(MultiTextEdit rootEdit) { + // This is going to be lossy... + // TODO: Attempt to "order" the items based on their visual positions + // and insert them in that order in the LinearLayout. + // TODO: Possibly use nesting if necessary, by spatial subdivision, + // to accomplish roughly the same layout as the relative layout specifies. + } + + /** + * Hand coded -generic- conversion from one layout to another. This is not going to be + * an accurate layout transformation; instead it simply migrates the layout attributes + * that are supported, and adds defaults for any new required layout attributes. In + * addition, it attempts to order the children visually based on where they fit in a + * rendering. (Unsupported layout attributes will be removed by the caller at the + * end.) + * <ul> + * <li>Try to handle nesting. Converting a *hierarchy* of layouts into a flatter + * layout for powerful layouts that support it, like RelativeLayout. + * <li>Try to do automatic "inference" about the layout. I can render it and look at + * the ViewInfo positions and sizes. I can render it multiple times, at different + * sizes, to infer "stretchiness" and "weight" properties of the children. + * <li>Try to do indirect transformations. E.g. if I can go from A to B, and B to C, + * then an attempt to go from A to C should perform conversions A to B and then B to + * C. + * </ul> + * + * @param rootEdit the root multi text edit to add edits to + * @param oldType the fully qualified class name of the layout type we are converting + * from + * @param newType the fully qualified class name of the layout type we are converting + * to + * @param layout the layout to be converted + */ + private void convertGeneric(MultiTextEdit rootEdit, String oldType, String newType, + Element layout) { + // TODO: Add hooks for 3rd party conversions getting registered through the + // IViewRule interface. + + // For now we simply go with the default behavior, which is to just strip the + // layout attributes that aren't supported. + removeUndefinedAttrs(rootEdit, layout); + addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, null); + } + + /** + * Removes all the unavailable attributes after a conversion, both on the + * layout element itself as well as the layout attributes of any of the + * children + */ + private void removeUndefinedAttrs(MultiTextEdit rootEdit, Element layout) { + removeUndefinedAttrs(rootEdit, layout, true /*removeLayoutAttrs*/); + } + + private void removeUndefinedAttrs(MultiTextEdit rootEdit, Element layout, + boolean removeLayoutAttrs) { + ViewElementDescriptor descriptor = getElementDescriptor(mTypeFqcn); + if (descriptor == null) { + return; + } + + if (removeLayoutAttrs) { + Set<String> defined = new HashSet<String>(); + AttributeDescriptor[] layoutAttributes = descriptor.getLayoutAttributes(); + for (AttributeDescriptor attribute : layoutAttributes) { + defined.add(attribute.getXmlLocalName()); + } + + NodeList children = layout.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + + List<Attr> attributes = findLayoutAttributes(child); + for (Attr attribute : attributes) { + String name = attribute.getLocalName(); + if (!defined.contains(name)) { + // Remove it + try { + removeAttribute(rootEdit, child, attribute.getNamespaceURI(), name); + } catch (MalformedTreeException mte) { + // Sometimes refactoring has modified attribute; not removing + // it is non-fatal so just warn instead of letting refactoring + // operation abort + AdtPlugin.log(IStatus.WARNING, + "Could not remove unsupported attribute %1$s; " + //$NON-NLS-1$ + "already modified during refactoring?", //$NON-NLS-1$ + attribute.getLocalName()); + } + } + } + } + } + } + + // Also remove the unavailable attributes (not layout attributes) on the + // converted element + Set<String> defined = new HashSet<String>(); + AttributeDescriptor[] attributes = descriptor.getAttributes(); + for (AttributeDescriptor attribute : attributes) { + defined.add(attribute.getXmlLocalName()); + } + + // Remove undefined attributes on the layout element itself + NamedNodeMap attributeMap = layout.getAttributes(); + for (int i = 0, n = attributeMap.getLength(); i < n; i++) { + Node attributeNode = attributeMap.item(i); + + String name = attributeNode.getLocalName(); + if (!name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attributeNode.getNamespaceURI())) { + if (!defined.contains(name)) { + // Remove it + removeAttribute(rootEdit, layout, ANDROID_URI, name); + } + } + } + } + + /** Hand coded conversion from any layout to a RelativeLayout */ + private void convertAnyToRelative(MultiTextEdit rootEdit, String oldType, String newType) { + // To perform a conversion from any other layout type, including nested conversion, + Element layout = getPrimaryElement(); + CanvasViewInfo rootView = mRootView; + if (rootView == null) { + LayoutCanvas canvas = mDelegate.getGraphicalEditor().getCanvasControl(); + ViewHierarchy viewHierarchy = canvas.getViewHierarchy(); + rootView = viewHierarchy.getRoot(); + } + + RelativeLayoutConversionHelper helper = + new RelativeLayoutConversionHelper(this, layout, mFlatten, rootEdit, rootView); + helper.convertToRelative(); + List<Element> deletedElements = helper.getDeletedElements(); + Set<Element> deleted = null; + if (deletedElements != null && deletedElements.size() > 0) { + deleted = new HashSet<Element>(deletedElements); + } + addMissingWrapContentAttributes(rootEdit, layout, oldType, newType, deleted); + } + + /** Hand coded conversion from any layout to a GridLayout */ + private void convertAnyToGridLayout(MultiTextEdit rootEdit) { + // To perform a conversion from any other layout type, including nested conversion, + Element layout = getPrimaryElement(); + CanvasViewInfo rootView = mRootView; + if (rootView == null) { + LayoutCanvas canvas = mDelegate.getGraphicalEditor().getCanvasControl(); + ViewHierarchy viewHierarchy = canvas.getViewHierarchy(); + rootView = viewHierarchy.getRoot(); + } + + GridLayoutConverter converter = new GridLayoutConverter(this, layout, mFlatten, + rootEdit, rootView); + converter.convertToGridLayout(); + } + + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.convert", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new ChangeLayoutRefactoring(args); + } + } + + String getOldType() { + Element primary = getPrimaryElement(); + if (primary != null) { + String oldType = primary.getTagName(); + if (oldType.indexOf('.') == -1) { + oldType = ANDROID_WIDGET_PREFIX + oldType; + } + return oldType; + } + + return null; + } + + @VisibleForTesting + protected CanvasViewInfo mRootView; + + @VisibleForTesting + public void setRootView(CanvasViewInfo rootView) { + mRootView = rootView; + } + + @Override + VisualRefactoringWizard createWizard() { + return new ChangeLayoutWizard(this, mDelegate); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutWizard.java new file mode 100644 index 000000000..f5582712f --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeLayoutWizard.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.FQCN_GRID_LAYOUT; +import static com.android.SdkConstants.FQCN_RELATIVE_LAYOUT; +import static com.android.SdkConstants.GRID_LAYOUT; +import static com.android.SdkConstants.RELATIVE_LAYOUT; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; +import static com.android.SdkConstants.VIEW_MERGE; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.PaletteMetadataDescriptor; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +class ChangeLayoutWizard extends VisualRefactoringWizard { + + public ChangeLayoutWizard(ChangeLayoutRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle("Change Layout"); + } + + @Override + protected void addUserInputPages() { + ChangeLayoutRefactoring ref = (ChangeLayoutRefactoring) getRefactoring(); + String oldType = ref.getOldType(); + addPage(new InputPage(mDelegate.getEditor().getProject(), oldType)); + } + + /** Wizard page which inputs parameters for the {@link ChangeLayoutRefactoring} operation */ + private static class InputPage extends VisualRefactoringInputPage { + private final IProject mProject; + private final String mOldType; + private Combo mTypeCombo; + private Button mFlatten; + private List<Pair<String, ViewElementDescriptor>> mClassNames; + + public InputPage(IProject project, String oldType) { + super("ChangeLayoutInputPage"); //$NON-NLS-1$ + mProject = project; + mOldType = oldType; + } + + @Override + public void createControl(Composite parent) { + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(2, false)); + + Label fromLabel = new Label(composite, SWT.NONE); + fromLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 2, 1)); + String oldTypeBase = mOldType.substring(mOldType.lastIndexOf('.') + 1); + fromLabel.setText(String.format("Change from %1$s", oldTypeBase)); + + Label typeLabel = new Label(composite, SWT.NONE); + typeLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); + typeLabel.setText("New Layout Type:"); + + mTypeCombo = new Combo(composite, SWT.READ_ONLY); + mTypeCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + SelectionAdapter selectionListener = new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + validatePage(); + // Hierarchy flattening only works for relative layout (and any future + // layouts that can also support arbitrary layouts). + String text = mTypeCombo.getText(); + mFlatten.setVisible(text.equals(RELATIVE_LAYOUT) || text.equals(GRID_LAYOUT)); + } + }; + mTypeCombo.addSelectionListener(selectionListener); + mTypeCombo.addSelectionListener(mSelectionValidateListener); + + mFlatten = new Button(composite, SWT.CHECK); + mFlatten.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, + false, false, 2, 1)); + mFlatten.setText("Flatten hierarchy"); + mFlatten.addSelectionListener(selectionListener); + // Should flattening be selected by default? + mFlatten.setSelection(true); + mFlatten.addSelectionListener(mSelectionValidateListener); + + // We don't exclude RelativeLayout even if the current layout is RelativeLayout, + // in case you are trying to flatten the hierarchy for a hierarchy that has a + // RelativeLayout at the root. + Set<String> exclude = new HashSet<String>(); + exclude.add(VIEW_INCLUDE); + exclude.add(VIEW_MERGE); + exclude.add(VIEW_FRAGMENT); + boolean oldIsRelativeLayout = mOldType.equals(FQCN_RELATIVE_LAYOUT); + boolean oldIsGridLayout = mOldType.equals(FQCN_GRID_LAYOUT); + if (oldIsRelativeLayout || oldIsGridLayout) { + exclude.add(mOldType); + } + mClassNames = WrapInWizard.addLayouts(mProject, mOldType, mTypeCombo, exclude, false); + + boolean gridLayoutAvailable = false; + for (int i = 0; i < mTypeCombo.getItemCount(); i++) { + if (mTypeCombo.getItem(i).equals(GRID_LAYOUT)) { + gridLayoutAvailable = true; + break; + } + } + + mTypeCombo.select(0); + // The default should be GridLayout (if available) and if not RelativeLayout, + // if available (and not the old Type) + if (gridLayoutAvailable && !oldIsGridLayout) { + for (int i = 0; i < mTypeCombo.getItemCount(); i++) { + if (mTypeCombo.getItem(i).equals(GRID_LAYOUT)) { + mTypeCombo.select(i); + break; + } + } + } else if (!oldIsRelativeLayout) { + for (int i = 0; i < mTypeCombo.getItemCount(); i++) { + if (mTypeCombo.getItem(i).equals(RELATIVE_LAYOUT)) { + mTypeCombo.select(i); + break; + } + } + } + mFlatten.setVisible(mTypeCombo.getText().equals(RELATIVE_LAYOUT) + || mTypeCombo.getText().equals(GRID_LAYOUT)); + + setControl(composite); + validatePage(); + } + + @Override + protected boolean validatePage() { + boolean ok = true; + + int selectionIndex = mTypeCombo.getSelectionIndex(); + String type = selectionIndex != -1 ? mClassNames.get(selectionIndex).getFirst() : null; + if (type == null) { + setErrorMessage("Select a layout type"); + ok = false; // The user has chosen a separator + } else { + setErrorMessage(null); + + // Record state + ChangeLayoutRefactoring refactoring = + (ChangeLayoutRefactoring) getRefactoring(); + refactoring.setType(type); + refactoring.setFlatten(mFlatten.getSelection()); + + ViewElementDescriptor descriptor = mClassNames.get(selectionIndex).getSecond(); + if (descriptor instanceof PaletteMetadataDescriptor) { + PaletteMetadataDescriptor paletteDescriptor = + (PaletteMetadataDescriptor) descriptor; + String initializedAttributes = paletteDescriptor.getInitializedAttributes(); + if (initializedAttributes != null && initializedAttributes.length() > 0) { + refactoring.setInitializedAttributes(initializedAttributes); + } + } else { + refactoring.setInitializedAttributes(null); + } + } + + setPageComplete(ok); + return ok; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewAction.java new file mode 100644 index 000000000..fa14e5222 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewAction.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Change View Type" menu item is invoked. + */ +public class ChangeViewAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + ChangeViewRefactoring ref = new ChangeViewRefactoring(mFile, mDelegate, + mTextSelection, mTreeSelection); + RefactoringWizard wizard = new ChangeViewWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Change Widget Type...", editorDelegate, ChangeViewAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewContribution.java new file mode 100644 index 000000000..7705ed808 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewContribution.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import org.eclipse.ltk.core.refactoring.RefactoringContribution; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; + +import java.util.Map; + +public class ChangeViewContribution extends RefactoringContribution { + + @SuppressWarnings("unchecked") + @Override + public RefactoringDescriptor createDescriptor(String id, String project, String description, + String comment, Map arguments, int flags) throws IllegalArgumentException { + return new ChangeViewRefactoring.Descriptor(project, description, comment, arguments); + } + + @SuppressWarnings("unchecked") + @Override + public Map retrieveArgumentMap(RefactoringDescriptor descriptor) { + if (descriptor instanceof ChangeViewRefactoring.Descriptor) { + return ((ChangeViewRefactoring.Descriptor) descriptor).getArguments(); + } + return super.retrieveArgumentMap(descriptor); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewRefactoring.java new file mode 100644 index 000000000..73f5eb149 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewRefactoring.java @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_TEXT; +import static com.android.SdkConstants.EXT_XML; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; + +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.eclipse.wst.xml.core.internal.document.ElementImpl; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Changes the type of the given widgets to the given target type + * and updates the attributes if necessary + */ +@SuppressWarnings("restriction") // XML model +public class ChangeViewRefactoring extends VisualRefactoring { + private static final String KEY_TYPE = "type"; //$NON-NLS-1$ + private String mTypeFqcn; + + /** + * This constructor is solely used by {@link Descriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + ChangeViewRefactoring(Map<String, String> arguments) { + super(arguments); + mTypeFqcn = arguments.get(KEY_TYPE); + } + + public ChangeViewRefactoring( + IFile file, + LayoutEditorDelegate delegate, + ITextSelection selection, + ITreeSelection treeSelection) { + super(file, delegate, selection, treeSelection); + } + + @VisibleForTesting + ChangeViewRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { + super(selectedElements, editor); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 6); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("No selection to convert"); + return status; + } + + // Make sure the selection is contiguous + if (mTreeSelection != null) { + List<CanvasViewInfo> infos = getSelectedViewInfos(); + if (!validateNotEmpty(infos, status)) { + return status; + } + } + + // Ensures that we have a valid DOM model: + if (mElements.size() == 0) { + status.addFatalError("Nothing to convert"); + return status; + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + protected Map<String, String> createArgumentMap() { + Map<String, String> args = super.createArgumentMap(); + args.put(KEY_TYPE, mTypeFqcn); + + return args; + } + + @Override + public String getName() { + return "Change Widget Type"; + } + + void setType(String typeFqcn) { + mTypeFqcn = typeFqcn; + } + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + String name = getViewClass(mTypeFqcn); + + IFile file = mDelegate.getEditor().getInputFile(); + List<Change> changes = new ArrayList<Change>(); + if (file == null) { + return changes; + } + TextFileChange change = new TextFileChange(file.getName(), file); + MultiTextEdit rootEdit = new MultiTextEdit(); + change.setEdit(rootEdit); + change.setTextType(EXT_XML); + changes.add(change); + + for (Element element : getElements()) { + IndexedRegion region = getRegion(element); + String text = getText(region.getStartOffset(), region.getEndOffset()); + String oldName = element.getNodeName(); + int open = text.indexOf(oldName); + int close = text.lastIndexOf(oldName); + if (element instanceof ElementImpl && ((ElementImpl) element).isEmptyTag()) { + close = -1; + } + + if (open != -1) { + int oldLength = oldName.length(); + rootEdit.addChild(new ReplaceEdit(region.getStartOffset() + open, + oldLength, name)); + } + if (close != -1 && close != open) { + int oldLength = oldName.length(); + rootEdit.addChild(new ReplaceEdit(region.getStartOffset() + close, oldLength, + name)); + } + + // Change tag type + String oldId = getId(element); + String newId = ensureIdMatchesType(element, mTypeFqcn, rootEdit); + // Update any layout references to the old id with the new id + if (oldId != null && newId != null) { + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null) { + IndexedRegion range = getRegion(element); + int skipStart = range.getStartOffset(); + int skipEnd = range.getEndOffset(); + List<TextEdit> replaceIds = replaceIds(getAndroidNamespacePrefix(), doc, + skipStart, skipEnd, + oldId, newId); + for (TextEdit edit : replaceIds) { + rootEdit.addChild(edit); + } + } + } finally { + model.releaseFromRead(); + } + } + + // Strip out attributes that no longer make sense + removeUndefinedAttrs(rootEdit, element); + } + + return changes; + } + + /** Removes all the unused attributes after a conversion */ + private void removeUndefinedAttrs(MultiTextEdit rootEdit, Element element) { + ViewElementDescriptor descriptor = getElementDescriptor(mTypeFqcn); + if (descriptor == null) { + return; + } + + Set<String> defined = new HashSet<String>(); + AttributeDescriptor[] layoutAttributes = descriptor.getAttributes(); + for (AttributeDescriptor attribute : layoutAttributes) { + defined.add(attribute.getXmlLocalName()); + } + + List<Attr> attributes = findAttributes(element); + for (Attr attribute : attributes) { + String name = attribute.getLocalName(); + if (!defined.contains(name)) { + // Remove it + removeAttribute(rootEdit, element, attribute.getNamespaceURI(), name); + } + } + + // Set text attribute if it's defined + if (defined.contains(ATTR_TEXT) && !element.hasAttributeNS(ANDROID_URI, ATTR_TEXT)) { + setAttribute(rootEdit, element, ANDROID_URI, getAndroidNamespacePrefix(), + ATTR_TEXT, descriptor.getUiName()); + } + } + + protected List<Attr> findAttributes(Node root) { + List<Attr> result = new ArrayList<Attr>(); + NamedNodeMap attributes = root.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attributeNode = attributes.item(i); + + String name = attributeNode.getLocalName(); + if (!name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attributeNode.getNamespaceURI())) { + result.add((Attr) attributeNode); + } + } + + return result; + } + + List<String> getOldTypes() { + List<String> types = new ArrayList<String>(); + for (Element primary : getElements()) { + String oldType = primary.getTagName(); + if (oldType.indexOf('.') == -1 + && !oldType.equals(VIEW_INCLUDE) && !oldType.equals(VIEW_FRAGMENT)) { + oldType = ANDROID_WIDGET_PREFIX + oldType; + } + types.add(oldType); + } + + return types; + } + + @Override + VisualRefactoringWizard createWizard() { + return new ChangeViewWizard(this, mDelegate); + } + + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.changeview", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new ChangeViewRefactoring(args); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewWizard.java new file mode 100644 index 000000000..0ac7106b3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ChangeViewWizard.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.REQUEST_FOCUS; +import static com.android.SdkConstants.VIEW_FRAGMENT; +import static com.android.SdkConstants.VIEW_INCLUDE; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CustomViewFinder; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.IAndroidTarget; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; + +import java.util.ArrayList; +import java.util.List; + +class ChangeViewWizard extends VisualRefactoringWizard { + private static final String SEPARATOR_LABEL = + "----------------------------------------"; //$NON-NLS-1$ + + public ChangeViewWizard(ChangeViewRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle("Change Widget Type"); + } + + @Override + protected void addUserInputPages() { + ChangeViewRefactoring ref = (ChangeViewRefactoring) getRefactoring(); + List<String> oldTypes = ref.getOldTypes(); + String oldType = null; + for (String type : oldTypes) { + if (oldType == null) { + oldType = type; + } else if (!oldType.equals(type)) { + // If the types differ, don't offer related categories + oldType = null; + break; + } + } + addPage(new InputPage(mDelegate.getEditor().getProject(), oldType)); + } + + /** Wizard page which inputs parameters for the {@link ChangeViewRefactoring} operation */ + private static class InputPage extends VisualRefactoringInputPage { + private final IProject mProject; + private Combo mTypeCombo; + private final String mOldType; + private List<String> mClassNames; + + public InputPage(IProject project, String oldType) { + super("ChangeViewInputPage"); //$NON-NLS-1$ + mProject = project; + mOldType = oldType; + } + + @Override + public void createControl(Composite parent) { + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(2, false)); + + Label typeLabel = new Label(composite, SWT.NONE); + typeLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); + typeLabel.setText("New Widget Type:"); + + mTypeCombo = new Combo(composite, SWT.READ_ONLY); + mTypeCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + mTypeCombo.addSelectionListener(mSelectionValidateListener); + + mClassNames = getWidgetTypes(mOldType, mTypeCombo); + mTypeCombo.select(0); + + setControl(composite); + validatePage(); + + mTypeCombo.setFocus(); + } + + private List<String> getWidgetTypes(String oldType, Combo combo) { + List<String> classNames = new ArrayList<String>(); + + // Populate type combo + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(mProject); + if (target != null) { + // Try to pick "related" widgets to the one you have selected. + // For example, for an AnalogClock, display DigitalClock first. + // For a Text, offer EditText, AutoComplete, etc. + if (oldType != null) { + ViewMetadataRepository repository = ViewMetadataRepository.get(); + List<String> relatedTo = repository.getRelatedTo(oldType); + if (relatedTo.size() > 0) { + for (String className : relatedTo) { + String base = className.substring(className.lastIndexOf('.') + 1); + combo.add(base); + classNames.add(className); + } + combo.add(SEPARATOR_LABEL); + classNames.add(null); + } + } + + Pair<List<String>,List<String>> result = + CustomViewFinder.findViews(mProject, false); + List<String> customViews = result.getFirst(); + List<String> thirdPartyViews = result.getSecond(); + if (customViews.size() > 0) { + for (String view : customViews) { + combo.add(view); + classNames.add(view); + } + combo.add(SEPARATOR_LABEL); + classNames.add(null); + } + + if (thirdPartyViews.size() > 0) { + for (String view : thirdPartyViews) { + combo.add(view); + classNames.add(view); + } + combo.add(SEPARATOR_LABEL); + classNames.add(null); + } + + AndroidTargetData targetData = currentSdk.getTargetData(target); + if (targetData != null) { + // Now add ALL known layout descriptors in case the user has + // a special case + List<ViewElementDescriptor> descriptors = + targetData.getLayoutDescriptors().getViewDescriptors(); + for (ViewElementDescriptor d : descriptors) { + String className = d.getFullClassName(); + if (className.equals(VIEW_INCLUDE) + || className.equals(VIEW_FRAGMENT) + || className.equals(REQUEST_FOCUS)) { + continue; + } + combo.add(d.getUiName()); + classNames.add(className); + + } + } + } + } else { + combo.add("SDK not initialized"); + classNames.add(null); + } + + return classNames; + } + + @Override + protected boolean validatePage() { + boolean ok = true; + int selectionIndex = mTypeCombo.getSelectionIndex(); + String type = selectionIndex != -1 ? mClassNames.get(selectionIndex) : null; + if (type == null) { + setErrorMessage("Select a widget type to convert to"); + ok = false; // The user has chosen a separator + } else { + setErrorMessage(null); + } + + // Record state + ChangeViewRefactoring refactoring = + (ChangeViewRefactoring) getRefactoring(); + refactoring.setType(type); + + setPageComplete(ok); + return ok; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeAction.java new file mode 100644 index 000000000..6f96fe489 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeAction.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Extract as Include" menu item is invoked. + */ +public class ExtractIncludeAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + ExtractIncludeRefactoring ref = new ExtractIncludeRefactoring(mFile, mDelegate, + mTextSelection, mTreeSelection); + RefactoringWizard wizard = new ExtractIncludeWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Extract Include...", editorDelegate, ExtractIncludeAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeContribution.java new file mode 100644 index 000000000..5903812ea --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeContribution.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import org.eclipse.ltk.core.refactoring.RefactoringContribution; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; + +import java.util.Map; + +public class ExtractIncludeContribution extends RefactoringContribution { + + @SuppressWarnings("unchecked") + @Override + public RefactoringDescriptor createDescriptor(String id, String project, String description, + String comment, Map arguments, int flags) throws IllegalArgumentException { + return new ExtractIncludeRefactoring.Descriptor(project, description, comment, arguments); + } + + @SuppressWarnings("unchecked") + @Override + public Map retrieveArgumentMap(RefactoringDescriptor descriptor) { + if (descriptor instanceof ExtractIncludeRefactoring.Descriptor) { + return ((ExtractIncludeRefactoring.Descriptor) descriptor).getArguments(); + } + return super.retrieveArgumentMap(descriptor); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeRefactoring.java new file mode 100644 index 000000000..f58ac5501 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeRefactoring.java @@ -0,0 +1,670 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_NS_NAME; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.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.DOT_XML; +import static com.android.SdkConstants.EXT_XML; +import static com.android.SdkConstants.FD_RES; +import static com.android.SdkConstants.FD_RESOURCES; +import static com.android.SdkConstants.FD_RES_LAYOUT; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; +import static com.android.SdkConstants.VIEW_INCLUDE; +import static com.android.SdkConstants.XMLNS; +import static com.android.SdkConstants.XMLNS_PREFIX; +import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP; +import static com.android.resources.ResourceType.LAYOUT; + +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlFormatPreferences; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlPrettyPrinter; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; +import com.android.utils.XmlUtils; + +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.core.runtime.Path; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.NullChange; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.swt.widgets.Display; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.wst.sse.core.StructuredModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.eclipse.wst.xml.core.internal.provisional.document.IDOMDocument; +import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Extracts the selection and writes it out as a separate layout file, then adds an + * include to that new layout file. Interactively asks the user for a new name for the + * layout. + */ +@SuppressWarnings("restriction") // XML model +public class ExtractIncludeRefactoring extends VisualRefactoring { + private static final String KEY_NAME = "name"; //$NON-NLS-1$ + private static final String KEY_OCCURRENCES = "all-occurrences"; //$NON-NLS-1$ + private String mLayoutName; + private boolean mReplaceOccurrences; + + /** + * This constructor is solely used by {@link Descriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + ExtractIncludeRefactoring(Map<String, String> arguments) { + super(arguments); + mLayoutName = arguments.get(KEY_NAME); + mReplaceOccurrences = Boolean.parseBoolean(arguments.get(KEY_OCCURRENCES)); + } + + public ExtractIncludeRefactoring( + IFile file, + LayoutEditorDelegate delegate, + ITextSelection selection, + ITreeSelection treeSelection) { + super(file, delegate, selection, treeSelection); + } + + @VisibleForTesting + ExtractIncludeRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { + super(selectedElements, editor); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 6); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("No selection to extract"); + return status; + } + + // Make sure the selection is contiguous + if (mTreeSelection != null) { + // TODO - don't do this if we based the selection on text. In this case, + // make sure we're -balanced-. + List<CanvasViewInfo> infos = getSelectedViewInfos(); + if (!validateNotEmpty(infos, status)) { + return status; + } + + if (!validateNotRoot(infos, status)) { + return status; + } + + // Disable if you've selected a single include tag + if (infos.size() == 1) { + UiViewElementNode uiNode = infos.get(0).getUiViewNode(); + if (uiNode != null) { + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode.getLocalName().equals(VIEW_INCLUDE)) { + status.addWarning("No point in refactoring a single include tag"); + } + } + } + + // Enforce that the selection is -contiguous- + if (!validateContiguous(infos, status)) { + return status; + } + } + + // This also ensures that we have a valid DOM model: + if (mElements.size() == 0) { + status.addFatalError("Nothing to extract"); + return status; + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + protected Map<String, String> createArgumentMap() { + Map<String, String> args = super.createArgumentMap(); + args.put(KEY_NAME, mLayoutName); + args.put(KEY_OCCURRENCES, Boolean.toString(mReplaceOccurrences)); + + return args; + } + + @Override + public String getName() { + return "Extract as Include"; + } + + void setLayoutName(String layoutName) { + mLayoutName = layoutName; + } + + void setReplaceOccurrences(boolean selection) { + mReplaceOccurrences = selection; + } + + // ---- Actual implementation of Extract as Include modification computation ---- + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + String extractedText = getExtractedText(); + + String namespaceDeclarations = computeNamespaceDeclarations(); + + // Insert namespace: + extractedText = insertNamespace(extractedText, namespaceDeclarations); + + StringBuilder sb = new StringBuilder(); + sb.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"); //$NON-NLS-1$ + sb.append(extractedText); + sb.append('\n'); + + List<Change> changes = new ArrayList<Change>(); + + String newFileName = mLayoutName + DOT_XML; + IProject project = mDelegate.getEditor().getProject(); + IFile sourceFile = mDelegate.getEditor().getInputFile(); + if (sourceFile == null) { + return changes; + } + + // Replace extracted elements by <include> tag + handleIncludingFile(changes, sourceFile, mSelectionStart, mSelectionEnd, + getDomDocument(), getPrimaryElement()); + + // Also extract in other variations of the same file (landscape/portrait, etc) + boolean haveVariations = false; + if (mReplaceOccurrences) { + List<IFile> layouts = getOtherLayouts(sourceFile); + for (IFile file : layouts) { + IModelManager modelManager = StructuredModelManager.getModelManager(); + IStructuredModel model = null; + // We could enhance this with a SubMonitor to make the progress bar move as + // well. + monitor.subTask(String.format("Looking for duplicates in %1$s", + file.getProjectRelativePath())); + if (monitor.isCanceled()) { + throw new OperationCanceledException(); + } + + try { + model = modelManager.getModelForRead(file); + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + IDOMDocument otherDocument = domModel.getDocument(); + List<Element> otherElements = new ArrayList<Element>(); + Element otherPrimary = null; + + for (Element element : getElements()) { + Element other = DomUtilities.findCorresponding(element, + otherDocument); + if (other != null) { + // See if the structure is similar to what we have in this + // document + if (DomUtilities.isEquivalent(element, other)) { + otherElements.add(other); + if (element == getPrimaryElement()) { + otherPrimary = other; + } + } + } + } + + // Only perform extract in the other file if we find a match for + // ALL of elements being extracted, and if they too are contiguous + if (otherElements.size() == getElements().size() && + DomUtilities.isContiguous(otherElements)) { + // Find the range + int begin = Integer.MAX_VALUE; + int end = Integer.MIN_VALUE; + for (Element element : otherElements) { + // Yes!! Extract this one as well! + IndexedRegion region = getRegion(element); + end = Math.max(end, region.getEndOffset()); + begin = Math.min(begin, region.getStartOffset()); + } + handleIncludingFile(changes, file, begin, + end, otherDocument, otherPrimary); + haveVariations = true; + } + } + } catch (IOException e) { + AdtPlugin.log(e, null); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } finally { + if (model != null) { + model.releaseFromRead(); + } + } + } + } + + // Add change to create the new file + IContainer parent = sourceFile.getParent(); + if (haveVariations) { + // If we're extracting from multiple configuration folders, then we need to + // place the extracted include in the base layout folder (if not it goes next to + // the including file) + parent = mProject.getFolder(FD_RES).getFolder(FD_RES_LAYOUT); + } + IPath parentPath = parent.getProjectRelativePath(); + final IFile file = project.getFile(new Path(parentPath + WS_SEP + newFileName)); + TextFileChange addFile = new TextFileChange("Create new separate layout", file); + addFile.setTextType(EXT_XML); + changes.add(addFile); + + String newFile = sb.toString(); + if (AdtPrefs.getPrefs().getFormatGuiXml()) { + newFile = EclipseXmlPrettyPrinter.prettyPrint(newFile, + EclipseXmlFormatPreferences.create(), XmlFormatStyle.LAYOUT, + null /*lineSeparator*/); + } + addFile.setEdit(new InsertEdit(0, newFile)); + + Change finishHook = createFinishHook(file); + changes.add(finishHook); + + return changes; + } + + private void handleIncludingFile(List<Change> changes, + IFile sourceFile, int begin, int end, Document document, Element primary) { + TextFileChange change = new TextFileChange(sourceFile.getName(), sourceFile); + MultiTextEdit rootEdit = new MultiTextEdit(); + change.setTextType(EXT_XML); + changes.add(change); + + String referenceId = getReferenceId(); + // Replace existing elements in the source file and insert <include> + String androidNsPrefix = getAndroidNamespacePrefix(document); + String include = computeIncludeString(primary, mLayoutName, androidNsPrefix, referenceId); + int length = end - begin; + ReplaceEdit replace = new ReplaceEdit(begin, length, include); + rootEdit.addChild(replace); + + // Update any layout references to the old id with the new id + if (referenceId != null && primary != null) { + String rootId = getId(primary); + IStructuredModel model = null; + try { + model = StructuredModelManager.getModelManager().getModelForRead(sourceFile); + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null && rootId != null) { + List<TextEdit> replaceIds = replaceIds(androidNsPrefix, doc, begin, + end, rootId, referenceId); + for (TextEdit edit : replaceIds) { + rootEdit.addChild(edit); + } + + if (AdtPrefs.getPrefs().getFormatGuiXml()) { + MultiTextEdit formatted = reformat(doc.get(), rootEdit, + XmlFormatStyle.LAYOUT); + if (formatted != null) { + rootEdit = formatted; + } + } + } + } catch (IOException e) { + AdtPlugin.log(e, null); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } finally { + if (model != null) { + model.releaseFromRead(); + } + } + } + + change.setEdit(rootEdit); + } + + /** + * Returns a list of all the other layouts (in all configurations) in the project other + * than the given source layout where the refactoring was initiated. Never null. + */ + private List<IFile> getOtherLayouts(IFile sourceFile) { + List<IFile> layouts = new ArrayList<IFile>(100); + IPath sourcePath = sourceFile.getProjectRelativePath(); + IFolder resources = mProject.getFolder(FD_RESOURCES); + try { + for (IResource folder : resources.members()) { + if (folder.getName().startsWith(FD_RES_LAYOUT) && + folder instanceof IFolder) { + IFolder layoutFolder = (IFolder) folder; + for (IResource file : layoutFolder.members()) { + if (file.getName().endsWith(EXT_XML) + && file instanceof IFile) { + if (!file.getProjectRelativePath().equals(sourcePath)) { + layouts.add((IFile) file); + } + } + } + } + } + } catch (CoreException e) { + AdtPlugin.log(e, null); + } + + return layouts; + } + + String getInitialName() { + String defaultName = ""; //$NON-NLS-1$ + Element primary = getPrimaryElement(); + if (primary != null) { + String id = primary.getAttributeNS(ANDROID_URI, ATTR_ID); + // id null check for https://bugs.eclipse.org/bugs/show_bug.cgi?id=272378 + if (id != null && (id.startsWith(ID_PREFIX) || id.startsWith(NEW_ID_PREFIX))) { + // Use everything following the id/, and make it lowercase since that is + // the convention for layouts (and use Locale.US to ensure that "Image" becomes + // "image" etc) + defaultName = id.substring(id.indexOf('/') + 1).toLowerCase(Locale.US); + + IInputValidator validator = ResourceNameValidator.create(true, mProject, LAYOUT); + + if (validator.isValid(defaultName) != null) { // Already exists? + defaultName = ""; //$NON-NLS-1$ + } + } + } + + return defaultName; + } + + IFile getSourceFile() { + return mFile; + } + + private Change createFinishHook(final IFile file) { + return new NullChange("Open extracted layout and refresh resources") { + @Override + public Change perform(IProgressMonitor pm) throws CoreException { + Display display = AdtPlugin.getDisplay(); + display.asyncExec(new Runnable() { + @Override + public void run() { + openFile(file); + mDelegate.getGraphicalEditor().refreshProjectResources(); + // Save file to trigger include finder scanning (as well as making + // the + // actual show-include feature work since it relies on reading + // files from + // disk, not a live buffer) + IWorkbenchPage page = mDelegate.getEditor().getEditorSite().getPage(); + page.saveEditor(mDelegate.getEditor(), false); + } + }); + + // Not undoable: just return null instead of an undo-change. + return null; + } + }; + } + + private String computeNamespaceDeclarations() { + String androidNsPrefix = null; + String namespaceDeclarations = null; + + StringBuilder sb = new StringBuilder(); + List<Attr> attributeNodes = findNamespaceAttributes(); + for (Node attributeNode : attributeNodes) { + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + sb.append(' '); + String name = attributeNode.getNodeName(); + sb.append(name); + sb.append('=').append('"'); + + String value = attributeNode.getNodeValue(); + if (value.equals(ANDROID_URI)) { + androidNsPrefix = name; + if (androidNsPrefix.startsWith(XMLNS_PREFIX)) { + androidNsPrefix = androidNsPrefix.substring(XMLNS_PREFIX.length()); + } + } + sb.append(XmlUtils.toXmlAttributeValue(value)); + sb.append('"'); + } + } + namespaceDeclarations = sb.toString(); + + if (androidNsPrefix == null) { + androidNsPrefix = ANDROID_NS_NAME; + } + + if (namespaceDeclarations.length() == 0) { + sb.setLength(0); + sb.append(' '); + sb.append(XMLNS_PREFIX); + sb.append(androidNsPrefix); + sb.append('=').append('"'); + sb.append(ANDROID_URI); + sb.append('"'); + namespaceDeclarations = sb.toString(); + } + + return namespaceDeclarations; + } + + /** Returns the id to be used for the include tag itself (may be null) */ + private String getReferenceId() { + String rootId = getRootId(); + if (rootId != null) { + return rootId + "_ref"; + } + + return null; + } + + /** + * Compute the actual {@code <include>} string to be inserted in place of the old + * selection + */ + private static String computeIncludeString(Element primaryNode, String newName, + String androidNsPrefix, String referenceId) { + StringBuilder sb = new StringBuilder(); + sb.append("<include layout=\"@layout/"); //$NON-NLS-1$ + sb.append(newName); + sb.append('"'); + sb.append(' '); + + // Create new id for the include itself + if (referenceId != null) { + sb.append(androidNsPrefix); + sb.append(':'); + sb.append(ATTR_ID); + sb.append('=').append('"'); + sb.append(referenceId); + sb.append('"').append(' '); + } + + // Add id string, unless it's a <merge>, since we may need to adjust any layout + // references to apply to the <include> tag instead + + // I should move all the layout_ attributes as well + // I also need to duplicate and modify the id and then replace + // everything else in the file with this new id... + + // HACK: see issue 13494: We must duplicate the width/height attributes on the + // <include> statement for designtime rendering only + String width = null; + String height = null; + if (primaryNode == null) { + // Multiple selection - in that case we will be creating an outer <merge> + // so we need to set our own width/height on it + width = height = VALUE_WRAP_CONTENT; + } else { + if (!primaryNode.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH)) { + width = VALUE_WRAP_CONTENT; + } else { + width = primaryNode.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + } + if (!primaryNode.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT)) { + height = VALUE_WRAP_CONTENT; + } else { + height = primaryNode.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + } + } + if (width != null) { + sb.append(' '); + sb.append(androidNsPrefix); + sb.append(':'); + sb.append(ATTR_LAYOUT_WIDTH); + sb.append('=').append('"'); + sb.append(XmlUtils.toXmlAttributeValue(width)); + sb.append('"'); + } + if (height != null) { + sb.append(' '); + sb.append(androidNsPrefix); + sb.append(':'); + sb.append(ATTR_LAYOUT_HEIGHT); + sb.append('=').append('"'); + sb.append(XmlUtils.toXmlAttributeValue(height)); + sb.append('"'); + } + + // Duplicate all the other layout attributes as well + if (primaryNode != null) { + NamedNodeMap attributes = primaryNode.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attr = attributes.item(i); + String name = attr.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attr.getNamespaceURI())) { + if (name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) { + // Already handled + continue; + } + + sb.append(' '); + sb.append(androidNsPrefix); + sb.append(':'); + sb.append(name); + sb.append('=').append('"'); + sb.append(XmlUtils.toXmlAttributeValue(attr.getNodeValue())); + sb.append('"'); + } + } + } + + sb.append("/>"); + return sb.toString(); + } + + /** Return the text in the document in the range start to end */ + private String getExtractedText() { + String xml = getText(mSelectionStart, mSelectionEnd); + Element primaryNode = getPrimaryElement(); + xml = stripTopLayoutAttributes(primaryNode, mSelectionStart, xml); + xml = dedent(xml); + + // Wrap siblings in <merge>? + if (primaryNode == null) { + StringBuilder sb = new StringBuilder(); + sb.append("<merge>\n"); //$NON-NLS-1$ + // indent an extra level + for (String line : xml.split("\n")) { //$NON-NLS-1$ + sb.append(" "); //$NON-NLS-1$ + sb.append(line).append('\n'); + } + sb.append("</merge>\n"); //$NON-NLS-1$ + xml = sb.toString(); + } + + return xml; + } + + @Override + VisualRefactoringWizard createWizard() { + return new ExtractIncludeWizard(this, mDelegate); + } + + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.extract.include", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new ExtractIncludeRefactoring(args); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeWizard.java new file mode 100644 index 000000000..f3ac3f1b3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractIncludeWizard.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; +import com.android.resources.ResourceType; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Text; + +class ExtractIncludeWizard extends VisualRefactoringWizard { + public ExtractIncludeWizard(ExtractIncludeRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle(ref.getName()); + } + + @Override + protected void addUserInputPages() { + ExtractIncludeRefactoring ref = (ExtractIncludeRefactoring) getRefactoring(); + String initialName = ref.getInitialName(); + IFile sourceFile = ref.getSourceFile(); + addPage(new InputPage(mDelegate.getEditor().getProject(), sourceFile, initialName)); + } + + /** Wizard page which inputs parameters for the {@link ExtractIncludeRefactoring} operation */ + private static class InputPage extends VisualRefactoringInputPage { + private final IProject mProject; + private final IFile mSourceFile; + private final String mSuggestedName; + private Text mNameText; + private Button mReplaceAllOccurrences; + + public InputPage(IProject project, IFile sourceFile, String suggestedName) { + super("ExtractIncludeInputPage"); + mProject = project; + mSourceFile = sourceFile; + mSuggestedName = suggestedName; + } + + @Override + public void createControl(Composite parent) { + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(2, false)); + + Label nameLabel = new Label(composite, SWT.NONE); + nameLabel.setText("New Layout Name:"); + nameLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); + + mNameText = new Text(composite, SWT.BORDER); + mNameText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + mNameText.addModifyListener(mModifyValidateListener); + + mReplaceAllOccurrences = new Button(composite, SWT.CHECK); + mReplaceAllOccurrences.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, + false, false, 2, 1)); + mReplaceAllOccurrences.setText( + "Replace occurrences in all layouts with include to new layout"); + mReplaceAllOccurrences.setEnabled(true); + mReplaceAllOccurrences.setSelection(true); + mReplaceAllOccurrences.addSelectionListener(mSelectionValidateListener); + + // Initialize UI: + if (mSuggestedName != null) { + mNameText.setText(mSuggestedName); + } + + setControl(composite); + validatePage(); + } + + @Override + protected boolean validatePage() { + boolean ok = true; + + String text = mNameText.getText().trim(); + + if (text.length() == 0) { + setErrorMessage("Provide a name for the new layout"); + ok = false; + } else { + ResourceNameValidator validator = ResourceNameValidator.create(false, mProject, + ResourceType.LAYOUT); + String message = validator.isValid(text); + if (message != null) { + setErrorMessage(message); + ok = false; + } + } + + if (ok) { + setErrorMessage(null); + + // Record state + ExtractIncludeRefactoring refactoring = + (ExtractIncludeRefactoring) getRefactoring(); + refactoring.setLayoutName(text); + refactoring.setReplaceOccurrences(mReplaceAllOccurrences.getSelection()); + } + + setPageComplete(ok); + return ok; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleAction.java new file mode 100644 index 000000000..4a498637d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleAction.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Extract Style" menu item is invoked. + */ +public class ExtractStyleAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + ExtractStyleRefactoring ref = new ExtractStyleRefactoring(mFile, mDelegate, + mTextSelection, mTreeSelection); + RefactoringWizard wizard = new ExtractStyleWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Extract Style...", editorDelegate, ExtractStyleAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleContribution.java new file mode 100644 index 000000000..95fbdbc43 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleContribution.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import org.eclipse.ltk.core.refactoring.RefactoringContribution; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; + +import java.util.Map; + +public class ExtractStyleContribution extends RefactoringContribution { + + @SuppressWarnings("unchecked") + @Override + public RefactoringDescriptor createDescriptor(String id, String project, String description, + String comment, Map arguments, int flags) throws IllegalArgumentException { + return new ExtractStyleRefactoring.Descriptor(project, description, comment, arguments); + } + + @SuppressWarnings("unchecked") + @Override + public Map retrieveArgumentMap(RefactoringDescriptor descriptor) { + if (descriptor instanceof ExtractStyleRefactoring.Descriptor) { + return ((ExtractStyleRefactoring.Descriptor) descriptor).getArguments(); + } + return super.retrieveArgumentMap(descriptor); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleRefactoring.java new file mode 100644 index 000000000..9b1770d82 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleRefactoring.java @@ -0,0 +1,579 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_NS_NAME; +import static com.android.SdkConstants.ANDROID_NS_NAME_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_HINT; +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.ATTR_NAME; +import static com.android.SdkConstants.ATTR_ON_CLICK; +import static com.android.SdkConstants.ATTR_PARENT; +import static com.android.SdkConstants.ATTR_SRC; +import static com.android.SdkConstants.ATTR_STYLE; +import static com.android.SdkConstants.ATTR_TEXT; +import static com.android.SdkConstants.EXT_XML; +import static com.android.SdkConstants.FD_RESOURCES; +import static com.android.SdkConstants.FD_RES_VALUES; +import static com.android.SdkConstants.PREFIX_ANDROID; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.REFERENCE_STYLE; +import static com.android.SdkConstants.TAG_ITEM; +import static com.android.SdkConstants.TAG_RESOURCES; +import static com.android.SdkConstants.XMLNS_PREFIX; +import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP; + +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.rendering.api.ResourceValue; +import com.android.ide.common.resources.ResourceResolver; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.DescriptorsUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.wizards.newxmlfile.NewXmlFileWizard; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.core.runtime.Path; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.wst.sse.core.StructuredModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IModelManager; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.eclipse.wst.xml.core.internal.provisional.document.IDOMDocument; +import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Extracts the selection and writes it out as a separate layout file, then adds an + * include to that new layout file. Interactively asks the user for a new name for the + * layout. + * <p> + * Remaining work to do / Possible enhancements: + * <ul> + * <li>Optionally look in other files in the project and attempt to set style attributes + * in other cases where the style attributes match? + * <li>If the elements we are extracting from already contain a style attribute, set that + * style as the parent style of the current style? + * <li>Add a parent-style picker to the wizard (initialized with the above if applicable) + * <li>Pick up indentation settings from the XML module + * <li>Integrate with themes somehow -- make an option to have the extracted style go into + * the theme instead + * </ul> + */ +@SuppressWarnings("restriction") // XML model +public class ExtractStyleRefactoring extends VisualRefactoring { + private static final String KEY_NAME = "name"; //$NON-NLS-1$ + private static final String KEY_REMOVE_EXTRACTED = "removeextracted"; //$NON-NLS-1$ + private static final String KEY_REMOVE_ALL = "removeall"; //$NON-NLS-1$ + private static final String KEY_APPLY_STYLE = "applystyle"; //$NON-NLS-1$ + private static final String KEY_PARENT = "parent"; //$NON-NLS-1$ + private String mStyleName; + /** The name of the file in res/values/ that the style will be added to. Normally + * res/values/styles.xml - but unit tests pick other names */ + private String mStyleFileName = "styles.xml"; + /** Set a style reference on the extracted elements? */ + private boolean mApplyStyle; + /** Remove the attributes that were extracted? */ + private boolean mRemoveExtracted; + /** List of attributes chosen by the user to be extracted */ + private List<Attr> mChosenAttributes = new ArrayList<Attr>(); + /** Remove all attributes that match the extracted attributes names, regardless of value */ + private boolean mRemoveAll; + /** The parent style to extend */ + private String mParent; + /** The full list of available attributes in the refactoring */ + private Map<String, List<Attr>> mAvailableAttributes; + + /** + * This constructor is solely used by {@link Descriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + ExtractStyleRefactoring(Map<String, String> arguments) { + super(arguments); + mStyleName = arguments.get(KEY_NAME); + mRemoveExtracted = Boolean.parseBoolean(arguments.get(KEY_REMOVE_EXTRACTED)); + mRemoveAll = Boolean.parseBoolean(arguments.get(KEY_REMOVE_ALL)); + mApplyStyle = Boolean.parseBoolean(arguments.get(KEY_APPLY_STYLE)); + mParent = arguments.get(KEY_PARENT); + if (mParent != null && mParent.length() == 0) { + mParent = null; + } + } + + public ExtractStyleRefactoring( + IFile file, + LayoutEditorDelegate delegate, + ITextSelection selection, + ITreeSelection treeSelection) { + super(file, delegate, selection, treeSelection); + } + + @VisibleForTesting + ExtractStyleRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { + super(selectedElements, editor); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 6); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("No selection to extract"); + return status; + } + + // This also ensures that we have a valid DOM model: + if (mElements.size() == 0) { + status.addFatalError("Nothing to extract"); + return status; + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + protected Map<String, String> createArgumentMap() { + Map<String, String> args = super.createArgumentMap(); + args.put(KEY_NAME, mStyleName); + args.put(KEY_REMOVE_EXTRACTED, Boolean.toString(mRemoveExtracted)); + args.put(KEY_REMOVE_ALL, Boolean.toString(mRemoveAll)); + args.put(KEY_APPLY_STYLE, Boolean.toString(mApplyStyle)); + args.put(KEY_PARENT, mParent != null ? mParent : ""); + + return args; + } + + @Override + public String getName() { + return "Extract Style"; + } + + void setStyleName(String styleName) { + mStyleName = styleName; + } + + void setStyleFileName(String styleFileName) { + mStyleFileName = styleFileName; + } + + void setChosenAttributes(List<Attr> attributes) { + mChosenAttributes = attributes; + } + + void setRemoveExtracted(boolean removeExtracted) { + mRemoveExtracted = removeExtracted; + } + + void setApplyStyle(boolean applyStyle) { + mApplyStyle = applyStyle; + } + + void setRemoveAll(boolean removeAll) { + mRemoveAll = removeAll; + } + + void setParent(String parent) { + mParent = parent; + } + + // ---- Actual implementation of Extract Style modification computation ---- + + /** + * Returns two items: a map from attribute name to a list of attribute nodes of that + * name, and a subset of these attributes that fall within the text selection + * (used to drive initial selection in the wizard) + */ + Pair<Map<String, List<Attr>>, Set<Attr>> getAvailableAttributes() { + mAvailableAttributes = new TreeMap<String, List<Attr>>(); + Set<Attr> withinSelection = new HashSet<Attr>(); + for (Element element : getElements()) { + IndexedRegion elementRegion = getRegion(element); + boolean allIncluded = + (mOriginalSelectionStart <= elementRegion.getStartOffset() && + mOriginalSelectionEnd >= elementRegion.getEndOffset()); + + NamedNodeMap attributeMap = element.getAttributes(); + for (int i = 0, n = attributeMap.getLength(); i < n; i++) { + Attr attribute = (Attr) attributeMap.item(i); + + String name = attribute.getLocalName(); + if (!isStylableAttribute(name)) { + // Don't offer to extract attributes that don't make sense in + // styles (like "id" or "style"), or attributes that the user + // probably does not want to define in styles (like layout + // attributes such as layout_width, or the label of a button etc). + // This makes the options offered listed in the wizard simpler. + // In special cases where the user *does* want to set one of these + // attributes, they can always do it manually so optimize for + // the common case here. + continue; + } + + // Skip attributes that are in a namespace other than the Android one + String namespace = attribute.getNamespaceURI(); + if (namespace != null && !ANDROID_URI.equals(namespace)) { + continue; + } + + if (!allIncluded) { + IndexedRegion region = getRegion(attribute); + boolean attributeIncluded = mOriginalSelectionStart < region.getEndOffset() && + mOriginalSelectionEnd >= region.getStartOffset(); + if (attributeIncluded) { + withinSelection.add(attribute); + } + } else { + withinSelection.add(attribute); + } + + List<Attr> list = mAvailableAttributes.get(name); + if (list == null) { + list = new ArrayList<Attr>(); + mAvailableAttributes.put(name, list); + } + list.add(attribute); + } + } + + return Pair.of(mAvailableAttributes, withinSelection); + } + + /** + * Returns whether the given local attribute name is one the style wizard + * should present as a selectable attribute to be extracted. + * + * @param name the attribute name, not including a namespace prefix + * @return true if the name is one that the user can extract + */ + public static boolean isStylableAttribute(String name) { + return !(name == null + || name.equals(ATTR_ID) + || name.startsWith(ATTR_STYLE) + || (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) && + !name.startsWith(ATTR_LAYOUT_MARGIN)) + || name.equals(ATTR_TEXT) + || name.equals(ATTR_HINT) + || name.equals(ATTR_SRC) + || name.equals(ATTR_ON_CLICK)); + } + + IFile getStyleFile(IProject project) { + return project.getFile(new Path(FD_RESOURCES + WS_SEP + FD_RES_VALUES + WS_SEP + + mStyleFileName)); + } + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + List<Change> changes = new ArrayList<Change>(); + if (mChosenAttributes.size() == 0) { + return changes; + } + + IFile file = getStyleFile(mDelegate.getEditor().getProject()); + boolean createFile = !file.exists(); + int insertAtIndex; + String initialIndent = null; + if (!createFile) { + Pair<Integer, String> context = computeInsertContext(file); + insertAtIndex = context.getFirst(); + initialIndent = context.getSecond(); + } else { + insertAtIndex = 0; + } + + TextFileChange addFile = new TextFileChange("Create new separate style declaration", file); + addFile.setTextType(EXT_XML); + changes.add(addFile); + String styleString = computeStyleDeclaration(createFile, initialIndent); + addFile.setEdit(new InsertEdit(insertAtIndex, styleString)); + + // Remove extracted attributes? + MultiTextEdit rootEdit = new MultiTextEdit(); + if (mRemoveExtracted || mRemoveAll) { + for (Attr attribute : mChosenAttributes) { + List<Attr> list = mAvailableAttributes.get(attribute.getLocalName()); + for (Attr attr : list) { + if (mRemoveAll || attr.getValue().equals(attribute.getValue())) { + removeAttribute(rootEdit, attr); + } + } + } + } + + // Set the style attribute? + if (mApplyStyle) { + for (Element element : getElements()) { + String value = PREFIX_RESOURCE_REF + REFERENCE_STYLE + mStyleName; + setAttribute(rootEdit, element, null, null, ATTR_STYLE, value); + } + } + + if (rootEdit.hasChildren()) { + IFile sourceFile = mDelegate.getEditor().getInputFile(); + if (sourceFile == null) { + return changes; + } + TextFileChange change = new TextFileChange(sourceFile.getName(), sourceFile); + change.setTextType(EXT_XML); + changes.add(change); + + if (AdtPrefs.getPrefs().getFormatGuiXml()) { + MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT); + if (formatted != null) { + rootEdit = formatted; + } + } + + change.setEdit(rootEdit); + } + + return changes; + } + + private String computeStyleDeclaration(boolean createFile, String initialIndent) { + StringBuilder sb = new StringBuilder(); + if (createFile) { + sb.append(NewXmlFileWizard.XML_HEADER_LINE); + sb.append('<').append(TAG_RESOURCES).append(' '); + sb.append(XMLNS_PREFIX).append(ANDROID_NS_NAME).append('=').append('"'); + sb.append(ANDROID_URI); + sb.append('"').append('>').append('\n'); + } + + // Indent. Use the existing indent found for previous <style> elements in + // the resource file - but if that indent was 0 (e.g. <style> elements are + // at the left margin) only use it to indent the style elements and use a real + // nonzero indent for its children. + String indent = " "; //$NON-NLS-1$ + if (initialIndent == null) { + initialIndent = indent; + } else if (initialIndent.length() > 0) { + indent = initialIndent; + } + sb.append(initialIndent); + String styleTag = "style"; //$NON-NLS-1$ // TODO - use constant in parallel changeset + sb.append('<').append(styleTag).append(' ').append(ATTR_NAME).append('=').append('"'); + sb.append(mStyleName); + sb.append('"'); + if (mParent != null) { + sb.append(' ').append(ATTR_PARENT).append('=').append('"'); + sb.append(mParent); + sb.append('"'); + } + sb.append('>').append('\n'); + + for (Attr attribute : mChosenAttributes) { + sb.append(initialIndent).append(indent); + sb.append('<').append(TAG_ITEM).append(' ').append(ATTR_NAME).append('=').append('"'); + // We've already enforced that regardless of prefix, only attributes with + // an Android namespace can be in the set of chosen attributes. Rewrite the + // prefix to android here. + if (attribute.getPrefix() != null) { + sb.append(ANDROID_NS_NAME_PREFIX); + } + sb.append(attribute.getLocalName()); + sb.append('"').append('>'); + sb.append(attribute.getValue()); + sb.append('<').append('/').append(TAG_ITEM).append('>').append('\n'); + } + sb.append(initialIndent).append('<').append('/').append(styleTag).append('>').append('\n'); + + if (createFile) { + sb.append('<').append('/').append(TAG_RESOURCES).append('>').append('\n'); + } + String styleString = sb.toString(); + return styleString; + } + + /** Computes the location in the file to insert the new style element at, as well as + * the exact indent string to use to indent the {@code <style>} element. + * @param file the styles.xml file to insert into + * @return a pair of an insert offset and an indent string + */ + private Pair<Integer, String> computeInsertContext(final IFile file) { + int insertAtIndex = -1; + // Find the insert of the final </resources> item where we will insert + // the new style elements. + String indent = null; + IModelManager modelManager = StructuredModelManager.getModelManager(); + IStructuredModel model = null; + try { + model = modelManager.getModelForRead(file); + if (model instanceof IDOMModel) { + IDOMModel domModel = (IDOMModel) model; + IDOMDocument otherDocument = domModel.getDocument(); + Element root = otherDocument.getDocumentElement(); + Node lastChild = root.getLastChild(); + if (lastChild != null) { + if (lastChild instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) lastChild; + insertAtIndex = region.getStartOffset() + region.getLength(); + } + + // Compute indent + while (lastChild != null) { + if (lastChild.getNodeType() == Node.ELEMENT_NODE) { + IStructuredDocument document = model.getStructuredDocument(); + indent = AndroidXmlEditor.getIndent(document, lastChild); + break; + } + lastChild = lastChild.getPreviousSibling(); + } + } + } + } catch (IOException e) { + AdtPlugin.log(e, null); + } catch (CoreException e) { + AdtPlugin.log(e, null); + } finally { + if (model != null) { + model.releaseFromRead(); + } + } + + if (insertAtIndex == -1) { + String contents = AdtPlugin.readFile(file); + insertAtIndex = contents.indexOf("</" + TAG_RESOURCES + ">"); //$NON-NLS-1$ + if (insertAtIndex == -1) { + insertAtIndex = contents.length(); + } + } + + return Pair.of(insertAtIndex, indent); + } + + @Override + VisualRefactoringWizard createWizard() { + return new ExtractStyleWizard(this, mDelegate); + } + + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.extract.style", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new ExtractStyleRefactoring(args); + } + } + + /** + * Determines the parent style to be used for this refactoring + * + * @return the parent style to be used for this refactoring + */ + public String getParentStyle() { + Set<String> styles = new HashSet<String>(); + for (Element element : getElements()) { + // Includes "" for elements not setting the style + styles.add(element.getAttribute(ATTR_STYLE)); + } + + if (styles.size() > 1) { + // The elements differ in what style attributes they are set to + return null; + } + + String style = styles.iterator().next(); + if (style != null && style.length() > 0) { + return style; + } + + // None of the elements set the style -- see if they have the same widget types + // and if so offer to extend the theme style for that widget type + + Set<String> types = new HashSet<String>(); + for (Element element : getElements()) { + types.add(element.getTagName()); + } + + if (types.size() == 1) { + String view = DescriptorsUtils.getBasename(types.iterator().next()); + + ResourceResolver resolver = mDelegate.getGraphicalEditor().getResourceResolver(); + // Look up the theme item name, which for a Button would be "buttonStyle", and so on. + String n = Character.toLowerCase(view.charAt(0)) + view.substring(1) + + "Style"; //$NON-NLS-1$ + ResourceValue value = resolver.findItemInTheme(n); + if (value != null) { + ResourceValue resolvedValue = resolver.resolveResValue(value); + String name = resolvedValue.getName(); + if (name != null) { + if (resolvedValue.isFramework()) { + return PREFIX_ANDROID + name; + } else { + return name; + } + } + } + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleWizard.java new file mode 100644 index 000000000..187452d21 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/ExtractStyleWizard.java @@ -0,0 +1,440 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static org.eclipse.jface.viewers.StyledString.DECORATIONS_STYLER; +import static org.eclipse.jface.viewers.StyledString.QUALIFIER_STYLER; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; +import com.android.resources.ResourceType; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.jface.viewers.CheckStateChangedEvent; +import org.eclipse.jface.viewers.CheckboxTableViewer; +import org.eclipse.jface.viewers.ICheckStateListener; +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.StyledCellLabelProvider; +import org.eclipse.jface.viewers.StyledString; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.jface.viewers.ViewerCell; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.layout.RowLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.Text; +import org.w3c.dom.Attr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +class ExtractStyleWizard extends VisualRefactoringWizard { + public ExtractStyleWizard(ExtractStyleRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle(ref.getName()); + } + + @Override + protected void addUserInputPages() { + String initialName = "styleName"; + addPage(new InputPage(mDelegate.getEditor().getProject(), initialName)); + } + + /** + * Wizard page which inputs parameters for the {@link ExtractStyleRefactoring} + * operation + */ + private static class InputPage extends VisualRefactoringInputPage { + private final IProject mProject; + private final String mSuggestedName; + private Text mNameText; + private Table mTable; + private Button mRemoveExtracted; + private Button mSetStyle; + private Button mRemoveAll; + private Button mExtend; + private CheckboxTableViewer mCheckedView; + + private String mParentStyle; + private Set<Attr> mInSelection; + private List<Attr> mAllAttributes; + private int mElementCount; + private Map<Attr, Integer> mFrequencyCount; + private Set<Attr> mShown; + private List<Attr> mInitialChecked; + private List<Attr> mAllChecked; + private List<Map.Entry<String, List<Attr>>> mRoot; + private Map<String, List<Attr>> mAvailableAttributes; + + public InputPage(IProject project, String suggestedName) { + super("ExtractStyleInputPage"); + mProject = project; + mSuggestedName = suggestedName; + } + + @Override + public void createControl(Composite parent) { + initialize(); + + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(2, false)); + + Label nameLabel = new Label(composite, SWT.NONE); + nameLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); + nameLabel.setText("Style Name:"); + + mNameText = new Text(composite, SWT.BORDER); + mNameText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + mNameText.addModifyListener(mModifyValidateListener); + + mRemoveExtracted = new Button(composite, SWT.CHECK); + mRemoveExtracted.setSelection(true); + mRemoveExtracted.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 2, 1)); + mRemoveExtracted.setText("Remove extracted attributes"); + mRemoveExtracted.addSelectionListener(mSelectionValidateListener); + + mRemoveAll = new Button(composite, SWT.CHECK); + mRemoveAll.setSelection(false); + mRemoveAll.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 2, 1)); + mRemoveAll.setText("Remove all extracted attributes regardless of value"); + mRemoveAll.addSelectionListener(mSelectionValidateListener); + + boolean defaultSetStyle = false; + if (mParentStyle != null) { + mExtend = new Button(composite, SWT.CHECK); + mExtend.setSelection(true); + mExtend.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 2, 1)); + mExtend.setText(String.format("Extend %1$s", mParentStyle)); + mExtend.addSelectionListener(mSelectionValidateListener); + defaultSetStyle = true; + } + + mSetStyle = new Button(composite, SWT.CHECK); + mSetStyle.setSelection(defaultSetStyle); + mSetStyle.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false, 2, 1)); + mSetStyle.setText("Set style attribute on extracted elements"); + mSetStyle.addSelectionListener(mSelectionValidateListener); + + new Label(composite, SWT.NONE); + new Label(composite, SWT.NONE); + + Label tableLabel = new Label(composite, SWT.NONE); + tableLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 2, 1)); + tableLabel.setText("Choose style attributes to extract:"); + + mCheckedView = CheckboxTableViewer.newCheckList(composite, SWT.BORDER + | SWT.FULL_SELECTION | SWT.HIDE_SELECTION); + mTable = mCheckedView.getTable(); + mTable.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true, 2, 2)); + ((GridData) mTable.getLayoutData()).heightHint = 200; + + mCheckedView.setContentProvider(new ArgumentContentProvider()); + mCheckedView.setLabelProvider(new ArgumentLabelProvider()); + mCheckedView.setInput(mRoot); + final Object[] initialSelection = mInitialChecked.toArray(); + mCheckedView.setCheckedElements(initialSelection); + + mCheckedView.addCheckStateListener(new ICheckStateListener() { + @Override + public void checkStateChanged(CheckStateChangedEvent event) { + // Try to disable other elements that conflict with this + boolean isChecked = event.getChecked(); + if (isChecked) { + Attr attribute = (Attr) event.getElement(); + List<Attr> list = mAvailableAttributes.get(attribute.getLocalName()); + for (Attr other : list) { + if (other != attribute && mShown.contains(other)) { + mCheckedView.setChecked(other, false); + } + } + } + + validatePage(); + } + }); + + // Select All / Deselect All + Composite buttonForm = new Composite(composite, SWT.NONE); + buttonForm.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false, 2, 1)); + RowLayout rowLayout = new RowLayout(SWT.HORIZONTAL); + rowLayout.marginTop = 0; + rowLayout.marginLeft = 0; + buttonForm.setLayout(rowLayout); + Button checkAllButton = new Button(buttonForm, SWT.FLAT); + checkAllButton.setText("Select All"); + checkAllButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // Select "all" (but not conflicting settings) + mCheckedView.setCheckedElements(mAllChecked.toArray()); + validatePage(); + } + }); + Button uncheckAllButton = new Button(buttonForm, SWT.FLAT); + uncheckAllButton.setText("Deselect All"); + uncheckAllButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mCheckedView.setAllChecked(false); + validatePage(); + } + }); + + // Initialize UI: + if (mSuggestedName != null) { + mNameText.setText(mSuggestedName); + } + + setControl(composite); + validatePage(); + } + + private void initialize() { + ExtractStyleRefactoring ref = (ExtractStyleRefactoring) getRefactoring(); + + mElementCount = ref.getElements().size(); + + mParentStyle = ref.getParentStyle(); + + // Set up data structures needed by the wizard -- to compute the actual + // attributes to list in the wizard (there could be multiple attributes + // of the same name (on different elements) and we only want to show one, etc.) + + Pair<Map<String, List<Attr>>, Set<Attr>> result = ref.getAvailableAttributes(); + // List of all available attributes on the selected elements + mAvailableAttributes = result.getFirst(); + // Set of attributes that overlap the text selection, or all attributes if + // wizard is invoked from GUI context + mInSelection = result.getSecond(); + + // The root data structure, which we set as the table root. The content provider + // will produce children from it. This is the entry set of a map from + // attribute name to list of attribute nodes for that attribute name. + mRoot = new ArrayList<Map.Entry<String, List<Attr>>>( + mAvailableAttributes.entrySet()); + + // Sort the items by attribute name -- the attribute name is the key + // in the entry set above. + Collections.sort(mRoot, new Comparator<Map.Entry<String, List<Attr>>>() { + @Override + public int compare(Map.Entry<String, List<Attr>> e1, + Map.Entry<String, List<Attr>> e2) { + return e1.getKey().compareTo(e2.getKey()); + } + }); + + // Set of attributes actually included in the list shown to the user. + // (There could be many additional "aliasing" nodes on other elements + // with the same name.) Note however that we DO show multiple attribute + // occurrences of the same attribute name: precisely one for each unique -value- + // of that attribute. + mShown = new HashSet<Attr>(); + + // The list of initially checked attributes. + mInitialChecked = new ArrayList<Attr>(); + + // The list of attributes to be checked if "Select All" is chosen (this is not + // the same as *all* attributes, since we need to exclude any conflicts) + mAllChecked = new ArrayList<Attr>(); + + // All attributes. + mAllAttributes = new ArrayList<Attr>(); + + // Frequency count, from attribute to integer. Attributes that do not + // appear in the list have frequency 1, not 0. + mFrequencyCount = new HashMap<Attr, Integer>(); + + for (Map.Entry<String, List<Attr>> entry : mRoot) { + // Iterate over all attributes of the same name, and sort them + // by value. This will make it easy to list each -unique- value in the + // wizard. + List<Attr> attrList = entry.getValue(); + Collections.sort(attrList, new Comparator<Attr>() { + @Override + public int compare(Attr a1, Attr a2) { + return a1.getValue().compareTo(a2.getValue()); + } + }); + + // We need to compute a couple of things: the frequency for all identical + // values (and stash them in the frequency map), and record the first + // attribute with a particular value into the list of attributes to + // be shown. + Attr prevAttr = null; + String prev = null; + List<Attr> uniqueValueAttrs = new ArrayList<Attr>(); + for (Attr attr : attrList) { + String value = attr.getValue(); + if (value.equals(prev)) { + Integer count = mFrequencyCount.get(prevAttr); + if (count == null) { + count = Integer.valueOf(2); + } else { + count = Integer.valueOf(count.intValue() + 1); + } + mFrequencyCount.put(prevAttr, count); + } else { + uniqueValueAttrs.add(attr); + prev = value; + prevAttr = attr; + } + } + + // Sort the values by frequency (and for equal frequencies, alphabetically + // by value) + Collections.sort(uniqueValueAttrs, new Comparator<Attr>() { + @Override + public int compare(Attr a1, Attr a2) { + Integer f1 = mFrequencyCount.get(a1); + Integer f2 = mFrequencyCount.get(a2); + if (f1 == null) { + f1 = Integer.valueOf(1); + } + if (f2 == null) { + f2 = Integer.valueOf(1); + } + int delta = f2.intValue() - f1.intValue(); + if (delta != 0) { + return delta; + } else { + return a1.getValue().compareTo(a2.getValue()); + } + } + }); + + // Add the items in order, and select those attributes that overlap + // the selection + mAllAttributes.addAll(uniqueValueAttrs); + mShown.addAll(uniqueValueAttrs); + Attr first = uniqueValueAttrs.get(0); + mAllChecked.add(first); + if (mInSelection.contains(first)) { + mInitialChecked.add(first); + } + } + } + + @Override + protected boolean validatePage() { + boolean ok = true; + + String text = mNameText.getText().trim(); + + if (text.length() == 0) { + setErrorMessage("Provide a name for the new style"); + ok = false; + } else { + ResourceNameValidator validator = ResourceNameValidator.create(false, mProject, + ResourceType.STYLE); + String message = validator.isValid(text); + if (message != null) { + setErrorMessage(message); + ok = false; + } + } + + Object[] checkedElements = mCheckedView.getCheckedElements(); + if (checkedElements.length == 0) { + setErrorMessage("Choose at least one attribute to extract"); + ok = false; + } + + if (ok) { + setErrorMessage(null); + + // Record state + ExtractStyleRefactoring refactoring = (ExtractStyleRefactoring) getRefactoring(); + refactoring.setStyleName(text); + refactoring.setRemoveExtracted(mRemoveExtracted.getSelection()); + refactoring.setRemoveAll(mRemoveAll.getSelection()); + refactoring.setApplyStyle(mSetStyle.getSelection()); + if (mExtend != null && mExtend.getSelection()) { + refactoring.setParent(mParentStyle); + } + List<Attr> attributes = new ArrayList<Attr>(); + for (Object o : checkedElements) { + attributes.add((Attr) o); + } + refactoring.setChosenAttributes(attributes); + } + + setPageComplete(ok); + return ok; + } + + private class ArgumentLabelProvider extends StyledCellLabelProvider { + public ArgumentLabelProvider() { + } + + @Override + public void update(ViewerCell cell) { + Object element = cell.getElement(); + Attr attribute = (Attr) element; + + StyledString styledString = new StyledString(); + styledString.append(attribute.getLocalName()); + styledString.append(" = ", QUALIFIER_STYLER); + styledString.append(attribute.getValue()); + + if (mElementCount > 1) { + Integer f = mFrequencyCount.get(attribute); + String s = String.format(" (in %d/%d elements)", + f != null ? f.intValue(): 1, mElementCount); + styledString.append(s, DECORATIONS_STYLER); + } + cell.setText(styledString.toString()); + cell.setStyleRanges(styledString.getStyleRanges()); + super.update(cell); + } + } + + private class ArgumentContentProvider implements IStructuredContentProvider { + public ArgumentContentProvider() { + } + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement == mRoot) { + return mAllAttributes.toArray(); + } + + return new Object[0]; + } + + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/GridLayoutConverter.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/GridLayoutConverter.java new file mode 100644 index 000000000..fe673a5b7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/GridLayoutConverter.java @@ -0,0 +1,988 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_BACKGROUND; +import static com.android.SdkConstants.ATTR_COLUMN_COUNT; +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_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; +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_RESOURCE_PREFIX; +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.FQCN_GRID_LAYOUT; +import static com.android.SdkConstants.FQCN_SPACE; +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.ID_PREFIX; +import static com.android.SdkConstants.LINEAR_LAYOUT; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.RADIO_GROUP; +import static com.android.SdkConstants.RELATIVE_LAYOUT; +import static com.android.SdkConstants.SPACE; +import static com.android.SdkConstants.TABLE_LAYOUT; +import static com.android.SdkConstants.TABLE_ROW; +import static com.android.SdkConstants.VALUE_FILL_PARENT; +import static com.android.SdkConstants.VALUE_HORIZONTAL; +import static com.android.SdkConstants.VALUE_MATCH_PARENT; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_HORIZ_MASK; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_VERT_MASK; + +import com.android.ide.common.api.IViewMetadata.FillPreference; +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.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.project.SupportLibraryHelper; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MalformedTreeException; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Helper class which performs the bulk of the layout conversion to grid layout + * <p> + * Future enhancements: + * <ul> + * <li>Render the layout at multiple screen sizes and analyze how the widget bounds + * change and use this to infer gravity + * <li> Use the layout_width and layout_height attributes on views to infer column and + * row flexibility (and as mentioned above, possibly layout_weight). + * move and stretch and use that to add in additional constraints + * <li> Take into account existing margins and add/subtract those from the + * bounds computations and either clear or update them. + * <li>Try to reorder elements into their natural order + * <li> Try to preserve spacing? Right now everything gets converted into a compact + * grid with no spacing between the views; consider inserting {@code <Space>} views + * with dimensions based on existing distances. + * </ul> + */ +@SuppressWarnings("restriction") // DOM model access +class GridLayoutConverter { + private final MultiTextEdit mRootEdit; + private final boolean mFlatten; + private final Element mLayout; + private final ChangeLayoutRefactoring mRefactoring; + private final CanvasViewInfo mRootView; + + private List<View> mViews; + private String mNamespace; + private int mColumnCount; + + /** Creates a new {@link GridLayoutConverter} */ + GridLayoutConverter(ChangeLayoutRefactoring refactoring, + Element layout, boolean flatten, MultiTextEdit rootEdit, CanvasViewInfo rootView) { + mRefactoring = refactoring; + mLayout = layout; + mFlatten = flatten; + mRootEdit = rootEdit; + mRootView = rootView; + } + + /** Performs conversion from any layout to a RelativeLayout */ + public void convertToGridLayout() { + if (mRootView == null) { + return; + } + + // Locate the view for the layout + CanvasViewInfo layoutView = findViewForElement(mRootView, mLayout); + if (layoutView == null || layoutView.getChildren().size() == 0) { + // No children. THAT was an easy conversion! + return; + } + + // Study the layout and get information about how to place individual elements + GridModel gridModel = new GridModel(layoutView, mLayout, mFlatten); + mViews = gridModel.getViews(); + mColumnCount = gridModel.computeColumnCount(); + + deleteRemovedElements(gridModel.getDeletedElements()); + mNamespace = mRefactoring.getAndroidNamespacePrefix(); + + processGravities(); + + // Insert space views if necessary + insertStretchableSpans(); + + // Create/update relative layout constraints + assignGridAttributes(); + + removeUndefinedAttrs(); + + if (mColumnCount > 0) { + mRefactoring.setAttribute(mRootEdit, mLayout, ANDROID_URI, + mNamespace, ATTR_COLUMN_COUNT, Integer.toString(mColumnCount)); + } + } + + private void insertStretchableSpans() { + // Look at the rows and columns and determine if we need to have a stretchable + // row and/or a stretchable column in the layout. + // In a GridLayout, a row or column is stretchable if it defines a gravity (regardless + // of what the gravity is -- in other words, a column is not just stretchable if it + // has gravity=fill but also if it has gravity=left). Furthermore, ALL the elements + // in the row/column have to be stretchable for the overall row/column to be + // considered stretchable. + + // Map from row index to boolean for "is the row fixed/inflexible?" + Map<Integer, Boolean> rowFixed = new HashMap<Integer, Boolean>(); + Map<Integer, Boolean> columnFixed = new HashMap<Integer, Boolean>(); + for (View view : mViews) { + if (view.mElement == mLayout) { + continue; + } + + int gravity = GravityHelper.getGravity(view.mGravity, 0); + if ((gravity & GRAVITY_HORIZ_MASK) == 0) { + columnFixed.put(view.mCol, true); + } else if (!columnFixed.containsKey(view.mCol)) { + columnFixed.put(view.mCol, false); + } + if ((gravity & GRAVITY_VERT_MASK) == 0) { + rowFixed.put(view.mRow, true); + } else if (!rowFixed.containsKey(view.mRow)) { + rowFixed.put(view.mRow, false); + } + } + + boolean hasStretchableRow = false; + boolean hasStretchableColumn = false; + for (boolean fixed : rowFixed.values()) { + if (!fixed) { + hasStretchableRow = true; + } + } + for (boolean fixed : columnFixed.values()) { + if (!fixed) { + hasStretchableColumn = true; + } + } + + if (!hasStretchableRow || !hasStretchableColumn) { + // Insert <Space> to hold stretchable space + // TODO: May also have to increment column count! + int offset = 0; // WHERE? + + String gridLayout = mLayout.getTagName(); + if (mLayout instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) mLayout; + int end = region.getEndOffset(); + // TODO: Look backwards for the "</" + // (and can it ever be <foo/>) ? + end -= (gridLayout.length() + 3); // 3: <, /, > + offset = end; + } + + int row = rowFixed.size(); + int column = columnFixed.size(); + StringBuilder sb = new StringBuilder(64); + String spaceTag = SPACE; + IFile file = mRefactoring.getFile(); + if (file != null) { + spaceTag = SupportLibraryHelper.getTagFor(file.getProject(), FQCN_SPACE); + if (spaceTag.equals(FQCN_SPACE)) { + spaceTag = SPACE; + } + } + + sb.append('<').append(spaceTag).append(' '); + String gravity; + if (!hasStretchableRow && !hasStretchableColumn) { + gravity = GRAVITY_VALUE_FILL; + } else if (!hasStretchableRow) { + gravity = GRAVITY_VALUE_FILL_VERTICAL; + } else { + assert !hasStretchableColumn; + gravity = GRAVITY_VALUE_FILL_HORIZONTAL; + } + + sb.append(mNamespace).append(':'); + sb.append(ATTR_LAYOUT_GRAVITY).append('=').append('"').append(gravity); + sb.append('"').append(' '); + + sb.append(mNamespace).append(':'); + sb.append(ATTR_LAYOUT_ROW).append('=').append('"').append(Integer.toString(row)); + sb.append('"').append(' '); + + sb.append(mNamespace).append(':'); + sb.append(ATTR_LAYOUT_COLUMN).append('=').append('"').append(Integer.toString(column)); + sb.append('"').append('/').append('>'); + + String space = sb.toString(); + InsertEdit replace = new InsertEdit(offset, space); + mRootEdit.addChild(replace); + + mColumnCount++; + } + } + + private void removeUndefinedAttrs() { + ViewElementDescriptor descriptor = mRefactoring.getElementDescriptor(FQCN_GRID_LAYOUT); + if (descriptor == null) { + return; + } + + Set<String> defined = new HashSet<String>(); + AttributeDescriptor[] layoutAttributes = descriptor.getLayoutAttributes(); + for (AttributeDescriptor attribute : layoutAttributes) { + defined.add(attribute.getXmlLocalName()); + } + + for (View view : mViews) { + Element child = view.mElement; + + List<Attr> attributes = mRefactoring.findLayoutAttributes(child); + for (Attr attribute : attributes) { + String name = attribute.getLocalName(); + if (!defined.contains(name)) { + // Remove it + try { + mRefactoring.removeAttribute(mRootEdit, child, attribute.getNamespaceURI(), + name); + } catch (MalformedTreeException mte) { + // Sometimes refactoring has modified attribute; not + // removing + // it is non-fatal so just warn instead of letting + // refactoring + // operation abort + AdtPlugin.log(IStatus.WARNING, + "Could not remove unsupported attribute %1$s; " + //$NON-NLS-1$ + "already modified during refactoring?", //$NON-NLS-1$ + attribute.getLocalName()); + } + } + } + } + } + + /** Removes any elements targeted for deletion */ + private void deleteRemovedElements(List<Element> delete) { + if (mFlatten && delete.size() > 0) { + for (Element element : delete) { + mRefactoring.removeElementTags(mRootEdit, element, delete, + false /*changeIndentation*/); + } + } + } + + /** + * Creates refactoring edits which adds or updates the grid attributes + */ + private void assignGridAttributes() { + // We always convert to horizontal grid layouts for now + mRefactoring.setAttribute(mRootEdit, mLayout, ANDROID_URI, + mNamespace, ATTR_ORIENTATION, VALUE_HORIZONTAL); + + assignCellAttributes(); + } + + /** + * Assign cell attributes to the table, skipping those that will be implied + * by the grid model + */ + private void assignCellAttributes() { + int implicitRow = 0; + int implicitColumn = 0; + int nextRow = 0; + for (View view : mViews) { + Element element = view.getElement(); + if (element == mLayout) { + continue; + } + + int row = view.getRow(); + int column = view.getColumn(); + + if (column != implicitColumn && (implicitColumn > 0 || implicitRow > 0)) { + mRefactoring.setAttribute(mRootEdit, element, ANDROID_URI, + mNamespace, ATTR_LAYOUT_COLUMN, Integer.toString(column)); + if (column < implicitColumn) { + implicitRow++; + } + implicitColumn = column; + } + if (row != implicitRow) { + mRefactoring.setAttribute(mRootEdit, element, ANDROID_URI, + mNamespace, ATTR_LAYOUT_ROW, Integer.toString(row)); + implicitRow = row; + } + + int rowSpan = view.getRowSpan(); + int columnSpan = view.getColumnSpan(); + assert columnSpan >= 1; + + if (rowSpan > 1) { + mRefactoring.setAttribute(mRootEdit, element, ANDROID_URI, + mNamespace, ATTR_LAYOUT_ROW_SPAN, Integer.toString(rowSpan)); + } + if (columnSpan > 1) { + mRefactoring.setAttribute(mRootEdit, element, ANDROID_URI, + mNamespace, ATTR_LAYOUT_COLUMN_SPAN, + Integer.toString(columnSpan)); + } + nextRow = Math.max(nextRow, row + rowSpan); + + // wrap_content is redundant in GridLayouts + Attr width = element.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + if (width != null && VALUE_WRAP_CONTENT.equals(width.getValue())) { + mRefactoring.removeAttribute(mRootEdit, width); + } + Attr height = element.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + if (height != null && VALUE_WRAP_CONTENT.equals(height.getValue())) { + mRefactoring.removeAttribute(mRootEdit, height); + } + + // Fix up children moved from LinearLayouts that have "invalid" sizes that + // was intended for layout weight handling in their old parent + if (LINEAR_LAYOUT.equals(element.getParentNode().getNodeName())) { + convert0dipToWrapContent(element); + } + + implicitColumn += columnSpan; + if (implicitColumn >= mColumnCount) { + implicitColumn = 0; + assert nextRow > implicitRow; + implicitRow = nextRow; + } + } + } + + private void processGravities() { + for (View view : mViews) { + Element element = view.getElement(); + if (element == mLayout) { + continue; + } + + Attr width = element.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + Attr height = element.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + String gravity = element.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_GRAVITY); + String newGravity = null; + if (width != null && (VALUE_MATCH_PARENT.equals(width.getValue()) || + VALUE_FILL_PARENT.equals(width.getValue()))) { + mRefactoring.removeAttribute(mRootEdit, width); + newGravity = gravity = GRAVITY_VALUE_FILL_HORIZONTAL; + } + if (height != null && (VALUE_MATCH_PARENT.equals(height.getValue()) || + VALUE_FILL_PARENT.equals(height.getValue()))) { + mRefactoring.removeAttribute(mRootEdit, height); + if (newGravity == GRAVITY_VALUE_FILL_HORIZONTAL) { + newGravity = GRAVITY_VALUE_FILL; + } else { + newGravity = GRAVITY_VALUE_FILL_VERTICAL; + } + gravity = newGravity; + } + + if (gravity == null || gravity.length() == 0) { + ElementDescriptor descriptor = view.mInfo.getUiViewNode().getDescriptor(); + if (descriptor instanceof ViewElementDescriptor) { + ViewElementDescriptor viewDescriptor = (ViewElementDescriptor) descriptor; + String fqcn = viewDescriptor.getFullClassName(); + FillPreference fill = ViewMetadataRepository.get().getFillPreference(fqcn); + gravity = GridLayoutRule.computeDefaultGravity(fill); + if (gravity != null) { + newGravity = gravity; + } + } + } + + if (newGravity != null) { + mRefactoring.setAttribute(mRootEdit, element, ANDROID_URI, + mNamespace, ATTR_LAYOUT_GRAVITY, newGravity); + } + + view.mGravity = newGravity != null ? newGravity : gravity; + } + } + + + /** Converts 0dip values in layout_width and layout_height to wrap_content instead */ + private void convert0dipToWrapContent(Element child) { + // Must convert layout_height="0dip" to layout_height="wrap_content". + // (And since wrap_content is the default, what we really do is remove + // the attribute completely.) + // 0dip is a special trick used in linear layouts in the presence of + // weights where 0dip ensures that the height of the view is not taken + // into account when distributing the weights. However, when converted + // to RelativeLayout this will instead cause the view to actually be assigned + // 0 height. + Attr height = child.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + // 0dip, 0dp, 0px, etc + if (height != null && height.getValue().startsWith("0")) { //$NON-NLS-1$ + mRefactoring.removeAttribute(mRootEdit, height); + } + Attr width = child.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + if (width != null && width.getValue().startsWith("0")) { //$NON-NLS-1$ + mRefactoring.removeAttribute(mRootEdit, width); + } + } + + /** + * Searches a view hierarchy and locates the {@link CanvasViewInfo} for the given + * {@link Element} + * + * @param info the root {@link CanvasViewInfo} to search below + * @param element the target element + * @return the {@link CanvasViewInfo} which corresponds to the given element + */ + private CanvasViewInfo findViewForElement(CanvasViewInfo info, Element element) { + if (getElement(info) == element) { + return info; + } + + for (CanvasViewInfo child : info.getChildren()) { + CanvasViewInfo result = findViewForElement(child, element); + if (result != null) { + return result; + } + } + + return null; + } + + /** Returns the {@link Element} for the given {@link CanvasViewInfo} */ + private static Element getElement(CanvasViewInfo info) { + Node node = info.getUiViewNode().getXmlNode(); + if (node instanceof Element) { + return (Element) node; + } + + return null; + } + + + /** Holds layout information about an individual view */ + private static class View { + private final Element mElement; + private int mRow = -1; + private int mCol = -1; + private int mRowSpan = -1; + private int mColSpan = -1; + private int mX1; + private int mY1; + private int mX2; + private int mY2; + private CanvasViewInfo mInfo; + private String mGravity; + + public View(CanvasViewInfo view, Element element) { + mInfo = view; + mElement = element; + + Rectangle b = mInfo.getAbsRect(); + mX1 = b.x; + mX2 = b.x + b.width; + mY1 = b.y; + mY2 = b.y + b.height; + } + + /** + * Returns the element for this view + * + * @return the element for the view + */ + public Element getElement() { + return mElement; + } + + /** + * The assigned row for this view + * + * @return the assigned row + */ + public int getRow() { + return mRow; + } + + /** + * The assigned column for this view + * + * @return the assigned column + */ + public int getColumn() { + return mCol; + } + + /** + * The assigned row span for this view + * + * @return the assigned row span + */ + public int getRowSpan() { + return mRowSpan; + } + + /** + * The assigned column span for this view + * + * @return the assigned column span + */ + public int getColumnSpan() { + return mColSpan; + } + + /** + * The left edge of the view to be used for placement + * + * @return the left edge x coordinate + */ + public int getLeftEdge() { + return mX1; + } + + /** + * The top edge of the view to be used for placement + * + * @return the top edge y coordinate + */ + public int getTopEdge() { + return mY1; + } + + /** + * The right edge of the view to be used for placement + * + * @return the right edge x coordinate + */ + public int getRightEdge() { + return mX2; + } + + /** + * The bottom edge of the view to be used for placement + * + * @return the bottom edge y coordinate + */ + public int getBottomEdge() { + return mY2; + } + + @Override + public String toString() { + return "View(" + VisualRefactoring.getId(mElement) + ": " + mX1 + "," + mY1 + ")"; + } + } + + /** Grid model for the views found in the view hierarchy, partitioned into rows and columns */ + private static class GridModel { + private final List<View> mViews = new ArrayList<View>(); + private final List<Element> mDelete = new ArrayList<Element>(); + private final Map<Element, View> mElementToView = new HashMap<Element, View>(); + private Element mLayout; + private boolean mFlatten; + + GridModel(CanvasViewInfo view, Element layout, boolean flatten) { + mLayout = layout; + mFlatten = flatten; + + scan(view, true); + analyzeKnownLayouts(); + initializeColumns(); + initializeRows(); + mDelete.remove(getElement(view)); + } + + /** + * Returns the {@link View} objects to be placed in the grid + * + * @return list of {@link View} objects, never null but possibly empty + */ + public List<View> getViews() { + return mViews; + } + + /** + * Returns the list of elements that are scheduled for deletion in the + * flattening operation + * + * @return elements to be deleted, never null but possibly empty + */ + public List<Element> getDeletedElements() { + return mDelete; + } + + /** + * Compute and return column count + * + * @return the column count + */ + public int computeColumnCount() { + int columnCount = 0; + for (View view : mViews) { + if (view.getElement() == mLayout) { + continue; + } + + int column = view.getColumn(); + int columnSpan = view.getColumnSpan(); + if (column + columnSpan > columnCount) { + columnCount = column + columnSpan; + } + } + return columnCount; + } + + /** + * Initializes the column and columnSpan attributes of the views + */ + private void initializeColumns() { + // Now initialize table view row, column and spans + Map<Integer, List<View>> mColumnViews = new HashMap<Integer, List<View>>(); + for (View view : mViews) { + if (view.mElement == mLayout) { + continue; + } + int x = view.getLeftEdge(); + List<View> list = mColumnViews.get(x); + if (list == null) { + list = new ArrayList<View>(); + mColumnViews.put(x, list); + } + list.add(view); + } + + List<Integer> columnOffsets = new ArrayList<Integer>(mColumnViews.keySet()); + Collections.sort(columnOffsets); + + int columnIndex = 0; + for (Integer column : columnOffsets) { + List<View> views = mColumnViews.get(column); + if (views != null) { + for (View view : views) { + view.mCol = columnIndex; + } + } + columnIndex++; + } + // Initialize column spans + for (View view : mViews) { + if (view.mElement == mLayout) { + continue; + } + int index = Collections.binarySearch(columnOffsets, view.getRightEdge()); + int column; + if (index == -1) { + // Smaller than the first element; just use the first column + column = 0; + } else if (index < 0) { + column = -(index + 2); + } else { + column = index; + } + + if (column < view.mCol) { + column = view.mCol; + } + + view.mColSpan = column - view.mCol + 1; + } + } + + /** + * Initializes the row and rowSpan attributes of the views + */ + private void initializeRows() { + Map<Integer, List<View>> mRowViews = new HashMap<Integer, List<View>>(); + for (View view : mViews) { + if (view.mElement == mLayout) { + continue; + } + int y = view.getTopEdge(); + List<View> list = mRowViews.get(y); + if (list == null) { + list = new ArrayList<View>(); + mRowViews.put(y, list); + } + list.add(view); + } + + List<Integer> rowOffsets = new ArrayList<Integer>(mRowViews.keySet()); + Collections.sort(rowOffsets); + + int rowIndex = 0; + for (Integer row : rowOffsets) { + List<View> views = mRowViews.get(row); + if (views != null) { + for (View view : views) { + view.mRow = rowIndex; + } + } + rowIndex++; + } + + // Initialize row spans + for (View view : mViews) { + if (view.mElement == mLayout) { + continue; + } + int index = Collections.binarySearch(rowOffsets, view.getBottomEdge()); + int row; + if (index == -1) { + // Smaller than the first element; just use the first row + row = 0; + } else if (index < 0) { + row = -(index + 2); + } else { + row = index; + } + + if (row < view.mRow) { + row = view.mRow; + } + + view.mRowSpan = row - view.mRow + 1; + } + } + + /** + * Walks over a given view hierarchy and locates views to be placed in + * the grid layout (or deleted if we are flattening the hierarchy) + * + * @param view the view to analyze + * @param isRoot whether this view is the root (which cannot be removed) + * @return the {@link View} object for the {@link CanvasViewInfo} + * hierarchy we just analyzed, or null + */ + private View scan(CanvasViewInfo view, boolean isRoot) { + View added = null; + if (!mFlatten || !isRemovableLayout(view)) { + added = add(view); + if (!isRoot) { + return added; + } + } else { + mDelete.add(getElement(view)); + } + + // Build up a table model of the view + for (CanvasViewInfo child : view.getChildren()) { + Element childElement = getElement(child); + + // See if this view shares the edge with the removed + // parent layout, and if so, record that such that we can + // later handle attachments to the removed parent edges + + if (mFlatten && isRemovableLayout(child)) { + // When flattening, we want to disregard all layouts and instead + // add their children! + for (CanvasViewInfo childView : child.getChildren()) { + scan(childView, false); + } + mDelete.add(childElement); + } else { + scan(child, false); + } + } + + return added; + } + + /** Adds the given {@link CanvasViewInfo} into our internal view list */ + private View add(CanvasViewInfo info) { + Element element = getElement(info); + View view = new View(info, element); + mViews.add(view); + mElementToView.put(element, view); + return view; + } + + private void analyzeKnownLayouts() { + Set<Element> parents = new HashSet<Element>(); + for (View view : mViews) { + Node parent = view.getElement().getParentNode(); + if (parent instanceof Element) { + parents.add((Element) parent); + } + } + + List<Collection<View>> rowGroups = new ArrayList<Collection<View>>(); + List<Collection<View>> columnGroups = new ArrayList<Collection<View>>(); + for (Element parent : parents) { + String tagName = parent.getTagName(); + if (tagName.equals(LINEAR_LAYOUT) || tagName.equals(TABLE_LAYOUT) || + tagName.equals(TABLE_ROW) || tagName.equals(RADIO_GROUP)) { + Set<View> group = new HashSet<View>(); + for (Element child : DomUtilities.getChildren(parent)) { + View view = mElementToView.get(child); + if (view != null) { + group.add(view); + } + } + if (group.size() > 1) { + boolean isVertical = VALUE_VERTICAL.equals(parent.getAttributeNS( + ANDROID_URI, ATTR_ORIENTATION)); + if (tagName.equals(TABLE_LAYOUT)) { + isVertical = true; + } else if (tagName.equals(TABLE_ROW)) { + isVertical = false; + } + if (isVertical) { + columnGroups.add(group); + } else { + rowGroups.add(group); + } + } + } else if (tagName.equals(RELATIVE_LAYOUT)) { + List<Element> children = DomUtilities.getChildren(parent); + for (Element child : children) { + View view = mElementToView.get(child); + if (view == null) { + continue; + } + NamedNodeMap attributes = child.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Attr attr = (Attr) attributes.item(i); + String name = attr.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX)) { + boolean alignVertical = + name.equals(ATTR_LAYOUT_ALIGN_TOP) || + name.equals(ATTR_LAYOUT_ALIGN_BOTTOM) || + name.equals(ATTR_LAYOUT_ALIGN_BASELINE); + boolean alignHorizontal = + name.equals(ATTR_LAYOUT_ALIGN_LEFT) || + name.equals(ATTR_LAYOUT_ALIGN_RIGHT); + if (!alignVertical && !alignHorizontal) { + continue; + } + String value = attr.getValue(); + if (value.startsWith(ID_PREFIX) + || value.startsWith(NEW_ID_PREFIX)) { + String targetName = BaseLayoutRule.stripIdPrefix(value); + Element target = null; + for (Element c : children) { + String id = VisualRefactoring.getId(c); + if (targetName.equals(BaseLayoutRule.stripIdPrefix(id))) { + target = c; + break; + } + } + View targetView = mElementToView.get(target); + if (targetView != null) { + List<View> group = new ArrayList<View>(2); + group.add(view); + group.add(targetView); + if (alignHorizontal) { + columnGroups.add(group); + } else { + assert alignVertical; + rowGroups.add(group); + } + } + } + } + } + } + } else { + // TODO: Consider looking for interesting metadata from other layouts + } + } + + // Assign the same top or left coordinates to the groups to ensure that they + // all get positioned in the same row or column + for (Collection<View> rowGroup : rowGroups) { + // Find the smallest one + Iterator<View> iterator = rowGroup.iterator(); + int smallest = iterator.next().mY1; + while (iterator.hasNext()) { + smallest = Math.min(smallest, iterator.next().mY1); + } + for (View view : rowGroup) { + view.mY2 -= (view.mY1 - smallest); + view.mY1 = smallest; + } + } + for (Collection<View> columnGroup : columnGroups) { + Iterator<View> iterator = columnGroup.iterator(); + int smallest = iterator.next().mX1; + while (iterator.hasNext()) { + smallest = Math.min(smallest, iterator.next().mX1); + } + for (View view : columnGroup) { + view.mX2 -= (view.mX1 - smallest); + view.mX1 = smallest; + } + } + } + + /** + * Returns true if the given {@link CanvasViewInfo} represents an element we + * should remove in a flattening conversion. We don't want to remove non-layout + * views, or layout views that for example contain drawables on their own. + */ + private boolean isRemovableLayout(CanvasViewInfo child) { + // The element being converted is NOT removable! + Element element = getElement(child); + if (element == mLayout) { + return false; + } + + ElementDescriptor descriptor = child.getUiViewNode().getDescriptor(); + String name = descriptor.getXmlLocalName(); + if (name.equals(LINEAR_LAYOUT) || name.equals(RELATIVE_LAYOUT) + || name.equals(TABLE_LAYOUT) || name.equals(TABLE_ROW)) { + // Don't delete layouts that provide a background image or gradient + if (element.hasAttributeNS(ANDROID_URI, ATTR_BACKGROUND)) { + AdtPlugin.log(IStatus.WARNING, + "Did not flatten layout %1$s because it defines a '%2$s' attribute", + VisualRefactoring.getId(element), ATTR_BACKGROUND); + return false; + } + + return true; + } + + return false; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/JavaQuickAssistant.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/JavaQuickAssistant.java new file mode 100644 index 000000000..df5d9eaf3 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/JavaQuickAssistant.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; +import com.android.ide.eclipse.adt.internal.refactorings.extractstring.ExtractStringProposal; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.ui.text.java.IInvocationContext; +import org.eclipse.jdt.ui.text.java.IJavaCompletionProposal; +import org.eclipse.jdt.ui.text.java.IProblemLocation; + +/** + * Quick Assistant for Java files in Android projects + */ +public class JavaQuickAssistant implements org.eclipse.jdt.ui.text.java.IQuickAssistProcessor { + public JavaQuickAssistant() { + } + + @Override + public boolean hasAssists(IInvocationContext context) throws CoreException { + return true; + } + + @Override + public IJavaCompletionProposal[] getAssists(IInvocationContext context, + IProblemLocation[] locations) throws CoreException { + // We should only offer Android quick assists within Android projects. + // This can be done by adding this logic to the extension registration: + // + // <enablement> + // <with variable="projectNatures"> + // <iterate operator="or"> + // <equals value="com.android.ide.eclipse.adt.AndroidNature"/> + // </iterate> + // </with> + // </enablement> + // + // However, this causes some errors to be dumped to the log, so instead we filter + // out non Android projects programmatically: + + IProject project = context.getCompilationUnit().getJavaProject().getProject(); + if (project == null || !BaseProjectHelper.isAndroidProject(project)) { + return null; + } + + ASTNode coveringNode = context.getCoveringNode(); + if (coveringNode != null && coveringNode.getNodeType() == ASTNode.STRING_LITERAL + && coveringNode.getLength() > 2) { // don't extract empty strings (includes quotes) + return new IJavaCompletionProposal[] { + new ExtractStringProposal(context) + }; + } + + return null; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/RefactoringAssistant.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/RefactoringAssistant.java new file mode 100644 index 000000000..aa8c11999 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/RefactoringAssistant.java @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.common.resources.ResourceUrl; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResourceProcessor; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResourceWizard; +import com.android.ide.eclipse.adt.internal.refactorings.core.RenameResourceXmlTextAction; +import com.android.ide.eclipse.adt.internal.refactorings.extractstring.ExtractStringRefactoring; +import com.android.ide.eclipse.adt.internal.refactorings.extractstring.ExtractStringWizard; +import com.android.resources.ResourceType; + +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.text.TextSelection; +import org.eclipse.jface.text.contentassist.ICompletionProposal; +import org.eclipse.jface.text.contentassist.IContextInformation; +import org.eclipse.jface.text.quickassist.IQuickAssistInvocationContext; +import org.eclipse.jface.text.quickassist.IQuickAssistProcessor; +import org.eclipse.jface.text.source.Annotation; +import org.eclipse.jface.text.source.ISourceViewer; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionProvider; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.participants.RenameRefactoring; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; +import org.eclipse.wst.sse.ui.StructuredTextEditor; +import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.List; + +/** + * QuickAssistProcessor which helps invoke refactoring operations on text elements. + */ +@SuppressWarnings("restriction") // XML model +public class RefactoringAssistant implements IQuickAssistProcessor { + + /** + * Creates a new {@link RefactoringAssistant} + */ + public RefactoringAssistant() { + } + + @Override + public boolean canAssist(IQuickAssistInvocationContext invocationContext) { + return true; + } + + @Override + public boolean canFix(Annotation annotation) { + return true; + } + + @Override + public ICompletionProposal[] computeQuickAssistProposals( + IQuickAssistInvocationContext invocationContext) { + + ISourceViewer sourceViewer = invocationContext.getSourceViewer(); + AndroidXmlEditor xmlEditor = AndroidXmlEditor.fromTextViewer(sourceViewer); + if (xmlEditor == null) { + return null; + } + + IFile file = xmlEditor.getInputFile(); + if (file == null) { + return null; + } + int offset = invocationContext.getOffset(); + + // Ensure that we are over a tag name (for element-based refactoring + // operations) or a value (for the extract include refactoring) + + boolean isValue = false; + boolean isReferenceValue = false; + boolean isTagName = false; + boolean isAttributeName = false; + boolean isStylableAttribute = false; + ResourceUrl resource = null; + IStructuredModel model = null; + try { + model = xmlEditor.getModelForRead(); + IStructuredDocument doc = model.getStructuredDocument(); + IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(offset); + ITextRegion subRegion = region.getRegionAtCharacterOffset(offset); + if (subRegion != null) { + String type = subRegion.getType(); + if (type.equals(DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE)) { + String value = region.getText(subRegion); + // Only extract values that aren't already resources + // (and value includes leading ' or ") + isValue = true; + if (value.startsWith("'@") || value.startsWith("\"@")) { //$NON-NLS-1$ //$NON-NLS-2$ + isReferenceValue = true; + resource = RenameResourceXmlTextAction.findResource(doc, offset); + } + } else if (type.equals(DOMRegionContext.XML_TAG_NAME) + || type.equals(DOMRegionContext.XML_TAG_OPEN) + || type.equals(DOMRegionContext.XML_TAG_CLOSE)) { + isTagName = true; + } else if (type.equals(DOMRegionContext.XML_TAG_ATTRIBUTE_NAME) ) { + isAttributeName = true; + String name = region.getText(subRegion); + int index = name.indexOf(':'); + if (index != -1) { + name = name.substring(index + 1); + } + isStylableAttribute = ExtractStyleRefactoring.isStylableAttribute(name); + } else if (type.equals(DOMRegionContext.XML_TAG_ATTRIBUTE_EQUALS)) { + // On the edge of an attribute name and an attribute value + isAttributeName = true; + isStylableAttribute = true; + } else if (type.equals(DOMRegionContext.XML_CONTENT)) { + resource = RenameResourceXmlTextAction.findResource(doc, offset); + } + } + } finally { + if (model != null) { + model.releaseFromRead(); + } + } + + List<ICompletionProposal> proposals = new ArrayList<ICompletionProposal>(); + if (isTagName || isAttributeName || isValue || resource != null) { + StructuredTextEditor structuredEditor = xmlEditor.getStructuredTextEditor(); + ISelectionProvider provider = structuredEditor.getSelectionProvider(); + ISelection selection = provider.getSelection(); + if (selection instanceof ITextSelection) { + ITextSelection textSelection = (ITextSelection) selection; + + ITextSelection originalSelection = textSelection; + + // Most of the visual refactorings do not work on text ranges + // ...except for Extract Style where the actual attributes overlapping + // the selection is going to be the set of eligible attributes + boolean selectionOkay = false; + + if (textSelection.getLength() == 0 && !isValue) { + selectionOkay = true; + ISourceViewer textViewer = xmlEditor.getStructuredSourceViewer(); + int caretOffset = textViewer.getTextWidget().getCaretOffset(); + if (caretOffset >= 0) { + Node node = DomUtilities.getNode(textViewer.getDocument(), caretOffset); + if (node instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) node; + int startOffset = region.getStartOffset(); + int length = region.getEndOffset() - region.getStartOffset(); + textSelection = new TextSelection(startOffset, length); + } + } + } + + if (isValue && !isReferenceValue) { + proposals.add(new RefactoringProposal(xmlEditor, + new ExtractStringRefactoring(file, xmlEditor, textSelection))); + } else if (resource != null) { + RenameResourceProcessor processor = new RenameResourceProcessor( + file.getProject(), resource.type, resource.name, null); + RenameRefactoring refactoring = new RenameRefactoring(processor); + proposals.add(new RefactoringProposal(xmlEditor, refactoring)); + } + + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(xmlEditor); + if (delegate != null) { + boolean showStyleFirst = isValue || (isAttributeName && isStylableAttribute); + if (showStyleFirst) { + proposals.add(new RefactoringProposal( + xmlEditor, + new ExtractStyleRefactoring( + file, + delegate, + originalSelection, + null))); + } + + if (selectionOkay) { + proposals.add(new RefactoringProposal( + xmlEditor, + new WrapInRefactoring( + file, + delegate, + textSelection, + null))); + proposals.add(new RefactoringProposal( + xmlEditor, + new UnwrapRefactoring( + file, + delegate, + textSelection, + null))); + proposals.add(new RefactoringProposal( + xmlEditor, + new ChangeViewRefactoring( + file, + delegate, + textSelection, + null))); + proposals.add(new RefactoringProposal( + xmlEditor, + new ChangeLayoutRefactoring( + file, + delegate, + textSelection, + null))); + } + + // Extract Include must always have an actual block to be extracted + if (textSelection.getLength() > 0) { + proposals.add(new RefactoringProposal( + xmlEditor, + new ExtractIncludeRefactoring( + file, + delegate, + textSelection, + null))); + } + + // If it's not a value or attribute name, don't place it on top + if (!showStyleFirst) { + proposals.add(new RefactoringProposal( + xmlEditor, + new ExtractStyleRefactoring( + file, + delegate, + originalSelection, + null))); + } + } + } + } + + if (proposals.size() == 0) { + return null; + } else { + return proposals.toArray(new ICompletionProposal[proposals.size()]); + } + } + + @Override + public String getErrorMessage() { + return null; + } + + private static class RefactoringProposal + implements ICompletionProposal { + private final AndroidXmlEditor mEditor; + private final Refactoring mRefactoring; + + RefactoringProposal(AndroidXmlEditor editor, Refactoring refactoring) { + super(); + mEditor = editor; + mRefactoring = refactoring; + } + + @Override + public void apply(IDocument document) { + RefactoringWizard wizard = null; + if (mRefactoring instanceof VisualRefactoring) { + wizard = ((VisualRefactoring) mRefactoring).createWizard(); + } else if (mRefactoring instanceof ExtractStringRefactoring) { + wizard = new ExtractStringWizard((ExtractStringRefactoring) mRefactoring, + mEditor.getProject()); + } else if (mRefactoring instanceof RenameRefactoring) { + RenameRefactoring refactoring = (RenameRefactoring) mRefactoring; + RenameResourceProcessor processor = + (RenameResourceProcessor) refactoring.getProcessor(); + ResourceType type = processor.getType(); + wizard = new RenameResourceWizard((RenameRefactoring) mRefactoring, type, false); + } else { + throw new IllegalArgumentException(); + } + + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); + op.run(window.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + } + } + + @Override + public String getAdditionalProposalInfo() { + return String.format("Initiates the \"%1$s\" refactoring", mRefactoring.getName()); + } + + @Override + public IContextInformation getContextInformation() { + return null; + } + + @Override + public String getDisplayString() { + return mRefactoring.getName(); + } + + @Override + public Image getImage() { + return AdtPlugin.getAndroidLogo(); + } + + @Override + public Point getSelection(IDocument document) { + return null; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/RelativeLayoutConversionHelper.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/RelativeLayoutConversionHelper.java new file mode 100644 index 000000000..e0d6313bf --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/RelativeLayoutConversionHelper.java @@ -0,0 +1,1633 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_BACKGROUND; +import static com.android.SdkConstants.ATTR_BASELINE_ALIGNED; +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_VERTICAL; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; +import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; +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.ID_PREFIX; +import static com.android.SdkConstants.LINEAR_LAYOUT; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.RELATIVE_LAYOUT; +import static com.android.SdkConstants.VALUE_FALSE; +import static com.android.SdkConstants.VALUE_N_DP; +import static com.android.SdkConstants.VALUE_TRUE; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.SdkConstants.VALUE_WRAP_CONTENT; +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_FILL_HORIZ; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_FILL_VERT; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_LEFT; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_RIGHT; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_TOP; +import static com.android.ide.common.layout.GravityHelper.GRAVITY_VERT_MASK; + +import com.android.ide.common.layout.GravityHelper; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.utils.Pair; + +import org.eclipse.core.runtime.IStatus; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.text.edits.MultiTextEdit; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Helper class which performs the bulk of the layout conversion to relative layout + * <p> + * Future enhancements: + * <ul> + * <li>Render the layout at multiple screen sizes and analyze how the widgets move and + * stretch and use that to add in additional constraints + * <li> Adapt the LinearLayout analysis code to work with TableLayouts and TableRows as well + * (just need to tweak the "isVertical" interpretation to account for the different defaults, + * and perhaps do something about column size properties. + * <li> We need to take into account existing margins and clear/update them + * </ul> + */ +class RelativeLayoutConversionHelper { + private final MultiTextEdit mRootEdit; + private final boolean mFlatten; + private final Element mLayout; + private final ChangeLayoutRefactoring mRefactoring; + private final CanvasViewInfo mRootView; + private List<Element> mDeletedElements; + + RelativeLayoutConversionHelper(ChangeLayoutRefactoring refactoring, + Element layout, boolean flatten, MultiTextEdit rootEdit, CanvasViewInfo rootView) { + mRefactoring = refactoring; + mLayout = layout; + mFlatten = flatten; + mRootEdit = rootEdit; + mRootView = rootView; + } + + /** Performs conversion from any layout to a RelativeLayout */ + public void convertToRelative() { + if (mRootView == null) { + return; + } + + // Locate the view for the layout + CanvasViewInfo layoutView = findViewForElement(mRootView, mLayout); + if (layoutView == null || layoutView.getChildren().size() == 0) { + // No children. THAT was an easy conversion! + return; + } + + // Study the layout and get information about how to place individual elements + List<View> views = analyzeLayout(layoutView); + + // Create/update relative layout constraints + createAttachments(views); + } + + /** Returns the elements that were deleted, or null */ + List<Element> getDeletedElements() { + return mDeletedElements; + } + + /** + * Analyzes the given view hierarchy and produces a list of {@link View} objects which + * contain placement information for each element + */ + private List<View> analyzeLayout(CanvasViewInfo layoutView) { + EdgeList edgeList = new EdgeList(layoutView); + mDeletedElements = edgeList.getDeletedElements(); + deleteRemovedElements(mDeletedElements); + + List<Integer> columnOffsets = edgeList.getColumnOffsets(); + List<Integer> rowOffsets = edgeList.getRowOffsets(); + + // Compute x/y offsets for each row/column index + int[] left = new int[columnOffsets.size()]; + int[] top = new int[rowOffsets.size()]; + + Map<Integer, Integer> xToCol = new HashMap<Integer, Integer>(); + int columnIndex = 0; + for (Integer offset : columnOffsets) { + left[columnIndex] = offset; + xToCol.put(offset, columnIndex++); + } + Map<Integer, Integer> yToRow = new HashMap<Integer, Integer>(); + int rowIndex = 0; + for (Integer offset : rowOffsets) { + top[rowIndex] = offset; + yToRow.put(offset, rowIndex++); + } + + // Create a complete list of view objects + List<View> views = createViews(edgeList, columnOffsets); + initializeSpans(edgeList, columnOffsets, rowOffsets, xToCol, yToRow); + + // Sanity check + for (View view : views) { + assert view.getLeftEdge() == left[view.mCol]; + assert view.getTopEdge() == top[view.mRow]; + assert view.getRightEdge() == left[view.mCol+view.mColSpan]; + assert view.getBottomEdge() == top[view.mRow+view.mRowSpan]; + } + + // Ensure that every view has a proper id such that it can be referred to + // with a constraint + initializeIds(edgeList, views); + + // Attempt to lay the views out in a grid with constraints (though not that widgets + // can overlap as well) + Grid grid = new Grid(views, left, top); + computeKnownConstraints(views, edgeList); + computeHorizontalConstraints(grid); + computeVerticalConstraints(grid); + + return views; + } + + /** Produces a list of {@link View} objects from an {@link EdgeList} */ + private List<View> createViews(EdgeList edgeList, List<Integer> columnOffsets) { + List<View> views = new ArrayList<View>(); + for (Integer offset : columnOffsets) { + List<View> leftEdgeViews = edgeList.getLeftEdgeViews(offset); + if (leftEdgeViews == null) { + // must have been a right edge + continue; + } + for (View view : leftEdgeViews) { + views.add(view); + } + } + return views; + } + + /** Removes any elements targeted for deletion */ + private void deleteRemovedElements(List<Element> delete) { + if (mFlatten && delete.size() > 0) { + for (Element element : delete) { + mRefactoring.removeElementTags(mRootEdit, element, delete, + !AdtPrefs.getPrefs().getFormatGuiXml() /*changeIndentation*/); + } + } + } + + /** Ensures that every element has an id such that it can be referenced from a constraint */ + private void initializeIds(EdgeList edgeList, List<View> views) { + // Ensure that all views have a valid id + for (View view : views) { + String id = mRefactoring.ensureHasId(mRootEdit, view.mElement, null); + edgeList.setIdAttributeValue(view, id); + } + } + + /** + * Initializes the column and row indices, as well as any column span and row span + * values + */ + private void initializeSpans(EdgeList edgeList, List<Integer> columnOffsets, + List<Integer> rowOffsets, Map<Integer, Integer> xToCol, Map<Integer, Integer> yToRow) { + // Now initialize table view row, column and spans + for (Integer offset : columnOffsets) { + List<View> leftEdgeViews = edgeList.getLeftEdgeViews(offset); + if (leftEdgeViews == null) { + // must have been a right edge + continue; + } + for (View view : leftEdgeViews) { + Integer col = xToCol.get(view.getLeftEdge()); + assert col != null; + Integer end = xToCol.get(view.getRightEdge()); + assert end != null; + + view.mCol = col; + view.mColSpan = end - col; + } + } + + for (Integer offset : rowOffsets) { + List<View> topEdgeViews = edgeList.getTopEdgeViews(offset); + if (topEdgeViews == null) { + // must have been a bottom edge + continue; + } + for (View view : topEdgeViews) { + Integer row = yToRow.get(view.getTopEdge()); + assert row != null; + Integer end = yToRow.get(view.getBottomEdge()); + assert end != null; + + view.mRow = row; + view.mRowSpan = end - row; + } + } + } + + /** + * Creates refactoring edits which adds or updates constraints for the given list of + * views + */ + private void createAttachments(List<View> views) { + // Make the attachments + String namespace = mRefactoring.getAndroidNamespacePrefix(); + for (View view : views) { + for (Pair<String, String> constraint : view.getHorizConstraints()) { + mRefactoring.setAttribute(mRootEdit, view.mElement, ANDROID_URI, + namespace, constraint.getFirst(), constraint.getSecond()); + } + for (Pair<String, String> constraint : view.getVerticalConstraints()) { + mRefactoring.setAttribute(mRootEdit, view.mElement, ANDROID_URI, + namespace, constraint.getFirst(), constraint.getSecond()); + } + } + } + + /** + * Analyzes the existing layouts and layout parameter objects in the document to infer + * constraints for layout types that we know about - such as LinearLayout baseline + * alignment, weights, gravity, etc. + */ + private void computeKnownConstraints(List<View> views, EdgeList edgeList) { + // List of parent layout elements we've already processed. We iterate through all + // the -children-, and we ask each for its element parent (which won't have a view) + // and we look at the parent's layout attributes and its children layout constraints, + // and then we stash away constraints that we can infer. This means that we will + // encounter the same parent for every sibling, so that's why there's a map to + // prevent duplicate work. + Set<Node> seen = new HashSet<Node>(); + + for (View view : views) { + Element element = view.getElement(); + Node parent = element.getParentNode(); + if (seen.contains(parent)) { + continue; + } + seen.add(parent); + + if (parent.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + Element layout = (Element) parent; + String layoutName = layout.getTagName(); + + if (LINEAR_LAYOUT.equals(layoutName)) { + analyzeLinearLayout(edgeList, layout); + } else if (RELATIVE_LAYOUT.equals(layoutName)) { + analyzeRelativeLayout(edgeList, layout); + } else { + // Some other layout -- add more conditional handling here + // for framelayout, tables, etc. + } + } + } + + /** + * Returns the layout weight of of the given child of a LinearLayout, or 0.0 if it + * does not define a weight + */ + private float getWeight(Element linearLayoutChild) { + String weight = linearLayoutChild.getAttributeNS(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 float getWeightSum(Element linearLayout) { + float sum = 0; + for (Element child : DomUtilities.getChildren(linearLayout)) { + sum += getWeight(child); + } + + return sum; + } + + /** + * Analyzes the given LinearLayout and updates the constraints to reflect + * relationships it can infer - based on baseline alignment, gravity, order and + * weights. This method also removes "0dip" as a special width/height used in + * LinearLayouts with weight distribution. + */ + private void analyzeLinearLayout(EdgeList edgeList, Element layout) { + boolean isVertical = VALUE_VERTICAL.equals(layout.getAttributeNS(ANDROID_URI, + ATTR_ORIENTATION)); + View baselineRef = null; + if (!isVertical && + !VALUE_FALSE.equals(layout.getAttributeNS(ANDROID_URI, ATTR_BASELINE_ALIGNED))) { + // Baseline alignment. Find the tallest child and set it as the baseline reference. + int tallestHeight = 0; + View tallest = null; + for (Element child : DomUtilities.getChildren(layout)) { + View view = edgeList.getView(child); + if (view != null && view.getHeight() > tallestHeight) { + tallestHeight = view.getHeight(); + tallest = view; + } + } + if (tallest != null) { + baselineRef = tallest; + } + } + + float weightSum = getWeightSum(layout); + float cumulativeWeight = 0; + + List<Element> children = DomUtilities.getChildren(layout); + String prevId = null; + boolean isFirstChild = true; + boolean linkBackwards = true; + boolean linkForwards = false; + + for (int index = 0, childCount = children.size(); index < childCount; index++) { + Element child = children.get(index); + + View childView = edgeList.getView(child); + if (childView == null) { + // Could be a nested layout that is being removed etc + prevId = null; + isFirstChild = false; + continue; + } + + // Look at the layout_weight attributes and determine whether we should be + // attached on the bottom/right or on the top/left + if (weightSum > 0.0f) { + float weight = getWeight(child); + + // We can't emulate a LinearLayout where multiple children have positive + // weights. However, we CAN support the common scenario where a single + // child has a non-zero weight, and all children after it are pushed + // to the end and the weighted child fills the remaining space. + if (cumulativeWeight == 0 && weight > 0) { + // See if we have a bottom/right edge to attach the forwards link to + // (at the end of the forwards chains). Only if so can we link forwards. + View referenced; + if (isVertical) { + referenced = edgeList.getSharedBottomEdge(layout); + } else { + referenced = edgeList.getSharedRightEdge(layout); + } + if (referenced != null) { + linkForwards = true; + } + } else if (cumulativeWeight > 0) { + linkBackwards = false; + } + + cumulativeWeight += weight; + } + + analyzeGravity(edgeList, layout, isVertical, child, childView); + convert0dipToWrapContent(child); + + // Chain elements together in the flow direction of the linear layout + if (prevId != null) { // No constraint for first child + if (linkBackwards) { + if (isVertical) { + childView.addVerticalConstraint(ATTR_LAYOUT_BELOW, prevId); + } else { + childView.addHorizConstraint(ATTR_LAYOUT_TO_RIGHT_OF, prevId); + } + } + } else if (isFirstChild) { + assert linkBackwards; + + // First element; attach it to the parent if we can + if (isVertical) { + View referenced = edgeList.getSharedTopEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_PARENT_TOP, + VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_TOP, + referenced.getId()); + } + } + } else { + View referenced = edgeList.getSharedLeftEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_PARENT_LEFT, + VALUE_TRUE); + } else { + childView.addHorizConstraint( + ATTR_LAYOUT_ALIGN_LEFT, referenced.getId()); + } + } + } + } + + if (linkForwards) { + if (index < (childCount - 1)) { + Element nextChild = children.get(index + 1); + String nextId = mRefactoring.ensureHasId(mRootEdit, nextChild, null); + if (nextId != null) { + if (isVertical) { + childView.addVerticalConstraint(ATTR_LAYOUT_ABOVE, nextId); + } else { + childView.addHorizConstraint(ATTR_LAYOUT_TO_LEFT_OF, nextId); + } + } + } else { + // Attach to right/bottom edge of the layout + if (isVertical) { + View referenced = edgeList.getSharedBottomEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, + VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_BOTTOM, + referenced.getId()); + } + } + } else { + View referenced = edgeList.getSharedRightEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_PARENT_RIGHT, + VALUE_TRUE); + } else { + childView.addHorizConstraint( + ATTR_LAYOUT_ALIGN_RIGHT, referenced.getId()); + } + } + } + } + } + + if (baselineRef != null && baselineRef.getId() != null + && !baselineRef.getId().equals(childView.getId())) { + assert !isVertical; + // Only align if they share the same gravity + if ((childView.getGravity() & GRAVITY_VERT_MASK) == + (baselineRef.getGravity() & GRAVITY_VERT_MASK)) { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_BASELINE, baselineRef.getId()); + } + } + + prevId = mRefactoring.ensureHasId(mRootEdit, child, null); + isFirstChild = false; + } + } + + /** + * Checks the layout "gravity" value for the given child and updates the constraints + * to account for the gravity + */ + private int analyzeGravity(EdgeList edgeList, Element layout, boolean isVertical, + Element child, View childView) { + // Use gravity to constrain elements in the axis orthogonal to the + // direction of the layout + int gravity = childView.getGravity(); + if (isVertical) { + if ((gravity & GRAVITY_RIGHT) != 0) { + View referenced = edgeList.getSharedRightEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_PARENT_RIGHT, + VALUE_TRUE); + } else { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_RIGHT, + referenced.getId()); + } + } + } else if ((gravity & GRAVITY_CENTER_HORIZ) != 0) { + View referenced1 = edgeList.getSharedLeftEdge(layout); + View referenced2 = edgeList.getSharedRightEdge(layout); + if (referenced1 != null && referenced2 == referenced1) { + if (isAncestor(referenced1.getElement(), child)) { + childView.addHorizConstraint(ATTR_LAYOUT_CENTER_HORIZONTAL, + VALUE_TRUE); + } + } + } else if ((gravity & GRAVITY_FILL_HORIZ) != 0) { + View referenced1 = edgeList.getSharedLeftEdge(layout); + View referenced2 = edgeList.getSharedRightEdge(layout); + if (referenced1 != null && referenced2 == referenced1) { + if (isAncestor(referenced1.getElement(), child)) { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_PARENT_LEFT, + VALUE_TRUE); + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_PARENT_RIGHT, + VALUE_TRUE); + } else { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_LEFT, + referenced1.getId()); + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_RIGHT, + referenced2.getId()); + } + } + } else if ((gravity & GRAVITY_LEFT) != 0) { + View referenced = edgeList.getSharedLeftEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_PARENT_LEFT, + VALUE_TRUE); + } else { + childView.addHorizConstraint(ATTR_LAYOUT_ALIGN_LEFT, + referenced.getId()); + } + } + } + } else { + // Handle horizontal layout: perform vertical gravity attachments + if ((gravity & GRAVITY_BOTTOM) != 0) { + View referenced = edgeList.getSharedBottomEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, + VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_BOTTOM, + referenced.getId()); + } + } + } else if ((gravity & GRAVITY_CENTER_VERT) != 0) { + View referenced1 = edgeList.getSharedTopEdge(layout); + View referenced2 = edgeList.getSharedBottomEdge(layout); + if (referenced1 != null && referenced2 == referenced1) { + if (isAncestor(referenced1.getElement(), child)) { + childView.addVerticalConstraint(ATTR_LAYOUT_CENTER_VERTICAL, + VALUE_TRUE); + } + } + } else if ((gravity & GRAVITY_FILL_VERT) != 0) { + View referenced1 = edgeList.getSharedTopEdge(layout); + View referenced2 = edgeList.getSharedBottomEdge(layout); + if (referenced1 != null && referenced2 == referenced1) { + if (isAncestor(referenced1.getElement(), child)) { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_PARENT_TOP, + VALUE_TRUE); + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, + VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_TOP, + referenced1.getId()); + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_BOTTOM, + referenced2.getId()); + } + } + } else if ((gravity & GRAVITY_TOP) != 0) { + View referenced = edgeList.getSharedTopEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_PARENT_TOP, + VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_TOP, + referenced.getId()); + } + } + } + } + return gravity; + } + + /** Converts 0dip values in layout_width and layout_height to wrap_content instead */ + private void convert0dipToWrapContent(Element child) { + // Must convert layout_height="0dip" to layout_height="wrap_content". + // 0dip is a special trick used in linear layouts in the presence of + // weights where 0dip ensures that the height of the view is not taken + // into account when distributing the weights. However, when converted + // to RelativeLayout this will instead cause the view to actually be assigned + // 0 height. + String height = child.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + // 0dip, 0dp, 0px, etc + if (height != null && height.startsWith("0")) { //$NON-NLS-1$ + mRefactoring.setAttribute(mRootEdit, child, ANDROID_URI, + mRefactoring.getAndroidNamespacePrefix(), ATTR_LAYOUT_HEIGHT, + VALUE_WRAP_CONTENT); + } + String width = child.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + if (width != null && width.startsWith("0")) { //$NON-NLS-1$ + mRefactoring.setAttribute(mRootEdit, child, ANDROID_URI, + mRefactoring.getAndroidNamespacePrefix(), ATTR_LAYOUT_WIDTH, + VALUE_WRAP_CONTENT); + } + } + + /** + * Analyzes an embedded RelativeLayout within a layout hierarchy and updates the + * constraints in the EdgeList with those relationships which can continue in the + * outer single RelativeLayout. + */ + private void analyzeRelativeLayout(EdgeList edgeList, Element layout) { + NodeList children = layout.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + View childView = edgeList.getView(child); + if (childView == null) { + // Could be a nested layout that is being removed etc + continue; + } + + NamedNodeMap attributes = child.getAttributes(); + for (int j = 0, m = attributes.getLength(); j < m; j++) { + Attr attribute = (Attr) attributes.item(j); + String name = attribute.getLocalName(); + String value = attribute.getValue(); + if (name.equals(ATTR_LAYOUT_WIDTH) + || name.equals(ATTR_LAYOUT_HEIGHT)) { + // Ignore these for now + } else if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attribute.getNamespaceURI())) { + // Determine if the reference is to a known edge + String id = getIdBasename(value); + if (id != null) { + View referenced = edgeList.getView(id); + if (referenced != null) { + // This is a valid reference, so preserve + // the attribute + if (name.equals(ATTR_LAYOUT_BELOW) || + name.equals(ATTR_LAYOUT_ABOVE) || + name.equals(ATTR_LAYOUT_ALIGN_TOP) || + name.equals(ATTR_LAYOUT_ALIGN_BOTTOM) || + name.equals(ATTR_LAYOUT_ALIGN_BASELINE)) { + // Vertical constraint + childView.addVerticalConstraint(name, value); + } else if (name.equals(ATTR_LAYOUT_ALIGN_LEFT) || + name.equals(ATTR_LAYOUT_TO_LEFT_OF) || + name.equals(ATTR_LAYOUT_TO_RIGHT_OF) || + name.equals(ATTR_LAYOUT_ALIGN_RIGHT)) { + // Horizontal constraint + childView.addHorizConstraint(name, value); + } else { + // We don't expect this + assert false : name; + } + } else { + // Reference to some layout that is not included here. + // TODO: See if the given layout has an edge + // that corresponds to one of our known views + // so we can adjust the constraints and keep it after all. + } + } else { + // It's a parent-relative constraint (such + // as aligning with a parent edge, or centering + // in the parent view) + boolean remove = true; + if (name.equals(ATTR_LAYOUT_ALIGN_PARENT_LEFT)) { + View referenced = edgeList.getSharedLeftEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addHorizConstraint(name, VALUE_TRUE); + } else { + childView.addHorizConstraint( + ATTR_LAYOUT_ALIGN_LEFT, referenced.getId()); + } + remove = false; + } + } else if (name.equals(ATTR_LAYOUT_ALIGN_PARENT_RIGHT)) { + View referenced = edgeList.getSharedRightEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addHorizConstraint(name, VALUE_TRUE); + } else { + childView.addHorizConstraint( + ATTR_LAYOUT_ALIGN_RIGHT, referenced.getId()); + } + remove = false; + } + } else if (name.equals(ATTR_LAYOUT_ALIGN_PARENT_TOP)) { + View referenced = edgeList.getSharedTopEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addVerticalConstraint(name, VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_TOP, + referenced.getId()); + } + remove = false; + } + } else if (name.equals(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM)) { + View referenced = edgeList.getSharedBottomEdge(layout); + if (referenced != null) { + if (isAncestor(referenced.getElement(), child)) { + childView.addVerticalConstraint(name, VALUE_TRUE); + } else { + childView.addVerticalConstraint(ATTR_LAYOUT_ALIGN_BOTTOM, + referenced.getId()); + } + remove = false; + } + } + + boolean alignWithParent = + name.equals(ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING); + if (remove && alignWithParent) { + // TODO - look for this one AFTER we have processed + // everything else, and then set constraints as necessary + // IF there are no other conflicting constraints! + } + + // Otherwise it's some kind of centering which we don't support + // yet. + + // TODO: Find a way to determine whether we have + // a corresponding edge for the parent (e.g. if + // the ViewInfo bounds match our outer parent or + // some other edge) and if so, substitute for that + // id. + // For example, if this element was centered + // horizontally in a RelativeLayout that actually + // occupies the entire width of our outer layout, + // then it can be preserved after all! + + if (remove) { + if (name.startsWith("layout_margin")) { //$NON-NLS-1$ + continue; + } + + // Remove unknown attributes? + // It's too early to do this, because we may later want + // to *set* this value and it would result in an overlapping edits + // exception. Therefore, we need to RECORD which attributes should + // be removed, which lines should have its indentation adjusted + // etc and finally process it all at the end! + //mRefactoring.removeAttribute(mRootEdit, child, + // attribute.getNamespaceURI(), name); + } + } + } + } + } + } + } + + /** + * Given {@code @id/foo} or {@code @+id/foo}, returns foo. Note that given foo it will + * return null. + */ + private static String getIdBasename(String id) { + 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; + } + + /** Returns true if the given second argument is a descendant of the first argument */ + private static boolean isAncestor(Node ancestor, Node node) { + while (node != null) { + if (node == ancestor) { + return true; + } + node = node.getParentNode(); + } + return false; + } + + /** + * Computes horizontal constraints for the views in the grid for any remaining views + * that do not have constraints (as the result of the analysis of known layouts). This + * will look at the rendered layout coordinates and attempt to connect elements based + * on a spatial layout in the grid. + */ + private void computeHorizontalConstraints(Grid grid) { + int columns = grid.getColumns(); + + String attachLeftProperty = ATTR_LAYOUT_ALIGN_PARENT_LEFT; + String attachLeftValue = VALUE_TRUE; + int marginLeft = 0; + for (int col = 0; col < columns; col++) { + if (!grid.colContainsTopLeftCorner(col)) { + // Just accumulate margins for the next column + marginLeft += grid.getColumnWidth(col); + } else { + // Add horizontal attachments + String firstId = null; + for (View view : grid.viewsStartingInCol(col, true)) { + assert view.getId() != null; + if (firstId == null) { + firstId = view.getId(); + if (view.isConstrainedHorizontally()) { + // Nothing to do -- we already have an accurate position for + // this view + } else if (attachLeftProperty != null) { + view.addHorizConstraint(attachLeftProperty, attachLeftValue); + if (marginLeft > 0) { + view.addHorizConstraint(ATTR_LAYOUT_MARGIN_LEFT, + String.format(VALUE_N_DP, marginLeft)); + marginLeft = 0; + } + } else { + assert false; + } + } else if (!view.isConstrainedHorizontally()) { + view.addHorizConstraint(ATTR_LAYOUT_ALIGN_LEFT, firstId); + } + } + } + + // Figure out edge for the next column + View view = grid.findRightEdgeView(col); + if (view != null) { + assert view.getId() != null; + attachLeftProperty = ATTR_LAYOUT_TO_RIGHT_OF; + attachLeftValue = view.getId(); + + marginLeft = 0; + } else if (marginLeft == 0) { + marginLeft = grid.getColumnWidth(col); + } + } + } + + /** + * Performs vertical layout just like the {@link #computeHorizontalConstraints} method + * did horizontally + */ + private void computeVerticalConstraints(Grid grid) { + int rows = grid.getRows(); + + String attachTopProperty = ATTR_LAYOUT_ALIGN_PARENT_TOP; + String attachTopValue = VALUE_TRUE; + int marginTop = 0; + for (int row = 0; row < rows; row++) { + if (!grid.rowContainsTopLeftCorner(row)) { + // Just accumulate margins for the next column + marginTop += grid.getRowHeight(row); + } else { + // Add horizontal attachments + String firstId = null; + for (View view : grid.viewsStartingInRow(row, true)) { + assert view.getId() != null; + if (firstId == null) { + firstId = view.getId(); + if (view.isConstrainedVertically()) { + // Nothing to do -- we already have an accurate position for + // this view + } else if (attachTopProperty != null) { + view.addVerticalConstraint(attachTopProperty, attachTopValue); + if (marginTop > 0) { + view.addVerticalConstraint(ATTR_LAYOUT_MARGIN_TOP, + String.format(VALUE_N_DP, marginTop)); + marginTop = 0; + } + } else { + assert false; + } + } else if (!view.isConstrainedVertically()) { + view.addVerticalConstraint(ATTR_LAYOUT_ALIGN_TOP, firstId); + } + } + } + + // Figure out edge for the next row + View view = grid.findBottomEdgeView(row); + if (view != null) { + assert view.getId() != null; + attachTopProperty = ATTR_LAYOUT_BELOW; + attachTopValue = view.getId(); + marginTop = 0; + } else if (marginTop == 0) { + marginTop = grid.getRowHeight(row); + } + } + } + + /** + * Searches a view hierarchy and locates the {@link CanvasViewInfo} for the given + * {@link Element} + * + * @param info the root {@link CanvasViewInfo} to search below + * @param element the target element + * @return the {@link CanvasViewInfo} which corresponds to the given element + */ + private CanvasViewInfo findViewForElement(CanvasViewInfo info, Element element) { + if (getElement(info) == element) { + return info; + } + + for (CanvasViewInfo child : info.getChildren()) { + CanvasViewInfo result = findViewForElement(child, element); + if (result != null) { + return result; + } + } + + return null; + } + + /** Returns the {@link Element} for the given {@link CanvasViewInfo} */ + private static Element getElement(CanvasViewInfo info) { + Node node = info.getUiViewNode().getXmlNode(); + if (node instanceof Element) { + return (Element) node; + } + + return null; + } + + /** + * A grid of cells which can contain views, used to infer spatial relationships when + * computing constraints. Note that a view can appear in than one cell; they will + * appear in all cells that their bounds overlap with! + */ + private class Grid { + private final int[] mLeft; + private final int[] mTop; + // A list from row to column to cell, where a cell is a list of views + private final List<List<List<View>>> mRowList; + private int mRowCount; + private int mColCount; + + Grid(List<View> views, int[] left, int[] top) { + mLeft = left; + mTop = top; + + // The left/top arrays should include the ending point too + mColCount = left.length - 1; + mRowCount = top.length - 1; + + // Using nested lists rather than arrays to avoid lack of typed arrays + // (can't create List<View>[row][column] arrays) + mRowList = new ArrayList<List<List<View>>>(top.length); + for (int row = 0; row < top.length; row++) { + List<List<View>> columnList = new ArrayList<List<View>>(left.length); + for (int col = 0; col < left.length; col++) { + columnList.add(new ArrayList<View>(4)); + } + mRowList.add(columnList); + } + + for (View view : views) { + // Get rid of the root view; we don't want that in the attachments logic; + // it was there originally such that it would contribute the outermost + // edges. + if (view.mElement == mLayout) { + continue; + } + + for (int i = 0; i < view.mRowSpan; i++) { + for (int j = 0; j < view.mColSpan; j++) { + mRowList.get(view.mRow + i).get(view.mCol + j).add(view); + } + } + } + } + + /** + * Returns the number of rows in the grid + * + * @return the row count + */ + public int getRows() { + return mRowCount; + } + + /** + * Returns the number of columns in the grid + * + * @return the column count + */ + public int getColumns() { + return mColCount; + } + + /** + * Returns the list of views overlapping the given cell + * + * @param row the row of the target cell + * @param col the column of the target cell + * @return a list of views overlapping the given column + */ + public List<View> get(int row, int col) { + return mRowList.get(row).get(col); + } + + /** + * Returns true if the given column contains a top left corner of a view + * + * @param column the column to check + * @return true if one or more views have their top left corner in this column + */ + public boolean colContainsTopLeftCorner(int column) { + for (int row = 0; row < mRowCount; row++) { + View view = getTopLeftCorner(row, column); + if (view != null) { + return true; + } + } + + return false; + } + + /** + * Returns true if the given row contains a top left corner of a view + * + * @param row the row to check + * @return true if one or more views have their top left corner in this row + */ + public boolean rowContainsTopLeftCorner(int row) { + for (int col = 0; col < mColCount; col++) { + View view = getTopLeftCorner(row, col); + if (view != null) { + return true; + } + } + + return false; + } + + /** + * Returns a list of views (optionally sorted by increasing row index) that have + * their left edge starting in the given column + * + * @param col the column to look up views for + * @param sort whether to sort the result in increasing row order + * @return a list of views starting in the given column + */ + public List<View> viewsStartingInCol(int col, boolean sort) { + List<View> views = new ArrayList<View>(); + for (int row = 0; row < mRowCount; row++) { + View view = getTopLeftCorner(row, col); + if (view != null) { + views.add(view); + } + } + + if (sort) { + View.sortByRow(views); + } + + return views; + } + + /** + * Returns a list of views (optionally sorted by increasing column index) that have + * their top edge starting in the given row + * + * @param row the row to look up views for + * @param sort whether to sort the result in increasing column order + * @return a list of views starting in the given row + */ + public List<View> viewsStartingInRow(int row, boolean sort) { + List<View> views = new ArrayList<View>(); + for (int col = 0; col < mColCount; col++) { + View view = getTopLeftCorner(row, col); + if (view != null) { + views.add(view); + } + } + + if (sort) { + View.sortByColumn(views); + } + + return views; + } + + /** + * Returns the pixel width of the given column + * + * @param col the column to look up the width of + * @return the width of the column + */ + public int getColumnWidth(int col) { + return mLeft[col + 1] - mLeft[col]; + } + + /** + * Returns the pixel height of the given row + * + * @param row the row to look up the height of + * @return the height of the row + */ + public int getRowHeight(int row) { + return mTop[row + 1] - mTop[row]; + } + + /** + * Returns the first view found that has its top left corner in the cell given by + * the row and column indexes, or null if not found. + * + * @param row the row of the target cell + * @param col the column of the target cell + * @return a view with its top left corner in the given cell, or null if not found + */ + View getTopLeftCorner(int row, int col) { + List<View> views = get(row, col); + if (views.size() > 0) { + for (View view : views) { + if (view.mRow == row && view.mCol == col) { + return view; + } + } + } + + return null; + } + + public View findRightEdgeView(int col) { + for (int row = 0; row < mRowCount; row++) { + List<View> views = get(row, col); + if (views.size() > 0) { + List<View> result = new ArrayList<View>(); + for (View view : views) { + // Ends on the right edge of this column? + if (view.mCol + view.mColSpan == col + 1) { + result.add(view); + } + } + if (result.size() > 1) { + View.sortByColumn(result); + } + if (result.size() > 0) { + return result.get(0); + } + } + } + + return null; + } + + public View findBottomEdgeView(int row) { + for (int col = 0; col < mColCount; col++) { + List<View> views = get(row, col); + if (views.size() > 0) { + List<View> result = new ArrayList<View>(); + for (View view : views) { + // Ends on the bottom edge of this column? + if (view.mRow + view.mRowSpan == row + 1) { + result.add(view); + } + } + if (result.size() > 1) { + View.sortByRow(result); + } + if (result.size() > 0) { + return result.get(0); + } + + } + } + + return null; + } + + /** + * 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 = 20; + + 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 < mColCount + 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 < mRowCount + 1; row++) { + out.printf("%" + cellWidth + "d", mTop[row]); //$NON-NLS-1$ //$NON-NLS-2$ + if (row == mRowCount) { + break; + } + for (int col = 0; col < mColCount; col++) { + List<View> views = get(row, col); + StringBuilder sb = new StringBuilder(); + for (View 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(); + } + } + + /** Holds layout information about an individual view. */ + private static class View { + private final Element mElement; + private int mRow = -1; + private int mCol = -1; + private int mRowSpan = -1; + private int mColSpan = -1; + private CanvasViewInfo mInfo; + private String mId; + private List<Pair<String, String>> mHorizConstraints = + new ArrayList<Pair<String, String>>(4); + private List<Pair<String, String>> mVerticalConstraints = + new ArrayList<Pair<String, String>>(4); + private int mGravity; + + public View(CanvasViewInfo view, Element element) { + mInfo = view; + mElement = element; + mGravity = GravityHelper.getGravity(element); + } + + public int getHeight() { + return mInfo.getAbsRect().height; + } + + public int getGravity() { + return mGravity; + } + + public String getId() { + return mId; + } + + public Element getElement() { + return mElement; + } + + public List<Pair<String, String>> getHorizConstraints() { + return mHorizConstraints; + } + + public List<Pair<String, String>> getVerticalConstraints() { + return mVerticalConstraints; + } + + public boolean isConstrainedHorizontally() { + return mHorizConstraints.size() > 0; + } + + public boolean isConstrainedVertically() { + return mVerticalConstraints.size() > 0; + } + + public void addHorizConstraint(String property, String value) { + assert property != null && value != null; + // TODO - look for duplicates? + mHorizConstraints.add(Pair.of(property, value)); + } + + public void addVerticalConstraint(String property, String value) { + assert property != null && value != null; + mVerticalConstraints.add(Pair.of(property, value)); + } + + public int getLeftEdge() { + return mInfo.getAbsRect().x; + } + + public int getTopEdge() { + return mInfo.getAbsRect().y; + } + + public int getRightEdge() { + Rectangle bounds = mInfo.getAbsRect(); + // +1: make the bounds overlap, so the right edge is the same as the + // left edge of the neighbor etc. Otherwise we end up with lots of 1-pixel wide + // columns between adjacent items. + return bounds.x + bounds.width + 1; + } + + public int getBottomEdge() { + Rectangle bounds = mInfo.getAbsRect(); + return bounds.y + bounds.height + 1; + } + + @Override + public String toString() { + return "View [mId=" + mId + "]"; //$NON-NLS-1$ //$NON-NLS-2$ + } + + public static void sortByRow(List<View> views) { + Collections.sort(views, new ViewComparator(true/*rowSort*/)); + } + + public static void sortByColumn(List<View> views) { + Collections.sort(views, new ViewComparator(false/*rowSort*/)); + } + + /** Comparator to help sort views by row or column index */ + private static class ViewComparator implements Comparator<View> { + boolean mRowSort; + + public ViewComparator(boolean rowSort) { + mRowSort = rowSort; + } + + @Override + public int compare(View view1, View view2) { + if (mRowSort) { + return view1.mRow - view2.mRow; + } else { + return view1.mCol - view2.mCol; + } + } + } + } + + /** + * An edge list takes a hierarchy of elements and records the bounds of each element + * into various lists such that it can answer queries about shared edges, about which + * particular pixels occur as a boundary edge, etc. + */ + private class EdgeList { + private final Map<Element, View> mElementToViewMap = new HashMap<Element, View>(100); + private final Map<String, View> mIdToViewMap = new HashMap<String, View>(100); + private final Map<Integer, List<View>> mLeft = new HashMap<Integer, List<View>>(); + private final Map<Integer, List<View>> mTop = new HashMap<Integer, List<View>>(); + private final Map<Integer, List<View>> mRight = new HashMap<Integer, List<View>>(); + private final Map<Integer, List<View>> mBottom = new HashMap<Integer, List<View>>(); + private final Map<Element, Element> mSharedLeftEdge = new HashMap<Element, Element>(); + private final Map<Element, Element> mSharedTopEdge = new HashMap<Element, Element>(); + private final Map<Element, Element> mSharedRightEdge = new HashMap<Element, Element>(); + private final Map<Element, Element> mSharedBottomEdge = new HashMap<Element, Element>(); + private final List<Element> mDelete = new ArrayList<Element>(); + + EdgeList(CanvasViewInfo view) { + analyze(view, true); + mDelete.remove(getElement(view)); + } + + public void setIdAttributeValue(View view, String id) { + assert id.startsWith(NEW_ID_PREFIX) || id.startsWith(ID_PREFIX); + view.mId = id; + mIdToViewMap.put(getIdBasename(id), view); + } + + public View getView(Element element) { + return mElementToViewMap.get(element); + } + + public View getView(String id) { + return mIdToViewMap.get(id); + } + + public List<View> getTopEdgeViews(Integer topOffset) { + return mTop.get(topOffset); + } + + public List<View> getLeftEdgeViews(Integer leftOffset) { + return mLeft.get(leftOffset); + } + + void record(Map<Integer, List<View>> map, Integer edge, View info) { + List<View> list = map.get(edge); + if (list == null) { + list = new ArrayList<View>(); + map.put(edge, list); + } + list.add(info); + } + + private List<Integer> getOffsets(Set<Integer> first, Set<Integer> second) { + Set<Integer> joined = new HashSet<Integer>(first.size() + second.size()); + joined.addAll(first); + joined.addAll(second); + List<Integer> unique = new ArrayList<Integer>(joined); + Collections.sort(unique); + + return unique; + } + + public List<Element> getDeletedElements() { + return mDelete; + } + + public List<Integer> getColumnOffsets() { + return getOffsets(mLeft.keySet(), mRight.keySet()); + } + public List<Integer> getRowOffsets() { + return getOffsets(mTop.keySet(), mBottom.keySet()); + } + + private View analyze(CanvasViewInfo view, boolean isRoot) { + View added = null; + if (!mFlatten || !isRemovableLayout(view)) { + added = add(view); + if (!isRoot) { + return added; + } + } else { + mDelete.add(getElement(view)); + } + + Element parentElement = getElement(view); + Rectangle parentBounds = view.getAbsRect(); + + // Build up a table model of the view + for (CanvasViewInfo child : view.getChildren()) { + Rectangle childBounds = child.getAbsRect(); + Element childElement = getElement(child); + + // See if this view shares the edge with the removed + // parent layout, and if so, record that such that we can + // later handle attachments to the removed parent edges + if (parentBounds.x == childBounds.x) { + mSharedLeftEdge.put(childElement, parentElement); + } + if (parentBounds.y == childBounds.y) { + mSharedTopEdge.put(childElement, parentElement); + } + if (parentBounds.x + parentBounds.width == childBounds.x + childBounds.width) { + mSharedRightEdge.put(childElement, parentElement); + } + if (parentBounds.y + parentBounds.height == childBounds.y + childBounds.height) { + mSharedBottomEdge.put(childElement, parentElement); + } + + if (mFlatten && isRemovableLayout(child)) { + // When flattening, we want to disregard all layouts and instead + // add their children! + for (CanvasViewInfo childView : child.getChildren()) { + analyze(childView, false); + + Element childViewElement = getElement(childView); + Rectangle childViewBounds = childView.getAbsRect(); + + // See if this view shares the edge with the removed + // parent layout, and if so, record that such that we can + // later handle attachments to the removed parent edges + if (parentBounds.x == childViewBounds.x) { + mSharedLeftEdge.put(childViewElement, parentElement); + } + if (parentBounds.y == childViewBounds.y) { + mSharedTopEdge.put(childViewElement, parentElement); + } + if (parentBounds.x + parentBounds.width == childViewBounds.x + + childViewBounds.width) { + mSharedRightEdge.put(childViewElement, parentElement); + } + if (parentBounds.y + parentBounds.height == childViewBounds.y + + childViewBounds.height) { + mSharedBottomEdge.put(childViewElement, parentElement); + } + } + mDelete.add(childElement); + } else { + analyze(child, false); + } + } + + return added; + } + + public View getSharedLeftEdge(Element element) { + return getSharedEdge(element, mSharedLeftEdge); + } + + public View getSharedRightEdge(Element element) { + return getSharedEdge(element, mSharedRightEdge); + } + + public View getSharedTopEdge(Element element) { + return getSharedEdge(element, mSharedTopEdge); + } + + public View getSharedBottomEdge(Element element) { + return getSharedEdge(element, mSharedBottomEdge); + } + + private View getSharedEdge(Element element, Map<Element, Element> sharedEdgeMap) { + Element original = element; + + while (element != null) { + View view = getView(element); + if (view != null) { + assert isAncestor(element, original); + return view; + } + element = sharedEdgeMap.get(element); + } + + return null; + } + + private View add(CanvasViewInfo info) { + Rectangle bounds = info.getAbsRect(); + Element element = getElement(info); + View view = new View(info, element); + mElementToViewMap.put(element, view); + record(mLeft, Integer.valueOf(bounds.x), view); + record(mTop, Integer.valueOf(bounds.y), view); + record(mRight, Integer.valueOf(view.getRightEdge()), view); + record(mBottom, Integer.valueOf(view.getBottomEdge()), view); + return view; + } + + /** + * Returns true if the given {@link CanvasViewInfo} represents an element we + * should remove in a flattening conversion. We don't want to remove non-layout + * views, or layout views that for example contain drawables on their own. + */ + private boolean isRemovableLayout(CanvasViewInfo child) { + // The element being converted is NOT removable! + Element element = getElement(child); + if (element == mLayout) { + return false; + } + + ElementDescriptor descriptor = child.getUiViewNode().getDescriptor(); + String name = descriptor.getXmlLocalName(); + if (name.equals(LINEAR_LAYOUT) || name.equals(RELATIVE_LAYOUT)) { + // Don't delete layouts that provide a background image or gradient + if (element.hasAttributeNS(ANDROID_URI, ATTR_BACKGROUND)) { + AdtPlugin.log(IStatus.WARNING, + "Did not flatten layout %1$s because it defines a '%2$s' attribute", + VisualRefactoring.getId(element), ATTR_BACKGROUND); + return false; + } + + return true; + } + + return false; + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapAction.java new file mode 100644 index 000000000..02c2a276c --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapAction.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Remove Container" menu item is invoked. + */ +public class UnwrapAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + UnwrapRefactoring ref = new UnwrapRefactoring(mFile, mDelegate, + mTextSelection, mTreeSelection); + RefactoringWizard wizard = new UnwrapWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Remove Container...", editorDelegate, UnwrapAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapContribution.java new file mode 100644 index 000000000..0869fd637 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapContribution.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import org.eclipse.ltk.core.refactoring.RefactoringContribution; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; + +import java.util.Map; + +public class UnwrapContribution extends RefactoringContribution { + + @SuppressWarnings("unchecked") + @Override + public RefactoringDescriptor createDescriptor(String id, String project, String description, + String comment, Map arguments, int flags) throws IllegalArgumentException { + return new UnwrapRefactoring.Descriptor(project, description, comment, arguments); + } + + @SuppressWarnings("unchecked") + @Override + public Map retrieveArgumentMap(RefactoringDescriptor descriptor) { + if (descriptor instanceof UnwrapRefactoring.Descriptor) { + return ((UnwrapRefactoring.Descriptor) descriptor).getArguments(); + } + return super.retrieveArgumentMap(descriptor); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapRefactoring.java new file mode 100644 index 000000000..4eff2cde5 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapRefactoring.java @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.EXT_XML; + +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Removes the layout surrounding the current selection (or if the current selection has + * children, removes the current layout), and migrates namespace and layout attributes. + */ +@SuppressWarnings("restriction") // XML model +public class UnwrapRefactoring extends VisualRefactoring { + private Element mContainer; + + /** + * This constructor is solely used by {@link Descriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + UnwrapRefactoring(Map<String, String> arguments) { + super(arguments); + } + + public UnwrapRefactoring( + IFile file, + LayoutEditorDelegate delegate, + ITextSelection selection, + ITreeSelection treeSelection) { + super(file, delegate, selection, treeSelection); + } + + @VisibleForTesting + UnwrapRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { + super(selectedElements, editor); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 6); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("No selection to wrap"); + return status; + } + + // Make sure that the selection all has the same parent? + if (mElements.size() == 0) { + status.addFatalError("Nothing to unwrap"); + return status; + } + + Element first = mElements.get(0); + + // Determine the element of the container to be removed. + // If you've selected a non-container, or you've selected multiple + // elements, then it's the parent which should be removed. Otherwise, + // it's the selection itself which represents the container. + boolean useParent = mElements.size() > 1; + if (!useParent) { + if (DomUtilities.getChildren(first).size() == 0) { + useParent = true; + } + } + Node parent = first.getParentNode(); + if (parent instanceof Document) { + mContainer = first; + List<Element> elements = DomUtilities.getChildren(mContainer); + if (elements.size() == 0) { + status.addFatalError( + "Cannot remove container when it has no children"); + return status; + } + } else if (useParent && (parent instanceof Element)) { + mContainer = (Element) parent; + } else { + mContainer = first; + } + + for (Element element : mElements) { + if (element.getParentNode() != parent) { + status.addFatalError( + "All unwrapped elements must share the same parent element"); + return status; + } + } + + // Ensure that if we are removing the root, that it has only one child + // such that there is a new single root + if (mContainer.getParentNode() instanceof Document) { + if (DomUtilities.getChildren(mContainer).size() > 1) { + status.addFatalError( + "Cannot remove root: it has more than one child " + + "which would result in multiple new roots"); + return status; + } + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + public String getName() { + return "Remove Container"; + } + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + // (1) If the removed parent is the root container, transfer its + // namespace declarations + // (2) Remove the root element completely + // (3) Transfer layout attributes? + // (4) Check for Java R.file usages? + + IFile file = mDelegate.getEditor().getInputFile(); + List<Change> changes = new ArrayList<Change>(); + if (file == null) { + return changes; + } + MultiTextEdit rootEdit = new MultiTextEdit(); + + // Transfer namespace elements? + if (mContainer.getParentNode() instanceof Document) { + List<Element> elements = DomUtilities.getChildren(mContainer); + assert elements.size() == 1; + Element newRoot = elements.get(0); + + List<Attr> declarations = findNamespaceAttributes(mContainer); + for (Attr attribute : declarations) { + if (attribute instanceof IndexedRegion) { + setAttribute(rootEdit, newRoot, attribute.getNamespaceURI(), + attribute.getPrefix(), attribute.getLocalName(), attribute.getValue()); + } + } + } + + // Transfer layout_ attributes (other than width and height) + List<Element> children = DomUtilities.getChildren(mContainer); + if (children.size() == 1) { + List<Attr> layoutAttributes = findLayoutAttributes(mContainer); + for (Attr attribute : layoutAttributes) { + String name = attribute.getLocalName(); + if ((name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) + && ANDROID_URI.equals(attribute.getNamespaceURI())) { + // Already handled specially + continue; + } + } + } + + // Remove the root + removeElementTags(rootEdit, mContainer, Collections.<Element>emptyList() /* skip */, + false /*changeIndentation*/); + + MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT); + if (formatted != null) { + rootEdit = formatted; + } + + TextFileChange change = new TextFileChange(file.getName(), file); + change.setEdit(rootEdit); + change.setTextType(EXT_XML); + changes.add(change); + return changes; + } + + @Override + public VisualRefactoringWizard createWizard() { + return new UnwrapWizard(this, mDelegate); + } + + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.unwrap", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new UnwrapRefactoring(args); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapWizard.java new file mode 100644 index 000000000..6e3bcf1e7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UnwrapWizard.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +public class UnwrapWizard extends VisualRefactoringWizard { + public UnwrapWizard(UnwrapRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle("Remove Container"); + } + + @Override + protected void addUserInputPages() { + // This refactoring takes no parameters + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableAction.java new file mode 100644 index 000000000..84d3e7ee8 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableAction.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Convert Layout" menu item is invoked. + */ +public class UseCompoundDrawableAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + UseCompoundDrawableRefactoring ref = new UseCompoundDrawableRefactoring( + mFile, mDelegate, mTextSelection, mTreeSelection); + RefactoringWizard wizard = new UseCompoundDrawableWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Convert to a Compound Drawable...", editorDelegate, + UseCompoundDrawableAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableRefactoring.java new file mode 100644 index 000000000..0e56bdf4d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableRefactoring.java @@ -0,0 +1,452 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_DRAWABLE_BOTTOM; +import static com.android.SdkConstants.ATTR_DRAWABLE_LEFT; +import static com.android.SdkConstants.ATTR_DRAWABLE_PADDING; +import static com.android.SdkConstants.ATTR_DRAWABLE_RIGHT; +import static com.android.SdkConstants.ATTR_DRAWABLE_TOP; +import static com.android.SdkConstants.ATTR_GRAVITY; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.ATTR_SRC; +import static com.android.SdkConstants.EXT_XML; +import static com.android.SdkConstants.IMAGE_VIEW; +import static com.android.SdkConstants.LINEAR_LAYOUT; +import static com.android.SdkConstants.PREFIX_RESOURCE_REF; +import static com.android.SdkConstants.TEXT_VIEW; +import static com.android.SdkConstants.VALUE_VERTICAL; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlFormatPreferences; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlPrettyPrinter; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Converts a LinearLayout with exactly a TextView child and an ImageView child into + * a single TextView with a compound drawable. + */ +@SuppressWarnings("restriction") // XML model +public class UseCompoundDrawableRefactoring extends VisualRefactoring { + /** + * Constructs a new {@link UseCompoundDrawableRefactoring} + * + * @param file the file to refactor in + * @param editor the corresponding editor + * @param selection the editor selection, or null + * @param treeSelection the canvas selection, or null + */ + public UseCompoundDrawableRefactoring(IFile file, LayoutEditorDelegate editor, + ITextSelection selection, ITreeSelection treeSelection) { + super(file, editor, selection, treeSelection); + } + + /** + * This constructor is solely used by {@link Descriptor}, to replay a + * previous refactoring. + * + * @param arguments argument map created by #createArgumentMap. + */ + private UseCompoundDrawableRefactoring(Map<String, String> arguments) { + super(arguments); + } + + @VisibleForTesting + UseCompoundDrawableRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { + super(selectedElements, editor); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 6); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("Nothing to convert"); + return status; + } + + // Make sure the selection is contiguous + if (mTreeSelection != null) { + List<CanvasViewInfo> infos = getSelectedViewInfos(); + if (!validateNotEmpty(infos, status)) { + return status; + } + + // Enforce that the selection is -contiguous- + if (!validateContiguous(infos, status)) { + return status; + } + } + + // Ensures that we have a valid DOM model: + if (mElements.size() == 0) { + status.addFatalError("Nothing to convert"); + return status; + } + + // Ensure that we have selected precisely one LinearLayout + if (mElements.size() != 1 || + !(mElements.get(0).getTagName().equals(LINEAR_LAYOUT))) { + status.addFatalError("Must select exactly one LinearLayout"); + return status; + } + + Element layout = mElements.get(0); + List<Element> children = DomUtilities.getChildren(layout); + if (children.size() != 2) { + status.addFatalError("The LinearLayout must have exactly two children"); + return status; + } + Element first = children.get(0); + Element second = children.get(1); + boolean haveTextView = + first.getTagName().equals(TEXT_VIEW) + || second.getTagName().equals(TEXT_VIEW); + boolean haveImageView = + first.getTagName().equals(IMAGE_VIEW) + || second.getTagName().equals(IMAGE_VIEW); + if (!(haveTextView && haveImageView)) { + status.addFatalError("The LinearLayout must have exactly one TextView child " + + "and one ImageView child"); + return status; + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + protected Map<String, String> createArgumentMap() { + return super.createArgumentMap(); + } + + @Override + public String getName() { + return "Convert to Compound Drawable"; + } + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + String androidNsPrefix = getAndroidNamespacePrefix(); + IFile file = mDelegate.getEditor().getInputFile(); + List<Change> changes = new ArrayList<Change>(); + if (file == null) { + return changes; + } + TextFileChange change = new TextFileChange(file.getName(), file); + MultiTextEdit rootEdit = new MultiTextEdit(); + change.setTextType(EXT_XML); + + // (1) Build up the contents of the new TextView. This is identical + // to the old contents, but with the addition of a drawableTop/Left/Right/Bottom + // attribute (depending on the orientation and order), as well as any layout + // params from the LinearLayout. + // (2) Delete the linear layout and replace with the text view. + // (3) Reformat. + + // checkInitialConditions has already validated that we have exactly a LinearLayout + // with an ImageView and a TextView child (in either order) + Element layout = mElements.get(0); + List<Element> children = DomUtilities.getChildren(layout); + Element first = children.get(0); + Element second = children.get(1); + final Element text; + final Element image; + if (first.getTagName().equals(TEXT_VIEW)) { + text = first; + image = second; + } else { + text = second; + image = first; + } + + // Horizontal is the default, so if no value is specified it is horizontal. + boolean isVertical = VALUE_VERTICAL.equals(layout.getAttributeNS(ANDROID_URI, + ATTR_ORIENTATION)); + + // The WST DOM implementation doesn't correctly implement cloneNode: this returns + // an empty document instead: + // text.getOwnerDocument().cloneNode(false/*deep*/); + // Luckily we just need to clone a single element, not a nested structure, so it's + // easy enough to do this manually: + Document tempDocument = DomUtilities.createEmptyDocument(); + if (tempDocument == null) { + return changes; + } + Element newTextElement = tempDocument.createElement(text.getTagName()); + tempDocument.appendChild(newTextElement); + + NamedNodeMap attributes = text.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Attr attribute = (Attr) attributes.item(i); + String name = attribute.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attribute.getNamespaceURI()) + && !(name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT))) { + // Ignore layout params: the parent layout is going away + } else { + newTextElement.setAttribute(attribute.getName(), attribute.getValue()); + } + } + + // Apply all layout params from the parent (except width and height), + // as well as android:gravity + List<Attr> layoutAttributes = findLayoutAttributes(layout); + for (Attr attribute : layoutAttributes) { + String name = attribute.getLocalName(); + if ((name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) + && ANDROID_URI.equals(attribute.getNamespaceURI())) { + // Already handled specially + continue; + } + newTextElement.setAttribute(attribute.getName(), attribute.getValue()); + } + String gravity = layout.getAttributeNS(ANDROID_URI, ATTR_GRAVITY); + if (gravity.length() > 0) { + setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_GRAVITY, gravity); + } + + String src = image.getAttributeNS(ANDROID_URI, ATTR_SRC); + + // Set the drawable + String drawableAttribute; + // The space between the image and the text can have margins/padding, both + // from the text's perspective and from the image's perspective. We need to + // combine these. + String padding1 = null; + String padding2 = null; + if (isVertical) { + if (first == image) { + drawableAttribute = ATTR_DRAWABLE_TOP; + padding1 = getPadding(image, ATTR_LAYOUT_MARGIN_BOTTOM); + padding2 = getPadding(text, ATTR_LAYOUT_MARGIN_TOP); + } else { + drawableAttribute = ATTR_DRAWABLE_BOTTOM; + padding1 = getPadding(text, ATTR_LAYOUT_MARGIN_BOTTOM); + padding2 = getPadding(image, ATTR_LAYOUT_MARGIN_TOP); + } + } else { + if (first == image) { + drawableAttribute = ATTR_DRAWABLE_LEFT; + padding1 = getPadding(image, ATTR_LAYOUT_MARGIN_RIGHT); + padding2 = getPadding(text, ATTR_LAYOUT_MARGIN_LEFT); + } else { + drawableAttribute = ATTR_DRAWABLE_RIGHT; + padding1 = getPadding(text, ATTR_LAYOUT_MARGIN_RIGHT); + padding2 = getPadding(image, ATTR_LAYOUT_MARGIN_LEFT); + } + } + + setAndroidAttribute(newTextElement, androidNsPrefix, drawableAttribute, src); + + String padding = combine(padding1, padding2); + if (padding != null) { + setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_DRAWABLE_PADDING, padding); + } + + // If the removed LinearLayout is the root container, transfer its namespace + // declaration to the TextView + if (layout.getParentNode() instanceof Document) { + List<Attr> declarations = findNamespaceAttributes(layout); + for (Attr attribute : declarations) { + if (attribute instanceof IndexedRegion) { + newTextElement.setAttribute(attribute.getName(), attribute.getValue()); + } + } + } + + // Update any layout references to the layout to point to the text view + String layoutId = getId(layout); + if (layoutId.length() > 0) { + String id = getId(text); + if (id.length() == 0) { + id = ensureHasId(rootEdit, text, null, false); + setAndroidAttribute(newTextElement, androidNsPrefix, ATTR_ID, id); + } + + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null) { + List<TextEdit> replaceIds = replaceIds(androidNsPrefix, + doc, mSelectionStart, mSelectionEnd, layoutId, id); + for (TextEdit edit : replaceIds) { + rootEdit.addChild(edit); + } + } + } finally { + model.releaseFromRead(); + } + } + + String xml = EclipseXmlPrettyPrinter.prettyPrint( + tempDocument.getDocumentElement(), + EclipseXmlFormatPreferences.create(), + XmlFormatStyle.LAYOUT, null, false); + + TextEdit replace = new ReplaceEdit(mSelectionStart, mSelectionEnd - mSelectionStart, xml); + rootEdit.addChild(replace); + + if (AdtPrefs.getPrefs().getFormatGuiXml()) { + MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT); + if (formatted != null) { + rootEdit = formatted; + } + } + + change.setEdit(rootEdit); + changes.add(change); + return changes; + } + + @Nullable + private static String getPadding(@NonNull Element element, @NonNull String attribute) { + String padding = element.getAttributeNS(ANDROID_URI, attribute); + if (padding != null && padding.isEmpty()) { + padding = null; + } + return padding; + } + + @VisibleForTesting + @Nullable + static String combine(@Nullable String dimension1, @Nullable String dimension2) { + if (dimension1 == null || dimension1.isEmpty()) { + if (dimension2 != null && dimension2.isEmpty()) { + return null; + } + return dimension2; + } else if (dimension2 == null || dimension2.isEmpty()) { + if (dimension1 != null && dimension1.isEmpty()) { + return null; + } + return dimension1; + } else { + // Two dimensions are specified (e.g. marginRight for the left one and marginLeft + // for the right one); we have to add these together. We can only do that if + // they use the same units, and do not use resources. + if (dimension1.startsWith(PREFIX_RESOURCE_REF) + || dimension2.startsWith(PREFIX_RESOURCE_REF)) { + return null; + } + + Pattern p = Pattern.compile("([\\d\\.]+)(.+)"); //$NON-NLS-1$ + Matcher matcher1 = p.matcher(dimension1); + Matcher matcher2 = p.matcher(dimension2); + if (matcher1.matches() && matcher2.matches()) { + String unit = matcher1.group(2); + if (unit.equals(matcher2.group(2))) { + float value1 = Float.parseFloat(matcher1.group(1)); + float value2 = Float.parseFloat(matcher2.group(1)); + return AdtUtils.formatFloatAttribute(value1 + value2) + unit; + } + } + } + + return null; + } + + /** + * Sets an Android attribute (in the Android namespace) on an element + * without a given namespace prefix. This is done when building a new Element + * in a temporary document such that the namespace prefix matches when the element is + * formatted and replaced in the target document. + */ + private static void setAndroidAttribute(Element element, String prefix, String name, + String value) { + element.setAttribute(prefix + ':' + name, value); + } + + @Override + public VisualRefactoringWizard createWizard() { + return new UseCompoundDrawableWizard(this, mDelegate); + } + + @SuppressWarnings("javadoc") + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.usecompound", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new UseCompoundDrawableRefactoring(args); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableWizard.java new file mode 100644 index 000000000..3ffd6b5ea --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/UseCompoundDrawableWizard.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +class UseCompoundDrawableWizard extends VisualRefactoringWizard { + UseCompoundDrawableWizard(UseCompoundDrawableRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle("Use Compound Drawable"); + } + + @Override + protected void addUserInputPages() { + // This refactoring takes no parameters + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java new file mode 100644 index 000000000..904a3a084 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoring.java @@ -0,0 +1,1403 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_NS_NAME; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ID_PREFIX; +import static com.android.SdkConstants.NEW_ID_PREFIX; +import static com.android.SdkConstants.XMLNS; +import static com.android.SdkConstants.XMLNS_PREFIX; + +import com.android.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlFormatPreferences; +import com.android.ide.eclipse.adt.internal.editors.formatting.EclipseXmlPrettyPrinter; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationDescription; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart; +import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.core.runtime.Path; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.ChangeDescriptor; +import org.eclipse.ltk.core.refactoring.CompositeChange; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringChangeDescriptor; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.text.edits.DeleteEdit; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MalformedTreeException; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.ide.IDE; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList; +import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext; +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Parent class for the various visual refactoring operations; contains shared + * implementations needed by most of them + */ +@SuppressWarnings("restriction") // XML model +public abstract class VisualRefactoring extends Refactoring { + private static final String KEY_FILE = "file"; //$NON-NLS-1$ + private static final String KEY_PROJECT = "proj"; //$NON-NLS-1$ + private static final String KEY_SEL_START = "sel-start"; //$NON-NLS-1$ + private static final String KEY_SEL_END = "sel-end"; //$NON-NLS-1$ + + protected final IFile mFile; + protected final LayoutEditorDelegate mDelegate; + protected final IProject mProject; + protected int mSelectionStart = -1; + protected int mSelectionEnd = -1; + protected final List<Element> mElements; + protected final ITreeSelection mTreeSelection; + protected final ITextSelection mSelection; + /** Same as {@link #mSelectionStart} but not adjusted to element edges */ + protected int mOriginalSelectionStart = -1; + /** Same as {@link #mSelectionEnd} but not adjusted to element edges */ + protected int mOriginalSelectionEnd = -1; + + protected final Map<Element, String> mGeneratedIdMap = new HashMap<Element, String>(); + protected final Set<String> mGeneratedIds = new HashSet<String>(); + + protected List<Change> mChanges; + private String mAndroidNamespacePrefix; + + /** + * This constructor is solely used by {@link VisualRefactoringDescriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + VisualRefactoring(Map<String, String> arguments) { + IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT)); + mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path); + path = Path.fromPortableString(arguments.get(KEY_FILE)); + mFile = (IFile) ResourcesPlugin.getWorkspace().getRoot().findMember(path); + mSelectionStart = Integer.parseInt(arguments.get(KEY_SEL_START)); + mSelectionEnd = Integer.parseInt(arguments.get(KEY_SEL_END)); + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + mDelegate = null; + mElements = null; + mSelection = null; + mTreeSelection = null; + } + + @VisibleForTesting + VisualRefactoring(List<Element> elements, LayoutEditorDelegate delegate) { + mElements = elements; + mDelegate = delegate; + + mFile = delegate != null ? delegate.getEditor().getInputFile() : null; + mProject = delegate != null ? delegate.getEditor().getProject() : null; + mSelectionStart = 0; + mSelectionEnd = 0; + mOriginalSelectionStart = 0; + mOriginalSelectionEnd = 0; + mSelection = null; + mTreeSelection = null; + + int end = Integer.MIN_VALUE; + int start = Integer.MAX_VALUE; + for (Element element : elements) { + if (element instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) element; + start = Math.min(start, region.getStartOffset()); + end = Math.max(end, region.getEndOffset()); + } + } + if (start >= 0) { + mSelectionStart = start; + mSelectionEnd = end; + mOriginalSelectionStart = start; + mOriginalSelectionEnd = end; + } + } + + public VisualRefactoring(IFile file, LayoutEditorDelegate editor, ITextSelection selection, + ITreeSelection treeSelection) { + mFile = file; + mDelegate = editor; + mProject = file.getProject(); + mSelection = selection; + mTreeSelection = treeSelection; + + // Initialize mSelectionStart and mSelectionEnd based on the selection context, which + // is either a treeSelection (when invoked from the layout editor or the outline), or + // a selection (when invoked from an XML editor) + if (treeSelection != null) { + int end = Integer.MIN_VALUE; + int start = Integer.MAX_VALUE; + for (TreePath path : treeSelection.getPaths()) { + Object lastSegment = path.getLastSegment(); + if (lastSegment instanceof CanvasViewInfo) { + CanvasViewInfo viewInfo = (CanvasViewInfo) lastSegment; + UiViewElementNode uiNode = viewInfo.getUiViewNode(); + if (uiNode == null) { + continue; + } + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) xmlNode; + + start = Math.min(start, region.getStartOffset()); + end = Math.max(end, region.getEndOffset()); + } + } + } + if (start >= 0) { + mSelectionStart = start; + mSelectionEnd = end; + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + } + if (selection != null) { + mOriginalSelectionStart = selection.getOffset(); + mOriginalSelectionEnd = mOriginalSelectionStart + selection.getLength(); + } + } else if (selection != null) { + // TODO: update selection to boundaries! + mSelectionStart = selection.getOffset(); + mSelectionEnd = mSelectionStart + selection.getLength(); + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + } + + mElements = initElements(); + } + + @NonNull + protected abstract List<Change> computeChanges(IProgressMonitor monitor); + + @Override + public RefactoringStatus checkFinalConditions(IProgressMonitor monitor) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + mChanges = new ArrayList<Change>(); + try { + monitor.beginTask("Checking post-conditions...", 5); + + // Reset state for each computeChanges call, in case the user goes back + // and forth in the refactoring wizard + mGeneratedIdMap.clear(); + mGeneratedIds.clear(); + List<Change> changes = computeChanges(monitor); + mChanges.addAll(changes); + + monitor.worked(1); + } finally { + monitor.done(); + } + + return status; + } + + @Override + public Change createChange(IProgressMonitor monitor) throws CoreException, + OperationCanceledException { + try { + monitor.beginTask("Applying changes...", 1); + + CompositeChange change = new CompositeChange( + getName(), + mChanges.toArray(new Change[mChanges.size()])) { + @Override + public ChangeDescriptor getDescriptor() { + VisualRefactoringDescriptor desc = createDescriptor(); + return new RefactoringChangeDescriptor(desc); + } + }; + + monitor.worked(1); + return change; + + } finally { + monitor.done(); + } + } + + protected abstract VisualRefactoringDescriptor createDescriptor(); + + protected Map<String, String> createArgumentMap() { + HashMap<String, String> args = new HashMap<String, String>(); + args.put(KEY_PROJECT, mProject.getFullPath().toPortableString()); + args.put(KEY_FILE, mFile.getFullPath().toPortableString()); + args.put(KEY_SEL_START, Integer.toString(mSelectionStart)); + args.put(KEY_SEL_END, Integer.toString(mSelectionEnd)); + + return args; + } + + IFile getFile() { + return mFile; + } + + // ---- Shared functionality ---- + + + protected void openFile(IFile file) { + GraphicalEditorPart graphicalEditor = mDelegate.getGraphicalEditor(); + IFile leavingFile = graphicalEditor.getEditedFile(); + + try { + // Duplicate the current state into the newly created file + String state = ConfigurationDescription.getDescription(leavingFile); + + // TODO: Look for a ".NoTitleBar.Fullscreen" theme version of the current + // theme to show. + + file.setSessionProperty(GraphicalEditorPart.NAME_INITIAL_STATE, state); + } catch (CoreException e) { + // pass + } + + /* TBD: "Show Included In" if supported. + * Not sure if this is a good idea. + if (graphicalEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) { + try { + Reference include = Reference.create(graphicalEditor.getEditedFile()); + file.setSessionProperty(GraphicalEditorPart.NAME_INCLUDE, include); + } catch (CoreException e) { + // pass - worst that can happen is that we don't start with inclusion + } + } + */ + + try { + IEditorPart part = + IDE.openEditor(mDelegate.getEditor().getEditorSite().getPage(), file); + if (part instanceof AndroidXmlEditor && AdtPrefs.getPrefs().getFormatGuiXml()) { + AndroidXmlEditor newEditor = (AndroidXmlEditor) part; + newEditor.reformatDocument(); + } + } catch (PartInitException e) { + AdtPlugin.log(e, "Can't open new included layout"); + } + } + + + /** Produce a list of edits to replace references to the given id with the given new id */ + protected static List<TextEdit> replaceIds(String androidNamePrefix, + IStructuredDocument doc, int skipStart, int skipEnd, + String rootId, String referenceId) { + if (rootId == null) { + return Collections.emptyList(); + } + + // We need to search for either @+id/ or @id/ + String match1 = rootId; + String match2; + if (match1.startsWith(ID_PREFIX)) { + match2 = '"' + NEW_ID_PREFIX + match1.substring(ID_PREFIX.length()) + '"'; + match1 = '"' + match1 + '"'; + } else if (match1.startsWith(NEW_ID_PREFIX)) { + match2 = '"' + ID_PREFIX + match1.substring(NEW_ID_PREFIX.length()) + '"'; + match1 = '"' + match1 + '"'; + } else { + return Collections.emptyList(); + } + + String namePrefix = androidNamePrefix + ':' + ATTR_LAYOUT_RESOURCE_PREFIX; + List<TextEdit> edits = new ArrayList<TextEdit>(); + + IStructuredDocumentRegion region = doc.getFirstStructuredDocumentRegion(); + for (; region != null; region = region.getNext()) { + ITextRegionList list = region.getRegions(); + int regionStart = region.getStart(); + + // Look at all attribute values and look for an id reference match + String attributeName = ""; //$NON-NLS-1$ + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) { + attributeName = region.getText(subRegion); + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) { + // Only replace references in layout attributes + if (!attributeName.startsWith(namePrefix)) { + continue; + } + // Skip occurrences in the given skip range + int subRegionStart = regionStart + subRegion.getStart(); + if (subRegionStart >= skipStart && subRegionStart <= skipEnd) { + continue; + } + + String attributeValue = region.getText(subRegion); + if (attributeValue.equals(match1) || attributeValue.equals(match2)) { + int start = subRegionStart + 1; // skip quote + int end = start + rootId.length(); + + edits.add(new ReplaceEdit(start, end - start, referenceId)); + } + } + } + } + + return edits; + } + + /** Get the id of the root selected element, if any */ + protected String getRootId() { + Element primary = getPrimaryElement(); + if (primary != null) { + String oldId = primary.getAttributeNS(ANDROID_URI, ATTR_ID); + // id null check for https://bugs.eclipse.org/bugs/show_bug.cgi?id=272378 + if (oldId != null && oldId.length() > 0) { + return oldId; + } + } + + return null; + } + + protected String getAndroidNamespacePrefix() { + if (mAndroidNamespacePrefix == null) { + List<Attr> attributeNodes = findNamespaceAttributes(); + for (Node attributeNode : attributeNodes) { + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + String name = attributeNode.getNodeName(); + String value = attributeNode.getNodeValue(); + if (value.equals(ANDROID_URI)) { + mAndroidNamespacePrefix = name; + if (mAndroidNamespacePrefix.startsWith(XMLNS_PREFIX)) { + mAndroidNamespacePrefix = + mAndroidNamespacePrefix.substring(XMLNS_PREFIX.length()); + } + } + } + } + + if (mAndroidNamespacePrefix == null) { + mAndroidNamespacePrefix = ANDROID_NS_NAME; + } + } + + return mAndroidNamespacePrefix; + } + + protected static String getAndroidNamespacePrefix(Document document) { + String nsPrefix = null; + List<Attr> attributeNodes = findNamespaceAttributes(document); + for (Node attributeNode : attributeNodes) { + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + String name = attributeNode.getNodeName(); + String value = attributeNode.getNodeValue(); + if (value.equals(ANDROID_URI)) { + nsPrefix = name; + if (nsPrefix.startsWith(XMLNS_PREFIX)) { + nsPrefix = + nsPrefix.substring(XMLNS_PREFIX.length()); + } + } + } + } + + if (nsPrefix == null) { + nsPrefix = ANDROID_NS_NAME; + } + + return nsPrefix; + } + + protected List<Attr> findNamespaceAttributes() { + Document document = getDomDocument(); + return findNamespaceAttributes(document); + } + + protected static List<Attr> findNamespaceAttributes(Document document) { + if (document != null) { + Element root = document.getDocumentElement(); + return findNamespaceAttributes(root); + } + + return Collections.emptyList(); + } + + protected static List<Attr> findNamespaceAttributes(Node root) { + List<Attr> result = new ArrayList<Attr>(); + NamedNodeMap attributes = root.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attributeNode = attributes.item(i); + + String prefix = attributeNode.getPrefix(); + if (XMLNS.equals(prefix)) { + result.add((Attr) attributeNode); + } + } + + return result; + } + + protected List<Attr> findLayoutAttributes(Node root) { + List<Attr> result = new ArrayList<Attr>(); + NamedNodeMap attributes = root.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attributeNode = attributes.item(i); + + String name = attributeNode.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attributeNode.getNamespaceURI())) { + result.add((Attr) attributeNode); + } + } + + return result; + } + + protected String insertNamespace(String xmlText, String namespaceDeclarations) { + // Insert namespace declarations into the extracted XML fragment + int firstSpace = xmlText.indexOf(' '); + int elementEnd = xmlText.indexOf('>'); + int insertAt; + if (firstSpace != -1 && firstSpace < elementEnd) { + insertAt = firstSpace; + } else { + insertAt = elementEnd; + } + xmlText = xmlText.substring(0, insertAt) + namespaceDeclarations + + xmlText.substring(insertAt); + + return xmlText; + } + + /** Remove sections of the document that correspond to top level layout attributes; + * these are placed on the include element instead */ + protected String stripTopLayoutAttributes(Element primary, int start, String xml) { + if (primary != null) { + // List of attributes to remove + List<IndexedRegion> skip = new ArrayList<IndexedRegion>(); + NamedNodeMap attributes = primary.getAttributes(); + for (int i = 0, n = attributes.getLength(); i < n; i++) { + Node attr = attributes.item(i); + String name = attr.getLocalName(); + if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) + && ANDROID_URI.equals(attr.getNamespaceURI())) { + if (name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) { + // These are special and are left in + continue; + } + + if (attr instanceof IndexedRegion) { + skip.add((IndexedRegion) attr); + } + } + } + if (skip.size() > 0) { + Collections.sort(skip, new Comparator<IndexedRegion>() { + // Sort in start order + @Override + public int compare(IndexedRegion r1, IndexedRegion r2) { + return r1.getStartOffset() - r2.getStartOffset(); + } + }); + + // Successively cut out the various layout attributes + // TODO remove adjacent whitespace too (but not newlines, unless they + // are newly adjacent) + StringBuilder sb = new StringBuilder(xml.length()); + int nextStart = 0; + + // Copy out all the sections except the skip sections + for (IndexedRegion r : skip) { + int regionStart = r.getStartOffset(); + // Adjust to string offsets since we've copied the string out of + // the document + regionStart -= start; + + sb.append(xml.substring(nextStart, regionStart)); + + nextStart = regionStart + r.getLength(); + } + if (nextStart < xml.length()) { + sb.append(xml.substring(nextStart)); + } + + return sb.toString(); + } + } + + return xml; + } + + protected static String getIndent(String line, int max) { + int i = 0; + int n = Math.min(max, line.length()); + for (; i < n; i++) { + char c = line.charAt(i); + if (!Character.isWhitespace(c)) { + return line.substring(0, i); + } + } + + if (n < line.length()) { + return line.substring(0, n); + } else { + return line; + } + } + + protected static String dedent(String xml) { + String[] lines = xml.split("\n"); //$NON-NLS-1$ + if (lines.length < 2) { + // The first line never has any indentation since we copy it out from the + // element start index + return xml; + } + + String indentPrefix = getIndent(lines[1], lines[1].length()); + for (int i = 2, n = lines.length; i < n; i++) { + String line = lines[i]; + + // Ignore blank lines + if (line.trim().length() == 0) { + continue; + } + + indentPrefix = getIndent(line, indentPrefix.length()); + + if (indentPrefix.length() == 0) { + return xml; + } + } + + StringBuilder sb = new StringBuilder(); + for (String line : lines) { + if (line.startsWith(indentPrefix)) { + sb.append(line.substring(indentPrefix.length())); + } else { + sb.append(line); + } + sb.append('\n'); + } + return sb.toString(); + } + + protected String getText(int start, int end) { + try { + IStructuredDocument document = mDelegate.getEditor().getStructuredDocument(); + return document.get(start, end - start); + } catch (BadLocationException e) { + // the region offset was invalid. ignore. + return null; + } + } + + protected List<Element> getElements() { + return mElements; + } + + protected List<Element> initElements() { + List<Element> nodes = new ArrayList<Element>(); + + assert mTreeSelection == null || mSelection == null : + "treeSel= " + mTreeSelection + ", sel=" + mSelection; + + // Initialize mSelectionStart and mSelectionEnd based on the selection context, which + // is either a treeSelection (when invoked from the layout editor or the outline), or + // a selection (when invoked from an XML editor) + if (mTreeSelection != null) { + int end = Integer.MIN_VALUE; + int start = Integer.MAX_VALUE; + for (TreePath path : mTreeSelection.getPaths()) { + Object lastSegment = path.getLastSegment(); + if (lastSegment instanceof CanvasViewInfo) { + CanvasViewInfo viewInfo = (CanvasViewInfo) lastSegment; + UiViewElementNode uiNode = viewInfo.getUiViewNode(); + if (uiNode == null) { + continue; + } + Node xmlNode = uiNode.getXmlNode(); + if (xmlNode instanceof Element) { + Element element = (Element) xmlNode; + nodes.add(element); + IndexedRegion region = getRegion(element); + start = Math.min(start, region.getStartOffset()); + end = Math.max(end, region.getEndOffset()); + } + } + } + if (start >= 0) { + mSelectionStart = start; + mSelectionEnd = end; + } + } else if (mSelection != null) { + mSelectionStart = mSelection.getOffset(); + mSelectionEnd = mSelectionStart + mSelection.getLength(); + mOriginalSelectionStart = mSelectionStart; + mOriginalSelectionEnd = mSelectionEnd; + + // Figure out the range of selected nodes from the document offsets + IStructuredDocument doc = mDelegate.getEditor().getStructuredDocument(); + Pair<Element, Element> range = DomUtilities.getElementRange(doc, + mSelectionStart, mSelectionEnd); + if (range != null) { + Element first = range.getFirst(); + Element last = range.getSecond(); + + // Adjust offsets to get rid of surrounding text nodes (if you happened + // to select a text range and included whitespace on either end etc) + mSelectionStart = getRegion(first).getStartOffset(); + mSelectionEnd = getRegion(last).getEndOffset(); + + if (mSelectionStart > mSelectionEnd) { + int tmp = mSelectionStart; + mSelectionStart = mSelectionEnd; + mSelectionEnd = tmp; + } + + if (first == last) { + nodes.add(first); + } else if (first.getParentNode() == last.getParentNode()) { + // Add the range + Node node = first; + while (node != null) { + if (node instanceof Element) { + nodes.add((Element) node); + } + if (node == last) { + break; + } + node = node.getNextSibling(); + } + } else { + // Different parents: this means we have an uneven selection, selecting + // elements from different levels. We can't extract ranges like that. + } + } + } else { + assert false; + } + + // Make sure that the list of elements is unique + //Set<Element> seen = new HashSet<Element>(); + //for (Element element : nodes) { + // assert !seen.contains(element) : element; + // seen.add(element); + //} + + return nodes; + } + + protected Element getPrimaryElement() { + List<Element> elements = getElements(); + if (elements != null && elements.size() == 1) { + return elements.get(0); + } + + return null; + } + + protected Document getDomDocument() { + if (mDelegate.getUiRootNode() != null) { + return mDelegate.getUiRootNode().getXmlDocument(); + } else { + return getElements().get(0).getOwnerDocument(); + } + } + + protected List<CanvasViewInfo> getSelectedViewInfos() { + List<CanvasViewInfo> infos = new ArrayList<CanvasViewInfo>(); + if (mTreeSelection != null) { + for (TreePath path : mTreeSelection.getPaths()) { + Object lastSegment = path.getLastSegment(); + if (lastSegment instanceof CanvasViewInfo) { + infos.add((CanvasViewInfo) lastSegment); + } + } + } + return infos; + } + + protected boolean validateNotEmpty(List<CanvasViewInfo> infos, RefactoringStatus status) { + if (infos.size() == 0) { + status.addFatalError("No selection to extract"); + return false; + } + + return true; + } + + protected boolean validateNotRoot(List<CanvasViewInfo> infos, RefactoringStatus status) { + for (CanvasViewInfo info : infos) { + if (info.isRoot()) { + status.addFatalError("Cannot refactor the root"); + return false; + } + } + + return true; + } + + protected boolean validateContiguous(List<CanvasViewInfo> infos, RefactoringStatus status) { + if (infos.size() > 1) { + // All elements must be siblings (e.g. same parent) + List<UiViewElementNode> nodes = new ArrayList<UiViewElementNode>(infos + .size()); + for (CanvasViewInfo info : infos) { + UiViewElementNode node = info.getUiViewNode(); + if (node != null) { + nodes.add(node); + } + } + if (nodes.size() == 0) { + status.addFatalError("No selected views"); + return false; + } + + UiElementNode parent = nodes.get(0).getUiParent(); + for (UiViewElementNode node : nodes) { + if (parent != node.getUiParent()) { + status.addFatalError("The selected elements must be adjacent"); + return false; + } + } + // Ensure that the siblings are contiguous; no gaps. + // If we've selected all the children of the parent then we don't need + // to look. + List<UiElementNode> siblings = parent.getUiChildren(); + if (siblings.size() != nodes.size()) { + Set<UiViewElementNode> nodeSet = new HashSet<UiViewElementNode>(nodes); + boolean inRange = false; + int remaining = nodes.size(); + for (UiElementNode node : siblings) { + boolean in = nodeSet.contains(node); + if (in) { + remaining--; + if (remaining == 0) { + break; + } + inRange = true; + } else if (inRange) { + status.addFatalError("The selected elements must be adjacent"); + return false; + } + } + } + } + + return true; + } + + /** + * Updates the given element with a new name if the current id reflects the old + * element type. If the name was changed, it will return the new name. + */ + protected String ensureIdMatchesType(Element element, String newType, MultiTextEdit rootEdit) { + String oldType = element.getTagName(); + if (oldType.indexOf('.') == -1) { + oldType = ANDROID_WIDGET_PREFIX + oldType; + } + String oldTypeBase = oldType.substring(oldType.lastIndexOf('.') + 1); + String id = getId(element); + if (id == null || id.length() == 0 + || id.toLowerCase(Locale.US).contains(oldTypeBase.toLowerCase(Locale.US))) { + String newTypeBase = newType.substring(newType.lastIndexOf('.') + 1); + return ensureHasId(rootEdit, element, newTypeBase); + } + + return null; + } + + /** + * Returns the {@link IndexedRegion} for the given node + * + * @param node the node to look up the region for + * @return the corresponding region, or null + */ + public static IndexedRegion getRegion(Node node) { + if (node instanceof IndexedRegion) { + return (IndexedRegion) node; + } + + return null; + } + + protected String ensureHasId(MultiTextEdit rootEdit, Element element, String prefix) { + return ensureHasId(rootEdit, element, prefix, true); + } + + protected String ensureHasId(MultiTextEdit rootEdit, Element element, String prefix, + boolean apply) { + String id = mGeneratedIdMap.get(element); + if (id != null) { + return NEW_ID_PREFIX + id; + } + + if (!element.hasAttributeNS(ANDROID_URI, ATTR_ID) + || (prefix != null && !getId(element).startsWith(prefix))) { + id = DomUtilities.getFreeWidgetId(element, mGeneratedIds, prefix); + // Make sure we don't use this one again + mGeneratedIds.add(id); + mGeneratedIdMap.put(element, id); + id = NEW_ID_PREFIX + id; + if (apply) { + setAttribute(rootEdit, element, + ANDROID_URI, getAndroidNamespacePrefix(), ATTR_ID, id); + } + return id; + } + + return getId(element); + } + + protected int getFirstAttributeOffset(Element element) { + IndexedRegion region = getRegion(element); + if (region != null) { + int startOffset = region.getStartOffset(); + int endOffset = region.getEndOffset(); + String text = getText(startOffset, endOffset); + String name = element.getLocalName(); + int nameOffset = text.indexOf(name); + if (nameOffset != -1) { + return startOffset + nameOffset + name.length(); + } + } + + return -1; + } + + /** + * Returns the id of the given element + * + * @param element the element to look up the id for + * @return the corresponding id, or an empty string (should not be null + * according to the DOM API, but has been observed to be null on + * some versions of Eclipse) + */ + public static String getId(Element element) { + return element.getAttributeNS(ANDROID_URI, ATTR_ID); + } + + protected String ensureNewId(String id) { + if (id != null && id.length() > 0) { + if (id.startsWith(ID_PREFIX)) { + id = NEW_ID_PREFIX + id.substring(ID_PREFIX.length()); + } else if (!id.startsWith(NEW_ID_PREFIX)) { + id = NEW_ID_PREFIX + id; + } + } else { + id = null; + } + + return id; + } + + protected String getViewClass(String fqcn) { + // Don't include android.widget. as a package prefix in layout files + if (fqcn.startsWith(ANDROID_WIDGET_PREFIX)) { + fqcn = fqcn.substring(ANDROID_WIDGET_PREFIX.length()); + } + + return fqcn; + } + + protected void setAttribute(MultiTextEdit rootEdit, Element element, + String attributeUri, + String attributePrefix, String attributeName, String attributeValue) { + int offset = getFirstAttributeOffset(element); + if (offset != -1) { + if (element.hasAttributeNS(attributeUri, attributeName)) { + replaceAttributeDeclaration(rootEdit, offset, element, attributePrefix, + attributeUri, attributeName, attributeValue); + } else { + addAttributeDeclaration(rootEdit, offset, attributePrefix, attributeName, + attributeValue); + } + } + } + + private void addAttributeDeclaration(MultiTextEdit rootEdit, int offset, + String attributePrefix, String attributeName, String attributeValue) { + StringBuilder sb = new StringBuilder(); + sb.append(' '); + + if (attributePrefix != null) { + sb.append(attributePrefix).append(':'); + } + sb.append(attributeName).append('=').append('"'); + sb.append(attributeValue).append('"'); + + InsertEdit setAttribute = new InsertEdit(offset, sb.toString()); + rootEdit.addChild(setAttribute); + } + + /** Replaces the value declaration of the given attribute */ + private void replaceAttributeDeclaration(MultiTextEdit rootEdit, int offset, + Element element, String attributePrefix, String attributeUri, + String attributeName, String attributeValue) { + // Find attribute value and replace it + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + IStructuredDocument doc = model.getStructuredDocument(); + + IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(offset); + ITextRegionList list = region.getRegions(); + int regionStart = region.getStart(); + + int valueStart = -1; + boolean useNextValue = false; + String targetName = attributePrefix != null + ? attributePrefix + ':' + attributeName : attributeName; + + // Look at all attribute values and look for an id reference match + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) { + // What about prefix? + if (targetName.equals(region.getText(subRegion))) { + useNextValue = true; + } + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) { + if (useNextValue) { + valueStart = regionStart + subRegion.getStart(); + break; + } + } + } + + if (valueStart != -1) { + String oldValue = element.getAttributeNS(attributeUri, attributeName); + int start = valueStart + 1; // Skip opening " + ReplaceEdit setAttribute = new ReplaceEdit(start, oldValue.length(), + attributeValue); + try { + rootEdit.addChild(setAttribute); + } catch (MalformedTreeException mte) { + AdtPlugin.log(mte, "Could not replace attribute %1$s with %2$s", + attributeName, attributeValue); + throw mte; + } + } + } finally { + model.releaseFromRead(); + } + } + + /** Strips out the given attribute, if defined */ + protected void removeAttribute(MultiTextEdit rootEdit, Element element, String uri, + String attributeName) { + if (element.hasAttributeNS(uri, attributeName)) { + Attr attribute = element.getAttributeNodeNS(uri, attributeName); + removeAttribute(rootEdit, attribute); + } + } + + /** Strips out the given attribute, if defined */ + protected void removeAttribute(MultiTextEdit rootEdit, Attr attribute) { + IndexedRegion region = getRegion(attribute); + if (region != null) { + int startOffset = region.getStartOffset(); + int endOffset = region.getEndOffset(); + DeleteEdit deletion = new DeleteEdit(startOffset, endOffset - startOffset); + rootEdit.addChild(deletion); + } + } + + + /** + * Removes the given element's opening and closing tags (including all of its + * attributes) but leaves any children alone + * + * @param rootEdit the multi edit to add the removal operation to + * @param element the element to delete the open and closing tags for + * @param skip a list of elements that should not be modified (for example because they + * are targeted for deletion) + * + * TODO: Rename this to "unwrap" ? And allow for handling nested deletions. + */ + protected void removeElementTags(MultiTextEdit rootEdit, Element element, List<Element> skip, + boolean changeIndentation) { + IndexedRegion elementRegion = getRegion(element); + if (elementRegion == null) { + return; + } + + // Look for the opening tag + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + int startLineInclusive = -1; + int endLineInclusive = -1; + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null) { + int start = elementRegion.getStartOffset(); + IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(start); + ITextRegionList list = region.getRegions(); + int regionStart = region.getStart(); + int startOffset = regionStart; + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_OPEN.equals(type)) { + startOffset = regionStart + subRegion.getStart(); + } else if (DOMRegionContext.XML_TAG_CLOSE.equals(type)) { + int endOffset = regionStart + subRegion.getStart() + subRegion.getLength(); + + DeleteEdit deletion = createDeletion(doc, startOffset, endOffset); + rootEdit.addChild(deletion); + startLineInclusive = doc.getLineOfOffset(endOffset) + 1; + break; + } + } + + // Find the close tag + // Look at all attribute values and look for an id reference match + region = doc.getRegionAtCharacterOffset(elementRegion.getEndOffset() + - element.getTagName().length() - 1); + list = region.getRegions(); + regionStart = region.getStartOffset(); + startOffset = -1; + for (int j = 0; j < region.getNumberOfRegions(); j++) { + ITextRegion subRegion = list.get(j); + String type = subRegion.getType(); + if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) { + startOffset = regionStart + subRegion.getStart(); + } else if (DOMRegionContext.XML_TAG_CLOSE.equals(type)) { + int endOffset = regionStart + subRegion.getStart() + subRegion.getLength(); + if (startOffset != -1) { + DeleteEdit deletion = createDeletion(doc, startOffset, endOffset); + rootEdit.addChild(deletion); + endLineInclusive = doc.getLineOfOffset(startOffset) - 1; + } + break; + } + } + } + + // Dedent the contents + if (changeIndentation && startLineInclusive != -1 && endLineInclusive != -1) { + String indent = AndroidXmlEditor.getIndentAtOffset(doc, getRegion(element) + .getStartOffset()); + setIndentation(rootEdit, indent, doc, startLineInclusive, endLineInclusive, + element, skip); + } + } finally { + model.releaseFromRead(); + } + } + + protected void removeIndentation(MultiTextEdit rootEdit, String removeIndent, + IStructuredDocument doc, int startLineInclusive, int endLineInclusive, + Element element, List<Element> skip) { + if (startLineInclusive > endLineInclusive) { + return; + } + int indentLength = removeIndent.length(); + if (indentLength == 0) { + return; + } + + try { + for (int line = startLineInclusive; line <= endLineInclusive; line++) { + IRegion info = doc.getLineInformation(line); + int lineStart = info.getOffset(); + int lineLength = info.getLength(); + int lineEnd = lineStart + lineLength; + if (overlaps(lineStart, lineEnd, element, skip)) { + continue; + } + String lineText = getText(lineStart, + lineStart + Math.min(lineLength, indentLength)); + if (lineText.startsWith(removeIndent)) { + rootEdit.addChild(new DeleteEdit(lineStart, indentLength)); + } + } + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + } + + protected void setIndentation(MultiTextEdit rootEdit, String indent, + IStructuredDocument doc, int startLineInclusive, int endLineInclusive, + Element element, List<Element> skip) { + if (startLineInclusive > endLineInclusive) { + return; + } + int indentLength = indent.length(); + if (indentLength == 0) { + return; + } + + try { + for (int line = startLineInclusive; line <= endLineInclusive; line++) { + IRegion info = doc.getLineInformation(line); + int lineStart = info.getOffset(); + int lineLength = info.getLength(); + int lineEnd = lineStart + lineLength; + if (overlaps(lineStart, lineEnd, element, skip)) { + continue; + } + String lineText = getText(lineStart, lineStart + lineLength); + int indentEnd = getFirstNonSpace(lineText); + rootEdit.addChild(new ReplaceEdit(lineStart, indentEnd, indent)); + } + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + } + + private int getFirstNonSpace(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isWhitespace(s.charAt(i))) { + return i; + } + } + + return s.length(); + } + + /** Returns true if the given line overlaps any of the given elements */ + private static boolean overlaps(int startOffset, int endOffset, + Element element, List<Element> overlaps) { + for (Element e : overlaps) { + if (e == element) { + continue; + } + + IndexedRegion region = getRegion(e); + if (region.getEndOffset() >= startOffset && region.getStartOffset() <= endOffset) { + return true; + } + } + return false; + } + + protected DeleteEdit createDeletion(IStructuredDocument doc, int startOffset, int endOffset) { + // Expand to delete the whole line? + try { + IRegion info = doc.getLineInformationOfOffset(startOffset); + int lineBegin = info.getOffset(); + // Is the text on the line leading up to the deletion region, + // and the text following it, all whitespace? + boolean deleteLine = true; + if (lineBegin < startOffset) { + String prefix = getText(lineBegin, startOffset); + if (prefix.trim().length() > 0) { + deleteLine = false; + } + } + info = doc.getLineInformationOfOffset(endOffset); + int lineEnd = info.getOffset() + info.getLength(); + if (lineEnd > endOffset) { + String suffix = getText(endOffset, lineEnd); + if (suffix.trim().length() > 0) { + deleteLine = false; + } + } + if (deleteLine) { + startOffset = lineBegin; + endOffset = Math.min(doc.getLength(), lineEnd + 1); + } + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + } + + + return new DeleteEdit(startOffset, endOffset - startOffset); + } + + /** + * Rewrite the edits in the given {@link MultiTextEdit} such that same edits are + * applied, but the resulting range is also formatted + */ + protected MultiTextEdit reformat(MultiTextEdit edit, XmlFormatStyle style) { + String xml = mDelegate.getEditor().getStructuredDocument().get(); + return reformat(xml, edit, style); + } + + /** + * Rewrite the edits in the given {@link MultiTextEdit} such that same edits are + * applied, but the resulting range is also formatted + * + * @param oldContents the original contents that should be edited by a + * {@link MultiTextEdit} + * @param edit the {@link MultiTextEdit} to be applied to some string + * @param style the formatting style to use + * @return a new {@link MultiTextEdit} which performs the same edits as the input edit + * but also reformats the text + */ + public static MultiTextEdit reformat(String oldContents, MultiTextEdit edit, + XmlFormatStyle style) { + IDocument document = new org.eclipse.jface.text.Document(); + document.set(oldContents); + + try { + edit.apply(document); + } catch (MalformedTreeException e) { + AdtPlugin.log(e, null); + return null; // Abort formatting + } catch (BadLocationException e) { + AdtPlugin.log(e, null); + return null; // Abort formatting + } + + String actual = document.get(); + + // TODO: Try to format only the affected portion of the document. + // To do that we need to find out what the affected offsets are; we know + // the MultiTextEdit's affected range, but that is referring to offsets + // in the old document. Use that to compute offsets in the new document. + //int distanceFromEnd = actual.length() - edit.getExclusiveEnd(); + //IStructuredModel model = DomUtilities.createStructuredModel(actual); + //int start = edit.getOffset(); + //int end = actual.length() - distanceFromEnd; + //int length = end - start; + //TextEdit format = AndroidXmlFormattingStrategy.format(model, start, length); + EclipseXmlFormatPreferences formatPrefs = EclipseXmlFormatPreferences.create(); + String formatted = EclipseXmlPrettyPrinter.prettyPrint(actual, formatPrefs, style, + null /*lineSeparator*/); + + + // Figure out how much of the before and after strings are identical and narrow + // the replacement scope + boolean foundDifference = false; + int firstDifference = 0; + int lastDifference = formatted.length(); + int start = 0; + int end = oldContents.length(); + + for (int i = 0, j = start; i < formatted.length() && j < end; i++, j++) { + if (formatted.charAt(i) != oldContents.charAt(j)) { + firstDifference = i; + foundDifference = true; + break; + } + } + + if (!foundDifference) { + // No differences - the document is already formatted, nothing to do + return null; + } + + lastDifference = firstDifference + 1; + for (int i = formatted.length() - 1, j = end - 1; + i > firstDifference && j > start; + i--, j--) { + if (formatted.charAt(i) != oldContents.charAt(j)) { + lastDifference = i + 1; + break; + } + } + + start += firstDifference; + end -= (formatted.length() - lastDifference); + end = Math.max(start, end); + formatted = formatted.substring(firstDifference, lastDifference); + + ReplaceEdit format = new ReplaceEdit(start, end - start, + formatted); + + MultiTextEdit newEdit = new MultiTextEdit(); + newEdit.addChild(format); + + return newEdit; + } + + protected ViewElementDescriptor getElementDescriptor(String fqcn) { + AndroidTargetData data = mDelegate.getEditor().getTargetData(); + if (data != null) { + return data.getLayoutDescriptors().findDescriptorByClass(fqcn); + } + + return null; + } + + /** Create a wizard for this refactoring */ + abstract VisualRefactoringWizard createWizard(); + + public abstract static class VisualRefactoringDescriptor extends RefactoringDescriptor { + private final Map<String, String> mArguments; + + public VisualRefactoringDescriptor( + String id, String project, String description, String comment, + Map<String, String> arguments) { + super(id, project, description, comment, STRUCTURAL_CHANGE | MULTI_CHANGE); + mArguments = arguments; + } + + public Map<String, String> getArguments() { + return mArguments; + } + + protected abstract Refactoring createRefactoring(Map<String, String> args); + + @Override + public Refactoring createRefactoring(RefactoringStatus status) throws CoreException { + try { + return createRefactoring(mArguments); + } catch (NullPointerException e) { + status.addFatalError("Failed to recreate refactoring from descriptor"); + return null; + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoringAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoringAction.java new file mode 100644 index 000000000..f1cc988d7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoringAction.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.AdtUtils; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IEditorSite; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.IWorkbenchWindowActionDelegate; +import org.eclipse.ui.part.FileEditorInput; + +abstract class VisualRefactoringAction implements IWorkbenchWindowActionDelegate { + protected IWorkbenchWindow mWindow; + protected ITextSelection mTextSelection; + protected ITreeSelection mTreeSelection; + protected LayoutEditorDelegate mDelegate; + protected IFile mFile; + + /** + * Keep track of the current workbench window. + */ + @Override + public void init(IWorkbenchWindow window) { + mWindow = window; + } + + @Override + public void dispose() { + } + + /** + * Examine the selection to determine if the action should be enabled or not. + * <p/> + * Keep a link to the relevant selection structure + */ + @Override + public void selectionChanged(IAction action, ISelection selection) { + // Look for selections in XML and in the layout UI editor + + // Note, two kinds of selections are returned here: + // ITextSelection on a Java source window + // IStructuredSelection in the outline or navigator + // This simply deals with the refactoring based on a non-empty selection. + // At that point, just enable the action and later decide if it's valid when it actually + // runs since we don't have access to the AST yet. + + mTextSelection = null; + mTreeSelection = null; + mFile = null; + + IEditorPart editor = null; + + if (selection instanceof ITextSelection) { + mTextSelection = (ITextSelection) selection; + editor = AdtUtils.getActiveEditor(); + mFile = getSelectedFile(editor); + } else if (selection instanceof ITreeSelection) { + Object firstElement = ((ITreeSelection)selection).getFirstElement(); + if (firstElement instanceof CanvasViewInfo) { + mTreeSelection = (ITreeSelection) selection; + editor = AdtUtils.getActiveEditor(); + mFile = getSelectedFile(editor); + } + } + + mDelegate = LayoutEditorDelegate.fromEditor(editor); + + action.setEnabled((mTextSelection != null || mTreeSelection != null) + && mFile != null && mDelegate != null); + } + + /** + * Create a new instance of our refactoring and a wizard to configure it. + */ + @Override + public abstract void run(IAction action); + + /** + * Returns the active {@link IFile} (hopefully matching our selection) or null. + * The file is only returned if it's a file from a project with an Android nature. + * <p/> + * At that point we do not try to analyze if the selection nor the file is suitable + * for the refactoring. This check is performed when the refactoring is invoked since + * it can then produce meaningful error messages as needed. + */ + private IFile getSelectedFile(IEditorPart editor) { + if (editor != null) { + IEditorInput input = editor.getEditorInput(); + + if (input instanceof FileEditorInput) { + FileEditorInput fi = (FileEditorInput) input; + IFile file = fi.getFile(); + if (file.exists()) { + IProject proj = file.getProject(); + try { + if (proj != null && proj.hasNature(AdtConstants.NATURE_DEFAULT)) { + return file; + } + } catch (CoreException e) { + // ignore + } + } + } + } + + return null; + } + + public static IAction create(String title, LayoutEditorDelegate editorDelegate, + Class<? extends VisualRefactoringAction> clz) { + return new ActionWrapper(title, editorDelegate, clz); + } + + private static class ActionWrapper extends Action { + private Class<? extends VisualRefactoringAction> mClass; + private LayoutEditorDelegate mEditorDelegate; + + ActionWrapper(String title, LayoutEditorDelegate editorDelegate, + Class<? extends VisualRefactoringAction> clz) { + super(title); + mEditorDelegate = editorDelegate; + mClass = clz; + } + + @Override + public void run() { + VisualRefactoringAction action; + try { + action = mClass.newInstance(); + } catch (Exception e) { + AdtPlugin.log(e, null); + return; + } + IEditorSite site = mEditorDelegate.getEditor().getEditorSite(); + action.init(site.getWorkbenchWindow()); + ISelection selection = site.getSelectionProvider().getSelection(); + action.selectionChanged(ActionWrapper.this, selection); + if (isEnabled()) { + action.run(ActionWrapper.this); + } + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoringWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoringWizard.java new file mode 100644 index 000000000..c103e47dc --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/VisualRefactoringWizard.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.UserInputWizardPage; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; + +public abstract class VisualRefactoringWizard extends RefactoringWizard { + protected final LayoutEditorDelegate mDelegate; + + public VisualRefactoringWizard(Refactoring refactoring, LayoutEditorDelegate editor) { + super(refactoring, DIALOG_BASED_USER_INTERFACE | PREVIEW_EXPAND_FIRST_NODE); + mDelegate = editor; + } + + @Override + public boolean performFinish() { + mDelegate.getEditor().setIgnoreXmlUpdate(true); + try { + return super.performFinish(); + } finally { + mDelegate.getEditor().setIgnoreXmlUpdate(false); + mDelegate.refreshXmlModel(); + } + } + + protected abstract static class VisualRefactoringInputPage extends UserInputWizardPage { + public VisualRefactoringInputPage(String name) { + super(name); + } + + /** + * Listener which can be attached on any widget in the wizard page to force + * modifications of the associated widget to validate the page again + */ + protected ModifyListener mModifyValidateListener = new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + validatePage(); + } + }; + + /** + * Listener which can be attached on any widget in the wizard page to force + * selection changes of the associated widget to validate the page again + */ + protected SelectionAdapter mSelectionValidateListener = new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + validatePage(); + } + }; + + protected abstract boolean validatePage(); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInAction.java new file mode 100644 index 000000000..1cd66596b --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInAction.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; + +import org.eclipse.jface.action.IAction; +import org.eclipse.ltk.ui.refactoring.RefactoringWizard; +import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation; + +/** + * Action executed when the "Wrap In" menu item is invoked. + */ +public class WrapInAction extends VisualRefactoringAction { + @Override + public void run(IAction action) { + if ((mTextSelection != null || mTreeSelection != null) && mFile != null) { + WrapInRefactoring ref = new WrapInRefactoring(mFile, mDelegate, + mTextSelection, mTreeSelection); + RefactoringWizard wizard = new WrapInWizard(ref, mDelegate); + RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard); + try { + op.run(mWindow.getShell(), wizard.getDefaultPageTitle()); + } catch (InterruptedException e) { + // Interrupted. Pass. + } + } + } + + public static IAction create(LayoutEditorDelegate editorDelegate) { + return create("Wrap in Container...", editorDelegate, WrapInAction.class); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInContribution.java new file mode 100644 index 000000000..61d7987d7 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInContribution.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import org.eclipse.ltk.core.refactoring.RefactoringContribution; +import org.eclipse.ltk.core.refactoring.RefactoringDescriptor; + +import java.util.Map; + +public class WrapInContribution extends RefactoringContribution { + + @SuppressWarnings("unchecked") + @Override + public RefactoringDescriptor createDescriptor(String id, String project, String description, + String comment, Map arguments, int flags) throws IllegalArgumentException { + return new WrapInRefactoring.Descriptor(project, description, comment, arguments); + } + + @SuppressWarnings("unchecked") + @Override + public Map retrieveArgumentMap(RefactoringDescriptor descriptor) { + if (descriptor instanceof WrapInRefactoring.Descriptor) { + return ((WrapInRefactoring.Descriptor) descriptor).getArguments(); + } + return super.retrieveArgumentMap(descriptor); + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInRefactoring.java new file mode 100644 index 000000000..07b00b8da --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInRefactoring.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.ANDROID_NS_NAME_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ANDROID_WIDGET_PREFIX; +import static com.android.SdkConstants.ATTR_ID; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.EXT_XML; +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.annotations.NonNull; +import com.android.annotations.VisibleForTesting; +import com.android.ide.common.xml.XmlFormatStyle; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; +import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.viewers.ITreeSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.DeleteEdit; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; +import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Inserts a new layout surrounding the current selection, migrates namespace + * attributes (if wrapping the root node), and optionally migrates layout + * attributes and updates references elsewhere. + */ +@SuppressWarnings("restriction") // XML model +public class WrapInRefactoring extends VisualRefactoring { + private static final String KEY_ID = "name"; //$NON-NLS-1$ + private static final String KEY_TYPE = "type"; //$NON-NLS-1$ + + private String mId; + private String mTypeFqcn; + private String mInitializedAttributes; + + /** + * This constructor is solely used by {@link Descriptor}, + * to replay a previous refactoring. + * @param arguments argument map created by #createArgumentMap. + */ + WrapInRefactoring(Map<String, String> arguments) { + super(arguments); + mId = arguments.get(KEY_ID); + mTypeFqcn = arguments.get(KEY_TYPE); + } + + public WrapInRefactoring( + IFile file, + LayoutEditorDelegate delegate, + ITextSelection selection, + ITreeSelection treeSelection) { + super(file, delegate, selection, treeSelection); + } + + @VisibleForTesting + WrapInRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { + super(selectedElements, editor); + } + + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, + OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + pm.beginTask("Checking preconditions...", 6); + + if (mSelectionStart == -1 || mSelectionEnd == -1) { + status.addFatalError("No selection to wrap"); + return status; + } + + // Make sure the selection is contiguous + if (mTreeSelection != null) { + // TODO - don't do this if we based the selection on text. In this case, + // make sure we're -balanced-. + + List<CanvasViewInfo> infos = getSelectedViewInfos(); + if (!validateNotEmpty(infos, status)) { + return status; + } + + // Enforce that the selection is -contiguous- + if (!validateContiguous(infos, status)) { + return status; + } + } + + // Ensures that we have a valid DOM model: + if (mElements.size() == 0) { + status.addFatalError("Nothing to wrap"); + return status; + } + + pm.worked(1); + return status; + + } finally { + pm.done(); + } + } + + @Override + protected VisualRefactoringDescriptor createDescriptor() { + String comment = getName(); + return new Descriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + } + + @Override + protected Map<String, String> createArgumentMap() { + Map<String, String> args = super.createArgumentMap(); + args.put(KEY_TYPE, mTypeFqcn); + args.put(KEY_ID, mId); + + return args; + } + + @Override + public String getName() { + return "Wrap in Container"; + } + + void setId(String id) { + mId = id; + } + + void setType(String typeFqcn) { + mTypeFqcn = typeFqcn; + } + + void setInitializedAttributes(String initializedAttributes) { + mInitializedAttributes = initializedAttributes; + } + + @Override + protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { + // (1) Insert the new container in front of the beginning of the + // first wrapped view + // (2) If the container is the new root, transfer namespace declarations + // to it + // (3) Insert the closing tag of the new container at the end of the + // last wrapped view + // (4) Reindent the wrapped views + // (5) If the user requested it, update all layout references to the + // wrapped views with the new container? + // For that matter, does RelativeLayout even require it? Probably not, + // it can point inside the current layout... + + // Add indent to all lines between mSelectionStart and mEnd + // TODO: Figure out the indentation amount? + // For now, use 4 spaces + String indentUnit = " "; //$NON-NLS-1$ + boolean separateAttributes = true; + IStructuredDocument document = mDelegate.getEditor().getStructuredDocument(); + String startIndent = AndroidXmlEditor.getIndentAtOffset(document, mSelectionStart); + + String viewClass = getViewClass(mTypeFqcn); + String androidNsPrefix = getAndroidNamespacePrefix(); + + + IFile file = mDelegate.getEditor().getInputFile(); + List<Change> changes = new ArrayList<Change>(); + if (file == null) { + return changes; + } + TextFileChange change = new TextFileChange(file.getName(), file); + MultiTextEdit rootEdit = new MultiTextEdit(); + change.setTextType(EXT_XML); + + String id = ensureNewId(mId); + + // Update any layout references to the old id with the new id + if (id != null) { + String rootId = getRootId(); + IStructuredModel model = mDelegate.getEditor().getModelForRead(); + try { + IStructuredDocument doc = model.getStructuredDocument(); + if (doc != null) { + List<TextEdit> replaceIds = replaceIds(androidNsPrefix, + doc, mSelectionStart, mSelectionEnd, rootId, id); + for (TextEdit edit : replaceIds) { + rootEdit.addChild(edit); + } + } + } finally { + model.releaseFromRead(); + } + } + + // Insert namespace elements? + StringBuilder namespace = null; + List<DeleteEdit> deletions = new ArrayList<DeleteEdit>(); + Element primary = getPrimaryElement(); + if (primary != null && getDomDocument().getDocumentElement() == primary) { + namespace = new StringBuilder(); + + List<Attr> declarations = findNamespaceAttributes(primary); + for (Attr attribute : declarations) { + if (attribute instanceof IndexedRegion) { + // Delete the namespace declaration in the node which is no longer the root + IndexedRegion region = (IndexedRegion) attribute; + int startOffset = region.getStartOffset(); + int endOffset = region.getEndOffset(); + String text = getText(startOffset, endOffset); + DeleteEdit deletion = new DeleteEdit(startOffset, endOffset - startOffset); + deletions.add(deletion); + rootEdit.addChild(deletion); + text = text.trim(); + + // Insert the namespace declaration in the new root + if (separateAttributes) { + namespace.append('\n').append(startIndent).append(indentUnit); + } else { + namespace.append(' '); + } + namespace.append(text); + } + } + } + + // Insert begin tag: <type ...> + StringBuilder sb = new StringBuilder(); + sb.append('<'); + sb.append(viewClass); + + if (namespace != null) { + sb.append(namespace); + } + + // Set the ID if any + if (id != null) { + if (separateAttributes) { + sb.append('\n').append(startIndent).append(indentUnit); + } else { + sb.append(' '); + } + sb.append(androidNsPrefix).append(':'); + sb.append(ATTR_ID).append('=').append('"').append(id).append('"'); + } + + // If any of the elements are fill/match parent, use that instead + String width = VALUE_WRAP_CONTENT; + String height = VALUE_WRAP_CONTENT; + + for (Element element : getElements()) { + String oldWidth = element.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + String oldHeight = element.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + + if (VALUE_MATCH_PARENT.equals(oldWidth) || VALUE_FILL_PARENT.equals(oldWidth)) { + width = oldWidth; + } + if (VALUE_MATCH_PARENT.equals(oldHeight) || VALUE_FILL_PARENT.equals(oldHeight)) { + height = oldHeight; + } + } + + // Add in width/height. + if (separateAttributes) { + sb.append('\n').append(startIndent).append(indentUnit); + } else { + sb.append(' '); + } + sb.append(androidNsPrefix).append(':'); + sb.append(ATTR_LAYOUT_WIDTH).append('=').append('"').append(width).append('"'); + + if (separateAttributes) { + sb.append('\n').append(startIndent).append(indentUnit); + } else { + sb.append(' '); + } + sb.append(androidNsPrefix).append(':'); + sb.append(ATTR_LAYOUT_HEIGHT).append('=').append('"').append(height).append('"'); + + if (mInitializedAttributes != null && mInitializedAttributes.length() > 0) { + for (String s : mInitializedAttributes.split(",")) { //$NON-NLS-1$ + sb.append(' '); + String[] nameValue = s.split("="); //$NON-NLS-1$ + String name = nameValue[0]; + String value = nameValue[1]; + if (name.startsWith(ANDROID_NS_NAME_PREFIX)) { + name = name.substring(ANDROID_NS_NAME_PREFIX.length()); + sb.append(androidNsPrefix).append(':'); + } + sb.append(name).append('=').append('"').append(value).append('"'); + } + } + + // Transfer layout_ attributes (other than width and height) + if (primary != null) { + List<Attr> layoutAttributes = findLayoutAttributes(primary); + for (Attr attribute : layoutAttributes) { + String name = attribute.getLocalName(); + if ((name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) + && ANDROID_URI.equals(attribute.getNamespaceURI())) { + // Already handled specially + continue; + } + + if (attribute instanceof IndexedRegion) { + IndexedRegion region = (IndexedRegion) attribute; + int startOffset = region.getStartOffset(); + int endOffset = region.getEndOffset(); + String text = getText(startOffset, endOffset); + DeleteEdit deletion = new DeleteEdit(startOffset, endOffset - startOffset); + rootEdit.addChild(deletion); + deletions.add(deletion); + + if (separateAttributes) { + sb.append('\n').append(startIndent).append(indentUnit); + } else { + sb.append(' '); + } + sb.append(text.trim()); + } + } + } + + // Finish open tag: + sb.append('>'); + sb.append('\n').append(startIndent).append(indentUnit); + + InsertEdit beginEdit = new InsertEdit(mSelectionStart, sb.toString()); + rootEdit.addChild(beginEdit); + + String nested = getText(mSelectionStart, mSelectionEnd); + int index = 0; + while (index != -1) { + index = nested.indexOf('\n', index); + if (index != -1) { + index++; + InsertEdit newline = new InsertEdit(mSelectionStart + index, indentUnit); + // Some of the deleted namespaces may have had newlines - be careful + // not to overlap edits + boolean covered = false; + for (DeleteEdit deletion : deletions) { + if (deletion.covers(newline)) { + covered = true; + break; + } + } + if (!covered) { + rootEdit.addChild(newline); + } + } + } + + // Insert end tag: </type> + sb.setLength(0); + sb.append('\n').append(startIndent); + sb.append('<').append('/').append(viewClass).append('>'); + InsertEdit endEdit = new InsertEdit(mSelectionEnd, sb.toString()); + rootEdit.addChild(endEdit); + + if (AdtPrefs.getPrefs().getFormatGuiXml()) { + MultiTextEdit formatted = reformat(rootEdit, XmlFormatStyle.LAYOUT); + if (formatted != null) { + rootEdit = formatted; + } + } + + change.setEdit(rootEdit); + changes.add(change); + return changes; + } + + String getOldType() { + Element primary = getPrimaryElement(); + if (primary != null) { + String oldType = primary.getTagName(); + if (oldType.indexOf('.') == -1) { + oldType = ANDROID_WIDGET_PREFIX + oldType; + } + return oldType; + } + + return null; + } + + @Override + VisualRefactoringWizard createWizard() { + return new WrapInWizard(this, mDelegate); + } + + public static class Descriptor extends VisualRefactoringDescriptor { + public Descriptor(String project, String description, String comment, + Map<String, String> arguments) { + super("com.android.ide.eclipse.adt.refactoring.wrapin", //$NON-NLS-1$ + project, description, comment, arguments); + } + + @Override + protected Refactoring createRefactoring(Map<String, String> args) { + return new WrapInRefactoring(args); + } + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInWizard.java new file mode 100644 index 000000000..2e06a3bbd --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/refactoring/WrapInWizard.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Eclipse Public License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.eclipse.org/org/documents/epl-v10.php + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; + +import static com.android.SdkConstants.FQCN_GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.FQCN_LINEAR_LAYOUT; +import static com.android.SdkConstants.FQCN_RADIO_BUTTON; +import static com.android.SdkConstants.GESTURE_OVERLAY_VIEW; +import static com.android.SdkConstants.RADIO_GROUP; +import static com.android.SdkConstants.VIEW_INCLUDE; + +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CustomViewFinder; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.PaletteMetadataDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.gre.ViewMetadataRepository; +import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.resources.ResourceType; +import com.android.sdklib.IAndroidTarget; +import com.android.utils.Pair; + +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Text; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +public class WrapInWizard extends VisualRefactoringWizard { + private static final String SEPARATOR_LABEL = + "----------------------------------------"; //$NON-NLS-1$ + + public WrapInWizard(WrapInRefactoring ref, LayoutEditorDelegate editor) { + super(ref, editor); + setDefaultPageTitle("Wrap in Container"); + } + + @Override + protected void addUserInputPages() { + WrapInRefactoring ref = (WrapInRefactoring) getRefactoring(); + String oldType = ref.getOldType(); + addPage(new InputPage(mDelegate.getEditor().getProject(), oldType)); + } + + /** Wizard page which inputs parameters for the {@link WrapInRefactoring} operation */ + private static class InputPage extends VisualRefactoringInputPage { + private final IProject mProject; + private final String mOldType; + private Text mIdText; + private Combo mTypeCombo; + private List<Pair<String, ViewElementDescriptor>> mClassNames; + + public InputPage(IProject project, String oldType) { + super("WrapInInputPage"); //$NON-NLS-1$ + mProject = project; + mOldType = oldType; + } + + @Override + public void createControl(Composite parent) { + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(2, false)); + + Label typeLabel = new Label(composite, SWT.NONE); + typeLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); + typeLabel.setText("Type of Container:"); + + mTypeCombo = new Combo(composite, SWT.READ_ONLY); + mTypeCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + mTypeCombo.addSelectionListener(mSelectionValidateListener); + + Label idLabel = new Label(composite, SWT.NONE); + idLabel.setText("New Layout Id:"); + idLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false, 1, 1)); + + mIdText = new Text(composite, SWT.BORDER); + mIdText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); + mIdText.addModifyListener(mModifyValidateListener); + + Set<String> exclude = Collections.singleton(VIEW_INCLUDE); + mClassNames = addLayouts(mProject, mOldType, mTypeCombo, exclude, true); + mTypeCombo.select(0); + + setControl(composite); + validatePage(); + + mTypeCombo.setFocus(); + } + + @Override + protected boolean validatePage() { + boolean ok = true; + + String id = mIdText.getText().trim(); + + if (id.length() == 0) { + setErrorMessage("ID required"); + ok = false; + } else { + // ...but if you do, it has to be valid! + ResourceNameValidator validator = ResourceNameValidator.create(false, mProject, + ResourceType.ID); + String message = validator.isValid(id); + if (message != null) { + setErrorMessage(message); + ok = false; + } + } + + int selectionIndex = mTypeCombo.getSelectionIndex(); + String type = selectionIndex != -1 ? mClassNames.get(selectionIndex).getFirst() : null; + if (type == null) { + setErrorMessage("Select a container type"); + ok = false; // The user has chosen a separator + } + + if (ok) { + setErrorMessage(null); + + // Record state + WrapInRefactoring refactoring = + (WrapInRefactoring) getRefactoring(); + refactoring.setId(id); + refactoring.setType(type); + + ViewElementDescriptor descriptor = mClassNames.get(selectionIndex).getSecond(); + if (descriptor instanceof PaletteMetadataDescriptor) { + PaletteMetadataDescriptor paletteDescriptor = + (PaletteMetadataDescriptor) descriptor; + String initializedAttributes = paletteDescriptor.getInitializedAttributes(); + refactoring.setInitializedAttributes(initializedAttributes); + } else { + refactoring.setInitializedAttributes(null); + } + } + + setPageComplete(ok); + return ok; + } + } + + static List<Pair<String, ViewElementDescriptor>> addLayouts(IProject project, + String oldType, Combo combo, + Set<String> exclude, boolean addGestureOverlay) { + List<Pair<String, ViewElementDescriptor>> classNames = + new ArrayList<Pair<String, ViewElementDescriptor>>(); + + if (oldType != null && oldType.equals(FQCN_RADIO_BUTTON)) { + combo.add(RADIO_GROUP); + // NOT a fully qualified name since android widgets do not include the package + classNames.add(Pair.of(RADIO_GROUP, (ViewElementDescriptor) null)); + + combo.add(SEPARATOR_LABEL); + classNames.add(Pair.<String,ViewElementDescriptor>of(null, null)); + } + + Pair<List<String>,List<String>> result = CustomViewFinder.findViews(project, true); + List<String> customViews = result.getFirst(); + List<String> thirdPartyViews = result.getSecond(); + if (customViews.size() > 0) { + for (String view : customViews) { + combo.add(view); + classNames.add(Pair.of(view, (ViewElementDescriptor) null)); + } + combo.add(SEPARATOR_LABEL); + classNames.add(Pair.<String,ViewElementDescriptor>of(null, null)); + } + + // Populate type combo + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(project); + if (target != null) { + AndroidTargetData targetData = currentSdk.getTargetData(target); + if (targetData != null) { + ViewMetadataRepository repository = ViewMetadataRepository.get(); + List<Pair<String,List<ViewElementDescriptor>>> entries = + repository.getPaletteEntries(targetData, false, true); + // Find the layout category - it contains LinearLayout + List<ViewElementDescriptor> layoutDescriptors = null; + + search: for (Pair<String,List<ViewElementDescriptor>> pair : entries) { + List<ViewElementDescriptor> list = pair.getSecond(); + for (ViewElementDescriptor d : list) { + if (d.getFullClassName().equals(FQCN_LINEAR_LAYOUT)) { + // Found - use this list + layoutDescriptors = list; + break search; + } + } + } + if (layoutDescriptors != null) { + for (ViewElementDescriptor d : layoutDescriptors) { + String className = d.getFullClassName(); + if (exclude == null || !exclude.contains(className)) { + combo.add(d.getUiName()); + classNames.add(Pair.of(className, d)); + } + } + + // SWT does not support separators in combo boxes + combo.add(SEPARATOR_LABEL); + classNames.add(null); + + if (thirdPartyViews.size() > 0) { + for (String view : thirdPartyViews) { + combo.add(view); + classNames.add(Pair.of(view, (ViewElementDescriptor) null)); + } + combo.add(SEPARATOR_LABEL); + classNames.add(null); + } + + if (addGestureOverlay) { + combo.add(GESTURE_OVERLAY_VIEW); + classNames.add(Pair.<String, ViewElementDescriptor> of( + FQCN_GESTURE_OVERLAY_VIEW, null)); + + combo.add(SEPARATOR_LABEL); + classNames.add(Pair.<String,ViewElementDescriptor>of(null, null)); + } + } + + // Now add ALL known layout descriptors in case the user has + // a special case + layoutDescriptors = + targetData.getLayoutDescriptors().getLayoutDescriptors(); + + for (ViewElementDescriptor d : layoutDescriptors) { + String className = d.getFullClassName(); + if (exclude == null || !exclude.contains(className)) { + combo.add(d.getUiName()); + classNames.add(Pair.of(className, d)); + } + } + } + } + } else { + combo.add("SDK not initialized"); + classNames.add(Pair.<String,ViewElementDescriptor>of(null, null)); + } + + return classNames; + } +} diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/uimodel/UiViewElementNode.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/uimodel/UiViewElementNode.java new file mode 100644 index 000000000..d9d272224 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/uimodel/UiViewElementNode.java @@ -0,0 +1,208 @@ +/* + * 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.eclipse.adt.internal.editors.layout.uimodel; + +import static com.android.SdkConstants.ANDROID_NS_NAME; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_CLASS; +import static com.android.SdkConstants.ATTR_ORIENTATION; +import static com.android.SdkConstants.FQCN_FRAME_LAYOUT; +import static com.android.SdkConstants.LINEAR_LAYOUT; +import static com.android.SdkConstants.VALUE_VERTICAL; +import static com.android.SdkConstants.VIEW_TAG; + +import com.android.ide.eclipse.adt.AdtPlugin; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.IconFactory; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.XmlnsAttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors; +import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiDocumentNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData; +import com.android.ide.eclipse.adt.internal.sdk.Sdk; +import com.android.sdklib.IAndroidTarget; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IProject; +import org.eclipse.swt.graphics.Image; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +/** + * Specialized version of {@link UiElementNode} for the {@link ViewElementDescriptor}s. + */ +public class UiViewElementNode extends UiElementNode { + + /** An AttributeDescriptor array that depends on the current UiParent. */ + private AttributeDescriptor[] mCachedAttributeDescriptors; + + public UiViewElementNode(ViewElementDescriptor elementDescriptor) { + super(elementDescriptor); + } + + /** + * Returns an AttributeDescriptor array that depends on the current UiParent. + * <p/> + * The array merges both "direct" attributes with the descriptor layout attributes. + * The array instance is cached and cleared if the UiParent is changed. + */ + @Override + public AttributeDescriptor[] getAttributeDescriptors() { + if (!getDescriptor().syncAttributes()) { + mCachedAttributeDescriptors = null; + } + if (mCachedAttributeDescriptors != null) { + return mCachedAttributeDescriptors; + } + + UiElementNode ui_parent = getUiParent(); + AttributeDescriptor[] direct_attrs = super.getAttributeDescriptors(); + mCachedAttributeDescriptors = direct_attrs; + + // Compute layout attributes: These depend on the *parent* this widget is within + AttributeDescriptor[] layout_attrs = null; + boolean need_xmlns = false; + + if (ui_parent instanceof UiDocumentNode) { + // Limitation: right now the layout behaves as if everything was + // owned by a FrameLayout. + // TODO replace by something user-configurable. + + IProject project = getEditor().getProject(); + if (project != null) { + Sdk currentSdk = Sdk.getCurrent(); + if (currentSdk != null) { + IAndroidTarget target = currentSdk.getTarget(project); + if (target != null) { + AndroidTargetData data = currentSdk.getTargetData(target); + if (data != null) { + LayoutDescriptors descriptors = data.getLayoutDescriptors(); + ViewElementDescriptor desc = + descriptors.findDescriptorByClass(FQCN_FRAME_LAYOUT); + if (desc != null) { + layout_attrs = desc.getLayoutAttributes(); + need_xmlns = true; + } + } + } + } + } + } else if (ui_parent instanceof UiViewElementNode) { + layout_attrs = + ((ViewElementDescriptor) ui_parent.getDescriptor()).getLayoutAttributes(); + } + + if (layout_attrs == null || layout_attrs.length == 0) { + return mCachedAttributeDescriptors; + } + + mCachedAttributeDescriptors = + new AttributeDescriptor[direct_attrs.length + + layout_attrs.length + + (need_xmlns ? 1 : 0)]; + System.arraycopy(direct_attrs, 0, + mCachedAttributeDescriptors, 0, + direct_attrs.length); + System.arraycopy(layout_attrs, 0, + mCachedAttributeDescriptors, direct_attrs.length, + layout_attrs.length); + if (need_xmlns) { + AttributeDescriptor desc = new XmlnsAttributeDescriptor(ANDROID_NS_NAME, ANDROID_URI); + mCachedAttributeDescriptors[direct_attrs.length + layout_attrs.length] = desc; + } + + return mCachedAttributeDescriptors; + } + + public Image getIcon() { + ElementDescriptor desc = getDescriptor(); + if (desc != null) { + Image img = null; + // Special case for the common case of vertical linear layouts: + // show vertical linear icon (the default icon shows horizontal orientation) + String uiName = desc.getUiName(); + IconFactory icons = IconFactory.getInstance(); + if (uiName.equals(LINEAR_LAYOUT)) { + Element e = (Element) getXmlNode(); + if (VALUE_VERTICAL.equals(e.getAttributeNS(ANDROID_URI, ATTR_ORIENTATION))) { + IconFactory factory = icons; + img = factory.getIcon("VerticalLinearLayout"); //$NON-NLS-1$ + } + } else if (uiName.equals(VIEW_TAG)) { + Node xmlNode = getXmlNode(); + if (xmlNode instanceof Element) { + String className = ((Element) xmlNode).getAttribute(ATTR_CLASS); + if (className != null && className.length() > 0) { + int index = className.lastIndexOf('.'); + if (index != -1) { + className = "customView"; //$NON-NLS-1$ + } + img = icons.getIcon(className); + } + } + + if (img == null) { + // Can't have both view.png and View.png; issues on case sensitive vs + // case insensitive file systems + img = icons.getIcon("View"); //$NON-NLS-1$ + } + } + if (img == null) { + img = desc.getGenericIcon(); + } + + if (img != null) { + AndroidXmlEditor editor = getEditor(); + if (editor != null) { + LayoutEditorDelegate delegate = LayoutEditorDelegate.fromEditor(editor); + if (delegate != null) { + IMarker marker = delegate.getIssueForNode(this); + if (marker != null) { + int severity = marker.getAttribute(IMarker.SEVERITY, 0); + if (severity == IMarker.SEVERITY_ERROR) { + return icons.addErrorIcon(img); + } else { + return icons.addWarningIcon(img); + } + } + } + } + + return img; + } + + return img; + } + + return AdtPlugin.getAndroidLogo(); + } + + /** + * Sets the parent of this UI node. + * <p/> + * Also removes the cached AttributeDescriptor array that depends on the current UiParent. + */ + @Override + protected void setUiParent(UiElementNode parent) { + super.setUiParent(parent); + mCachedAttributeDescriptors = null; + } +} |