From 7843d4d38531033b7457603cb912639b05ffc8af Mon Sep 17 00:00:00 2001 From: Wayne Heaney <42350981+wheaney@users.noreply.github.com> Date: Mon, 8 Jul 2024 15:02:04 -0700 Subject: [PATCH] Widescreen/SBS support (#28) Squashed 28 commits --- bin/breezy_gnome_setup | 12 +- gnome/bin/dev/use_local_extension.sh | 11 +- gnome/src/cursormanager.js | 158 ++++-- gnome/src/extension.js | 283 +++++++-- gnome/src/ipc.js | 12 +- gnome/src/monitormanager.js | 198 ++++++- gnome/src/time.js | 4 + gnome/src/xrEffect.js | 291 +++++++--- modules/XRLinuxDriver | 2 +- modules/sombrero | 2 +- .../com.xronlinux.BreezyDesktop.gschema.xml | 63 +++ ui/modules/PyXRLinuxDriverIPC | 2 +- ui/src/connecteddevice.py | 40 +- ui/src/gtk/connected-device.ui | 535 ++++++++++++------ ui/src/gtk/license-dialog.ui | 4 +- ui/src/gtk/window.ui | 4 + ui/src/licensefeaturerow.py | 8 +- ui/src/licensetierrow.py | 2 +- ui/src/main.py | 6 + ui/src/statemanager.py | 6 + 20 files changed, 1230 insertions(+), 413 deletions(-) diff --git a/bin/breezy_gnome_setup b/bin/breezy_gnome_setup index 798b3a7..c3714cd 100755 --- a/bin/breezy_gnome_setup +++ b/bin/breezy_gnome_setup @@ -18,21 +18,23 @@ tmp_dir=$(mktemp -d -t breezy-gnome-XXXXXXXXXX) pushd $tmp_dir > /dev/null echo "Created temp directory: ${tmp_dir}" -# if the first argument is "-v" then the second argument is metrics version, and the third argument is binary path -# otherwise, if the first argument is present, it's the binary path +binary_download_url="https://github.com/wheaney/breezy-desktop/releases/latest/download/breezyGNOME.tar.gz" if [ "$1" = "-v" ] then metrics_version="$2" binary_path_arg="$3" +elif [ "$1" = "--tag" ] && [ -n "$2" ] +then + binary_download_url="https://github.com/wheaney/breezy-desktop/releases/download/$2/breezyGNOME.tar.gz" else binary_path_arg="$1" fi if [ -z "$binary_path_arg" ] then - # download and unzip the latest driver - echo "Downloading latest release to: ${tmp_dir}/breezyGNOME.tar.gz" - curl -L -O https://github.com/wheaney/breezy-desktop/releases/latest/download/breezyGNOME.tar.gz + # download and unzip the binary + echo "Downloading to: ${tmp_dir}/breezyGNOME.tar.gz" + curl -L -O $binary_download_url else if [[ "$binary_path_arg" = /* ]]; then abs_path="$binary_path_arg" diff --git a/gnome/bin/dev/use_local_extension.sh b/gnome/bin/dev/use_local_extension.sh index 54e9577..f959f13 100755 --- a/gnome/bin/dev/use_local_extension.sh +++ b/gnome/bin/dev/use_local_extension.sh @@ -6,6 +6,7 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) if [ -z "$XDG_DATA_HOME" ]; then XDG_DATA_HOME="$USER_HOME/.local/share" fi +DATA_DIR="$XDG_DATA_HOME/breezy_gnome" # if $XDG_DATA_HOME/gnome-shell/extensions/breezydesktop@xronlinux.com exists extension_path="$XDG_DATA_HOME/gnome-shell/extensions/breezydesktop@xronlinux.com" @@ -17,4 +18,12 @@ fi # recursively copy the $SCRIPT_DIR/../../src to extension_path, don't preserve symlinks cp -rL $SCRIPT_DIR/../../src $extension_path -glib-compile-schemas $extension_path/schemas \ No newline at end of file +glib-compile-schemas $extension_path/schemas + +pushd $extension_path +GNOME_MANIFEST_LINE=$(find -L . -type f ! -name "*.compiled" -exec sha256sum {} \; | sort | sha256sum | sed 's/ .*//') +popd + +pushd $DATA_DIR +echo -e "$GNOME_MANIFEST_LINE breezydesktop@xronlinux.com" > manifest +popd diff --git a/gnome/src/cursormanager.js b/gnome/src/cursormanager.js index 545619f..c2f5e82 100644 --- a/gnome/src/cursormanager.js +++ b/gnome/src/cursormanager.js @@ -1,4 +1,5 @@ import Clutter from 'gi://Clutter'; +import GLib from 'gi://GLib'; import Meta from 'gi://Meta'; import * as PointerWatcher from 'resource:///org/gnome/shell/ui/pointerWatcher.js'; import { MouseSpriteContent } from './cursor.js'; @@ -6,10 +7,9 @@ import Globals from './globals.js'; // Taken from https://github.com/jkitching/soft-brightness-plus export class CursorManager { - constructor(mainActor) { + constructor(mainActor, refreshRate) { this._mainActor = mainActor; - - this._changeHookFn = null; + this._refreshRate = refreshRate; // Set/destroyed by _enableCloningMouse/_disableCloningMouse this._cursorWantedVisible = null; @@ -26,7 +26,7 @@ export class CursorManager { this._cursorWatch = null; this._cursorChangedConnection = null; this._cursorVisibilityChangedConnection = null; - this._cursorPositionInvalidatedConnection = null; + this._redraw_timeline = null; } enable() { @@ -71,11 +71,7 @@ export class CursorManager { this._cursorSprite.content = new MouseSpriteContent(); this._cursorActor = new Clutter.Actor(); - if (Clutter.Container === undefined) { - this._cursorActor.add_child(this._cursorSprite); - } else { - this._cursorActor.add_actor(this._cursorSprite); - } + this._cursorActor.add_child(this._cursorSprite); this._cursorWatcher = PointerWatcher.getPointerWatcher(); this._cursorSeat = Clutter.get_default_backend().get_default_seat(); } @@ -92,6 +88,11 @@ export class CursorManager { Meta.CursorTracker.prototype.set_pointer_visible = this._cursorTrackerSetPointerVisible; this._cursorTracker.set_pointer_visible(this._cursorWantedVisible); + if (this._cursorSprite) { + this._cursorSprite.content = null; + if (this._cursorActor) this._cursorActor.remove_child(this._cursorSprite); + } + this._cursorWantedVisible = null; this._cursorTracker = null; this._cursorTrackerSetPointerVisible = null; @@ -123,23 +124,32 @@ export class CursorManager { // prereqs: setup in _enableCloningMouse, _cursorWantedVisible is true _startCloningMouse() { Globals.logger.log_debug('CursorManager _startCloningMouse'); - if (this._cursorWatch == null) { - if (Clutter.Container === undefined) { - this._mainActor.add_child(this._cursorActor); - } else { - this._mainActor.add_actor(this._cursorActor); - } - this._cursorChangedConnection = this._cursorTracker.connect('cursor-changed', this._updateMouseSprite.bind(this)); - this._cursorVisibilityChangedConnection = this._cursorTracker.connect('visibility-changed', this._updateMouseSprite.bind(this)); - this._cursorPositionInvalidatedConnection = this._cursorTracker.connect('position-invalidated', this._updateMouseSprite.bind(this)); + this._mainActor.add_child(this._cursorActor); + this._cursorChangedConnection = this._cursorTracker.connect('cursor-changed', this._queueSpriteUpdate.bind(this)); + this._cursorVisibilityChangedConnection = this._cursorTracker.connect('visibility-changed', this._queueVisibilityUpdate.bind(this)); - const interval = 1000 / 250; - this._cursorWatch = this._cursorWatcher.addWatch(interval, this._updateMousePosition.bind(this)); + const interval = 1000.0 / this._refreshRate; + this._redraw_timeline = Clutter.Timeline.new_for_actor(this._mainActor, interval * 10); + this._redraw_timeline.connect('new-frame', (() => { + this.handleNewFrame(); + }).bind(this)); - const [x, y] = global.get_pointer(); - this._updateMousePosition(x, y); - this._updateMouseSprite(); - } + // Some elements will occasionally appear above the cursor, so we periodically reset the actor stacking. + // This could theoretically be fixed "better" by attaching to all events that might affect actor ordering, + // but finding a comprehensive list is difficult and not future proof. So this ugly solution helps us + // catch everything. + this._redraw_timeline.connect('completed', (() => { + this._periodicReset(); + }).bind(this)); + + this._redraw_timeline.set_repeat_count(-1); + this._redraw_timeline.start(); + + this._cursorWatch = this._cursorWatcher.addWatch(interval, this._queuePositionUpdate.bind(this)); + + const [x, y] = global.get_pointer(); + this._queuePositionUpdate(x, y); + this._queueSpriteUpdate(); if (this._cursorTracker.set_keep_focus_while_hidden) { this._cursorTracker.set_keep_focus_while_hidden(true); @@ -163,27 +173,25 @@ export class CursorManager { if (this._cursorWatch != null) { this._cursorWatch.remove(); this._cursorWatch = null; + } + if (this._cursorChangedConnection) { this._cursorTracker.disconnect(this._cursorChangedConnection); this._cursorChangedConnection = null; + } + if (this._cursorVisibilityChangedConnection) { this._cursorTracker.disconnect(this._cursorVisibilityChangedConnection); this._cursorVisibilityChangedConnection = null; - - this._cursorTracker.disconnect(this._cursorPositionInvalidatedConnection); - this._cursorPositionInvalidatedConnection = null; - - if (Clutter.Container === undefined) { - this._mainActor.remove_child(this._cursorActor); - } else { - this._mainActor.remove_actor(this._cursorActor); - } } - if (this._cursorTracker.set_keep_focus_while_hidden) { - this._cursorTracker.set_keep_focus_while_hidden(false); + if (this._redraw_timeline) { + this._redraw_timeline.stop(); + this._redraw_timeline = null; } + if (this._cursorActor) this._mainActor.remove_child(this._cursorActor); + if (this._cursorUnfocusInhibited) { Globals.logger.log_debug('uninhibit_unfocus'); this._cursorSeat.uninhibit_unfocus(); @@ -191,31 +199,71 @@ export class CursorManager { } } - _updateMousePosition(x, y) { - this._cursorActor.set_position(x, y); + _queuePositionUpdate(x, y) { + this._queued_cursor_position = [x, y]; } - _updateMouseSprite() { - const sprite = this._cursorTracker.get_sprite(); - if (sprite) { - this._cursorSprite.content.texture = sprite; - this._cursorSprite.show(); - } else { - this._cursorSprite.hide(); - } + _queueSpriteUpdate() { + this._queued_sprite_update = true; + } - const [xHot, yHot] = this._cursorTracker.get_hot(); - this._cursorSprite.set({ - translation_x: -xHot, - translation_y: -yHot, - }); - this._mainActor.set_child_above_sibling(this._cursorActor, null); + _queueVisibilityUpdate() { + this._queued_visibility_update = true; this._cursorTrackerSetPointerVisibleBound(false); + this._queueSpriteUpdate(); + } - // some other processes are uninhibiting when they shouldn't, so we need to re-inhibit here - if (!this._cursorSeat.is_unfocus_inhibited() && this._cursorUnfocusInhibited) { - Globals.logger.log_debug('reinhibiting'); - this._cursorSeat.inhibit_unfocus(); + // updates the stacking and other attributes that are hard to track and may periodically get out of sync + _periodicReset() { + this._queue_reset = true; + this._queueVisibilityUpdate(); + } + + handleNewFrame() { + let redraw = false; + if (this._queued_cursor_position) { + const [x, y] = this._queued_cursor_position; + this._cursorActor.set_position(x, y); + this._queued_cursor_position = null; + redraw = true; } + + if (this._queued_sprite_update) { + const sprite = this._cursorTracker.get_sprite(); + if (sprite) { + this._cursorSprite.content.texture = sprite; + this._cursorSprite.show(); + } else { + this._cursorSprite.hide(); + } + + const [xHot, yHot] = this._cursorTracker.get_hot(); + this._cursorSprite.set({ + translation_x: -xHot, + translation_y: -yHot, + }); + this._queued_sprite_update = false; + redraw = true; + } + + if (this._queued_visibility_update) { + this._queued_visibility_update = false; + redraw = true; + } + + if (this._queue_reset) { + if (this._mainActor.get_last_child() !== this._cursorActor) + this._mainActor.set_child_above_sibling(this._cursorActor, null); + + // some other processes are uninhibiting when they shouldn't, so we need to re-inhibit here + if (!this._cursorSeat.is_unfocus_inhibited() && this._cursorUnfocusInhibited) { + Globals.logger.log_debug('reinhibiting'); + this._cursorSeat.inhibit_unfocus(); + } + this._queue_reset = false; + redraw = true; + } + + return redraw; } } \ No newline at end of file diff --git a/gnome/src/extension.js b/gnome/src/extension.js index 4f56a51..62cdcac 100644 --- a/gnome/src/extension.js +++ b/gnome/src/extension.js @@ -8,7 +8,8 @@ import St from 'gi://St'; import { CursorManager } from './cursormanager.js'; import Globals from './globals.js'; import { Logger } from './logger.js'; -import MonitorManager from './monitormanager.js'; +import { MonitorManager } from './monitormanager.js'; +import { isValidKeepAlive } from './time.js'; import { IPC_FILE_PATH, XREffect } from './xrEffect.js'; import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; @@ -20,8 +21,7 @@ const SUPPORTED_MONITOR_PRODUCTS = [ 'Air', 'Air 2', // guessing this one 'Air 2 Pro', - 'SmartGlasses', // TCL/RayNeo - 'MetaMonitor' // nested mode dummy monitor + 'SmartGlasses' // TCL/RayNeo ]; export default class BreezyDesktopExtension extends Extension { @@ -40,8 +40,17 @@ export default class BreezyDesktopExtension extends Extension { this._distance_binding = null; this._distance_connection = null; this._follow_threshold_connection = null; + this._widescreen_mode_settings_connection = null; + this._widescreen_mode_effect_state_connection = null; + this._supported_device_detected_connected = null; this._start_binding = null; this._end_binding = null; + this._curved_display_binding = null; + this._display_size_binding = null; + this._look_ahead_override_binding = null; + this._disable_anti_aliasing_binding = null; + this._optimal_monitor_config_binding = null; + this._headset_as_primary_binding = null; if (!Globals.logger) { Globals.logger = new Logger({ @@ -59,13 +68,22 @@ export default class BreezyDesktopExtension extends Extension { Globals.extension_dir = this.path; this.settings.bind('debug', Globals.logger, 'debug', Gio.SettingsBindFlags.DEFAULT); - this._monitor_manager = new MonitorManager(this.path); - this._monitor_manager.setChangeHook(this._setup.bind(this)); + this._monitor_manager = new MonitorManager({ + use_optimal_monitor_config: this.settings.get_boolean('use-optimal-monitor-config'), + headset_as_primary: this.settings.get_boolean('headset-as-primary'), + extension_path: this.path + }); + this._monitor_manager.setChangeHook(this._handle_monitor_change.bind(this)); this._monitor_manager.enable(); + this._optimal_monitor_config_binding = this.settings.bind('use-optimal-monitor-config', + this._monitor_manager, 'use-optimal-monitor-config', Gio.SettingsBindFlags.DEFAULT); + this._headset_as_primary_binding = this.settings.bind('headset-as-primary', + this._monitor_manager, 'headset-as-primary', Gio.SettingsBindFlags.DEFAULT); + this._setup(); } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension enable ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension enable ${e.message}\n${e.stack}`); } } @@ -74,12 +92,21 @@ export default class BreezyDesktopExtension extends Extension { var target_monitor = this._target_monitor; var is_effect_running = this._is_effect_running; this._running_poller_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, (() => { - if (is_effect_running) return GLib.SOURCE_REMOVE; + if (is_effect_running) { + this._running_poller_id = undefined; + return GLib.SOURCE_REMOVE; + } const is_driver_running = this._check_driver_running(); if (is_driver_running && target_monitor) { - Globals.logger.log('Driver is running, supported monitor connected. Enabling XR effect.'); - this._effect_enable(); + // Don't enable the effect yet if monitor updates are needed. + // _setup will be triggered again since a !ready result means it will trigger monitor changes, + // so we can remove this timeout_add no matter what. + if (this._target_monitor_ready(target_monitor)) { + Globals.logger.log('Driver is running, supported monitor connected. Enabling XR effect.'); + this._effect_enable(); + } + this._running_poller_id = undefined; return GLib.SOURCE_REMOVE; } else { return GLib.SOURCE_CONTINUE; @@ -93,57 +120,113 @@ export default class BreezyDesktopExtension extends Extension { const target_monitor = this._monitor_manager.getMonitorPropertiesList()?.find( monitor => SUPPORTED_MONITOR_PRODUCTS.includes(monitor.product)); if (target_monitor !== undefined) { + Globals.logger.log_debug(`BreezyDesktopExtension _find_supported_monitor - Identified supported monitor: ${target_monitor.connector}`); return { monitor: this._monitor_manager.getMonitors()[target_monitor.index], - refreshRate: target_monitor.refreshRate, + connector: target_monitor.connector, + refreshRate: target_monitor.refreshRate }; } if (this.settings.get_boolean('developer-mode')) { + Globals.logger.log_debug('BreezyDesktopExtension _find_supported_monitor - Using dummy monitor'); // allow testing XR devices with just USB, no video needed return { monitor: this._monitor_manager.getMonitors()[0], + connector: 'dummy', refreshRate: 60, + is_dummy: true }; } + Globals.logger.log_debug('BreezyDesktopExtension _find_supported_monitor - No supported monitor found'); return null; } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension _find_supported_monitor ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension _find_supported_monitor ${e.message}\n${e.stack}`); return null; } } + // Assumes target_monitor is set, and was returned by _find_supported_monitor. + // A false result means we'll expect _handle_monitor_change to be triggered, so active polling + // can be disabled. + _target_monitor_ready(target_monitor) { + if (target_monitor.is_dummy) { + // do this so it updates the state, if needed, but don't worry about the result since + // it won't trigger a monitor change + this._needs_widescreen_monitor_update(); + return true; + } + + // widescreen check should always be first since it changes the type of monitor, and we wouldn't + // want to update the optimal mode if the monitor configs are going to change again + return !this._needs_widescreen_monitor_update() && + !this._monitor_manager.needsOptimalModeCheck(target_monitor.connector); + } + _setup() { Globals.logger.log_debug('BreezyDesktopExtension _setup'); if (this._is_effect_running) { - Globals.logger.log('Monitors changed, disabling XR effect'); - this._effect_disable(); + Globals.logger.log('Reset triggered, disabling XR effect'); + this._effect_disable(true); } const target_monitor = this._find_supported_monitor(); // if target_monitor isn't set, do nothing and wait for MonitorManager to call this again if (target_monitor && this._running_poller_id === undefined) { - this._target_monitor = target_monitor.monitor; - this._refresh_rate = target_monitor.refreshRate; + this._target_monitor = target_monitor; if (this._check_driver_running()) { - Globals.logger.log('Ready, enabling XR effect'); - this._effect_enable(); + // Don't enable the effect yet if monitor updates are needed. + // _setup will be triggered again since a !ready result means it will trigger monitor changes + if (this._target_monitor_ready(target_monitor)) { + Globals.logger.log('Ready, enabling XR effect'); + this._effect_enable(); + } else { + Globals.logger.log_debug('BreezyDesktopExtension _setup - driver running but async monitor action needed'); + } } else { + Globals.logger.log_debug('BreezyDesktopExtension _setup - driver not running, starting poller'); this._poll_for_ready(); } + } else { + if (!target_monitor) { + Globals.logger.log_debug(`BreezyDesktopExtension _setup - Doing nothing, no supported monitor found`); + } else { + Globals.logger.log_debug(`BreezyDesktopExtension _setup - Doing nothing, target monitor found, waiting for poller to pick it up`); + } } } _check_driver_running() { try { if (!Globals.ipc_file) Globals.ipc_file = Gio.file_new_for_path(IPC_FILE_PATH); - return Globals.ipc_file.query_exists(null); + if (Globals.ipc_file.query_exists(null)) { + const file_info = Globals.ipc_file.query_info(Gio.FILE_ATTRIBUTE_TIME_MODIFIED, Gio.FileQueryInfoFlags.NONE, null); + const file_modified_time = file_info.get_attribute_uint64(Gio.FILE_ATTRIBUTE_TIME_MODIFIED); + + // when the driver is running, the IMU file is updated at least 60x per second, do a strict check + return isValidKeepAlive(file_modified_time, true); + } } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension _check_driver_running ${e.message}`, e.stack); - return false; + Globals.logger.log(`ERROR: BreezyDesktopExtension _check_driver_running ${e.message}\n${e.stack}`); } + + return false; + } + + _needs_widescreen_monitor_update() { + Globals.logger.log_debug('BreezyDesktopExtension _needs_widescreen_monitor_update'); + const state = this._read_state(['sbs_mode_enabled']); + const sbs_enabled = state['sbs_mode_enabled'] === 'true'; + const widescreen_setting_enabled = this.settings.get_boolean('widescreen-mode'); + if (widescreen_setting_enabled !== sbs_enabled) { + Globals.logger.log_debug('BreezyDesktopExtension _needs_widescreen_monitor_update - true'); + this._write_control('sbs_mode', widescreen_setting_enabled ? 'enable' : 'disable'); + return true; + } + + return false; } _effect_enable() { @@ -153,23 +236,21 @@ export default class BreezyDesktopExtension extends Extension { this._is_effect_running = true; try { - this._cursor_manager = new CursorManager(Main.layoutManager.uiGroup); + const targetMonitor = this._target_monitor.monitor; + const refreshRate = targetMonitor.refreshRate ?? 60; + this._cursor_manager = new CursorManager(Main.layoutManager.uiGroup, refreshRate); this._cursor_manager.enable(); - this._overlay = new St.Bin({ style: 'background-color: rgba(0, 0, 0, 1);'}); + this._overlay = new St.Bin(); this._overlay.opacity = 255; - this._overlay.set_position(this._target_monitor.x, this._target_monitor.y); - this._overlay.set_size(this._target_monitor.width, this._target_monitor.height); + this._overlay.set_position(targetMonitor.x, targetMonitor.y); + this._overlay.set_size(targetMonitor.width, targetMonitor.height); const overlayContent = new Clutter.Actor({clip_to_allocation: true}); const uiClone = new Clutter.Clone({ source: Main.layoutManager.uiGroup, clip_to_allocation: true }); - uiClone.x = -this._target_monitor.x; - uiClone.y = -this._target_monitor.y; - if (Clutter.Container === undefined) { - overlayContent.add_child(uiClone); - } else { - overlayContent.add_actor(uiClone); - } + uiClone.x = -targetMonitor.x; + uiClone.y = -targetMonitor.y; + overlayContent.add_child(uiClone); this._overlay.set_child(overlayContent); @@ -177,21 +258,31 @@ export default class BreezyDesktopExtension extends Extension { Shell.util_set_hidden_from_pick(this._overlay, true); this._xr_effect = new XREffect({ - target_monitor: this._target_monitor, - target_framerate: this._refresh_rate ?? 60, + target_monitor: targetMonitor, + target_framerate: refreshRate, 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'), + look_ahead_override: this.settings.get_int('look-ahead-override'), + disable_anti_aliasing: this.settings.get_boolean('disable-anti-aliasing') }); - this._update_display_distance(this.settings); this._update_follow_threshold(this.settings); + this._widescreen_mode_effect_state_connection = this._xr_effect.connect('notify::widescreen-mode-state', this._update_widescreen_mode_from_state.bind(this)); + this._supported_device_detected_connected = this._xr_effect.connect('notify::supported-device-detected', this._handle_supported_device_change.bind(this)); + this._distance_binding = this.settings.bind('display-distance', this._xr_effect, 'display-distance', Gio.SettingsBindFlags.DEFAULT) this._distance_connection = this.settings.connect('changed::display-distance', this._update_display_distance.bind(this)) this._follow_threshold_connection = this.settings.connect('changed::follow-threshold', this._update_follow_threshold.bind(this)) + + this._widescreen_mode_settings_connection = this.settings.connect('changed::widescreen-mode', this._update_widescreen_mode_from_settings.bind(this)) this._start_binding = this.settings.bind('toggle-display-distance-start', this._xr_effect, 'toggle-display-distance-start', Gio.SettingsBindFlags.DEFAULT) this._end_binding = this.settings.bind('toggle-display-distance-end', this._xr_effect, 'toggle-display-distance-end', Gio.SettingsBindFlags.DEFAULT) + this._curved_display_binding = this.settings.bind('curved-display', this._xr_effect, 'curved-display', Gio.SettingsBindFlags.DEFAULT) + this._display_size_binding = this.settings.bind('display-size', this._xr_effect, 'display-size', Gio.SettingsBindFlags.DEFAULT); + this._look_ahead_override_binding = this.settings.bind('look-ahead-override', this._xr_effect, 'look-ahead-override', Gio.SettingsBindFlags.DEFAULT); + this._disable_anti_aliasing_binding = this.settings.bind('disable-anti-aliasing', this._xr_effect, 'disable-anti-aliasing', Gio.SettingsBindFlags.DEFAULT); this._overlay.add_effect_with_name('xr-desktop', this._xr_effect); Meta.disable_unredirect_for_display(global.display); @@ -200,7 +291,7 @@ export default class BreezyDesktopExtension extends Extension { this._add_settings_keybinding('toggle-display-distance-shortcut', this._xr_effect._change_distance.bind(this._xr_effect)); this._add_settings_keybinding('toggle-follow-shortcut', this._toggle_follow_mode.bind(this)); } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension _effect_enable ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension _effect_enable ${e.message}\n${e.stack}`); this._effect_disable(); } } @@ -231,11 +322,11 @@ export default class BreezyDesktopExtension extends Extension { bind_to_function ); } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension _add_settings_keybinding settings binding lambda ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension _add_settings_keybinding settings binding lambda ${e.message}\n${e.stack}`); } }); } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension _add_settings_keybinding ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension _add_settings_keybinding ${e.message}\n${e.stack}`); } } @@ -246,6 +337,27 @@ export default class BreezyDesktopExtension extends Extension { stream.close(null); } + _read_state(keys) { + const state = {}; + const file = Gio.file_new_for_path('/dev/shm/xr_driver_state'); + if (file.query_exists(null)) { + const data = file.load_contents(null); + + if (data[0]) { + const bytes = new Uint8Array(data[1]); + const decoder = new TextDecoder(); + const contents = decoder.decode(bytes); + + const lines = contents.split('\n'); + for (const line of lines) { + const [k, v] = line.split('='); + if (keys.includes(k)) state[k] = v; + } + } + } + return state; + } + _update_display_distance(settings, event) { const value = settings.get_double('display-distance'); Globals.logger.log_debug(`BreezyDesktopExtension _update_display_distance ${value}`); @@ -258,6 +370,40 @@ export default class BreezyDesktopExtension extends Extension { if (value !== undefined) this._write_control('breezy_desktop_follow_threshold', value); } + _update_widescreen_mode_from_settings(settings, event) { + const value = settings.get_boolean('widescreen-mode'); + Globals.logger.log_debug(`BreezyDesktopExtension _update_widescreen_mode_from_settings ${value}`); + if (value !== undefined && value !== this._xr_effect.widescreen_mode_state) + this._write_control('sbs_mode', value ? 'enable' : 'disable'); + else + Globals.logger.log_debug('effect.widescreen_mode_state already matched setting'); + } + + _update_widescreen_mode_from_state(effect, _pspec) { + const value = effect.widescreen_mode_state; + Globals.logger.log_debug(`BreezyDesktopExtension _update_widescreen_mode_from_state ${value}`); + if (value !== this.settings.get_boolean('widescreen-mode')) + this.settings.set_boolean('widescreen-mode', value); + else + Globals.logger.log_debug('settings.widescreen-mode already matched state'); + } + + _handle_monitor_change() { + Globals.logger.log('Monitor change detected'); + this._setup(); + } + + _handle_supported_device_change(effect, _pspec) { + const value = effect.supported_device_detected; + Globals.logger.log_debug(`BreezyDesktopExtension _handle_supported_device_change ${value}`); + + // this will disable the effect and begin polling for a ready state again + if (!value && this._is_effect_running) { + Globals.logger.log('Supported device disconnected'); + this._setup(); + } + } + _recenter_display() { Globals.logger.log_debug('BreezyDesktopExtension _recenter_display'); this._write_control('recenter_screen', 'true'); @@ -268,12 +414,16 @@ export default class BreezyDesktopExtension extends Extension { this._write_control('toggle_breezy_desktop_smooth_follow', 'true'); } - _effect_disable() { + // for_setup should be true if our intention is to immediately re-enable the extension + _effect_disable(for_setup = false) { try { Globals.logger.log_debug('BreezyDesktopExtension _effect_disable'); this._is_effect_running = false; - if (this._running_poller_id) GLib.source_remove(this._running_poller_id); + if (this._running_poller_id) { + GLib.source_remove(this._running_poller_id); + this._running_poller_id = undefined; + } Main.wm.removeKeybinding('recenter-display-shortcut'); Main.wm.removeKeybinding('toggle-display-distance-shortcut'); @@ -281,12 +431,13 @@ export default class BreezyDesktopExtension extends Extension { Meta.enable_unredirect_for_display(global.display); if (this._overlay) { - global.stage.remove_child(this._overlay); + if (this._xr_effect) this._xr_effect.cleanup(); this._overlay.remove_effect_by_name('xr-desktop'); + + global.stage.remove_child(this._overlay); this._overlay.destroy(); this._overlay = null; } - if (this._distance_binding) { this.settings.unbind(this._distance_binding); this._distance_binding = null; @@ -299,6 +450,10 @@ export default class BreezyDesktopExtension extends Extension { this.settings.disconnect(this._follow_threshold_connection); this._follow_threshold_connection = null; } + if (this._widescreen_mode_settings_connection) { + this.settings.disconnect(this._widescreen_mode_settings_connection); + this._widescreen_mode_settings_connection = null; + } if (this._start_binding) { this.settings.unbind(this._start_binding); this._start_binding = null; @@ -307,17 +462,46 @@ export default class BreezyDesktopExtension extends Extension { this.settings.unbind(this._end_binding); this._end_binding = null; } + if (this._curved_display_binding) { + this.settings.unbind(this._curved_display_binding); + this._curved_display_binding = null; + } + if (this._display_size_binding) { + this.settings.unbind(this._display_size_binding); + this._display_size_binding = null; + } + if (this._look_ahead_override_binding) { + this.settings.unbind(this._look_ahead_override_binding); + this._look_ahead_override_binding = null; + } + if (this._disable_anti_aliasing_binding) { + this.settings.unbind(this._disable_anti_aliasing_binding); + this._disable_anti_aliasing_binding = null; + } if (this._xr_effect) { - this._xr_effect.cleanup(); + if (this._widescreen_mode_effect_state_connection) { + this._xr_effect.disconnect(this._widescreen_mode_effect_state_connection); + this._widescreen_mode_effect_state_connection = null; + } + if (this._supported_device_detected_connected) { + this._xr_effect.disconnect(this._supported_device_detected_connected); + this._supported_device_detected_connected = null; + } this._xr_effect = null; } - if (this._cursor_manager) { this._cursor_manager.disable(); this._cursor_manager = null; } + + // this should always be done at the end of this function after the widescreen settings binding is removed, + // so it doesn't reset the setting to false + if (!for_setup && this.settings.get_boolean('widescreen-mode')) { + Globals.logger.log('Disabling SBS mode due to disabling effect'); + this._write_control('sbs_mode', 'disable'); + } } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension _effect_disable ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension _effect_disable ${e.message}\n${e.stack}`); } } @@ -327,11 +511,20 @@ export default class BreezyDesktopExtension extends Extension { this._effect_disable(); this._target_monitor = null; if (this._monitor_manager) { + if (this._optimal_monitor_config_binding) { + this.settings.unbind(this._optimal_monitor_config_binding); + this._optimal_monitor_config_binding = null + } + if (this._headset_as_primary_binding) { + this.settings.unbind(this._headset_as_primary_binding); + this._headset_as_primary_binding = null; + } + this._monitor_manager.disable(); this._monitor_manager = null; } } catch (e) { - Globals.logger.log(`ERROR: BreezyDesktopExtension disable ${e.message}`, e.stack); + Globals.logger.log(`ERROR: BreezyDesktopExtension disable ${e.message}\n${e.stack}`); } } } diff --git a/gnome/src/ipc.js b/gnome/src/ipc.js index 77d8fa1..a729368 100644 --- a/gnome/src/ipc.js +++ b/gnome/src/ipc.js @@ -24,7 +24,7 @@ export function dataViewBigUint(dataView, dataViewInfo) { return Number(dataView.getBigUint64(dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX], true)); } -export function dataViewUintArray(dataView, dataViewInfo) { +export function dataViewUint32Array(dataView, dataViewInfo) { const uintArray = [] let offset = dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX]; for (let i = 0; i < dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX]; i++) { @@ -34,6 +34,16 @@ export function dataViewUintArray(dataView, dataViewInfo) { return uintArray; } +export function dataViewUint8Array(dataView, dataViewInfo) { + const uintArray = [] + let offset = dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX]; + for (let i = 0; i < dataViewInfo[DATA_VIEW_INFO_SIZE_INDEX] * dataViewInfo[DATA_VIEW_INFO_COUNT_INDEX]; i++) { + uintArray.push(dataView.getUint8(offset)); + offset += UINT8_SIZE; + } + return uintArray; +} + export function dataViewFloat(dataView, dataViewInfo) { return dataView.getFloat32(dataViewInfo[DATA_VIEW_INFO_OFFSET_INDEX], true); } diff --git a/gnome/src/monitormanager.js b/gnome/src/monitormanager.js index 0eacea5..8126ecf 100644 --- a/gnome/src/monitormanager.js +++ b/gnome/src/monitormanager.js @@ -17,6 +17,7 @@ // along with this program. If not, see . import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; @@ -52,10 +53,10 @@ export function newDisplayConfig(extPath, callback) { ); } -export function getMonitorConfig(displayConfigProxy, callback) { - displayConfigProxy.GetResourcesRemote((result) => { - if (result.length <= 2) { - callback(null, 'Cannot get DisplayConfig: No outputs in GetResources()'); +function getMonitorConfig(displayConfigProxy, callback) { + displayConfigProxy.GetResourcesRemote((result, error) => { + if (error) { + callback(null, `GetResourcesRemote failed: ${error}`); } else { const monitors = []; for (let i = 0; i < result[2].length; i++) { @@ -84,32 +85,163 @@ export function getMonitorConfig(displayConfigProxy, callback) { }); } +// triggers callback with true result if an an async monitor config change was triggered, false if no config change needed +function performOptimalModeCheck(displayConfigProxy, connectorName, headsetAsPrimary, callback) { + Globals.logger.log_debug(`monitormanager.js performOptimalModeCheck for ${connectorName}`); + displayConfigProxy.GetCurrentStateRemote((result, error) => { + if (error) { + callback(null, `GetCurrentState failed: ${error}`); + } else { + Globals.logger.log_debug(`monitormanager.js performOptimalModeCheck GetCurrentState result: ${JSON.stringify(result)}`); + const [serial, monitors, logicalMonitors, properties] = result; + + // iterate over all monitors at least once, collecting the best fit mode for our monitor, and mode information + // for each monitor + let ourMonitor = undefined; + let monitorToModeIdMap = {}; + let bestFitMode = undefined; + for (let monitor of monitors) { + const [details, modes, monProperties] = monitor; + const [connector, vendor, product, monitorSerial] = details; + const isOurMonitor = connector == connectorName; + if (isOurMonitor) ourMonitor = monitor; + + for (let mode of modes) { + const [modeId, width, height, refreshRate, preferredScale, supportedScales, modeProperites] = mode; + const isCurrent = !!modeProperites['is-current']; + if (isCurrent) monitorToModeIdMap[connector] = modeId; + + if (isOurMonitor && (!bestFitMode || ( + width >= bestFitMode.width && + height >= bestFitMode.height && + refreshRate >= bestFitMode.refreshRate))) { + // find the scale that is closest to 1.0 + const bestScale = supportedScales.reduce((prev, curr) => { + return Math.abs(curr - 1.0) < Math.abs(prev - 1.0) ? curr : prev; + }); + + bestFitMode = { + modeId, + width, + height, + refreshRate, + bestScale, + isCurrent + }; + } + } + } + + if (!!ourMonitor) { + let anyMonitorsChanged = false; + if (!!bestFitMode) { + // map from original logical monitors schema to a(iiduba(ssa{sv})) for ApplyMonitorsConfig call + const updatedLogicalMonitors = logicalMonitors.map((logicalMonitor) => { + const [x, y, scale, transform, primary, monitors, logMonProperties] = logicalMonitor; + const hasOurMonitor = !!monitors.some((monitor) => monitor[0] === connectorName); + anyMonitorsChanged |= hasOurMonitor && bestFitMode.bestScale !== scale; + + // there can only be one primary monitor, so we need to set all other monitors to not primary and glasses to primary, + // if headsetAsPrimary is true + anyMonitorsChanged |= headsetAsPrimary && ((hasOurMonitor && !primary) || (!hasOurMonitor && primary)); + return [ + x, + y, + hasOurMonitor ? bestFitMode.bestScale : scale, + transform, + headsetAsPrimary ? hasOurMonitor : primary, + monitors.map((monitor) => { + const monitorConnector = monitor[0]; + const isOurMonitor = monitorConnector === connectorName; + anyMonitorsChanged |= isOurMonitor && !bestFitMode.isCurrent; + return [ + monitorConnector, + isOurMonitor ? bestFitMode.modeId : monitorToModeIdMap[monitorConnector], + {} // properties + ]; + }) + ]; + }); + + // if our monitor is already properly configured, we can skip the ApplyMonitorsConfig call + if (anyMonitorsChanged) { + Globals.logger.log_debug(`monitormanager.js performOptimalModeCheck updatedLogicalMonitors: ${JSON.stringify(updatedLogicalMonitors)}`); + displayConfigProxy.ApplyMonitorsConfigRemote( + serial, + 1, // "temporary" config -- "permanent" might be pointless since we always do this check + updatedLogicalMonitors, + {}, // properties + (_result, error) => { + if (error) { + callback(null, `ApplyMonitorsConfig failed: ${error}`); + } else { + callback(true, null); + } + } + ); + } + } + if (!anyMonitorsChanged) callback(false, null); + } else { + callback(null, `Monitor ${connectorName} not found in GetCurrentState result`); + } + } + }); +} + // Monitor change handling -export default class MonitorManager { - constructor(extPath) { - this._extPath = extPath; +export const MonitorManager = GObject.registerClass({ + Properties: { + 'use-optimal-monitor-config': GObject.ParamSpec.boolean( + 'use-optimal-monitor-config', + 'Use optimal monitor configuration', + 'Automatically set the optimal monitor configuration upon connection', + GObject.ParamFlags.READWRITE, + true + ), + 'headset-as-primary': GObject.ParamSpec.boolean( + 'headset-as-primary', + 'Use headset as primary monitor', + 'Automatically set the headset as the primary display upon connection', + GObject.ParamFlags.READWRITE, + true + ), + 'extension-path': GObject.ParamSpec.string( + 'extension-path', + 'Extension path', + 'Path to the extension directory', + GObject.ParamFlags.READWRITE, + '' + ) + } +}, class MonitorManager extends GObject.Object { + constructor(params = {}) { + super(params); this._monitorsChangedConnection = null; this._displayConfigProxy = null; this._backendManager = null; this._monitorProperties = null; this._changeHookFn = null; + this._needsConfigCheck = this.use_optimal_monitor_config; } enable() { + Globals.logger.log_debug('MonitorManager enable'); this._backendManager = global.backend.get_monitor_manager(); - newDisplayConfig(this._extPath, (proxy, error) => { + newDisplayConfig(this.extension_path, ((proxy, error) => { if (error) { return; } this._displayConfigProxy = proxy; this._on_monitors_change(); - }); + }).bind(this)); this._monitorsChangedConnection = Main.layoutManager.connect('monitors-changed', this._on_monitors_change.bind(this)); } disable() { + Globals.logger.log_debug('MonitorManager disable'); Main.layoutManager.disconnect(this._monitorsChangedConnection); this._monitorsChangedConnection = null; @@ -123,10 +255,6 @@ export default class MonitorManager { this._changeHookFn = fn; } - setPostCallback(callback) { - this._postCallback = callback; - } - getMonitors() { return Main.layoutManager.monitors; } @@ -135,11 +263,45 @@ export default class MonitorManager { return this._monitorProperties; } + // returns true if a check is needed, caller should wait for the next change hook call + needsOptimalModeCheck(monitorConnector) { + Globals.logger.log_debug(`MonitorManager checkOptimalMode: ${monitorConnector}`); + if (this._displayConfigProxy == null) { + Globals.logger.log('MonitorManager checkOptimalMode: _displayConfigProxy not set!'); + return false; + } + + if (this._needsConfigCheck) { + performOptimalModeCheck(this._displayConfigProxy, monitorConnector, this.headset_as_primary, ((configChanged, error) => { + this._needsConfigCheck = false; + if (error) { + Globals.logger.log(`Failed to switch to optimal mode for monitor ${monitorConnector}: ${error}`); + } else { + if (configChanged) { + Globals.logger.log(`Switched to optimal mode for monitor ${monitorConnector}`); + } else if (!!this._changeHookFn) { + Globals.logger.log_debug('MonitorManager checkOptimalMode: no config change'); + + // no config change means this won't be triggered automatically, so trigger it manually + this._changeHookFn(); + } else { + Globals.logger.log('MonitorManager checkOptimalMode: can\'t trigger change hook, no hook set!'); + } + } + }).bind(this)); + } else { + Globals.logger.log_debug('MonitorManager checkOptimalMode: skipping config check'); + } + return this._needsConfigCheck; + } + _on_monitors_change() { + Globals.logger.log_debug('MonitorManager _on_monitors_change'); if (this._displayConfigProxy == null) { return; } - getMonitorConfig(this._displayConfigProxy, (result, error) => { + this._needsConfigCheck = this.use_optimal_monitor_config; + getMonitorConfig(this._displayConfigProxy, ((result, error) => { if (error) { return; } @@ -161,9 +323,11 @@ export default class MonitorManager { } } this._monitorProperties = monitorProperties; - if (this._changeHookFn !== null) { + if (!!this._changeHookFn) { this._changeHookFn(); + } else { + Globals.logger.log('MonitorManager _on_monitors_change: can\'t trigger change hook, no hook set!'); } - }); + }).bind(this)); } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/gnome/src/time.js b/gnome/src/time.js index 6c3259c..7883b9b 100644 --- a/gnome/src/time.js +++ b/gnome/src/time.js @@ -4,4 +4,8 @@ export function getEpochSec() { export function toSec(milliseconds) { return Math.floor(milliseconds / 1000); +} + +export function isValidKeepAlive(dateSec, strictCheck = false) { + return Math.abs(toSec(Date.now()) - dateSec) <= (strictCheck ? 1 : 5); } \ No newline at end of file diff --git a/gnome/src/xrEffect.js b/gnome/src/xrEffect.js index 0c0b15b..2680cae 100644 --- a/gnome/src/xrEffect.js +++ b/gnome/src/xrEffect.js @@ -11,7 +11,8 @@ import { dataViewEnd, dataViewUint8, dataViewBigUint, - dataViewUintArray, + dataViewUint32Array, + dataViewUint8Array, dataViewFloat, dataViewFloatArray, BOOL_SIZE, @@ -23,12 +24,12 @@ import { } from "./ipc.js"; import { degreeToRadian } from "./math.js"; import { getShaderSource } from "./shader.js"; -import { toSec } from "./time.js"; +import { isValidKeepAlive, toSec } from "./time.js"; export const IPC_FILE_PATH = "/dev/shm/breezy_desktop_imu"; // the driver should be using the same data layout version -const DATA_LAYOUT_VERSION = 2; +const DATA_LAYOUT_VERSION = 3; // DataView info: [offset, size, count] const VERSION = [0, UINT8_SIZE, 1]; @@ -41,7 +42,8 @@ const SBS_ENABLED = [dataViewEnd(LENS_DISTANCE_RATIO), BOOL_SIZE, 1]; const CUSTOM_BANNER_ENABLED = [dataViewEnd(SBS_ENABLED), BOOL_SIZE, 1]; const EPOCH_MS = [dataViewEnd(CUSTOM_BANNER_ENABLED), UINT_SIZE, 2]; const IMU_QUAT_DATA = [dataViewEnd(EPOCH_MS), FLOAT_SIZE, 16]; -const DATA_VIEW_LENGTH = dataViewEnd(IMU_QUAT_DATA); +const IMU_PARITY_BYTE = [dataViewEnd(IMU_QUAT_DATA), UINT8_SIZE, 1]; +const DATA_VIEW_LENGTH = dataViewEnd(IMU_PARITY_BYTE); // cached after first retrieval const shaderUniformLocations = { @@ -50,20 +52,23 @@ const shaderUniformLocations = { 'imu_quat_data': null, 'look_ahead_cfg': null, 'look_ahead_ms': null, - 'stage_aspect_ratio': null, - 'display_aspect_ratio': null, 'trim_width_percent': null, 'trim_height_percent': null, - 'display_zoom': null, + 'display_size': null, 'display_north_offset': null, - 'lens_distance_ratio': null, + 'lens_vector': null, + 'lens_vector_r': null, // only used if sbs_enabled is true + 'texcoord_x_limits': null, // index 0: min; index 1: max + 'texcoord_x_limits_r': null, // only used if sbs_enabled is true 'sbs_enabled': null, - 'sbs_content': null, 'custom_banner_enabled': null, 'half_fov_z_rads': null, 'half_fov_y_rads': null, - 'screen_distance': null, - 'display_res': null + 'fov_half_widths': null, + 'fov_widths': null, + 'display_resolution': null, + 'source_to_display_ratio': null, + 'curved_display': null }; function setUniformFloat(effect, locationName, dataViewInfo, value) { @@ -109,67 +114,121 @@ function lookAheadMS(dataView) { // most uniforms don't change frequently, this function should be called periodically function setIntermittentUniformVariables() { - const dataView = this._dataView; + try { + const dataView = this._dataView; - if (dataView.byteLength === DATA_VIEW_LENGTH) { - const version = dataViewUint8(dataView, VERSION); - const imuDateMS = dataViewBigUint(dataView, EPOCH_MS); - const currentDateMS = Date.now(); - const validKeepalive = Math.abs(toSec(currentDateMS) - toSec(imuDateMS)) < 5; - const imuData = dataViewFloatArray(dataView, IMU_QUAT_DATA); - const imuResetState = validKeepalive && 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; + if (dataView.byteLength === DATA_VIEW_LENGTH) { + const version = dataViewUint8(dataView, VERSION); + const imuDateMs = dataViewBigUint(dataView, EPOCH_MS); + const validKeepalive = isValidKeepAlive(toSec(imuDateMs)); + const imuData = dataViewFloatArray(dataView, IMU_QUAT_DATA); + const imuResetState = validKeepalive && 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; + const displayRes = dataViewUint32Array(dataView, DISPLAY_RES); + const sbsEnabled = dataViewUint8(dataView, SBS_ENABLED) !== 0; - if (enabled) { - const displayRes = dataViewUintArray(dataView, DISPLAY_RES); - const displayFov = dataViewFloat(dataView, DISPLAY_FOV); - const lensDistanceRatio = dataViewFloat(dataView, LENS_DISTANCE_RATIO); + if (enabled) { + const displayFov = dataViewFloat(dataView, DISPLAY_FOV); - // compute these values once, they only change when the XR device changes - const displayAspectRatio = displayRes[0] / displayRes[1]; - const stageAspectRatio = this.target_monitor.width / this.target_monitor.height; - const diagToVertRatio = Math.sqrt(Math.pow(stageAspectRatio, 2) + 1); - const halfFovZRads = degreeToRadian(displayFov / diagToVertRatio) / 2; - const halfFovYRads = halfFovZRads * stageAspectRatio; - const screenDistance = 1.0 - lensDistanceRatio; + // TODO - drive these values from settings + const sbsContent = false; + const sbsModeStretched = true; - // our overlay doesn't quite cover the full screen texture, which allows us to see some of the real desktop - // underneath, so we trim two pixels around the entire edge of the texture - const trimWidthPercent = 3.0 / this.target_monitor.width; - const trimHeightPercent = 3.0 / this.target_monitor.height; - - // all these values are transferred directly, unmodified from the driver - transferUniformFloat(this, 'look_ahead_cfg', dataView, LOOK_AHEAD_CFG); - transferUniformFloat(this, 'lens_distance_ratio', dataView, LENS_DISTANCE_RATIO); + // compute these values once, they only change when the XR device changes + const displayAspectRatio = displayRes[0] / displayRes[1]; + const diagToVertRatio = Math.sqrt(Math.pow(displayAspectRatio, 2) + 1); + const halfFovZRads = degreeToRadian(displayFov / diagToVertRatio) / 2; + const halfFovYRads = halfFovZRads * displayAspectRatio; + const fovHalfWidths = [Math.tan(halfFovYRads), Math.tan(halfFovZRads)]; + const fovWidths = [fovHalfWidths[0] * 2, fovHalfWidths[1] * 2]; + let texcoordXLimits = [0.0, 1.0]; + let texcoordXLimitsRight = [0.0, 1.0]; + if (sbsEnabled) { + if (sbsContent) { + texcoordXLimits[1] = 0.5; + texcoordXLimitsRight[0] = 0.5; + if (!sbsModeStretched) { + texcoordXLimits[0] = 0.25; + texcoordXLimitsRight[1] = 0.75; + } + } else if (!sbsModeStretched) { + texcoordXLimits[0] = 0.25; + texcoordXLimits[1] = 0.75; + } + } + const lensDistanceRatio = dataViewFloat(dataView, LENS_DISTANCE_RATIO); + const lensFromCenter = lensDistanceRatio / 3.0; + const lensVector = [lensDistanceRatio, lensFromCenter, 0.0]; + const lensVectorRight = [lensDistanceRatio, -lensFromCenter, 0.0]; - // computed values with no dataViewInfo, so we set these manually - setSingleFloat(this, 'stage_aspect_ratio', stageAspectRatio); - setSingleFloat(this, 'display_aspect_ratio', displayAspectRatio); - setSingleFloat(this, 'trim_width_percent', trimWidthPercent); - setSingleFloat(this, 'trim_height_percent', trimHeightPercent); - setSingleFloat(this, 'half_fov_z_rads', halfFovZRads); - setSingleFloat(this, 'half_fov_y_rads', halfFovYRads); - setSingleFloat(this, 'screen_distance', screenDistance); + // our overlay doesn't quite cover the full screen texture, which allows us to see some of the real desktop + // underneath, so we trim three pixels around the entire edge of the texture + const trimWidthPercent = 3.0 / this.target_monitor.width; + const trimHeightPercent = 3.0 / this.target_monitor.height; + + // all these values are transferred directly, unmodified from the driver + transferUniformFloat(this, 'look_ahead_cfg', dataView, LOOK_AHEAD_CFG); + transferUniformFloat(this, 'lens_distance_ratio', dataView, LENS_DISTANCE_RATIO); - // TOOD - drive from settings - setSingleFloat(this, 'display_zoom', 1.0); + // computed values with no dataViewInfo, so we set these manually + setSingleFloat(this, 'trim_width_percent', trimWidthPercent); + setSingleFloat(this, 'trim_height_percent', trimHeightPercent); + setSingleFloat(this, 'half_fov_z_rads', halfFovZRads); + setSingleFloat(this, 'half_fov_y_rads', halfFovYRads); + this.set_uniform_float(shaderUniformLocations['fov_half_widths'], 2, fovHalfWidths); + this.set_uniform_float(shaderUniformLocations['fov_widths'], 2, fovWidths); + setSingleFloat(this, 'curved_display', this.curved_display ? 1.0 : 0.0); + this.set_uniform_float(shaderUniformLocations['texcoord_x_limits'], 2, texcoordXLimits); + this.set_uniform_float(shaderUniformLocations['texcoord_x_limits_r'], 2, texcoordXLimitsRight); + this.set_uniform_float(shaderUniformLocations['lens_vector'], 3, lensVector); + this.set_uniform_float(shaderUniformLocations['lens_vector_r'], 3, lensVectorRight); + } + + // update the supported device detected property if the state changes, trigger "notify::" events + if (this.supported_device_detected !== validKeepalive) this.supported_device_detected = validKeepalive; + + // update the widescreen property if the state changes while still enabled, trigger "notify::" events + if (enabled && this.widescreen_mode_state !== sbsEnabled) this.widescreen_mode_state = sbsEnabled; + + // these variables are always in play, even if enabled is false + setSingleFloat(this, 'enabled', enabled ? 1.0 : 0.0); + setSingleFloat(this, 'show_banner', imuResetState ? 1.0 : 0.0); + setSingleFloat(this, 'sbs_enabled', sbsEnabled ? 1.0 : 0.0); + setSingleFloat(this, 'custom_banner_enabled', dataViewUint8(dataView, CUSTOM_BANNER_ENABLED) !== 0 ? 1.0 : 0.0); + + this.set_uniform_float(shaderUniformLocations['display_resolution'], 2, displayRes); + this.set_uniform_float(shaderUniformLocations['source_to_display_ratio'], 2, [this.target_monitor.width/displayRes[0], this.target_monitor.height/displayRes[1]]); + } else if (dataView.byteLength !== 0) { + throw new Error(`Invalid dataView.byteLength: ${dataView.byteLength} !== ${DATA_VIEW_LENGTH}`); } - - // these variables are always in play, even if enabled is false - setSingleFloat(this, 'enabled', enabled ? 1.0 : 0.0); - setSingleFloat(this, 'show_banner', imuResetState ? 1.0 : 0.0); - setSingleFloat(this, 'sbs_content', 0.0); // TOOD - drive from settings - setSingleFloat(this, 'sbs_enabled', dataViewUint8(dataView, SBS_ENABLED) !== 0 ? 1.0 : 0.0); - setSingleFloat(this, 'custom_banner_enabled', dataViewUint8(dataView, CUSTOM_BANNER_ENABLED) !== 0 ? 1.0 : 0.0); - - this.set_uniform_float(shaderUniformLocations['display_res'], 2, [this.target_monitor.width, this.target_monitor.height]); - } else if (dataView.byteLength !== 0) { - Globals.logger.log(`ERROR: Invalid dataView.byteLength: ${dataView.byteLength} !== ${DATA_VIEW_LENGTH}`) + } catch (e) { + Globals.logger.log(`ERROR: xrEffect.js setIntermittentUniformVariables ${e.message}\n${e.stack}`); } } +function checkParityByte(dataView) { + const parityByte = dataViewUint8(dataView, IMU_PARITY_BYTE); + let parity = 0; + const epochUint8 = dataViewUint8Array(dataView, EPOCH_MS); + const imuDataUint8 = dataViewUint8Array(dataView, IMU_QUAT_DATA); + for (let i = 0; i < epochUint8.length; i++) { + parity ^= epochUint8[i]; + } + for (let i = 0; i < imuDataUint8.length; i++) { + parity ^= imuDataUint8[i]; + } + return parityByte === parity; +} + export const XREffect = GObject.registerClass({ Properties: { + 'supported-device-detected': GObject.ParamSpec.boolean( + 'supported-device-detected', + 'Supported device detected', + 'Whether a supported device is connected', + GObject.ParamFlags.READWRITE, + false + ), 'target-monitor': GObject.ParamSpec.jsobject( 'target-monitor', 'Target Monitor', @@ -191,6 +250,15 @@ export const XREffect = GObject.registerClass({ 2.5, 1.05 ), + 'display-size': GObject.ParamSpec.double( + 'display-size', + 'Display size', + 'Size of the display', + GObject.ParamFlags.READWRITE, + 0.2, + 2.5, + 1.0 + ), 'toggle-display-distance-start': GObject.ParamSpec.double( 'toggle-display-distance-start', 'Display distance start', @@ -208,14 +276,42 @@ export const XREffect = GObject.registerClass({ 0.2, 2.5, 1.05 + ), + 'curved-display': GObject.ParamSpec.boolean( + 'curved-display', + 'Curved Display', + 'Whether the display is curved', + GObject.ParamFlags.READWRITE, + false + ), + 'widescreen-mode-state': GObject.ParamSpec.boolean( + 'widescreen-mode-state', + 'Widescreen mode state', + 'The state of widescreen mode from the perspective of the driver', + GObject.ParamFlags.READWRITE, + false + ), + 'look-ahead-override': GObject.ParamSpec.int( + 'look-ahead-override', + 'Look ahead override', + 'Override the look ahead value', + GObject.ParamFlags.READWRITE, + -1, + 45, + -1 + ), + 'disable-anti-aliasing': GObject.ParamSpec.boolean( + 'disable-anti-aliasing', + 'Disable anti-aliasing', + 'Disable anti-aliasing for the effect', + GObject.ParamFlags.READWRITE, + false ) } }, class XREffect extends Shell.GLSLEffect { constructor(params = {}) { super(params); - this._frametime = Math.floor(1000 / this.target_framerate); - this._is_display_distance_at_end = false; this._distance_ease_timeline = null; @@ -255,14 +351,11 @@ export const XREffect = GObject.registerClass({ } vfunc_paint_target(node, paintContext) { - var now = Date.now(); - var lastPaint = this._last_paint || 0; - var frametime = this._frametime; var calibratingImage = this.calibratingImage; var customBannerImage = this.customBannerImage; - const data = Globals.ipc_file.load_contents(null); + let data = Globals.ipc_file.load_contents(null); if (data[0]) { - const buffer = new Uint8Array(data[1]).buffer; + let buffer = new Uint8Array(data[1]).buffer; this._dataView = new DataView(buffer); if (!this._initialized) { this.set_uniform_float(this.get_uniform_location('uDesktopTexture'), 1, [0]); @@ -278,10 +371,12 @@ export const XREffect = GObject.registerClass({ this.setIntermittentUniformVariables = setIntermittentUniformVariables.bind(this); this.setIntermittentUniformVariables(); - this._redraw_timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._frametime, () => { - if ((now - lastPaint) > frametime) global.stage.queue_redraw(); - return GLib.SOURCE_CONTINUE; - }); + this._redraw_timeline = Clutter.Timeline.new_for_actor(this.get_actor(), 1000); + this._redraw_timeline.connect('new-frame', (() => { + this.queue_repaint(); + }).bind(this)); + this._redraw_timeline.set_repeat_count(-1); + this._redraw_timeline.start(); this._uniforms_timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, (() => { this.setIntermittentUniformVariables(); @@ -291,30 +386,48 @@ export const XREffect = GObject.registerClass({ this._initialized = true; } - if (this._dataView.byteLength === DATA_VIEW_LENGTH) { - 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) { - Globals.logger.log(`ERROR: Invalid dataView.byteLength: ${this._dataView.byteLength} !== ${DATA_VIEW_LENGTH}`) + let success = false; + let attempts = 0; + while (!success && attempts < 2) { + if (this._dataView.byteLength === DATA_VIEW_LENGTH) { + if (checkParityByte(this._dataView)) { + setSingleFloat(this, 'display_north_offset', this.display_distance); + setSingleFloat(this, 'look_ahead_ms', + this.look_ahead_override === -1 ? lookAheadMS(this._dataView) : this.look_ahead_override); + setUniformMatrix(this, 'imu_quat_data', 4, this._dataView, IMU_QUAT_DATA); + setSingleFloat(this, 'display_size', this.display_size); + success = true; + } + } else if (this._dataView.byteLength !== 0) { + Globals.logger.log(`ERROR: Invalid dataView.byteLength: ${this._dataView.byteLength} !== ${DATA_VIEW_LENGTH}`) + } + + if (!success && ++attempts < 3) { + data = Globals.ipc_file.load_contents(null); + if (data[0]) { + buffer = new Uint8Array(data[1]).buffer; + this._dataView = new DataView(buffer); + } + } } - // improves sampling quality for smooth text and edges - this.get_pipeline().set_layer_filters ( - 0, - Cogl.PipelineFilter.LINEAR_MIPMAP_LINEAR, - Cogl.PipelineFilter.LINEAR - ); - - super.vfunc_paint_target(node, paintContext); - } else { - super.vfunc_paint_target(node, paintContext); + if (!this.disable_anti_aliasing) { + // improves sampling quality for smooth text and edges + this.get_pipeline().set_layer_filters( + 0, + Cogl.PipelineFilter.LINEAR_MIPMAP_LINEAR, + Cogl.PipelineFilter.LINEAR + ); + } } - this._last_paint = now; + super.vfunc_paint_target(node, paintContext); } cleanup() { - if (this._redraw_timeout_id) GLib.source_remove(this._redraw_timeout_id); + if (this._redraw_timeline) { + this._redraw_timeline.stop(); + this._redraw_timeline = null; + } if (this._uniforms_timeout_id) GLib.source_remove(this._uniforms_timeout_id); } }); \ No newline at end of file diff --git a/modules/XRLinuxDriver b/modules/XRLinuxDriver index f45b8fb..b97faa8 160000 --- a/modules/XRLinuxDriver +++ b/modules/XRLinuxDriver @@ -1 +1 @@ -Subproject commit f45b8fbaa7ef0b3f824a3452a91dc74bffb6ea38 +Subproject commit b97faa82a7767e4270e46886d68dd1c70b71abca diff --git a/modules/sombrero b/modules/sombrero index 17ebe10..ec3f82a 160000 --- a/modules/sombrero +++ b/modules/sombrero @@ -1 +1 @@ -Subproject commit 17ebe10ad9a006cd6a51d5705d6ceddca4464369 +Subproject commit ec3f82aa4834847adf1f0c640eba4b87b6f3ff65 diff --git a/ui/data/com.xronlinux.BreezyDesktop.gschema.xml b/ui/data/com.xronlinux.BreezyDesktop.gschema.xml index a1babc3..7b33ad7 100644 --- a/ui/data/com.xronlinux.BreezyDesktop.gschema.xml +++ b/ui/data/com.xronlinux.BreezyDesktop.gschema.xml @@ -64,6 +64,69 @@ End distance when using the "toggle display distance" shortcut. + + + false + + Widescreen mode + + Enable widescreen/SBS mode + + + + + 1.0 + + Display size + + The size of the display + + + + + false + + Curved display + + Enable curved display mode + + + + + -1 + + Look-ahead override + + Manually override the look-ahead calculation + + + + + true + + Use optimal monitor configuration + + Automatically set the optimal monitor configuration upon connection + + + + + true + + Headset as primary + + Automatically set the headset as the primary display upon connection + + + + + false + + Disable anti-aliasing + + Disable anti-aliasing + + false diff --git a/ui/modules/PyXRLinuxDriverIPC b/ui/modules/PyXRLinuxDriverIPC index 50ce02f..34a349d 160000 --- a/ui/modules/PyXRLinuxDriverIPC +++ b/ui/modules/PyXRLinuxDriverIPC @@ -1 +1 @@ -Subproject commit 50ce02fc9e341d417785bd26abeee9f7305bee6c +Subproject commit 34a349d39efc02f4b65550debd193d29d95a84f9 diff --git a/ui/src/connecteddevice.py b/ui/src/connecteddevice.py index 16f9ad4..241c4d5 100644 --- a/ui/src/connecteddevice.py +++ b/ui/src/connecteddevice.py @@ -10,13 +10,17 @@ from .xrdriveripc import XRDriverIPC class ConnectedDevice(Gtk.Box): __gtype_name__ = "ConnectedDevice" + device_label = Gtk.Template.Child() effect_enable_switch = Gtk.Template.Child() display_distance_scale = Gtk.Template.Child() display_distance_adjustment = Gtk.Template.Child() + display_size_scale = Gtk.Template.Child() + display_size_adjustment = Gtk.Template.Child() follow_threshold_scale = Gtk.Template.Child() follow_threshold_adjustment = Gtk.Template.Child() follow_mode_switch = Gtk.Template.Child() - device_label = Gtk.Template.Child() + widescreen_mode_switch = Gtk.Template.Child() + curved_display_switch = Gtk.Template.Child() set_toggle_display_distance_start_button = Gtk.Template.Child() set_toggle_display_distance_end_button = Gtk.Template.Child() reassign_recenter_display_shortcut_button = Gtk.Template.Child() @@ -25,19 +29,30 @@ class ConnectedDevice(Gtk.Box): toggle_display_distance_shortcut_label = Gtk.Template.Child() reassign_toggle_follow_shortcut_button = Gtk.Template.Child() toggle_follow_shortcut_label = Gtk.Template.Child() + headset_as_primary_switch = Gtk.Template.Child() + use_optimal_monitor_config_switch = Gtk.Template.Child() + movement_look_ahead_scale = Gtk.Template.Child() + movement_look_ahead_adjustment = Gtk.Template.Child() + def __init__(self): super(Gtk.Box, self).__init__() self.init_template() self.all_enabled_state_inputs = [ self.display_distance_scale, + self.display_size_scale, self.follow_mode_switch, self.follow_threshold_scale, + self.widescreen_mode_switch, + self.curved_display_switch, self.set_toggle_display_distance_start_button, self.set_toggle_display_distance_end_button, self.reassign_recenter_display_shortcut_button, self.reassign_toggle_display_distance_shortcut_button, - self.reassign_toggle_follow_shortcut_button + self.reassign_toggle_follow_shortcut_button, + self.headset_as_primary_switch, + self.use_optimal_monitor_config_switch, + self.movement_look_ahead_scale ] self.settings = SettingsManager.get_instance().settings @@ -45,7 +60,13 @@ class ConnectedDevice(Gtk.Box): self.extensions_manager = ExtensionsManager.get_instance() self.settings.bind('display-distance', self.display_distance_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('display-size', self.display_size_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) self.settings.bind('follow-threshold', self.follow_threshold_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('widescreen-mode', self.widescreen_mode_switch, 'active', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('curved-display', self.curved_display_switch, 'active', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('headset-as-primary', self.headset_as_primary_switch, 'active', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('use-optimal-monitor-config', self.use_optimal_monitor_config_switch, 'active', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('look-ahead-override', self.movement_look_ahead_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) bind_shortcut_settings(self.get_parent(), [ [self.reassign_recenter_display_shortcut_button, self.recenter_display_shortcut_label], @@ -68,8 +89,11 @@ class ConnectedDevice(Gtk.Box): self.effect_enable_switch.set_active(self._is_config_enabled(self.ipc.retrieve_config()) and self.extensions_manager.is_enabled()) self.effect_enable_switch.connect('notify::active', self._refresh_inputs_for_enabled_state) + self.use_optimal_monitor_config_switch.connect('notify::active', self._refresh_use_optimal_monitor_config) + self._handle_enabled_features(self.state_manager, None) self._refresh_inputs_for_enabled_state(self.effect_enable_switch, None) + self._refresh_use_optimal_monitor_config(self.use_optimal_monitor_config_switch, None) self.extensions_manager.bind_property('breezy-enabled', self.effect_enable_switch, 'active', GObject.BindingFlags.BIDIRECTIONAL) self.connect("destroy", self._on_widget_destroy) @@ -98,7 +122,8 @@ class ConnectedDevice(Gtk.Box): for widget in self.all_enabled_state_inputs: widget.set_sensitive(requesting_enabled) - if requesting_enabled: self._refresh_follow_mode(self.follow_mode_switch, None) + if requesting_enabled: + self._refresh_follow_mode(self.follow_mode_switch, None) def _refresh_follow_mode(self, switch, param): self.follow_threshold_scale.set_sensitive(switch.get_active()) @@ -109,6 +134,11 @@ class ConnectedDevice(Gtk.Box): 'enable_breezy_desktop_smooth_follow': switch.get_active() }) + def _refresh_use_optimal_monitor_config(self, switch, param): + self.headset_as_primary_switch.set_sensitive(switch.get_active()) + if not switch.get_active(): + self.headset_as_primary_switch.set_active(False) + def set_device_name(self, name): self.device_label.set_markup(f"{name}") @@ -120,7 +150,9 @@ class ConnectedDevice(Gtk.Box): def _on_widget_destroy(self, widget): self.state_manager.unbind_property('follow-mode', self.follow_mode_switch, 'active') self.settings.unbind('display-distance', self.display_distance_adjustment, 'value') + self.settings.unbind('display-size', self.display_size_adjustment, 'value') self.settings.unbind('follow-threshold', self.follow_threshold_adjustment, 'value') + self.settings.unbind('widescreen-mode', self.widescreen_mode_switch, 'active') self.extensions_manager.unbind_property('breezy-enabled', self.effect_enable_switch, 'active') def reload_display_distance_toggle_button(widget): @@ -131,4 +163,4 @@ def on_set_display_distance_toggle(widget): settings = SettingsManager.get_instance().settings distance = settings.get_double('display-distance') settings.set_double(widget.get_name(), distance) - reload_display_distance_toggle_button(widget) \ No newline at end of file + reload_display_distance_toggle_button(widget) diff --git a/ui/src/gtk/connected-device.ui b/ui/src/gtk/connected-device.ui index 5617700..215ca1c 100644 --- a/ui/src/gtk/connected-device.ui +++ b/ui/src/gtk/connected-device.ui @@ -24,201 +24,364 @@ - - 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 + + general + General Settings + applications-system-symbolic + + + 1 + 20 + 20 + 20 + + + Features + + + XR effect + Enables the Breezy Desktop XR effect. + + + 3 + + + + + + + Widescreen mode + Switches your glasses into side-by-side mode and doubles the width of the display. + 2 + + + 3 + + + + + + + Follow mode + Keep the virtual display near the center of your view. + + + 3 + + + + + + + Curved display + Switch between flat and curved displays. + 2 + + + 3 + + + + - - - - - - - - - - - - - Follow enabled - Keep the virtual display near the center of your view - - - 3 - - - - - - - Follow threshold - How far away you can look before the display follows - - - 3 - true - 0 - 0 - 350 - false - - - 1 - 45 - 1 - 15 + + + + Adjustments + + + Display distance + Closer appears larger, further appears smaller. Controls depth when in widescreen mode. + + + 3 + true + 0 + 2 + 350 + false + + + 0.2 + 2.5 + 0.01 + 1.05 + + + + + + + + + + + + + + Display size + Combine with display distance to achieve a comfortable level of depth and size. + + + 3 + true + 0 + 2 + 350 + false + + + 0.2 + 2.5 + 0.01 + 1.0 + + + + + + + + + + + + + + Display toggle distances + Use the buttons to capture the current display distance for use with the keyboard shortcut. + 2 + + + 30 + 150 + 30 + + + toggle-display-distance-start + 3 + + + + + toggle-display-distance-end + 3 + + + + + + + + + Follow threshold + How far away you can look before the display follows. + + + 3 + true + 0 + 0 + 350 + false + + + 1 + 45 + 1 + 15 + + + + + + - + - + + + + + + shortcuts + Keyboard Shortcuts + preferences-desktop-keyboard-shortcuts-symbolic + + + + + Keyboard Shortcuts + + + Re-center display shortcut + Pin the virtual display to the current position. + 2 + + + 30 + 30 + + + 3 + + + + + + + recenter-display-shortcut + 3 + Change + + + + + + + + + Display distance shortcut + Quickly toggle between two predefined distances. + 2 + + + 30 + 30 + + + 3 + + + + + + + toggle-display-distance-shortcut + 3 + Change + + + + + + + + + Toggle follow mode shortcut + Quickly toggle follow mode. + 2 + + + 30 + 30 + + + 3 + + + + + + + toggle-follow-shortcut + 3 + Change + + + + + + + + + + + + + + + advanced + Advanced Settings + applications-system-symbolic + + + + + Advanced Settings + + + Find optimal display config + Automatically modify the glasses display configuration for maximum resolution and best scaling when plugged in. + + + 3 + + + + + + + Always primary display + Automatically set the glasses as the primary display when plugged in. + + + 3 + + + + + + + Movement look-ahead + Counteracts input lag by predicting head-tracking position ahead of render time. Stick with default unless virtual display drags behind your head movements, jumps ahead, or is very shaky. + + + 3 + false + 0 + 0 + 350 + false + + + -1 + 40 + 1 + -1 + + + + Default + 10ms + 20ms + 30ms + 40ms + + + + + + + + + - - Shortcuts - Modify keyboard shortcuts and how they work - - - Re-center display shortcut - Pin the virtual display to the current position - 2 - - - 30 - 30 - - - 3 - - - - - - - recenter-display-shortcut - 3 - Change - - - - - - - - - Display distance shortcut - Quickly toggle between two predefined distances - 2 - - - 30 - 30 - - - 3 - - - - - - - toggle-display-distance-shortcut - 3 - Change - - - - - - - - - Display distance start and end - 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 - - - - - - - - - Toggle follow mode shortcut - Quickly toggle follow mode - 2 - - - 30 - 30 - - - 3 - - - - - - - toggle-follow-shortcut - 3 - Change - - - - - - + + stack + wide diff --git a/ui/src/gtk/license-dialog.ui b/ui/src/gtk/license-dialog.ui index 896b618..2954559 100644 --- a/ui/src/gtk/license-dialog.ui +++ b/ui/src/gtk/license-dialog.ui @@ -57,11 +57,11 @@ - + - + diff --git a/ui/src/gtk/window.ui b/ui/src/gtk/window.ui index d06e8c7..40717e0 100644 --- a/ui/src/gtk/window.ui +++ b/ui/src/gtk/window.ui @@ -55,6 +55,10 @@ License Details app.license + + Force Reset + app.reset_driver + _About BreezyDesktop app.about diff --git a/ui/src/licensefeaturerow.py b/ui/src/licensefeaturerow.py index 5f4904a..7d55ed8 100644 --- a/ui/src/licensefeaturerow.py +++ b/ui/src/licensefeaturerow.py @@ -3,10 +3,10 @@ from gi.repository import Adw from .time import time_remaining_text FEATURE_NAMES = { - 'sbs': 'Side-by-side mode (for gaming)', - 'smooth_follow': 'Smooth Follow', - 'productivity_basic': 'Breezy Desktop', - 'productivity_pro': 'Breezy Desktop w/ multiple monitors', + 'sbs': 'Side-by-side mode (gaming)', + 'smooth_follow': 'Smooth Follow (gaming)', + 'productivity_basic': 'Breezy Desktop (productivity)', + 'productivity_pro': 'Breezy Desktop Pro (productivity)', } class LicenseFeatureRow(Adw.ActionRow): diff --git a/ui/src/licensetierrow.py b/ui/src/licensetierrow.py index 9ee6cc7..9f5917c 100644 --- a/ui/src/licensetierrow.py +++ b/ui/src/licensetierrow.py @@ -53,7 +53,7 @@ class LicenseTierRow(Adw.ExpanderRow): elif active_period is not None: amount_text += " to upgrade" elif active_period is not None and PERIOD_RANKS[period] >= PERIOD_RANKS[active_period]: - amount_text = "Ready to auto-renew" + amount_text = "Paid through next renewal period" if amount_text is not None: row_widget = Adw.ActionRow(title=period.capitalize()) diff --git a/ui/src/main.py b/ui/src/main.py index f77a4dc..a2a2281 100644 --- a/ui/src/main.py +++ b/ui/src/main.py @@ -64,6 +64,7 @@ class BreezydesktopApplication(Adw.Application): self.create_action('quit', self.on_quit_action, ['q']) self.create_action('about', self.on_about_action) self.create_action('license', self.on_license_action) + self.create_action('reset_driver', self.on_reset_driver_action) def do_activate(self): """Called when the application is activated. @@ -94,6 +95,11 @@ class BreezydesktopApplication(Adw.Application): dialog.set_transient_for(self.props.active_window) dialog.present() + def on_reset_driver_action(self, widget, _): + XRDriverIPC.get_instance().write_control_flags({ + 'force_quit': True + }) + def create_action(self, name, callback, shortcuts=None): """Add an application action. diff --git a/ui/src/statemanager.py b/ui/src/statemanager.py index a8aa977..8c2497f 100644 --- a/ui/src/statemanager.py +++ b/ui/src/statemanager.py @@ -22,6 +22,7 @@ class StateManager(GObject.GObject): __gproperties__ = { 'follow-mode': (bool, 'Follow Mode', 'Whether the follow mode is enabled', False, GObject.ParamFlags.READWRITE), 'follow-threshold': (float, 'Follow Threshold', 'The follow threshold', 1.0, 45.0, 15.0, GObject.ParamFlags.READWRITE), + 'widescreen-mode': (bool, 'Widescreen Mode', 'Whether widescreen mode is enabled', False, GObject.ParamFlags.READWRITE), 'license-action-needed': (bool, 'License Action Needed', 'Whether the license needs attention', False, GObject.ParamFlags.READWRITE), 'license-present': (bool, 'License Present', 'Whether a license is present', False, GObject.ParamFlags.READWRITE), 'enabled-features-list': (object, 'Enabled Features List', 'A list of the enabled features', GObject.ParamFlags.READWRITE), @@ -94,12 +95,15 @@ class StateManager(GObject.GObject): self.set_property('license-present', False) self.set_property('follow-mode', self.state.get('breezy_desktop_smooth_follow_enabled')) + self.set_property('widescreen-mode', self.state.get('sbs_mode_enabled')) if self.running: threading.Timer(1.0, self._refresh_state).start() def do_set_property(self, prop, value): if prop.name == 'follow-mode': self.follow_mode = value + if prop.name == 'widescreen-mode': + self.widescreen_mode = value if prop.name == 'license-action-needed': self.license_action_needed = value if prop.name == 'license-present': @@ -110,6 +114,8 @@ class StateManager(GObject.GObject): def do_get_property(self, prop): if prop.name == 'follow-mode': return self.follow_mode + if prop.name == 'widescreen-mode': + return self.widescreen_mode if prop.name == 'license-action-needed': return self.license_action_needed if prop.name == 'license-present':