From 7dc965c684fe1456f29c5ce88ce923dee0ef4676 Mon Sep 17 00:00:00 2001 From: Wayne Heaney <42350981+wheaney@users.noreply.github.com> Date: Thu, 2 May 2024 16:12:13 -0700 Subject: [PATCH] Add Gtk Python UI (#19) Initial UI implementation --- .gitignore | 1 + gnome/bin/setup-45.sh | 5 + .../breezydesktop@org.xronlinux/extension.js | 57 ++-- .../schemas/gschemas.compiled | Bin 428 -> 740 bytes ...hell.extensions.breezy-desktop.gschema.xml | 44 ++- gnome/breezydesktop@org.xronlinux/xrEffect.js | 68 +++-- ui/BreezyDesktop.py | 93 ++++++ ui/Pipfile | 12 + ui/Pipfile.lock | 28 ++ ui/ShortcutDialog.py | 104 +++++++ ui/XRDriverIPC.py | 279 ++++++++++++++++++ ui/breezy-desktop.ui | 142 +++++++++ ui/shortcut-dialog.ui | 43 +++ 13 files changed, 835 insertions(+), 41 deletions(-) mode change 100644 => 100755 gnome/bin/setup-45.sh create mode 100644 ui/BreezyDesktop.py create mode 100644 ui/Pipfile create mode 100644 ui/Pipfile.lock create mode 100644 ui/ShortcutDialog.py create mode 100644 ui/XRDriverIPC.py create mode 100644 ui/breezy-desktop.ui create mode 100644 ui/shortcut-dialog.ui diff --git a/.gitignore b/.gitignore index 92b8c92..37d93f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vulkan/build/ /build/ +__pycache__ diff --git a/gnome/bin/setup-45.sh b/gnome/bin/setup-45.sh old mode 100644 new mode 100755 index 8504b54..8139e0d --- a/gnome/bin/setup-45.sh +++ b/gnome/bin/setup-45.sh @@ -58,6 +58,11 @@ if [ ! -L $extensions_dir/breezydesktop@org.xronlinux ]; then chown -R $user:$group $extensions_dir/breezydesktop@org.xronlinux fi +glib-compile-schemas --targetdir=$extensions_dir/breezydesktop@org.xronlinux/schemas/ $extensions_dir/breezydesktop@org.xronlinux/schemas/ + +sudo cp $extensions_dir/breezydesktop@org.xronlinux/schemas/org.gnome.shell.extensions.breezy-desktop.gschema.xml /usr/share/glib-2.0/schemas/ +sudo glib-compile-schemas /usr/share/glib-2.0/schemas/ + echo "Breezy Desktop extension is installed. Please log out, log back in, \ and then run the following command to enable it:\ gnome-extensions enable breezydesktop@org.xronlinux" diff --git a/gnome/breezydesktop@org.xronlinux/extension.js b/gnome/breezydesktop@org.xronlinux/extension.js index 33ca3fd..5a1f9ab 100644 --- a/gnome/breezydesktop@org.xronlinux/extension.js +++ b/gnome/breezydesktop@org.xronlinux/extension.js @@ -23,6 +23,8 @@ const SUPPORTED_MONITOR_PRODUCTS = [ export default class BreezyDesktopExtension extends Extension { constructor(metadata, uuid) { super(metadata, uuid); + + this.settings = this.getSettings(); // Set/destroyed by enable/disable this._cursor_manager = null; @@ -129,26 +131,22 @@ export default class BreezyDesktopExtension extends Extension { this._xr_effect = new XREffect({ target_monitor: this._target_monitor, - target_framerate: this._refresh_rate ?? 60 + target_framerate: this._refresh_rate ?? 60, + display_distance: this.settings.get_double('display-distance'), + toggle_display_distance_start: this.settings.get_double('toggle-display-distance-start'), + toggle_display_distance_end: this.settings.get_double('toggle-display-distance-end'), }); + this.settings.bind('effect-enable', this._xr_effect, 'effect-enable', Gio.SettingsBindFlags.DEFAULT) + this.settings.bind('display-distance', this._xr_effect, 'display-distance', Gio.SettingsBindFlags.DEFAULT) + this.settings.bind('toggle-display-distance-start', this._xr_effect, 'toggle-display-distance-start', Gio.SettingsBindFlags.DEFAULT) + this.settings.bind('toggle-display-distance-end', this._xr_effect, 'toggle-display-distance-end', Gio.SettingsBindFlags.DEFAULT) + this._overlay.add_effect_with_name('xr-desktop', this._xr_effect); Meta.disable_unredirect_for_display(global.display); - Main.wm.addKeybinding( - 'shortcut-recenter', - this.getSettings(), - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, - this._recenter_display.bind(this) - ); - Main.wm.addKeybinding( - 'shortcut-change-distance', - this.getSettings(), - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, - this._xr_effect._change_distance.bind(this._xr_effect) - ); + this._add_settings_keybinding('recenter-display-shortcut', this._recenter_display.bind(this)); + this._add_settings_keybinding('toggle-display-distance-shortcut', this._xr_effect._change_distance.bind(this._xr_effect)); } catch (e) { console.error('Error enabling XR effect', e); this._effect_disable(); @@ -156,6 +154,31 @@ export default class BreezyDesktopExtension extends Extension { } } + _add_settings_keybinding(settings_key, bind_to_function) { + Main.wm.addKeybinding( + settings_key, + this.settings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, + bind_to_function + ); + + // Connect to the 'changed' signal for the keybinding property + this.settings.connect(`changed::${settings_key}`, () => { + // Remove the old keybinding + Main.wm.removeKeybinding(settings_key); + + // Add the updated keybinding + Main.wm.addKeybinding( + settings_key, + this.settings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, + bind_to_function + ); + }); + } + _recenter_display() { const file = Gio.file_new_for_path('/dev/shm/xr_driver_control'); const stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null); @@ -168,8 +191,8 @@ export default class BreezyDesktopExtension extends Extension { if (this._running_poller_id) GLib.source_remove(this._running_poller_id); - Main.wm.removeKeybinding('shortcut-recenter'); - Main.wm.removeKeybinding('shortcut-change-distance'); + Main.wm.removeKeybinding('recenter-display-shortcut'); + Main.wm.removeKeybinding('toggle-display-distance-shortcut'); Meta.enable_unredirect_for_display(global.display); if (this._overlay) { diff --git a/gnome/breezydesktop@org.xronlinux/schemas/gschemas.compiled b/gnome/breezydesktop@org.xronlinux/schemas/gschemas.compiled index dd50967b2ca36b12110a97af95dbae5a98440a1a..ff1fbd430a791937caadf07e3b479886d74ea1c4 100644 GIT binary patch literal 740 zcmZ`%O-md>5Un+?CVqg3<^u`jBF-W^6UcQj2T9CfPuU=Q5Ms}4xw|;_4Bfq=g5oiU zK+d^KeuEdoo^nZY$|XM_H$gmli?4cj{Q`@5yvOV6uIlPpSyiU3w4;7J1=o8z7#f)1 z;w;gBL~pI?bJpO`y*ZqhG|?q`N1woXfe0UErWrPMw#A`sahis_<5=5vrfnD-=Dpo= zgYBlva^xa{hduUtrM6(Wj}fq}clQs*r4Ol5q;1*(e*@YdtKW-Q7yS%=7kmc%i7!5g zFM0~T96AB~JX-lGzUUY5UxL?x{rRzB@kKv}e+d2z4DK(QsOr5V=pT<}zDU04N#q}c zPk^`e>5llK=ivVZ{{zBYxfc8g!-@QGB0rqydN|ST;Y38%aKhTLDL2|SPt|VOwld?A zj-$$Qrk%;srAn=nvstpaN|=_NNf*7bSo6l?1qzfc4Yq@w(nzG=za-9IQ_v{6tl3QY zs>UGcx)}PtTkk!)j?p&@_MImsU-8~Eoig&%z4CGD>tj45?~JFSQQ!d}@6Q_>oVYS; T6{l#~M}dO)-iBE>ZrbDq7f delta 221 zcmaFDx`uf|2qVM9Q1f~g1_)pRQp`XM;`iLCxdGxcFfuSmFqAP!0BKVoK2Tf7{T~QG zY*wJS1CY)Gu|*jeK;j@a1A`Ak0g$c$;$wHO*?`Oeu?2zZCjjX+KwO-WUsRG@TB4hr z!JC+uo~oOYSzMBsmz+A;fJvNBoIxY8SW~Ycu_ObicXBDCp(4mwi1XlDi&B$Q^GZ^S PAX1a}Gm5i`fi(gEpHVV3 diff --git a/gnome/breezydesktop@org.xronlinux/schemas/org.gnome.shell.extensions.breezy-desktop.gschema.xml b/gnome/breezydesktop@org.xronlinux/schemas/org.gnome.shell.extensions.breezy-desktop.gschema.xml index 3788b5a..55561be 100644 --- a/gnome/breezydesktop@org.xronlinux/schemas/org.gnome.shell.extensions.breezy-desktop.gschema.xml +++ b/gnome/breezydesktop@org.xronlinux/schemas/org.gnome.shell.extensions.breezy-desktop.gschema.xml @@ -1,22 +1,58 @@ - + - space']]]> + true + + Enable XR effect + + Enable XR effect + + + + + space', 'Ctrl+Super+Space']]]> Re-center display Shortcut to re-center the virtual display. - + - Return']]]> + Return', 'Ctrl+Super+Return']]]> Trigger change to display distance Shortcut to change the display distance. + + + 1.05 + + Display distance + + How far away the display appears. Farther will look smaller, closer will look larger. + + + + + 0.85 + + Display distance start + + Start distance when using the "change distance" shortcut. + + + + + 1.05 + + Display distance end + + End distance when using the "toggle display distance" shortcut. + + \ No newline at end of file diff --git a/gnome/breezydesktop@org.xronlinux/xrEffect.js b/gnome/breezydesktop@org.xronlinux/xrEffect.js index f238c31..a567c2b 100644 --- a/gnome/breezydesktop@org.xronlinux/xrEffect.js +++ b/gnome/breezydesktop@org.xronlinux/xrEffect.js @@ -25,8 +25,6 @@ import { getShaderSource } from "./shader.js"; import { toSec } from "./time.js"; export const IPC_FILE_PATH = "/dev/shm/breezy_desktop_imu"; -const display_distance_nearest = 0.85; -const display_distance_furthest = 1.05; // the driver should be using the same data layout version const DATA_LAYOUT_VERSION = 2; @@ -123,7 +121,7 @@ function setIntermittentUniformVariables() { const validKeepalive = Math.abs(toSec(currentDateMS) - toSec(imuDateMS)) < 5; const imuData = dataViewFloatArray(dataView, IMU_QUAT_DATA); const imuResetState = imuData[0] === 0.0 && imuData[1] === 0.0 && imuData[2] === 0.0 && imuData[3] === 1.0; - const enabled = dataViewUint8(dataView, ENABLED) !== 0 && version === DATA_LAYOUT_VERSION && validKeepalive && !imuResetState; + const enabled = this.effect_enable && dataViewUint8(dataView, ENABLED) !== 0 && version === DATA_LAYOUT_VERSION && validKeepalive && !imuResetState; if (enabled) { const displayRes = dataViewUintArray(dataView, DISPLAY_RES); @@ -171,6 +169,13 @@ function setIntermittentUniformVariables() { export const XREffect = GObject.registerClass({ Properties: { + 'effect-enable': GObject.ParamSpec.boolean( + 'effect-enable', + 'Effect enable', + 'Whether this effect is enabled', + GObject.ParamFlags.READWRITE, + true + ), 'target-monitor': GObject.ParamSpec.jsobject( 'target-monitor', 'Target Monitor', @@ -182,6 +187,33 @@ export const XREffect = GObject.registerClass({ 'Target Framerate', 'Target framerate for this effect', GObject.ParamFlags.READWRITE, 60, 240, 60 + ), + 'display-distance': GObject.ParamSpec.double( + 'display-distance', + 'Display Distance', + 'How far away the display appears', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.05 + ), + 'toggle-display-distance-start': GObject.ParamSpec.double( + 'toggle-display-distance-start', + 'Display distance start', + 'Start distance when using the "change distance" shortcut.', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.05 + ), + 'toggle-display-distance-end': GObject.ParamSpec.double( + 'toggle-display-distance-end', + 'Display distance end', + 'End distance when using the "change distance" shortcut.', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.05 ) } }, class XREffect extends Shell.GLSLEffect { @@ -190,28 +222,24 @@ export const XREffect = GObject.registerClass({ this._frametime = Math.floor(1000 / this.target_framerate); - // slightly zoomed out by default - this._display_distance = display_distance_furthest; - this._display_distance_near = false; + this._is_display_distance_at_end = false; this._distance_ease_timeline = null; } _change_distance() { - if (this._distance_ease_timeline?.is_playing()) this._distance_ease_timeline.stop(); - this._distance_ease_start = this._display_distance; + if (this._distance_ease_timeline?.is_playing()) this._distance_ease_timeline.stop(); + + this._distance_ease_start = this.display_distance; this._distance_ease_timeline = Clutter.Timeline.new_for_actor(this.get_actor(), 250); - if (this._display_distance_near) { - this._distance_ease_timeline.connect('new-frame', () => { - this._display_distance = this._distance_ease_start + this._distance_ease_timeline.get_progress() * (display_distance_furthest - this._distance_ease_start); - }); - this._display_distance_near = false; - } else { - this._distance_ease_timeline.connect('new-frame', () => { - this._display_distance = this._distance_ease_start - this._distance_ease_timeline.get_progress() * (this._distance_ease_start - display_distance_nearest); - }); - this._display_distance_near = true; - } + const toggle_display_distance_target = this._is_display_distance_at_end ? + this.toggle_display_distance_start : this.toggle_display_distance_end; + this._distance_ease_timeline.connect('new-frame', () => { + this.display_distance = this._distance_ease_start + + this._distance_ease_timeline.get_progress() * + (toggle_display_distance_target - this._distance_ease_start); + }); + this._is_display_distance_at_end = !this._is_display_distance_at_end; this._distance_ease_timeline.start(); } @@ -252,7 +280,7 @@ export const XREffect = GObject.registerClass({ } if (this._dataView.byteLength === DATA_VIEW_LENGTH) { - setSingleFloat(this, 'display_north_offset', this._display_distance); + setSingleFloat(this, 'display_north_offset', this.display_distance); setSingleFloat(this, 'look_ahead_ms', lookAheadMS(this._dataView)); setUniformMatrix(this, 'imu_quat_data', 4, this._dataView, IMU_QUAT_DATA); } else if (this._dataView.byteLength !== 0) { diff --git a/ui/BreezyDesktop.py b/ui/BreezyDesktop.py new file mode 100644 index 0000000..6c6f3f5 --- /dev/null +++ b/ui/BreezyDesktop.py @@ -0,0 +1,93 @@ +import gi +import sys +import threading + +gi.require_version("Gtk", "4.0") +gi.require_version('Adw', '1') + +from gi.repository import Adw, Gio, Gtk + +from XRDriverIPC import XRDriverIPC +from ShortcutDialog import bind_shortcut_settings + +class Logger: + def info(self, message): + print(message) + + def error(self, message): + print(message) + +class MainWindow(Gtk.ApplicationWindow): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_title("Breezy Desktop") + + builder = Gtk.Builder() + builder.add_from_file("./breezy-desktop.ui") + self.set_child(builder.get_object("main")) + self.connected_device_info = builder.get_object("connected-device-info") + self.connected_device_label = builder.get_object("connected-device-label") + self.connected_device_settings = builder.get_object("connected-device-settings") + self.connected_device_shortcuts = builder.get_object("connected-device-shortcuts") + self.no_connected_device = builder.get_object("no-connected-device") + + self.settings = Gio.Settings.new_with_path("org.gnome.shell.extensions.breezy-desktop", "/org/gnome/shell/extensions/breezy-desktop/") + self.ipc = XRDriverIPC(logger = Logger()) + self._refresh_state() + + bind_shortcut_settings(self, self.settings, [ + builder.get_object('reassign-recenter-display-shortcut-button'), + builder.get_object('reassign-toggle-display-distance-shortcut-button'), + ]) + + self.bind_set_distance_toggle([ + builder.get_object('set-toggle-display-distance-start-button'), + builder.get_object('set-toggle-display-distance-end-button') + ]) + display_distance_slider = builder.get_object('display-distance-slider') + self.settings.bind('display-distance', display_distance_slider, 'value', Gio.SettingsBindFlags.DEFAULT) + + effect_enable_switch = builder.get_object('effect-enable') + self.settings.bind('effect-enable', effect_enable_switch, 'active', Gio.SettingsBindFlags.DEFAULT) + + def _refresh_state(self): + self.state = self.ipc.retrieve_driver_state() + if self.state.get('connected_device_brand') and self.state.get('connected_device_model'): + self.connected_device_info.set_visible(True) + self.connected_device_settings.set_visible(True) + self.connected_device_shortcuts.set_visible(True) + self.no_connected_device.set_visible(False) + self.connected_device_label.set_markup(f"{self.state['connected_device_brand']} {self.state['connected_device_model']}") + else: + self.connected_device_info.set_visible(False) + self.connected_device_settings.set_visible(False) + self.connected_device_shortcuts.set_visible(False) + self.no_connected_device.set_visible(True) + threading.Timer(1.0, self._refresh_state).start() + + + def bind_set_distance_toggle(self, widgets): + for widget in widgets: + widget.connect('clicked', lambda *args, widget=widget: on_set_display_distance_toggle(self.settings, widget)) + reload_display_distance_toggle_button(self.settings, widget) + +def reload_display_distance_toggle_button(settings, widget): + distance = settings.get_double(widget.get_name()) + if distance: widget.set_label(str(distance)) + +def on_set_display_distance_toggle(settings, widget): + distance = settings.get_double('display-distance') + settings.set_double(widget.get_name(), distance) + reload_display_distance_toggle_button(settings, widget) + +class BreezyDesktop(Adw.Application): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.connect('activate', self.on_activate) + + def on_activate(self, app): + self.win = MainWindow(application=app) + self.win.present() + +app = BreezyDesktop(application_id="com.example.GtkApplication") +app.run(sys.argv) \ No newline at end of file diff --git a/ui/Pipfile b/ui/Pipfile new file mode 100644 index 0000000..35280f8 --- /dev/null +++ b/ui/Pipfile @@ -0,0 +1,12 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +pygobject-stubs = "*" + +[requires] +python_version = "3.11" diff --git a/ui/Pipfile.lock b/ui/Pipfile.lock new file mode 100644 index 0000000..f9343d4 --- /dev/null +++ b/ui/Pipfile.lock @@ -0,0 +1,28 @@ +{ + "_meta": { + "hash": { + "sha256": "f363f820b1e601b5678bb4d65939db586a4db4fb008ee873697aff3df08d0877" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.11" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": {}, + "develop": { + "pygobject-stubs": { + "hashes": [ + "sha256:e1c7e32658213ae711d8afc5ea083a434231b8b588d1de23f50d5705a9c8eefe" + ], + "index": "pypi", + "version": "==2.11.0" + } + } +} diff --git a/ui/ShortcutDialog.py b/ui/ShortcutDialog.py new file mode 100644 index 0000000..8531888 --- /dev/null +++ b/ui/ShortcutDialog.py @@ -0,0 +1,104 @@ +from gi.repository import Gtk, Gdk + +# ported from https://github.com/velitasali/gnome-shell-extension-awesome-tiles +class ShortcutDialog: + def __init__(self, settings, settings_key): + self.settings = settings + self.settings_key = settings_key + + self._builder = Gtk.Builder() + self._builder.add_from_file('./shortcut-dialog.ui') + + self.widget = self._builder.get_object('dialog') + + self.event_controller = self._builder.get_object('event-controller') + self.key_pressed_connect_id = self.event_controller.connect('key-pressed', self._on_key_pressed) + + def _on_key_pressed(self, widget, keyval, keycode, state): + mask = state & Gtk.accelerator_get_default_mod_mask() + mask &= ~Gdk.ModifierType.LOCK_MASK + + done = True + if mask == 0 and keyval == Gdk.KEY_Escape: + self.widget.visible = False + elif keyval == Gdk.KEY_BackSpace: + self.settings.set_strv(self.settings_key, []) + self.widget.close() + elif is_binding_valid(mask, keycode, keyval) and is_accel_valid(state, keyval): + binding = Gtk.accelerator_name_with_keycode( + None, + keyval, + keycode, + state + ) + label = Gtk.accelerator_get_label(keyval, state) + + # hacky way to store the label, causes warnings from the WM + self.settings.set_strv(self.settings_key, [binding, label]) + + self.widget.close() + else: + done = False + + if done and self.key_pressed_connect_id: + self.event_controller.disconnect(self.key_pressed_connect_id) + self.key_pressed_connect_id = None + + return Gdk.EVENT_STOP + +def is_binding_valid(mask, keycode, keyval): + if mask == 0 or mask == Gdk.ModifierType.SHIFT_MASK and keycode != 0: + if keyval >= Gdk.KEY_a and keyval <= Gdk.KEY_z or \ + keyval >= Gdk.KEY_A and keyval <= Gdk.KEY_Z or \ + keyval >= Gdk.KEY_0 and keyval <= Gdk.KEY_9 or \ + keyval >= Gdk.KEY_kana_fullstop and keyval <= Gdk.KEY_semivoicedsound or \ + keyval >= Gdk.KEY_Arabic_comma and keyval <= Gdk.KEY_Arabic_sukun or \ + keyval >= Gdk.KEY_Serbian_dje and keyval <= Gdk.KEY_Cyrillic_HARDSIGN or \ + keyval >= Gdk.KEY_Greek_ALPHAaccent and keyval <= Gdk.KEY_Greek_omega or \ + keyval >= Gdk.KEY_hebrew_doublelowline and keyval <= Gdk.KEY_hebrew_taf or \ + keyval >= Gdk.KEY_Thai_kokai and keyval <= Gdk.KEY_Thai_lekkao or \ + keyval >= Gdk.KEY_Hangul_Kiyeog and keyval <= Gdk.KEY_Hangul_J_YeorinHieuh or \ + keyval == Gdk.KEY_space and mask == 0 or \ + is_keyval_forbidden(keyval): + return False + return True + +def is_keyval_forbidden(keyval): + forbidden_keyvals = [ + Gdk.KEY_Home, + Gdk.KEY_Left, + Gdk.KEY_Up, + Gdk.KEY_Right, + Gdk.KEY_Down, + Gdk.KEY_Page_Up, + Gdk.KEY_Page_Down, + Gdk.KEY_End, + Gdk.KEY_Tab, + Gdk.KEY_KP_Enter, + Gdk.KEY_Return, + Gdk.KEY_Mode_switch + ] + return keyval in forbidden_keyvals + +def is_accel_valid(mask, keyval): + return Gtk.accelerator_valid(keyval, mask) or (keyval == Gdk.KEY_Tab and mask != 0) + +def bind_shortcut_settings(window, settings, widgets): + for widget in widgets: + settings.connect('changed::' + widget.get_name(), lambda *args, widget=widget: reload_shortcut_widget(settings, widget)) + widget.connect('clicked', lambda *args, widget=widget: on_assign_shortcut(window, settings, widget)) + + reload_shortcut_widgets(settings, widgets) + +def on_assign_shortcut(window, settings, widget): + dialog = ShortcutDialog(settings, widget.get_name()) + dialog.widget.set_transient_for(window) + dialog.widget.present() + +def reload_shortcut_widget(settings, widget): + shortcut = settings.get_strv(widget.get_name()) + widget.set_label(shortcut[1] if len(shortcut) > 1 else 'Disabled') + +def reload_shortcut_widgets(settings, widgets): + for widget in widgets: + reload_shortcut_widget(settings, widget) \ No newline at end of file diff --git a/ui/XRDriverIPC.py b/ui/XRDriverIPC.py new file mode 100644 index 0000000..ba4bd14 --- /dev/null +++ b/ui/XRDriverIPC.py @@ -0,0 +1,279 @@ +import json +import os +import pwd +import stat +import subprocess +import time + +# write-only file that the driver reads (but never writes) to get user-specified control flags +CONTROL_FLAGS_FILE_PATH = '/dev/shm/xr_driver_control' + +# read-only file that the driver writes (but never reads) to with its current state +DRIVER_STATE_FILE_PATH = '/dev/shm/xr_driver_state' + +CONTROL_FLAGS = ['recenter_screen', 'recalibrate', 'sbs_mode', 'refresh_device_license'] +SBS_MODE_VALUES = ['unset', 'enable', 'disable'] +MANAGED_EXTERNAL_MODES = ['virtual_display', 'sideview', 'none'] +VR_LITE_OUTPUT_MODES = ['mouse', 'joystick'] + +def parse_boolean(value, default): + if not value: + return default + + return value.lower() == 'true' + + +def parse_int(value, default): + return int(value) if value.isdigit() else default + +def parse_float(value, default): + try: + return float(value) + except ValueError: + return default + +def parse_string(value, default): + return value if value else default + +def parse_array(value, default): + return value.split(",") if value else default + + +CONFIG_PARSER_INDEX = 0 +CONFIG_DEFAULT_VALUE_INDEX = 1 +CONFIG_ENTRIES = { + 'disabled': [parse_boolean, True], + 'output_mode': [parse_string, 'mouse'], + 'external_mode': [parse_array, 'none'], + 'mouse_sensitivity': [parse_int, 30], + 'display_zoom': [parse_float, 1.0], + 'look_ahead': [parse_int, 0], + 'sbs_display_size': [parse_float, 1.0], + 'sbs_display_distance': [parse_float, 1.0], + 'sbs_content': [parse_boolean, False], + 'sbs_mode_stretched': [parse_boolean, False], + 'sideview_position': [parse_string, 'center'], + 'sideview_display_size': [parse_float, 1.0], + 'virtual_display_smooth_follow_enabled': [parse_boolean, False], + 'sideview_smooth_follow_enabled': [parse_boolean, False] +} + +class XRDriverIPC: + def __init__(self, logger, user=None, user_home=None): + self.breezy_installed = False + self.breezy_installing = False + self.user = user if user else pwd.getpwuid( os.getuid() )[0] + self.user_home = user_home if user_home else os.path.expanduser("~") + self.config_file_path = os.path.join(self.user_home, ".xreal_driver_config") + self.config_script_path = os.path.join(self.user_home, "bin/xreal_driver_config") + self.logger = logger + + def retrieve_config(self): + config = {} + for key, value in CONFIG_ENTRIES.items(): + config[key] = value[CONFIG_DEFAULT_VALUE_INDEX] + + try: + with open(self.config_file_path, 'r') as f: + for line in f: + try: + if not line.strip(): + continue + + key, value = line.strip().split('=') + if key in CONFIG_ENTRIES: + parser = CONFIG_ENTRIES[key][CONFIG_PARSER_INDEX] + default_val = CONFIG_ENTRIES[key][CONFIG_DEFAULT_VALUE_INDEX] + config[key] = parser(value, default_val) + except Exception as e: + self.logger.error(f"Error parsing line {line}: {e}") + except FileNotFoundError as e: + self.logger.error(f"Config file not found {e}") + return config + + config['ui_view'] = self.build_ui_view(config) + + return config + + def write_config(self, config): + try: + output = "" + + # Since the UI doesn't refresh the config before it updates, the external_mode can get out of sync with + # what's on disk. To avoid losing external_mode values, we retrieve the previous configs to preserve + # any non-managed external modes. + old_config = self._retrieve_config(self) + + # remove the UI's "view" data, translate back to config values, and merge them in + view = config.pop('ui_view', None) + config.update(self.headset_mode_to_config(view['headset_mode'], view['is_joystick_mode'], old_config['external_mode'])) + + for key, value in config.items(): + if key != "updated": + if isinstance(value, bool): + output += f'{key}={str(value).lower()}\n' + elif isinstance(value, int): + output += f'{key}={value}\n' + elif isinstance(value, list): + output += f'{key}={",".join(value)}\n' + else: + output += f'{key}={value}\n' + + temp_file = "temp.txt" + + # Write to a temporary file + with open(temp_file, 'w') as f: + f.write(output) + + # Atomically replace the old config file with the new one + os.replace(temp_file, self.config_file_path) + os.chmod(self.config_file_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH) + + config['ui_view'] = self.build_ui_view(self, config) + + return config + except Exception as e: + self.logger.error(f"Error writing config {e}") + raise e + + # like a SQL "view," these are computed values that are commonly used in the UI + def build_ui_view(self, config): + view = {} + view['headset_mode'] = self.config_to_headset_mode(config) + view['is_joystick_mode'] = config['output_mode'] == 'joystick' + return view + + def filter_to_other_external_modes(self, external_modes): + return [mode for mode in external_modes if mode not in MANAGED_EXTERNAL_MODES] + + def headset_mode_to_config(self, headset_mode, joystick_mode, old_external_modes): + new_external_modes = self.filter_to_other_external_modes(old_external_modes) + + config = {} + if headset_mode == "virtual_display": + # TODO - uncomment this when the driver can support multiple external_mode values + # new_external_modes.append("virtual_display") + new_external_modes = ["virtual_display"] + config['output_mode'] = "external_only" + config['disabled'] = False + elif headset_mode == "vr_lite": + config['output_mode'] = "joystick" if joystick_mode else "mouse" + config['disabled'] = False + elif headset_mode == "sideview": + # TODO - uncomment this when the driver can support multiple external_mode values + # new_external_modes.append("sideview") + new_external_modes = ["sideview"] + config['output_mode'] = "external_only" + config['disabled'] = False + else: + config['output_mode'] = "external_only" + + has_external_mode = len(new_external_modes) > 0 + if not has_external_mode: + new_external_modes.append("none") + config['external_mode'] = new_external_modes + + return config + + def config_to_headset_mode(self, config): + if not config or config['disabled']: + return "disabled" + + if config['output_mode'] in VR_LITE_OUTPUT_MODES: + return "vr_lite" + + managed_mode = next((mode for mode in MANAGED_EXTERNAL_MODES if mode in config['external_mode']), None) + if managed_mode and managed_mode != "none": + return managed_mode + + return "disabled" + + def write_control_flags(self, control_flags): + try: + output = "" + for key, value in control_flags.items(): + if key in CONTROL_FLAGS: + if key == 'sbs_mode': + if value not in SBS_MODE_VALUES: + self.logger.error(f"Invalid value {value} for sbs_mode flag") + continue + elif not isinstance(value, bool): + self.logger.error(f"Invalid value {value} for {key} flag") + continue + output += f'{key}={str(value).lower()}\n' + + with open(CONTROL_FLAGS_FILE_PATH, 'w') as f: + f.write(output) + except Exception as e: + self.logger.error(f"Error writing control flags {e}") + + def retrieve_driver_state(self): + state = {} + state['heartbeat'] = 0 + state['connected_device_brand'] = None + state['connected_device_model'] = None + state['calibration_setup'] = "AUTOMATIC" + state['calibration_state'] = "NOT_CALIBRATED" + state['sbs_mode_enabled'] = False + state['sbs_mode_supported'] = False + state['firmware_update_recommended'] = False + state['device_license'] = {} + + try: + with open(DRIVER_STATE_FILE_PATH, 'r') as f: + output = f.read() + for line in output.splitlines(): + try: + if not line.strip(): + continue + + key, value = line.strip().split('=') + if key == 'heartbeat': + state[key] = parse_int(value, 0) + elif key in ['calibration_setup', 'calibration_state', 'connected_device_brand', 'connected_device_model']: + state[key] = value + elif key in ['sbs_mode_enabled', 'sbs_mode_supported', 'firmware_update_recommended']: + state[key] = parse_boolean(value, False) + elif key == 'device_license': + state[key] = json.loads(value) + except Exception as e: + self.logger.error(f"Error parsing key-value pair {key}={value}: {e}") + except FileNotFoundError: + pass + + # state is stale, just send the license + if state['heartbeat'] == 0 or (time.time() - state['heartbeat']) > 5: + return { + 'heartbeat': state['heartbeat'], + 'device_license': state['device_license'] + } + + return state + + async def request_token(self, email): + self.logger.info(f"Requesting a new token for {email}") + + # Set the USER environment variable for this command + env_copy = os.environ.copy() + env_copy["USER"] = self.user + + try: + output = subprocess.check_output([self.config_script_path, "--request-token", email], stderr=subprocess.STDOUT, env=env_copy) + return output.strip() == b"Token request sent" + except subprocess.CalledProcessError as exc: + self.logger.error(f"Error running config script {exc.output}") + return False + + async def verify_token(self, token): + self.logger.info(f"Verifying token {token}") + + # Set the USER environment variable for this command + env_copy = os.environ.copy() + env_copy["USER"] = self.user + + try: + output = subprocess.check_output([self.config_script_path, "--verify-token", token], stderr=subprocess.STDOUT, env=env_copy) + return output.strip() == b"Token verified" + except subprocess.CalledProcessError as exc: + self.logger.error(f"Error running config script {exc.output}") + return False \ No newline at end of file diff --git a/ui/breezy-desktop.ui b/ui/breezy-desktop.ui new file mode 100644 index 0000000..cbcab64 --- /dev/null +++ b/ui/breezy-desktop.ui @@ -0,0 +1,142 @@ + + + + + 1 + 20 + 20 + 20 + 20 + 20 + + + No device connected + Breezy Desktop was unable to detect any supported XR devices. + 650 + + + + + 4 + + + VITURE One + + + + + connected + + + + + + + Settings + + + Effect enabled + Turn on or off the XR desktop effect + + + 3 + + + + + + + Display distance + + + 3 + true + 0 + 2 + 350 + false + + + 0.2 + 2.5 + 0.01 + 1.05 + + + + + + + + + + + + + + + + Keyboard Shortcuts + Modify keyboard shortcuts and how they work + + + Re-center display shortcut + Pin the virtual display to the current position + + + + recenter-display-shortcut + 3 + Test + + + + + + + Display distance shortcut + Quickly toggle between two predefined distances + + + + toggle-display-distance-shortcut + 3 + Test + + + + + + + Toggle distance start + Use the buttons to capture the current display distance as start and end points. + 2 + + + 30 + 150 + 30 + + + toggle-display-distance-start + 3 + + + + + toggle-display-distance-end + 3 + + + + + + + + + + diff --git a/ui/shortcut-dialog.ui b/ui/shortcut-dialog.ui new file mode 100644 index 0000000..2f2cc5d --- /dev/null +++ b/ui/shortcut-dialog.ui @@ -0,0 +1,43 @@ + + + + + 1 + 440 + 200 + + + vertical + 2 + 16 + 16 + 16 + 16 + + + 1 + Press your keyboard shortcut or 'Backspace' to disable... + + + + + + + + + Keyboard Shortcut + 1 + end + 5 + + + + + + + + + + \ No newline at end of file