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