aboutsummaryrefslogtreecommitdiff
path: root/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js
diff options
context:
space:
mode:
Diffstat (limited to '.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js')
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js256
1 files changed, 256 insertions, 0 deletions
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;
+ }
+}
nihil fit ex nihilo