diff options
| author | Raul Benencia <raul@thousandeyes.com> | 2026-08-12 13:36:16 -0700 |
|---|---|---|
| committer | Raul Benencia <raul@thousandeyes.com> | 2026-08-12 13:36:16 -0700 |
| commit | d38a24bf072b8407b21042214b4c36fd59149af7 (patch) | |
| tree | 74a197ce036a04397faa3b0ebba6b93ba3e17936 /.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js | |
| parent | bba6b39dfcd7b76807c58afeab85c518dd19d67c (diff) | |
gnome: display-profile-switcher extension
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.js | 501 |
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; + } +}); |
