org.rstudio.core.client.widget.ModifyKeyboardShortcutsWidget.java Source code

Java tutorial

Introduction

Here is the source code for org.rstudio.core.client.widget.ModifyKeyboardShortcutsWidget.java

Source

/*
 * ModifyKeyboardShortcutsWidget.java
 *
 * Copyright (C) 2009-12 by RStudio, Inc.
 *
 * Unless you have received this program directly from RStudio pursuant
 * to the terms of a commercial license agreement with RStudio, then
 * this program is licensed to you under the terms of version 3 of the
 * GNU Affero General Public License. This program is distributed WITHOUT
 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
 *
 */
package org.rstudio.core.client.widget;

import com.google.gwt.cell.client.EditTextCell;
import com.google.gwt.cell.client.FieldUpdater;
import com.google.gwt.cell.client.ValueUpdater;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.dom.client.Style;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.event.dom.client.MouseDownEvent;
import com.google.gwt.event.dom.client.MouseDownHandler;
import com.google.gwt.event.logical.shared.AttachEvent;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.resources.client.ImageResource;
import com.google.gwt.user.cellview.client.AbstractCellTable;
import com.google.gwt.user.cellview.client.Column;
import com.google.gwt.user.cellview.client.ColumnSortEvent;
import com.google.gwt.user.cellview.client.DataGrid;
import com.google.gwt.user.cellview.client.TextColumn;
import com.google.gwt.user.cellview.client.TextHeader;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Event.NativePreviewEvent;
import com.google.gwt.user.client.Event.NativePreviewHandler;
import com.google.gwt.user.client.ui.DockPanel;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.Image;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.RadioButton;
import com.google.gwt.user.client.ui.SuggestOracle;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwt.view.client.CellPreviewEvent;
import com.google.gwt.view.client.ListDataProvider;
import com.google.gwt.view.client.ProvidesKey;
import com.google.inject.Inject;

import org.rstudio.core.client.CommandWithArg;
import org.rstudio.core.client.Pair;
import org.rstudio.core.client.SerializedCommand;
import org.rstudio.core.client.SerializedCommandQueue;
import org.rstudio.core.client.StringUtil;
import org.rstudio.core.client.command.*;
import org.rstudio.core.client.command.EditorCommandManager.EditorKeyBinding;
import org.rstudio.core.client.command.EditorCommandManager.EditorKeyBindings;
import org.rstudio.core.client.command.KeyboardShortcut.KeySequence;
import org.rstudio.core.client.command.ShortcutManager.Handle;
import org.rstudio.core.client.dom.DomUtils;
import org.rstudio.core.client.dom.DomUtils.ElementPredicate;
import org.rstudio.core.client.events.EditorKeybindingsChangedEvent;
import org.rstudio.core.client.events.RStudioKeybindingsChangedEvent;
import org.rstudio.core.client.js.JsUtil;
import org.rstudio.core.client.resources.ImageResource2x;
import org.rstudio.core.client.theme.RStudioDataGridResources;
import org.rstudio.core.client.theme.RStudioDataGridStyle;
import org.rstudio.core.client.theme.res.ThemeResources;
import org.rstudio.studio.client.RStudioGinjector;
import org.rstudio.studio.client.application.events.EventBus;
import org.rstudio.studio.client.common.GlobalDisplay;
import org.rstudio.studio.client.common.HelpLink;
import org.rstudio.studio.client.workbench.AddinsMRUList;
import org.rstudio.studio.client.workbench.addins.Addins.RAddin;
import org.rstudio.studio.client.workbench.addins.Addins.RAddins;
import org.rstudio.studio.client.workbench.addins.AddinsCommandManager;
import org.rstudio.studio.client.workbench.addins.AddinsKeyBindingsChangedEvent;
import org.rstudio.studio.client.workbench.addins.AddinsServerOperations;
import org.rstudio.studio.client.workbench.commands.Commands;
import org.rstudio.studio.client.workbench.views.console.shell.assist.PopupPositioner;
import org.rstudio.studio.client.workbench.views.source.editors.text.ace.AceCommand;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ModifyKeyboardShortcutsWidget extends ModalDialogBase {
    public static class KeyboardShortcutEntry {
        public KeyboardShortcutEntry(String id, String displayName, KeySequence keySequence, int commandType,
                boolean isCustom, AppCommand.Context context) {
            id_ = id;
            name_ = displayName;
            keySequence_ = keySequence;
            commandType_ = commandType;
            isCustom_ = isCustom;
            context_ = context;
        }

        public String getId() {
            return id_;
        }

        public String getName() {
            return name_;
        }

        public KeySequence getKeySequence() {
            if (newKeySequence_ != null)
                return newKeySequence_;

            return keySequence_;
        }

        public int getCommandType() {
            return commandType_;
        }

        public String getDisplayType() {
            if (commandType_ == TYPE_EDITOR_COMMAND)
                return "Editor";

            return context_.toString();
        }

        public boolean isCustomBinding() {
            return isCustom_;
        }

        public AppCommand.Context getContext() {
            return context_;
        }

        public void setDefaultKeySequence(KeySequence keys) {
            keySequence_ = keys.clone();
            newKeySequence_ = null;
        }

        public void setKeySequence(KeySequence keys) {
            if (keys.equals(keySequence_))
                newKeySequence_ = null;
            else
                newKeySequence_ = keys.clone();
        }

        public KeySequence getOriginalKeySequence() {
            return keySequence_;
        }

        public void restoreOriginalKeySequence() {
            newKeySequence_ = null;
        }

        public boolean isModified() {
            return newKeySequence_ != null;
        }

        @Override
        public boolean equals(Object object) {
            if (object == null || !(object instanceof KeyboardShortcutEntry))
                return false;

            KeyboardShortcutEntry other = (KeyboardShortcutEntry) object;
            return commandType_ == other.commandType_ && id_.equals(other.id_);
        }

        private final String id_;
        private final String name_;
        private final int commandType_;
        private final AppCommand.Context context_;

        private boolean isCustom_ = false;
        private KeySequence keySequence_;
        private KeySequence newKeySequence_;

        public static final int TYPE_RSTUDIO_COMMAND = 1; // RStudio AppCommands
        public static final int TYPE_EDITOR_COMMAND = 2; // e.g. Ace commands
        public static final int TYPE_ADDIN = 3;
    }

    private static interface ValueGetter<T> {
        public String getValue(T object);
    }

    public ModifyKeyboardShortcutsWidget() {
        this(null);
    }

    public ModifyKeyboardShortcutsWidget(String filterText) {
        RStudioGinjector.INSTANCE.injectMembers(this);

        initialFilterText_ = filterText;
        shortcuts_ = ShortcutManager.INSTANCE;

        changes_ = new HashMap<KeyboardShortcutEntry, KeyboardShortcutEntry>();
        buffer_ = new KeySequence();

        table_ = new DataGrid<KeyboardShortcutEntry>(1000, RES, KEY_PROVIDER);

        FlowPanel emptyWidget = new FlowPanel();
        Label emptyLabel = new Label("No bindings available");
        emptyLabel.getElement().getStyle().setMarginTop(20, Unit.PX);
        emptyLabel.getElement().getStyle().setColor("#888");
        emptyWidget.add(emptyLabel);
        table_.setEmptyTableWidget(emptyWidget);

        table_.setWidth("700px");
        table_.setHeight("400px");

        // Add a 'global' click handler that performs a row selection regardless
        // of the cell clicked (it seems GWT clicks can be 'fussy' about whether
        // you click on the contents of a cell vs. the '<td>' element itself)
        table_.addDomHandler(new ClickHandler() {
            @Override
            public void onClick(ClickEvent event) {
                Element el = event.getNativeEvent().getEventTarget().cast();
                Element rowEl = DomUtils.findParentElement(el, new ElementPredicate() {
                    @Override
                    public boolean test(Element el) {
                        return el.getTagName().toLowerCase().equals("tr");
                    }
                });

                if (rowEl == null)
                    return;

                if (rowEl.hasAttribute("__gwt_row")) {
                    int row = StringUtil.parseInt(rowEl.getAttribute("__gwt_row"), -1);
                    if (row != -1) {
                        event.stopPropagation();
                        event.preventDefault();
                        table_.setKeyboardSelectedRow(row);
                        table_.setKeyboardSelectedColumn(0);
                    }
                }
            }
        }, ClickEvent.getType());

        table_.setKeyboardSelectionHandler(new CellPreviewEvent.Handler<KeyboardShortcutEntry>() {
            private final AbstractCellTable.CellTableKeyboardSelectionHandler<KeyboardShortcutEntry> handler_ = new AbstractCellTable.CellTableKeyboardSelectionHandler<KeyboardShortcutEntry>(
                    table_);

            @Override
            public void onCellPreview(CellPreviewEvent<KeyboardShortcutEntry> preview) {
                NativeEvent event = preview.getNativeEvent();
                int code = event.getKeyCode();

                // Don't let arrow keys change the selection when a shortcut cell
                // has been selected.
                if (preview.getColumn() == 1) {
                    if (code == KeyCodes.KEY_UP || code == KeyCodes.KEY_DOWN || code == KeyCodes.KEY_LEFT
                            || code == KeyCodes.KEY_RIGHT) {
                        return;
                    }
                }

                // Also disable 'left', 'right' keys as they can 'navigate' the widget
                // into an unusable state.
                if (code == KeyCodes.KEY_LEFT || code == KeyCodes.KEY_RIGHT) {
                    return;
                }

                handler_.onCellPreview(preview);
            }
        });

        dataProvider_ = new ListDataProvider<KeyboardShortcutEntry>();
        dataProvider_.addDataDisplay(table_);

        addColumns();
        addHandlers();

        setText("Keyboard Shortcuts");
        addOkButton(new ThemedButton("Apply", new ClickHandler() {
            @Override
            public void onClick(ClickEvent event) {
                applyChanges();
            }
        }));

        addCancelButton();

        radioAll_ = radioButton("All", new ClickHandler() {
            @Override
            public void onClick(ClickEvent event) {
                filter();
            }
        });

        radioCustomized_ = radioButton("Customized", new ClickHandler() {
            @Override
            public void onClick(ClickEvent event) {
                filter();
            }
        });

        filterWidget_ = new SearchWidget(new SuggestOracle() {

            @Override
            public void requestSuggestions(Request request, Callback callback) {
                callback.onSuggestionsReady(request, new Response(new ArrayList<Suggestion>()));
            }

        });

        filterWidget_.addValueChangeHandler(new ValueChangeHandler<String>() {
            @Override
            public void onValueChange(ValueChangeEvent<String> event) {
                filter();
            }
        });

        filterWidget_.setPlaceholderText("Filter...");

        addLeftWidget(new ThemedButton("Reset...", new ClickHandler() {
            @Override
            public void onClick(ClickEvent event) {
                globalDisplay_.showYesNoMessage(GlobalDisplay.MSG_QUESTION, "Reset Keyboard Shortcuts",
                        "Are you sure you want to reset keyboard shortcuts to their default values? "
                                + "This action cannot be undone.",
                        new ProgressOperation() {
                            @Override
                            public void execute(final ProgressIndicator indicator) {
                                indicator.onProgress("Resetting Keyboard Shortcuts...");
                                appCommands_.resetBindings(new CommandWithArg<EditorKeyBindings>() {
                                    @Override
                                    public void execute(EditorKeyBindings appBindings) {
                                        editorCommands_.resetBindings(new Command() {
                                            @Override
                                            public void execute() {
                                                addins_.resetBindings(new Command() {
                                                    @Override
                                                    public void execute() {
                                                        indicator.onCompleted();
                                                        ShortcutManager.INSTANCE.resetAppCommandBindings();
                                                        resetState();
                                                    }
                                                });
                                            }
                                        });
                                    }
                                });
                            }
                        }, false);
            }
        }));
    }

    private void applyChanges() {
        // Build up command diffs for save after application
        final EditorKeyBindings editorBindings = EditorKeyBindings.create();
        final EditorKeyBindings appBindings = EditorKeyBindings.create();
        final EditorKeyBindings addinBindings = EditorKeyBindings.create();

        // Loop through all changes and apply based on type
        for (Map.Entry<KeyboardShortcutEntry, KeyboardShortcutEntry> entry : changes_.entrySet()) {
            KeyboardShortcutEntry newBinding = entry.getValue();
            String id = newBinding.getId();

            // Get all commands with this ID.
            List<KeyboardShortcutEntry> bindingsWithId = new ArrayList<KeyboardShortcutEntry>();
            for (KeyboardShortcutEntry binding : originalBindings_)
                if (binding.getId().equals(id))
                    bindingsWithId.add(binding);

            // Collect all shortcuts.
            List<KeySequence> keys = new ArrayList<KeySequence>();
            for (KeyboardShortcutEntry binding : bindingsWithId)
                keys.add(binding.getKeySequence());

            int commandType = newBinding.getCommandType();

            if (commandType == KeyboardShortcutEntry.TYPE_RSTUDIO_COMMAND)
                appBindings.setBindings(id, keys);
            else if (commandType == KeyboardShortcutEntry.TYPE_EDITOR_COMMAND)
                editorBindings.setBindings(id, keys);
            else if (commandType == KeyboardShortcutEntry.TYPE_ADDIN)
                addinBindings.setBindings(id, keys);
        }

        // Tell satellites that they need to update bindings.
        appCommands_.addBindingsAndSave(appBindings, new CommandWithArg<EditorKeyBindings>() {
            @Override
            public void execute(EditorKeyBindings bindings) {
                events_.fireEventToAllSatellites(new RStudioKeybindingsChangedEvent(bindings));
            }
        });

        editorCommands_.addBindingsAndSave(editorBindings, new CommandWithArg<EditorKeyBindings>() {
            @Override
            public void execute(EditorKeyBindings bindings) {
                events_.fireEventToAllSatellites(new EditorKeybindingsChangedEvent(bindings));
            }
        });

        addins_.addBindingsAndSave(addinBindings, new CommandWithArg<EditorKeyBindings>() {
            @Override
            public void execute(EditorKeyBindings bindings) {
                events_.fireEvent(new AddinsKeyBindingsChangedEvent(bindings));
            }
        });

        closeDialog();
    }

    @Inject
    public void initialize(EditorCommandManager editorCommands, ApplicationCommandManager appCommands,
            AddinsCommandManager addins, AddinsServerOperations addinsServer, Commands commands,
            GlobalDisplay globalDisplay, EventBus events, AddinsMRUList mruAddins) {
        editorCommands_ = editorCommands;
        appCommands_ = appCommands;
        addins_ = addins;
        addinsServer_ = addinsServer;
        commands_ = commands;
        globalDisplay_ = globalDisplay;
        events_ = events;
        mruAddins_ = mruAddins;
    }

    private void addColumns() {
        nameColumn_ = textColumn("Name", new ValueGetter<KeyboardShortcutEntry>() {
            @Override
            public String getValue(KeyboardShortcutEntry object) {
                return object.getName();
            }
        });

        shortcutColumn_ = editableTextColumn("Shortcut", new ValueGetter<KeyboardShortcutEntry>() {
            @Override
            public String getValue(KeyboardShortcutEntry object) {
                KeySequence sequence = object.getKeySequence();
                return sequence == null ? "" : sequence.toString();
            }
        });

        typeColumn_ = textColumn("Scope", new ValueGetter<KeyboardShortcutEntry>() {
            @Override
            public String getValue(KeyboardShortcutEntry object) {
                return object.getDisplayType();
            }
        });
        table_.setColumnWidth(typeColumn_, "160px");

    }

    private TextColumn<KeyboardShortcutEntry> textColumn(String name,
            final ValueGetter<KeyboardShortcutEntry> getter) {
        TextColumn<KeyboardShortcutEntry> column = new TextColumn<KeyboardShortcutEntry>() {
            @Override
            public String getValue(KeyboardShortcutEntry binding) {
                return getter.getValue(binding);
            }
        };

        column.setSortable(true);
        table_.addColumn(column, new TextHeader(name));
        return column;
    }

    private Column<KeyboardShortcutEntry, String> editableTextColumn(String name,
            final ValueGetter<KeyboardShortcutEntry> getter) {
        EditTextCell editTextCell = new EditTextCell() {
            @Override
            public void onBrowserEvent(final Context context, final Element parent, final String value,
                    final NativeEvent event, final ValueUpdater<String> updater) {
                // GWT's EditTextCell will reset the text of the cell to the last
                // entered text on an Escape keypress. We don't desire that
                // behaviour (we want to restore the _first_ value presented when
                // the user opened the widget); so instead we just blur the input
                // element (thereby committing the current selection) and ensure
                // that selection has been appropriately reset in an earlier preview
                // handler.
                if (event.getType().equals("keyup") && event.getKeyCode() == KeyCodes.KEY_ESCAPE) {
                    parent.getFirstChildElement().blur();
                    return;
                }

                super.onBrowserEvent(context, parent, value, event, updater);
            }
        };

        Column<KeyboardShortcutEntry, String> column = new Column<KeyboardShortcutEntry, String>(editTextCell) {
            @Override
            public String getValue(KeyboardShortcutEntry binding) {
                return getter.getValue(binding);
            }
        };

        column.setFieldUpdater(new FieldUpdater<KeyboardShortcutEntry, String>() {
            @Override
            public void update(int index, KeyboardShortcutEntry binding, String value) {
                KeySequence keys = KeySequence.fromShortcutString(value);

                // Differentiate between resetting the key sequence and
                // adding a new key sequence.
                if (keys.equals(binding.getOriginalKeySequence())) {
                    changes_.remove(binding);
                    binding.restoreOriginalKeySequence();
                } else {
                    KeyboardShortcutEntry newBinding = new KeyboardShortcutEntry(binding.getId(), binding.getName(),
                            keys, binding.getCommandType(), true, binding.getContext());

                    changes_.put(binding, newBinding);
                    binding.setKeySequence(keys);
                }

                table_.setKeyboardSelectedColumn(0);
                updateData(dataProvider_.getList());
            }
        });

        column.setSortable(true);
        table_.addColumn(column, new TextHeader(name));
        return column;
    }

    private void addHandlers() {
        table_.addCellPreviewHandler(new CellPreviewEvent.Handler<KeyboardShortcutEntry>() {
            @Override
            public void onCellPreview(CellPreviewEvent<KeyboardShortcutEntry> preview) {
                Handle shortcutsHandler = shortcuts_.disable();
                int column = preview.getColumn();
                if (column == 0)
                    onNameCellPreview(preview);
                else if (column == 1)
                    onShortcutCellPreview(preview);
                else if (column == 2)
                    onNameCellPreview(preview);
                shortcutsHandler.close();
            }
        });

        table_.addColumnSortHandler(new ColumnSortEvent.Handler() {
            @Override
            public void onColumnSort(ColumnSortEvent event) {
                List<KeyboardShortcutEntry> data = dataProvider_.getList();
                if (event.getColumn().equals(nameColumn_))
                    sort(data, 0, event.isSortAscending());
                else if (event.getColumn().equals(shortcutColumn_))
                    sort(data, 1, event.isSortAscending());
                else if (event.getColumn().equals(typeColumn_))
                    sort(data, 2, event.isSortAscending());

                updateData(data);
            }
        });

        // Fix a bug where clicking on a table header would also
        // select the cell at position [0, 0]. It seems that GWT's
        // DataGrid over-aggressively selects the first cell on the
        // _first_ mouse down event seen; after the first click,
        // cell selection occurs only after full mouse clicks.
        table_.addDomHandler(new MouseDownHandler() {
            @Override
            public void onMouseDown(MouseDownEvent event) {
                Element target = event.getNativeEvent().getEventTarget().cast();
                if (target.hasAttribute("__gwt_header")) {
                    event.stopPropagation();
                    event.preventDefault();
                }
            }
        }, MouseDownEvent.getType());
    }

    private void sort(List<KeyboardShortcutEntry> data, final int column, final boolean ascending) {
        Collections.sort(data, new Comparator<KeyboardShortcutEntry>() {
            @Override
            public int compare(KeyboardShortcutEntry o1, KeyboardShortcutEntry o2) {
                int result = 0;
                if (column == 0) {
                    result = o1.getName().compareTo(o2.getName());
                } else if (column == 1) {
                    KeySequence k1 = o1.getKeySequence();
                    KeySequence k2 = o2.getKeySequence();
                    if (k1 == null && k2 == null)
                        result = 0;
                    else if (k1 == null)
                        result = 1;
                    else if (k2 == null)
                        result = -1;
                    else
                        result = k1.toString().compareTo(k2.toString());
                } else if (column == 2) {
                    result = o1.getContext().toString().compareTo(o2.getContext().toString());
                }

                return ascending ? result : -result;
            }
        });
    }

    private void filter() {
        String query = filterWidget_.getValue();

        boolean isEmptyQuery = StringUtil.isNullOrEmpty(query);
        boolean customOnly = radioCustomized_.getValue();

        List<KeyboardShortcutEntry> filtered = new ArrayList<KeyboardShortcutEntry>();
        for (int i = 0; i < originalBindings_.size(); i++) {
            KeyboardShortcutEntry binding = originalBindings_.get(i);

            String name = binding.getName();
            String context = binding.getContext().toString();

            if (StringUtil.isNullOrEmpty(name))
                continue;

            if (customOnly && !(binding.isCustomBinding() || binding.isModified()))
                continue;

            boolean isGoodBinding = isEmptyQuery || name.toLowerCase().indexOf(query.toLowerCase()) != -1
                    || context.toLowerCase().indexOf(query.toLowerCase()) != -1;

            if (isGoodBinding)
                filtered.add(binding);
        }

        updateData(filtered);

    }

    private void onNameCellPreview(CellPreviewEvent<KeyboardShortcutEntry> preview) {
        NativeEvent event = preview.getNativeEvent();
        String type = event.getType();
        if (type.equals("blur")) {
            buffer_.clear();
        } else if (type.equals("keydown")) {
            int keyCode = event.getKeyCode();
            int modifiers = KeyboardShortcut.getModifierValue(event);
            if (keyCode == KeyCodes.KEY_ESCAPE && modifiers == 0) {
                event.stopPropagation();
                event.preventDefault();
                filterWidget_.focus();
            } else if (keyCode == KeyCodes.KEY_ENTER && modifiers == 0) {
                event.stopPropagation();
                event.preventDefault();
                table_.setKeyboardSelectedColumn(1);
            }
        }
    }

    private Element getElement(DataGrid<?> grid, int row, int column) {
        return grid.getRowElement(row).getChild(column).cast();
    }

    private Element shortcutInput() {
        Element el = DOM.createInputText();
        el.addClassName(RES.dataGridStyle().shortcutInput());
        return el;
    }

    private void onShortcutCellPreview(CellPreviewEvent<KeyboardShortcutEntry> preview) {
        NativeEvent event = preview.getNativeEvent();
        String type = event.getType();

        if (type.equals("keydown")) {
            int keyCode = event.getKeyCode();
            int modifiers = KeyboardShortcut.getModifierValue(event);

            // Don't handle raw 'Enter' keypresses (let underlying input
            // widget process)
            if (keyCode == KeyCodes.KEY_ENTER && modifiers == 0)
                return;

            // Handle any other key events.
            if (modifiers != 0)
                swallowNextKeyUpEvent_ = true;

            event.stopPropagation();
            event.preventDefault();

            if (KeyboardHelper.isModifierKey(event.getKeyCode()))
                return;

            if (keyCode == KeyCodes.KEY_BACKSPACE && modifiers == 0) {
                buffer_.pop();
            } else if (keyCode == KeyCodes.KEY_DELETE && modifiers == 0) {
                buffer_.clear();
            } else if (keyCode == KeyCodes.KEY_ESCAPE && modifiers == 0) {
                buffer_.set(preview.getValue().getOriginalKeySequence());
            } else {
                buffer_.add(event);
            }

            // Sneak into the element and find the active <input>, then update it.
            Element el = getElement(table_, preview.getIndex(), preview.getColumn());
            Element input = el.getFirstChildElement().getFirstChildElement();
            if (input == null)
                return;

            assert input.getTagName().toLowerCase().equals("input") : "Failed to find <input> element in table";

            String bufferString = buffer_.toString();
            input.setAttribute("value", bufferString);
            input.setInnerHTML(bufferString);

            // Move the cursor to the end of the selection.
            DomUtils.setSelectionRange(input, bufferString.length(), bufferString.length());
        }
    }

    private RadioButton radioButton(String label, ClickHandler handler) {
        RadioButton button = new RadioButton(RADIO_BUTTON_GROUP, label);
        button.getElement().getStyle().setMarginRight(6, Unit.PX);
        button.getElement().getStyle().setFloat(Style.Float.LEFT);
        button.getElement().getStyle().setMarginTop(-2, Unit.PX);
        button.addClickHandler(handler);
        return button;
    }

    private void resetState() {
        filterWidget_.clear();
        changes_.clear();
        radioAll_.setValue(true);
        collectShortcuts();
    }

    @Override
    protected Widget createMainWidget() {
        resetState();

        setEscapeDisabled(true);
        setEnterDisabled(true);

        previewHandler_ = Event.addNativePreviewHandler(new NativePreviewHandler() {
            @Override
            public void onPreviewNativeEvent(NativePreviewEvent preview) {
                if (swallowNextKeyUpEvent_ && preview.getTypeInt() == Event.ONKEYUP) {
                    swallowNextKeyUpEvent_ = false;
                    preview.cancel();
                    preview.getNativeEvent().stopPropagation();
                    preview.getNativeEvent().preventDefault();
                } else if (preview.getTypeInt() == Event.ONKEYDOWN) {
                    int keyCode = preview.getNativeEvent().getKeyCode();
                    if (keyCode == KeyCodes.KEY_ESCAPE || keyCode == KeyCodes.KEY_ENTER) {
                        // If the DataGrid (or an underlying element) has focus, let it
                        // handle the escape / enter key.
                        Element target = preview.getNativeEvent().getEventTarget().cast();
                        Element foundTable = DomUtils.findParentElement(target, new ElementPredicate() {
                            @Override
                            public boolean test(Element el) {
                                return el.equals(table_.getElement());
                            }
                        });

                        if (foundTable != null)
                            return;

                        // If the filter widget has focus, Enter / Escape shouldn't close
                        // the widget.
                        if (filterWidget_.isFocused()) {
                            if (keyCode == KeyCodes.KEY_ENTER) {
                                table_.setKeyboardSelectedRow(0);
                                table_.setKeyboardSelectedColumn(0);
                                return;
                            } else if (keyCode == KeyCodes.KEY_ESCAPE) {
                                focusOkButton();
                                return;
                            }
                        }

                        // Otherwise, handle Enter / Escape 'modally' as we might normally do.
                        preview.cancel();
                        preview.getNativeEvent().stopPropagation();
                        preview.getNativeEvent().preventDefault();

                        if (keyCode == KeyCodes.KEY_ENTER) {
                            clickOkButton();
                            return;
                        } else if (keyCode == KeyCodes.KEY_ESCAPE) {
                            closeDialog();
                            return;
                        }
                    }
                }
            }
        });

        addAttachHandler(new AttachEvent.Handler() {
            @Override
            public void onAttachOrDetach(AttachEvent event) {
                if (event.isAttached())
                    ;
                else
                    previewHandler_.removeHandler();
            }
        });

        VerticalPanel container = new VerticalPanel();

        FlowPanel headerPanel = new FlowPanel();

        Label radioLabel = new Label("Show:");
        radioLabel.getElement().getStyle().setFloat(Style.Float.LEFT);
        radioLabel.getElement().getStyle().setMarginRight(8, Unit.PX);
        headerPanel.add(radioLabel);
        headerPanel.add(radioAll_);
        radioAll_.setValue(true);
        headerPanel.add(radioCustomized_);

        filterWidget_.getElement().getStyle().setFloat(Style.Float.LEFT);
        filterWidget_.getElement().getStyle().setMarginLeft(10, Unit.PX);
        filterWidget_.getElement().getStyle().setMarginTop(-1, Unit.PX);
        headerPanel.add(filterWidget_);

        HelpLink link = new HelpLink("Customizing Keyboard Shortcuts", "custom_keyboard_shortcuts");
        link.getElement().getStyle().setFloat(Style.Float.RIGHT);
        headerPanel.add(link);

        container.add(headerPanel);

        FlowPanel spacer = new FlowPanel();
        spacer.setWidth("100%");
        spacer.setHeight("4px");
        container.add(spacer);

        DockPanel dockPanel = new DockPanel();
        dockPanel.add(table_, DockPanel.CENTER);
        container.add(dockPanel);

        return container;
    }

    private void collectShortcuts() {
        final List<KeyboardShortcutEntry> bindings = new ArrayList<KeyboardShortcutEntry>();
        SerializedCommandQueue queue = new SerializedCommandQueue();

        // Load addins discovered as part of package exports. This registers
        // the addin, with the actual keybinding to be registered later,
        // if discovered.
        queue.addCommand(new SerializedCommand() {
            @Override
            public void onExecute(final Command continuation) {
                RAddins rAddins = addins_.getRAddins();
                for (String key : JsUtil.asIterable(rAddins.keys())) {
                    RAddin addin = rAddins.get(key);

                    bindings.add(new KeyboardShortcutEntry(addin.getPackage() + "::" + addin.getBinding(),
                            addin.getName(), new KeySequence(), KeyboardShortcutEntry.TYPE_ADDIN, false,
                            AppCommand.Context.Addin));
                }
                continuation.execute();
            }
        });

        // Load saved addin bindings
        queue.addCommand(new SerializedCommand() {
            @Override
            public void onExecute(final Command continuation) {
                addins_.loadBindings(new CommandWithArg<EditorKeyBindings>() {
                    @Override
                    public void execute(EditorKeyBindings addinBindings) {
                        for (String commandId : addinBindings.iterableKeys()) {
                            EditorKeyBinding addinBinding = addinBindings.get(commandId);
                            for (KeyboardShortcutEntry binding : bindings) {
                                if (binding.getId() == commandId) {
                                    List<KeySequence> keys = addinBinding.getKeyBindings();
                                    if (keys.size() >= 1)
                                        binding.setDefaultKeySequence(keys.get(0));

                                    if (keys.size() >= 2) {
                                        for (int i = 1; i < keys.size(); i++) {
                                            bindings.add(
                                                    new KeyboardShortcutEntry(binding.getId(), binding.getName(),
                                                            keys.get(i), KeyboardShortcutEntry.TYPE_ADDIN, false,
                                                            AppCommand.Context.Addin));
                                        }
                                    }
                                }
                            }
                        }

                        continuation.execute();
                    }
                });
            }
        });

        // Ace loading command
        queue.addCommand(new SerializedCommand() {
            @Override
            public void onExecute(final Command continuation) {
                // Ace Commands
                JsArray<AceCommand> aceCommands = editorCommands_.getCommands();
                for (int i = 0; i < aceCommands.length(); i++) {
                    AceCommand command = aceCommands.get(i);
                    JsArrayString shortcuts = command.getBindingsForCurrentPlatform();

                    if (shortcuts != null) {
                        String id = command.getInternalName();
                        String name = command.getDisplayName();
                        boolean custom = command.isCustomBinding();

                        for (int j = 0; j < shortcuts.length(); j++) {
                            String shortcut = shortcuts.get(j);
                            KeySequence keys = KeySequence.fromShortcutString(shortcut);
                            int type = KeyboardShortcutEntry.TYPE_EDITOR_COMMAND;
                            bindings.add(new KeyboardShortcutEntry(id, name, keys, type, custom,
                                    AppCommand.Context.Editor));
                        }
                    }
                }

                continuation.execute();
            }
        });

        // RStudio commands
        queue.addCommand(new SerializedCommand() {
            @Override
            public void onExecute(final Command continuation) {
                // RStudio Commands
                appCommands_.loadBindings(new CommandWithArg<EditorKeyBindings>() {
                    @Override
                    public void execute(final EditorKeyBindings customBindings) {
                        Map<String, AppCommand> commands = commands_.getCommands();
                        for (Map.Entry<String, AppCommand> entry : commands.entrySet()) {
                            AppCommand command = entry.getValue();
                            if (isExcludedCommand(command))
                                continue;

                            String id = command.getId();
                            String name = getAppCommandName(command);
                            int type = KeyboardShortcutEntry.TYPE_RSTUDIO_COMMAND;
                            boolean isCustom = customBindings.hasKey(id);

                            List<KeySequence> keySequences = new ArrayList<KeySequence>();
                            if (isCustom)
                                keySequences = customBindings.get(id).getKeyBindings();
                            else
                                keySequences.add(command.getKeySequence());

                            for (KeySequence keys : keySequences) {
                                KeyboardShortcutEntry binding = new KeyboardShortcutEntry(id, name, keys, type,
                                        isCustom, command.getContext());
                                bindings.add(binding);
                            }
                        }

                        continuation.execute();
                    }
                });
            }
        });

        // Sort and finish up
        queue.addCommand(new SerializedCommand() {
            @Override
            public void onExecute(final Command continuation) {
                Collections.sort(bindings, new Comparator<KeyboardShortcutEntry>() {
                    @Override
                    public int compare(KeyboardShortcutEntry o1, KeyboardShortcutEntry o2) {
                        if (o1.getContext() != o2.getContext())
                            return o1.getContext().compareTo(o2.getContext());

                        return o1.getName().compareTo(o2.getName());
                    }
                });

                originalBindings_ = bindings;
                updateData(bindings);
                continuation.execute();
            }
        });

        queue.addCommand(new SerializedCommand() {
            @Override
            public void onExecute(Command continuation) {
                if (initialFilterText_ != null) {
                    filterWidget_.setText(initialFilterText_);
                    filter();
                }
                continuation.execute();
            }
        });

        // Exhaust the queue
        queue.run();
    }

    private void updateData(List<KeyboardShortcutEntry> bindings) {
        dataProvider_.setList(bindings);

        // Loop through and update styling on each row.
        for (int i = 0; i < bindings.size(); i++) {
            KeyboardShortcutEntry binding = bindings.get(i);
            if (binding.isCustomBinding() || binding.isModified()) {
                TableRowElement rowEl = table_.getRowElement(i);
                DomUtils.toggleClass(rowEl, RES.dataGridStyle().customBindingRow(), binding.isCustomBinding());
                DomUtils.toggleClass(rowEl, RES.dataGridStyle().modifiedRow(), binding.isModified());
            }
        }

        // Identify conflicts / masking in the set of bindings and report
        // them. Note that this is an O(n^2) run through of commands but
        // given that the list shouldn't be excessively large it's probably
        // something we could live with.
        for (int i = 0; i < bindings.size(); i++) {
            KeyboardShortcutEntry cb1 = bindings.get(i);
            if (cb1.getKeySequence() == null || cb1.getKeySequence().isEmpty())
                continue;

            for (int j = 0; j < originalBindings_.size(); j++) {
                KeyboardShortcutEntry cb2 = originalBindings_.get(j);

                if (cb1.equals(cb2))
                    continue;

                int t1 = cb1.getCommandType();
                int t2 = cb2.getCommandType();

                // allow for keybindings within the same keymap when they
                // map to different contexts. this is mainly done to support
                // 'dynamic' commands as handled with AppCommands
                if (t1 == t2 && cb1.getContext() != cb2.getContext())
                    continue;

                KeySequence ks1 = cb1.getKeySequence();
                KeySequence ks2 = cb2.getKeySequence();

                if (ks1 == null || ks2 == null || ks1.isEmpty() || ks2.isEmpty())
                    continue;

                boolean hasConflict = ks1.equals(ks2) || ks1.startsWith(ks2, true) || ks2.startsWith(ks1, true);

                if (hasConflict) {
                    // editor commands can be masked by AppCommands and addins
                    if (t1 == KeyboardShortcutEntry.TYPE_EDITOR_COMMAND && t1 != t2)
                        addMaskedCommandStyles(i, j, cb2);

                    // addins can mask both AppCommands and editor commands
                    else if (t2 == KeyboardShortcutEntry.TYPE_ADDIN && t1 != t2)
                        addMaskedCommandStyles(i, j, cb2);

                    // two commands with the same binding in the same 'group' == conflict
                    else if (t1 == t2)
                        addConflictCommandStyles(i, j, cb2);
                }
            }
        }
    }

    private String describeCommand(KeyboardShortcutEntry command) {
        StringBuilder builder = new StringBuilder();
        builder.append("'").append(command.getName()).append("'");
        if (command.getKeySequence() != null)
            builder.append(" (").append(command.getKeySequence().toString()).append(")");
        return builder.toString();
    }

    private void addMaskedCommandStyles(int index, int maskedIndex, KeyboardShortcutEntry maskedBy) {
        Element shortcutCell = table_.getRowElement(index).getChild(1).cast();

        embedIcon(shortcutCell, new ImageResource2x(ThemeResources.INSTANCE.syntaxInfo2x()),
                "Masked by RStudio command: ", maskedIndex);

        shortcutCell.addClassName(RES.dataGridStyle().maskedEditorCommandCell());
    }

    private void addConflictCommandStyles(int index, int maskedIndex, KeyboardShortcutEntry conflictsWith) {
        Element shortcutCell = table_.getRowElement(index).getChild(1).cast();

        embedIcon(shortcutCell, new ImageResource2x(ThemeResources.INSTANCE.syntaxWarning2x()),
                "Conflicts with command: ", maskedIndex);

        shortcutCell.addClassName(RES.dataGridStyle().conflictRow());
    }

    private void embedIcon(Element el, ImageResource res, String toolTipText, int maskedIndex) {
        Image icon = new Image(res);
        icon.addStyleName(RES.dataGridStyle().icon());
        icon.setTitle(toolTipText);
        icon.getElement().setAttribute("__rstudio_masked_index", String.valueOf(maskedIndex));
        bindNativeClickToShowToolTip(icon.getElement(), toolTipText);
        el.appendChild(icon.getElement());
    }

    private native final void bindNativeClickToShowToolTip(Element icon, String text)
    /*-{
       var self = this;
       icon.addEventListener("click", $entry(function(evt) {
         
     // Prevent click from reaching shortcut cell
     evt.stopPropagation();
     evt.preventDefault();
         
     self.@org.rstudio.core.client.widget.ModifyKeyboardShortcutsWidget::showToolTip(Ljava/lang/Object;Ljava/lang/String;)(icon, text);
       }));
    }-*/;

    private native final void bindNativeClickToSelectRow(Element el, Element parent, int index) /*-{
                                                                                                var self = this;
                                                                                                el.addEventListener("click", $entry(function(evt) {
                                                                                                    
                                                                                                evt.stopPropagation();
                                                                                                evt.preventDefault();
                                                                                                    
                                                                                                parent.parentNode.removeChild(parent);
                                                                                                    
                                                                                                self.@org.rstudio.core.client.widget.ModifyKeyboardShortcutsWidget::selectRow(I)(index);
                                                                                                }));
                                                                                                }-*/;

    private void selectRow(int index) {
        table_.setKeyboardSelectedRow(index);
        table_.setKeyboardSelectedColumn(0);
    }

    private void showToolTip(Object object, String text) {
        assert object instanceof Element;
        Element el = (Element) object;

        int index = StringUtil.parseInt(el.getAttribute("__rstudio_masked_index"), -1);
        KeyboardShortcutEntry conflictBinding = originalBindings_.get(index);

        Element divEl = DOM.createDiv();
        Element spanEl = DOM.createSpan();
        spanEl.setInnerHTML(text);
        divEl.appendChild(spanEl);

        String conflictDescription = describeCommand(conflictBinding);

        // We use an anchor element here just to get browser default styling for
        // anchor links; we take over the click behaviour to ensure that the normal
        // 'href' navigation doesn't actually occur.
        Element conflictEl = DOM.createAnchor();
        conflictEl.setAttribute("href", "#");
        conflictEl.setInnerHTML(conflictDescription);
        divEl.appendChild(conflictEl);

        MiniPopupPanel tooltip = new MiniPopupPanel(true);

        bindNativeClickToSelectRow(conflictEl, tooltip.getElement(), index);

        tooltip.getElement().appendChild(divEl);
        tooltip.show();
        PopupPositioner.setPopupPosition(tooltip, el.getAbsoluteRight(), el.getAbsoluteBottom(), 10);
    }

    private String getAppCommandName(AppCommand command) {
        String label = command.getLabel();
        if (!StringUtil.isNullOrEmpty(label))
            return label;

        return StringUtil.prettyCamel(command.getId());
    }

    private boolean isExcludedCommand(AppCommand command) {
        if (!command.isRebindable())
            return true;

        String id = command.getId();

        if (StringUtil.isNullOrEmpty(id))
            return true;

        return false;
    }

    private static final ProvidesKey<KeyboardShortcutEntry> KEY_PROVIDER = new ProvidesKey<KeyboardShortcutEntry>() {

        @Override
        public Object getKey(KeyboardShortcutEntry item) {
            return item.hashCode();
        }
    };

    private final ShortcutManager shortcuts_;
    private final KeySequence buffer_;
    private final DataGrid<KeyboardShortcutEntry> table_;
    private final ListDataProvider<KeyboardShortcutEntry> dataProvider_;
    private final Map<KeyboardShortcutEntry, KeyboardShortcutEntry> changes_;
    private final SearchWidget filterWidget_;
    private final String initialFilterText_;

    private final RadioButton radioAll_;
    private final RadioButton radioCustomized_;
    private static final String RADIO_BUTTON_GROUP = "radioCustomizeKeyboardShortcuts";

    private HandlerRegistration previewHandler_;
    private List<KeyboardShortcutEntry> originalBindings_;
    private Pair<Integer, Integer> lastSelectedIndices_;
    private boolean swallowNextKeyUpEvent_;

    // Columns ----
    private TextColumn<KeyboardShortcutEntry> nameColumn_;
    private Column<KeyboardShortcutEntry, String> shortcutColumn_;
    private TextColumn<KeyboardShortcutEntry> typeColumn_;

    // Injected ----
    private EditorCommandManager editorCommands_;
    private ApplicationCommandManager appCommands_;
    private AddinsCommandManager addins_;
    private AddinsServerOperations addinsServer_;
    private Commands commands_;
    private GlobalDisplay globalDisplay_;
    private EventBus events_;
    private AddinsMRUList mruAddins_;

    // Resources, etc ----
    public interface Resources extends RStudioDataGridResources {
        @Source({ RStudioDataGridStyle.RSTUDIO_DEFAULT_CSS, "ModifyKeyboardShortcutsWidget.css" })
        Styles dataGridStyle();
    }

    public interface Styles extends RStudioDataGridStyle {
        String customBindingRow();

        String modifiedRow();

        String maskedEditorCommandCell();

        String conflictRow();

        String shortcutInput();

        String icon();
    }

    private static final Resources RES = GWT.create(Resources.class);

    static {
        RES.dataGridStyle().ensureInjected();
    }

}