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, }); } });