Page 1 of 1

Python script to toggle mapping of Wacom tablet to a single monitor

Posted: Mon Apr 05, 2021 7:55 pm
by Pheeble
I have a 4-monitor display, and I also use a prehistoric Wacom graphics tablet in Blender, Gimp, etc. (Anybody remember the Graphire 2 tablet? Yep, that's the one. :lol: )

It's difficult to work with the Wacom when its input area is spread across 4 monitors, and it's better to map the Wacom's input to just one monitor. The problem for me is that I want to map the Wacom to different monitors depending on what I'm doing.

To deal with this, I wrote a Python3 script to map all attached Wacom devices to the monitor that currently contains the mouse cursor. The script acts as a toggle, so running the script again disables the mapping and reverts the Wacom input back to the default. That way I can lock the Wacom to one monitor, then disable the mapping and move the cursor to a different monitor, and lock the Wacom to that monitor.

I doubt if anyone else will ever have a use for this script, but I thought I'd post it here just in case.

Code: Select all

#!/usr/bin/env python3
""""
    wacom_toggle_map_to_monitor.py
    
    A Python3 script to toggle the mapping of all connected Wacom
    devices to the monitor containing the mouse cursor.

    This script requires the utilities 'xinput' and 'notify-send'.

    OS: Linux (Tested on Linux Mint 20.1 XFCE)

    Released: 2021-04-06_09-27-58
"""

import sys
import subprocess
import numpy as np
from Xlib import display
import gi
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk

# Default 'Coordinate Transformation Matrix' values (ie. no mapping)
F_CTM_DEFAULT = np.array([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
S_CTM_DEFAULT = ['1', '0', '0', '0', '1', '0', '0', '0', '1']

def get_wacoms():
    """ Get the names of all connected Wacom devices from 'xinput'

        Returns: list of strings
    """

    # Get a list of device names from xinput (NO PYTHON LIBRARY TO DO THIS IN XINPUT)
    xinput = subprocess.run(['xinput',
                             '--list',
                             '--name-only'],
                            check=False,
                            encoding='UTF-8',
                            capture_output=True)

    if xinput.returncode:
        print('get_wacoms() : xinput error: {0}'.format(xinput.stderr))
        sys.exit(xinput.returncode)

    xlines = xinput.stdout

    # Find any line containing 'wacom' and store it in a list
    wacoms = []
    for line in xlines.split('\n'):
        if 'wacom' in line.lower():
            wacoms.append(line)

    return wacoms

def get_cursor_monitor_name():
    """ Get the name of the monitor containing the mouse cursor.

        Returns: string
    """

    # Get the current mouse cursor position
    data = display.Display().screen().root.query_pointer()
    cursor_x = data.root_x
    cursor_y = data.root_y

    # Try to find which monitor contains the cursor
    cmon = ''
    gdkdsp = Gdk.Display.get_default()

    for i in range(gdkdsp.get_n_monitors()):
        monitor = gdkdsp.get_monitor(i)
        scale = monitor.get_scale_factor()
        geo = monitor.get_geometry()

        # Calculate the coordinates of this monitor
        xmin = geo.x * scale
        xmax = xmin + (geo.width * scale)
        ymin = geo.y * scale
        ymax = ymin + (geo.height * scale)

        # Check if the cursor is within this monitor's coordinates
        if xmin <= cursor_x < xmax and ymin <= cursor_y < ymax:
            # Get the name of the monitor containing the cursor
            cmon = monitor.get_model()
            break

    return cmon

def is_mapping_on(wacoms):
    """ Check if any of the connected Wacom devices have a non-default
        Coordinate Transformation Matrix, indicating that they have
        already been mapped to a monitor

        Parameters: string list

        Returns: boolean
    """

    for wacd in wacoms:
        xinput = subprocess.run(['xinput',
                                 'list-props',
                                 wacd],
                                check=False,
                                encoding='UTF-8',
                                capture_output=True)

        if xinput.returncode:
            print('is_mapping_on(wacoms) : xinput error: {0}'.format(xinput.stderr))
            sys.exit(xinput.returncode)

        xlines = xinput.stdout

        for line in xlines.split('\n'):
            if 'Coordinate Transformation Matrix' in line:
                # Get the xinput result after ';', then remove whitespace,
                # and then convert to list using ',' as separator
                line = (line.split(':', 1)[-1]).strip().split(',')

                # Convert list to array of floats
                ctm = np.array(line, dtype=float)

                # Check if ctm float array matches f_default_CTM array
                comparison = F_CTM_DEFAULT == ctm
                if not comparison.all(): # ctm has been changed
                    return True

    return False

def set_mapping_off(wacoms):
    """ Undo any mapping of connected Wacom devices by setting their
        Coordinate Transformation Matrix to the default.

        Parameters: string list

        Returns: none
    """

    wmsg = ''

    for wacd in wacoms:
        xinput = subprocess.run(['xinput',
                                 'set-prop',
                                 wacd,
                                 'Coordinate Transformation Matrix']
                                + S_CTM_DEFAULT,
                                check=False,
                                encoding='UTF-8',
                                capture_output=True)

        if xinput.returncode:
            print('set_mapping_off(wacoms) : xinput error: {0}'.format(xinput.stderr))
            sys.exit(xinput.returncode)

        wmsg += '\n<b>' + wacd + '</b>'

    notify('Wacom Mapping Disabled',
           '\nThe Wacom devices:\n{0}\n\nare not mapped to'
           ' a monitor'.format(wmsg))

def set_mapping_on(wacoms, monitor):
    """
        Map each listed Wacom device to the specified monitor

        Parameters: string list, string

        Returns: none
    """

    wmsg = ''

    for wacd in wacoms:
        xinput = subprocess.run(['xinput',
                                 'map-to-output',
                                 wacd, monitor],
                                check=False,
                                encoding='UTF-8',
                                capture_output=True)

        if xinput.returncode:
            print('set_mapping_on(wacoms, monitor) : xinput error: {0}'.format(xinput.stderr))
            sys.exit(xinput.returncode)

        wmsg += '\n<b>' + wacd + '</b>'

    notify('Wacom Mapping Enabled',
           '\nThe Wacom devices:\n{0}\n\nhave been mapped to'
           ' the monitor connected at\n\n<b>{1}</b>'.format(wmsg, monitor))

def notify(title, message):
    """
        Send a desktop notification re tablet mapping state.

        Parameters: string, string

        Returns: none

    """
    send = subprocess.run(['notify-send',
                           '--urgency=normal',
                           '--expire-time=5000',
                           '--icon=input-tablet',
                           '--category=device',
                           title,
                           message],
                          check=False,
                          encoding='UTF-8',
                          capture_output=True)
    if send.returncode:
        print('notify(title, message) : notify-send error: {0}'.format(send.stderr))
        sys.exit(send.returncode)

def main():
    """ It's the main function, innit... """

    wacoms = get_wacoms()

    if is_mapping_on(wacoms):
        set_mapping_off(wacoms)
    else:
        monitor = get_cursor_monitor_name()
        if monitor:
            set_mapping_on(wacoms, monitor)
        else:
            print('Error: could not get name of cursor monitor')
            sys.exit(1)

    sys.exit(0)

if __name__ == "__main__":
    # execute only if run as a script
    main()