From d38a24bf072b8407b21042214b4c36fd59149af7 Mon Sep 17 00:00:00 2001 From: Raul Benencia Date: Wed, 12 Aug 2026 13:36:16 -0700 Subject: gnome: display-profile-switcher extension --- .../extension.js | 256 +++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 .local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js (limited to '.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js') diff --git a/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js new file mode 100644 index 0000000..a3f1b49 --- /dev/null +++ b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js @@ -0,0 +1,256 @@ +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; + } +} -- cgit v1.2.3