#!/usr/bin/env python3
'Read gestures from libinput touchpad and action shell commands.'
# Mark Blakeney, Sep 2015
import os, sys, argparse, subprocess, shlex, re, getpass, fcntl, platform, math
from collections import OrderedDict
from pathlib import Path
from distutils.version import LooseVersion as Version

PROG = Path(sys.argv[0]).stem

# Conf file containing gesture commands.
# Search first for user file then system file.
CONFNAME = '{}.conf'.format(PROG)
USERDIR = os.getenv('XDG_CONFIG_DIR', os.path.expanduser('~/.config'))
CONFDIRS = (USERDIR, '/etc')

# Ratio of X/Y (or Y/X) at which we consider an oblique swipe gesture.
# The number is the trigger angle in degrees.
OBLIQUE_RATIO = math.tan(math.radians(22.5))

# Minimum significant distance to move for swipes, in dots squared.
ABZSQUARE = 70**2

# Rotation threshold in degrees to discriminate pinch rotate from in/out
ROTATE_ANGLE = 15.0

# Set up command line arguments
opt = argparse.ArgumentParser(description=__doc__)
opt.add_argument('-c', '--conffile',
        help='alternative configuration file')
opt.add_argument('-v', '--verbose', action='store_true',
        help='output diagnostic messages')
opt.add_argument('-d', '--debug', action='store_true',
        help='output diagnostic messages only, do not action gestures')
opt.add_argument('-r', '--raw', action='store_true',
        help='output raw libinput debug-event messages only, '
        'do not action gestures')
opt.add_argument('-l', '--list', action='store_true',
        help='just list out available and configured gestures')
opt.add_argument('-e', '--env', action='store_true',
        help='just list out environment for diagnostic purposes')
opt.add_argument('--device',
        help='explicit device name to use (or path if starts with /)')
args = opt.parse_args()

if args.debug or args.raw or args.env:
    args.verbose = True

def open_lock(*args):
    'Create a lock based on given list of arguments'
    # We use exclusive assess to a file for this
    fp = Path('/tmp', '-'.join(args) + '.lock').open('w')
    try:
        fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except IOError:
        return None

    return fp

def run(cmd, check=True):
    'Run subprocess function and return standard output or None'
    # Maintain subprocess compatibility with python 3.4 so use
    # check_output() rather than run().
    try:
        stdout = subprocess.check_output(cmd, universal_newlines=True,
                stderr=(None if check else subprocess.DEVNULL))
    except Exception as e:
        stdout = None
        if check:
            print(str(e), file=sys.stderr)

    return stdout

def get_libinput_vers():
    'Return the libinput installed version number string'
    # Try to use newer libinput interface then fall back to old
    # (depreciated) interface.
    res = run(('libinput', '--version'), check=False)
    return res.strip() if res else run(('libinput-list-devices', '--version'))

# Libinput changed the way in which it's utilities are called
libvers = get_libinput_vers()
if Version(libvers) >= Version('1.8'):
    cmd_debug_events = 'libinput debug-events'
    cmd_list_devices = 'libinput list-devices'
else:
    cmd_debug_events = 'libinput-debug-events'
    cmd_list_devices = 'libinput-list-devices'

def get_devices_list():
    'Get list of devices and their attributes (as a dict) from libinput'
    stdout = run(cmd_list_devices.split())
    if stdout:
        dev = {}
        for line in stdout.splitlines():
            line = line.strip()
            if line and ':' in line:
                key, value = line.split(':', maxsplit=1)
                dev[key.strip().lower()] = value.strip()
            elif dev:
                yield(dev)
                dev = {}

def get_device(name):
    'Determine libinput touchpad device'
    devices = list(get_devices_list())

    if not devices:
        print('Can not see any devices, did you add yourself to the '
                'input group and log out/in?', file=sys.stderr)
        return None

    # If a specific device name was asked for then return that device
    # This is the "Device" name from libinput list-devices command.
    if name:
        kdev = str(Path(name).resolve()) if name[0] == '/' else None
        for d in devices:
            # If the device name starts with a '/' then it is instead
            # considered as the explicit device path although since
            # device paths can change through reboots this is best to be
            # a symlink. E.g. use the corresponding full path link under
            # /dev/input/by-path/*.
            if kdev:
                if d.get('kernel') == kdev:
                    return d
            elif d.get('device') == name:
                return d
        return None

    # Otherwise look for 1st device with touchpad capabilities
    for d in devices:
        if 'size' in d and d.get('capabilities') == 'pointer':
            return d

    # Otherwise look for 1st device with touchpad in it's name
    for d in devices:
        if re.search(r'touch ?pad', d.get('device', ''), re.I):
            return d

    # Give up
    return None

class COMMAND:
    'Generic command handler'
    def __init__(self, args):
        self.argslist = args

    def run(self):
        'Run this command + arguments'
        run(self.argslist)

    def __str__(self):
        'Create string representation'
        return ' '.join(self.argslist)

# Table of internal commands
internal_commands = OrderedDict()

def add_internal_command(cls):
    'Add configuration command to command lookup table based on name'
    internal_commands[re.sub('^COMMAND', '', cls.__name__)] = cls

@add_internal_command
class COMMAND_internal(COMMAND):
    'Internal command handler.'
    def __init__(self, args):
        'Action internal swipe commands'
        super().__init__(args)

        # Commands currently supported are:
        commands = (
            'ws_up',
            'ws_down',
        )
        # Set up command line arguments
        opt = argparse.ArgumentParser(prog=args[0], description=self.__doc__)
        opt.add_argument('-w', '--wrap', action='store_true',
                help='Wrap workspaces when switching to/from start/end')
        opt.add_argument('--row', type=int,
                help='Step along the row for this number in a row')
        opt.add_argument('--col', type=int,
                help='Step along the column for this number in a column')
        opt.add_argument('action', choices=commands,
                help='Internal command to action')
        self.args = opt.parse_args(args[1:])
        self.is_up = self.args.action == commands[0]

    def run(self):
        'Get list of current workspaces and select next one'
        stdout = run(('wmctrl', '-d'), check=False)
        if not stdout:
            # This command can fail on GNOME when you have only a single
            # dynamic workspace using Xorg (probably a GNOME bug) so let's
            # just ignore it given there is no other workspace to switch to
            # anyhow.
            return

        out = stdout.strip().splitlines()
        lines = [l.split(maxsplit=3)[1] for l in out]
        index = lines.index('*')

        if self.args.row:
            minindex = index - (index % self.args.row)
            maxindex = minindex + self.args.row
            count = 1
        else:
            minindex = 0
            maxindex = len(lines)
            count = (maxindex // self.args.col) if self.args.col else 1

        # Work out desired workspace
        index += count if self.is_up else -count
        if self.args.wrap:
            if index == minindex - count:
                index = maxindex - count
            elif index >= maxindex:
                index += minindex - maxindex
            elif index < minindex:
                index += maxindex - minindex

        # Switch to desired workspace
        if index >= minindex and index < maxindex:
            run(('wmctrl', '-s', str(index)))

# Table of gesture handlers
handlers = OrderedDict()

def add_gesture_handler(cls):
    'Create gesture handler instance and add to lookup table based on name'
    handlers[cls.__name__] = cls()

class GESTURE:
    'Abstract base class for handling for gestures'
    def __init__(self):
        'Initialise this gesture at program start'
        self.name = type(self).__name__
        self.motions = OrderedDict()
        self.has_extended = False

    def add(self, motion, fingers, command):
        'Add a configured motion command for this gesture'
        if motion not in self.SUPPORTED_MOTIONS:
            return 'Gesture {} does not support motion "{}".\n' \
                    'Must be "{}"'.format(self.name.lower(), motion,
                            '" or "'.join(self.SUPPORTED_MOTIONS))
        if not command:
            return 'No command configured'

        # If any extended gestures configured then set flag to enable
        # their discrimination
        if self.extended_text in motion:
            self.has_extended = True

        key = (motion, fingers) if fingers else motion
        cmds = shlex.split(command)
        cls = internal_commands.get(cmds[0], COMMAND)
        self.motions[key] = cls(cmds)
        return None

    def begin(self, fingers):
        'Initialise this gesture at the start of motion'
        self.fingers = fingers
        self.data = [0.0, 0.0]

    def action(self, motion):
        'Action a motion command for this gesture'
        command = self.motions.get((motion, self.fingers)) or \
                self.motions.get(motion)

        if args.verbose:
            print('{}: {} {} {} {}'.format(PROG, self.name, motion,
                self.fingers, self.data))
            if command:
                print('  ', command)

        if command and not args.debug:
            command.run()

@add_gesture_handler
class SWIPE(GESTURE):
    'Class to handle this type of gesture'
    SUPPORTED_MOTIONS = ('left', 'right', 'up', 'down',
            'left_up', 'right_up', 'left_down', 'right_down')
    extended_text = '_'

    def update(self, coords):
        'Update this gesture for a motion'
        self.data[0] += float(coords[2])
        self.data[1] += float(coords[3])

    def end(self):
        'Action this gesture at the end of a motion sequence'
        x, y = self.data
        abx = abs(x)
        aby = abs(y)

        # Require absolute distance movement beyond a small thresh-hold.
        if abx**2 + aby**2 < ABZSQUARE:
            return

        # Discriminate left/right or up/down.
        # If significant movement in both planes the consider it a
        # oblique swipe (but only if any are configured)
        if abx > aby:
            motion = 'left' if x < 0 else 'right'
            if self.has_extended and aby / abx > OBLIQUE_RATIO:
                motion += ('_up' if y < 0 else '_down')
        else:
            motion = 'up' if y < 0 else 'down'
            if self.has_extended and abx / aby > OBLIQUE_RATIO:
                motion = ('left_' if x < 0 else 'right_') + motion

        self.action(motion)

@add_gesture_handler
class PINCH(GESTURE):
    'Class to handle this type of gesture'
    SUPPORTED_MOTIONS = ('in', 'out', 'clockwise', 'anticlockwise')
    extended_text = 'clock'

    def update(self, coords):
        'Update this gesture for a motion'
        self.data[0] += float(coords[4]) - 1.0
        self.data[1] += float(coords[5])

    def end(self):
        'Action this gesture at the end of a motion sequence'
        ratio, angle = self.data

        if self.has_extended and abs(angle) > ROTATE_ANGLE:
            self.action('clockwise' if angle >= 0.0 else 'anticlockwise')
        elif ratio != 0.0:
            self.action('in' if ratio <= 0.0 else 'out')

# Table of configuration commands
conf_commands = OrderedDict()

def add_conf_command(func):
    'Add configuration command to command lookup table based on name'
    conf_commands[re.sub('^conf_', '', func.__name__)] = func

@add_conf_command
def conf_gesture(lineargs):
    'Process a single gesture line in conf file'
    fields = lineargs.split(maxsplit=2)

    # Look for configured gesture. Sanity check the line.
    if len(fields) < 3:
        return 'Invalid gesture line - not enough fields'

    gesture, motion, command = fields
    handler = handlers.get(gesture.upper())

    if not handler:
        return 'Gesture "{}" is not supported.\nMust be "{}"'.format(
                gesture, '" or "'.join([h.lower() for h in handlers]))

    # Gesture command can be configured with optional specific finger
    # count so look for that
    fingers, *fcommand = command.split(maxsplit=1)
    if fingers.isdigit() and len(fingers) == 1:
        command = fcommand[0] if fcommand else ''
    else:
        fingers = None

    # Add the configured gesture
    return handler.add(motion.lower(), fingers, command)

@add_conf_command
def conf_device(lineargs):
    'Process a single device line in conf file'
    # Command line overrides configuration file
    if not args.device:
        args.device = lineargs

    return None if args.device else 'No device specified'

def get_conf_line(line):
    'Process a single line in conf file'
    key, *argslist = line.split(maxsplit=1)
    key = key.rstrip(':')
    conf_func = conf_commands.get(key)

    if not conf_func:
        return 'Configuration command "{}" is not supported.\n' \
                'Must be "{}"'.format(key, '" or "'.join(conf_commands))

    return conf_func(argslist[0] if argslist else '')

def get_conf(conffile):
    'Read given configuration file and store internal actions etc'
    with conffile.open() as fp:
        for num, line in enumerate(fp, 1):
            line = line.strip()
            if not line or line[0] == '#':
                continue

            errmsg = get_conf_line(line)
            if errmsg:
                sys.exit('Error at line {} in file {}:\n>> {} <<\n{}.'.format(
                    num, conffile, line, errmsg))

def unexpanduser(cfile):
    'Return absolute path name, with $HOME replaced by ~'
    relslash = cfile.resolve()
    try:
        relhome = relslash.relative_to(os.getenv('HOME'))
    except:
        relhome = None

    return ('~/' + str(relhome)) if relhome else str(relslash)

if args.verbose:
    xsession = os.getenv('XDG_SESSION_DESKTOP') or \
            os.getenv('DESKTOP_SESSION') or 'unknown'
    xtype = os.getenv('XDG_SESSION_TYPE') or 'unknown'
    print('{}: session {}+{} on {}, python {}, libinput {}'.format(PROG,
        xsession, xtype, platform.platform(), platform.python_version(),
        libvers))

# Search for configuration file. Use file given as command line
# argument, else look for file in search dir order.
if args.conffile:
    conffile = Path(args.conffile)
    if not conffile.exists():
        sys.exit('Conf file "{}" does not exist.'.format(conffile))
else:
    for confdir in CONFDIRS:
        conffile = Path(confdir, CONFNAME)
        if conffile.exists():
            break
    else:
        sys.exit('No file {} in {}.'.format(CONFNAME, ' or '.join(
            [unexpanduser(Path(c)) for c in CONFDIRS])))

# Hide any personal user dir/names from diag output
confname = unexpanduser(conffile)

# Read and process the conf file
get_conf(conffile)

if args.verbose:
    print('{}: using {} with {} gestures'.format(PROG,
        confname, sum([len(h.motions) for h in handlers.values()])))

# Just list out available gestures if that is asked for
if args.list:
    print('Gestures configured in {}:'.format(confname))
    for h in handlers.values():
        for mpair, cmd in h.motions.items():
            motion, fingers = (mpair, '') if isinstance(mpair, str) else mpair
            print('{} {:10}{:>2} {}'.format(h.name.lower(), motion,
                fingers, cmd))
    sys.exit()

# Get touchpad device
if not args.device or args.device.lower() != "all":
    device = get_device(args.device)
    if not device:
        sys.exit('Could not determine touchpad device.')
else:
    device = None

if args.verbose:
    if device:
        print('{}: device {}: {}'.format(PROG, device.get('kernel'),
            device.get('device')))
    else:
        print('{}: monitoring all devices'.format(PROG))

# If just called to list out above environment info then exit now
if args.env:
    sys.exit()

# Make sure only one instance running for current user
user = getpass.getuser()
proglock = open_lock(PROG, user)
if not proglock:
    sys.exit('{} is already running for {}, terminating ..'.format(PROG, user))

# Note your must "sudo gpasswd -a $USER input" then log out/in for
# permission to access the device.
devstr = ' --device {}'.format(device.get('kernel')) if device else ''
command = 'stdbuf -oL -- {}{}'.format(cmd_debug_events, devstr)

cmd = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE,
        bufsize=0, universal_newlines=True)

# Sit in a loop forever reading the libinput messages ..
handler = None
for line in cmd.stdout:

    # Just output raw messages if in that mode
    if args.raw:
        print(line.strip())
        continue

    # Only interested in gestures
    if 'GESTURE_' not in line:
        continue

    # Split received message line into relevant fields
    dev, gevent, time, other = line.strip().split(maxsplit=3)
    gesture, event = gevent[8:].split('_')
    fingers, *argslist = other.split(maxsplit=1)
    params = argslist[0] if argslist else ''

    # Action each type of event
    if event == 'UPDATE':
        if handler:
            # Split parameters into list of clean numbers
            handler.update(re.split(r'[^-.\d]+', params))
    elif event == 'BEGIN':
        handler = handlers.get(gesture)
        if handler:
            handler.begin(fingers)
        else:
            print('Unknown gesture received: {}.'.format(gesture),
                    file=sys.stderr)
    elif event == 'END':
        # Ignore gesture if final action is cancelled
        if handler and params != 'cancelled':
            handler.end()
        handler = None
    else:
        print('Unknown gesture + event received: {} + {}.'.format(gesture,
            event), file=sys.stderr)
