Move shared logic to breezy-desktop-shared-js, clean up

This commit is contained in:
wheaney 2026-05-27 19:41:22 -07:00
parent 24f240f338
commit 851d972da5
10 changed files with 14 additions and 450 deletions

6
.gitmodules vendored
View File

@ -11,3 +11,9 @@
[submodule "ui/modules/PyXRLinuxDriverIPC"]
path = ui/modules/PyXRLinuxDriverIPC
url = https://github.com/wheaney/PyXRLinuxDriverIPC.git
[submodule "gnome/src/shared"]
path = gnome/src/shared
url = git@github.com:wheaney/breezy-desktop-shared-js.git
[submodule "kwin/src/qml/shared"]
path = kwin/src/qml/shared
url = git@github.com:wheaney/breezy-desktop-shared-js.git

View File

@ -41,6 +41,7 @@ copy_static_files() {
cp $SRC_DIR/dbus-interfaces/*.xml $DEST_DIR/dbus-interfaces/
mkdir -p $DEST_DIR/textures/
cp -rL $SRC_DIR/textures/* $DEST_DIR/textures/
cp -r $SRC_DIR/shared $DEST_DIR/shared
cp $SRC_DIR/metadata.json $DEST_DIR/
}

View File

@ -1 +0,0 @@
export { degreeToRadian, diagonalToCrossFOVs, fovConversionFns, applyQuaternionToVector, vectorMagnitude, normalizeVector } from '../../shared/js/math.js';

1
gnome/src/shared Submodule

@ -0,0 +1 @@
Subproject commit 6cc1d97ece687b2d71dfdb51c94eaf11c82c7fe5

View File

@ -5,7 +5,7 @@ import GObject from 'gi://GObject';
import Shell from 'gi://Shell';
import Globals from './globals.js';
import { degreeToRadian, diagonalToCrossFOVs, fovConversionFns } from './math.js';
import { degreeToRadian, diagonalToCrossFOVs, fovConversionFns } from './shared/math.js';
// these need to mirror the values in XRLinuxDriver

View File

@ -7,8 +7,8 @@ import Shell from 'gi://Shell';
import St from 'gi://St';
import { VirtualDisplayEffect, SMOOTH_FOLLOW_SLERP_TIMELINE_MS } from './virtualdisplayeffect.js';
import { degreeToRadian, diagonalToCrossFOVs, fovConversionFns } from './math.js';
import { findFocusedMonitor, monitorsToPlacements } from '../../shared/js/displayPlacement.js';
import { degreeToRadian, diagonalToCrossFOVs, fovConversionFns } from './shared/math.js';
import { findFocusedMonitor, monitorsToPlacements } from './shared/displayPlacement.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';

View File

@ -1,6 +1,6 @@
import QtQuick
import "../../../shared/js/math.js" as SharedMath
import "../../../shared/js/displayPlacement.js" as SharedPlacement
import "./shared/math.js" as SharedMath
import "./shared/displayPlacement.js" as SharedPlacement
QtObject {
readonly property real focusThreshold: SharedPlacement.FOCUS_THRESHOLD

1
kwin/src/qml/shared Submodule

@ -0,0 +1 @@
Subproject commit 6cc1d97ece687b2d71dfdb51c94eaf11c82c7fe5

View File

@ -1,365 +0,0 @@
import { applyQuaternionToVector, fovConversionFns, vectorMagnitude } from './math.js';
// if nothing is in focus, take it as soon as it crosses into the monitor's bounds
export const FOCUS_THRESHOLD = 0.95 / 2.0;
// if we leave the monitor with some margin, unfocus even if no other monitor is in focus
export const UNFOCUS_THRESHOLD = 1.1 / 2.0;
/**
* Given the known radian positions of previously-placed monitors, compute the begin/center/end
* radian positions for one monitor along a wrapped axis.
*
* All vector arguments use plain JS arrays; callers on Qt platforms convert before/after.
*
* @param {Object} cachedMonitorRadians - mutable pixelradian cache shared across all monitors in one axis
* @param {number} monitorSpacingPixels
* @param {number} monitorBeginPixel
* @param {number} monitorLengthPixels
* @param {function} lengthToRadianFn
* @returns {{begin: number, center: number, end: number}}
*/
export function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) {
// Monitor coordinates can become fractional due to size adjustment.
// If a monitor edge lands extremely close to a cached pixel key, snap to it;
// otherwise tiny negative gaps can cause us to subtract a full spacing interval.
let beginPixel = monitorBeginPixel;
const pixelEpsilon = Math.max(1e-6, Math.abs(monitorLengthPixels) * 1e-6);
let closestWrapPixel = beginPixel;
let closestWrap = cachedMonitorRadians[beginPixel];
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 closestWrapPixelNumber = Number(closestWrapPixel);
if (Number.isFinite(closestWrapPixelNumber) && Math.abs(closestWrapPixelNumber - beginPixel) < pixelEpsilon) {
beginPixel = closestWrapPixelNumber;
closestWrapPixel = closestWrapPixelNumber;
}
const spacingRadians = lengthToRadianFn(monitorSpacingPixels);
if (closestWrapPixel !== beginPixel) {
// there's a gap between the cached wrap value and this one
const gapPixels = beginPixel - 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
// compensate for the spacing that's needed at the right/bottom
const appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians;
closestWrap = closestWrap + gapRadians + appliedSpacingRadians;
closestWrapPixel = beginPixel;
cachedMonitorRadians[closestWrapPixel] = closestWrap;
}
const monitorRadians = lengthToRadianFn(monitorLengthPixels);
const centerRadians = closestWrap + monitorRadians / 2;
const endRadians = closestWrap + monitorRadians;
// cache the end position so adjacent monitors can snap to it
const nextMonitorPixel = beginPixel + monitorLengthPixels;
if (cachedMonitorRadians[nextMonitorPixel] === undefined)
cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians;
return {
begin: closestWrap,
center: centerRadians,
end: endRadians
}
}
// sort monitors left-to-right, top-to-bottom before placing them to avoid odd gaps
export function horizontalMonitorSort(monitors) {
return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => {
const aMon = a.monitorDetails;
const bMon = b.monitorDetails;
if (aMon.y !== bMon.y) return aMon.y - bMon.y;
return aMon.x - bMon.x;
});
}
// sort monitors top-to-bottom, left-to-right before placing them to avoid odd gaps
export function verticalMonitorSort(monitors) {
return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => {
const aMon = a.monitorDetails;
const bMon = b.monitorDetails;
if (aMon.x !== bMon.x) return aMon.x - bMon.x;
return aMon.y - bMon.y;
});
}
/**
* Detect whether a multi-monitor layout is wider or taller relative to the viewport,
* returning 'horizontal' or 'vertical'. Used when wrappingScheme is 'automatic'.
*
* @param {Object[]} monitors - [{x, y, width, height}]
* @param {number} viewportWidth
* @param {number} viewportHeight
* @returns {'horizontal'|'vertical'}
*/
export function autoDetectWrapScheme(monitors, viewportWidth, viewportHeight) {
const minX = Math.min(...monitors.map(m => m.x));
const maxX = Math.max(...monitors.map(m => m.x + m.width));
const minY = Math.min(...monitors.map(m => m.y));
const maxY = Math.max(...monitors.map(m => m.y + m.height));
return (maxX - minX) / viewportWidth >= (maxY - minY) / viewportHeight ? 'horizontal' : 'vertical';
}
/**
* Returns how far the look vector is from the center of a monitor, as a percentage of
* the monitor's dimensions (0 = center, 0.5 = exactly at edge, >0.5 = outside).
*
* All vector arguments are plain arrays in NWU order: [north, west, up].
*
* @param {Object} fovDetails
* @param {number} lookUpPixels
* @param {number} lookWestPixels
* @param {number[]} monitorVector - [north, west, up] center of the monitor relative to lens
* @param {{width: number, height: number}} monitorDetails
* @param {function} upAngleToLength
* @param {function} westAngleToLength
* @returns {number}
*/
export function getMonitorDistance(fovDetails, lookUpPixels, lookWestPixels, monitorVector, monitorDetails, upAngleToLength, westAngleToLength) {
const monitorDistance = vectorMagnitude(monitorVector);
const distanceAdjustment = monitorDistance / fovDetails.completeScreenDistancePixels;
// monitorVector[0]=north, monitorVector[1]=west, monitorVector[2]=up
const vectorUpPixels = upAngleToLength(
fovDetails.defaultDistanceVerticalRadians,
fovDetails.heightPixels,
monitorDistance,
monitorVector[2],
monitorVector[0]
) * distanceAdjustment;
const upPercentage = Math.abs(lookUpPixels * distanceAdjustment - vectorUpPixels) / monitorDetails.height;
const vectorWestPixels = westAngleToLength(
fovDetails.defaultDistanceHorizontalRadians,
fovDetails.widthPixels,
monitorDistance,
monitorVector[1],
monitorVector[0]
) * distanceAdjustment;
const westPercentage = Math.abs(lookWestPixels * distanceAdjustment - vectorWestPixels) / monitorDetails.width;
return Math.max(upPercentage, westPercentage);
}
/**
* Find which monitor the user is looking at.
*
* All vectors use plain NWU arrays: [north, west, up].
* Quaternion is [x, y, z, w].
*
* @param {number[]} quaternion - current head orientation [x, y, z, w]
* @param {number[]} position - lens position [north, west, up] in pixel units
* @param {number[][]} monitorVectors - centerLook for each monitor
* @param {number} currentFocusedIndex
* @param {number} focusedMonitorDistance - display_distance / display_distance_default, < 1 when zoomed in
* @param {boolean} smoothFollowEnabled
* @param {Object} fovDetails
* @param {Object[]} monitorsDetails - [{x, y, width, height}]
* @returns {number} index of focused monitor, or -1 if none
*/
export function findFocusedMonitor(quaternion, position, monitorVectors, currentFocusedIndex, focusedMonitorDistance, smoothFollowEnabled, fovDetails, monitorsDetails) {
if (currentFocusedIndex !== -1 && smoothFollowEnabled) return currentFocusedIndex;
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]
);
function vectorRelativeToLensPosition(vector) {
return [
vector[0] - position[0],
vector[1] - position[1],
vector[2] - position[2]
];
}
// the currently focused monitor is the most likely to be the closest, check it first and exit early if it is
if (currentFocusedIndex !== -1) {
const focusedDistance = getMonitorDistance(
fovDetails,
lookUpPixels,
lookWestPixels,
vectorRelativeToLensPosition(monitorVectors[currentFocusedIndex]),
monitorsDetails[currentFocusedIndex],
upConversionFns.angleToLength,
westConversionFns.angleToLength
) * focusedMonitorDistance;
if (focusedDistance < UNFOCUS_THRESHOLD) return currentFocusedIndex;
}
let closestIndex = -1;
let closestDistance = Infinity;
monitorVectors.forEach((monitorVector, index) => {
if (index === currentFocusedIndex) return;
const distance = getMonitorDistance(
fovDetails,
lookUpPixels,
lookWestPixels,
vectorRelativeToLensPosition(monitorVector),
monitorsDetails[index],
upConversionFns.angleToLength,
westConversionFns.angleToLength
);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
});
if (smoothFollowEnabled || closestDistance < FOCUS_THRESHOLD) return closestIndex;
return -1;
}
/**
* Convert monitor layout details into NWU placement vectors for rendering.
*
* Vectors in the returned objects are plain arrays [north, west, up].
* Qt callers should wrap centerNoRotate/centerLook with Qt.vector3d after calling.
*
* @param {Object} fovDetails - widthPixels, heightPixels, sizeAdjustedWidthPixels, sizeAdjustedHeightPixels,
* defaultDistanceHorizontalRadians, defaultDistanceVerticalRadians,
* completeScreenDistancePixels, monitorWrappingScheme, curvedDisplay
* @param {Object[]} monitorDetailsList - [{x, y, width, height}] in size-adjusted viewport-relative coords
* @param {number} monitorSpacing - visual spacing as a fraction of viewport width (e.g. 0.02)
* @returns {Object[]} - [{originalIndex, monitorCenterNorth, centerNoRotate, centerLook, rotationAngleRadians}]
*/
export function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) {
const monitorPlacements = [];
const cachedMonitorRadians = {};
const conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat;
if (fovDetails.monitorWrappingScheme === 'horizontal') {
const sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedWidthPixels);
const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels;
const lengthToRadianFn = (targetWidth) => conversionFns.lengthToRadians(
fovDetails.defaultDistanceHorizontalRadians,
fovDetails.widthPixels,
sideEdgeRadius,
targetWidth
);
cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedWidthPixels) / 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.sizeAdjustedHeightPixels) * monitorSpacingPixels;
const upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2;
const upCenterPixels = upTopPixels - upCenterOffsetPixels;
monitorPlacements.push({
originalIndex,
monitorCenterNorth: monitorCenterRadius,
centerNoRotate: [monitorCenterRadius, 0, upCenterPixels],
centerLook: [
monitorCenterRadius * Math.cos(monitorWrapDetails.center),
-monitorCenterRadius * Math.sin(monitorWrapDetails.center),
upCenterPixels
],
rotationAngleRadians: { x: 0, y: -monitorWrapDetails.center }
});
});
} else if (fovDetails.monitorWrappingScheme === 'vertical') {
const topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedHeightPixels);
const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedHeightPixels;
const lengthToRadianFn = (targetHeight) => conversionFns.lengthToRadians(
fovDetails.defaultDistanceVerticalRadians,
fovDetails.heightPixels,
topEdgeRadius,
targetHeight
);
cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedHeightPixels) / 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.sizeAdjustedWidthPixels) * monitorSpacingPixels;
const westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2;
const westCenterPixels = westLeftPixels - westCenterOffsetPixels;
monitorPlacements.push({
originalIndex,
monitorCenterNorth: monitorCenterRadius,
centerNoRotate: [monitorCenterRadius, westCenterPixels, 0],
centerLook: [
monitorCenterRadius * Math.cos(monitorWrapDetails.center),
westCenterPixels,
-monitorCenterRadius * Math.sin(monitorWrapDetails.center)
],
rotationAngleRadians: { x: -monitorWrapDetails.center, y: 0 }
});
});
} else {
const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels;
monitorDetailsList.forEach((monitorDetails, index) => {
const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels;
const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels;
const westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2;
const upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2;
const westCenterPixels = westLeftPixels - westCenterOffsetPixels;
const upCenterPixels = upTopPixels - upCenterOffsetPixels;
monitorPlacements.push({
originalIndex: index,
monitorCenterNorth: fovDetails.completeScreenDistancePixels,
centerNoRotate: [fovDetails.completeScreenDistancePixels, westCenterPixels, upCenterPixels],
centerLook: [fovDetails.completeScreenDistancePixels, westCenterPixels, upCenterPixels],
rotationAngleRadians: { x: 0, y: 0 }
});
});
}
monitorPlacements.sort((a, b) => a.originalIndex - b.originalIndex);
return monitorPlacements;
}

View File

@ -1,79 +0,0 @@
export function degreeToRadian(degree) {
return degree * Math.PI / 180;
}
// FOV in radians is spherical, so doesn't follow Pythagoras' theorem
export function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) {
// first convert from a spherical FOV to a diagonal FOV on a flat plane at a unit distance of 1.0
const diagonalLengthUnitDistance = 2 * Math.tan(diagonalFOVRadians / 2);
// then convert to flat plane horizontal and vertical FOVs
const heightUnitDistance = diagonalLengthUnitDistance / Math.sqrt(1 + aspectRatio * aspectRatio);
const widthUnitDistance = heightUnitDistance * aspectRatio;
return {
// then convert back to spherical FOV
diagonalRadians: diagonalFOVRadians,
horizontalRadians: 2 * Math.atan(widthUnitDistance / 2),
verticalRadians: 2 * Math.atan(heightUnitDistance / 2),
// flat values are relative to a unit distance of 1.0
diagonalLengthUnitDistance,
widthUnitDistance,
heightUnitDistance
}
}
const segmentsPerRadian = 20.0 / degreeToRadian(90.0);
// displays are placed around a circle, these functions help determine radians and distances from the original
// FOV measurements scaled to the display dimensions
export const fovConversionFns = {
// convert curved FOV for flat displays
flat: {
// distance to an edge is the hypothenuse of the triangle where the opposite side is half the width of the reference fov screen
centerToFovEdgeDistance: (centerDistance, fovLength) => Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)),
fovEdgeToScreenCenterDistance: (edgeDistance, screenLength) => Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)),
lengthToRadians: (fovRadians, fovLength, screenEdgeDistance, toLength) => Math.asin(toLength / 2 / screenEdgeDistance) * 2,
angleToLength: (fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) => {
return toAngleOpposite / toAngleAdjacent * screenDistance;
},
fovRadiansAtDistance: (fovRadians, unitLength, newScreenDistance) => {
return 2 * Math.atan(unitLength / 2 / newScreenDistance);
},
radiansToSegments: (screenRadians) => 1
},
// convert curved FOV for curved displays, scaling either involves no change or is linear
curved: {
centerToFovEdgeDistance: (centerDistance, fovLength) => centerDistance,
fovEdgeToScreenCenterDistance: (edgeDistance, screenLength) => edgeDistance,
lengthToRadians: (fovRadians, fovLength, screenEdgeDistance, toLength) => fovRadians / fovLength * toLength,
angleToLength: (fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) => fovLength / fovRadians * Math.atan2(toAngleOpposite, toAngleAdjacent),
fovRadiansAtDistance: (fovRadians, unitLength, newScreenDistance) => fovRadians / newScreenDistance,
radiansToSegments: (screenRadians) => Math.ceil(screenRadians * segmentsPerRadian)
}
}
// quaternion is [x, y, z, w], vector is [x, y, z]
export const 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]
];
}
export const vectorMagnitude = (vector) => {
return Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]);
}
export const normalizeVector = (vector) => {
const length = vectorMagnitude(vector);
return [vector[0] / length, vector[1] / length, vector[2] / length];
}