diff options
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/editors/layout/gle2')
68 files changed, 32214 insertions, 0 deletions
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); + } + } + } +} |