Skip to content

Instantly share code, notes, and snippets.

@BradNeuberg
Created January 19, 2021 23:42
Show Gist options
  • Save BradNeuberg/ab1e4fd7d687c99b8be1d9ede9905e4d to your computer and use it in GitHub Desktop.
Save BradNeuberg/ab1e4fd7d687c99b8be1d9ede9905e4d to your computer and use it in GitHub Desktop.
from enum import Enum
class JSKeycodes(Enum):
# Physical keys that can be used, appropriate for JavaScript keydown listeners:
# http://gcctech.org/csc/javascript/javascript_keycodes.htm
LEFT_ARROW = 37
UP_ARROW = 38
RIGHT_ARROW = 39
DOWN_ARROW = 40
class JSKeyboardAccelerators(object):
"""
UI's built in Jupyter using the pyviz ecosystem (Panel, Bokeh, Holoviews, etc.) have no native support for
keyboard accelerators. This can be a problem when building workflow tools for dealing with machine learning
labels, which can involve hundreds or thousands of repetitive actions.
This class solves this problem by injecting JavaScript into the page to handle the keyboard accelerators.
In order to prevent Jupyter and this JavaScript from 'stepping' on each others toes, we require that the panel
application have a "Grab Keyboard/Release Keyboard" button in their UI. When the user clicks this, they can
then use a proscribed list of keyboard accelerator keys that when used will "click" the correct panel button.
To use, first make sure your UI has such as button:
self.instance_class = 'some_tool'
self.grab_keyboard_button = pn.widgets.Button(name="Grab Keyboard")
Also define a unique instance_class that will be used to 'silo' your panel when dealing with its buttons
in case there are multiple panel instances in the Jupyter notebook. Pass both of these into your panel when
drawing the UI:
def panel(self):
return pn.Column(
# ... other parts of your UI
pn.Row(
pn.Column(
# 4 pn.widgets.Buttons that we want to attach keyboard accelerators to.
pn.Row(
self.prev_button,
self.valid_button,
self.invalid_button,
self.next_button,
),
),
pn.layout.HSpacer(),
pn.Column(
# The Grab Keyboard button.
self.grab_keyboard_button,
align="center",
),
background='lightgrey',
),
# Make sure we have a JavaScript hook to bind onto the buttons for just this
# pyviz panel in case there are multiple ones in a Jupyter notebook. Used
# so that we can add keybindings.
css_classes=[self.instance_class],
)
Then, instantiate an JSKeyboardAccelerators instance with the actions you want to bind to:
self.accelerators = JSKeyboardAccelerators(self.instance_class, self.grab_keyboard_button,
actions=[
{
'action_name': 'Previous',
'keycode': JSKeycodes.LEFT_ARROW,
'html_button_ordering': 0,
'expected_text': '◀',
},
{
'action_name': 'Next',
'keycode': JSKeycodes.RIGHT_ARROW,
'html_button_ordering': 1,
'expected_text': '▶',
},
{
'action_name': 'Invalid',
'keycode': JSKeycodes.DOWN_ARROW,
'html_button_ordering': 2,
'expected_text': '✖',
},
{
'action_name': 'Valid',
'keycode': JSKeycodes.UP_ARROW,
'html_button_ordering': 3,
'expected_text': '✔',
},
])
The action_name and expected_text fields are debugging fields that will be printed to the JavaScript console
when this keyboard accelerator is pressed to aid in ensuring that the right action is being
invoked; keycode is one of the JSKeycodes enums (add your own if one is not listed that you want to
use); and html_button_ordering is the ordering of the button you want to be 'clicked' on in the
background -- basically, the order returned from the CSS selector for your instance_class defined,
such as '.some_tool button'.
"""
def __init__(self, instance_class, grab_keyboard_button, actions):
self.instance_class = instance_class;
self.grab_keyboard_button = grab_keyboard_button;
self.setup_js(actions)
def setup_js(self, actions):
keydown_details = []
for action in actions:
keydown_details.append(f"""
case {action['keycode'].value}:
console.log('Keyboard accelerator: {action['action_name']}');
// Make sure the underlying key doesn't do its default action, such as jumping around
// the page if it was the pageup key, for example.
evt.preventDefault();
evt.stopPropagation();
evt.cancelBubble = true;
var b = document.querySelectorAll('.{self.instance_class} button')[{action['html_button_ordering']}];
console.log("Keyboard button text: " + b.innerText + ", expected text: {action['expected_text']}");
b.click();
break;
"""
)
keydown_details = "\n".join(keydown_details)
args = {'grab_keyboard_button': self.grab_keyboard_button}
self.grab_keyboard_button.jscallback(args=args, clicks="""
// Panel/Bokeh have 'timing' issues where they sometimes call this JavaScript multiple
// times even if the Grab Keyboard/Release Keyboard button is only clicked once. Debounce
// the call to prevent this causing problems.
var debounce_ms = 500;
if (typeof window['debounce_timeout'] != 'undefined'){
return;
}
window.debounce_timeout = setTimeout(function(){
delete window.debounce_timeout;
main();
}, debounce_ms);
function main(){
// First time running - no details saved yet on whether keyboard grabbing is on or off.
if (typeof window['keyboard_grabbed'] == 'undefined'){
window.keyboard_grabbed = false;
}
// Make sure we cleanly remove any pre-existing keyboard listeners
// so they don't build up.
function disableGrab(){
console.log('Disabling keyboard accelerators');
window.keyboard_grabbed = false;
grab_keyboard_button.label = "Grab Keyboard";
var useCapture = false;
document.removeEventListener('keydown', window['grabbed_listener'], useCapture);
}
if (window.keyboard_grabbed){ // Release the keyboard
disableGrab();
} else { // Grab the keyboard
console.log('Enabling keyboard accelerators');
window.keyboard_grabbed = true;
grab_keyboard_button.label = "Release Keyboard";
// The keyboard accelerators work best when the "Grab Keyboard/Release Keyboard" button
// has focus. If the user clicks away into the rest of the Jupyter notebook, clear out
// the keyboard handler.
var clearAfterFocus = function(evt){
document.removeEventListener('focusin', clearAfterFocus);
disableGrab();
};
document.addEventListener('focusin', clearAfterFocus);
// The actual onkeydown code that handles responding to keypresses.
window.grabbed_listener = function(evt){
switch(evt.keyCode){
""" + keydown_details + """
}
};
var useCapture = false; // Make sure we get the event before the Jupyter notebook UI itself does.
document.addEventListener('keydown', window.grabbed_listener, useCapture);
} // Grab the keyboard
} // main()
""")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment