From bac31a772a2906b7239adf8bbadefbab2afb2fb9 Mon Sep 17 00:00:00 2001 From: June Tate-Gans Date: Tue, 27 Apr 2021 00:00:16 -0500 Subject: [PATCH] g13gui: Major functionality additions This gets us nearly to a proper profile manager and keybinding tool! Very very close, despite the messiness of the codebase. There's lots of low hanging fruit for those who are interested in contributing. - Made BindingProfile serializable to a python dict - Fixed a bug in BindingProfile that named keys in the g13d command stream incorrectly. - Made ButtonMenu attempt to grab the keyboard when it shows so we can get more correct keypresses. This is only half the battle -- need to stop GTK's event loop for other widgets from catching events and handling them. - Added a g13d communications worker thread, including saving of profiles to disk as well as uploading profile configurations to g13d. This uses a Queue for incoming tasks from the GUI thread, and dispatches results back by way of GObject signals. Could use some work to make this less clunky. - Made MainWindow load the profiles from disk. This is done on the GUI thread, which isn't ideal. - Added an Upload button to push changes over to g13d. This reacts to the worker thread's connected/disconnected signals. --- g13gui/g13gui/bindingprofile.py | 32 +++++++-- g13gui/g13gui/buttonmenu.py | 4 ++ g13gui/g13gui/common.py | 9 +++ g13gui/g13gui/g13d.py | 116 ++++++++++++++++++++++++++++++++ g13gui/g13gui/main.py | 17 ++++- g13gui/g13gui/mainwindow.py | 94 ++++++++++++++++++++++++-- 6 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 g13gui/g13gui/common.py create mode 100644 g13gui/g13gui/g13d.py diff --git a/g13gui/g13gui/bindingprofile.py b/g13gui/g13gui/bindingprofile.py index 5b58cd8..501f152 100644 --- a/g13gui/g13gui/bindingprofile.py +++ b/g13gui/g13gui/bindingprofile.py @@ -4,11 +4,18 @@ import bindings class BindingProfile(object): - def __init__(self): - self._stickMode = bindings.GetStickModeNum('KEYS') - self._stickRegions = bindings.DEFAULT_STICK_REGIONS - self._stickRegionBindings = bindings.DEFAULT_STICK_REGION_BINDINGS - self._keyBindings = bindings.DEFAULT_KEY_BINDINGS + def __init__(self, dict=None): + if dict: + self._stickMode = dict['stickMode'] + self._stickRegions = dict['stickRegions'] + self._stickRegionBindings = dict['stickRegionBindings'] + self._keyBindings = dict['keyBindings'] + else: + self._stickMode = bindings.GetStickModeNum('KEYS') + self._stickRegions = bindings.DEFAULT_STICK_REGIONS + self._stickRegionBindings = bindings.DEFAULT_STICK_REGION_BINDINGS + self._keyBindings = bindings.DEFAULT_KEY_BINDINGS + self._observers = [] def registerObserver(self, observer): @@ -45,8 +52,11 @@ class BindingProfile(object): commands = [] for gkey, kbdkey in self._keyBindings.items(): - keys = ' '.join(['KEY_' + key for key in kbdkey]) - commands.append("bind %s %s" % (gkey, keys)) + if len(kbdkey) > 0: + keys = ' '.join(['KEY_' + key for key in kbdkey]) + commands.append("bind %s %s" % (gkey, keys)) + else: + commands.append("unbind %s" % (gkey)) if self._stickMode == bindings.GetStickModeNum('KEYS'): for region, bounds in self._stickRegions.items(): @@ -56,3 +66,11 @@ class BindingProfile(object): commands.append("stickzone action %s %s" % (region, keys)) return '\n'.join(commands) + + def toDict(self): + return { + 'stickMode': self._stickMode, + 'stickRegions': self._stickRegions, + 'stickRegionBindings': self._stickRegionBindings, + 'keyBindings': self._keyBindings + } diff --git a/g13gui/g13gui/buttonmenu.py b/g13gui/g13gui/buttonmenu.py index d6362e5..e6abe5d 100644 --- a/g13gui/g13gui/buttonmenu.py +++ b/g13gui/g13gui/buttonmenu.py @@ -42,11 +42,15 @@ class ButtonMenu(Gtk.Popover): self.connect("key-press-event", self.keypress) self.connect("key-release-event", self.keyrelease) + self.connect("show", self.shown) self.connect("closed", self.closed) button.connect("pressed", self.clear) self.rebuildBindingDisplay() + def shown(self, widget): + Gdk.keyboard_grab(self.get_window(), False, Gdk.CURRENT_TIME) + def rebuildBindingDisplay(self): if self._bindingBox: self._box.remove(self._bindingBox) diff --git a/g13gui/g13gui/common.py b/g13gui/g13gui/common.py new file mode 100644 index 0000000..8078787 --- /dev/null +++ b/g13gui/g13gui/common.py @@ -0,0 +1,9 @@ +#!/usr/bin/python + +import os +import os.path +import xdg.BaseDirectory as basedir + +VERSION = '0.1.0' +PROFILES_CONFIG_PATH = os.path.join(basedir.save_config_path('g13', 'g13gui'), + 'profiles.json') diff --git a/g13gui/g13gui/g13d.py b/g13gui/g13gui/g13d.py new file mode 100644 index 0000000..6b1c938 --- /dev/null +++ b/g13gui/g13gui/g13d.py @@ -0,0 +1,116 @@ +#!/usr/bin/python3 + +import gi +import os +import os.path +import threading +import time +import traceback +import xdg.BaseDirectory as basedir +import json + +from common import PROFILES_CONFIG_PATH +from common import VERSION + +gi.require_version('Gtk', '3.0') + +from gi.repository import GObject + + +class UploadTask(): + def __init__(self, commands): + self._commands = str.encode(commands) + + def run(self, outfp, infp, callback): + bytes_written = 0 + while bytes_written < len(self._commands): + result = os.write(outfp, self._commands[bytes_written:]) + if result > 0: + bytes_written = result + bytes_written + callback(bytes_written / len(self._commands)) + callback(1.0) + + +class SaveTask(): + def __init__(self, profiles, defaultProfileName): + self._profiles = profiles + self._defaultProfileName = defaultProfileName + + def run(self, outfp, infp, callback): + profiles = {} + for key, profile in self._profiles.items(): + profiles[key] = profile.toDict() + + config = { + 'version': VERSION, + 'defaultProfileName': self._defaultProfileName, + 'profiles': profiles + } + + encoder = json.JSONEncoder() + with open(PROFILES_CONFIG_PATH, 'w') as f: + f.write(encoder.encode(config)) + f.flush() + + +G13D_IN_FIFO = "/run/g13d/in" +G13D_OUT_FIFO = "/run/g13d/out" + + +class G13DWorker(threading.Thread): + def __init__(self, q, window): + threading.Thread.__init__(self, daemon=True) + self._mainWindow = window + self._queue = q + self._connected = False + + def _connect(self): + try: + self._outfp = os.open(G13D_IN_FIFO, os.O_WRONLY) + self._infp = os.open(G13D_OUT_FIFO, os.O_RDONLY) + + except FileNotFoundError: + self._outfp = None + self._infp = None + self._connected = False + print("g13d is not running, or not listening on %s" % (G13D_IN_FIFO)) + self._mainWindow.emit("daemon-connection-changed", False) + print("Sleeping for 10 seconds...") + time.sleep(10) + + except Exception as err: + self._outfp = None + self._infp = None + self._connected = False + print("Unknown exception occurred: %s %s" % (type(err), err)) + self._mainWindow.emit("daemon-connection-changed", False) + print("Sleeping for 10 seconds...") + time.sleep(10) + + else: + self._mainWindow.emit("daemon-connection-changed", True) + print("Connected to g13d") + self._connected = True + + def run(self): + while True: + while not self._connected: + self._connect() + + item = self._queue.get() + + try: + item.run(self._outfp, self._infp, self.callback) + except BrokenPipeError as err: + print("g13d connection broken: %s" % (err)) + self._connected = False + except Exception as err: + traceback.print_exc() + finally: + self._queue.task_done() + + def callback(self, percentage): + self._mainWindow.emit("uploading", percentage) + + def getQueue(self): + return self._queue diff --git a/g13gui/g13gui/main.py b/g13gui/g13gui/main.py index 668eec1..fd53f97 100644 --- a/g13gui/g13gui/main.py +++ b/g13gui/g13gui/main.py @@ -1,15 +1,26 @@ #!/usr/bin/python +import queue import gi gi.require_version('Gtk', '3.0') -gi.require_version('Notify', '0.7') -from gi.repository import Gtk +from gi.repository import Gtk, GObject from mainwindow import MainWindow +from g13d import G13DWorker + + +VERSION = '0.1.0' + if __name__ == '__main__': - win = MainWindow() + queue = queue.Queue() + + win = MainWindow(queue) win.connect("destroy", Gtk.main_quit) win.show_all() + + worker = G13DWorker(queue, win) + worker.start() + Gtk.main() diff --git a/g13gui/g13gui/mainwindow.py b/g13gui/g13gui/mainwindow.py index 6554b04..ecef2cc 100644 --- a/g13gui/g13gui/mainwindow.py +++ b/g13gui/g13gui/mainwindow.py @@ -1,34 +1,58 @@ #!/usr/bin/python import gi +import json +import traceback -gi.require_version('Gtk', '3.0') - -from gi.repository import Gtk +from common import VERSION +from common import PROFILES_CONFIG_PATH +from g13d import UploadTask +from g13d import SaveTask from bindings import G13D_TO_GDK_KEYBINDS from bindings import G13_KEYS from bindingprofile import BindingProfile from buttonmenu import ButtonMenu +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, GObject + + class MainWindow(Gtk.Window): - def __init__(self): + def __init__(self, workerQueue): Gtk.Window.__init__(self) + self._workerQueue = workerQueue + + GObject.signal_new("uploading", self, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, (GObject.TYPE_FLOAT,)) + self.connect("uploading", self.uploadStatusChanged) + GObject.signal_new("daemon-connection-changed", self, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, (GObject.TYPE_BOOLEAN,)) + self.connect("daemon-connection-changed", self.daemonConnectionChanged) + default_profile = BindingProfile() default_profile.registerObserver(self) self._profiles = {'Default Profile': default_profile} self._currentProfile = self._profiles['Default Profile'] + self.loadProfiles() + self.headerBar = Gtk.HeaderBar() self.headerBar.set_title("G13 Configurator") self.headerBar.set_show_close_button(True) self.profileComboBox = Gtk.ComboBoxText() + self.profileComboBox.connect("changed", self.profileChanged) self.headerBar.add(self.profileComboBox) + addProfileButton = Gtk.Button.new_from_icon_name("add", 1) + addProfileButton.connect("clicked", self.addProfileClicked) self.headerBar.add(addProfileButton) + self._uploadButton = Gtk.Button.new_from_icon_name("up", 1) + self._uploadButton.connect("clicked", self.uploadClicked) + self.headerBar.add(self._uploadButton) + Gtk.Window.set_default_size(self, 640, 480) Gtk.Window.set_titlebar(self, self.headerBar) @@ -38,6 +62,65 @@ class MainWindow(Gtk.Window): self.setupG13ButtonGrid() self.updateProfileBox() + def loadProfiles(self): + result = {} + currentProfile = None + + try: + with open(PROFILES_CONFIG_PATH, 'r') as f: + serializedConfig = json.load(f) + + if serializedConfig['version'] != VERSION: + print('WARNING: This profile config is from a different version (wanted %s got %s)!' % + (VERSION, result['version'])) + print('This configuration may not load properly!') + + print("Loaded dict: %s" % (serializedConfig)) + + for name, dict in serializedConfig['profiles'].items(): + result[name] = BindingProfile(dict=dict) + + for name, dict in result.items(): + if name == serializedConfig['defaultProfileName']: + currentProfile = result[name] + + except (OSError, json.JSONDecodeError, KeyError, ValueError) as e: + print("Failed to read profiles from disk: %s" % (e)) + traceback.print_exc() + else: + self._profiles = result + self._currentProfile = currentProfile + + def daemonConnectionChanged(self, widget, connected): + self._connected = connected + if connected: + self._uploadButton.set_state_flags(Gtk.StateFlags.NORMAL, True) + else: + self._uploadButton.set_state_flags(Gtk.StateFlags.INSENSITIVE, True) + + def uploadStatusChanged(self, widget, percentage): + print("Upload in progress: %f" % (percentage * 100)) + + def profileChanged(self, widget): + pass + + def addProfileClicked(self, widget): + pass + + def uploadClicked(self, widget): + config = self._currentProfile.generateConfigString() + task = UploadTask(config) + self._workerQueue.put(task) + + currentProfileName = None + for name, profile in self._profiles.items(): + if self._currentProfile == profile: + currentProfileName = name + break + + task = SaveTask(self._profiles, currentProfileName) + self._workerQueue.put(task) + def updateProfileBox(self): self.profileComboBox.remove_all() row = 0 @@ -148,5 +231,4 @@ class MainWindow(Gtk.Window): for key in G13_KEYS: self.updateG13Button(key) - print("Profile updated to:") - print(self._currentProfile.generateConfigString()) + self.uploadClicked(self)