import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js'; import {CaptureDialog} from './capture-dialog.js'; import { DisplayConfig, ProfileIndex, PROFILE_VARIANT_TYPE, applyScalesToLayout, getTextScalingFactor, profileLayoutMatches, profileMatches, setTextScalingFactor, } from './display-config.js'; const AUTO_APPLY_DELAY_MS = 1000; function monitorSetSignature(monitors) { return JSON.stringify(monitors.map(({connector, vendor, product, serial}) => [connector, vendor, product, serial]).sort()); } const DisplayProfileToggle = GObject.registerClass( class DisplayProfileToggle extends QuickSettings.QuickMenuToggle { _init(extension) { super._init({title: 'Displays', iconName: 'video-display-symbolic', toggleMode: false}); this.menu.setHeader('video-display-symbolic', 'Display Profiles'); this._extension = extension; this._settings = extension.getSettings(); this._interfaceSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}); this._displayConfig = new DisplayConfig(); this._autoApplySourceId = 0; this._captureDialog = null; this._autoAppliedMonitorSet = null; this._applying = false; this._settingsChangedId = this._settings.connect('changed', () => this._rebuildMenu()); this._textScalingChangedId = this._interfaceSettings.connect( 'changed::text-scaling-factor', () => this._rebuildMenu()); this._displayConfig.connectObject('state-changed', () => this._onStateChanged(), this); this.connect('clicked', () => this._applyNext()); this._rebuildMenu(); } destroy() { if (this._autoApplySourceId) GLib.source_remove(this._autoApplySourceId); this._captureDialog?.destroy(); this._captureDialog = null; this._displayConfig.disconnectObject(this); this._displayConfig.destroy(); this._settings.disconnect(this._settingsChangedId); this._interfaceSettings.disconnect(this._textScalingChangedId); this._interfaceSettings = null; super.destroy(); } _profiles() { return this._settings.get_value('profiles').deepUnpack(); } _matchingProfiles() { return this._profiles().filter(profile => profileMatches(profile, this._displayConfig.monitors)); } _activeProfile(profiles) { const snapshot = this._displayConfig.snapshot(); return profiles.find(profile => { try { const adaptedProfile = this._displayConfig.adaptProfile(profile); return adaptedProfile !== null && profileLayoutMatches( adaptedProfile, snapshot, this._interfaceSettings.get_double('text-scaling-factor')); } catch (error) { logError(error, `Unable to inspect display profile ${profile[ProfileIndex.NAME]}`); return false; } }); } _onStateChanged() { this._rebuildMenu(); if (!this._settings.get_boolean('automatically-apply')) { if (this._autoApplySourceId) GLib.source_remove(this._autoApplySourceId); this._autoApplySourceId = 0; return; } const monitorSet = monitorSetSignature(this._displayConfig.monitors); if (monitorSet === this._autoAppliedMonitorSet) return; if (this._autoApplySourceId) GLib.source_remove(this._autoApplySourceId); this._autoApplySourceId = 0; const profiles = this._matchingProfiles(); if (this._activeProfile(profiles)) { this._autoAppliedMonitorSet = monitorSet; return; } this._autoApplySourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTO_APPLY_DELAY_MS, () => { this._autoApplySourceId = 0; const profile = this._matchingProfiles()[0]; if (profile) { this._autoAppliedMonitorSet = monitorSet; this._apply(profile); } return GLib.SOURCE_REMOVE; }); } _rebuildMenu() { this.menu.removeAll(); const profiles = this._matchingProfiles(); const activeProfile = this._activeProfile(profiles); this.checked = activeProfile !== undefined; this.subtitle = activeProfile?.[ProfileIndex.NAME] ?? (profiles.length ? `${profiles.length} matching` : 'No match'); for (const profile of profiles) { const item = new PopupMenu.PopupMenuItem(profile[ProfileIndex.NAME]); item.connect('activate', () => this._apply(profile)); if (profile === activeProfile) item.setOrnament(PopupMenu.Ornament.CHECK); this.menu.addMenuItem(item); } if (!profiles.length) this.menu.addMenuItem(new PopupMenu.PopupMenuItem('No saved profile matches these displays.', {reactive: false})); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); const save = new PopupMenu.PopupImageMenuItem('Save current layout as profile', 'document-save-symbolic'); save.connect('activate', () => this._promptToSaveCurrentLayout()); this.menu.addMenuItem(save); const preferences = new PopupMenu.PopupImageMenuItem('Profile preferences', 'emblem-system-symbolic'); preferences.connect('activate', () => this._extension.openPreferences()); this.menu.addMenuItem(preferences); } _applyNext() { const profiles = this._matchingProfiles(); if (profiles.length) this._apply(profiles[0]); } async _apply(profile) { if (this._applying) return; this._applying = true; try { await this._displayConfig.apply(profile); const textScalingFactor = getTextScalingFactor(profile); if (textScalingFactor !== null) this._interfaceSettings.set_double('text-scaling-factor', textScalingFactor); } catch (error) { logError(error, `Unable to apply display profile ${profile[ProfileIndex.NAME]}`); Main.notifyError('Display Profile Switcher', error.message); } finally { this._applying = false; } } _promptToSaveCurrentLayout() { if (this._captureDialog) return; if (!this._displayConfig.snapshot()) { Main.notify('Display Profile Switcher', 'Display configuration is still loading.'); return; } const profileNames = this._profiles().map(profile => profile[ProfileIndex.NAME]); this._captureDialog = new CaptureDialog( profileNames, this._displayConfig.monitors, this._displayConfig.scaleGroups, this._displayConfig.logicalDisplays.length, this._interfaceSettings.get_double('text-scaling-factor'), options => { this._captureDialog?.destroy(); this._captureDialog = null; this._saveCurrentLayout(options); }); this._captureDialog.connect('closed', () => { if (this._captureDialog?.opened) return; this._captureDialog?.destroy(); this._captureDialog = null; }); this._captureDialog.open(); } _saveCurrentLayout({rules, name, overwrite, textScalingFactor, displayScales}) { const snapshot = this._displayConfig.snapshot(); if (!snapshot) { Main.notify('Display Profile Switcher', 'Display configuration is still loading.'); return; } if (!name) { Main.notifyError('Display Profile Switcher', 'A profile name is required.'); return; } if (!Number.isFinite(textScalingFactor) || textScalingFactor <= 0) { Main.notifyError('Display Profile Switcher', 'Text scale must be a positive number.'); return; } if (rules.some(rule => !Object.keys(rule).some(field => !field.startsWith('__')))) { Main.notifyError('Display Profile Switcher', 'Choose at least one matching field for every display.'); return; } if (displayScales.length !== snapshot.logicalMonitors.length || displayScales.some(scale => !Number.isFinite(scale) || scale <= 0)) { Main.notifyError('Display Profile Switcher', 'Choose a valid scale for every display.'); return; } applyScalesToLayout( snapshot.logicalMonitors, displayScales, this._displayConfig.globalScaleRequired, this._displayConfig.logicalDisplays, this._displayConfig.layoutMode); const profile = [name, rules, snapshot.logicalMonitors, snapshot.properties]; setTextScalingFactor(profile, textScalingFactor); const profiles = this._profiles(); const existingIndex = profiles.findIndex(candidate => candidate[ProfileIndex.NAME] === name); if (overwrite && existingIndex === -1) { Main.notifyError('Display Profile Switcher', `No existing profile is named “${name}”.`); return; } if (overwrite) profiles[existingIndex] = profile; else profiles.push(profile); this._settings.set_value('profiles', new GLib.Variant(PROFILE_VARIANT_TYPE, profiles)); this._apply(profile); } }); export default class DisplayProfileSwitcherExtension extends Extension { enable() { this._indicator = new QuickSettings.SystemIndicator(); this._toggle = new DisplayProfileToggle(this); this._indicator.quickSettingsItems.push(this._toggle); Main.panel.statusArea.quickSettings.addExternalIndicator(this._indicator); } disable() { this._toggle?.destroy(); this._indicator?.destroy(); this._toggle = null; this._indicator = null; } }