/* * 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); } } } }