breezy-desktop/gnome/src/virtualdisplaysactor.js

949 lines
43 KiB
JavaScript

import Clutter from 'gi://Clutter'
import Cogl from 'gi://Cogl';
import GdkPixbuf from 'gi://GdkPixbuf';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Shell from 'gi://Shell';
import St from 'gi://St';
import { VirtualDisplayEffect, SMOOTH_FOLLOW_SLERP_TIMELINE_MS } from './virtualdisplayeffect.js';
import { applyQuaternionToVector, degreeToRadian, diagonalToCrossFOVs, fovConversionFns, normalizeVector } from './math.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Globals from './globals.js';
// if nothing is in focus, take it as soon as it crosses into the monitor's bounds
const FOCUS_THRESHOLD = 0.95 / 2.0;
// if we leave the monitor with some margin, unfocus even if no other monitor is in focus
const UNFOCUS_THRESHOLD = 1.1 / 2.0;
/**
* Find the vector in the array that's closest to the quaternion rotation
*
* @param {number[]} quaternion - Reference quaternion [x, y, z, w]
* @param {number[][]} monitorVectors - Array of monitor vectors [x, y, z] to search from
* @param {number} currentFocusedIndex - Index of the currently focused monitor
* @param {number} focusedMonitorDistance - Distance to the focused monitor, < 1.0 if zoomed in
* @param {boolean} smoothFollowEnabled - If true, always keep the current monitor in focus or choose the closest
* @param {Object} fovDetails - Contains reference widthPixels, heightPixels, horizontal and vertical radians, and pixel distance to the center of the screen
* @param {Object[]} monitorsDetails - Contains x, y, width, height (coordinates from top-left) for each monitor
* @returns {number} Index of the closest vector, if it surpasses the previous closest index by a certain margin, otherwise the previous index
*/
function findFocusedMonitor(quaternion, monitorVectors, currentFocusedIndex, focusedMonitorDistance, smoothFollowEnabled, fovDetails, monitorsDetails) {
const lookVector = [1.0, 0.0, 0.0]; // NWU vector pointing to the center of the screen
const rotatedLookVector = applyQuaternionToVector(lookVector, quaternion);
// TODO - right now we're using the curved functions to figure out distances even for flat monitors
// because it will account for the monitors facing towards us, but this will lose some accuracy
const upConversionFns = fovDetails.monitorWrappingScheme === 'vertical' ? fovConversionFns.curved : fovConversionFns.flat;
const lookUpPixels = upConversionFns.angleToLength(
fovDetails.defaultDistanceVerticalRadians,
fovDetails.heightPixels,
fovDetails.completeScreenDistancePixels,
rotatedLookVector[2],
rotatedLookVector[0]
);
const westConversionFns = fovDetails.monitorWrappingScheme === 'horizontal' ? fovConversionFns.curved : fovConversionFns.flat;
const lookWestPixels = westConversionFns.angleToLength(
fovDetails.defaultDistanceHorizontalRadians,
fovDetails.widthPixels,
fovDetails.completeScreenDistancePixels,
rotatedLookVector[1],
rotatedLookVector[0]
);
let closestIndex = -1;
let closestDistance = Infinity;
let currentFocusedDistance = Infinity;
// find the vector closest to the rotated look vector
monitorVectors.forEach((monitorVector, index) => {
const monitor = monitorsDetails[index];
const monitorAspectRatio = monitor.width / monitor.height;
// weight the up distance by the aspect ratio
const vectorUpPixels = upConversionFns.angleToLength(
fovDetails.defaultDistanceVerticalRadians,
fovDetails.heightPixels,
fovDetails.completeScreenDistancePixels,
monitorVector[2],
monitorVector[0]
);
const upDeltaPixels = (lookUpPixels - vectorUpPixels) * monitorAspectRatio;
const vectorWestPixels = westConversionFns.angleToLength(
fovDetails.defaultDistanceHorizontalRadians,
fovDetails.widthPixels,
fovDetails.completeScreenDistancePixels,
monitorVector[1],
monitorVector[0]
);
const westDeltaPixels = lookWestPixels - vectorWestPixels;
const totalDeltaPixels = Math.sqrt(upDeltaPixels * upDeltaPixels + westDeltaPixels * westDeltaPixels);
// threshold is a percentage of width, and height was already properly weighted
const distanceFromCenterSizeRatio = totalDeltaPixels / monitor.width;
if (currentFocusedIndex === index) {
currentFocusedDistance = distanceFromCenterSizeRatio * focusedMonitorDistance;
}
if (distanceFromCenterSizeRatio < closestDistance) {
closestIndex = index;
closestDistance = distanceFromCenterSizeRatio;
}
});
const keepCurrent = currentFocusedIndex !== -1 && (smoothFollowEnabled || currentFocusedDistance < UNFOCUS_THRESHOLD);
if (!keepCurrent) {
if (smoothFollowEnabled || closestDistance < FOCUS_THRESHOLD) return closestIndex;
// neither the current nor the closest will take focus, unfocus all displays
return -1;
}
return currentFocusedIndex;
}
/***
* @returns {Object} - containing `begin`, `center`, and `end` radians for rotating the given monitor
*/
function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) {
let closestWrapPixel = monitorBeginPixel;
let closestWrap = cachedMonitorRadians[monitorBeginPixel];
if (closestWrap === undefined) {
closestWrapPixel = Object.keys(cachedMonitorRadians).reduce((previousPixel, currentPixel) => {
if (previousPixel === undefined) return currentPixel;
const currentDelta = currentPixel - monitorBeginPixel;
const previousDelta = previousPixel - monitorBeginPixel;
// always prefer an exact monitor width match
if (previousDelta % monitorLengthPixels !== 0) {
if (currentDelta % monitorLengthPixels === 0) return currentPixel;
// prefer placing a monitor to the right or below, even if there's a closer placement to the left or above
if (previousDelta < 0 && currentDelta > 0) return currentPixel;
// otherwise, just prefer the closest one
if (Math.abs(currentDelta) < Math.abs(previousDelta)) return currentPixel;
}
return previousPixel;
}, undefined);
closestWrap = cachedMonitorRadians[closestWrapPixel];
}
const spacingRadians = lengthToRadianFn(monitorSpacingPixels);
if (closestWrapPixel !== monitorBeginPixel) {
// there's a gap between the cached wrap value and this one
const gapPixels = monitorBeginPixel - closestWrapPixel;
const gapRadians = lengthToRadianFn(gapPixels);
// use Math.floor so if it's negative (this monitor is to the left of or above the closest) it will always
// compenstate for the spacing that's needed at the right/bottom
const appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians;
// update the closestWrap value and cache it
closestWrap = closestWrap + gapRadians + appliedSpacingRadians;
closestWrapPixel = monitorBeginPixel;
cachedMonitorRadians[closestWrapPixel] = closestWrap;
}
const monitorRadians = lengthToRadianFn(monitorLengthPixels);
const centerRadians = closestWrap + monitorRadians / 2;
const endRadians = closestWrap + monitorRadians;
// since we're computing the end values for this monitor, cache them too in case they line up with a future monitor
const nextMonitorPixel = monitorBeginPixel + monitorLengthPixels;
if (cachedMonitorRadians[nextMonitorPixel] === undefined)
cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians;
return {
begin: closestWrap,
center: centerRadians,
end: endRadians
}
}
/**
* Convert the given monitor details into NWU vectors describing the center of the fully placed monitor,
* and the top-left of the partially placed monitor (minus only a single-axis rotation)
*
* @param {Object} fovDetails - contains reference widthPixels, heightPixels, horizontal and vertical radians,
* and distance to the center of the screen
* @param {Object[]} monitorDetailsList - contains x, y, width, height (coordinates from top-left)
* @param {number} monitorSpacing - visual spacing between monitors, as a percentage of the viewport width
* @returns {Object[]} - contains NWU vectors used for rendering and focused monitor detection
*/
function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) {
const monitorPlacements = [];
const cachedMonitorRadians = {};
Globals.logger.log_debug(`\t\t\tFOV Details: ${JSON.stringify(fovDetails)}`);
const conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat;
if (fovDetails.monitorWrappingScheme === 'horizontal') {
// monitors wrap around us horizontally
const sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.widthPixels);
const monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels;
const lengthToRadianFn = (targetWidth) => conversionFns.lengthToRadians(
fovDetails.defaultDistanceHorizontalRadians,
fovDetails.widthPixels,
sideEdgeRadius,
targetWidth
);
cachedMonitorRadians[0] = -fovDetails.defaultDistanceHorizontalRadians / 2;
horizontalMonitorSort(monitorDetailsList).forEach(({monitorDetails, originalIndex}) => {
const monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.x, monitorDetails.width, lengthToRadianFn);
const monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(sideEdgeRadius, monitorDetails.width);
const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels;
// offset for aligning this monitor's center with the fov-sized viewport's center
const upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2;
// this is where our monitor's center is in relation to an fov-sized viewport centered about (0, 0)
const upCenterPixels = upTopPixels - upCenterOffsetPixels;
monitorPlacements.push({
originalIndex,
centerNoRotate: [
monitorCenterRadius,
// west is centered about the FOV center
0,
// up is flat when wrapping horizontally
upCenterPixels
],
centerLook: normalizeVector([
// north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians
monitorCenterRadius * Math.cos(monitorWrapDetails.center),
// west is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians
-monitorCenterRadius * Math.sin(monitorWrapDetails.center),
// up is flat when wrapping horizontally
upCenterPixels
]),
rotationAngleRadians: {
x: 0,
y: -monitorWrapDetails.center
}
});
});
} else if (fovDetails.monitorWrappingScheme === 'vertical') {
// monitors wrap around us vertically
const topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.heightPixels);
const monitorSpacingPixels = monitorSpacing * fovDetails.heightPixels;
const lengthToRadianFn = (targetHeight) => conversionFns.lengthToRadians(
fovDetails.defaultDistanceVerticalRadians,
fovDetails.heightPixels,
topEdgeRadius,
targetHeight
);
cachedMonitorRadians[0] = -fovDetails.defaultDistanceVerticalRadians / 2;
verticalMonitorSort(monitorDetailsList).forEach(({monitorDetails, originalIndex}) => {
const monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.y, monitorDetails.height, lengthToRadianFn);
const monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(topEdgeRadius, monitorDetails.height);
const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels;
// offset for aligning this monitor's center with the fov-sized viewport's center
const westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2;
// this is where our monitor's center is in relation to an fov-sized viewport centered about (0, 0)
const westCenterPixels = westLeftPixels - westCenterOffsetPixels;
monitorPlacements.push({
originalIndex,
centerNoRotate: [
monitorCenterRadius,
// west is flat when wrapping horizontally
westCenterPixels,
// up is centered about the FOV center
0
],
centerLook: normalizeVector([
// north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians
monitorCenterRadius * Math.cos(monitorWrapDetails.center),
// west is flat when wrapping vertically
westCenterPixels,
// up is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians
-monitorCenterRadius * Math.sin(monitorWrapDetails.center)
]),
rotationAngleRadians: {
x: -monitorWrapDetails.center,
y: 0
}
});
});
} else {
const monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels;
// monitors make a flat wall in front of us, no wrapping
monitorDetailsList.forEach((monitorDetails, index) => {
const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels;
const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels;
// offsets for aligning this monitor's center with the fov-sized viewport's center
const westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2;
const upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2;
const westCenterPixels = westLeftPixels - westCenterOffsetPixels;
const upCenterPixels = upTopPixels - upCenterOffsetPixels;
monitorPlacements.push({
originalIndex: index,
centerNoRotate: [
fovDetails.completeScreenDistancePixels,
westCenterPixels,
upCenterPixels
],
centerLook: normalizeVector([
fovDetails.completeScreenDistancePixels,
westCenterPixels,
upCenterPixels
]),
rotationAngleRadians: {
x: 0,
y: 0
}
});
});
}
// put them back in the original monitor order before returning
monitorPlacements.sort((a, b) => a.originalIndex - b.originalIndex);
Globals.logger.log_debug(`\t\t\tMonitor placements: ${JSON.stringify(monitorPlacements)}, cached values: ${JSON.stringify(cachedMonitorRadians)}`);
return monitorPlacements;
}
// sort monitors based on wrapping scheme before determining their placements to avoid odd gaps
function horizontalMonitorSort(monitors) {
return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => {
const aMon = a.monitorDetails;
const bMon = b.monitorDetails;
// First compare by y-coordinate to form rows (top to bottom)
if (aMon.y !== bMon.y) {
return aMon.y - bMon.y;
}
// Then compare by x-coordinate within the same row (left to right)
return aMon.x - bMon.x;
});
}
// sort monitors based on wrapping scheme before determining their placements to avoid odd gaps
function verticalMonitorSort(monitors) {
return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => {
const aMon = a.monitorDetails;
const bMon = b.monitorDetails;
// First compare by x-coordinate to form columns (left to right)
if (aMon.x !== bMon.x) {
return aMon.x - bMon.x;
}
// Then compare by y-coordinate within the same column (top to bottom)
return aMon.y - bMon.y;
});
}
export const VirtualDisplaysActor = GObject.registerClass({
Properties: {
'target-monitor': GObject.ParamSpec.jsobject(
'target-monitor',
'Target Monitor',
'Details about the monitor being used as a viewport',
GObject.ParamFlags.READWRITE
),
'virtual-monitors': GObject.ParamSpec.jsobject(
'virtual-monitors',
'Virtual Monitors',
'Details about the virtual monitors',
GObject.ParamFlags.READWRITE
),
'fov-details': GObject.ParamSpec.jsobject(
'fov-details',
'FOV Details',
'Details about the field of view of the headset',
GObject.ParamFlags.READWRITE
),
'monitor-wrapping-scheme': GObject.ParamSpec.string(
'monitor-wrapping-scheme',
'Monitor Wrapping Scheme',
'How the monitors are wrapped around the viewport',
GObject.ParamFlags.READWRITE,
'horizontal', ['horizontal', 'vertical', 'none']
),
'monitor-spacing': GObject.ParamSpec.int(
'monitor-spacing',
'Monitor Spacing',
'Visual spacing between monitors, units are 0.001 of the viewport width',
GObject.ParamFlags.READWRITE,
0, 100, 0
),
'viewport-offset-x': GObject.ParamSpec.double(
'viewport-offset-x',
'Viewport Offset x',
'Offset to apply to the viewport',
GObject.ParamFlags.READWRITE,
-2.5, 2.5, 0.0
),
'viewport-offset-y': GObject.ParamSpec.double(
'viewport-offset-y',
'Viewport Offset y',
'Offset to apply to the viewport',
GObject.ParamFlags.READWRITE,
-2.5, 2.5, 0.0
),
'monitor-placements': GObject.ParamSpec.jsobject(
'monitor-placements',
'Monitor Placements',
'Target and virtual monitor placement details, as relevant to rendering',
GObject.ParamFlags.READWRITE
),
'monitor-actors': GObject.ParamSpec.jsobject(
'monitor-actors',
'Monitor Actors',
'Tracking actors and details for each monitor',
GObject.ParamFlags.READWRITE
),
'imu-snapshots': GObject.ParamSpec.jsobject(
'imu-snapshots',
'IMU Snapshots',
'Latest IMU quaternion snapshots and epoch timestamp for when it was collected',
GObject.ParamFlags.READWRITE
),
'curved-display': GObject.ParamSpec.boolean(
'curved-display',
'Curved Display',
'Whether the displays are curved',
GObject.ParamFlags.READWRITE,
false
),
'smooth-follow-enabled': GObject.ParamSpec.boolean(
'smooth-follow-enabled',
'Smooth follow enabled',
'Whether smooth follow is enabled',
GObject.ParamFlags.READWRITE,
false
),
'smooth-follow-toggle-epoch-ms': GObject.ParamSpec.uint64(
'smooth-follow-toggle-epoch-ms',
'Smooth follow toggle epoch time',
'ms since epoch when smooth follow was toggled',
GObject.ParamFlags.READWRITE,
0, Number.MAX_SAFE_INTEGER, 0
),
'show-banner': GObject.ParamSpec.boolean(
'show-banner',
'Show banner',
'Whether the banner should be displayed',
GObject.ParamFlags.READWRITE,
false
),
'custom-banner-enabled': GObject.ParamSpec.boolean(
'custom-banner-enabled',
'Custom banner enabled',
'Whether the custom banner should be displayed',
GObject.ParamFlags.READWRITE,
false
),
'focused-monitor-index': GObject.ParamSpec.int(
'focused-monitor-index',
'Focused Monitor Index',
'Index of the monitor that is currently focused',
GObject.ParamFlags.READWRITE,
-1, 100, -1
),
'focused-monitor-details': GObject.ParamSpec.jsobject(
'focused-monitor-details',
'Focused Monitor Details',
'Details about the monitor that is currently focused',
GObject.ParamFlags.READWRITE
),
'display-size': GObject.ParamSpec.double(
'display-size',
'Display size',
'Size of the display',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.0
),
'display-zoom-on-focus': GObject.ParamSpec.boolean(
'display-zoom-on-focus',
'Display zoom on focus',
'Automatically move a display closer when it becomes focused.',
GObject.ParamFlags.READWRITE,
true
),
'display-distance': GObject.ParamSpec.double(
'display-distance',
'Display Distance',
'Distance of the display from the camera',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.05
),
'headset-display-as-viewport-center': GObject.ParamSpec.boolean(
'headset-display-as-viewport-center',
'Headset display as viewport center',
'Whether to use the headset display as the reference point for the center of the viewport',
GObject.ParamFlags.READWRITE,
false
),
'lens-vector': GObject.ParamSpec.jsobject(
'lens-vector',
'Lens Vector',
'Vector representing the offset of the lens from the pivot point',
GObject.ParamFlags.READWRITE
),
'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
),
'framerate-cap': GObject.ParamSpec.double(
'framerate-cap',
'Framerate Cap',
'Maximum framerate to render at',
GObject.ParamFlags.READWRITE,
0.0, 240.0, 0.0
),
'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 VirtualDisplaysActor extends Clutter.Actor {
constructor(params = {}) {
super(params);
this._all_monitors = [
this.target_monitor,
...this.virtual_monitors
];
this.focused_monitor_index = -1;
try {
const calibratingBanner = GdkPixbuf.Pixbuf.new_from_file(`${Globals.extension_dir}/textures/calibrating.png`);
const customBanner = GdkPixbuf.Pixbuf.new_from_file(`${Globals.extension_dir}/textures/custom_banner.png`);
if (Clutter.Image) {
const calibratingImage = new Clutter.Image();
calibratingImage.set_data(calibratingBanner.get_pixels(), Cogl.PixelFormat.RGB_888,
calibratingBanner.width, calibratingBanner.height, calibratingBanner.rowstride);
this.bannerContent = Clutter.TextureContent.new_from_texture(calibratingImage.get_texture(), null);
const customBannerImage = new Clutter.Image();
customBannerImage.set_data(customBanner.get_pixels(), Cogl.PixelFormat.RGB_888,
customBanner.width, customBanner.height, customBanner.rowstride);
this.customBannerContent = Clutter.TextureContent.new_from_texture(customBannerImage.get_texture(), null);
} else {
const backend = global.stage.get_context?.().get_backend() ?? Clutter.get_default_backend();
const coglContext = backend.get_cogl_context();
this.bannerContent = St.ImageContent.new_with_preferred_size(calibratingBanner.width, calibratingBanner.height);
this.bannerContent.set_bytes(
coglContext,
calibratingBanner.get_pixels(),
Cogl.PixelFormat.RGB_888,
calibratingBanner.width,
calibratingBanner.height,
calibratingBanner.rowstride
)
this.customBannerContent = St.ImageContent.new_with_preferred_size(customBanner.width, customBanner.height);
this.customBannerContent.set_bytes(
coglContext,
customBanner.get_pixels(),
Cogl.PixelFormat.RGB_888,
customBanner.width,
customBanner.height,
customBanner.rowstride
);
}
this.bannerActor = new Clutter.Actor({
width: calibratingBanner.width,
height: calibratingBanner.height,
reactive: false
});
this.bannerActor.set_position(
(this.target_monitor.width - this.bannerActor.width) / 2,
this.target_monitor.height * 0.75 - this.bannerActor.height / 2
);
this.bannerActor.set_content(this.custom_banner_enabled ? this.customBannerContent : this.bannerContent);
this.bannerActor.hide();
} catch (e) {
Globals.logger.log(`ERROR: virtualdisplaysactor.js ${e.message}\n${e.stack}`);
}
this.monitor_actors = [];
}
renderMonitors() {
// collect bindings and connections to clean up on dispose
this._property_bindings = [];
this._property_connections = [];
const notifyToFunction = ((property, fn) => {
this._property_connections.push(this.connect(`notify::${property}`, fn.bind(this)));
}).bind(this);
this._distance_ease_timeline = null;
notifyToFunction('toggle-display-distance-start', this._handle_display_distance_properties_change);
notifyToFunction('toggle-display-distance-end', this._handle_display_distance_properties_change);
notifyToFunction('display-distance', this._handle_display_distance_properties_change);
notifyToFunction('monitor-wrapping-scheme', this._update_monitor_placements);
notifyToFunction('monitor-spacing', this._update_monitor_placements);
notifyToFunction('headset-display-as-viewport-center', this._update_monitor_placements);
notifyToFunction('curved-display', this._update_monitor_placements);
notifyToFunction('viewport-offset-x', this._update_monitor_placements);
notifyToFunction('viewport-offset-y', this._update_monitor_placements);
notifyToFunction('show-banner', this._handle_banner_update);
notifyToFunction('custom-banner-enabled', this._handle_banner_update);
notifyToFunction('framerate-cap', this._handle_frame_rate_cap_change);
notifyToFunction('smooth-follow-enabled', this._handle_smooth_follow_enabled_change);
this._handle_display_distance_properties_change();
this._handle_frame_rate_cap_change();
const actorToDisplayRatios = [
global.stage.width / this.target_monitor.width,
global.stage.height / this.target_monitor.height
];
// how far this viewport actor's center is from the center of the whole stage
const actorMidX = this.target_monitor.x + this.target_monitor.width / 2;
const actorMidY = this.target_monitor.y + this.target_monitor.height / 2;
const actorToDisplayOffsets = [
(global.stage.width / 2 - (actorMidX - global.stage.x)) * 2 / this.target_monitor.width,
(global.stage.height / 2 - (actorMidY - global.stage.y)) * 2 / this.target_monitor.height
];
Globals.logger.log_debug(`\t\t\tActor to display ratios: ${actorToDisplayRatios}, offsets: ${actorToDisplayOffsets}`);
this._all_monitors.forEach(((monitor, index) => {
Globals.logger.log_debug(`\t\t\tMonitor ${index}: ${monitor.x}, ${monitor.y}, ${monitor.width}, ${monitor.height}`);
const containerActor = new Clutter.Actor({
clip_to_allocation: true
});
const viewport = new St.Bin({
child: containerActor,
width: monitor.width,
height: monitor.height
});
// Create a clone of the stage content for this monitor
const monitorClone = new Clutter.Clone({
source: Main.layoutManager.uiGroup,
clip_to_allocation: true,
x: -monitor.x,
y: -monitor.y
});
// Add the monitor actor to the scene
containerActor.add_child(monitorClone);
const effect = new VirtualDisplayEffect({
focused_monitor_index: this.focused_monitor_index,
imu_snapshots: this.imu_snapshots,
monitor_index: index,
monitor_details: monitor,
monitor_placements: this.monitor_placements,
fov_details: this.fov_details,
target_monitor: this.target_monitor,
display_distance: this.display_distance,
display_distance_default: this._display_distance_default(),
actor_to_display_ratios: actorToDisplayRatios,
actor_to_display_offsets: actorToDisplayOffsets,
lens_vector: this.lens_vector,
show_banner: this.show_banner
});
viewport.add_effect_with_name('viewport-effect', effect);
this.add_child(viewport);
Shell.util_set_hidden_from_pick(viewport, true);
this.monitor_actors.push({
viewport,
containerActor,
monitorClone,
effect,
monitorDetails: monitor
});
// do this so the primary monitor is always on top at first, before the focused monitor logic comes into play
this.set_child_below_sibling(viewport, null);
[
'monitor-placements',
'fov-details',
'imu-snapshots',
'smooth-follow-enabled',
'smooth-follow-toggle-epoch-ms',
'focused-monitor-index',
'lens-vector',
'look-ahead-override',
'disable-anti-aliasing',
'show-banner'
].forEach((property => {
this._property_bindings.push(this.bind_property(property, effect, property, GObject.BindingFlags.DEFAULT));
}));
const updateEffectDistanceDefault = (() => {
effect.no_distance_ease = Math.abs(this.display_distance - effect.display_distance) <= 0.05;
effect.display_distance = this.display_distance;
effect.display_distance_default = this._display_distance_default();
}).bind(this);
this._property_connections.push(this.connect('notify::display-distance', updateEffectDistanceDefault));
this._property_connections.push(this.connect('notify::toggle-display-distance-start', updateEffectDistanceDefault));
this._property_connections.push(this.connect('notify::toggle-display-distance-end', updateEffectDistanceDefault));
// in addition to rendering distance properly in the shader, the parent actor determines overlap based on child ordering
effect.connect('notify::is-closest', ((actor, _pspec) => {
if (!this._is_disposed && actor.is_closest) {
this.set_child_above_sibling(viewport, null);
if (this.show_banner && this.bannerActor) this.set_child_above_sibling(this.bannerActor, null);
}
}).bind(this));
}).bind(this));
if (this.bannerActor) {
this.add_child(this.bannerActor);
if (this.show_banner) {
this.set_child_above_sibling(this.bannerActor, null);
this.bannerActor.show();
}
}
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, (() => {
if (this._is_disposed) return GLib.SOURCE_REMOVE;
if (this.show_banner) {
this.focused_monitor_index = -1;
this.focused_monitor_details = null;
} else if (this.imu_snapshots && (!this._smooth_follow_slerping || this.focused_monitor_index === -1)) {
// if smooth follow is enabled, use the origin IMU data to inform the initial focused monitor
// since it reflects where the user is looking in relation to the original monitor positions
const currentPoseQuat = this.smooth_follow_enabled ?
this.imu_snapshots.smooth_follow_origin.splice(0, 4) :
this.imu_snapshots.imu_data.splice(0, 4);
const focusedMonitorIndex = findFocusedMonitor(
currentPoseQuat,
this.monitor_placements.map(monitorVectors => monitorVectors.centerLook),
this.focused_monitor_index,
this.display_distance / this._display_distance_default(),
this.smooth_follow_enabled,
this.fov_details,
this._all_monitors
);
if (this.focused_monitor_index !== focusedMonitorIndex) {
Globals.logger.log_debug(`Switching to monitor ${focusedMonitorIndex}`);
this.focused_monitor_index = focusedMonitorIndex;
this.focused_monitor_details = this._all_monitors[focusedMonitorIndex];
}
}
return GLib.SOURCE_CONTINUE;
}).bind(this));
this._redraw_timeline = Clutter.Timeline.new_for_actor(global.stage, 1000);
this._redraw_timeline.connect('new-frame', (() => {
// let's try to cap the forced redraw rate
if (this._is_disposed || this._last_redraw !== undefined && Date.now() - this._last_redraw < this._cap_frametime_ms) return;
Globals.data_stream.refresh_data();
this.imu_snapshots = Globals.data_stream.imu_snapshots;
this.monitor_actors.forEach(({ monitorClone }) => monitorClone.queue_redraw());
this._last_redraw = Date.now();
}).bind(this));
this._redraw_timeline.set_repeat_count(-1);
this._redraw_timeline.start();
}
_display_distance_default() {
return Math.max(this.display_distance, this.toggle_display_distance_start, this.toggle_display_distance_end);
}
_fov_details() {
const aspect = this.target_monitor.width / this.target_monitor.height;
const fovRadians = diagonalToCrossFOVs(degreeToRadian(Globals.data_stream.device_data.displayFov), aspect);
// adjusted angles based on how far away the screens are e.g. a closer screen takes up a larger slice of our FOV
const defaultDistanceVerticalRadians = 2 * Math.atan(Math.tan(fovRadians.vertical / 2) / this._display_distance_default());
const defaultDistanceHorizontalRadians = 2 * Math.atan(Math.tan(fovRadians.horizontal / 2) / this._display_distance_default());
// distance needed for the FOV-sized monitor to fill up the screen
const fullScreenDistance = this.target_monitor.height / 2 / Math.tan(fovRadians.vertical / 2);
const lensDistancePixels = fullScreenDistance / (1.0 - Globals.data_stream.device_data.lensDistanceRatio) - fullScreenDistance;
// distance of a display at the default (most zoomed out) distance, plus the lens distance constant
const lensToScreenDistance = this.target_monitor.height / 2 / Math.tan(defaultDistanceVerticalRadians / 2);
const completeScreenDistancePixels = lensToScreenDistance + lensDistancePixels;
return {
widthPixels: this.target_monitor.width,
heightPixels: this.target_monitor.height,
defaultDistanceVerticalRadians,
defaultDistanceHorizontalRadians,
lensDistancePixels,
completeScreenDistancePixels,
monitorWrappingScheme: this._actual_wrap_scheme(),
curvedDisplay: this.curved_display
};
}
_actual_wrap_scheme() {
// use automatic wrapping if the none/flat wrapping option is selected and the display is supposed to be curved
const noneUseAutomatic = this.monitor_wrapping_scheme === 'none' && this.curved_display;
if (this.monitor_wrapping_scheme !== 'automatic' && !noneUseAutomatic) return this.monitor_wrapping_scheme;
const minX = Math.min(...this._all_monitors.map(monitor => monitor.x));
const maxX = Math.max(...this._all_monitors.map(monitor => monitor.x + monitor.width));
const minY = Math.min(...this._all_monitors.map(monitor => monitor.y));
const maxY = Math.max(...this._all_monitors.map(monitor => monitor.y + monitor.height));
if ((maxX - minX) / this.target_monitor.width >= (maxY - minY) / this.target_monitor.height) {
return 'horizontal';
} else {
return 'vertical';
}
}
_update_monitor_placements() {
try {
const minX = Math.min(...this._all_monitors.map(monitor => monitor.x));
const maxX = Math.max(...this._all_monitors.map(monitor => monitor.x + monitor.width));
const minY = Math.min(...this._all_monitors.map(monitor => monitor.y));
const maxY = Math.max(...this._all_monitors.map(monitor => monitor.y + monitor.height));
// the beginning edges of the viewport if it's centered on all displays
const allDisplaysCenterXBegin = (minX + maxX) / 2 - this.target_monitor.width / 2;
const allDisplaysCenterYBegin = (minY + maxY) / 2 - this.target_monitor.height / 2;
const viewportXBegin = this.headset_display_as_viewport_center ? this.target_monitor.x : allDisplaysCenterXBegin;
const viewportYBegin = this.headset_display_as_viewport_center ? this.target_monitor.y : allDisplaysCenterYBegin;
this.fov_details = this._fov_details();
this.lens_vector = [0.0, 0.0, -this.fov_details.lensDistancePixels];
this.monitor_placements = monitorsToPlacements(
this.fov_details,
// shift all monitors so they center around the viewport center, then adjusted by the offsets
this._all_monitors.map(monitor => ({
x: monitor.x - viewportXBegin - this.viewport_offset_x * this.target_monitor.width,
y: monitor.y - viewportYBegin + this.viewport_offset_y * this.target_monitor.height,
width: monitor.width,
height: monitor.height
})),
this.monitor_spacing / 1000.0
);
} catch (e) {
Globals.logger.log(`ERROR: virtualdisplaysactor.js _update_monitor_placements ${e.message}\n${e.stack}`);
}
}
_handle_display_distance_properties_change() {
const distance_from_end = Math.abs(this.display_distance - this.toggle_display_distance_end);
const distance_from_start = Math.abs(this.display_distance - this.toggle_display_distance_start);
this._is_display_distance_at_end = distance_from_end < distance_from_start;
this._update_monitor_placements();
}
_handle_banner_update() {
if (this.bannerActor) {
if (this.show_banner) {
this.bannerActor.set_content(this.custom_banner_enabled ? this.customBannerContent : this.bannerContent);
this.bannerActor.show();
} else {
this.bannerActor.hide();
}
}
}
_handle_frame_rate_cap_change() {
// add a margin to the cap time so we don't cut off frames that come in close
const frametime_margin = 0.75;
this._cap_frametime_ms = this.framerate_cap === 0 ? 0.0 : Math.floor(1000 * frametime_margin / this.framerate_cap);
}
_handle_smooth_follow_enabled_change() {
if (this._smooth_follow_timeout_id !== undefined) GLib.source_remove(this._smooth_follow_timeout_id);
this._smooth_follow_slerping = true;
this._smooth_follow_timeout_id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SMOOTH_FOLLOW_SLERP_TIMELINE_MS, (() => {
this._smooth_follow_slerping = false;
this._smooth_follow_timeout_id = undefined;
return GLib.SOURCE_REMOVE;
}).bind(this));
}
_change_distance() {
this.display_distance = this._is_display_distance_at_end ?
this.toggle_display_distance_start : this.toggle_display_distance_end;
}
vfunc_dispose() {
Globals.logger.log_debug(`Disposing VirtualMonitorsActor`);
this._is_disposed = true;
if (this._redraw_timeline) {
this._redraw_timeline.stop();
this._redraw_timeline = null;
}
this.monitor_actors.forEach(({ viewport, containerActor, monitorClone, effect }) => {
viewport.remove_effect(effect);
containerActor.remove_child(monitorClone);
viewport.set_child(null);
this.remove_child(viewport);
});
this.monitor_actors = [];
this._property_bindings.forEach(binding => binding.unbind());
this._property_bindings = [];
this._property_connections.forEach(connection => this.disconnect(connection));
}
});