ComplexTools/KeyboardMapper.py

# Copyright 2009 Autodesk, Inc.  All rights reserved.
# Use of this software is subject to the terms of the Autodesk license agreement 
# provided at the time of installation or download, or which otherwise accompanies
# this software in either electronic or hard copy form.
#
# Script description:
# A tool that allows editing of MoBu keyboard mapping files.
#
# Topic: FBInputType, FBLayout, FBConfigFile, FBSpread
# 

from pyfbsdk import *
from pyfbsdk_additions import *

import os
import os.path
import ConfigParser
import mbutils

# Methods to display a shortcut to the user
def UIValue(key, mods):
    if key:
        return "%s : %s" % (mods, key)
    else:
        return ""
    
# class to store anything about a keyboard mapping (action and key used)
class Mapping(object):
    def __init__(self,action,key="", mods="", time = "DN"):
        self.action = action
        self.mods = self.edit_mods = mods
        self.key = self.edit_key = key
        self.time = time
                
    # Format the shortcut the way MB expects it.
    def FileValue(self):
        if self.key:
            return "{%s:%s*%s}" % (self.mods,self.key,self.time)
        else:
            return ""
            
    # Format the shorcut for ui presentation
    def UIValue(self):
        return UIValue(self.key,self.mods)
        
# Create a MB shortcut description that can contains multiple mappings
def MappingsToShortcut(mappings):
    shortcut = ""
    for i, mapping in enumerate(mappings):        
        value = mapping.FileValue()            
        if value:
            if shortcut:
                shortcut += "|"
            shortcut += value
    return shortcut
                
MOD_TO_ID = { "NONE" : 0,"SHFT" : 1,"CTRL" : 2,"ALT" : 4,"ALCT" : 6,"ALSH" : 5,"CTSH" : 3,"ALCTSH" : 7 }

KEY_TO_ID = { "NONE" : -1,
"ESC" : 0x1b, "TAB" : 0x09, "CAPS" : 0x14, "BKSP" : 0x08, "LBR" : 0xdb, "RBR" : 0xdd, "SEMI" : 0xba, "ENTR" : 0x0d,
"SPC" : 0x20, "PRNT" : 0x2c, "SCRL" : 0x91, "PAUS" : 0x13, "INS" : 0x2d, "HOME" : 0x24, "PGUP" : 0x21, "DEL" : 0x2e, 
"END" : 0x1b, "PGDN" : 0x1b, "UP" : 0x1b, "LEFT" : 0x1b, "DOWN" : 0x1b, "RGHT" : 0x1b, 
"F1" : 0x70,"F2" : 0x71 ,"F3" : 0x72, "F4" : 0x73, "F5" : 0x74, "F6" : 0x75, "F7" : 0x76, "F8" : 0x77, "F9" : 0x78,"F10" : 0x79 ,"F11" : 0x7a, "F12" : 0x7b, 
"NUML" : 0x90, "NMUL" : 0x6a, "NADD" : 0x6b, "NDIV" : 0x6f, "NSUB" : 0x6d,"NDEC" : 0x6e ,"N0" : 0x60, "N1" : 0x61, "N2" : 0x62, "N3" : 0x63, "N4" : 0x64, "N5" : 0x65, "N6" : 0x66,"N7" : 0x67 ,"N8" : 0x68, "N9" : 0x69, 
"'" : 0xde, "," : 0xbc, "-" : 0xbd, "/" : 0xbf,"=" : 0xbb ,"." : 0xbe, "\\" : 0xdc, "`" : 0xc0, 
"0" : 48, "1" : 49, "2" : 50, "3" : 51, "4" : 52,"5" : 53 ,"6" : 54, "7" : 55, "8" : 56, "9" : 59,
"A" : 65, "B" : 66,"C" : 67, "D" : 68, "E" : 69,"F" : 70 , "G" : 71, "H" : 72,"I" : 73,
"J" : 74, "K" : 75,"L" : 76, "M" : 77, "N" : 78,"O" : 79, "P" : 80, "Q" : 81,"R" : 82 ,
"S" : 83, "T" : 84,"U" : 85, "V" : 86, "W" : 87,"X" : 88, "Y" : 89, "Z" : 90 
 }
 
ID_TO_KEY = {}
for key, i in KEY_TO_ID.iteritems():
    ID_TO_KEY[i] = key

ID_TO_MOD = {}
for key, i in MOD_TO_ID.iteritems():
    ID_TO_MOD[i] = key
        
class KeyboardMapper(object):
    
    def GetMappingFromShortcut(self,key,mods,mappings = None):
        # Finds the mapping corresponding to key and mods in a mappings list
        if not mappings:
            mappings = self.row_to_mapping
        for mapping in mappings:
            if key and key == mapping.key and mapping.mods == mods:
                return mapping
        return None
    
    def OnShortcut(self,control,event):
        # User has typed a shortcut
        if event.InputType == FBInputType.kFBKeyPressRaw and event.Key != -1 and self.edit_mapping:
            # Update edited mapping
            self.edit_mapping.edit_key = ID_TO_KEY[event.Key]
            self.edit_mapping.edit_mods = ID_TO_MOD[event.KeyState]            
            # Update UI
            shortcut = UIValue(self.edit_mapping.edit_key,self.edit_mapping.edit_mods)
            self.shortcut_edit.Text = shortcut
            # check if we are in conflict with someone
            self.conflict_mapping = self.GetMappingFromShortcut(self.edit_mapping.edit_key,self.edit_mapping.edit_mods)
            if self.conflict_mapping:
                self.conflict_edit.Text = self.conflict_mapping.action
            else:
                self.conflict_edit.Text = ""
    
    def UpdateEditMapping(self,row):
        # Populate edition bar and edit_mapping according to chosen row
        if self.shortcut_spread.GetRow(row).RowSelected:
            mapping = self.row_to_mapping[row]
            self.action_edit.Text = mapping.action
            self.shortcut_edit.Text = mapping.UIValue()
            self.edit_mapping = mapping
            
            global foin
            foin = mapping
        else:
            print "none"
            self.edit_mapping = None
            self.action_edit.Text = ""
            self.shortcut_edit.Text = ""
        self.conflict_mapping = None
        self.conflict_edit.Text = ""
            
                                    
    def RowClicked(self,control,event):
        # User has clicked on a row
        self.UpdateEditMapping(event.Row)
                            
    def OnKeyboardChange(self,control,event):
        # User has changed the keyboard file to edit
        
        # Reset all relevant values since we will repopulate everything
        if self.row_to_mapping:
            self.shortcut_spread.Clear()
            self.shortcut_spread.ColumnAdd("Shortcut")
            self.mapping_to_row = {}
            self.row_to_mapping = []
            self.action_to_mappings = {}
        
        # Populate our UI with relevant infos from file
        config = mbutils.OpenConfigFile(self.keyboard_files[self.file_list.ItemIndex])
        rowref = 0
        
        # Sort element alphabetically
        items = config.items("Actions")
        items.sort(key = lambda pair : pair[0] )
                
        for action, shortcut in items:
            mappings = self.ParseShortcut(action, shortcut)
            # Populate spread
            for i, mapping in enumerate(mappings):
                if i > 0:
                    self.shortcut_spread.RowAdd("", rowref)                    
                else:                    
                    self.shortcut_spread.RowAdd(action, rowref)
                
                # Populate mapping structures
                self.row_to_mapping.append(mapping)
                self.mapping_to_row[mapping] = rowref
                self.shortcut_spread.SetCellValue(rowref, 0, mapping.UIValue())
                rowref += 1
                            
                                                
    def ParseShortcut(self,action, desc_string):
        # Parse an action shortcut and populate mapping structures
        l = []
        desclist = desc_string.split("|")
        for desc in desclist:
            if desc:                            
                keystr = desc.strip("{}")
                mod_keytime = keystr.split(":")
                key_time = mod_keytime[1].split("*")
                mapping = Mapping(action,key_time[0],mod_keytime[0],key_time[1])
            else:
                mapping = Mapping(action)            
            l.append(mapping)
        self.action_to_mappings[action] = l
        return l
            
    def Assign(self,control,event):        
        # User wants to change the binding for a particular action
        if not self.edit_mapping:
            return
            
        action_mappings = self.action_to_mappings[self.edit_mapping.action]        
        # try to add a mapping that exists already for the action
        if self.GetMappingFromShortcut(self.edit_mapping.edit_key,self.edit_mapping.edit_mods,action_mappings):
            return
            
        # update new mapping  
        self.edit_mapping.key = self.edit_mapping.edit_key
        self.edit_mapping.mods = self.edit_mapping.edit_mods
        
        # Update UI
        self.shortcut_spread.SetCellValue(self.mapping_to_row[self.edit_mapping], 0, self.edit_mapping.UIValue())
        
        # Write the change to file
        self.WriteEditMapping()
        
    def WriteEditMapping(self,update_conflict = True):
        # Update the edited action
        keyboard_dir, keyboard_file = os.path.split(self.keyboard_files[self.file_list.ItemIndex])
        config = FBConfigFile(keyboard_file,keyboard_dir)
        config.Set("Actions",self.edit_mapping.action,MappingsToShortcut(self.action_to_mappings[self.edit_mapping.action]))
        
        # If there is a conflicting binding: unassigned it
        if self.conflict_mapping:
            self.conflict_mapping.key = ""
            self.conflict_mapping.mods = ""
            if update_conflict:
                self.shortcut_spread.SetCellValue(self.mapping_to_row[self.conflict_mapping], 0, "")            
                config.Set("Actions",self.conflict_mapping.action,MappingsToShortcut(self.action_to_mappings[self.conflict_mapping.action]))                
            self.conflict_edit.Text = ""
        
    def Remove(self,control,event):
        # User wants to remove a mapping for a particular action
        if not self.edit_mapping:
            return
        row = self.mapping_to_row[self.edit_mapping]         
                        
        # Unbind mapping
        self.edit_mapping.key = ""
        self.edit_mapping.mods = ""
        self.shortcut_edit.Text = ""        
        # Write the change to file
        self.WriteEditMapping(False)
                        
        # Reload file and repopulate UI
        self.OnKeyboardChange(None, None)
        # set the selection where it was
        if row > len(self.row_to_mapping):
            row = len(self.row_to_mapping) - 1 
        self.shortcut_spread.GetRow(row).RowSelected = True
        # Update the edit box according to selection
        self.UpdateEditMapping(row)
        
        
    def Add(self,control,event):
        # User wants to add a new binding for this action        
        if not self.edit_mapping:
            return
        
        action_mappings = self.action_to_mappings[self.edit_mapping.action]
        
        # try to add a mapping that exists already for the action
        if self.GetMappingFromShortcut(self.edit_mapping.edit_key,self.edit_mapping.edit_mods,action_mappings):
            return
        
        row = self.mapping_to_row[action_mappings[0]] + len(action_mappings)
        
        # Create new mapping and add it so its gets written to file
        mapping = Mapping(self.edit_mapping.action,self.edit_mapping.edit_key,self.edit_mapping.edit_mods)
        self.edit_mapping = mapping
        action_mappings.append(mapping)
        self.WriteEditMapping(True)
        
        # Reload file and repopulate UI
        self.OnKeyboardChange(None, None)
        # set the selection where it was
        if row > len(self.row_to_mapping):
            row = len(self.row_to_mapping) - 1 
        self.shortcut_spread.GetRow(row).RowSelected = True
        # Update the edit box according to selection
        self.UpdateEditMapping(row)
                    
    def Show(self):
        self.popup.Show()
        del self.popup
    
    def ClosePopup(self,control, event):
        self.popup.Close(True)

    def __init__(self):
        # Create the Keyboard Mapper in a Modal Popup. this way no shortcut are sent to the application
        # when we assign new shortcut to actions
        self.popup = FBPopup()
        
        self.popup.Caption = "Keyboard Mapper"
        self.popup.Modal = True
        x = FBAddRegionParam(0,FBAttachType.kFBAttachLeft,"")
        y = FBAddRegionParam(0,FBAttachType.kFBAttachTop,"")
        w = FBAddRegionParam(0,FBAttachType.kFBAttachRight,"")
        h = FBAddRegionParam(0,FBAttachType.kFBAttachBottom,"")                
        self.popup.AddRegion("main","main", x,y,w,h)
        self.popup.Left = 300
        self.popup.Top = 300
        self.popup.Width = 650
        self.popup.Height = 500
        
        grid = FBGridLayout()
        self.popup.SetControl("main",grid)
        
        row = 0
        widgetHeight = 25
        buttonWidth = 90
        
        # Row 1
        l = FBLabel()
        l.Caption = "Keyboard files"
        grid.Add(l, row, 0)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Row 2: keyboard file list
        self.file_list = FBList()
        self.file_list.OnChange.Add(self.OnKeyboardChange)
        self.file_list.Style = FBListStyle.kFBDropDownList
        grid.Add(self.file_list, row, 0)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Row 3
        l = FBLabel()
        l.Caption = "Shortcut list"
        grid.Add(l, row, 0)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # row 4
        self.shortcut_spread = FBSpread()
        self.shortcut_spread.Caption = "Action Name"
        self.shortcut_spread.ColumnAdd("Shortcut")
        self.shortcut_spread.OnRowClick.Add(self.RowClicked)
        grid.AddRange(self.shortcut_spread, row, row, 0, 2)
        grid.SetRowRatio(row, 1.0)
        row += 1
        
        # Row 5
        l = FBLabel()
        l.Caption = "Action Name"
        grid.Add(l, row, 0)
        
        l = FBLabel()
        l.Caption = "Shortcut"
        grid.Add(l, row, 2)
        
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Row 6
        self.action_edit = FBEdit()
        self.action_edit.ReadOnly = True
        grid.Add(self.action_edit, row, 0)
        
        # Trick: FBLayout is the only class with a OnInput event. So if we put
        # focus in this layout and if we type something on the keyboard we "catch" the 
        # shortcut that has been typed.
        self.input_layout = FBLayout()
        x = FBAddRegionParam(5,FBAttachType.kFBAttachLeft,"")
        y = FBAddRegionParam(5,FBAttachType.kFBAttachTop,"")
        w = FBAddRegionParam(-5,FBAttachType.kFBAttachRight,"")
        h = FBAddRegionParam(-5,FBAttachType.kFBAttachBottom,"") 
        self.input_layout.AddRegion("Border","Click here and type a shortcut", x, y, w, h)
        self.input_layout.SetBorder("Border",FBBorderStyle.kFBEmbossBorder,True, False,2,2,90,0)
        self.input_layout.OnInput.Add(self.OnShortcut)
        grid.Add(self.input_layout, row, 1)
        
        self.shortcut_edit = FBEdit()
        self.shortcut_edit.ReadOnly = True
        grid.Add(self.shortcut_edit, row, 2)                        
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        hbox = FBHBoxLayout()
        b = FBButton()
        b.Caption = "Replace shortcut"
        b.OnClick.Add(self.Assign)
        hbox.Add(b, buttonWidth)
        b = FBButton()
        b.Caption = "Add shortcut"
        b.OnClick.Add(self.Add)
        hbox.Add(b, buttonWidth)
        b = FBButton()
        b.Caption = "Remove shortcut"
        b.OnClick.Add(self.Remove)
        hbox.Add(b, buttonWidth)
        
        grid.AddRange(hbox, row, row, 0, 1)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Row 7
        l = FBLabel()
        l.Caption = "Shortcut already assigned to action"
        grid.AddRange(l, row, row, 0,2)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Row 8
        self.conflict_edit = FBEdit()
        self.conflict_edit.ReadOnly = True
        grid.Add(self.conflict_edit, row, 0)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Row 9
        hbox = FBHBoxLayout()
        b = FBButton()
        b.Caption = "Close"
        b.OnClick.Add(self.ClosePopup)
        hbox.Add(b, buttonWidth)
        l = FBLabel()
        l.Caption = 'Reset Settings->Keyboard configuration->"Your Keyboard" for changes to take place.'
        l.Style = FBTextStyle.kFBTextStyleBold
        hbox.Add(l, 600)
        
        grid.AddRange(hbox, row, row, 0, 2)
        grid.SetRowHeight(row, widgetHeight)
        row += 1
        
        # Init data structure:
        self.action_to_mappings = {}
        self.mapping_to_row = {}        
        self.row_to_mapping = []        
        self.edit_mapping = None
        self.conflict_mapping = None
        
        self.keyboard_files = []
        # Populate with Default Keyboard files:
        keyboard_folder = os.path.join(mbutils.GetConfigPath(),"Keyboard")
        for f in os.listdir(keyboard_folder):
            self.keyboard_files.append(os.path.join(keyboard_folder,f))
            self.file_list.Items.append(f)

        # Populate with User Keyboard files:
        keyboard_folder = os.path.join(mbutils.GetUserConfigPath(),"Keyboard")
        for f in os.listdir(keyboard_folder):
            self.keyboard_files.append(os.path.join(keyboard_folder,f))
            self.file_list.Items.append(f)
 
        PythonKeyboardPath = os.path.join(mbutils.GetUserConfigPath(),"Python", "PythonKeyboard.txt")

        if os.path.isfile(PythonKeyboardPath):
            # Add User Python Console keyboard mapping
            self.keyboard_files.append(os.path.join(mbutils.GetUserConfigPath(),"Python", "PythonKeyboard.txt"))
            self.file_list.Items.append("PythonKeyboard.txt")
        else:
            # Add Default Python Console keyboard mapping
            self.keyboard_files.append(os.path.join(mbutils.GetConfigPath(),"Python\Keyboard", "PythonKeyboard.txt"))
            self.file_list.Items.append("PythonKeyboard.txt")

        self.OnKeyboardChange(None, None)
        
def PopKeyboardMapper(control,event):
    mapper = KeyboardMapper()
    mapper.Show()
    del mapper

def PopulateLayout(tool):
    # Keyboard mapper is created as a popup inside a Tool to avoid this behavior:
    # When we execute a script we automatically changed the cursor to a wair cursor
    # for the whole script execution. 
    # Problem is: if executing a script create a popup, while the dialog is up the cursor
    # is a wait cursor since the script has not technically finished its execution!
    # if we start the Mapper from a tool, the script ends its execution when the tool is
    # executed and starting the KeyboardMapper popup keep the normal cursor.
    
    x = FBAddRegionParam(0,FBAttachType.kFBAttachLeft,"")
    y = FBAddRegionParam(0,FBAttachType.kFBAttachTop,"")
    w = FBAddRegionParam(0,FBAttachType.kFBAttachRight,"")
    h = FBAddRegionParam(0,FBAttachType.kFBAttachBottom,"")
    
    vbox = FBVBoxLayout()
    tool.AddRegion("main","main", x, y, w, h)
    tool.SetControl("main",vbox)

    b = FBButton()
    b.Caption = "Keyboard mapper"
    vbox.Add(b, 35)
    b.OnClick.Add(PopKeyboardMapper)

def CreateTool():
    tool = FBCreateUniqueTool("Keyboard Utilities")
    tool.StartSizeX = 200
    tool.StartSizeY = 100
    PopulateLayout(tool)
    ShowTool(tool)


CreateTool()