aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRaul Benencia <raul@thousandeyes.com>2026-08-12 13:36:16 -0700
committerRaul Benencia <raul@thousandeyes.com>2026-08-12 13:36:16 -0700
commitd38a24bf072b8407b21042214b4c36fd59149af7 (patch)
tree74a197ce036a04397faa3b0ebba6b93ba3e17936
parentbba6b39dfcd7b76807c58afeab85c518dd19d67c (diff)
gnome: display-profile-switcher extension
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/capture-dialog.js273
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/display-config.js501
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/extension.js256
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/metadata.json11
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/prefs.js157
-rw-r--r--.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/schemas/org.gnome.shell.extensions.display-profile-switcher.gschema.xml21
6 files changed, 1219 insertions, 0 deletions
diff --git a/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/capture-dialog.js b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/capture-dialog.js
new file mode 100644
index 0000000..b0fed1b
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/capture-dialog.js
@@ -0,0 +1,273 @@
+import Clutter from 'gi://Clutter';
+import GObject from 'gi://GObject';
+import Pango from 'gi://Pango';
+import St from 'gi://St';
+
+import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+
+import {SAVED_CONNECTOR_PROPERTY, escapeRegex} from './display-config.js';
+
+export const CaptureDialog = GObject.registerClass(
+class CaptureDialog extends ModalDialog.ModalDialog {
+ _init(profileNames, monitors, scaleGroups, logicalDisplayCount, textScalingFactor, onResponse) {
+ super._init({destroyOnClose: false});
+ this._onResponse = onResponse;
+
+ const box = new St.BoxLayout({
+ vertical: true,
+ style_class: 'prompt-dialog-main-layout',
+ style: 'width: 38em; spacing: 18px;',
+ });
+ box.add_child(this._wrappedLabel(
+ 'Save Display Profile', 'headline'));
+ box.add_child(this._wrappedLabel(
+ 'Choose how connected displays are matched, then save a new profile or replace an existing one.',
+ 'description'));
+
+ const matchingSection = this._section('Display Matching');
+ matchingSection.add_child(this._wrappedLabel(
+ 'Enabled fields must match. Each value is an editable regular expression.',
+ 'description'));
+ this._monitorFields = [];
+ this._monitorConnectors = [];
+ monitors.forEach((monitor, index) => this._addMonitorFields(matchingSection, monitor, index));
+ box.add_child(matchingSection);
+
+ const settingsSection = this._section('Display Settings');
+ settingsSection.add_child(this._wrappedLabel(
+ scaleGroups.length === 1 && scaleGroups[0].logicalMonitorIndexes.length > 1
+ ? 'This session requires one display scale for all connected displays.'
+ : 'Display scale controls the size of everything on an individual display.',
+ 'description'));
+ this._logicalDisplayCount = logicalDisplayCount;
+ this._scaleStates = [];
+ const scaleRows = new St.BoxLayout({
+ vertical: true,
+ style_class: 'popup-menu-content',
+ style: 'spacing: 2px;',
+ });
+ scaleGroups.forEach(display => this._addDisplayScale(scaleRows, display));
+ settingsSection.add_child(scaleRows);
+ box.add_child(settingsSection);
+
+ const profileSection = this._section('Profile');
+ const profileColumns = new St.BoxLayout({x_expand: true, style: 'spacing: 18px;'});
+ const newProfileColumn = new St.BoxLayout({vertical: true, x_expand: true, style: 'spacing: 8px;'});
+ newProfileColumn.add_child(this._wrappedLabel('New Profile', 'headline'));
+ this._nameEntry = new St.Entry({
+ text: `Profile ${profileNames.length + 1}`,
+ hint_text: 'Profile Name',
+ can_focus: true,
+ x_expand: true,
+ });
+ this._newProfileName = this._nameEntry.get_text();
+ this._nameEntry.clutter_text.connect('text-changed', () => {
+ if (this._selectedProfileName === null)
+ this._newProfileName = this._nameEntry.get_text();
+ });
+ this._nameEntry.clutter_text.connect('activate', () => this._respond());
+ newProfileColumn.add_child(this._nameEntry);
+ newProfileColumn.add_child(this._wrappedLabel('Interface Text Scale', 'description'));
+ this._textScaleEntry = new St.Entry({
+ text: textScalingFactor.toFixed(2),
+ hint_text: '1.00',
+ can_focus: true,
+ x_expand: true,
+ });
+ newProfileColumn.add_child(this._textScaleEntry);
+ profileColumns.add_child(newProfileColumn);
+
+ const overwriteColumn = new St.BoxLayout({vertical: true, x_expand: true, style: 'spacing: 8px;'});
+ overwriteColumn.add_child(this._wrappedLabel('Or Overwrite Existing Profile', 'headline'));
+ this._selectedProfileName = null;
+ this._profileItems = new Map();
+ const profileChoices = new St.BoxLayout({
+ vertical: true,
+ style_class: 'popup-menu-content',
+ style: 'spacing: 2px;',
+ });
+ if (profileNames.length) {
+ this._addProfileChoice(profileChoices, null, 'Do Not Overwrite');
+ profileNames.forEach(profileName =>
+ this._addProfileChoice(profileChoices, profileName, profileName));
+ } else {
+ profileChoices.add_child(new PopupMenu.PopupMenuItem(
+ 'No Profiles Available', {reactive: false}));
+ }
+ overwriteColumn.add_child(profileChoices);
+ profileColumns.add_child(overwriteColumn);
+ profileSection.add_child(profileColumns);
+ box.add_child(profileSection);
+ this.contentLayout.add_child(box);
+
+ this.setButtons([
+ {
+ label: 'Cancel',
+ action: () => this.close(),
+ key: Clutter.KEY_Escape,
+ },
+ {
+ label: 'Save',
+ default: true,
+ action: () => this._respond(),
+ },
+ ]);
+
+ this.connect('opened', () => this._nameEntry.grab_key_focus());
+ }
+
+ _wrappedLabel(text, styleClass) {
+ const label = new St.Label({text, style_class: styleClass, x_expand: true});
+ label.clutter_text.set_line_wrap(true);
+ label.clutter_text.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR);
+ label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ return label;
+ }
+
+ _section(title) {
+ const section = new St.BoxLayout({vertical: true, style: 'spacing: 8px;'});
+ section.add_child(this._wrappedLabel(title, 'headline'));
+ return section;
+ }
+
+ _addMonitorFields(box, monitor, index) {
+ const fields = new Map();
+ this._monitorFields.push(fields);
+ this._monitorConnectors.push(monitor.connector);
+ box.add_child(this._wrappedLabel(
+ `Display ${index + 1} — ${monitor.vendor} ${monitor.product} (${monitor.connector})`,
+ 'headline'));
+ const rows = new St.BoxLayout({
+ vertical: true,
+ style_class: 'popup-menu-content',
+ style: 'spacing: 2px;',
+ });
+ this._addField(rows, fields, 'connector', 'Connector / Port', monitor.connector, true);
+ this._addField(rows, fields, 'vendor', 'Vendor', monitor.vendor, true);
+ this._addField(rows, fields, 'product', 'Model', monitor.product, true);
+ this._addField(rows, fields, 'serial', 'Serial Number', monitor.serial, false);
+ box.add_child(rows);
+ }
+
+ _addField(box, fields, field, label, value, enabled) {
+ const row = new St.BoxLayout({x_expand: true, style: 'spacing: 12px;'});
+ const item = new PopupMenu.PopupSwitchMenuItem(label, enabled);
+ item.x_expand = false;
+ item.style = 'width: 12em;';
+ const entry = new St.Entry({
+ text: escapeRegex(value),
+ can_focus: true,
+ x_expand: true,
+ hint_text: 'Regular expression',
+ });
+ const setEnabled = isEnabled => {
+ fields.get(field).enabled = isEnabled;
+ entry.reactive = isEnabled;
+ entry.can_focus = isEnabled;
+ if (isEnabled)
+ entry.remove_style_pseudo_class('insensitive');
+ else
+ entry.add_style_pseudo_class('insensitive');
+ };
+ item.connect('toggled', (_item, isEnabled) => setEnabled(isEnabled));
+ fields.set(field, {enabled, entry});
+ setEnabled(enabled);
+ row.add_child(item);
+ row.add_child(entry);
+ box.add_child(row);
+ }
+
+ _addDisplayScale(box, display) {
+ const row = new St.BoxLayout({
+ x_expand: true,
+ style: 'spacing: 12px;',
+ });
+ const label = new St.Label({
+ text: `${display.name}${display.primary ? ' — Primary' : ''}`,
+ x_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ const state = {value: display.scale};
+ const choices = new St.BoxLayout({style: 'spacing: 4px;'});
+ const buttons = [];
+ display.supportedScales.forEach(scale => {
+ const button = new St.Button({
+ label: this._formatScale(scale),
+ style_class: 'button',
+ toggle_mode: true,
+ checked: Math.abs(scale - display.scale) < 0.001,
+ can_focus: true,
+ });
+ button.connect('clicked', () => {
+ state.value = scale;
+ for (const candidate of buttons)
+ candidate.checked = candidate === button;
+ });
+ buttons.push(button);
+ choices.add_child(button);
+ });
+ state.logicalMonitorIndexes = display.logicalMonitorIndexes;
+ this._scaleStates.push(state);
+ row.add_child(label);
+ row.add_child(choices);
+ box.add_child(row);
+ }
+
+ _formatScale(scale) {
+ return `${Math.round(scale * 100)}%`;
+ }
+
+ _addProfileChoice(box, profileName, label) {
+ const item = new PopupMenu.PopupMenuItem(label);
+ item.connect('activate', () => this._selectProfile(profileName));
+ item.setOrnament(profileName === null ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
+ this._profileItems.set(profileName, item);
+ box.add_child(item);
+ }
+
+ _selectProfile(profileName) {
+ const previousProfileName = this._selectedProfileName;
+ this._selectedProfileName = profileName;
+ for (const [name, item] of this._profileItems)
+ item.setOrnament(name === profileName ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
+
+ if (profileName === null) {
+ if (previousProfileName !== null)
+ this._nameEntry.set_text(this._newProfileName);
+ this._nameEntry.reactive = true;
+ this._nameEntry.can_focus = true;
+ this._nameEntry.remove_style_pseudo_class('insensitive');
+ this._nameEntry.grab_key_focus();
+ return;
+ }
+
+ if (previousProfileName === null)
+ this._newProfileName = this._nameEntry.get_text();
+ this._nameEntry.set_text(profileName);
+ this._nameEntry.reactive = false;
+ this._nameEntry.can_focus = false;
+ this._nameEntry.add_style_pseudo_class('insensitive');
+ }
+
+ _respond() {
+ const rules = this._monitorFields.map((fields, index) => ({
+ ...Object.fromEntries([...fields].filter(([, state]) => state.enabled)
+ .map(([field, state]) => [field, state.entry.get_text().trim()])),
+ [SAVED_CONNECTOR_PROPERTY]: this._monitorConnectors[index],
+ }));
+ const displayScales = Array(this._logicalDisplayCount);
+ for (const state of this._scaleStates) {
+ for (const index of state.logicalMonitorIndexes)
+ displayScales[index] = state.value;
+ }
+ this.close();
+ this._onResponse({
+ rules,
+ name: this._selectedProfileName ?? this._nameEntry.get_text().trim(),
+ overwrite: this._selectedProfileName !== null,
+ textScalingFactor: Number(this._textScaleEntry.get_text()),
+ displayScales,
+ });
+ }
+});
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;
+ }
+});
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;
+ }
+}
diff --git a/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/metadata.json
new file mode 100644
index 0000000..73417da
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/metadata.json
@@ -0,0 +1,11 @@
+{
+ "uuid": "display-profile-switcher@rbenencia.name",
+ "extension-id": "display-profile-switcher",
+ "name": "Display Profile Switcher",
+ "description": "Save display layouts and select them using regular-expression monitor rules.",
+ "settings-schema": "org.gnome.shell.extensions.display-profile-switcher",
+ "shell-version": ["46", "47", "48", "49"],
+ "session-modes": ["user"],
+ "url": "https://rbenencia.name",
+ "version": 2
+}
diff --git a/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/prefs.js b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/prefs.js
new file mode 100644
index 0000000..3f44f55
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/prefs.js
@@ -0,0 +1,157 @@
+import Adw from 'gi://Adw';
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import Gtk from 'gi://Gtk';
+
+import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+
+import {
+ PROFILE_VARIANT_TYPE,
+ ProfileIndex,
+ getTextScalingFactor,
+ setTextScalingFactor,
+} from './display-config.js';
+
+const RULE_FIELDS = [
+ ['connector', 'Connector'],
+ ['vendor', 'Vendor'],
+ ['product', 'Product'],
+ ['serial', 'Serial'],
+];
+
+export default class DisplayProfileSwitcherPreferences extends ExtensionPreferences {
+ fillPreferencesWindow(window) {
+ this._settings = this.getSettings();
+ this._profiles = this._settings.get_value('profiles').deepUnpack();
+
+ const page = new Adw.PreferencesPage({title: 'Display Profiles', icon_name: 'video-display-symbolic'});
+ const behavior = new Adw.PreferencesGroup({title: 'Behavior'});
+ const autoApply = new Adw.SwitchRow({
+ title: 'Apply automatically',
+ subtitle: 'Apply the first matching profile shortly after displays change.',
+ });
+ this._settings.bind('automatically-apply', autoApply, 'active', Gio.SettingsBindFlags.DEFAULT);
+ behavior.add(autoApply);
+ page.add(behavior);
+
+ this._profilesGroup = new Adw.PreferencesGroup({
+ title: 'Saved profiles',
+ description: 'Rules are case-insensitive JavaScript regular expressions. Every rule must match a connected monitor.',
+ });
+ this._profileRows = [];
+ page.add(this._profilesGroup);
+ window.add(page);
+ this._rebuildProfiles();
+
+ window.connect('close-request', () => {
+ this._settings = null;
+ this._profiles = null;
+ this._profilesGroup = null;
+ this._profileRows = null;
+ });
+ }
+
+ _rebuildProfiles() {
+ for (const row of this._profileRows)
+ this._profilesGroup.remove(row);
+ this._profileRows = [];
+
+ if (!this._profiles.length) {
+ const row = new Adw.ActionRow({
+ title: 'No profiles saved',
+ subtitle: 'Open the Displays quick-settings menu and choose “Save current layout as profile”.',
+ });
+ this._profilesGroup.add(row);
+ this._profileRows.push(row);
+ return;
+ }
+
+ this._profiles.forEach((profile, profileIndex) => {
+ const row = this._profileRow(profile, profileIndex);
+ this._profilesGroup.add(row);
+ this._profileRows.push(row);
+ });
+ }
+
+ _profileRow(profile, profileIndex) {
+ const row = new Adw.ExpanderRow({title: profile[ProfileIndex.NAME], expanded: false});
+ const name = new Gtk.Entry({text: profile[ProfileIndex.NAME], hexpand: true, valign: Gtk.Align.CENTER});
+ const nameRow = new Adw.ActionRow({title: 'Name'});
+ nameRow.add_suffix(name);
+ row.add_row(nameRow);
+ name.connect('changed', entry => {
+ this._profiles[profileIndex][ProfileIndex.NAME] = entry.text;
+ row.title = entry.text || 'Unnamed profile';
+ this._saveProfiles();
+ });
+
+ profile[ProfileIndex.RULES].forEach((rule, ruleIndex) => {
+ const ruleRow = new Adw.ExpanderRow({title: `Monitor rule ${ruleIndex + 1}`, expanded: false});
+ for (const [field, label] of RULE_FIELDS) {
+ const entry = new Gtk.Entry({text: rule[field] ?? '', hexpand: true, valign: Gtk.Align.CENTER});
+ const fieldRow = new Adw.ActionRow({title: label});
+ fieldRow.add_suffix(entry);
+ ruleRow.add_row(fieldRow);
+ entry.connect('changed', widget => {
+ this._profiles[profileIndex][ProfileIndex.RULES][ruleIndex][field] = widget.text;
+ this._saveProfiles();
+ });
+ }
+ row.add_row(ruleRow);
+ });
+
+ this._addSavedLayoutRows(row, profile, profileIndex);
+
+ const remove = new Gtk.Button({label: 'Remove', valign: Gtk.Align.CENTER, css_classes: ['destructive-action']});
+ remove.connect('clicked', () => {
+ this._profiles.splice(profileIndex, 1);
+ this._saveProfiles();
+ this._rebuildProfiles();
+ });
+ row.add_suffix(remove);
+ return row;
+ }
+
+ _addSavedLayoutRows(row, profile, profileIndex) {
+ const logicalMonitors = profile[ProfileIndex.LOGICAL_MONITORS];
+ const layoutRow = new Adw.ActionRow({
+ title: 'Saved display layout',
+ subtitle: `${logicalMonitors.length} logical display${logicalMonitors.length === 1 ? '' : 's'}`,
+ });
+ row.add_row(layoutRow);
+
+ logicalMonitors.forEach(([x, y, scale, transform, primary, monitors], index) => {
+ const monitorSummary = monitors.map(([connector, mode]) => `${connector}: ${mode}`).join(', ');
+ const transformName = ['Normal', '90° clockwise', 'Upside-down', '90° counter-clockwise'][transform] ?? `Transform ${transform}`;
+ row.add_row(new Adw.ActionRow({
+ title: `Display ${index + 1}${primary ? ' (primary)' : ''}`,
+ subtitle: `${monitorSummary}; ${Math.round(scale * 100)}% display scale; ${transformName}; position ${x}, ${y}`,
+ }));
+ });
+
+ const savedTextScale = getTextScalingFactor(profile);
+ const textScale = new Gtk.SpinButton({
+ adjustment: new Gtk.Adjustment({lower: 0.5, upper: 3.0, step_increment: 0.05, page_increment: 0.25}),
+ digits: 2,
+ value: savedTextScale ?? 1.0,
+ valign: Gtk.Align.CENTER,
+ });
+ const textScaleRow = new Adw.ActionRow({
+ title: 'Text scale',
+ subtitle: savedTextScale === null
+ ? 'Not captured by this older profile. Set a value to apply it with the layout.'
+ : 'Applied with this display profile.',
+ });
+ textScaleRow.add_suffix(textScale);
+ row.add_row(textScaleRow);
+ textScale.connect('value-changed', widget => {
+ setTextScalingFactor(this._profiles[profileIndex], widget.get_value());
+ textScaleRow.subtitle = 'Applied with this display profile.';
+ this._saveProfiles();
+ });
+ }
+
+ _saveProfiles() {
+ this._settings.set_value('profiles', new GLib.Variant(PROFILE_VARIANT_TYPE, this._profiles));
+ }
+}
diff --git a/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/schemas/org.gnome.shell.extensions.display-profile-switcher.gschema.xml b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/schemas/org.gnome.shell.extensions.display-profile-switcher.gschema.xml
new file mode 100644
index 0000000..ffac288
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/display-profile-switcher@rbenencia.name/schemas/org.gnome.shell.extensions.display-profile-switcher.gschema.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<schemalist>
+ <schema id="org.gnome.shell.extensions.display-profile-switcher"
+ path="/org/gnome/shell/extensions/display-profile-switcher/">
+ <!-- profile: name, monitor regex rules, Mutter logical monitors, Mutter properties -->
+ <key name="profiles" type="a(saa{ss}a(iiduba(ssa{sv}))a{sv})">
+ <default>[]</default>
+ <summary>Saved display profiles</summary>
+ <description>
+ Each profile has a name, one or more monitor rules, and a display
+ layout captured from org.gnome.Mutter.DisplayConfig. Monitor-rule keys
+ are connector, vendor, product, and serial; their values are regular
+ expressions matched against connected monitors.
+ </description>
+ </key>
+ <key name="automatically-apply" type="b">
+ <default>false</default>
+ <summary>Apply the first matching profile after monitor changes</summary>
+ </key>
+ </schema>
+</schemalist>
nihil fit ex nihilo