breezy-desktop/gnome/src/virtualdisplaysactor.js

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));
}
});