#!/usr/bin/env python
# xpaste - paste text into X11 windows that don't work with selections
# Copyright (C) 2016,2018  Walter Doekes, OSSO B.V.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
#
# xpaste takes input from stdin and "pastes" it by sending keypress
# events to the window of your choosing. This works around the fact
# that some applications don't accept selection/clipboard pastes.
#
# See README.rst or xpaste.1x for more info. See LICENSE for license.
#
# Sample code borrowed from:
# - http://www.shallowsky.com/software/crikey/pykey-0.1 (GPLv2+, 2008)
#   by Akkana Peck
# Similar applications:
# - xdotool, almost works, but lacks the ability to choose the right
#   window. If we got that right, we could do:
#   "xdotool type --window WIN --file - <<EOF"
#
from __future__ import print_function

from collections import namedtuple
import os
import sys
import time

import Xlib.display
import Xlib.X
import Xlib.XK
import Xlib.protocol.event


# Display-aware keyboard symbol.
Keystate = namedtuple('Keystate', 'mod code')


class TextToKeysyms(object):
    special_X_keysyms = {
        # This dict is collapsed into fewer lines to make a
        # BetterCodeHub false positive go away.
        ' ': 'space', '\t': 'Tab',
        # Keyboard Enter maps to Return, which is usually translated to
        # LF when typing, so in the reverse we do that too.
        '\n': 'Return',
        # Mark this bad so we don't paste it accidentally.
        '\r': Xlib.X.NoSymbol,
        '\e': 'Escape', '!': 'exclam', '#': 'numbersign',
        '%': 'percent', '$': 'dollar', '&': 'ampersand', '"': 'quotedbl',
        "'": 'apostrophe', '(': 'parenleft', ')': 'parenright',
        '*': 'asterisk', '=': 'equal', '+': 'plus', ',': 'comma', '-': 'minus',
        '.': 'period', '/': 'slash', ':': 'colon', ';': 'semicolon',
        '<': 'less', '>': 'greater', '?': 'question', '@': 'at',
        '[': 'bracketleft', ']': 'bracketright', '\\': 'backslash',
        '^': 'asciicircum', '_': 'underscore', '`': 'grave', '{': 'braceleft',
        '|': 'bar', '}': 'braceright', '~': 'asciitilde'
    }

    def __call__(self, text):
        return [self.to_keysym(ch) for ch in text]

    def to_keysym(self, ch):
        "The X-keysymbol for this character."
        # Example: ch in ('A', 'b', '%', '\n')
        keysym = self.special_X_keysyms.get(ch, ch)
        # Example: keysym in ('A', 'b', 'percent', 'Return')
        ret = Xlib.XK.string_to_keysym(keysym)
        # Example: ret in (65, 98, 37, 65293)  [not always equal to ord(ch)]
        return ret


# A functor as text-to-keys converter.
text_to_keysyms = TextToKeysyms()


class KeysToKeystates(object):
    def __init__(self, display):
        self.display = display

    def __call__(self, keys):
        return [self.to_keystate(key) for key in keys]

    def to_keystate(self, symbol):
        # Example: [(38, 1), (38, 3)]  # for 65 ('A')
        # Example: [(56, 0), (56, 2)]  # for 98 ('b')
        # Example: [(14, 1), (14, 3)]  # for 37 ('%')
        # Example: [(36, 0), (36, 2)]  # for 65293 (Return)
        codes = list(self.display.keysym_to_keycodes(symbol))
        assert codes, 'cannot map %r (got: %r)' % (symbol, codes)
        code, mod = codes[0]
        assert mod in (0, Xlib.X.ShiftMask), codes
        return Keystate(mod, code)


class EventGenerator(object):
    def __init__(self, display, window):
        self.display = display
        self.window = window
        self.root = display.screen().root
        self.keys_to_keystates = KeysToKeystates(display)
        self.timedelta = 50  # 50 ms per event
        self.time = (int(time.time() * 1000) - self.timedelta) % 0x100000000
        self.rshift_code = self.display.keysym_to_keycode(Xlib.XK.XK_Shift_R)

    def from_text(self, text):
        for symbol in text_to_keysyms(text):
            keystate = self.keys_to_keystates.to_keystate(symbol)
            for event in self.keypress(keystate):
                yield event

    def keypress(self, keystate):
        # Some applications explicitly need the SHIFT key pressed as well.
        if keystate.mod & Xlib.X.ShiftMask:
            yield self._new_press(state=0, detail=self.rshift_code)

        yield self._new_press(state=keystate.mod, detail=keystate.code)
        yield self._new_release(state=keystate.mod, detail=keystate.code)

        if keystate.mod & Xlib.X.ShiftMask:
            yield self._new_release(
                state=Xlib.X.ShiftMask, detail=self.rshift_code)

    def _new_press(self, state, detail):
        return self._new_event(Xlib.protocol.event.KeyPress, state, detail)

    def _new_release(self, state, detail):
        return self._new_event(Xlib.protocol.event.KeyRelease, state, detail)

    def _new_event(self, event, state, detail):
        return event(
            time=self.key_time(), root=self.root, window=self.window,
            same_screen=0, child=Xlib.X.NONE,
            root_x=0, root_y=0, event_x=0, event_y=0,
            state=state, detail=detail)

    def key_time(self):
        self.time = (self.time + self.timedelta) % 0x100000000
        return self.time


class XPaste(object):
    @staticmethod
    def get_display():
        """Wrapper around Xlib.display.Display() to silence debug-print bug.

        Display() prints "<class ...>" to stdout.
        URL: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=566172
        This is fixed in 0.14+20091101-4. Work around it by temporarily
        setting stdout to /dev/null.
        """
        with open('/dev/null', 'w') as tmp_stdout:
            orig_stdout, sys.stdout = sys.stdout, tmp_stdout
            display = Xlib.display.Display()
            sys.stdout = orig_stdout
        return display

    def __init__(self):
        self.display = self.get_display()

        # Quickly check that this will work.
        if not hasattr(self.display.get_input_focus().focus, 'send_event'):
            if os.environ.get('XDG_SESSION_TYPE') == 'wayland':
                # Because we cannot just peek/poke in other windows on
                # Wayland.
                raise RuntimeError(
                    'xpaste(1) does not work on Wayland, try starting an '
                    'Xwayland X compatibility terminal first, by running '
                    'WAYLAND_DISPLAY= gnome-terminal; and then fire up '
                    'both applications from there.')
            raise RuntimeError('No send_event on window objects? {0!r}'.format(
                self.display.get_input_focus().focus))

    def get_current_window(self):
        "Return currently focused window."
        return self.display.get_input_focus().focus

    def wait_for_keypress(self):
        "Stall until 'Enter' is pressed."
        enter_keystates = KeysToKeystates(self.display)([
            Xlib.XK.XK_Return, Xlib.XK.XK_KP_Enter])

        self._grab_release(enter_keystates, release=False)
        try:
            self._wait_for_keyrelease_event(enter_keystates)
        finally:
            self._grab_release(enter_keystates, release=True)

    def _grab_release(self, keystates, release=False):
        "Grab or release the listening for keypress events."
        root = self.display.screen().root

        # Through testing, it appears that using ORed modifiers does
        # not yield grab_key events if not *all* of the modifiers are
        # used. Instead, we'll grab_key() all of the sane combinations.
        #
        # Also, using Xlib.X.AnyModifier gives us a BadAccess error,
        # possibly because the main window manager also traps that?
        # > If some other client has issued a XGrabKey() with the same
        # > key combination on the same window, a BadAccess error
        # > results. When using AnyModifier or AnyKey, the request fails
        # > completely, and a BadAccess error results (no grabs are
        # > established) if there is a conflicting grab for any
        # > combination.
        extra_mods = (
            # All combinations of caps- and num-lock.
            0, Xlib.X.Mod2Mask, Xlib.X.LockMask,
            Xlib.X.Mod2Mask | Xlib.X.LockMask)

        if release:
            # Stop listening for the specified key.
            for extra_mod in extra_mods:
                for keystate in keystates:
                    root.ungrab_key(keystate.code, keystate.mod | extra_mod)
        else:
            # Listen for the specified key.
            for extra_mod in extra_mods:
                for keystate in keystates:
                    root.grab_key(
                        keystate.code, keystate.mod | extra_mod,
                        True, Xlib.X.GrabModeAsync, Xlib.X.GrabModeAsync)

    def _wait_for_keyrelease_event(self, keystates):
        "After the grab, wait for the expected keypresses."
        codes = [keystate.code for keystate in keystates]
        while True:
            event = self.display.next_event()
            if event.type == Xlib.X.KeyRelease and event.detail in codes:
                # Ignore event.window and event.child. They are not the
                # input windows we're after.
                break

    def paste_into(self, window, text):
        "Paste the text into window by faking key presses."
        # print('paste into', window)
        for event in EventGenerator(self.display, window).from_text(text):
            window.send_event(event, propagate=True)
        self.display.sync()


# def example_paste_into_focused():
#     xpaste = XPaste()
#     xpaste.paste_into(xpaste.get_current_window(), 'Hi there!')
#
# def example_paste_into_keypressed():
#     xpaste = XPaste()
#     xpaste.wait_for_keypress()
#     xpaste.paste_into(xpaste.get_current_window(), 'Hi there!')

def main():
    # Open display immediately, so user gets error if there is no valid
    # DISPLAY.
    xpaste = XPaste()

    if sys.stdin.isatty():
        print(
            '| xpaste allows you to paste text into windows that fail to\n'
            '| work with the X11 selection buffers, like some Java apps.\n'
            '|\n'
            '| Example invocation: xsel -b | xpaste\n'
            '| Go to misbehaving application: <press enter>\n'
            '|\n'
            '| Please type text to paste on stdin; end with [CTRL+D]')
    text = sys.stdin.read().replace('\r', '')

    if sys.stdin.isatty():
        print('|')
    print(
        '| Focus on the destination window to paste into and press [Enter]\n'
        '| or abort with [CTRL+C].')

    if not text.endswith('\n'):
        print(
            '|\n'
            '| NOTE: The input does not end with a line feed. You may need\n'
            '| to press [Enter] twice.')
    xpaste.wait_for_keypress()
    xpaste.paste_into(xpaste.get_current_window(), text)


if __name__ == '__main__':
    main()
