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