diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..759bd60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/* +data/**/* diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..3d94c0f --- /dev/null +++ b/Pipfile @@ -0,0 +1,20 @@ +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[packages] + +evdev = "*" +xlib = "*" + + +[dev-packages] + + + +[requires] + +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..a22d616 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,43 @@ +{ + "_meta": { + "hash": { + "sha256": "5364a12c57cbeca5c66a33ca1fccbd7cdf27cc83c2279c959a97b0ff1a140903" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "evdev": { + "hashes": [ + "sha256:2dd67291be20e70643e8ef6f2381efc10e0c6e44a32abb3c1db74996ea3b0351" + ], + "index": "pypi", + "version": "==1.1.2" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "xlib": { + "hashes": [ + "sha256:60b7cd5d90f5d5922d9ce27b61589c07d970796558d417461db7b66e366bc401", + "sha256:8eee67dad83ef4b82bbbfa85d51eeb20c79d12b119fe25aa1d27bd602ff82212" + ], + "index": "pypi", + "version": "==0.21" + } + }, + "develop": {} +} diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clipboard.py b/clipboard.py new file mode 100644 index 0000000..b511313 --- /dev/null +++ b/clipboard.py @@ -0,0 +1,27 @@ +import subprocess + +def copy(string, target=None): + extra_args = [] + if target != None: + extra_args += ['-target', target] + + return subprocess.run( + ['xclip', '-selection', 'c'] + extra_args, + universal_newlines=True, + input=string + ) + +def get(target=None): + extra_args = [] + if target != None: + extra_args += ['-target', target] + + result = subprocess.run( + ['xclip', '-selection', 'c', '-o'] + extra_args, + stdout=subprocess.PIPE, + universal_newlines=True + ) + + # returncode = result.returncode + stdout = result.stdout.strip() + return stdout diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..ae9ad52 --- /dev/null +++ b/constants.py @@ -0,0 +1,61 @@ +KEYSYM_MAP = { + 65307: "ESC", + 32: "SPACE", + 39: "'", + 44: ",", + 45: "-", + 46: ".", + 47: "/", + 48: "0", + 49: "1", + 50: "2", + 51: "3", + 52: "4", + 53: "5", + 54: "6", + 55: "7", + 56: "8", + 57: "9", + 59: ";", + 61: "=", + 91: "[", + 92: "\\", + 93: "]", + 96: "`", + 97: "a", + 98: "b", + 99: "c", + 100: "d", + 101: "e", + 102: "f", + 103: "g", + 104: "h", + 105: "i", + 106: "j", + 107: "k", + 108: "l", + 109: "m", + 110: "n", + 111: "o", + 112: "p", + 113: "q", + 114: "r", + 115: "s", + 116: "t", + 117: "u", + 118: "v", + 119: "w", + 120: "x", + 121: "y", + 122: "z", +} + +TARGET = 'image/x-inkscape-svg' + +NORMAL = '' +VIM = 'Vim' +STYLE = 'Style' +SAVE_STYLE = 'Save style' +OBJECT = 'Object' +SAVE_OBJECT = 'Save object' +DISABLED = 'Disabled' diff --git a/constants.pyc b/constants.pyc new file mode 100644 index 0000000..9608b4e Binary files /dev/null and b/constants.pyc differ diff --git a/disabled.py b/disabled.py new file mode 100644 index 0000000..6c22879 --- /dev/null +++ b/disabled.py @@ -0,0 +1,16 @@ +from Xlib import X +from constants import KEYSYM_MAP, NORMAL +from mode import mode +import normal + +def disabled_mode(event, keysym, manager): + if keysym in KEYSYM_MAP: + character = KEYSYM_MAP.get(keysym, 0) + + if event.type == X.KeyPress: + if character == '`': + mode(NORMAL) + manager.teardown() + manager.listen(normal.normal_mode) + elif event.type == X.KeyRelease: + pass diff --git a/main.py b/main.py new file mode 100644 index 0000000..aa3c29f --- /dev/null +++ b/main.py @@ -0,0 +1,76 @@ +from Xlib.display import Display +from Xlib import X +from Xlib.ext import record +from Xlib.protocol import rq + +import time +from constants import KEYSYM_MAP, NORMAL + +from mode import mode +from normal import normal_mode + +class ShortcutManager(): + def __init__(self): + self.disp2 = Display() + + def is_inkscape(self): + window = self.disp2.get_input_focus().focus + cls = window.get_wm_class() + return cls and cls[0] == 'inkscape' + + def meta_handler(self, handler): + def handle(reply): + print('yes') + data = reply.data + while len(data): + event, data = rq.EventField(None).parse_binary_value( + data, self.disp.display, None, None) + + if not self.is_inkscape(): + return + + keysym = self.disp.keycode_to_keysym(event.detail, 0) + handler(event, keysym, self) + + return handle + + def listen(self, handler): + self.disp = Display() + root = self.disp.screen().root + self.ctx = self.disp.record_create_context( + # datum_flags + 0, + # clients + [record.AllClients], + # rangers + [{ + 'core_requests': (0, 0), + 'core_replies': (0, 0), + 'ext_requests': (0, 0, 0, 0), + 'ext_replies': (0, 0, 0, 0), + 'delivered_events': (0, 0), + 'device_events': (0x02, 0x05), + 'errors': (0, 0), + 'client_started': False, + 'client_died': False, + }]) + + self.disp.record_enable_context(self.ctx, self.meta_handler(handler)) + self.disp.record_free_context(self.ctx) + + try: + while True: + time.sleep(1) + # Infinite wait, doesn't do anything as no events are grabbed + event = root.display.next_event() + except: + print('exception!') + + def teardown(self): + self.disp.record_disable_context(self.ctx) + +sm = ShortcutManager() +mode('INIT') +time.sleep(1) +mode(NORMAL) +sm.listen(normal_mode) diff --git a/mode.py b/mode.py new file mode 100644 index 0000000..7f585b2 --- /dev/null +++ b/mode.py @@ -0,0 +1,2 @@ +def mode(name): + print(name) diff --git a/mode.pyc b/mode.pyc new file mode 100644 index 0000000..bc1bbae Binary files /dev/null and b/mode.pyc differ diff --git a/normal.py b/normal.py new file mode 100644 index 0000000..f63d2be --- /dev/null +++ b/normal.py @@ -0,0 +1,199 @@ +from press import press +from Xlib import X +from evdev import ecodes + +from clipboard import copy +from constants import KEYSYM_MAP, NORMAL, VIM, SAVE_OBJECT, OBJECT, SAVE_STYLE, STYLE, DISABLED + +from mode import mode +from vim import open_vim +import styles +import disabled + +pressed = set() +shift = False + +def normal_mode(event, keysym, manager): + global shift + if event.state & X.ControlMask: + # there are modifiers + # eg. X.ControlMask + # ~or X.ShiftMask~ + return + + if keysym in KEYSYM_MAP: + character = KEYSYM_MAP.get(keysym, 0) + + if event.type == X.KeyPress: + if event.state & X.ShiftMask: + shift = True + + pressed.add(character) + + elif event.type == X.KeyRelease: + + if 'ESC' in pressed: + press(ecodes.KEY_F1) + pressed.clear() + + if character in pressed: + if len(pressed) >= 2: + fire(pressed) + else: + key = pressed.pop() + if key == 'w': + press(ecodes.KEY_F6) # pencil + if key == 'e': + press(ecodes.KEY_F5) # ellipse + if key == 'r': + press(ecodes.KEY_F4) # rectangle + if key == 't': + manager.teardown() + mode(VIM) + compile_latex = shift # shift-t compiles latex + open_vim(compile_latex) + mode(NORMAL) + manager.listen(normal_mode) + if key == 'y': + press(ecodes.KEY_F8) #text + + if key == '`': + manager.teardown() + mode(DISABLED) + manager.listen(disabled.disabled_mode) + + if shift and key == 'a': + mode(SAVE_OBJECT) + manager.teardown() + styles.save_object_mode() + shift = False + mode(NORMAL) + manager.listen(normal_mode) + elif key == 'a': + mode(OBJECT) + manager.teardown() + manager.listen(styles.object_mode) + + if shift and key == 's': + mode(SAVE_STYLE) + manager.teardown() + styles.save_style_mode() + shift = False + manager.listen(normal_mode) + mode(NORMAL) + elif key == 's': + mode(STYLE) + manager.teardown() + manager.listen(styles.style_mode) + + + if key == 'd': + press(ecodes.KEY_F7) # dropper + if key == 'f': + press(ecodes.KEY_F6, [ecodes.KEY_LEFTSHIFT]) # bezier + if key == 'h': + press(ecodes.KEY_H, [ecodes.KEY_LEFTSHIFT]) # flip + + if key == 'z': + press(ecodes.KEY_DELETE) #delete + + if key == 'x': + press(ecodes.KEY_5, [ecodes.KEY_LEFTSHIFT]) # snap + + if key == 'v': + press(ecodes.KEY_V, [ecodes.KEY_LEFTSHIFT]) # flip + + pressed.clear() + + if shift: + shift = False + + if not (event.state & X.ShiftMask): + shift = False + + +def fire(combination): + pt = 1.327 # pixels + w = 0.4 * pt + thick_width = 0.8 * pt + very_thick_width = 1.2 * pt + + style = { + 'stroke-opacity': 1 + } + + if {'s', 'a', 'd', 'g', 'h', 'x', 'e'} & combination: + style['stroke'] = 'black' + style['stroke-width'] = w + style['marker-end'] = 'none' + style['marker-start'] = 'none' + style['stroke-dasharray'] = 'none' + else: + style['stroke'] = 'none' + + if 'g' in combination: + w = thick_width + style['stroke-width'] = w + + if 'h' in combination: + w = very_thick_width + style['stroke-width'] = w + + if 'a' in combination: + style['marker-end'] = f'url(#marker-arrow-{w})' + + if 'x' in combination: + style['marker-start'] = f'url(#marker-arrow-{w})' + style['marker-end'] = f'url(#marker-arrow-{w})' + + if 'd' in combination: + style['stroke-dasharray'] = f'{w},{2*pt}' + + if 'e' in combination: + style['stroke-dasharray'] = f'{3*pt},{3*pt}' + + if 'f' in combination: + style['fill'] = 'black' + style['fill-opacity'] = 0.12 + if 'b' in combination: + style['fill'] = 'black' + style['fill-opacity'] = 1 + if not {'f', 'b'} & combination: + style['fill'] = 'none' + style['fill-opacity'] = 1 + + + if style['fill'] == 'none' and style['stroke'] == 'none': + return + + svg = ''' + + +''' + + if ('marker-end' in style and style['marker-end'] != 'none') or \ + ('marker-start' in style and style['marker-start'] != 'none'): + svg += f''' + + + + + + + +''' + + style_string = ';'.join('{}: {}'.format(key, value) + for key, value in sorted(style.items(), key=lambda x: x[0]) + ) + svg += f'' + + copy(svg, target='image/x-inkscape-svg') + press(ecodes.KEY_V, [ecodes.KEY_LEFTSHIFT, ecodes.KEY_LEFTCTRL]) diff --git a/press.py b/press.py new file mode 100644 index 0000000..9cec61a --- /dev/null +++ b/press.py @@ -0,0 +1,13 @@ +from evdev.uinput import UInput +from evdev import ecodes + +def press(key, modifiers=[], uinput=UInput()): + for mod in modifiers: + uinput.write(ecodes.EV_KEY, mod, 1) + uinput.write(ecodes.EV_KEY, key, 1) + uinput.write(ecodes.EV_KEY, key, 0) + for mod in modifiers: + uinput.write(ecodes.EV_KEY, mod, 0) + + # synchronize ... + uinput.syn() diff --git a/rofi.py b/rofi.py new file mode 100644 index 0000000..101c1c6 --- /dev/null +++ b/rofi.py @@ -0,0 +1,30 @@ +import subprocess + +def rofi(prompt, options, rofi_args=[], fuzzy=True): + optionstr = '\n'.join(option.replace('\n', ' ') for option in options) + args = ['rofi', '-sort', '-no-levenshtein-sort'] + if fuzzy: + args += ['-matching', 'fuzzy'] + args += ['-dmenu', '-p', prompt, '-format', 's', '-i'] + args += rofi_args + args = [str(arg) for arg in args] + + + result = subprocess.run(args, input=optionstr, stdout=subprocess.PIPE, universal_newlines=True) + returncode = result.returncode + stdout = result.stdout.strip() + + selected = stdout.strip() + try: + index = [opt.strip() for opt in options].index(selected) + except ValueError: + index = -1 + + if returncode == 0: + key = 0 + elif returncode == 1: + key = -1 + elif returncode > 9: + key = returncode - 9 + + return key, index, selected diff --git a/styles.py b/styles.py new file mode 100644 index 0000000..4b4bcd6 --- /dev/null +++ b/styles.py @@ -0,0 +1,119 @@ +from evdev import ecodes +from pathlib import Path +from time import sleep +import os +from Xlib import X + +from clipboard import copy, get +from constants import KEYSYM_MAP, TARGET, NORMAL +from press import press +from rofi import rofi +import normal + +from mode import mode + +pressed = [] + +script_path = Path(os.path.realpath(__file__)).parents[0] + +data_dirs = { + 'style': script_path / 'data' / 'styles', + 'object': script_path / 'data' / 'objects', +} + + +def check(what, manager, name): + files = list(data_dirs[what].iterdir()) + names = [f.stem for f in files] + + filtered = list(i for i, n in enumerate(names) if n.startswith(name)) + # print(name,', '.join(n for n in names if n.startswith(name))) + + if len(filtered) == 0: + pressed.clear() + return back_to_normal(manager) + + if len(filtered) == 1: + index = filtered[0] + copy(files[index].read_text(), target=TARGET) + if what == 'style': + press(ecodes.KEY_V, [ecodes.KEY_LEFTCTRL, ecodes.KEY_LEFTSHIFT]) + else: + press(ecodes.KEY_V, [ecodes.KEY_LEFTCTRL]) + + sleep(0.5) # Give the user some time when an object is added. + return back_to_normal(manager) + + +def back_to_normal(manager): + mode(NORMAL) + pressed.clear() + manager.teardown() + manager.listen(normal.normal_mode) + + +def paste_mode(what, event, keysym, manager): + if event.state & X.ControlMask: + # there are modifiers + # eg. X.ControlMask + # ~or X.ShiftMask~ + return + + if keysym in KEYSYM_MAP: + character = KEYSYM_MAP.get(keysym, 0) + + if event.type == X.KeyPress: + if character == 'ESC': + if len(pressed) == 0: + return back_to_normal(manager) + else: + pressed.clear() + else: + pressed.append(character) + return check(what, manager, ''.join(pressed)) + + elif event.type == X.KeyRelease: + pass + +def save_mode(what): + sleep(0.1) + press(ecodes.KEY_C, [ecodes.KEY_LEFTCTRL]) + sleep(0.1) + svg = get(TARGET) + if not 'svg' in svg: + return + + directory = data_dirs[what] + files = list(directory.iterdir()) + names = [f.stem for f in files] + key, index, name = rofi( + 'Save as', + names, + ['-theme', '~/.config/rofi/ribbon.rasi'], + fuzzy=False + ) + + if index != -1: + f = files[index]; + key, index, yn = rofi( + f'Overwrite {name}?', + ['y', 'n'], + ['-theme', '~/.config/rofi/ribbon.rasi', '-auto-select'], + fuzzy=False + ) + if yn == 'n': + return + + (directory / f'{name}.svg').write_text(get(TARGET)) + +def style_mode(event, keysym, manager): + paste_mode('style', event, keysym, manager) + +def object_mode(event, keysym, manager): + paste_mode('object', event, keysym, manager) + +def save_style_mode(): + save_mode('style') + +def save_object_mode(): + save_mode('object') diff --git a/testing.py b/testing.py new file mode 100644 index 0000000..dd6f3f8 --- /dev/null +++ b/testing.py @@ -0,0 +1,34 @@ +from Xlib.display import Display +from Xlib import X +import time +import signal +import sys + +disp=Display() +screen=disp.screen() +root=screen.root + +def handle_event(evt): + print(evt) + +def main(): + inkscapes = [ + w for w in screen.root.query_tree().children + if w.get_wm_class() and w.get_wm_class()[0] == 'inkscape' + ] + + print(inkscapes) + + for inkscape in inkscapes: + inkscape.grab_key(10, X.NONE, True,X.GrabModeAsync, X.GrabModeAsync) + + signal.signal(signal.SIGALRM, lambda a,b:sys.exit(1)) + signal.alarm(10) + # grab_key(62, X.NONE) + while True: + evt=root.display.next_event() + if evt.type in [X.KeyPress, X.KeyRelease]: #ignore X.MappingNotify(=34) + handle_event(evt) + +if __name__ == '__main__': + main() diff --git a/vim.py b/vim.py new file mode 100644 index 0000000..31d5d36 --- /dev/null +++ b/vim.py @@ -0,0 +1,76 @@ +import os +import tempfile +import subprocess +from constants import TARGET +from clipboard import copy +from press import press +from evdev import ecodes + +def open_vim(compile_latex): + f = tempfile.NamedTemporaryFile(mode='w+', delete=False) + + f.write('$$') + f.close() + + subprocess.run([ + 'urxvt', + '-fn', 'xft:Iosevka Term:pixelsize=24', + '-geometry', '60x5', + '-name', 'popup-bottom-center', + '-e', "vim", + "-u", "~/.minimal-tex-vimrc", + f"{f.name}", + ]) + + latex = "" + with open(f.name, 'r') as g: + latex = g.read().strip() + + os.remove(f.name) + + if latex != '$$': + if not compile_latex: + svg = f""" + + {latex} + """ + copy(svg, target=TARGET) + else: + m = tempfile.NamedTemporaryFile(mode='w+', delete=False) + m.write(r""" + \documentclass[12pt,border=12pt]{standalone} + + \usepackage[utf8]{inputenc} + \usepackage[T1]{fontenc} + \usepackage{textcomp} + \usepackage[dutch]{babel} + \usepackage{amsmath, amssymb} + \newcommand{\R}{\mathbb R} + + \begin{document} + """ + latex + r"""\end{document}""") + m.close() + + working_directory = tempfile.gettempdir() + subprocess.run( + ['pdflatex', m.name], + cwd=working_directory, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + subprocess.run( + ['pdf2svg', f'{m.name}.pdf', f'{m.name}.svg'], + cwd=working_directory + ) + + with open(f'{m.name}.svg') as svg: + subprocess.run( + ['xclip', '-selection', 'c', '-target', TARGET], + stdin=svg + ) + + press(ecodes.KEY_V, [ecodes.KEY_LEFTCTRL]) + press(ecodes.KEY_ESC)