diff options
Diffstat (limited to '.local/share')
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> |
