831 lines
36 KiB
JavaScript
831 lines
36 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 Mtk from 'gi://Mtk';
|
|
import Shell from 'gi://Shell';
|
|
import St from 'gi://St';
|
|
|
|
import { VirtualDisplayEffect } from './virtualdisplayeffect.js';
|
|
|
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
|
|
|
import Globals from './globals.js';
|
|
|
|
function applyQuaternionToVector(vector, quaternion) {
|
|
const t = [
|
|
2.0 * (quaternion[1] * vector[2] - quaternion[2] * vector[1]),
|
|
2.0 * (quaternion[2] * vector[0] - quaternion[0] * vector[2]),
|
|
2.0 * (quaternion[0] * vector[1] - quaternion[1] * vector[0])
|
|
];
|
|
return [
|
|
vector[0] + quaternion[3] * t[0] + quaternion[1] * t[2] - quaternion[2] * t[1],
|
|
vector[1] + quaternion[3] * t[1] + quaternion[2] * t[0] - quaternion[0] * t[2],
|
|
vector[2] + quaternion[3] * t[2] + quaternion[0] * t[1] - quaternion[1] * t[0]
|
|
];
|
|
}
|
|
|
|
// 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 {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, fovDetails, monitorsDetails) {
|
|
const lookVector = [1.0, 0.0, 0.0]; // NWU vector pointing to the center of the screen
|
|
const rotatedLookVector = applyQuaternionToVector(lookVector, quaternion);
|
|
|
|
const xzMagnitude = Math.sqrt(rotatedLookVector[0]*rotatedLookVector[0] + rotatedLookVector[2]*rotatedLookVector[2]);
|
|
const lookUpTheta = Math.atan2(rotatedLookVector[2], rotatedLookVector[0]);
|
|
|
|
let closestIndex = -1;
|
|
let closestDistance = Infinity;
|
|
let currentFocusedDistance = Infinity;
|
|
|
|
// find the vector closest to the rotated look vector
|
|
monitorVectors.forEach((vector, index) => {
|
|
const monitor = monitorsDetails[index];
|
|
const monitorAspectRatio = monitor.width / monitor.height;
|
|
|
|
// weight the rotation about the y-axis between the two vectors, by the aspect ratio
|
|
const vectorUpTheta = Math.atan2(vector[2], vector[0]);
|
|
const upDelta = lookUpTheta - vectorUpTheta;
|
|
const newLookUpTheta = Math.tan(Math.max(
|
|
-Math.PI,
|
|
Math.min(
|
|
Math.PI,
|
|
upDelta * monitorAspectRatio + vectorUpTheta
|
|
)
|
|
));
|
|
const weightedLookVector = [
|
|
xzMagnitude * Math.cos(newLookUpTheta),
|
|
rotatedLookVector[1],
|
|
xzMagnitude * Math.sin(newLookUpTheta)
|
|
];
|
|
|
|
// find the distance between the monitor vector and weighted look vector
|
|
const distance = Math.acos(
|
|
Math.min(1.0, Math.max(-1.0,
|
|
vector[0] * weightedLookVector[0] +
|
|
vector[1] * weightedLookVector[1] +
|
|
vector[2] * weightedLookVector[2]
|
|
))
|
|
);
|
|
|
|
const distancePixels = fovDetails.completeScreenDistance * Math.tan(distance);
|
|
const distanceToMonitorSize = distancePixels / monitor.width;
|
|
|
|
if (currentFocusedIndex === index) {
|
|
currentFocusedDistance = distanceToMonitorSize * focusedMonitorDistance;
|
|
}
|
|
|
|
if (distanceToMonitorSize < closestDistance) {
|
|
closestIndex = index;
|
|
closestDistance = distanceToMonitorSize;
|
|
}
|
|
});
|
|
|
|
const keepCurrent = currentFocusedIndex !== -1 && currentFocusedDistance < UNFOCUS_THRESHOLD;
|
|
if (!keepCurrent) {
|
|
if (closestDistance < FOCUS_THRESHOLD) return closestIndex;
|
|
|
|
// neither the current nor the closest will take focus, unfocus all displays
|
|
return -1;
|
|
}
|
|
|
|
return currentFocusedIndex;
|
|
}
|
|
|
|
function degreesToRadians(degrees) {
|
|
return degrees * Math.PI / 180.0;
|
|
}
|
|
|
|
/***
|
|
* @returns {Object} - containing `begin`, `center`, and `end` radians for rotating the given monitor
|
|
*/
|
|
function monitorWrap(cachedMonitorRadians, radiusPixels, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels) {
|
|
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 = Math.asin(monitorSpacingPixels / 2 / radiusPixels) * 2;
|
|
if (closestWrapPixel !== monitorBeginPixel) {
|
|
// there's a gap between the cached wrap value and this one
|
|
const gapPixels = monitorBeginPixel - closestWrapPixel;
|
|
const gapHalfRadians = Math.asin(gapPixels / 2 / radiusPixels);
|
|
const gapRadians = gapHalfRadians * 2;
|
|
|
|
// 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 monitorHalfRadians = Math.asin(monitorLengthPixels / 2 / radiusPixels);
|
|
const centerRadians = closestWrap + monitorHalfRadians;
|
|
const endRadians = centerRadians + monitorHalfRadians;
|
|
|
|
// 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 {string} monitorWrappingScheme - horizontal, vertical, none
|
|
* @returns {Object[]} - contains NWU vectors pointing to `topLeftNoRotate` and `center` of each monitor
|
|
* and a `rotation` angle for the given wrapping scheme
|
|
*/
|
|
function monitorsToPlacements(fovDetails, monitorDetailsList, monitorWrappingScheme, monitorSpacing) {
|
|
const monitorPlacements = [];
|
|
const cachedMonitorRadians = {};
|
|
|
|
Globals.logger.log_debug(`\t\t\tFOV Details: ${JSON.stringify(fovDetails)}, Monitor Wrapping Scheme: ${monitorWrappingScheme}`);
|
|
|
|
if (monitorWrappingScheme === 'horizontal') {
|
|
// monitors wrap around us horizontally
|
|
|
|
// distance to a horizontal edge is the hypothenuse of the triangle where the opposite side is half the width of the reference fov screen
|
|
const sideEdgeRadius = Math.sqrt(Math.pow(fovDetails.widthPixels / 2, 2) + Math.pow(fovDetails.completeScreenDistance, 2));
|
|
const monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels;
|
|
|
|
cachedMonitorRadians[0] = -fovDetails.horizontalRadians / 2;
|
|
monitorDetailsList.forEach(monitorDetails => {
|
|
const monitorWrapDetails = monitorWrap(cachedMonitorRadians, sideEdgeRadius, monitorSpacingPixels, monitorDetails.x, monitorDetails.width);
|
|
const upTopPixels = monitorDetails.y + (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels;
|
|
const upCenterPixels = upTopPixels + monitorDetails.height / 2 - fovDetails.heightPixels / 2;
|
|
|
|
monitorPlacements.push({
|
|
topLeftNoRotate: [
|
|
fovDetails.completeScreenDistance,
|
|
|
|
// west stays aligned with (0, 0), will apply rotationAngleRadians value during rendering
|
|
-(monitorDetails.width - fovDetails.widthPixels) / 2,
|
|
|
|
// up is flat when wrapping horizontally, apply it here as a constant, not touched by rendering
|
|
-upTopPixels
|
|
],
|
|
centerNoRotate: [
|
|
fovDetails.completeScreenDistance,
|
|
|
|
// west centered about the FOV center
|
|
0,
|
|
|
|
// up is flat when wrapping horizontally
|
|
-upCenterPixels
|
|
],
|
|
center: [
|
|
// north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians
|
|
fovDetails.completeScreenDistance * Math.cos(monitorWrapDetails.center),
|
|
|
|
// west is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians
|
|
-fovDetails.completeScreenDistance * Math.sin(monitorWrapDetails.center),
|
|
|
|
// up is flat when wrapping horizontally
|
|
-upCenterPixels
|
|
],
|
|
rotationAngleRadians: {
|
|
x: 0,
|
|
y: -monitorWrapDetails.center
|
|
}
|
|
});
|
|
});
|
|
} else if (monitorWrappingScheme === 'vertical') {
|
|
// monitors wrap around us vertically
|
|
|
|
// distance to the top edge is the hypothenuse of the triangle where the opposite side is half the height of the reference fov screen
|
|
const topEdgeRadius = Math.sqrt(Math.pow(fovDetails.heightPixels / 2, 2) + Math.pow(fovDetails.completeScreenDistance, 2));
|
|
const monitorSpacingPixels = monitorSpacing * fovDetails.heightPixels;
|
|
|
|
cachedMonitorRadians[0] = -fovDetails.verticalRadians / 2;
|
|
monitorDetailsList.forEach(monitorDetails => {
|
|
const monitorWrapDetails = monitorWrap(cachedMonitorRadians, topEdgeRadius, monitorSpacingPixels, monitorDetails.y, monitorDetails.height);
|
|
const westPixels = monitorDetails.x + (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels;
|
|
const westCenterPixels = westPixels + monitorDetails.width / 2 - fovDetails.widthPixels / 2;
|
|
|
|
monitorPlacements.push({
|
|
topLeftNoRotate: [
|
|
fovDetails.completeScreenDistance,
|
|
|
|
// west is flat when wrapping vertically, apply it here as a constant, not touched by rendering
|
|
westPixels,
|
|
|
|
// up stays aligned with (0, 0), will apply rotationAngleRadians value during rendering
|
|
(monitorDetails.height - fovDetails.heightPixels) / 2
|
|
],
|
|
centerNoRotate: [
|
|
fovDetails.completeScreenDistance,
|
|
|
|
// west is flat when wrapping horizontally
|
|
westCenterPixels,
|
|
|
|
// west centered about the FOV center
|
|
0
|
|
],
|
|
center: [
|
|
// north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians
|
|
fovDetails.completeScreenDistance * 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
|
|
-fovDetails.completeScreenDistance * 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 => {
|
|
const upPixels = monitorDetails.y + (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels;
|
|
const westPixels = monitorDetails.x + (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels;
|
|
const westCenterPixels = westPixels + monitorDetails.width / 2 - fovDetails.widthPixels / 2;
|
|
const upCenterPixels = upPixels + monitorDetails.height / 2 - fovDetails.heightPixels / 2;
|
|
monitorPlacements.push({
|
|
topLeftNoRotate: [
|
|
fovDetails.completeScreenDistance,
|
|
westPixels,
|
|
-upPixels
|
|
],
|
|
centerNoRotate: [
|
|
fovDetails.completeScreenDistance,
|
|
westCenterPixels,
|
|
-upCenterPixels
|
|
],
|
|
center: [
|
|
fovDetails.completeScreenDistance,
|
|
-westCenterPixels,
|
|
-upCenterPixels
|
|
],
|
|
rotationAngleRadians: {
|
|
x: 0,
|
|
y: 0
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
Globals.logger.log_debug(`\t\t\tMonitor placements: ${JSON.stringify(monitorPlacements)}, cached values: ${JSON.stringify(cachedMonitorRadians)}`);
|
|
|
|
return monitorPlacements;
|
|
}
|
|
|
|
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
|
|
),
|
|
'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
|
|
),
|
|
'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
|
|
),
|
|
'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
|
|
),
|
|
'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
|
|
]
|
|
|
|
const bannerTextureClippingRect = new Mtk.Rectangle({
|
|
x: 0,
|
|
y: 0,
|
|
width: 800,
|
|
height: 200
|
|
});
|
|
|
|
const calibratingBanner = GdkPixbuf.Pixbuf.new_from_file(`${Globals.extension_dir}/textures/calibrating.png`);
|
|
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(), bannerTextureClippingRect);
|
|
|
|
const customBanner = GdkPixbuf.Pixbuf.new_from_file(`${Globals.extension_dir}/textures/custom_banner.png`);
|
|
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(), bannerTextureClippingRect);
|
|
|
|
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();
|
|
|
|
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('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);
|
|
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_placements: this.monitor_placements,
|
|
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',
|
|
'imu-snapshots',
|
|
'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.set_child_above_sibling(this.bannerActor, null);
|
|
}
|
|
}).bind(this));
|
|
}).bind(this));
|
|
|
|
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;
|
|
} else if (this.imu_snapshots) {
|
|
const focusedMonitorIndex = findFocusedMonitor(
|
|
this.imu_snapshots.imu_data.splice(0, 4),
|
|
this._monitorsAsNormalizedVectors,
|
|
this.focused_monitor_index,
|
|
this.display_distance / this._display_distance_default(),
|
|
this._fov_details(),
|
|
this._sorted_monitors
|
|
);
|
|
|
|
if (this.focused_monitor_index !== focusedMonitorIndex) {
|
|
Globals.logger.log_debug(`Switching to monitor ${focusedMonitorIndex}`);
|
|
this.focused_monitor_index = 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 fovVerticalRadiansFullScreen = degreesToRadians(Globals.data_stream.device_data.displayFov / Math.sqrt(1 + aspect * aspect));
|
|
const fovVerticalRadians = Math.atan(Math.tan(fovVerticalRadiansFullScreen) / 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(fovVerticalRadiansFullScreen / 2);
|
|
const lensDistance = 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(fovVerticalRadians / 2);
|
|
const completeScreenDistance = lensToScreenDistance + lensDistance;
|
|
|
|
return {
|
|
widthPixels: this.target_monitor.width,
|
|
heightPixels: this.target_monitor.height,
|
|
verticalRadians: fovVerticalRadians,
|
|
horizontalRadians: fovVerticalRadians * aspect,
|
|
lensDistance,
|
|
completeScreenDistance
|
|
};
|
|
}
|
|
|
|
_horizontal_monitor_sort() {
|
|
return [...this._all_monitors].sort((a, b) => {
|
|
// First compare by y-coordinate to form rows (top to bottom)
|
|
if (a.y !== b.y) {
|
|
return a.y - b.y;
|
|
}
|
|
// Then compare by x-coordinate within the same row (left to right)
|
|
return a.x - b.x;
|
|
});
|
|
}
|
|
|
|
_vertical_monitor_sort() {
|
|
return [...this._all_monitors].sort((a, b) => {
|
|
// First compare by x-coordinate to form columns (left to right)
|
|
if (a.x !== b.x) {
|
|
return a.x - b.x;
|
|
}
|
|
// Then compare by y-coordinate within the same column (top to bottom)
|
|
return a.y - b.y;
|
|
});
|
|
}
|
|
|
|
_update_monitor_placements() {
|
|
// collect minimum and maximum x and y values of monitors
|
|
let actualWrapScheme = this.monitor_wrapping_scheme;
|
|
if (actualWrapScheme === 'automatic') {
|
|
const minX = Math.min(...this._all_monitors.map(monitor => monitor.x));
|
|
const minY = Math.min(...this._all_monitors.map(monitor => monitor.y));
|
|
const maxX = Math.max(...this._all_monitors.map(monitor => monitor.x + monitor.width));
|
|
const maxY = Math.max(...this._all_monitors.map(monitor => monitor.y + monitor.height));
|
|
|
|
// check if there are more monitors in the horizontal or vertical direction, prefer horizontal if equal
|
|
if ((maxX - minX) / this.target_monitor.width >= (maxY - minY) / this.target_monitor.height) {
|
|
actualWrapScheme = 'horizontal';
|
|
} else {
|
|
actualWrapScheme = 'vertical';
|
|
}
|
|
}
|
|
|
|
// use horizontal in all cases but vertical wrapping
|
|
this._sorted_monitors = actualWrapScheme === 'vertical' ?
|
|
this._vertical_monitor_sort() :
|
|
this._horizontal_monitor_sort();
|
|
|
|
const fovDetails = this._fov_details();
|
|
this.lens_vector = [0.0, 0.0, -fovDetails.lensDistance];
|
|
this.monitor_placements = monitorsToPlacements(
|
|
fovDetails,
|
|
|
|
// shift all monitors so they center around the target monitor, then adjusted by the offsets
|
|
this._sorted_monitors.map(monitor => ({
|
|
x: monitor.x - this.target_monitor.x - this.viewport_offset_x * this.target_monitor.width,
|
|
y: monitor.y - this.target_monitor.y + this.viewport_offset_y * this.target_monitor.height,
|
|
width: monitor.width,
|
|
height: monitor.height
|
|
})),
|
|
actualWrapScheme,
|
|
this.monitor_spacing / 1000.0
|
|
);
|
|
|
|
// normalize the center vectors
|
|
this._monitorsAsNormalizedVectors = this.monitor_placements.map(monitorVectors => {
|
|
const vector = monitorVectors.center;
|
|
const length = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]);
|
|
return [vector[0] / length, vector[1] / length, vector[2] / length];
|
|
});
|
|
}
|
|
|
|
_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.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);
|
|
}
|
|
|
|
_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.remove_child(containerActor);
|
|
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));
|
|
}
|
|
}); |