aboutsummaryrefslogtreecommitdiff
path: root/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js
diff options
context:
space:
mode:
Diffstat (limited to '.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js')
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js501
1 files changed, 501 insertions, 0 deletions
diff --git a/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js
new file mode 100644
index 0000000..71ab96b
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js
@@ -0,0 +1,501 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import GObject from 'gi://GObject';
+
+const DISPLAY_CONFIG_BUS_NAME = 'org.gnome.Mutter.DisplayConfig';
+const DISPLAY_CONFIG_OBJECT_PATH = '/org/gnome/Mutter/DisplayConfig';
+const DISPLAY_CONFIG_INTERFACE = 'org.gnome.Mutter.DisplayConfig';
+
+export const LayoutMode = Object.freeze({
+ LOGICAL: 1,
+ PHYSICAL: 2,
+});
+
+export const ProfileIndex = Object.freeze({
+ NAME: 0,
+ RULES: 1,
+ LOGICAL_MONITORS: 2,
+ PROPERTIES: 3,
+});
+
+export const PROFILE_VARIANT_TYPE = 'a(saa{ss}a(iiduba(ssa{sv}))a{sv})';
+export const TEXT_SCALING_FACTOR_PROPERTY = 'display-profile-switcher-text-scaling-factor';
+export const SAVED_CONNECTOR_PROPERTY = '__saved-connector';
+
+export function getTextScalingFactor(profile) {
+ const value = profile[ProfileIndex.PROPERTIES][TEXT_SCALING_FACTOR_PROPERTY];
+ return value === undefined ? null : value.get_double();
+}
+
+export function setTextScalingFactor(profile, value) {
+ profile[ProfileIndex.PROPERTIES][TEXT_SCALING_FACTOR_PROPERTY] = GLib.Variant.new_double(value);
+}
+
+function nearlyEqual(first, second) {
+ return Math.abs(first - second) < 0.01;
+}
+
+function alignedPosition(oldAnchorStart, oldAnchorSize, oldDisplayStart, oldDisplaySize,
+ newAnchorStart, newAnchorSize, newDisplaySize) {
+ if (nearlyEqual(oldDisplayStart, oldAnchorStart))
+ return newAnchorStart;
+ if (nearlyEqual(oldDisplayStart + oldDisplaySize, oldAnchorStart + oldAnchorSize))
+ return newAnchorStart + newAnchorSize - newDisplaySize;
+ if (nearlyEqual(oldDisplayStart + oldDisplaySize / 2,
+ oldAnchorStart + oldAnchorSize / 2))
+ return newAnchorStart + (newAnchorSize - newDisplaySize) / 2;
+
+ return newAnchorStart +
+ (oldDisplayStart - oldAnchorStart) * newAnchorSize / oldAnchorSize;
+}
+
+function adjacentPosition(anchorIndex, displayIndex, oldRects, newRects, positions) {
+ const anchor = oldRects[anchorIndex];
+ const display = oldRects[displayIndex];
+ const newAnchor = newRects[anchorIndex];
+ const newDisplay = newRects[displayIndex];
+ const anchorPosition = positions[anchorIndex];
+ const horizontalOverlap = Math.min(anchor.x + anchor.width, display.x + display.width) -
+ Math.max(anchor.x, display.x);
+ const verticalOverlap = Math.min(anchor.y + anchor.height, display.y + display.height) -
+ Math.max(anchor.y, display.y);
+
+ if (nearlyEqual(display.x, anchor.x + anchor.width) && verticalOverlap > 0) {
+ return {
+ x: anchorPosition.x + newAnchor.width,
+ y: alignedPosition(anchor.y, anchor.height, display.y, display.height,
+ anchorPosition.y, newAnchor.height, newDisplay.height),
+ };
+ }
+ if (nearlyEqual(display.x + display.width, anchor.x) && verticalOverlap > 0) {
+ return {
+ x: anchorPosition.x - newDisplay.width,
+ y: alignedPosition(anchor.y, anchor.height, display.y, display.height,
+ anchorPosition.y, newAnchor.height, newDisplay.height),
+ };
+ }
+ if (nearlyEqual(display.y, anchor.y + anchor.height) && horizontalOverlap > 0) {
+ return {
+ x: alignedPosition(anchor.x, anchor.width, display.x, display.width,
+ anchorPosition.x, newAnchor.width, newDisplay.width),
+ y: anchorPosition.y + newAnchor.height,
+ };
+ }
+ if (nearlyEqual(display.y + display.height, anchor.y) && horizontalOverlap > 0) {
+ return {
+ x: alignedPosition(anchor.x, anchor.width, display.x, display.width,
+ anchorPosition.x, newAnchor.width, newDisplay.width),
+ y: anchorPosition.y - newDisplay.height,
+ };
+ }
+ return null;
+}
+
+export function applyScalesToLayout(
+ logicalMonitors, scales, globalScaleRequired, logicalDisplays = [],
+ layoutMode = LayoutMode.LOGICAL) {
+ const oldScales = logicalMonitors.map(logicalMonitor => logicalMonitor[2]);
+ logicalMonitors.forEach((logicalMonitor, index) => {
+ logicalMonitor[2] = scales[index];
+ });
+
+ // X11's physical layout coordinates do not change with scale. Only the
+ // shared UI scale changes.
+ if (layoutMode === LayoutMode.PHYSICAL)
+ return;
+
+ if (globalScaleRequired && logicalMonitors.length > 0) {
+ const oldScale = oldScales[0];
+ const newScale = scales[0];
+ const positionRatio = oldScale / newScale;
+ for (const logicalMonitor of logicalMonitors) {
+ logicalMonitor[0] = Math.round(logicalMonitor[0] * positionRatio);
+ logicalMonitor[1] = Math.round(logicalMonitor[1] * positionRatio);
+ }
+ return;
+ }
+
+ if (logicalDisplays.length !== logicalMonitors.length)
+ return;
+
+ const oldRects = logicalDisplays.map(display => ({
+ x: display.x,
+ y: display.y,
+ width: display.width,
+ height: display.height,
+ }));
+ const newRects = logicalDisplays.map((display, index) => ({
+ width: display.physicalWidth / scales[index],
+ height: display.physicalHeight / scales[index],
+ }));
+ const positions = Array(logicalMonitors.length).fill(null);
+ const anchorIndex = logicalDisplays.findIndex(display => display.primary);
+ const firstIndex = anchorIndex >= 0 ? anchorIndex : 0;
+ positions[firstIndex] = {x: oldRects[firstIndex].x, y: oldRects[firstIndex].y};
+ const queue = [firstIndex];
+
+ while (queue.length) {
+ const currentIndex = queue.shift();
+ for (let index = 0; index < logicalMonitors.length; index++) {
+ if (positions[index])
+ continue;
+ const position = adjacentPosition(
+ currentIndex, index, oldRects, newRects, positions);
+ if (!position)
+ continue;
+ positions[index] = position;
+ queue.push(index);
+ }
+ }
+
+ // A valid Mutter layout is normally one connected component. Preserve the
+ // original origin as a fallback if a driver reports otherwise.
+ for (let index = 0; index < positions.length; index++)
+ positions[index] ??= {x: oldRects[index].x, y: oldRects[index].y};
+
+ const minimumX = Math.min(...positions.map(position => position.x));
+ const minimumY = Math.min(...positions.map(position => position.y));
+ logicalMonitors.forEach((logicalMonitor, index) => {
+ logicalMonitor[0] = Math.round(positions[index].x - minimumX);
+ logicalMonitor[1] = Math.round(positions[index].y - minimumY);
+ });
+}
+
+export function escapeRegex(value) {
+ return `^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`;
+}
+
+export function monitorRules(monitors) {
+ return monitors.map(monitor => ({
+ connector: escapeRegex(monitor.connector),
+ vendor: escapeRegex(monitor.vendor),
+ product: escapeRegex(monitor.product),
+ serial: escapeRegex(monitor.serial),
+ }));
+}
+
+function ruleMatchesMonitor(rule, monitor) {
+ return Object.entries(rule).every(([field, pattern]) => {
+ if (field.startsWith('__') || !pattern)
+ return true;
+
+ try {
+ return new RegExp(pattern, 'i').test(monitor[field] ?? '');
+ } catch (_error) {
+ return false;
+ }
+ });
+}
+
+export function matchProfileMonitors(profile, monitors) {
+ const rules = profile[ProfileIndex.RULES];
+ if (rules.length === 0)
+ return null;
+
+ const matchRule = (ruleIndex, remainingMonitors) => {
+ if (ruleIndex === rules.length)
+ return [];
+
+ const rule = rules[ruleIndex];
+ const candidates = [...remainingMonitors].sort((first, second) => {
+ const savedConnector = rule[SAVED_CONNECTOR_PROPERTY];
+ return Number(second.connector === savedConnector) - Number(first.connector === savedConnector);
+ });
+ for (const monitor of candidates) {
+ if (!ruleMatchesMonitor(rule, monitor))
+ continue;
+ const remaining = remainingMonitors.filter(candidate => candidate !== monitor);
+ const subsequentMatches = matchRule(ruleIndex + 1, remaining);
+ if (subsequentMatches !== null)
+ return [monitor, ...subsequentMatches];
+ }
+ return null;
+ };
+
+ // A connected monitor can satisfy only one saved rule. This distinguishes
+ // a two-monitor profile from two broad rules that both happen to match one
+ // display.
+ return matchRule(0, monitors);
+}
+
+export function profileMatches(profile, monitors) {
+ return matchProfileMonitors(profile, monitors) !== null;
+}
+
+export function profileLayoutMatches(profile, snapshot, textScalingFactor) {
+ if (!snapshot)
+ return false;
+
+ const layoutSignature = logicalMonitors => logicalMonitors.map(
+ ([x, y, scale, transform, primary, monitors]) => [
+ x, y, scale, transform, primary,
+ monitors.map(([connector, mode]) => [connector, mode]).sort(),
+ ]).sort((first, second) => JSON.stringify(first).localeCompare(JSON.stringify(second)));
+ const savedLayout = layoutSignature(profile[ProfileIndex.LOGICAL_MONITORS]);
+ const currentLayout = layoutSignature(snapshot.logicalMonitors);
+ if (JSON.stringify(savedLayout) !== JSON.stringify(currentLayout))
+ return false;
+
+ const savedTextScalingFactor = getTextScalingFactor(profile);
+ return savedTextScalingFactor === null ||
+ Math.abs(savedTextScalingFactor - textScalingFactor) < 0.001;
+}
+
+export const DisplayConfig = GObject.registerClass({
+ Signals: {'state-changed': {}},
+}, class DisplayConfig extends GObject.Object {
+ constructor() {
+ super();
+ this._state = null;
+ this._proxy = null;
+ this._signalId = 0;
+ this._ready = this._connect();
+ }
+
+ async _connect() {
+ Gio._promisify(Gio.DBusProxy, 'new_for_bus');
+ this._proxy = await Gio.DBusProxy.new_for_bus(
+ Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, null,
+ DISPLAY_CONFIG_BUS_NAME, DISPLAY_CONFIG_OBJECT_PATH,
+ DISPLAY_CONFIG_INTERFACE, null);
+ Gio._promisify(this._proxy, 'call');
+ this._signalId = this._proxy.connect('g-signal::MonitorsChanged', () => this.refresh());
+ await this.refresh();
+ }
+
+ destroy() {
+ if (this._signalId)
+ this._proxy.disconnect(this._signalId);
+ this._signalId = 0;
+ this._proxy = null;
+ this._state = null;
+ }
+
+ async refresh() {
+ if (!this._proxy)
+ return;
+ const reply = await this._proxy.call('GetCurrentState', null,
+ Gio.DBusCallFlags.NONE, -1, null);
+ this._state = reply.recursiveUnpack();
+ this.emit('state-changed');
+ }
+
+ get ready() {
+ return this._ready;
+ }
+
+ get monitors() {
+ if (!this._state)
+ return [];
+ return this._state[1].map(([id, modes]) => ({
+ connector: id[0] ?? '',
+ vendor: id[1] ?? '',
+ product: id[2] ?? '',
+ serial: id[3] ?? '',
+ modes: modes.map(([modeId, width, height, refreshRate, preferredScale, supportedScales,
+ properties]) => ({
+ id: modeId,
+ width,
+ height,
+ refreshRate,
+ preferredScale,
+ supportedScales,
+ current: Boolean(properties['is-current']),
+ preferred: Boolean(properties['is-preferred']),
+ })),
+ }));
+ }
+
+ get logicalDisplays() {
+ if (!this._state)
+ return [];
+
+ const monitorsByConnector = new Map(this._state[1].map(monitor => [monitor[0][0], monitor]));
+ return this._state[2].map((logicalMonitor, index) => {
+ const [x, y, scale, transform, primary, attachedMonitors] = logicalMonitor;
+ const connected = attachedMonitors.map(([connector]) => monitorsByConnector.get(connector))
+ .filter(Boolean);
+ const currentModes = connected.map(([, modes]) =>
+ modes.find(mode => mode[6]['is-current'])).filter(Boolean);
+ const scaleSets = currentModes.map(currentMode => {
+ return new Set(currentMode?.[5] ?? [scale]);
+ });
+ let supportedScales = scaleSets.length ? [...scaleSets[0]] : [scale];
+ for (const scaleSet of scaleSets.slice(1))
+ supportedScales = supportedScales.filter(value => scaleSet.has(value));
+ if (!supportedScales.some(value => Math.abs(value - scale) < 0.001))
+ supportedScales.push(scale);
+ supportedScales.sort((first, second) => first - second);
+
+ const names = connected.map(([id]) => `${id[1]} ${id[2]} (${id[0]})`);
+ const referenceMode = currentModes[0];
+ let physicalWidth = referenceMode?.[1] ?? 1;
+ let physicalHeight = referenceMode?.[2] ?? 1;
+ if (transform === 1 || transform === 3)
+ [physicalWidth, physicalHeight] = [physicalHeight, physicalWidth];
+ return {
+ name: names.join(' + ') || `Display ${index + 1}`,
+ primary,
+ x,
+ y,
+ scale,
+ physicalWidth,
+ physicalHeight,
+ width: physicalWidth / scale,
+ height: physicalHeight / scale,
+ supportedScales,
+ };
+ });
+ }
+
+ get globalScaleRequired() {
+ return Boolean(this._state?.[3]?.['global-scale-required']);
+ }
+
+ get layoutMode() {
+ return this._state?.[3]?.['layout-mode'] ?? LayoutMode.LOGICAL;
+ }
+
+ get scaleGroups() {
+ const displays = this.logicalDisplays;
+ if (!this.globalScaleRequired) {
+ return displays.map((display, index) => ({
+ ...display,
+ logicalMonitorIndexes: [index],
+ }));
+ }
+
+ let supportedScales = displays[0]?.supportedScales ?? [1];
+ for (const display of displays.slice(1)) {
+ supportedScales = supportedScales.filter(scale =>
+ display.supportedScales.some(candidate => Math.abs(candidate - scale) < 0.001));
+ }
+ const currentScale = displays[0]?.scale ?? 1;
+ if (!supportedScales.some(scale => Math.abs(scale - currentScale) < 0.001))
+ supportedScales.push(currentScale);
+ supportedScales.sort((first, second) => first - second);
+
+ return [{
+ name: 'All Displays',
+ primary: false,
+ scale: currentScale,
+ supportedScales,
+ logicalMonitorIndexes: displays.map((_, index) => index),
+ }];
+ }
+
+ snapshot() {
+ if (!this._state)
+ return null;
+ const [, monitors, logicalMonitors, properties] = this._state;
+ const currentModes = new Map(monitors.map(([id, modes]) => [id[0],
+ modes.find(([,, , , , , modeProperties]) => modeProperties['is-current'])?.[0]]));
+ const layout = logicalMonitors.map(([x, y, scale, transform, primary, attached]) => [
+ x, y, scale, transform, primary,
+ attached.map(([connector]) => [connector, currentModes.get(connector), {}]),
+ ]);
+ const applyProperties = {};
+ if (properties['supports-changing-layout-mode'] && properties['layout-mode'] !== undefined)
+ applyProperties['layout-mode'] = GLib.Variant.new_uint32(properties['layout-mode']);
+ return {logicalMonitors: layout, properties: applyProperties};
+ }
+
+ adaptProfile(profile) {
+ const matches = matchProfileMonitors(profile, this.monitors);
+ if (matches === null)
+ return null;
+
+ const rules = profile[ProfileIndex.RULES];
+ const savedConnectors = [...new Set(
+ profile[ProfileIndex.LOGICAL_MONITORS].flatMap(logicalMonitor =>
+ logicalMonitor[5].map(([connector]) => connector)))];
+ const availableSavedConnectors = new Set(savedConnectors);
+ const connectorMap = new Map();
+ for (let index = 0; index < rules.length; index++) {
+ const rule = rules[index];
+ let savedConnector = rule[SAVED_CONNECTOR_PROPERTY];
+ if (!savedConnector && rule.connector) {
+ try {
+ savedConnector = [...availableSavedConnectors].find(connector =>
+ new RegExp(rule.connector, 'i').test(connector));
+ } catch (_error) {
+ savedConnector = null;
+ }
+ }
+ savedConnector ??= [...availableSavedConnectors][0];
+ if (!savedConnector)
+ throw new Error('The profile does not identify every saved display.');
+ availableSavedConnectors.delete(savedConnector);
+ connectorMap.set(savedConnector, matches[index]);
+ }
+
+ const logicalMonitors = profile[ProfileIndex.LOGICAL_MONITORS].map(
+ ([x, y, scale, transform, primary, attachedMonitors]) => [
+ x, y, scale, transform, primary,
+ attachedMonitors.map(([savedConnector, savedModeId, properties]) => {
+ const monitor = connectorMap.get(savedConnector);
+ if (!monitor)
+ throw new Error(`No connected display matches saved connector ${savedConnector}.`);
+ const mode = this._compatibleMode(monitor, savedModeId);
+ if (!mode) {
+ throw new Error(
+ `${monitor.vendor} ${monitor.product} does not support saved mode ${savedModeId}.`);
+ }
+ return [monitor.connector, mode.id, properties];
+ }),
+ ]);
+
+ return [
+ profile[ProfileIndex.NAME],
+ profile[ProfileIndex.RULES],
+ logicalMonitors,
+ profile[ProfileIndex.PROPERTIES],
+ ];
+ }
+
+ async apply(profile) {
+ await this.ready;
+ if (!this._state)
+ throw new Error('GNOME has not reported a display configuration yet.');
+ const adaptedProfile = this.adaptProfile(profile);
+ if (!adaptedProfile)
+ throw new Error('The connected displays do not match this profile.');
+ const logicalMonitors = adaptedProfile[ProfileIndex.LOGICAL_MONITORS];
+ if (this.globalScaleRequired && logicalMonitors.some(
+ logicalMonitor => Math.abs(logicalMonitor[2] - logicalMonitors[0][2]) >= 0.001)) {
+ throw new Error(
+ 'This session requires one shared display scale. Overwrite this profile to choose a shared scale.');
+ }
+ const parameters = new GLib.Variant('(uua(iiduba(ssa{sv}))a{sv})', [
+ this._state[0], 1,
+ logicalMonitors,
+ this._mutterProperties(profile[ProfileIndex.PROPERTIES]),
+ ]);
+ await this._proxy.call('ApplyMonitorsConfig', parameters,
+ Gio.DBusCallFlags.NONE, -1, null);
+ }
+
+ _compatibleMode(monitor, savedModeId) {
+ const exactMode = monitor.modes.find(mode => mode.id === savedModeId);
+ if (exactMode)
+ return exactMode;
+
+ const match = /^(\d+)x(\d+)@([\d.]+)$/.exec(savedModeId);
+ if (!match)
+ return null;
+ const width = Number(match[1]);
+ const height = Number(match[2]);
+ const refreshRate = Number(match[3]);
+ const compatibleModes = monitor.modes.filter(mode =>
+ mode.width === width && mode.height === height);
+ compatibleModes.sort((first, second) =>
+ Math.abs(first.refreshRate - refreshRate) - Math.abs(second.refreshRate - refreshRate));
+ return compatibleModes[0] ?? null;
+ }
+
+ _mutterProperties(properties) {
+ const mutterProperties = {...properties};
+ delete mutterProperties[TEXT_SCALING_FACTOR_PROPERTY];
+ return mutterProperties;
+ }
+});
nihil fit ex nihilo