Language-independent hotkeys on Linux

How do I set hotkeys (keyboard shortcuts) for a specific widget, that would work regardless of the active keyboard input language? Preferably, in a platform-independent way, but, at the very least, under Linux and wxGTK? Should be something simple, all reasonable software behave like that, but I couldn’t find how to do it.

I’ve tried with EVT_KEY_DOWN, but that required separate treatment of every individual letter attached to the hardware key. Perhaps the GetRawKeyFlags() could help, but how do I get the value it would return not for the event handler, but for a character (say, I have ‘Ctrl+Q’ written in some text config, or even hardcoded, how do I get the scan code of the hardware key the letter Q is assigned to)?

The menu shortcuts, actually, work regardless of the current input language (in the example below, ‘Ctrl+W’ triggers even when it’s in Cyrillic, so it’s kinda ‘Ctrl+Ц’). But, as I understand, you can only have one global menu attached to the main frame. What if I don’t want my frame to have a menu? What if I need the same hotkeys used by different widgets for different purposes?

I’ve also tried with accelerator tables. Despite reading somewhere on this forum that you can have only one global accelerator table, sort of like the menu, I was able to assign separate accelerators to separate widgets and they worked “correctly”, depending on the active focus. But, unlike with the menu, there I ran into the same problem again: in the example below the ‘Ctrl+E’ only triggers if the input is in Latin.

Windows doesn’t have any of those problems, ‘Ctrl+Q’, ‘Ctrl+W’ and ‘Ctrl+E’, respectively, get triggered regardless of the active input language.

import wx
    
class MainWindow(wx.Frame):
    def __init__(self, parent, id, title):
        wx.Frame.__init__(self, parent, wx.ID_ANY, title, size=(500, 500))

        #1 - Key event processed in OnKeyPressed
        self.panel = wx.Panel(self)
        self.panel.Bind(wx.EVT_KEY_DOWN, self.OnKeyPressed)
        self.hotkey_lat          = 'Q' # on the QWERTY layout
        self.hotkey_cyr          = 'й' # same key in Cyrillic layout (has to be the lower case letter, too)
        self.hotkey_raw_key_flag = 24  # value of GetRawKeyFlags() under GTK

        #2 - Menu event processed in OnMenu1
        self.menu_bar = wx.MenuBar()
        menu = wx.Menu()
        menuitemid = wx.NewIdRef()
        menu.Append(menuitemid, f"Menu {menuitemid}\tCtrl+W", "Do the Ctrl-W")
        self.menu_bar.Append(menu, "Menu")
        self.SetMenuBar(self.menu_bar)
        self.Bind(wx.EVT_MENU, self.OnMenu1, id=menuitemid)

        #3 - Accelerator processed in AccelOnE
        accelitemid = wx.NewIdRef()
        accel_entry_E = wx.AcceleratorEntry(wx.ACCEL_CTRL, ord('E'), accelitemid)
        accel_table = wx.AcceleratorTable([accel_entry_E])
        self.panel.SetAcceleratorTable(accel_table)
        self.Bind(wx.EVT_MENU, self.AccelOnE, id=accelitemid)
        
        
    def OnKeyPressed(self, evt):
        is_caught = False
        if evt.ControlDown():

            # triggers when the keyboard is in Latin
            if evt.GetUnicodeKey() == ord(self.hotkey_lat):
                is_caught = True
                print(f"Ctrl-{self.hotkey_lat} triggered!")

            # triggers when the keyboard is in Cyrillic
            if evt.GetUnicodeKey() == ord(self.hotkey_cyr):
                is_caught = True
                print(f"Ctrl-{self.hotkey_cyr} triggered!")

            # triggers in both of the above cases
            if evt.GetRawKeyFlags() == self.hotkey_raw_key_flag:
                is_caught = True
                print(f"Ctrl-{self.hotkey_raw_key_flag} triggered!")
        evt.Skip(not is_caught)

    def OnMenu1(self, evt):
        print("Ctrl-W menu item triggered!")

    def AccelOnE(self, evt):
        print("Ctrl-E accelerator entry triggered!")

app = wx.App()
frame = MainWindow(None, -1, "Window")
frame.Show(1)
app.MainLoop()

I was able to solve it, albeit not by means of wxPython.

Based on this answer on StackOverflow, I wrote the following single function module:

# scancode_xlib.py
# requirements:
# python3-xlib
from Xlib.display import Display
import Xlib.XK
import os

_display = Display(os.environ['DISPLAY']) # slow, initialize once
def char_to_keycode(c):
    '''
    Input:  a single ASCII character (case doesn't matter)
    Output: the platform-dependent hardware key code.
    '''
    keysym = Xlib.XK.string_to_keysym(c)
    keycode = _display.keysym_to_keycode(keysym)
    return keycode

Then, in the main program (safeguards to check the platform, etc. are omitted.):

import wx
from scancode_xlib import char_to_keycode
    
class MainWindow(wx.Frame):
    def __init__(self, parent, id, title):
        wx.Frame.__init__(self, parent, wx.ID_ANY, title, size=(500, 500))

        self.panel = wx.Panel(self)
        self.panel.Bind(wx.EVT_KEY_DOWN, self.OnKeyPressed)

        # Ctrl + <key> to press : <function> to call
        self.ctrl_hotkeys = {'Q': self.ProcessCtrlQ, 'W': self.ProcessCtrlW, 'E': self.ProcessCtrlE}

        # convert the keys from ASCII to hardware_keycode
        self.ctrl_hotkeys = {char_to_keycode(c): fn for c, fn in self.ctrl_hotkeys.items()}
        
    def OnKeyPressed(self, evt):
        if evt.ControlDown():
            code = evt.GetRawKeyFlags()
            if code in self.ctrl_hotkeys.keys():
                self.ctrl_hotkeys[code]()

    def ProcessCtrlQ(self):
        print("Processing Ctrl-Q.")

    def ProcessCtrlW(self):
        print("Processing Ctrl-W.")

    def ProcessCtrlE(self):
        print("Processing Ctrl-E.")

app = wx.App()
frame = MainWindow(None, -1, "Window")
frame.Show(1)
app.MainLoop()

With this, Ctrl-Q, Ctrl-W and Ctrl-E are triggered under wxGTK on Linux, regardless of the active input language.

A similar issue discussed previously:

Contrary to the Robin’s reply, under Linux EVT_KEYDOWN does NOT give the same values regardless of active input language.

I had this solved accidentally, but for alt+ keys, by simply having hotkeys on the buttons. The beauty of it is that I did nothing else, and it just worked in both environments. In the apps I write (for myself) I keep the captions in cyrillic, however the keyboard layout may be both latin and cyrillic, depending on content I create. Regardless, alt+S works the same in both layouts - if it’s in cyrillic layout, I’ve actually typed alt+С, and the button’s caption is &Сними (snimi, i.e. record - for Save).
Even weirder, I have a little utility to convert text from latinic to cyrillic and vice versa (makes sense if text is in proper serbian, written phonetically), as the button captions for those are &л2ћ, &ћ2л (ћ aka ć being in the serbian word for the script, ћирилица/ćirilica). Typing Alt+л and Alt+l always triggers the first button, Alt+ћ and Alt+ć the second.

I don’t understand how this works, where’s the equivalence achieved - true, the pairs are on the same keys in both layouts, so maybe it’s positional, dunno. Just happy to report that this worked out of the box, I did nothing.

Interesting to know, although it doesn’t solve the fundamental problem, and is even more limiting than menus in terms of available modifier keys - as I understand, you can only use Alt in that case? And in my case the windows I want to assign the hotkeys to don’t have any buttons to begin with. I’ve been thinking, maybe actually putting everything into the main menu and then changing some part of the menu depending on the active focus would be a better approach.

As for how it works, I’d guess (a pure speculation of no practical significance, though), when it comes to handling those hotkeys for built-in stuff, like menus and buttons, wxPython only provides a thin wrapper over the underlying system functions, so they get handled by raw GTK, which uses hardware scan codes. Whereas with a full key down handler more stuff is handled in the Python wrapper, where, for whatever reason, the things are done differently.

The behavior of buttons’ hotkeys (but should also work for menus and anything that has a caption property) to react to alt+hotkey is an old thing, I remember it worked, in most of the apps, even in Dos. So the behavior became the default everywhere, and it seems any environment complies with it.

I know it’s not any direct help to your problem, just maybe adding to the general big picture. Keyboard interactions, ouch, one can get lost in there. Just an example: daughter handed me down her old Mac, and its serbian latin keyboard layout is plain wrong, most of punctuation is in wrong places (can’t find the apostrophe etc) and it’s a qwerty, not qwertz. So I found where it’s defined, wanted to edit it - it’s still a bsd unix beneath, no binaries - and brought in my linux layout to compare. Within ten minutes I got completely lost - every key was defined in about seven places, depending on the situation - is there any active modifier keys, was a pre-modifier key pressed before it etc etc. And mind you, the order of those was transposed, one was ordered by scancodes, another by physical layout. A nightmare in plain text. Now imagine diving into the gory details of that in code. The horrors.