Created
January 19, 2021 23:42
-
-
Save BradNeuberg/ab1e4fd7d687c99b8be1d9ede9905e4d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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