== Foreward == I'm addicted to XEmacs, but hate trying to learn elisp to extend it. I have a pie-in-the-sky project to implement an XEmacs workalike in python, [[http://www.flipturn.org/peppy|peppy]], and use the following code to process emacs-style keybindings. == Introduction == I based this code on Josiah Carlson's [[Using Multi-key Shortcuts]] recipe here in the wxPython wiki. There are two classes that handle the key processing, the !KeyMap class that represents a collection of keybindings, and the !KeyProcessor class that processes individual keystrokes and attempts to match them with a command in the keybinding lists. === Mac Help === If you have a Mac, I'd be really interested for you to try this out. I have no idea how it's going to work on the Mac, or what the proper mapping of the Apple key should be. The best answer is "It depends." ;-) The various Emacsen that I've used on the Mac use the Apple key (or Cmd key) the same as the Alt key on PCs. In other words it is used as the Meta key in Emacs. In wx.!KeyEvent the !MetaDown() method will be true when the Cmd key is pressed. However, if you want to be compliant with other Mac apps, then the Cmd key is usually used the same way that Ctrl would be used in apps on the PC. For example, standard accelerator keys like Ctrl-C, Ctrl-V, Ctrl-Z, etc. in PC apps would be Cmd-C, Cmd-V, Cmd-Z, etc. in Mac apps. In wx any accelerators defined in menu items with Ctrl are automatically converted to be Cmd accelerators. Also, in wx.!KeyEvent you can use !CmdDown() and it will be equivallent to either !ControlDown or !MetaDown depending on the platform you are running on. -- RobinDunn '''Update''': I've recently updated my Emacs config on Mac to use the Option (Alt) key as the Emacs Alt/Meta key, and to leave the Cmd key alone so it can be bound to standard functions that will make it act like the Cmd key in all other Apple HIG compliant apps. This makes it feel much more natural and a better fit on the platform, but it sacrifices the default functionality of the Option key on Macs, which is like the !AltGr key on non-U.S. keyboards on a PC and is how special accented characters and etc. are entered. But since I'm a English speaker and Emacs for me is almost exclusivly for editing source code this was not a great loss for me. -- RobinDunn === Quirks === If you put the accelerator text in a wxMenu, the key processing code of the menu causes the application to respond to the last keystroke in the accelerator text as a command independent of the keyboard processing. I had to add an ascii zero to the end of the accelerator text to kill this effect. On Unix, non standard modifiers don't even get displayed properly in the wxMenu items. "C-X C-S" gets displayed as "X S", and processed as only "S", which means every time you pressed a single "S" character the menu gets activated. Not what we want, so I hacked around that by displaying the accelerator in the label part. Doesn't look as nice, but it avoids the problem of the menu interpreting the keystrokes. == KeyMap Class == This class represents a group of key mappings. Each key mapping consists of a sequence of keystrokes and a function to call when that sequence is matched. Keystrokes are defined by a text string that is made up of a sequence of modifier keys applied to key names, for instance: * Ctrl-C * Alt-A * C-X C-S * C-X 5 2 * Meta-Alt-Shift-Q Internally, all modifiers are represented by the abbreviations '''C-''', '''S-''', '''A-''', and '''M-''' for '''Ctrl''', '''Shift''', '''Alt''', and '''Meta''' respectively. When specifying keystrokes, the longer names may be used. The function that is called when the keystroke is matched should take two parameters, the second of which is an optional numeric argument. I tend to implement these as Command objects: {{{ #!python class Command(object): def __init__(self, stuff): self.stuff=stuff def __call__(self, evt, number=None): print "%s called by keybindings" % self # perform some cool function self.stuff.performCoolThing() }}} so that the keybinding is defined by: {{{ #!python from wxemacskeybindings import * class UserFrame(wx.Frame): def __init__(...) # [...] keymap=KeyMap() keymap.define("C-C C-C", Command(self)) def performCoolThing(self): # cool thing done here }}} == KeyProcessor class == The !KeyProcessor handles the keystrokes and searches through all the !KeyMap classes to find a match. It has the concept of global and local keymaps, as well as "minor mode" keymaps, with the search order of minor mode keymaps first, then local, and finally global if nothing else matches. The keymaps can be changed at any time using the provided set*Keymap methods, although if they're replaced in the middle of a keystroke sequence, you'll lose the previous keystrokes. All keyboard processing must be diverted to the !KeyProcessor, which means intercepting the EVT_KEY_DOWN event of whatever wx objects need to handle the keypresses. For example: {{{ #!python class UserFrame(wx.Frame): def __init__(...) # [...] self.globalKeyMap=KeyMap() self.globalKeyMap.define("C-C C-C", Command(self)) self.keys=KeyProcessor(status=self) self.keys.setGlobalKeyMap(self.globalKeyMap) self.Bind(wx.EVT_KEY_DOWN, self.KeyPressed, ctrl) def KeyPressed(self, evt): self.keys.process(evt) }}} == Implementation == Here's the source code for the two classes, with lots of comments and an example program. Save as wxemacskeybindings.py and run it to start the demo. {{{ #!python #!/usr/bin/env python import sys import wx # Based on demo program by Josiah Carlson found at # http://wiki.wxpython.org/index.cgi/Using_Multi-key_Shortcuts wxkeynames = ( "BACK", "TAB", "RETURN", "ESCAPE", "SPACE", "DELETE", "START", "LBUTTON", "RBUTTON", "CANCEL", "MBUTTON", "CLEAR", "PAUSE", "CAPITAL", "PRIOR", "NEXT", "END", "HOME", "LEFT", "UP", "RIGHT", "DOWN", "SELECT", "PRINT", "EXECUTE", "SNAPSHOT", "INSERT", "HELP", "NUMPAD0", "NUMPAD1", "NUMPAD2", "NUMPAD3", "NUMPAD4", "NUMPAD5", "NUMPAD6", "NUMPAD7", "NUMPAD8", "NUMPAD9", "MULTIPLY", "ADD", "SEPARATOR", "SUBTRACT", "DECIMAL", "DIVIDE", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20", "F21", "F22", "F23", "F24", "NUMLOCK", "SCROLL", "PAGEUP", "PAGEDOWN", "NUMPAD_SPACE", "NUMPAD_TAB", "NUMPAD_ENTER", "NUMPAD_F1", "NUMPAD_F2", "NUMPAD_F3", "NUMPAD_F4", "NUMPAD_HOME", "NUMPAD_LEFT", "NUMPAD_UP", "NUMPAD_RIGHT", "NUMPAD_DOWN", "NUMPAD_PRIOR", "NUMPAD_PAGEUP", "NUMPAD_NEXT", "NUMPAD_PAGEDOWN", "NUMPAD_END", "NUMPAD_BEGIN", "NUMPAD_INSERT", "NUMPAD_DELETE", "NUMPAD_EQUAL", "NUMPAD_MULTIPLY", "NUMPAD_ADD", "NUMPAD_SEPARATOR", "NUMPAD_SUBTRACT", "NUMPAD_DECIMAL", "NUMPAD_DIVIDE", ) ## # This class represents a group of key mappings. The KeyProcessor # class below uses multiple groups, one to represent global keymaps, # one for local keymaps, and an arbitrary number of other keymaps for # any additional minor modes that need other keymappings. class KeyMap(object): def __init__(self): self.debug=False self.lookup={} self.reset() self.modifiers=['C-','S-','A-','M-'] self.modaliases={'Ctrl-':'C-', 'Shift-':'S-', 'Alt-':'A-', 'Meta-':'M-', 'Ctrl+':'C-', 'Shift+':'S-', 'Alt+':'A-', 'Meta+':'M-', } self.keyaliases={'RET':'RETURN', 'SPC':'SPACE', 'ESC':'ESCAPE', } self.function=None # if this is true, it will throw an exception when finding a # duplicate keystroke. If false, it silently overwrites any # previously defined keystroke with the new one. self.exceptionsWhenDuplicate=False def reset(self): self.cur=self.lookup self.function=None ## # return True if keystroke is processed by the handler def add(self, key): if self.cur: if key in self.cur: # get next item, either a dict of more possible # choices or a function to execute self.cur=self.cur[key] if not isinstance(self.cur, dict): self.function = self.cur self.cur=None return True elif self.cur is not self.lookup: # if we get here, we have processed a partial match, # but the most recent keystroke doesn't match # anything. Flag as unknown keystroke combo self.cur=None return True else: # OK, this is the first keystroke and it doesn't match # any of the first keystrokes in our keymap. It's # probably a regular character, so flag it as # unprocessed by our handler. self.cur=None return False ## # Convience function to check whether the keystroke combo is an # unknown combo. def isUnknown(self): return self.cur==None and self.function==None ## # Find a modifier in the accerelator string def matchModifier(self,str): for m in self.modifiers: if str.startswith(m): return len(m),m for m in self.modaliases.keys(): if str.startswith(m): return len(m),self.modaliases[m] return 0,None ## # Find a keyname (not modifier name) in the accelerator string, # matching any special keys or abbreviations of the special keys def matchKey(self,str): key=None i=0 for name in self.keyaliases: if str.startswith(name): val=self.keyaliases[name] return i+len(val),val for name in wxkeynames: if str.startswith(name): return i+len(name),name if i=0: # match the original format from the wxpython wiki, where # keystrokes are delimited by tab characters keystrokes = [i for i in acc.split('\t') if i] else: # find the individual keystrokes from a more emacs style # list, where the keystrokes are separated by whitespace. keystrokes=[] i=0 flags={} while i0: # at least one keymap is still matching, so continue processing self.sofar += key + ' ' if self.debug: print "add: sofar=%s processed=%d unknown=%d function=%s" % (self.sofar,processed,unknown,function) else: if unknown==self.num and self.sofar=='': # if the keystroke doesn't match the first character # in any of the keymaps, don't flag it as unknown. It # is a key that should be processed by the # application, not us. unknown=0 if self.debug: print "add: sofar=%s processed=%d unknown=%d skipping %s" % (self.sofar,processed,unknown,key) return (processed==0,unknown==self.num,function) ## # This starts the emacs-style numeric arguments that are ended by # the first non-numeric keystroke def startArgument(self, key=None): self.number=None self.scale=1 if key is not None: self.args=key + ' ' self.processingArgument=1 ## # Helper function to decode a numeric argument keystroke. It can # be a number or, if the first keystroke, the '-' sign. If C-U is # used to start the argumen processing, the numbers don't have to # have the Ctrl modifier pressed. def getNumber(self, key, musthavectrl=False): ctrl=False if key[0:2]=='C-': key=key[2:] ctrl=True if musthavectrl and not ctrl: return None # only allow minus sign at first character if key=='-' and self.processingArgument==1: return -1 elif key>='0' and key<='9': return ord(key)-ord('0') return None ## # Process a numeric keystroke def argument(self, key): # allow control and a number to work as well num=self.getNumber(key) if num is None: # this keystroke isn't a number, so calculate the final # value of the numeric argument and flag that we're done if self.number is None: self.number=self.defaultNumber else: self.number=self.scale*self.number if self.debug: print "number = %d" % self.number self.processingArgument=0 else: # this keystroke IS a number, so process it. if num==-1: self.scale=-1 else: if self.number is None: self.number=num else: self.number=10*self.number+num self.args+=key + ' ' self.processingArgument+=1 ## # The main driver routine. Get a keystroke and run through the # processing chain. def process(self, evt): key = self.decode(evt) if key == self.abortKey: self.reset() self.show("Quit") elif self.nextStickyMetaCancel and key==self.stickyMeta: # this must be processed before the check for metaNext, # otherwise we'll never be able to process the ESC-ESC-ESC # quit sequence self.reset() self.show("Quit") elif self.metaNext: # OK, the meta sticky key is down, but it's not a quit # sequence self.show(self.args+self.sofar+" "+self.stickyMeta) elif key.endswith('-') and len(key) > 1 and not key.endswith('--'): #modifiers only, if we don't skip these events, then when people #hold down modifier keys, things get ugly evt.Skip() elif key==self.universalArgument: # signal the start of a numeric argument self.startArgument(key) self.show(self.args) elif not self.processingArgument and self.getNumber(key,musthavectrl=True) is not None: # allow Ctrl plus number keys to also start a numeric argument self.startArgument() self.argument(key) else: # OK, not one of those special cases. if self.processingArgument: # if we're inside a numeric argument chain, show it. # Note that processingArgument may get reset inside # the call to argument() self.argument(key) self.show(self.args) # Can't use an else here because the flag # self.processingArgument may get reset inside # self.argument() if the key is not a number. We don't # want to lose that keystroke if it isn't a number so # process it as a potential hotkey. if not self.processingArgument: # So, we're not processing a numeric argument now. # Check to see where we are in the processing chain. skip,unknown,function=self.add(key) if function: # Found a function in one of the keymaps, so # execute it. save=self.number self.reset() if save is not None: function(evt,save) else: function(evt) elif unknown: # This is an unknown keystroke combo sf = "%s not defined."%(self.sofar) self.reset() self.show(sf) elif skip: # this is the first keystroke and it doesn't match # anything. Skip it up to the next event handler # to get processed elsewhere. self.reset() evt.Skip() else: self.show(self.args+self.sofar) if __name__ == '__main__': #a utility function and class class StatusUpdater: def __init__(self, frame, message): self.frame = frame self.message = message def __call__(self, evt, number=None): if number is not None: self.frame.SetStatusText("%d x %s" % (number,self.message)) else: self.frame.SetStatusText(self.message) class RemoveLocal: def __init__(self, frame, message): self.frame = frame self.message = message def __call__(self, evt, number=None): self.frame.keys.clearLocalKeyMap() self.frame.SetStatusText(self.message) class ApplyLocal: def __init__(self, frame, message): self.frame = frame self.message = message def __call__(self, evt, number=None): self.frame.keys.setLocalKeyMap(self.frame.localKeyMap) self.frame.SetStatusText(self.message) #The frame with hotkey chaining. class MainFrame(wx.Frame): def __init__(self): wx.Frame.__init__(self, None, -1, "test") self.CreateStatusBar() ctrl = self.ctrl = wx.TextCtrl(self, -1, style=wx.TE_MULTILINE|wx.WANTS_CHARS|wx.TE_RICH2) ctrl.SetFocus() ctrl.Bind(wx.EVT_KEY_DOWN, self.KeyPressed, ctrl) self.globalKeyMap=KeyMap() self.localKeyMap=KeyMap() self.keys=KeyProcessor(status=self) self.keys.setGlobalKeyMap(self.globalKeyMap) self.keys.setLocalKeyMap(self.localKeyMap) menuBar = wx.MenuBar() self.SetMenuBar(menuBar) # Adding the MenuBar to the Frame content. self.menuBar = menuBar self.whichkeymap={} gmap = wx.Menu() self.whichkeymap[gmap]=self.globalKeyMap self.menuAddM(menuBar, gmap, "Global", "Global key map") self.menuAdd(gmap, "Open \tC-X\tC-F", "Open File", StatusUpdater(self, "open...")) self.menuAdd(gmap, "Save File\tC-X\tC-S", "Save Current File", StatusUpdater(self, "saved...")) self.menuAdd(gmap, "Sit \tC-X\tC-X\tC-S", "Sit", StatusUpdater(self, "sit...")) self.menuAdd(gmap, "Stay \tC-S\tC-X\tC-S", "Stay", StatusUpdater(self, "stay...")) self.menuAdd(gmap, "Execute \tCtrl-C Ctrl-C", "Execute Buffer", StatusUpdater(self, "execute buffer...")) self.menuAdd(gmap, "New Frame\tC-x 5 2", "New Frame", StatusUpdater(self, "open new frame")) self.menuAdd(gmap, "Help\tCtrl-H", "Help", StatusUpdater(self, "show help")) self.menuAdd(gmap, "Help\tShift+Z", "Shift Z", StatusUpdater(self, "Shift Z")) self.menuAdd(gmap, "Exit\tC-X C-C", "Exit", sys.exit) lmap = wx.Menu() self.whichkeymap[lmap]=self.localKeyMap self.menuAddM(menuBar, lmap, "Local", "Local key map") self.menuAdd(lmap, "Turn Off Local Keymap", "Turn off local keymap", RemoveLocal(self, "local keymap removed")) self.menuAdd(lmap, "Turn On Local Keymap", "Turn off local keymap", ApplyLocal(self, "local keymap added")) self.menuAdd(lmap, "Comment Region\tC-C C-C", "testdesc", StatusUpdater(self, "comment region")) self.menuAdd(lmap, "Stay \tC-S C-X C-S", "Stay", StatusUpdater(self, "stay...")) self.menuAdd(lmap, "Multi-Modifier \tC-S-a S-C-m", "Shift-Control test", StatusUpdater(self, "pressed Shift-Control-A, Shift-Control-M")) self.menuAdd(lmap, "Control a\tC-A", "lower case a", StatusUpdater(self, "pressed Control-A")) self.menuAdd(lmap, "Control b\tC-b", "upper case b", StatusUpdater(self, "pressed Control-B")) self.menuAdd(lmap, "Control Shift b\tC-S-b", "upper case b", StatusUpdater(self, "pressed Control-Shift-B")) self.menuAdd(lmap, "Control RET\tC-RET", "control-return", StatusUpdater(self, "pressed C-RET")) self.menuAdd(lmap, "Control SPC\tC-SPC", "control-space", StatusUpdater(self, "pressed C-SPC")) self.menuAdd(lmap, "Control Page Up\tC-PRIOR", "control-prior", StatusUpdater(self, "pressed C-PRIOR")) self.menuAdd(lmap, "Control F5\tC-F5", "control-f5", StatusUpdater(self, "pressed C-F5")) self.menuAdd(lmap, "Meta-X\tM-x", "meta-x", StatusUpdater(self, "pressed meta-x")) self.menuAdd(lmap, "Meta-nothing\tM-", "meta-nothing", StatusUpdater(self, "pressed meta-nothing")) self.menuAdd(lmap, "Double Meta-nothing\tM- M-", "meta-nothing", StatusUpdater(self, "pressed meta-nothing")) #print self.lookup self.Show(1) def menuAdd(self, menu, name, desc, fcn, id=-1, kind=wx.ITEM_NORMAL): if id == -1: id = wx.NewId() a = wx.MenuItem(menu, id, 'TEMPORARYNAME', desc, kind) menu.AppendItem(a) wx.EVT_MENU(self, id, fcn) def _spl(st): if '\t' in st: return st.split('\t', 1) return st, '' ns, acc = _spl(name) if acc: if menu in self.whichkeymap: keymap=self.whichkeymap[menu] else: # menu not listed in menu-to-keymap mapping. Put in # local keymap=self.localKeyMap keymap.define(acc, fcn) acc=acc.replace('\t',' ') #print "acc=%s" % acc if wx.Platform == '__WXMSW__': # If windows recognizes the accelerator (e.g. "Ctrl+A") OR # it doesn't recognize the whole accererator text but does # recognize the last part (e.g. "C-A" where it doesn't # know what the "C-" is but does see the "A", it will # automatically process the accelerator before we even see # it. So, append an ascii zero to the end. menu.SetLabel(id, '%s\t%s\00'%(ns,acc)) else: # unix doesn't allow displaying arbitrary text as the # accelerator, so we have to just put it in the menu # itself. This doesn't look very nice, but that's about # all we can do. menu.SetLabel(id, '%s (%s)'%(ns,acc)) else: menu.SetLabel(id,ns) menu.SetHelpString(id, desc) def menuAddM(self, parent, menu, name, help=''): if isinstance(parent, wx.Menu) or isinstance(parent, wx.MenuPtr): id = wx.NewId() parent.AppendMenu(id, "TEMPORARYNAME", menu, help) self.menuBar.SetLabel(id, name) self.menuBar.SetHelpString(id, help) else: parent.Append(menu, name) def KeyPressed(self, evt): self.keys.process(evt) app = wx.PySimpleApp() frame = MainFrame() app.MainLoop() }}} -- RobMcMullen