/*******************************************************************************
 * Copyright (c) 2000, 2018 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *     Lars Vogel <Lars.Vogel@gmail.com> - Bug 440810
 *     Patrik Suzzi <psuzzi@gmail.com> - Bug 489250
 *******************************************************************************/

package org.eclipse.ui.internal.keys;

import static org.eclipse.swt.events.SelectionListener.widgetDefaultSelectedAdapter;
import static org.eclipse.swt.events.SelectionListener.widgetSelectedAdapter;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.text.Collator;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ResourceBundle;
import java.util.Set;
import org.eclipse.core.commands.Category;
import org.eclipse.core.commands.Command;
import org.eclipse.core.commands.CommandManager;
import org.eclipse.core.commands.ParameterizedCommand;
import org.eclipse.core.commands.common.NotDefinedException;
import org.eclipse.core.commands.contexts.Context;
import org.eclipse.core.commands.contexts.ContextManager;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.SafeRunner;
import org.eclipse.core.runtime.Status;
import org.eclipse.jface.bindings.Binding;
import org.eclipse.jface.bindings.BindingManager;
import org.eclipse.jface.bindings.Scheme;
import org.eclipse.jface.bindings.TriggerSequence;
import org.eclipse.jface.bindings.keys.KeyBinding;
import org.eclipse.jface.bindings.keys.KeySequence;
import org.eclipse.jface.bindings.keys.KeySequenceText;
import org.eclipse.jface.bindings.keys.KeyStroke;
import org.eclipse.jface.contexts.IContextIds;
import org.eclipse.jface.dialogs.IDialogConstants;
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.preference.PreferencePage;
import org.eclipse.jface.util.SafeRunnable;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.FocusEvent;
import org.eclipse.swt.events.FocusListener;
import org.eclipse.swt.events.MouseAdapter;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.FileDialog;
import org.eclipse.swt.widgets.Group;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Menu;
import org.eclipse.swt.widgets.MenuItem;
import org.eclipse.swt.widgets.TabFolder;
import org.eclipse.swt.widgets.TabItem;
import org.eclipse.swt.widgets.Table;
import org.eclipse.swt.widgets.TableColumn;
import org.eclipse.swt.widgets.TableItem;
import org.eclipse.swt.widgets.Text;
import org.eclipse.ui.IWorkbench;
import org.eclipse.ui.IWorkbenchPreferencePage;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.activities.IActivityManager;
import org.eclipse.ui.commands.ICommandService;
import org.eclipse.ui.contexts.IContextService;
import org.eclipse.ui.internal.IPreferenceConstants;
import org.eclipse.ui.internal.IWorkbenchHelpContextIds;
import org.eclipse.ui.internal.WorkbenchPlugin;
import org.eclipse.ui.internal.misc.StatusUtil;
import org.eclipse.ui.internal.util.PrefUtil;
import org.eclipse.ui.internal.util.Util;
import org.eclipse.ui.keys.IBindingService;
import org.eclipse.ui.statushandlers.StatusManager;

/**
 * The preference page for defining keyboard shortcuts. While some of its
 * underpinning have been made generic to "bindings" rather than "key bindings",
 * it will still take some work to remove the link entirely.
 *
 * @since 3.0
 */
public final class KeysPreferencePage extends PreferencePage implements IWorkbenchPreferencePage {

	/**
	 * A selection listener to be used on the columns in the table on the view tab.
	 * This selection listener modifies the sort order so that the appropriate
	 * column is in the first position.
	 *
	 * @since 3.1
	 */
	private class SortOrderSelectionListener extends SelectionAdapter {

		/**
		 * The column to be put in the first position. This value should be one of the
		 * constants defined by <code>SORT_COLUMN_</code>.
		 */
		private final int columnSelected;

		/**
		 * Constructs a new instance of <code>SortOrderSelectionListener</code>.
		 *
		 * @param columnSelected The column to be given first priority in the sort
		 *                       order; this value should be one of the constants
		 *                       defined as <code>SORT_COLUMN_</code>.
		 */
		private SortOrderSelectionListener(final int columnSelected) {
			this.columnSelected = columnSelected;
		}

		@Override
		public void widgetSelected(SelectionEvent e) {
			// Change the column titles.
			final int oldSortIndex = sortOrder[0];
			final TableColumn oldSortColumn = tableBindings.getColumn(oldSortIndex);
			oldSortColumn.setText(UNSORTED_COLUMN_NAMES[oldSortIndex]);
			final TableColumn newSortColumn = tableBindings.getColumn(columnSelected);
			newSortColumn.setText(SORTED_COLUMN_NAMES[columnSelected]);

			// Change the sort order.
			boolean columnPlaced = false;
			boolean enoughRoom = false;
			int bumpedColumn = -1;
			for (int i = 0; i < sortOrder.length; i++) {
				if (sortOrder[i] == columnSelected) {
					/*
					 * We've found the place where the column existing in the old sort order. No
					 * matter what at this point, we have completed the reshuffling.
					 */
					enoughRoom = true;
					if (bumpedColumn != -1) {
						// We have already started bumping things around, so
						// drop the last bumped column here.
						sortOrder[i] = bumpedColumn;
					} else {
						// The order has not changed.
						columnPlaced = true;
					}
					break;

				} else if (columnPlaced) {
					// We are currently bumping, so just bump another.
					int temp = sortOrder[i];
					sortOrder[i] = bumpedColumn;
					bumpedColumn = temp;

				} else {
					/*
					 * We are not currently bumping, so drop the column and start bumping.
					 */
					bumpedColumn = sortOrder[i];
					sortOrder[i] = columnSelected;
					columnPlaced = true;
				}
			}

			// Grow the sort order.
			if (!enoughRoom) {
				final int[] newSortOrder = new int[sortOrder.length + 1];
				System.arraycopy(sortOrder, 0, newSortOrder, 0, sortOrder.length);
				newSortOrder[sortOrder.length] = bumpedColumn;
				sortOrder = newSortOrder;
			}

			// Update the view tab.
			updateViewTab();
		}
	}

	/**
	 * The data key for the binding stored on an SWT widget. The key is a
	 * fully-qualified name, but in reverse order. This is so that the equals method
	 * will detect misses faster.
	 */
	private static final String BINDING_KEY = "Binding.bindings.jface.eclipse.org"; //$NON-NLS-1$

	/**
	 * The image associate with a binding that exists as part of the system
	 * definition.
	 */
	private static final Image IMAGE_BLANK = ImageFactory.getImage("blank"); //$NON-NLS-1$

	/**
	 * The image associated with a binding changed by the user.
	 */
	private static final Image IMAGE_CHANGE = ImageFactory.getImage("change"); //$NON-NLS-1$

	/**
	 * The data key at which the <code>Binding</code> instance for a table item is
	 * stored.
	 */
	private static final String ITEM_DATA_KEY = "org.eclipse.jface.bindings"; //$NON-NLS-1$

	/**
	 * The number of items to show in the combo boxes.
	 */
	private static final int ITEMS_TO_SHOW = 9;

	/**
	 * The resource bundle from which translations can be retrieved.
	 */
	private static final ResourceBundle RESOURCE_BUNDLE = ResourceBundle.getBundle(KeysPreferencePage.class.getName());

	/**
	 * The total number of columns on the view tab.
	 */
	private static final int VIEW_TOTAL_COLUMNS = 4;

	/**
	 * The translated names for the columns when they are the primary sort key
	 * (e.g., {@literal ">Category<"}).
	 */
	private static final String[] SORTED_COLUMN_NAMES = new String[VIEW_TOTAL_COLUMNS];

	/**
	 * The index of the modify tab.
	 *
	 * @since 3.1
	 */
	private static final int TAB_INDEX_MODIFY = 1;

	/**
	 * The translated names for the columns when they are not the primary sort key
	 * (e.g., "Category").
	 */
	private static final String[] UNSORTED_COLUMN_NAMES = new String[VIEW_TOTAL_COLUMNS];

	/**
	 * The index of the column on the view tab containing the category name.
	 */
	private static final int VIEW_CATEGORY_COLUMN_INDEX = 0;

	/**
	 * The index of the column on the view tab containing the command name.
	 */
	private static final int VIEW_COMMAND_COLUMN_INDEX = 1;

	/**
	 * The index of the column on the view tab containing the context name.
	 */
	private static final int VIEW_CONTEXT_COLUMN_INDEX = 3;

	/**
	 * The index of the column on the view tab containing the key sequence.
	 */
	private static final int VIEW_KEY_SEQUENCE_COLUMN_INDEX = 2;

	static {
		UNSORTED_COLUMN_NAMES[VIEW_CATEGORY_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE,
				"tableColumnCategory"); //$NON-NLS-1$
		UNSORTED_COLUMN_NAMES[VIEW_COMMAND_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE, "tableColumnCommand"); //$NON-NLS-1$
		UNSORTED_COLUMN_NAMES[VIEW_KEY_SEQUENCE_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE,
				"tableColumnKeySequence"); //$NON-NLS-1$
		UNSORTED_COLUMN_NAMES[VIEW_CONTEXT_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE, "tableColumnContext"); //$NON-NLS-1$

		SORTED_COLUMN_NAMES[VIEW_CATEGORY_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE,
				"tableColumnCategorySorted"); //$NON-NLS-1$
		SORTED_COLUMN_NAMES[VIEW_COMMAND_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE,
				"tableColumnCommandSorted"); //$NON-NLS-1$
		SORTED_COLUMN_NAMES[VIEW_KEY_SEQUENCE_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE,
				"tableColumnKeySequenceSorted"); //$NON-NLS-1$
		SORTED_COLUMN_NAMES[VIEW_CONTEXT_COLUMN_INDEX] = Util.translateString(RESOURCE_BUNDLE,
				"tableColumnContextSorted"); //$NON-NLS-1$
	}

	/**
	 * The workbench's activity manager. This activity manager is used to see if
	 * certain commands should be filtered from the user interface.
	 */
	private IActivityManager activityManager;

	/**
	 * The workbench's binding service. This binding service is used to access the
	 * current set of bindings, and to persist changes.
	 */
	private IBindingService bindingService;

	/**
	 * The add button located on the bottom left of the preference page. This button
	 * adds the current trigger sequence to the currently selected command.
	 */
	private Button buttonAdd;

	/**
	 * The remove button located on the bottom left of the preference page. This
	 * button removes the current trigger sequence from the current command.
	 */
	private Button buttonRemove;

	/**
	 * The restore button located on the bottom left of the preference page. This
	 * button attempts to restore the currently trigger sequence to its initial
	 * (i.e., Binding.SYSTEM) state -- undoing all user modifications.
	 */
	private Button buttonRestore;

	/**
	 * A map of all the category identifiers indexed by the names that appear in the
	 * user interface. This look-up table is built during initialization.
	 */
	private Map<String, String> categoryIdsByUniqueName;

	/**
	 * A map of all the category names in the user interface indexed by their
	 * identifiers. This look-up table is built during initialization.
	 */
	private Map<String, String> categoryUniqueNamesById;

	/**
	 * The combo box containing the list of all categories for commands.
	 */
	private Combo comboCategory;

	/**
	 * The combo box containing the list of commands relevent for the currently
	 * selected category.
	 */
	private Combo comboCommand;

	/**
	 * The combo box containing the list of contexts in the system.
	 */
	private Combo comboContext;

	/**
	 * The combo box containing the list of schemes in the system.
	 */
	private Combo comboScheme;

	/**
	 * A map of all the command identifiers indexed by the categories to which they
	 * belong. This look-up table is built during initialization.
	 */
	private Map<String, Set<String>> commandIdsByCategoryId;

	/**
	 * The parameterized commands corresponding to the current contents of
	 * <code>comboCommand</code>. The commands in this array are in the same order
	 * as in the combo. This value can be <code>null</code> if nothing is selected
	 * in the combo.
	 */
	private ParameterizedCommand[] commands = null;

	/**
	 * The workbench's command service. This command service is used to access the
	 * list of commands.
	 */
	private ICommandService commandService;

	/**
	 * A map of all the context identifiers indexed by the names that appear in the
	 * user interface. This look-up table is built during initialization.
	 */
	private Map<String, String> contextIdsByUniqueName;

	/**
	 * The workbench's context service. This context service is used to access the
	 * list of contexts.
	 */
	private IContextService contextService;

	/**
	 * A map of all the category names in the user interface indexed by their
	 * identifiers. This look-up table is built during initialization.
	 */
	private Map<String, String> contextUniqueNamesById;

	/**
	 * The workbench's help system. This is used to register the page with the help
	 * system.
	 *
	 * TODO Add a help context
	 */
	// private IWorkbenchHelpSystem helpSystem;
	/**
	 * This is the label next to the table showing the bindings matching a
	 * particular command. The label is disabled if there isn't a selected command
	 * identifier.
	 */
	private Label labelBindingsForCommand;

	/**
	 * This is the label next to the table showing the bindings matching a
	 * particular trigger sequence. The label is disabled if there isn't a current
	 * key sequence.
	 */
	private Label labelBindingsForTriggerSequence;

	/**
	 * The label next to the context combo box. This label indicates whether the
	 * context is a child of another context. If the current context is not a child,
	 * then this label is blank.
	 */
	private Label labelContextExtends;

	/**
	 * The label next to the scheme combo box. This label indicates whether the
	 * scheme is a child of another scheme. If the current scheme is not a child,
	 * then this label is blank.
	 */
	private Label labelSchemeExtends;

	/**
	 * A binding manager local to this preference page. When the page is
	 * initialized, the current bindings are read out from the binding service and
	 * placed in this manager. This manager is then updated as the user makes
	 * changes. When the user has finished, the contents of this manager are
	 * compared with the contents of the binding service. The changes are then
	 * persisted.
	 */
	private final BindingManager localChangeManager = new BindingManager(new ContextManager(), new CommandManager());

	/**
	 * A map of all the scheme identifiers indexed by the names that appear in the
	 * user interface. This look-up table is built during initialization.
	 */
	private Map<String, String> schemeIdsByUniqueName;

	/**
	 * A map of all the scheme names in the user interface indexed by their
	 * identifiers. This look-up table is built during initialization.
	 */
	private Map<String, String> schemeUniqueNamesById;

	/**
	 * The sort order to be used on the view tab to display all of the key bindings.
	 * This sort order can be changed by the user. This array is never
	 * <code>null</code>, but may be empty.
	 */
	private int[] sortOrder = { VIEW_CATEGORY_COLUMN_INDEX, VIEW_COMMAND_COLUMN_INDEX, VIEW_KEY_SEQUENCE_COLUMN_INDEX,
			VIEW_CONTEXT_COLUMN_INDEX };

	/**
	 * The top-most tab folder for the preference page -- containing a view and a
	 * modify tab.
	 */
	private TabFolder tabFolder;

	/**
	 * A table of the key bindings currently defined. This table appears on the view
	 * tab; it is intended to be an easy way for users to learn the key bindings in
	 * Eclipse. This value is only <code>null</code> until the controls are first
	 * created.
	 */
	private Table tableBindings;

	/**
	 * The table containing all of the bindings matching the selected command.
	 */
	private Table tableBindingsForCommand;

	/**
	 * The table containing all of the bindings matching the current trigger
	 * sequence.
	 */
	private Table tableBindingsForTriggerSequence;

	/**
	 * The text widget where keys are entered. This widget is managed by
	 * <code>textTriggerSequenceManager</code>, which provides its special
	 * behaviour.
	 */
	private Text textTriggerSequence;

	/**
	 * The manager for the text widget that traps incoming key events. This manager
	 * should be used to access the widget, rather than accessing the widget
	 * directly.
	 */
	private KeySequenceText textTriggerSequenceManager;

	@Override
	public void applyData(Object data) {
		if (data instanceof Binding) {
			editBinding((Binding) data);
		}
	}

	@Override
	protected Control createContents(final Composite parent) {

		PlatformUI.getWorkbench().getHelpSystem().setHelp(parent, IWorkbenchHelpContextIds.KEYS_PREFERENCE_PAGE);

		tabFolder = new TabFolder(parent, SWT.NULL);

		// View tab
		final TabItem viewTab = new TabItem(tabFolder, SWT.NULL);
		viewTab.setText(Util.translateString(RESOURCE_BUNDLE, "viewTab.Text")); //$NON-NLS-1$
		viewTab.setControl(createViewTab(tabFolder));

		// Modify tab
		final TabItem modifyTab = new TabItem(tabFolder, SWT.NULL);
		modifyTab.setText(Util.translateString(RESOURCE_BUNDLE, "modifyTab.Text")); //$NON-NLS-1$
		modifyTab.setControl(createModifyTab(tabFolder));

		// Do some fancy stuff.
		applyDialogFont(tabFolder);
		final IPreferenceStore store = getPreferenceStore();
		final int selectedTab = store.getInt(IPreferenceConstants.KEYS_PREFERENCE_SELECTED_TAB);
		if ((tabFolder.getItemCount() > selectedTab) && (selectedTab > 0)) {
			tabFolder.setSelection(selectedTab);
		}

		return tabFolder;
	}

	/**
	 * Creates the tab that allows the user to change the keyboard shortcuts.
	 *
	 * @param parent The tab folder in which the tab should be created; must not be
	 *               <code>null</code>.
	 * @return The composite which represents the contents of the tab; never
	 *         <code>null</code>.
	 */
	private Composite createModifyTab(final TabFolder parent) {
		final Composite composite = new Composite(parent, SWT.NULL);
		composite.setLayout(new GridLayout());
		GridData gridData = new GridData(GridData.FILL_BOTH);
		composite.setLayoutData(gridData);
		final Composite compositeKeyConfiguration = new Composite(composite, SWT.NULL);
		GridLayout gridLayout = new GridLayout();
		gridLayout.numColumns = 3;
		compositeKeyConfiguration.setLayout(gridLayout);
		gridData = new GridData(GridData.FILL_HORIZONTAL);
		compositeKeyConfiguration.setLayoutData(gridData);
		final Label labelKeyConfiguration = new Label(compositeKeyConfiguration, SWT.LEFT);
		labelKeyConfiguration.setText(Util.translateString(RESOURCE_BUNDLE, "labelScheme")); //$NON-NLS-1$
		comboScheme = new Combo(compositeKeyConfiguration, SWT.READ_ONLY);
		gridData = new GridData();
		gridData.widthHint = 200;
		comboScheme.setLayoutData(gridData);
		comboScheme.setVisibleItemCount(ITEMS_TO_SHOW);

		comboScheme.addSelectionListener(widgetSelectedAdapter(e -> selectedComboScheme()));

		labelSchemeExtends = new Label(compositeKeyConfiguration, SWT.LEFT);
		gridData = new GridData(GridData.FILL_HORIZONTAL);
		labelSchemeExtends.setLayoutData(gridData);
		final Control spacer = new Composite(composite, SWT.NULL);
		gridData = new GridData();
		gridData.heightHint = 10;
		gridData.widthHint = 10;
		spacer.setLayoutData(gridData);
		final Group groupCommand = new Group(composite, SWT.SHADOW_NONE);
		gridLayout = new GridLayout();
		gridLayout.numColumns = 3;
		groupCommand.setLayout(gridLayout);
		gridData = new GridData(GridData.FILL_BOTH);
		groupCommand.setLayoutData(gridData);
		groupCommand.setText(Util.translateString(RESOURCE_BUNDLE, "groupCommand")); //$NON-NLS-1$
		final Label labelCategory = new Label(groupCommand, SWT.LEFT);
		gridData = new GridData();
		labelCategory.setLayoutData(gridData);
		labelCategory.setText(Util.translateString(RESOURCE_BUNDLE, "labelCategory")); //$NON-NLS-1$
		comboCategory = new Combo(groupCommand, SWT.READ_ONLY);
		gridData = new GridData();
		gridData.horizontalSpan = 2;
		gridData.widthHint = 200;
		comboCategory.setLayoutData(gridData);
		comboCategory.setVisibleItemCount(ITEMS_TO_SHOW);

		comboCategory.addSelectionListener(widgetSelectedAdapter(e -> update()));

		final Label labelCommand = new Label(groupCommand, SWT.LEFT);
		gridData = new GridData();
		labelCommand.setLayoutData(gridData);
		labelCommand.setText(Util.translateString(RESOURCE_BUNDLE, "labelCommand")); //$NON-NLS-1$
		comboCommand = new Combo(groupCommand, SWT.READ_ONLY);
		gridData = new GridData();
		gridData.horizontalSpan = 2;
		gridData.widthHint = 300;
		comboCommand.setLayoutData(gridData);
		comboCommand.setVisibleItemCount(9);

		comboCommand.addSelectionListener(widgetSelectedAdapter(e -> update()));

		labelBindingsForCommand = new Label(groupCommand, SWT.LEFT);
		gridData = new GridData(GridData.VERTICAL_ALIGN_BEGINNING);
		gridData.verticalAlignment = GridData.FILL_VERTICAL;
		labelBindingsForCommand.setLayoutData(gridData);
		labelBindingsForCommand.setText(Util.translateString(RESOURCE_BUNDLE, "labelAssignmentsForCommand")); //$NON-NLS-1$
		tableBindingsForCommand = new Table(groupCommand,
				SWT.BORDER | SWT.FULL_SELECTION | SWT.H_SCROLL | SWT.V_SCROLL);
		tableBindingsForCommand.setHeaderVisible(true);
		gridData = new GridData(GridData.FILL_BOTH);
		gridData.heightHint = 60;
		gridData.horizontalSpan = 2;
		boolean isMac = org.eclipse.jface.util.Util.isMac();
		gridData.widthHint = isMac ? 620 : 520;
		tableBindingsForCommand.setLayoutData(gridData);
		TableColumn tableColumnDelta = new TableColumn(tableBindingsForCommand, SWT.NULL, 0);
		tableColumnDelta.setResizable(false);
		tableColumnDelta.setText(Util.ZERO_LENGTH_STRING);
		tableColumnDelta.setWidth(20);
		TableColumn tableColumnContext = new TableColumn(tableBindingsForCommand, SWT.NULL, 1);
		tableColumnContext.setResizable(true);
		tableColumnContext.setText(Util.translateString(RESOURCE_BUNDLE, "tableColumnContext")); //$NON-NLS-1$
		tableColumnContext.pack();
		tableColumnContext.setWidth(200);
		final TableColumn tableColumnKeySequence = new TableColumn(tableBindingsForCommand, SWT.NULL, 2);
		tableColumnKeySequence.setResizable(true);
		tableColumnKeySequence.setText(Util.translateString(RESOURCE_BUNDLE, "tableColumnKeySequence")); //$NON-NLS-1$
		tableColumnKeySequence.pack();
		tableColumnKeySequence.setWidth(300);

		tableBindingsForCommand.addMouseListener(new MouseAdapter() {

			@Override
			public void mouseDoubleClick(MouseEvent mouseEvent) {
				update();
			}
		});

		tableBindingsForCommand
				.addSelectionListener(widgetSelectedAdapter(selectionEvent -> selectedTableBindingsForCommand()));

		final Group groupKeySequence = new Group(composite, SWT.SHADOW_NONE);
		gridLayout = new GridLayout();
		gridLayout.numColumns = 4;
		groupKeySequence.setLayout(gridLayout);
		gridData = new GridData(GridData.FILL_BOTH);
		groupKeySequence.setLayoutData(gridData);
		groupKeySequence.setText(Util.translateString(RESOURCE_BUNDLE, "groupKeySequence")); //$NON-NLS-1$
		final Label labelKeySequence = new Label(groupKeySequence, SWT.LEFT);
		gridData = new GridData();
		labelKeySequence.setLayoutData(gridData);
		labelKeySequence.setText(Util.translateString(RESOURCE_BUNDLE, "labelKeySequence")); //$NON-NLS-1$

		// The text widget into which the key strokes will be entered.
		textTriggerSequence = new Text(groupKeySequence, SWT.BORDER);
		// On MacOS X, this font will be changed by KeySequenceText
		textTriggerSequence.setFont(groupKeySequence.getFont());
		gridData = new GridData();
		gridData.horizontalSpan = 2;
		gridData.widthHint = 300;
		textTriggerSequence.setLayoutData(gridData);
		textTriggerSequence.addModifyListener(e -> update());
		textTriggerSequence.addFocusListener(new FocusListener() {
			@Override
			public void focusGained(FocusEvent e) {
				bindingService.setKeyFilterEnabled(false);
			}

			@Override
			public void focusLost(FocusEvent e) {
				bindingService.setKeyFilterEnabled(true);
			}
		});
		textTriggerSequence.addDisposeListener(e -> {
			if (!bindingService.isKeyFilterEnabled()) {
				bindingService.setKeyFilterEnabled(true);
			}
		});

		// The manager for the key sequence text widget.
		textTriggerSequenceManager = new KeySequenceText(textTriggerSequence);
		textTriggerSequenceManager.setKeyStrokeLimit(4);

		// Button for adding trapped key strokes
		final Button buttonAddKey = new Button(groupKeySequence, SWT.LEFT | SWT.ARROW);
		buttonAddKey.setToolTipText(Util.translateString(RESOURCE_BUNDLE, "buttonAddKey.ToolTipText")); //$NON-NLS-1$
		gridData = new GridData();
		gridData.heightHint = comboCategory.getTextHeight();
		buttonAddKey.setLayoutData(gridData);

		// Arrow buttons aren't normally added to the tab list. Let's fix that.
		final Control[] tabStops = groupKeySequence.getTabList();
		final ArrayList<Control> newTabStops = new ArrayList<>();
		for (Control tabStop : tabStops) {
			newTabStops.add(tabStop);
			if (textTriggerSequence.equals(tabStop)) {
				newTabStops.add(buttonAddKey);
			}
		}
		final Control[] newTabStopArray = newTabStops.toArray(new Control[newTabStops.size()]);
		groupKeySequence.setTabList(newTabStopArray);

		// Construct the menu to attach to the above button.
		final Menu menuButtonAddKey = new Menu(buttonAddKey);
		final Iterator<KeyStroke> trappedKeyItr = KeySequenceText.TRAPPED_KEYS.iterator();
		while (trappedKeyItr.hasNext()) {
			final KeyStroke trappedKey = trappedKeyItr.next();
			final MenuItem menuItem = new MenuItem(menuButtonAddKey, SWT.PUSH);
			menuItem.setText(trappedKey.format());
			menuItem.addSelectionListener(widgetSelectedAdapter(e -> {
				textTriggerSequenceManager.insert(trappedKey);
				textTriggerSequence.setFocus();
				textTriggerSequence.setSelection(textTriggerSequence.getTextLimit());
			}));
		}
		buttonAddKey.addSelectionListener(widgetSelectedAdapter(selectionEvent -> {
			Point buttonLocation = buttonAddKey.getLocation();
			buttonLocation = groupKeySequence.toDisplay(buttonLocation.x, buttonLocation.y);
			Point buttonSize = buttonAddKey.getSize();
			menuButtonAddKey.setLocation(buttonLocation.x, buttonLocation.y + buttonSize.y);
			menuButtonAddKey.setVisible(true);
		}));

		labelBindingsForTriggerSequence = new Label(groupKeySequence, SWT.LEFT);
		gridData = new GridData(GridData.VERTICAL_ALIGN_BEGINNING);
		gridData.verticalAlignment = GridData.FILL_VERTICAL;
		labelBindingsForTriggerSequence.setLayoutData(gridData);
		labelBindingsForTriggerSequence
				.setText(Util.translateString(RESOURCE_BUNDLE, "labelAssignmentsForKeySequence")); //$NON-NLS-1$
		tableBindingsForTriggerSequence = new Table(groupKeySequence,
				SWT.BORDER | SWT.FULL_SELECTION | SWT.H_SCROLL | SWT.V_SCROLL);
		tableBindingsForTriggerSequence.setHeaderVisible(true);
		gridData = new GridData(GridData.FILL_BOTH);
		gridData.heightHint = 60;
		gridData.horizontalSpan = 3;
		gridData.widthHint = isMac ? 620 : 520;
		tableBindingsForTriggerSequence.setLayoutData(gridData);
		tableColumnDelta = new TableColumn(tableBindingsForTriggerSequence, SWT.NULL, 0);
		tableColumnDelta.setResizable(false);
		tableColumnDelta.setText(Util.ZERO_LENGTH_STRING);
		tableColumnDelta.setWidth(20);
		tableColumnContext = new TableColumn(tableBindingsForTriggerSequence, SWT.NULL, 1);
		tableColumnContext.setResizable(true);
		tableColumnContext.setText(Util.translateString(RESOURCE_BUNDLE, "tableColumnContext")); //$NON-NLS-1$
		tableColumnContext.pack();
		tableColumnContext.setWidth(200);
		final TableColumn tableColumnCommand = new TableColumn(tableBindingsForTriggerSequence, SWT.NULL, 2);
		tableColumnCommand.setResizable(true);
		tableColumnCommand.setText(Util.translateString(RESOURCE_BUNDLE, "tableColumnCommand")); //$NON-NLS-1$
		tableColumnCommand.pack();
		tableColumnCommand.setWidth(300);

		tableBindingsForTriggerSequence.addMouseListener(new MouseAdapter() {

			@Override
			public void mouseDoubleClick(MouseEvent mouseEvent) {
				update();
			}
		});

		tableBindingsForTriggerSequence.addSelectionListener(
				widgetSelectedAdapter(selectionEvent -> selectedTableBindingsForTriggerSequence()));

		final Composite compositeContext = new Composite(composite, SWT.NULL);
		gridLayout = new GridLayout();
		gridLayout.numColumns = 3;
		compositeContext.setLayout(gridLayout);
		gridData = new GridData(GridData.FILL_HORIZONTAL);
		compositeContext.setLayoutData(gridData);
		final Label labelContext = new Label(compositeContext, SWT.LEFT);
		labelContext.setText(Util.translateString(RESOURCE_BUNDLE, "labelContext")); //$NON-NLS-1$
		comboContext = new Combo(compositeContext, SWT.READ_ONLY);
		gridData = new GridData();
		gridData.widthHint = 250;
		comboContext.setLayoutData(gridData);
		comboContext.setVisibleItemCount(ITEMS_TO_SHOW);

		comboContext.addSelectionListener(widgetSelectedAdapter(e -> update()));

		labelContextExtends = new Label(compositeContext, SWT.LEFT);
		gridData = new GridData(GridData.FILL_HORIZONTAL);
		labelContextExtends.setLayoutData(gridData);
		final Composite compositeButton = new Composite(composite, SWT.NULL);
		gridLayout = new GridLayout();
		gridLayout.marginHeight = 20;
		gridLayout.marginWidth = 0;
		gridLayout.numColumns = 3;
		compositeButton.setLayout(gridLayout);
		gridData = new GridData();
		compositeButton.setLayoutData(gridData);
		buttonAdd = new Button(compositeButton, SWT.CENTER | SWT.PUSH);
		gridData = new GridData();
		int widthHint = convertHorizontalDLUsToPixels(IDialogConstants.BUTTON_WIDTH);
		buttonAdd.setText(Util.translateString(RESOURCE_BUNDLE, "buttonAdd")); //$NON-NLS-1$
		gridData.widthHint = Math.max(widthHint, buttonAdd.computeSize(SWT.DEFAULT, SWT.DEFAULT, true).x) + 5;
		buttonAdd.setLayoutData(gridData);

		buttonAdd.addSelectionListener(widgetSelectedAdapter(selectionEvent -> selectedButtonAdd()));

		buttonRemove = new Button(compositeButton, SWT.CENTER | SWT.PUSH);
		gridData = new GridData();
		widthHint = convertHorizontalDLUsToPixels(IDialogConstants.BUTTON_WIDTH);
		buttonRemove.setText(Util.translateString(RESOURCE_BUNDLE, "buttonRemove")); //$NON-NLS-1$
		gridData.widthHint = Math.max(widthHint, buttonRemove.computeSize(SWT.DEFAULT, SWT.DEFAULT, true).x) + 5;
		buttonRemove.setLayoutData(gridData);

		buttonRemove.addSelectionListener(widgetSelectedAdapter(selectionEvent -> selectedButtonRemove()));

		buttonRestore = new Button(compositeButton, SWT.CENTER | SWT.PUSH);
		gridData = new GridData();
		widthHint = convertHorizontalDLUsToPixels(IDialogConstants.BUTTON_WIDTH);
		buttonRestore.setText(Util.translateString(RESOURCE_BUNDLE, "buttonRestore")); //$NON-NLS-1$
		gridData.widthHint = Math.max(widthHint, buttonRestore.computeSize(SWT.DEFAULT, SWT.DEFAULT, true).x) + 5;
		buttonRestore.setLayoutData(gridData);

		buttonRestore.addSelectionListener(widgetSelectedAdapter(selectionEvent -> selectedButtonRestore()));

		return composite;
	}

	/**
	 * Creates a tab on the main page for displaying an uneditable list of the
	 * current key bindings. This is intended as a discovery tool for new users. It
	 * shows all of the key bindings for the current key configuration, platform and
	 * locale.
	 *
	 * @param parent The tab folder in which the tab should be created; must not be
	 *               <code>null</code>.
	 * @return The newly created composite containing all of the controls; never
	 *         <code>null</code>.
	 * @since 3.1
	 */
	private Composite createViewTab(final TabFolder parent) {
		GridData gridData = null;
		int widthHint;

		// Create the composite for the tab.
		final Composite composite = new Composite(parent, SWT.NONE);
		composite.setLayoutData(new GridData(GridData.FILL_BOTH));
		composite.setLayout(new GridLayout());

		// Place a table inside the tab.
		tableBindings = new Table(composite, SWT.BORDER | SWT.FULL_SELECTION | SWT.H_SCROLL | SWT.V_SCROLL);
		tableBindings.setHeaderVisible(true);
		gridData = new GridData(GridData.FILL_BOTH);
		gridData.heightHint = 400;
		gridData.horizontalSpan = 2;
		tableBindings.setLayoutData(gridData);
		final TableColumn tableColumnCategory = new TableColumn(tableBindings, SWT.NONE, VIEW_CATEGORY_COLUMN_INDEX);
		tableColumnCategory.setText(SORTED_COLUMN_NAMES[VIEW_CATEGORY_COLUMN_INDEX]);
		tableColumnCategory.addSelectionListener(new SortOrderSelectionListener(VIEW_CATEGORY_COLUMN_INDEX));
		final TableColumn tableColumnCommand = new TableColumn(tableBindings, SWT.NONE, VIEW_COMMAND_COLUMN_INDEX);
		tableColumnCommand.setText(UNSORTED_COLUMN_NAMES[VIEW_COMMAND_COLUMN_INDEX]);
		tableColumnCommand.addSelectionListener(new SortOrderSelectionListener(VIEW_COMMAND_COLUMN_INDEX));
		final TableColumn tableColumnKeySequence = new TableColumn(tableBindings, SWT.NONE,
				VIEW_KEY_SEQUENCE_COLUMN_INDEX);
		tableColumnKeySequence.setText(UNSORTED_COLUMN_NAMES[VIEW_KEY_SEQUENCE_COLUMN_INDEX]);
		tableColumnKeySequence.addSelectionListener(new SortOrderSelectionListener(VIEW_KEY_SEQUENCE_COLUMN_INDEX));
		final TableColumn tableColumnContext = new TableColumn(tableBindings, SWT.NONE, VIEW_CONTEXT_COLUMN_INDEX);
		tableColumnContext.setText(UNSORTED_COLUMN_NAMES[VIEW_CONTEXT_COLUMN_INDEX]);
		tableColumnContext.addSelectionListener(new SortOrderSelectionListener(VIEW_CONTEXT_COLUMN_INDEX));
		tableBindings.addSelectionListener(widgetDefaultSelectedAdapter(e -> selectedTableKeyBindings()));

		// A composite for the buttons.
		final Composite buttonBar = new Composite(composite, SWT.NONE);
		buttonBar.setLayout(new GridLayout(2, false));
		gridData = new GridData();
		gridData.horizontalAlignment = GridData.END;
		buttonBar.setLayoutData(gridData);

		// A button for editing the current selection.
		final Button editButton = new Button(buttonBar, SWT.PUSH);
		gridData = new GridData();
		widthHint = convertHorizontalDLUsToPixels(IDialogConstants.BUTTON_WIDTH);
		editButton.setText(Util.translateString(RESOURCE_BUNDLE, "buttonEdit")); //$NON-NLS-1$
		gridData.widthHint = Math.max(widthHint, editButton.computeSize(SWT.DEFAULT, SWT.DEFAULT, true).x) + 5;
		editButton.setLayoutData(gridData);
		editButton.addSelectionListener(new SelectionListener() {

			@Override
			public void widgetDefaultSelected(final SelectionEvent event) {
				selectedTableKeyBindings();
			}

			@Override
			public void widgetSelected(SelectionEvent e) {
				widgetDefaultSelected(e);
			}
		});

		// A button for exporting the contents to a file.
		final Button buttonExport = new Button(buttonBar, SWT.PUSH);
		gridData = new GridData();
		widthHint = convertHorizontalDLUsToPixels(IDialogConstants.BUTTON_WIDTH);
		buttonExport.setText(Util.translateString(RESOURCE_BUNDLE, "buttonExport")); //$NON-NLS-1$
		gridData.widthHint = Math.max(widthHint, buttonExport.computeSize(SWT.DEFAULT, SWT.DEFAULT, true).x) + 5;
		buttonExport.setLayoutData(gridData);
		buttonExport.addSelectionListener(new SelectionListener() {

			@Override
			public void widgetDefaultSelected(final SelectionEvent event) {
				selectedButtonExport();
			}

			@Override
			public void widgetSelected(SelectionEvent e) {
				widgetDefaultSelected(e);
			}
		});

		return composite;
	}

	@Override
	protected IPreferenceStore doGetPreferenceStore() {
		return PrefUtil.getInternalPreferenceStore();
	}

	/**
	 * Allows the user to change the key bindings for a particular command. Switches
	 * the tab to the modify tab, and then selects the category and command that
	 * corresponds with the given command name. It then selects the given key
	 * sequence and gives focus to the key sequence text widget.
	 *
	 * @param binding The binding to be edited; if <code>null</code>, then just
	 *                switch to the modify tab. If the <code>binding</code> does not
	 *                correspond to anything in the keys preference page, then this
	 *                also just switches to the modify tab.
	 * @since 3.1
	 */
	public void editBinding(final Binding binding) {
		// Switch to the modify tab.
		tabFolder.setSelection(TAB_INDEX_MODIFY);

		// If there is no command name, stop here.
		if (binding == null) {
			return;
		}

		/*
		 * Get the corresponding category and command names. If either is undefined,
		 * then we can just stop now. We won't be able to find their name.
		 */
		final ParameterizedCommand command = binding.getParameterizedCommand();
		String categoryName = null;
		String commandName = null;
		try {
			categoryName = command.getCommand().getCategory().getName();
			commandName = command.getName();
		} catch (final NotDefinedException e) {
			return; // no name
		}

		// Update the category combo box.
		final String[] categoryNames = comboCategory.getItems();
		int i = 0;
		for (; i < categoryNames.length; i++) {
			if (categoryName.equals(categoryNames[i])) {
				break;
			}
		}
		if (i >= comboCategory.getItemCount()) {
			// Couldn't find the category, so abort.
			return;
		}
		comboCategory.select(i);

		// Update the commands combo box.
		updateComboCommand();

		// Update the command combo box.
		final String[] commandNames = comboCommand.getItems();
		int j = 0;
		for (; j < commandNames.length; j++) {
			if (commandName.equals(commandNames[j])) {
				if (comboCommand.getSelectionIndex() != j) {
					comboCommand.select(j);
				}
				break;
			}
		}
		if (j >= comboCommand.getItemCount()) {
			// Couldn't find the command, so just select the first and then stop
			if (comboCommand.getSelectionIndex() != 0) {
				comboCommand.select(0);
			}
			update();
			return;
		}

		/*
		 * Update and validate the state of the modify tab in response to these
		 * selection changes.
		 */
		update();

		// Select the right key binding, if possible.
		final TableItem[] items = tableBindingsForCommand.getItems();
		int k = 0;
		for (; k < items.length; k++) {
			final String currentKeySequence = items[k].getText(2);
			if (binding.getTriggerSequence().format().equals(currentKeySequence)) {
				break;
			}
		}
		if (k < tableBindingsForCommand.getItemCount()) {
			tableBindingsForCommand.select(k);
			tableBindingsForCommand.notifyListeners(SWT.Selection, null);
			textTriggerSequence.setFocus();
		}
	}

	/**
	 * Returns the identifier for the currently selected category.
	 *
	 * @return The selected category; <code>null</code> if none.
	 */
	private String getCategoryId() {
		return !commandIdsByCategoryId.containsKey(null) || comboCategory.getSelectionIndex() > 0
				? categoryIdsByUniqueName.get(comboCategory.getText())
				: null;
	}

	/**
	 * Returns the identifier for the currently selected context.
	 *
	 * @return The selected context; <code>null</code> if none.
	 */
	private String getContextId() {
		return comboContext.getSelectionIndex() >= 0 ? contextIdsByUniqueName.get(comboContext.getText())
				: null;
	}

	/**
	 * Returns the current trigger sequence.
	 *
	 * @return The trigger sequence; may be empty, but never <code>null</code>.
	 */
	private KeySequence getKeySequence() {
		return textTriggerSequenceManager.getKeySequence();
	}

	/**
	 * Returns the currently-selected fully-parameterized command.
	 *
	 * @return The selected fully-parameterized command; <code>null</code> if none.
	 */
	private ParameterizedCommand getParameterizedCommand() {
		final int selectionIndex = comboCommand.getSelectionIndex();
		if ((selectionIndex >= 0) && (commands != null) && (selectionIndex < commands.length)) {
			return commands[selectionIndex];
		}

		return null;
	}

	/**
	 * Returns the identifier for the currently selected scheme.
	 *
	 * @return The selected scheme; <code>null</code> if none.
	 */
	private String getSchemeId() {
		return comboScheme.getSelectionIndex() >= 0 ? schemeIdsByUniqueName.get(comboScheme.getText()) : null;
	}

	@Override
	public void init(final IWorkbench workbench) {
		activityManager = workbench.getActivitySupport().getActivityManager();
		bindingService = workbench.getService(IBindingService.class);
		commandService = workbench.getService(ICommandService.class);
		contextService = workbench.getService(IContextService.class);
	}

	/**
	 * Checks whether the activity manager knows anything about this command
	 * identifier. If the activity manager is currently filtering this command, then
	 * it does not appear in the user interface.
	 *
	 * @param command The command which should be checked against the activities;
	 *                must not be <code>null</code>.
	 * @return <code>true</code> if the command identifier is not filtered;
	 *         <code>false</code> if it is
	 */
	private boolean isActive(final Command command) {
		return activityManager.getIdentifier(command.getId()).isEnabled();
	}

	/**
	 * Logs the given exception, and opens an error dialog saying that something
	 * went wrong. The exception is assumed to have something to do with the
	 * preference store.
	 *
	 * @param exception The exception to be logged; must not be <code>null</code>.
	 */
	private void logPreferenceStoreException(final Throwable exception) {
		final String message = Util.translateString(RESOURCE_BUNDLE, "PreferenceStoreError.Message"); //$NON-NLS-1$
		String exceptionMessage = exception.getMessage();
		if (exceptionMessage == null) {
			exceptionMessage = message;
		}
		final IStatus status = new Status(IStatus.ERROR, WorkbenchPlugin.PI_WORKBENCH, 0, exceptionMessage, exception);
		WorkbenchPlugin.log(message, status);
		StatusUtil.handleStatus(message, exception, StatusManager.SHOW);
	}

	@Override
	public boolean performCancel() {
		// Save the selected tab for future reference.
		persistSelectedTab();

		return super.performCancel();
	}

	@Override
	protected void performDefaults() {
		// Ask the user to confirm
		final String title = Util.translateString(RESOURCE_BUNDLE, "restoreDefaultsMessageBoxText"); //$NON-NLS-1$
		final String message = Util.translateString(RESOURCE_BUNDLE, "restoreDefaultsMessageBoxMessage"); //$NON-NLS-1$
		final boolean confirmed = MessageDialog.open(MessageDialog.CONFIRM, getShell(), title, message, SWT.SHEET);

		if (confirmed) {
			// Fix the scheme in the local changes.
			final String defaultSchemeId = bindingService.getDefaultSchemeId();
			final Scheme defaultScheme = localChangeManager.getScheme(defaultSchemeId);
			try {
				localChangeManager.setActiveScheme(defaultScheme);
			} catch (final NotDefinedException e) {
				// At least we tried....
			}

			// Fix the bindings in the local changes.
			final Binding[] currentBindings = localChangeManager.getBindings();
			final int currentBindingsLength = currentBindings.length;
			final Set<Binding> trimmedBindings = new HashSet<>();
			for (int i = 0; i < currentBindingsLength; i++) {
				final Binding binding = currentBindings[i];
				if (binding.getType() != Binding.USER) {
					trimmedBindings.add(binding);
				}
			}
			final Binding[] trimmedBindingArray = trimmedBindings
					.toArray(new Binding[trimmedBindings.size()]);
			localChangeManager.setBindings(trimmedBindingArray);

			// Apply the changes.
			try {
				bindingService.savePreferences(defaultScheme, trimmedBindingArray);
			} catch (final IOException e) {
				logPreferenceStoreException(e);
			}
		}

		setScheme(localChangeManager.getActiveScheme()); // update the scheme
		update(true);
		super.performDefaults();
	}

	@Override
	public boolean performOk() {
		// Save the preferences.
		try {
			bindingService.savePreferences(localChangeManager.getActiveScheme(), localChangeManager.getBindings());
		} catch (final IOException e) {
			logPreferenceStoreException(e);
		}

		// Save the selected tab for future reference.
		persistSelectedTab();

		return super.performOk();
	}

	/**
	 * Remembers the currently selected tab for when the preference page next opens.
	 */
	private void persistSelectedTab() {
		final IPreferenceStore store = getPreferenceStore();
		store.setValue(IPreferenceConstants.KEYS_PREFERENCE_SELECTED_TAB, tabFolder.getSelectionIndex());
	}

	/**
	 * Handles the selection event on the add button. This removes all user-defined
	 * bindings matching the given key sequence, scheme and context. It then adds a
	 * new binding with the current selections.
	 */
	private void selectedButtonAdd() {
		final ParameterizedCommand command = getParameterizedCommand();
		final String contextId = getContextId();
		final String schemeId = getSchemeId();
		final KeySequence keySequence = getKeySequence();
		localChangeManager.removeBindings(keySequence, schemeId, contextId, null, null, null, Binding.USER);
		localChangeManager
				.addBinding(new KeyBinding(keySequence, command, schemeId, contextId, null, null, null, Binding.USER));
		update(true);
	}

	/**
	 * Provides a facility for exporting the viewable list of key bindings to a
	 * file. Currently, this only supports exporting to a list of comma-separated
	 * values. The user is prompted for which file should receive our bounty.
	 *
	 * @since 3.1
	 */
	private void selectedButtonExport() {
		final FileDialog fileDialog = new FileDialog(getShell(), SWT.SAVE | SWT.SHEET);
		fileDialog.setFilterExtensions(new String[] { "*.csv" }); //$NON-NLS-1$
		fileDialog.setFilterNames(new String[] { Util.translateString(RESOURCE_BUNDLE, "csvFilterName") }); //$NON-NLS-1$
		final String filePath = fileDialog.open();
		if (filePath == null) {
			return;
		}

		final SafeRunnable runnable = new SafeRunnable() {
			@Override
			public void run() throws IOException {
				try (Writer fileWriter = new BufferedWriter(new FileWriter(filePath))) {
					final TableItem[] items = tableBindings.getItems();
					final int numColumns = tableBindings.getColumnCount();
					for (final TableItem item : items) {
						for (int j = 0; j < numColumns; j++) {
							String buf = Util.replaceAll(item.getText(j), "\"", //$NON-NLS-1$
									"\"\""); //$NON-NLS-1$
							fileWriter.write("\"" + buf + "\""); //$NON-NLS-1$//$NON-NLS-2$
							if (j < numColumns - 1) {
								fileWriter.write(',');
							}
						}
						fileWriter.write(System.lineSeparator());
					}

				}
			}
		};
		SafeRunner.run(runnable);
	}

	/**
	 * Handles the selection event on the remove button. This removes all
	 * user-defined bindings matching the given key sequence, scheme and context. It
	 * then adds a new deletion binding for the selected trigger sequence.
	 */
	private void selectedButtonRemove() {
		final String contextId = getContextId();
		final String schemeId = getSchemeId();
		final KeySequence keySequence = getKeySequence();
		localChangeManager.removeBindings(keySequence, schemeId, contextId, null, null, null, Binding.USER);
		localChangeManager
				.addBinding(new KeyBinding(keySequence, null, schemeId, contextId, null, null, null, Binding.USER));
		update(true);
	}

	/**
	 * Handles the selection event on the restore button. This removes all
	 * user-defined bindings matching the given key sequence, scheme and context.
	 */
	private void selectedButtonRestore() {
		String contextId = getContextId();
		String schemeId = getSchemeId();
		KeySequence keySequence = getKeySequence();
		localChangeManager.removeBindings(keySequence, schemeId, contextId, null, null, null, Binding.USER);
		update(true);
	}

	/**
	 * Updates the local managers active scheme, and then updates the interface.
	 */
	private void selectedComboScheme() {
		final String activeSchemeId = getSchemeId();
		final Scheme activeScheme = localChangeManager.getScheme(activeSchemeId);
		try {
			localChangeManager.setActiveScheme(activeScheme);
		} catch (final NotDefinedException e) {
			// Oh, well.
		}
		update(true);
	}

	/**
	 * Handles the selection event on the table containing the bindings for a
	 * particular command. This updates the context and trigger sequence based on
	 * the selected binding.
	 */
	private void selectedTableBindingsForCommand() {
		final int selection = tableBindingsForCommand.getSelectionIndex();
		if ((selection >= 0) && (selection < tableBindingsForCommand.getItemCount())) {
			final TableItem item = tableBindingsForCommand.getItem(selection);
			final KeyBinding binding = (KeyBinding) item.getData(ITEM_DATA_KEY);
			setContextId(binding.getContextId());
			setKeySequence(binding.getKeySequence());
		}

		update();
	}

	/**
	 * Handles the selection event on the table containing the bindings for a
	 * particular trigger sequence. This updates the context based on the selected
	 * binding.
	 */
	private void selectedTableBindingsForTriggerSequence() {
		final int selection = tableBindingsForTriggerSequence.getSelectionIndex();
		if ((selection >= 0) && (selection < tableBindingsForTriggerSequence.getItemCount())) {
			final TableItem item = tableBindingsForTriggerSequence.getItem(selection);
			final Binding binding = (Binding) item.getData(ITEM_DATA_KEY);
			setContextId(binding.getContextId());
		}

		update();
	}

	/**
	 * Responds to some kind of trigger on the View tab by taking the current
	 * selection on the key bindings table and selecting the appropriate items in
	 * the Modify tab.
	 *
	 * @since 3.1
	 */
	private void selectedTableKeyBindings() {
		final int selectionIndex = tableBindings.getSelectionIndex();
		if (selectionIndex != -1) {
			final TableItem item = tableBindings.getItem(selectionIndex);
			final Binding binding = (Binding) item.getData(BINDING_KEY);
			editBinding(binding);

		} else {
			editBinding(null);
		}
	}

	/**
	 * Changes the selected context name in the context combo box. The context
	 * selected is either the one matching the identifier provided (if possible), or
	 * the default context identifier. If no matching name can be found in the
	 * combo, then the first item is selected.
	 *
	 * @param contextId The context identifier for the context to be selected in the
	 *                  combo box; may be <code>null</code>.
	 */
	private void setContextId(final String contextId) {
		// Clear the current selection.
		comboContext.clearSelection();
		comboContext.deselectAll();

		// Figure out which name to look for.
		String contextName = contextUniqueNamesById.get(contextId);
		if (contextName == null) {
			contextName = contextUniqueNamesById.get(IContextIds.CONTEXT_ID_WINDOW);
		}
		if (contextName == null) {
			contextName = Util.ZERO_LENGTH_STRING;
		}

		// Scan the list for the selection we're looking for.
		final String[] items = comboContext.getItems();
		boolean found = false;
		for (int i = 0; i < items.length; i++) {
			if (contextName.equals(items[i])) {
				comboContext.select(i);
				found = true;
				break;
			}
		}

		// If we didn't find an item, then set the first item as selected.
		if ((!found) && (items.length > 0)) {
			comboContext.select(0);
		}
	}

	/**
	 * Sets the current trigger sequence.
	 *
	 * @param keySequence The trigger sequence; may be <code>null</code>.
	 */
	private void setKeySequence(final KeySequence keySequence) {
		textTriggerSequenceManager.setKeySequence(keySequence);
	}

	/**
	 * Changes the selection in the command combo box.
	 *
	 * @param command The fully-parameterized command to select; may be
	 *                <code>null</code>.
	 */
	private void setParameterizedCommand(final ParameterizedCommand command) {
		int i = 0;
		if (commands != null) {
			final int commandCount = commands.length;
			for (; i < commandCount; i++) {
				if (commands[i].equals(command)) {
					if ((comboCommand.getSelectionIndex() != i) && (i < comboCommand.getItemCount())) {
						comboCommand.select(i);
					}
					break;
				}
			}
			if ((i >= comboCommand.getItemCount()) && (comboCommand.getSelectionIndex() != 0)) {
				comboCommand.select(0);
			}
		}
	}

	/**
	 * Sets the currently selected scheme
	 *
	 * @param scheme The scheme to select; may be <code>null</code>.
	 */
	private void setScheme(final Scheme scheme) {
		comboScheme.clearSelection();
		comboScheme.deselectAll();
		final String schemeUniqueName = schemeUniqueNamesById.get(scheme.getId());

		if (schemeUniqueName != null) {
			final String items[] = comboScheme.getItems();

			for (int i = 0; i < items.length; i++) {
				if (schemeUniqueName.equals(items[i])) {
					comboScheme.select(i);
					break;
				}
			}
		}
	}

	/**
	 * Builds the internal look-up tables before allowing the page to become
	 * visible.
	 */
	@Override
	public void setVisible(final boolean visible) {
		if (visible == true) {
			Map<String, Set<Context>> contextsByName = new HashMap<>();

			for (Iterator<String> iterator = contextService.getDefinedContextIds().iterator(); iterator.hasNext();) {
				Context context = contextService.getContext(iterator.next());
				try {
					String name = context.getName();
					Set<Context> contexts = contextsByName.get(name);

					if (contexts == null) {
						contexts = new HashSet<>();
						contextsByName.put(name, contexts);
					}

					contexts.add(context);
				} catch (final NotDefinedException e) {
					// Do nothing.
				}
			}

			Map<String, Collection<Command>> commandsByName = new HashMap<>();

			for (Iterator<String> iterator = commandService.getDefinedCommandIds().iterator(); iterator.hasNext();) {
				Command command = commandService.getCommand(iterator.next());
				if (!isActive(command)) {
					continue;
				}

				try {
					String name = command.getName();
					Collection<Command> commands = commandsByName.get(name);

					if (commands == null) {
						commands = new HashSet<>();
						commandsByName.put(name, commands);
					}

					commands.add(command);
				} catch (NotDefinedException eNotDefined) {
					// Do nothing
				}
			}

			// moved here to allow us to remove any empty categories
			commandIdsByCategoryId = new HashMap<>();

			for (Iterator<String> iterator = commandService.getDefinedCommandIds().iterator(); iterator.hasNext();) {
				final Command command = commandService.getCommand(iterator.next());
				if (!isActive(command)) {
					continue;
				}

				try {
					String categoryId = command.getCategory().getId();
					Set<String> commandIds = commandIdsByCategoryId.get(categoryId);

					if (commandIds == null) {
						commandIds = new HashSet<>();
						commandIdsByCategoryId.put(categoryId, commandIds);
					}

					commandIds.add(command.getId());
				} catch (NotDefinedException eNotDefined) {
					// Do nothing
				}
			}

			Map<String, Set<Category>> categoriesByName = new HashMap<>();

			for (Iterator<String> iterator = commandService.getDefinedCategoryIds().iterator(); iterator.hasNext();) {
				Category category = commandService.getCategory(iterator.next());

				try {
					if (commandIdsByCategoryId.containsKey(category.getId())) {
						String name = category.getName();
						Set<Category> categories = categoriesByName.get(name);

						if (categories == null) {
							categories = new HashSet<>();
							categoriesByName.put(name, categories);
						}

						categories.add(category);
					}
				} catch (NotDefinedException eNotDefined) {
					// Do nothing
				}
			}

			Map<String, Set<Scheme>> schemesByName = new HashMap<>();

			final Scheme[] definedSchemes = bindingService.getDefinedSchemes();
			for (final Scheme scheme : definedSchemes) {
				try {
					String name = scheme.getName();
					Set<Scheme> schemes = schemesByName.get(name);

					if (schemes == null) {
						schemes = new HashSet<>();
						schemesByName.put(name, schemes);
					}

					schemes.add(scheme);
				} catch (final NotDefinedException e) {
					// Do nothing.
				}
			}

			contextIdsByUniqueName = new HashMap<>();
			contextUniqueNamesById = new HashMap<>();

			for (Map.Entry<String, Set<Context>> entry : contextsByName.entrySet()) {
				String name = entry.getKey();
				Set<Context> contexts = entry.getValue();
				Iterator<Context> iterator2 = contexts.iterator();

				if (contexts.size() == 1) {
					Context context = iterator2.next();
					contextIdsByUniqueName.put(name, context.getId());
					contextUniqueNamesById.put(context.getId(), name);
				} else {
					while (iterator2.hasNext()) {
						Context context = iterator2.next();
						String uniqueName = MessageFormat.format(Util.translateString(RESOURCE_BUNDLE, "uniqueName"), //$NON-NLS-1$
							name, context.getId());
						contextIdsByUniqueName.put(uniqueName, context.getId());
						contextUniqueNamesById.put(context.getId(), uniqueName);
					}
				}
			}

			categoryIdsByUniqueName = new HashMap<>();
			categoryUniqueNamesById = new HashMap<>();

			for (Map.Entry<String, Set<Category>> entry : categoriesByName.entrySet()) {
				String name = entry.getKey();
				Set<Category> categories = entry.getValue();
				Iterator<Category> iterator2 = categories.iterator();

				if (categories.size() == 1) {
					Category category = iterator2.next();
					categoryIdsByUniqueName.put(name, category.getId());
					categoryUniqueNamesById.put(category.getId(), name);
				} else {
					while (iterator2.hasNext()) {
						Category category = iterator2.next();
						String uniqueName = MessageFormat.format(Util.translateString(RESOURCE_BUNDLE, "uniqueName"), //$NON-NLS-1$
							new Object[] { name, category.getId() });
						categoryIdsByUniqueName.put(uniqueName, category.getId());
						categoryUniqueNamesById.put(category.getId(), uniqueName);
					}
				}
			}

			schemeIdsByUniqueName = new HashMap<>();
			schemeUniqueNamesById = new HashMap<>();

			for (Map.Entry<String, Set<Scheme>> entry : schemesByName.entrySet()) {
				String name = entry.getKey();
				Set<Scheme> keyConfigurations = entry.getValue();
				Iterator<Scheme> iterator2 = keyConfigurations.iterator();

				if (keyConfigurations.size() == 1) {
					Scheme scheme = iterator2.next();
					schemeIdsByUniqueName.put(name, scheme.getId());
					schemeUniqueNamesById.put(scheme.getId(), name);
				} else {
					while (iterator2.hasNext()) {
						Scheme scheme = iterator2.next();
						String uniqueName = MessageFormat.format(Util.translateString(RESOURCE_BUNDLE, "uniqueName"), //$NON-NLS-1$
							new Object[] { name, scheme.getId() });
						schemeIdsByUniqueName.put(uniqueName, scheme.getId());
						schemeUniqueNamesById.put(scheme.getId(), uniqueName);
					}
				}
			}

			Scheme activeScheme = bindingService.getActiveScheme();

			// Make an internal copy of the binding manager, for local changes.
			try {
				for (final Scheme scheme : definedSchemes) {
					final Scheme copy = localChangeManager.getScheme(scheme.getId());
					copy.define(scheme.getName(), scheme.getDescription(), scheme.getParentId());
				}
				localChangeManager.setActiveScheme(bindingService.getActiveScheme());
			} catch (final NotDefinedException e) {
				throw new Error("There is a programmer error in the keys preference page"); //$NON-NLS-1$
			}
			localChangeManager.setLocale(bindingService.getLocale());
			localChangeManager.setPlatform(bindingService.getPlatform());
			localChangeManager.setBindings(bindingService.getBindings());

			// Populate the category combo box.
			List<String> categoryNames = new ArrayList<>(categoryIdsByUniqueName.keySet());
			categoryNames.sort(Collator.getInstance());
			if (commandIdsByCategoryId.containsKey(null)) {
				categoryNames.add(0, Util.translateString(RESOURCE_BUNDLE, "other")); //$NON-NLS-1$
			}
			comboCategory.setItems(categoryNames.toArray(new String[categoryNames.size()]));
			comboCategory.clearSelection();
			comboCategory.deselectAll();
			if (commandIdsByCategoryId.containsKey(null) || !categoryNames.isEmpty()) {
				comboCategory.select(0);
			}

			// Populate the scheme combo box.
			List<String> schemeNames = new ArrayList<>(schemeIdsByUniqueName.keySet());
			schemeNames.sort(Collator.getInstance());
			comboScheme.setItems(schemeNames.toArray(new String[schemeNames.size()]));
			setScheme(activeScheme);

			// Update the entire page.
			update(true);
		}

		super.setVisible(visible);
	}

	/**
	 * Updates the entire preference page -- except the view tab -- based on current
	 * selection sate. This preference page is written so that everything can be
	 * made consistent simply by inspecting the state of its widgets. A change is
	 * triggered by the user, and an event is fired. The event triggers an update.
	 * It is possible for extra work to be done by this page before calling update.
	 */
	private void update() {
		update(false);
	}

	/**
	 * Updates the entire preference page based on current changes. This preference
	 * page is written so that everything can be made consistent simply by
	 * inspecting the state of its widgets. A change is triggered by the user, and
	 * an event is fired. The event triggers an update. It is possible for extra
	 * work to be done by this page before calling update.
	 *
	 * @param updateViewTab Whether the view tab should be updated as well.
	 */
	private void update(final boolean updateViewTab) {
		if (updateViewTab) {
			updateViewTab();
		}
		updateComboCommand();
		updateComboContext();
		final TriggerSequence triggerSequence = getKeySequence();
		updateTableBindingsForTriggerSequence(triggerSequence);
		final ParameterizedCommand command = getParameterizedCommand();
		updateTableBindingsForCommand(command);
		final String contextId = getContextId();
		updateSelection(tableBindingsForTriggerSequence, contextId, triggerSequence);
		updateSelection(tableBindingsForCommand, contextId, triggerSequence);
		updateLabelSchemeExtends();
		updateLabelContextExtends();
		updateEnabled(triggerSequence, command);
	}

	/**
	 * Updates the contents of the commands combo box, based on the current
	 * selection in the category combo box.
	 */
	private void updateComboCommand() {
		// Remember the current selection, so we can restore it later.
		final ParameterizedCommand command = getParameterizedCommand();

		// Figure out where command identifiers apply to the selected category.
		final String categoryId = getCategoryId();
		Set<String> commandIds = commandIdsByCategoryId.get(categoryId);
		if (commandIds == null) {
			commandIds = Collections.EMPTY_SET;
		}

		/*
		 * Generate an array of parameterized commands based on these identifiers. The
		 * parameterized commands will be sorted based on their names.
		 */
		List<ParameterizedCommand> commands = new ArrayList<>();
		final Iterator<String> commandIdItr = commandIds.iterator();
		while (commandIdItr.hasNext()) {
			final String currentCommandId = commandIdItr.next();
			final Command currentCommand = commandService.getCommand(currentCommandId);
			try {
				commands.addAll(ParameterizedCommand.generateCombinations(currentCommand));
			} catch (final NotDefinedException e) {
				// It is safe to just ignore undefined commands.
			}
		}

		// sort the commands with a collator, so they appear in the
		// combo correctly
		commands = sortParameterizedCommands(commands);

		final int commandCount = commands.size();
		this.commands = commands.toArray(new ParameterizedCommand[commandCount]);

		/*
		 * Generate an array of command names based on this array of parameterized
		 * commands.
		 */
		final String[] commandNames = new String[commandCount];
		for (int i = 0; i < commandCount; i++) {
			try {
				commandNames[i] = this.commands[i].getName();
			} catch (final NotDefinedException e) {
				throw new Error("Concurrent modification of the command's defined state"); //$NON-NLS-1$
			}
		}

		/*
		 * Copy the command names into the combo box, but only if they've changed. We do
		 * this to try to avoid unnecessary calls out to the operating system, as well
		 * as to defend against bugs in SWT's event mechanism.
		 */
		final String[] currentItems = comboCommand.getItems();
		if (!Arrays.equals(currentItems, commandNames)) {
			comboCommand.setItems(commandNames);
		}

		// Try to restore the selection.
		setParameterizedCommand(command);

		/*
		 * Just to be extra careful, make sure that we have a selection at this point.
		 * This line could probably be removed, but it makes the code a bit more robust.
		 */
		if ((comboCommand.getSelectionIndex() == -1) && (commandCount > 0)) {
			comboCommand.select(0);
		}
	}

	/**
	 * Sort the commands using the correct language.
	 *
	 * @param commands the List of ParameterizedCommands
	 * @return The sorted List
	 */
	private List<ParameterizedCommand> sortParameterizedCommands(List<ParameterizedCommand> commands) {
		final Collator collator = Collator.getInstance();

		// this comparator is based on the ParameterizedCommands#compareTo(*)
		// method, but uses the collator.
		Comparator<ParameterizedCommand> comparator = (o1, o2) -> {
			String name1 = null;
			String name2 = null;
			try {
				name1 = o1.getName();
			} catch (NotDefinedException e1) {
				return -1;
			}
			try {
				name2 = o2.getName();
			} catch (NotDefinedException e2) {
				return 1;
			}
			int rc = collator.compare(name1, name2);
			if (rc != 0) {
				return rc;
			}

			String id1 = o1.getId();
			String id2 = o2.getId();
			return collator.compare(id1, id2);
		};
		commands.sort(comparator);
		return commands;
	}

	/**
	 * Updates the contents of the context combo box, as well as its selection.
	 */
	private void updateComboContext() {
		final String contextId = getContextId();
		final Map<String, String> contextIdsByName = new HashMap<>(contextIdsByUniqueName);

		final List<String> contextNames = new ArrayList<>(contextIdsByName.keySet());
		contextNames.sort(Collator.getInstance());

		comboContext.setItems(contextNames.toArray(new String[contextNames.size()]));
		setContextId(contextId);

		if (comboContext.getSelectionIndex() == -1 && !contextNames.isEmpty()) {
			comboContext.select(0);
		}
	}

	/**
	 * Updates the enabled state of the various widgets on this page. The decision
	 * is based on the current trigger sequence and the currently selected command.
	 *
	 * @param triggerSequence The current trigger sequence; may be empty, but never
	 *                        <code>null</code>.
	 * @param command         The currently selected command, if any;
	 *                        <code>null</code> otherwise.
	 */
	private void updateEnabled(final TriggerSequence triggerSequence, final ParameterizedCommand command) {
		final boolean commandSelected = command != null;
		labelBindingsForCommand.setEnabled(commandSelected);
		tableBindingsForCommand.setEnabled(commandSelected);

		final boolean triggerSequenceSelected = !triggerSequence.isEmpty();
		labelBindingsForTriggerSequence.setEnabled(triggerSequenceSelected);
		tableBindingsForTriggerSequence.setEnabled(triggerSequenceSelected);

		/*
		 * TODO Do some better button enablement.
		 */
		final boolean buttonsEnabled = commandSelected && triggerSequenceSelected;
		buttonAdd.setEnabled(buttonsEnabled);
		buttonRemove.setEnabled(buttonsEnabled);
		buttonRestore.setEnabled(buttonsEnabled);
	}

	/**
	 * Updates the label next to the context that says "extends" if the context is a
	 * child of another context. If the context is not a child of another context,
	 * then the label is simply blank.
	 */
	private void updateLabelContextExtends() {
		final String contextId = getContextId();

		if (contextId != null) {
			final Context context = contextService.getContext(getContextId());
			if (context.isDefined()) {
				try {
					final String parentId = context.getParentId();
					if (parentId != null) {
						final String name = contextUniqueNamesById.get(parentId);
						if (name != null) {
							labelContextExtends
									.setText(MessageFormat.format(Util.translateString(RESOURCE_BUNDLE, "extends"), //$NON-NLS-1$
											name));
							return;
						}
					}
				} catch (final NotDefinedException e) {
					// Do nothing
				}
			}
		}

		labelContextExtends.setText(Util.ZERO_LENGTH_STRING);
	}

	/**
	 * Updates the label next to the scheme that says "extends" if the scheme is a
	 * child of another scheme. If the scheme is not a child of another scheme, then
	 * the label is simply blank.
	 */
	private void updateLabelSchemeExtends() {
		final String schemeId = getSchemeId();

		if (schemeId != null) {
			final Scheme scheme = bindingService.getScheme(schemeId);
			try {
				final String name = schemeUniqueNamesById.get(scheme.getParentId());
				if (name != null) {
					labelSchemeExtends.setText(MessageFormat.format(Util.translateString(RESOURCE_BUNDLE, "extends"), //$NON-NLS-1$
							name));
					return;
				}
			} catch (final NotDefinedException e) {
				// Do nothing
			}
		}

		labelSchemeExtends.setText(Util.ZERO_LENGTH_STRING);
	}

	/**
	 * Tries to select the correct entry in table based on the currently selected
	 * context and trigger sequence. If the table hasn't really changed, then this
	 * method is essentially trying to restore the selection. If it has changed,
	 * then it is trying to select the most entry based on the context.
	 *
	 * @param table           The table to be changed; must not be
	 *                        <code>null</code>.
	 * @param contextId       The currently selected context; should not be
	 *                        <code>null</code>.
	 * @param triggerSequence The current trigger sequence; should not be
	 *                        <code>null</code>.
	 */
	private void updateSelection(final Table table, final String contextId, final TriggerSequence triggerSequence) {
		if (table.getSelectionCount() > 1) {
			table.deselectAll();
		}

		final TableItem[] items = table.getItems();
		int selection = -1;
		for (int i = 0; i < items.length; i++) {
			final Binding binding = (Binding) items[i].getData(ITEM_DATA_KEY);
			if ((Objects.equals(contextId, binding.getContextId()))
					&& (Objects.equals(triggerSequence, binding.getTriggerSequence()))) {
				selection = i;
				break;
			}
		}

		if (selection != -1) {
			table.select(selection);
		}
	}

	/**
	 * Updates the contents of the table showing the bindings for the currently
	 * selected command. The selection is destroyed by this process.
	 *
	 * @param parameterizedCommand The currently selected fully-parameterized
	 *                             command; may be <code>null</code>.
	 */
	private void updateTableBindingsForCommand(final ParameterizedCommand parameterizedCommand) {
		// Clear the table of existing items.
		tableBindingsForCommand.removeAll();

		// Add each of the bindings, if the command identifier matches.
		final Collection<Binding> bindings = localChangeManager.getActiveBindingsDisregardingContextFlat();
		final Iterator<Binding> bindingItr = bindings.iterator();
		while (bindingItr.hasNext()) {
			final Binding binding = bindingItr.next();
			if (!Objects.equals(parameterizedCommand, binding.getParameterizedCommand())) {
				continue; // binding does not match
			}

			final TableItem tableItem = new TableItem(tableBindingsForCommand, SWT.NULL);
			tableItem.setData(ITEM_DATA_KEY, binding);

			/*
			 * Set the associated image based on the type of binding. Either it is a user
			 * binding or a system binding.
			 *
			 * TODO Identify more image types.
			 */
			if (binding.getType() == Binding.SYSTEM) {
				tableItem.setImage(0, IMAGE_BLANK);
			} else {
				tableItem.setImage(0, IMAGE_CHANGE);
			}

			String contextName = contextUniqueNamesById.get(binding.getContextId());
			if (contextName == null) {
				contextName = Util.ZERO_LENGTH_STRING;
			}
			tableItem.setText(1, contextName);
			tableItem.setText(2, binding.getTriggerSequence().format());
		}
	}

	/**
	 * Updates the contents of the table showing the bindings for the current
	 * trigger sequence. The selection is destroyed by this process.
	 *
	 * @param triggerSequence The current trigger sequence; may be <code>null</code>
	 *                        or empty.
	 */
	private void updateTableBindingsForTriggerSequence(final TriggerSequence triggerSequence) {
		// Clear the table of its existing items.
		tableBindingsForTriggerSequence.removeAll();

		// Get the collection of bindings for the current command.
		final Map<TriggerSequence, Collection<Binding>> activeBindings = localChangeManager.getActiveBindingsDisregardingContext();
		final Collection<Binding> bindings = activeBindings.get(triggerSequence);
		if (bindings == null) {
			return;
		}

		// Add each of the bindings.
		final Iterator<Binding> bindingItr = bindings.iterator();
		while (bindingItr.hasNext()) {
			final Binding binding = bindingItr.next();
			final Context context = contextService.getContext(binding.getContextId());
			final ParameterizedCommand parameterizedCommand = binding.getParameterizedCommand();
			final Command command = parameterizedCommand.getCommand();
			if ((!context.isDefined()) && (!command.isDefined())) {
				continue;
			}

			final TableItem tableItem = new TableItem(tableBindingsForTriggerSequence, SWT.NULL);
			tableItem.setData(ITEM_DATA_KEY, binding);

			/*
			 * Set the associated image based on the type of binding. Either it is a user
			 * binding or a system binding.
			 *
			 * TODO Identify more image types.
			 */
			if (binding.getType() == Binding.SYSTEM) {
				tableItem.setImage(0, IMAGE_BLANK);
			} else {
				tableItem.setImage(0, IMAGE_CHANGE);
			}

			try {
				tableItem.setText(1, context.getName());
				tableItem.setText(2, parameterizedCommand.getName());
			} catch (final NotDefinedException e) {
				throw new Error(
						"Context or command became undefined on a non-UI thread while the UI thread was processing."); //$NON-NLS-1$
			}
		}
	}

	/**
	 * Updates the contents of the view tab. This queries the command manager for a
	 * list of key sequence binding definitions, and these definitions are then
	 * added to the table.
	 *
	 * @since 3.1
	 */
	private void updateViewTab() {
		// Clear out the existing table contents.
		tableBindings.removeAll();

		// Get a sorted list of key binding contents.
		final List<Binding> bindings = new ArrayList<>(localChangeManager.getActiveBindingsDisregardingContextFlat());
		bindings.sort(new Comparator<Binding>() {
			/**
			 * Compares two instances of <code>Binding</code> based on the current sort
			 * order.
			 *
			 * @param object1 The first object to compare; must be an instance of
			 *                <code>Binding</code> (i.e., not <code>null</code>).
			 * @param object2 The second object to compare; must be an instance of
			 *                <code>Binding</code> (i.e., not <code>null</code>).
			 * @return The integer value representing the comparison. The comparison is
			 *         based on the current sort order.
			 * @since 3.1
			 */
			@Override
			public int compare(final Binding object1, final Binding object2) {
				final Binding binding1 = object1;
				final Binding binding2 = object2;

				/*
				 * Get the category name, command name, formatted key sequence and context name
				 * for the first binding.
				 */
				final Command command1 = binding1.getParameterizedCommand().getCommand();
				String categoryName1 = Util.ZERO_LENGTH_STRING;
				String commandName1 = Util.ZERO_LENGTH_STRING;
				try {
					commandName1 = command1.getName();
					categoryName1 = command1.getCategory().getName();
				} catch (final NotDefinedException e) {
					// Just use the zero-length string.
				}
				final String triggerSequence1 = binding1.getTriggerSequence().format();
				final String contextId1 = binding1.getContextId();
				String contextName1 = Util.ZERO_LENGTH_STRING;
				if (contextId1 != null) {
					final Context context = contextService.getContext(contextId1);
					try {
						contextName1 = context.getName();
					} catch (final org.eclipse.core.commands.common.NotDefinedException e) {
						// Just use the zero-length string.
					}
				}

				/*
				 * Get the category name, command name, formatted key sequence and context name
				 * for the first binding.
				 */
				final Command command2 = binding2.getParameterizedCommand().getCommand();
				String categoryName2 = Util.ZERO_LENGTH_STRING;
				String commandName2 = Util.ZERO_LENGTH_STRING;
				try {
					commandName2 = command2.getName();
					categoryName2 = command2.getCategory().getName();
				} catch (final org.eclipse.core.commands.common.NotDefinedException e) {
					// Just use the zero-length string.
				}
				final String keySequence2 = binding2.getTriggerSequence().format();
				final String contextId2 = binding2.getContextId();
				String contextName2 = Util.ZERO_LENGTH_STRING;
				if (contextId2 != null) {
					final Context context = contextService.getContext(contextId2);
					try {
						contextName2 = context.getName();
					} catch (final org.eclipse.core.commands.common.NotDefinedException e) {
						// Just use the zero-length string.
					}
				}

				// Compare the items in the current sort order.
				int compare = 0;
				for (int element : sortOrder) {
					switch (element) {
					case VIEW_CATEGORY_COLUMN_INDEX:
						compare = Util.compare(categoryName1, categoryName2);
						if (compare != 0) {
							return compare;
						}
						break;
					case VIEW_COMMAND_COLUMN_INDEX:
						compare = Util.compare(commandName1, commandName2);
						if (compare != 0) {
							return compare;
						}
						break;
					case VIEW_KEY_SEQUENCE_COLUMN_INDEX:
						compare = Util.compare(triggerSequence1, keySequence2);
						if (compare != 0) {
							return compare;
						}
						break;
					case VIEW_CONTEXT_COLUMN_INDEX:
						compare = Util.compare(contextName1, contextName2);
						if (compare != 0) {
							return compare;
						}
						break;
					default:
						throw new Error(
								"Programmer error: added another sort column without modifying the comparator."); //$NON-NLS-1$
					}
				}

				return compare;
			}

			/**
			 * @see Object#equals(java.lang.Object)
			 */
			@Override
			public boolean equals(final Object object) {
				return super.equals(object);
			}
		});

		// Add a table item for each item in the list.
		final Iterator<Binding> keyBindingItr = bindings.iterator();
		while (keyBindingItr.hasNext()) {
			final Binding binding = keyBindingItr.next();

			// Get the command and category name.
			final ParameterizedCommand command = binding.getParameterizedCommand();
			String commandName = Util.ZERO_LENGTH_STRING;
			String categoryName = Util.ZERO_LENGTH_STRING;
			try {
				commandName = command.getName();
				categoryName = command.getCommand().getCategory().getName();
			} catch (final org.eclipse.core.commands.common.NotDefinedException e) {
				// Just use the zero-length string.
			}

			// Ignore items with a meaningless command name.
			if ((commandName == null) || (commandName.isEmpty())) {
				continue;
			}

			// Get the context name.
			final String contextId = binding.getContextId();
			String contextName = Util.ZERO_LENGTH_STRING;
			if (contextId != null) {
				final Context context = contextService.getContext(contextId);
				try {
					contextName = context.getName();
				} catch (final org.eclipse.core.commands.common.NotDefinedException e) {
					// Just use the zero-length string.
				}
			}

			// Create the table item.
			final TableItem item = new TableItem(tableBindings, SWT.NONE);
			item.setText(VIEW_CATEGORY_COLUMN_INDEX, categoryName);
			item.setText(VIEW_COMMAND_COLUMN_INDEX, commandName);
			item.setText(VIEW_KEY_SEQUENCE_COLUMN_INDEX, binding.getTriggerSequence().format());
			item.setText(VIEW_CONTEXT_COLUMN_INDEX, contextName);
			item.setData(BINDING_KEY, binding);
		}

		// Pack the columns.
		for (int i = 0; i < tableBindings.getColumnCount(); i++) {
			tableBindings.getColumn(i).pack();
		}
	}

}
