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