holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.82k stars 521 forks source link

Can panel implement keyboard hotkeys? #3193

Open ahuang11 opened 2 years ago

ahuang11 commented 2 years ago

https://gist.github.com/brunomsantiago/e0ab366fc0dbc092c1a5946b30caa9a0 Like that

hoxbro commented 2 years ago

Something like this?

https://user-images.githubusercontent.com/19758978/153715568-d8b518ce-13ca-4886-8d29-d66e9390a538.mp4

import panel as pn

script = """
<script>
const doc = window.parent.document;
buttons = Array.from(doc.querySelectorAll('button[type=button]'));
const left_button = buttons.find(el => el.innerText === 'LEFT');
const right_button = buttons.find(el => el.innerText === 'RIGHT');
doc.addEventListener('keydown', function(e) {
    switch (e.keyCode) {
        case 37: // (37 = left arrow)
            left_button.click();
            break;
        case 39: // (39 = right arrow)
            right_button.click();
            break;
    }
});
</script>
"""

html = pn.pane.HTML(script)

button_left = pn.widgets.Button(name="LEFT")
button_right = pn.widgets.Button(name="RIGHT")

button_left.on_click(lambda x: print("left"))
# OR
pn.bind(lambda: print("right"), button_right, watch=True)

pn.Row(
    pn.Column(button_left, button_left.param.clicks),
    pn.Column(button_right, button_right.param.clicks),
    html,
).servable()
ahuang11 commented 2 years ago

Neat! I wonder if it's possible to have a built-in "hotkey=..." keyword for each widget

ahuang11 commented 1 year ago

I suppose this doesn't work anymore with intro of shadow root (or at least much more convoluted).

mattpap commented 1 year ago
switch (e.keyCode) {
    case 37: // (37 = left arrow)

keyCode is a legacy API, use key instead. See here for a list of possible key names, or see bokehjs' type definitions (note it's incomplete; only what we actually use).

hoxbro commented 1 year ago

An updated example that works with Panel 1. I have updated doc.querySelectorAll to be able to pierce through the shadow dom with the $$$ function and updates to use the key API.

import panel as pn

script = """
<script>
function $$$(selector, rootNode=document.body) {
    const arr = []

    const traverser = node => {
        // 1. decline all nodes that are not elements
        if(node.nodeType !== Node.ELEMENT_NODE) {
            return
        }

        // 2. add the node to the array, if it matches the selector
        if(node.matches(selector)) {
            arr.push(node)
        }

        // 3. loop through the children
        const children = node.children
        if (children.length) {
            for(const child of children) {
                traverser(child)
            }
        }

        // 4. check for shadow DOM, and loop through it's children
        const shadowRoot = node.shadowRoot
        if (shadowRoot) {
            const shadowChildren = shadowRoot.children
            for(const shadowChild of shadowChildren) {
                traverser(shadowChild)
            }
        }
    }

    traverser(rootNode)
    return arr
}

const doc = window.parent.document;
buttons = Array.from($$$('button[type=button]'));
const left_button = buttons.find(el => el.innerText === 'LEFT');
const right_button = buttons.find(el => el.innerText === 'RIGHT');
doc.addEventListener('keydown', function(e) {
    switch (e.key) {
        case "ArrowLeft":
            left_button.click();
            break;
        case "ArrowRight":
            right_button.click();
            break;
    }
});
</script>
"""

html = pn.pane.HTML(script)

button_left = pn.widgets.Button(name="LEFT")
button_right = pn.widgets.Button(name="RIGHT")

button_left.on_click(lambda x: print("left"))
button_right.on_click(lambda x: print("right"))

pn.Row(
    pn.Column(button_left, button_left.param.clicks),
    pn.Column(button_right, button_right.param.clicks),
    html,
).servable()
mikalai-prakapenka commented 9 months ago

@Hoxbro thanks a lot for the example!

Could you please tell if there is a way to remove this event listener while running in Jupyter Lab when the kernel is stopped or restarted?

dennisjlee commented 2 months ago

Now that Panel 1.5 is out, I tried using ReactComponent to implement global keyboard shortcuts in a generalized way. This will also deregister the keyboard shortcuts if the KeyboardShortcuts component is unrendered.

from typing import TypedDict, NotRequired

# Note: this uses TypedDict instead of Pydantic or dataclass because Bokeh/Panel doesn't seem to
# like serializing custom classes to the frontend (and I can't figure out how to customize that).
class KeyboardShortcut(TypedDict):
    name: str
    key: str
    altKey: NotRequired[bool]
    ctrlKey: NotRequired[bool]
    metaKey: NotRequired[bool]
    shiftKey: NotRequired[bool]

class KeyboardShortcuts(ReactComponent):
    """
    Class to install global keyboard shortcuts into a Panel app.

    Pass in shortcuts as a list of KeyboardShortcut dictionaries, and then handle shortcut events in Python
    by calling `on_msg` on this component. The `name` field of the matching KeyboardShortcut will be sent as the `data`
    field in the `DataEvent`.

    Example:
    >>> shortcuts = [
        KeyboardShortcut(name="save", key="s", ctrlKey=True),
        KeyboardShortcut(name="print", key="p", ctrlKey=True),
    ]
    >>> shortcuts_component = KeyboardShortcuts(shortcuts=shortcuts)
    >>> def handle_shortcut(event: DataEvent):
            if event.data == "save":
                print("Save shortcut pressed!")
            elif event.data == "print":
                print("Print shortcut pressed!")
    >>> shortcuts_component.on_msg(handle_shortcut)
    """

    shortcuts = param.List(class_=dict)

    _esm = """
    // Hash a shortcut into a string for use in a dictionary key (booleans / null / undefined are coerced into 1 or 0)
    function hashShortcut({ key, altKey, ctrlKey, metaKey, shiftKey }) {
      return `${key}.${+!!altKey}.${+!!ctrlKey}.${+!!metaKey}.${+!!shiftKey}`;
    }

    export function render({ model }) {
      const [shortcuts] = model.useState("shortcuts");

      const keyedShortcuts = {};
      for (const shortcut of shortcuts) {
        keyedShortcuts[hashShortcut(shortcut)] = shortcut.name;
      }

      function onKeyDown(e) {
        const name = keyedShortcuts[hashShortcut(e)];
        if (name) {
          e.preventDefault();
          e.stopPropagation();
          model.send_msg(name);
          return;
        }
      }

      React.useEffect(() => {
        window.addEventListener('keydown', onKeyDown);
        return () => {
          window.removeEventListener('keydown', onKeyDown);
        };
      });

      return <></>;
    }
    """