How to map modifiers (e.g. CTRL) to mouse thumb buttons using xbindkeys

I spent a lot of time trying to make that binding work. I eventually found a solution, which is complicated but works well and doesn't imply third party software. I share it here hoping it will help people. Besides, I know this is not perfect in terms of security, so any constructive feedback is more than welcome.

There are solutions who are really nice, like the one proposed here, but It always suffer from the limitation of xbindkeys who grab the entire mouse, making modifers+mouse click mapping uncertain. Plus the guile based solution from the above link use ctrl+plus/ctrl+minus which isn't recognize by Gimp for example.

I figured out that what we want is a mouse button who act as a keyboard, so I used uinput, who can be accessed via python, wrote a script that monitor /dev/my-mouse for the thumb button click and send the ctrl key to the virtual keyboard. Here are the detailed steps :

1. Make udev rules

We want the devices to be accessible (rights and location).

For the mouse :

/etc/udev/rules.d/93-mxmouse.conf.rules
------------------------------------------------------------
KERNEL=="event[0-9]*", SUBSYSTEM=="input", SUBSYSTEMS=="input", 
ATTRS{name}=="Logitech Performance MX", SYMLINK+="my_mx_mouse", 
GROUP="mxgrabber", MODE="640"

Udev will look for a device recognized by the kernel with names like event5, and I select my mouse with the name. The SYMLINK instruction assure I will find my mouse in /dev/my_mx_mouse. The device will be readable by a member of the group "mxgrabber".

To find information about your hardware, you should run something like

udevadm info -a -n /dev/input/eventX

For uinput :

/etc/udev/rules.d/94-mxkey.rules
----------------------------------------------------
KERNEL=="uinput", GROUP="mxgrabber", MODE="660"

No need for symlink, uinput will always be in $/dev/uinput or $/dev/input/uinput depending on the system you're on. Just give him the group and the rights to read AND write of course.

You need to unplug - plug your mouse, and the new link should appear in /dev. You can force udev to trigger your rules with $udevadm trigger

2. Activate UINPUT Module

sudo modprobe uinput

And to make it boot persistant :

/etc/modules-load.d/uinput.conf
-----------------------------------------------
uinput

3. Create new group

sudo groupadd mxgrabber

Or whatever you have called your access group. Then you should add yourself to it :

sudo usermod -aG mxgrabber your_login

4. Python script

You need to install the python-uinput library (obviously) and the python-evdev library. Use pip or your distribution package.

The script is quite straightforward, you just have to identify the event.code of you button.

#!/usr/bin/python3.5
# -*- coding: utf-8 -*-

"""
Sort of mini driver.
Read a specific InputDevice (my_mx_mouse),
monitoring for special thumb button
Use uinput (virtual driver) to create a mini keyboard
Send ctrl keystroke on that keyboard
"""

from evdev import InputDevice, categorize, ecodes
import uinput

# Initialize keyboard, choosing used keys
ctrl_keyboard = uinput.Device([
    uinput.KEY_KEYBOARD,
    uinput.KEY_LEFTCTRL,
    uinput.KEY_F4,
    ])

# Sort of initialization click (not sure if mandatory)
# ( "I'm-a-keyboard key" )
ctrl_keyboard.emit_click(uinput.KEY_KEYBOARD)

# Useful to list input devices
#for i in range(0,15):
#    dev = InputDevice('/dev/input/event{}'.format(i))
#    print(dev)

# Declare device patch.
# I made a udev rule to assure it's always the same name
dev = InputDevice('/dev/my_mx_mouse')
#print(dev)
ctrlkey_on = False

# Infinite monitoring loop
for event in dev.read_loop():
    # My thumb button code (use "print(event)" to find)
    if event.code == 280 :
        # Button status, 1 is down, 0 is up
        if event.value == 1:
            ctrl_keyboard.emit(uinput.KEY_LEFTCTRL, 1)
            ctrlkey_on = True
        elif event.value == 0:
            ctrl_keyboard.emit(uinput.KEY_LEFTCTRL, 0)
            ctrlkey_on = False

5. Enjoy !

All you need now is make your python file executable, and ask your desktop manager to load the file at startup. Maybe also a glass of wine to celebrate the good work !

6. Extra for free

I use xbindkeys for additional behavior. For instance, the following configuration may be nice if you have a mouse with wheel side clicks :

~/.xbindkeysrc
---------------------------------------------
# Navigate between tabs with side wheel buttons
"xdotool key ctrl+Tab"
  b:7
"xdotool key ctrl+shift+Tab"
  b:6

# Close tab with ctrl + right click
# --clearmodifiers ensure that ctrl state will be 
# restored if button is still pressed
"xdotool key --clearmodifiers ctrl+F4"
  control+b:3

For this last combinaison to work, you must disable the button you configured for the python script, otherwise it will still be grabed by xbindkeys. Only the Ctrl key must remain :

~/.Xmodmap
-------------------------------------------
! Disable button 13
! Is mapped to ctrl with uinput and python script
pointer = 1 2 3 4 5 6 7 8 9 10 11 12 0 14 15

Reload with $ xmodmap ~/.Xmodmap

7. Conclusion

As I said in the beginning, I'm not perfectly happy with the fact that I have to give myself the wrights to write to /dev/uinput, even if it's thought the "mxgrabber" group. I'm sure there is a safer way of doing that, but I don't know how.

On the bright side, it works really, really well. Any combinaison of keyboard or mouse key how works with the Ctrl button of the keyboard now works with the one of the mouse !!


I found a solution with PyUserInput. This ends up being quite simple and does not require administration rights. With python 2 and PyUserInput installed, I used the following script:

#!/usr/bin/python
from pymouse import PyMouseEvent
from pykeyboard import PyKeyboard

k = PyKeyboard()
class MouseToButton(PyMouseEvent):
    def click(self, x, y, button, press):
        if button == 8:
            if press:    # press
                k.press_key(k.control_l_key)
            else:        # release
                k.release_key(k.control_l_key)

C = MouseToButton()
C.run()

After giving execution rights to the script, I call it with a line in ~/.xsessionrc, for instance

~/path/to/script.py &

Note. this does not prevent the mouse button event from firing. In my case I used xinput set-button-map to change the xinput button mapping and assign the number of the button I was interested in to something that was not in use.

For instance, if you want to use button 8 on your mouse but button 8 has already a function (for instance, page-next), you could use the following .xsessionrc

logitech_mouse_id=$(xinput | grep "Logitech M705" | sed 's/^.*id=\([0-9]*\)[ \t].*$/\1/')
xinput set-button-map $logitech_mouse_id 1 2 3 4 5 6 7 12 9 10 11 12 13 14 15 16 17 18 19 20
./.xbuttonmodifier.py &

provided button 12 carries no meaning to the OS, and assign a custom function to button 12 in .xbuttonmodifier.py, the script I described above.