/*
* 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.IMenuCallback;
import com.android.ide.common.api.IViewRule;
import com.android.ide.common.api.MenuAction;
import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.internal.editors.IconFactory;
import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditor;
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.ExtractIncludeAction;
import com.android.ide.eclipse.adt.internal.editors.layout.refactoring.WrapInAction;
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.MenuManager;
import org.eclipse.jface.action.Separator;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.Map.Entry;
import java.util.regex.Pattern;
/**
* 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}.
*/
/* package */ class DynamicContextMenu {
/** The XML layout editor that contains the canvas that uses this menu. */
private final LayoutEditor mEditor;
/** 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 editor 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(LayoutEditor editor, LayoutCanvas canvas, MenuManager rootMenu) {
mEditor = editor;
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() {
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 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() {
// Map action-id => action object (one per selected view that defined it)
final TreeMap<String /*id*/, ArrayList<MenuAction>> actionsMap =
new TreeMap<String, ArrayList<MenuAction>>();
// Map group-id => actions to place in this group.
TreeMap<String /*id*/, MenuAction.Group> groupsMap =
new TreeMap<String, MenuAction.Group>();
int maxMenuSelection = collectDynamicMenuActions(actionsMap, groupsMap);
// Now 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();
// First create the groups
Map<String, MenuManager> menuGroups = new HashMap<String, MenuManager>();
for (MenuAction.Group group : groupsMap.values()) {
String id = group.getId();
MenuManager submenu = new MenuManager(group.getTitle(), id);
menuGroups.put(id, submenu);
mMenuManager.insertBefore(endId, submenu);
endId = id;
}
boolean needGroupSep = !menuGroups.isEmpty();
// Now fill in the actions
for (ArrayList<MenuAction> actions : actionsMap.values()) {
// Filter actions... if we have a multiple selection, only accept actions
// which are common to *all* the selection which actually returned at least
// one menu action.
if (actions == null ||
actions.isEmpty() ||
actions.size() != maxMenuSelection) {
continue;
}
if (!(actions.get(0) instanceof MenuAction.Action)) {
continue;
}
// Arbitrarily select the first action, as all the actions with the same id
// should have the same constant attributes such as id and title.
final MenuAction.Action firstAction = (MenuAction.Action) actions.get(0);
IContributionItem contrib = null;
if (firstAction instanceof MenuAction.Toggle) {
contrib = createDynamicMenuToggle((MenuAction.Toggle) firstAction, actionsMap);
} else if (firstAction instanceof MenuAction.Choices) {
Map<String, String> choiceMap = ((MenuAction.Choices) firstAction).getChoices();
if (choiceMap != null && !choiceMap.isEmpty()) {
contrib = createDynamicChoices(
(MenuAction.Choices)firstAction, choiceMap, actionsMap);
}
} else {
// Must be a plain action
contrib = createDynamicAction(firstAction, actionsMap);
}
if (contrib != null) {
MenuManager groupMenu = menuGroups.get(firstAction.getGroupId());
if (groupMenu != null) {
groupMenu.add(contrib);
} else {
if (needGroupSep) {
needGroupSep = false;
sep = new Separator();
sep.setId("-dyn-gle-sep2"); //$NON-NLS-1$
mMenuManager.insertBefore(endId, sep);
endId = sep.getId();
}
mMenuManager.insertBefore(endId, contrib);
}
}
}
insertVisualRefactorings(endId);
}
private void insertVisualRefactorings(String endId) {
// Extract As <include> refactoring.
// 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());
mMenuManager.insertBefore(endId, ExtractIncludeAction.create(mEditor));
mMenuManager.insertBefore(endId, WrapInAction.create(mEditor));
mMenuManager.insertBefore(endId, ChangeLayoutAction.create(mEditor));
mMenuManager.insertBefore(endId, new Separator());
}
/**
* Collects all the {@link MenuAction} contributed by the {@link IViewRule} of the
* current selection.
* This is the first step of {@link #populateDynamicContextMenu()}.
*
* @param outActionsMap Map that collects all the contributed actions.
* @param outGroupsMap Map that collects all the contributed groups (sub-menus).
* @return The max number of selected items that contributed the same action ID.
* This is used later to filter on multiple selections so that we can display only
* actions that are common to all selected items that contributed at least one action.
*/
private int collectDynamicMenuActions(
final TreeMap<String, ArrayList<MenuAction>> outActionsMap,
final TreeMap<String, MenuAction.Group> outGroupsMap) {
int maxMenuSelection = 0;
for (SelectionItem selection : mCanvas.getSelectionManager().getSelections()) {
List<MenuAction> viewActions = null;
if (selection != null) {
CanvasViewInfo vi = selection.getViewInfo();
if (vi != null) {
viewActions = getMenuActions(vi);
}
}
if (viewActions == null) {
continue;
}
boolean foundAction = false;
for (MenuAction action : viewActions) {
// Allow nulls - ignore these. Make it easier to define action lists
// literals where some items may not be included (because their references
// are null).
if (action == null) {
continue;
}
if (action.getId() == null || action.getTitle() == null) {
// TODO Log verbose error for invalid action.
continue;
}
String id = action.getId();
if (action instanceof MenuAction.Group) {
if (!outGroupsMap.containsKey(id)) {
outGroupsMap.put(id, (MenuAction.Group) action);
}
continue;
}
ArrayList<MenuAction> actions = outActionsMap.get(id);
if (actions == null) {
actions = new ArrayList<MenuAction>();
outActionsMap.put(id, actions);
}
// All the actions for the same id should have be equal
if (!actions.isEmpty()) {
if (!action.equals(actions.get(0))) {
// TODO Log verbose error for invalid type mismatch.
continue;
}
}
actions.add(action);
foundAction = true;
}
if (foundAction) {
maxMenuSelection++;
}
}
return maxMenuSelection;
}
/**
* Returns the menu actions computed by the rule associated with this view.
*
* @param vi the canvas view info we need menu actions for
* @return a list of {@link MenuAction} objects applicable to the view info
*/
public List<MenuAction> getMenuActions(CanvasViewInfo vi) {
if (vi == null) {
return null;
}
NodeProxy node = mCanvas.getNodeFactory().create(vi);
if (node == null) {
return null;
}
List<MenuAction> actions = mCanvas.getRulesEngine().callGetContextMenu(node);
if (actions == null || actions.size() == 0) {
return null;
}
return actions;
}
/**
* Invoked by {@link #populateDynamicContextMenu()} to create a new menu item
* for a {@link MenuAction.Toggle}.
* <p/>
* Toggles are represented by a checked menu item.
*
* @param firstAction The toggle action to convert to a menu item. In the case of a
* multiple selection, this is the first of many similar actions.
* @param actionsMap Map of all contributed actions.
* @return a new {@link IContributionItem} to add to the context menu
*/
private IContributionItem createDynamicMenuToggle(
final MenuAction.Toggle firstAction,
final TreeMap<String, ArrayList<MenuAction>> actionsMap) {
final boolean isChecked = firstAction.isChecked();
Action a = new Action(firstAction.getTitle(), IAction.AS_CHECK_BOX) {
@Override
public void run() {
final List<MenuAction> actions = actionsMap.get(firstAction.getId());
if (actions == null || actions.isEmpty()) {
return;
}
String label = String.format("Toggle attribute %s", actions.get(0).getTitle());
if (actions.size() > 1) {
label += String.format(" (%d elements)", actions.size());
}
if (mEditor.isEditXmlModelPending()) {
// This should not be happening.
logError("Action '%s' failed: XML changes pending, document might be corrupt.", //$NON-NLS-1$
label);
return;
}
mEditor.wrapUndoEditXmlModel(label, new Runnable() {
public void run() {
// Invoke the callbacks of all the actions using the same action-id
for (MenuAction a2 : actions) {
if (a2 instanceof MenuAction.Action) {
IMenuCallback c = ((MenuAction.Action) a2).getCallback();
if (c != null) {
try {
c.action(a2, null /* no valueId for a toggle */,
!isChecked);
} catch (Exception e) {
AdtPlugin.log(e, "XML edit operation failed: %s",
e.toString());
}
}
}
}
}
});
}
};
a.setId(firstAction.getId());
a.setChecked(isChecked);
return new ActionContributionItem(a);
}
/**
* Invoked by {@link #populateDynamicContextMenu()} to create a new menu item
* for a plain action. This is nearly identical to {@link #createDynamicMenuToggle},
* except for the {@link IAction} type and the removal of setChecked, isChecked, etc.
*
* @param firstAction The action to convert to a menu item. In the case of a
* multiple selection, this is the first of many similar actions.
* @param actionsMap Map of all contributed actions.
* @return a new {@link IContributionItem} to add to the context menu
*/
private IContributionItem createDynamicAction(
final MenuAction.Action firstAction,
final TreeMap<String, ArrayList<MenuAction>> actionsMap) {
Action a = new Action(firstAction.getTitle(), IAction.AS_PUSH_BUTTON) {
@Override
public void run() {
final List<MenuAction> actions = actionsMap.get(firstAction.getId());
if (actions == null || actions.isEmpty()) {
return;
}
String label = actions.get(0).getTitle();
if (actions.size() > 1) {
label += String.format(" (%d elements)", actions.size());
}
if (mEditor.isEditXmlModelPending()) {
// This should not be happening.
logError("Action '%s' failed: XML changes pending, document might be corrupt.", //$NON-NLS-1$
label);
return;
}
mEditor.wrapUndoEditXmlModel(label, new Runnable() {
public void run() {
// Invoke the callbacks of all the actions using the same action-id
for (MenuAction a2 : actions) {
if (a2 instanceof MenuAction.Action) {
IMenuCallback c = ((MenuAction.Action) a2).getCallback();
if (c != null) {
try {
// Values do not apply for plain actions
c.action(a2, null /* valueId */, null /* newValue */);
} catch (Exception e) {
AdtPlugin.log(e, "XML edit operation failed: %s",
e.toString());
}
}
}
}
}
});
}
};
a.setId(firstAction.getId());
return new ActionContributionItem(a);
}
/**
* Invoked by {@link #populateDynamicContextMenu()} to create a new menu item
* for a {@link MenuAction.Choices}.
* <p/>
* Multiple-choices are represented by a sub-menu containing checked items.
*
* @param firstAction The choices action to convert to a menu item. In the case of a
* multiple selection, this is the first of many similar actions.
* @param actionsMap Map of all contributed actions.
* @return a new {@link IContributionItem} to add to the context menu
*/
private IContributionItem createDynamicChoices(
final MenuAction.Choices firstAction,
Map<String, String> choiceMap,
final TreeMap<String, ArrayList<MenuAction>> actionsMap) {
IconFactory factory = IconFactory.getInstance();
MenuManager submenu = new MenuManager(firstAction.getTitle(), firstAction.getId());
// Convert to a tree map as needed so that keys be naturally ordered.
if (!(choiceMap instanceof TreeMap<?, ?>)) {
choiceMap = new TreeMap<String, String>(choiceMap);
}
String sepPattern = Pattern.quote(MenuAction.Choices.CHOICE_SEP);
for (Entry<String, String> entry : choiceMap.entrySet() ) {
final String key = entry.getKey();
String title = entry.getValue();
if (key == null || title == null) {
continue;
}
if (MenuAction.Choices.SEPARATOR.equals(title)) {
submenu.add(new Separator());
continue;
}
final List<MenuAction> actions = actionsMap.get(firstAction.getId());
if (actions == null || actions.isEmpty()) {
continue;
}
// Are all actions for this id checked, unchecked, or in a mixed state?
int numOff = 0;
int numOn = 0;
for (MenuAction a2 : actions) {
MenuAction.Choices choice = (MenuAction.Choices) a2;
String current = choice.getCurrent();
if (current == null) {
// None of the choices were selected. This can for example happen if
// the user does not have an attribute for "layout_width" set on the element
// and the context menu is opened to see the width choices.
numOff++;
continue;
}
boolean found = false;
if (current.indexOf(MenuAction.Choices.CHOICE_SEP) >= 0) {
// current choice has a separator, so it's a flag with multiple values
// selected. Compare keys with the split values.
if (current.indexOf(key) >= 0) {
for(String value : current.split(sepPattern)) {
if (key.equals(value)) {
found = true;
break;
}
}
}
} else {
// current choice has no separator, simply compare to the key
found = key.equals(current);
}
if (found) {
numOn++;
} else {
numOff++;
}
}
// We consider the item to be checked if all actions are all checked.
// This means a mixed item will be first toggled from off to on by all the callbacks.
final boolean isChecked = numOff == 0 && numOn > 0;
boolean isMixed = numOff > 0 && numOn > 0;
if (isMixed) {
title += String.format(" (%1$d/%2$d)", numOn, numOff + numOn);
}
Action a = new Action(title, IAction.AS_CHECK_BOX) {
@Override
public void run() {
String label =
String.format("Change attribute %1$s", actions.get(0).getTitle());
if (actions.size() > 1) {
label += String.format(" (%1$d elements)", actions.size());
}
if (mEditor.isEditXmlModelPending()) {
// This should not be happening.
logError("Action '%1$s' failed: XML changes pending, document might be corrupt.", //$NON-NLS-1$
label);
return;
}
mEditor.wrapUndoEditXmlModel(label, new Runnable() {
public void run() {
// Invoke the callbacks of all the actions using the same action-id
for (MenuAction a2 : actions) {
if (a2 instanceof MenuAction.Action) {
try {
((MenuAction.Action) a2).getCallback().action(a2, key,
!isChecked);
} catch (Exception e) {
AdtPlugin.log(e, "XML edit operation failed: %s",
e.toString());
}
}
}
}
});
}
};
a.setId(String.format("%1$s_%2$s", firstAction.getId(), key)); //$NON-NLS-1$
a.setChecked(isChecked);
if (isMixed) {
a.setImageDescriptor(factory.getImageDescriptor("match_multiple")); //$NON-NLS-1$
}
submenu.add(a);
}
return submenu;
}
private void logError(String format, Object...args) {
AdtPlugin.logAndPrintError(
null, // exception
mCanvas.getRulesEngine().getProject().getName(), // tag
format, args);
}
}