Introduction

Sometimes people have the desire and/or need to require multiple keyboard commands to signal an event. For example, Emacs users use Ctrl+x followed by Ctrl+s to save the file they are currently editing. This recipe will show how to create menu items that have this feature, and the base class also supports creating keyboard shortcuts without menu items.

Making It Happen

The base wx.Menu classes do not support all of the keys that our keyboards support. If we are going to be overriding the keyboard shortcut handling of the menu, we may as well support all of the possible keys that our keyboards do. We are first going to get all of the ids for the 'special' keyboard keys and their names.

   1 import wx
   2 
   3 keyMap = {}
   4 
   5 def gen_keymap():
   6     keys = ("BACK", "TAB", "RETURN", "ESCAPE", "SPACE", "DELETE", "START",
   7         "LBUTTON", "RBUTTON", "CANCEL", "MBUTTON", "CLEAR", "PAUSE",
   8         "CAPITAL", "PRIOR", "NEXT", "END", "HOME", "LEFT", "UP", "RIGHT",
   9         "DOWN", "SELECT", "PRINT", "EXECUTE", "SNAPSHOT", "INSERT", "HELP",
  10         "NUMPAD0", "NUMPAD1", "NUMPAD2", "NUMPAD3", "NUMPAD4", "NUMPAD5",
  11         "NUMPAD6", "NUMPAD7", "NUMPAD8", "NUMPAD9", "MULTIPLY", "ADD",
  12         "SEPARATOR", "SUBTRACT", "DECIMAL", "DIVIDE", "F1", "F2", "F3", "F4",
  13         "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "F13", "F14",
  14         "F15", "F16", "F17", "F18", "F19", "F20", "F21", "F22", "F23", "F24",
  15         "NUMLOCK", "SCROLL", "PAGEUP", "PAGEDOWN", "NUMPAD_SPACE",
  16         "NUMPAD_TAB", "NUMPAD_ENTER", "NUMPAD_F1", "NUMPAD_F2", "NUMPAD_F3",
  17         "NUMPAD_F4", "NUMPAD_HOME", "NUMPAD_LEFT", "NUMPAD_UP",
  18         "NUMPAD_RIGHT", "NUMPAD_DOWN", "NUMPAD_PRIOR", "NUMPAD_PAGEUP",
  19         "NUMPAD_NEXT", "NUMPAD_PAGEDOWN", "NUMPAD_END", "NUMPAD_BEGIN",
  20         "NUMPAD_INSERT", "NUMPAD_DELETE", "NUMPAD_EQUAL", "NUMPAD_MULTIPLY",
  21         "NUMPAD_ADD", "NUMPAD_SEPARATOR", "NUMPAD_SUBTRACT", "NUMPAD_DECIMAL",
  22         "NUMPAD_DIVIDE")
  23     
  24     for i in keys:
  25         keyMap[getattr(wx, "WXK_"+i)] = i
  26     for i in ("SHIFT", "ALT", "CONTROL", "MENU"):
  27         keyMap[getattr(wx, "WXK_"+i)] = ''

Now that we know all of the keyboard keys, we are going to need to generate specific names for each keyboard keypress combination that we come upon in our event processing.

   1 def GetKeyPress(evt):
   2     keycode = evt.GetKeyCode()
   3     keyname = keyMap.get(keycode, None)
   4     modifiers = ""
   5     for mod, ch in ((evt.ControlDown(), 'Ctrl+'),
   6                     (evt.AltDown(),     'Alt+'),
   7                     (evt.ShiftDown(),   'Shift+'),
   8                     (evt.MetaDown(),    'Meta+')):
   9         if mod:
  10             modifiers += ch
  11 
  12     if keyname is None:
  13         if 27 < keycode < 256:
  14             keyname = chr(keycode)
  15         else:
  16             keyname = "(%s)unknown" % keycode
  17     return modifiers + keyname

The above GetKeyPress() function will, if given an wx.EVT_KEY_DOWN event, return a string that describes the keyboard keys currently being pressed.

Because the following code is pretty ugly, and I'm not very good at step-by-step tutorials, you get it all in one big block.

   1 #a utility function and class
   2 def _spl(st):
   3     if '\t' in st:
   4         return st.split('\t', 1)
   5     return st, ''
   6 
   7 class StatusUpdater:
   8     def __init__(self, frame, message):
   9         self.frame = frame
  10         self.message = message
  11     def __call__(self, evt):
  12         self.frame.SetStatusText(self.message)
  13 
  14 #The frame with hotkey chaining.
  15 
  16 class MainFrame(wx.Frame):
  17     def __init__(self):
  18         wx.Frame.__init__(self, None, -1, "test")
  19         self.CreateStatusBar()
  20         ctrl = self.ctrl = wx.TextCtrl(self, -1, style=wx.TE_MULTILINE|wx.WANTS_CHARS|wx.TE_RICH2)
  21         ctrl.SetFocus()
  22         ctrl.Bind(wx.EVT_KEY_DOWN, self.KeyPressed, ctrl)
  23         
  24         self.lookup = {}
  25         
  26         menuBar = wx.MenuBar()
  27         self.SetMenuBar(menuBar)  # Adding the MenuBar to the Frame content.
  28         self.menuBar = menuBar
  29         
  30         testmenu = wx.Menu()
  31         self.menuAddM(menuBar, testmenu, "TestMenu", "help")
  32         self.menuAdd(testmenu, "testitem\tH\tE\tL\tP", "helptext", StatusUpdater(self, "Did you want help?"))
  33         self.menuAdd(testmenu, "testitem\tCtrl+C\tAlt+3\tShift+B", "testdesc", StatusUpdater(self, "hello!"))
  34         
  35         #print self.lookup
  36         
  37         self._reset()
  38         self.Show(1)
  39     
  40     def addHotkey(self, acc, fcn):
  41         hotkeys = self.lookup
  42         x = [i for i in acc.split('\t') if i]
  43         x = [(i, j==len(x)-1) for j,i in enumerate(x)]
  44         for name, last in x:
  45             if last:
  46                 if name in hotkeys:
  47                     raise Exception("Some other hotkey shares a prefix with this hotkey: %s"%acc)
  48                 hotkeys[name] = fcn
  49             else:
  50                 if name in hotkeys:
  51                     if not isinstance(hotkeys[name], dict):
  52                         raise Exception("Some other hotkey shares a prefix with this hotkey: %s"%acc)
  53                 else:
  54                     hotkeys[name] = {}
  55                 hotkeys = hotkeys[name]
  56 
  57     def menuAdd(self, menu, name, desc, fcn, id=-1, kind=wx.ITEM_NORMAL):
  58         if id == -1:
  59             id = wx.NewId()
  60         a = wx.MenuItem(menu, id, 'TEMPORARYNAME', desc, kind)
  61         menu.AppendItem(a)
  62         wx.EVT_MENU(self, id, fcn)
  63         ns, acc = _spl(name)
  64         
  65         if acc:
  66             self.addHotkey(acc, fcn)
  67         
  68         menu.SetLabel(id, '%s\t%s'%(ns, acc.replace('\t', ' ')))
  69         menu.SetHelpString(id, desc)
  70 
  71     def menuAddM(self, parent, menu, name, help=''):
  72         if isinstance(parent, (wx.Menu, wx.MenuPtr)):
  73             id = wx.NewId()
  74             parent.AppendMenu(id, "TEMPORARYNAME", menu, help)
  75 
  76             self.menuBar.SetLabel(id, name)
  77             self.menuBar.SetHelpString(id, help)
  78         else:
  79             parent.Append(menu, name)
  80 
  81     def _reset(self):
  82         self.sofar = ''
  83         self.cur = self.lookup
  84         self.SetStatusText('')
  85     
  86     def _add(self, key):
  87         self.cur = self.cur[key]
  88         self.sofar += ' ' + key
  89         self.SetStatusText(self.sofar)
  90 
  91     def KeyPressed(self, evt):
  92         key = GetKeyPress(evt)
  93         #print key
  94         
  95         if key == 'ESCAPE':
  96             self._reset()
  97         elif key.endswith('+') and len(key) > 1 and not key.endswith('++'):
  98             #modifiers only, if we don't skip these events, then when people
  99             #hold down modifier keys, things get ugly
 100             evt.Skip()
 101         elif key in self.cur:
 102             self._add(key)
 103             if not isinstance(self.cur, dict):
 104                 sc = self.cur
 105                 self._reset()
 106                 sc(evt)
 107             #comment the next line if you don't want the partial keyboard
 108             #command to continue on to the other controls
 109             evt.Skip()
 110         elif self.cur is not self.lookup:
 111             sf = "%s %s  <- Unknown sequence"%(self.sofar, key)
 112             self._reset()
 113             self.SetStatusText(sf)
 114         else:
 115             evt.Skip()
 116 
 117 if __name__ == '__main__':
 118     gen_keymap()
 119     app = wx.PySimpleApp()
 120     frame = MainFrame()
 121     app.MainLoop()

Comments

This is just a simple example which uses nested dictionaries and keyboard key down events to implement multi-stage keyboard shortcuts. This was strictly a proof-of-concept.

If you plan on using something like this I would encourage you to do a few things:

  1. seriously reconsider whether you want to use multi-stage keyboard shortcuts, you don't want people to get emacs pinky
  2. move all of your keyboard handling off into a mixin
  3. examine the KeyPressed() method and play with it to verify that it does what you want it to do

  4. remember that if you are going to use a standard character, use the uppercase version, not the lowercase (A instead of a)

PyPE uses a variant of the above to handle its keyboard shortcuts, though does not support multi-stage keyboard shortcuts.


Nice recipe! In testing it, I found that putting the accelerator in the menu causes the wxMenu to respond to the last keystroke in the accelerator text as a command independent of the keyboard processing. For instance, in the example above pressing "Shift-B" at any time will display "hello!" on the status bar, even in the middle of keystroke processing.

Changing the accelerator text to Emacs style keystroke definitions (i.e. from "Ctrl-C" to "C-C", "Shift-B" to "S-B", etc.) works around this problem on Windows:

   1     def menuAdd(self, menu, name, desc, fcn, id=-1, kind=wx.ITEM_NORMAL):
   2         if id == -1:
   3             id = wx.NewId()
   4         a = wx.MenuItem(menu, id, 'TEMPORARYNAME', desc, kind)
   5         menu.AppendItem(a)
   6         wx.EVT_MENU(self, id, fcn)
   7         ns, acc = _spl(name)
   8 
   9         if acc:
  10             self.addHotkey(acc, fcn)
  11 
  12         # unix doesn't allow displaying arbitrary text as the accelerator key.
  13         acc=acc.replace('Ctrl','C').replace('Shift','S').replace('Alt','M')
  14         acc=acc.replace('+','-').replace('\t',' ')
  15         print "acc=%s" % acc
  16         menu.SetLabel(id, '%s\t%s'%(ns,acc))
  17         menu.SetHelpString(id, desc)

but as noted in the comment, the wxGTK port doesn't allow arbirtary text as a menu accelerator. On unix it apparently checks the accelerator text to see if it is a valid keystroke combination before it will place it in the menu.

--RobMcMullen


I've enhanced this recipe to include additional Emacs style processing, including global and local keymaps, numeric arguments, the ESC-ESC-ESC cancel sequence, and more. See EmacsStyleKeybindings for the implementation.

--RobMcMullen

Using Multi-key Shortcuts (last edited 2010-08-29 18:13:54 by adsl-71-141-106-234)

NOTE: To edit pages in this wiki you must be a member of the TrustedEditorsGroup.