diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index dcca3c07d..513ccecd2 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -22,6 +22,7 @@ import qs.Modules.ProcessList import qs.Modules.DankBar import qs.Modules.DankBar.Popouts import qs.Modules.WorkspaceOverlays +import qs.Modules.Settings.DisplayConfig import qs.Services Item { @@ -271,6 +272,8 @@ Item { dockRecreateDebounce.start(); // Force PolkitService singleton to initialize PolkitService.polkitAvailable; + // Force DisplayConfigState singleton to initialize so auto-config runs at startup + DisplayConfigState.hasOutputBackend; loginSoundTimer.start(); } diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index c0258a98e..b44092939 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1643,7 +1643,7 @@ Item { if (id === matchedId) flags.push("matched"); const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : ""; - lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet)); + lines.push(p.name + flagStr + " -> " + JSON.stringify(Object.keys(p.outputs))); } if (lines.length === 0) @@ -1675,13 +1675,16 @@ Item { return `PROFILE_SET_SUCCESS: ${profileName}`; } - // ! TODO - auto profile switching is buggy on niri and other compositors function toggleAuto(): string { - return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs"; + SettingsData.displayProfileAutoSelect = !SettingsData.displayProfileAutoSelect; + SettingsData.saveSettings(); + if (SettingsData.displayProfileAutoSelect) + DisplayConfigState.autoSelectProfile(); + return `Auto profile selection: ${SettingsData.displayProfileAutoSelect ? "enabled" : "disabled"}`; } function status(): string { - const auto = "off"; // disabled for now + const auto = SettingsData.displayProfileAutoSelect ? "on" : "off"; const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor); const matchedId = DisplayConfigState.matchedProfile; const profiles = DisplayConfigState.validatedProfiles; diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index b6e253ba0..8a5223945 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -41,6 +41,12 @@ Singleton { property bool profilesLoading: false property var validatedProfiles: ({}) property bool manualActivation: false + property bool profilesReady: false + property var monitorsCache: null + // Last config entry that was applied (set by applyConfigEntry / confirmChanges). + // Used to recover position, scale, and transform for disabled outputs that wlr + // no longer reports a logical viewport for. + property var lastAppliedEntry: null signal changesApplied(var changeDescriptions) signal changesConfirmed @@ -70,224 +76,535 @@ Singleton { return outputName; } - function validateProfiles() { - const compositor = CompositorService.compositor; - const profiles = SettingsData.getDisplayProfiles(compositor); - const profilesDir = getProfilesDir(); - const ext = getProfileExtension(); - - if (!profilesDir) { - validatedProfiles = {}; + function readMonitorsJson(callback) { + if (monitorsCache !== null) { + callback(monitorsCache); return; } - - const profileIds = Object.keys(profiles); - if (profileIds.length === 0) { - validatedProfiles = {}; - return; - } - - const fileChecks = profileIds.map(id => profilesDir + "/" + id + ext).join(" "); - Proc.runCommand("validate-profiles", ["sh", "-c", `for f in ${fileChecks}; do [ -f "$f" ] && echo "$f"; done`], (output, exitCode) => { - const existingFiles = new Set(output.trim().split("\n").filter(f => f)); - const validated = {}; - for (const profileId of profileIds) { - const profileFile = profilesDir + "/" + profileId + ext; - if (existingFiles.has(profileFile)) - validated[profileId] = profiles[profileId]; - else - SettingsData.removeDisplayProfile(compositor, profileId); + Proc.runCommand("read-monitors-json", ["cat", Paths.strip(Paths.config) + "/monitors.json"], (content, exitCode) => { + if (exitCode !== 0 || !content.trim()) { + monitorsCache = {"version": 1, "configurations": []}; + callback(monitorsCache); + return; } - validatedProfiles = validated; - matchedProfile = findMatchingProfile(); + try { + monitorsCache = JSON.parse(content); + if (!Array.isArray(monitorsCache.configurations)) + monitorsCache.configurations = []; + } catch (e) { + console.warn("Failed to parse monitors.json, using empty config", e); + monitorsCache = {"version": 1, "configurations": []}; + } + callback(monitorsCache); }); } - function findMatchingProfile() { - const profiles = validatedProfiles; + function writeMonitorsJson(data, callback) { + const path = Paths.strip(Paths.config) + "/monitors.json"; + const dir = path.substring(0, path.lastIndexOf("/")); + const jsonContent = JSON.stringify(data, null, 2); + monitorsCache = data; + Proc.runCommand("write-monitors-json-dir", ["mkdir", "-p", dir], (output, exitCode) => { + if (exitCode !== 0) { + callback && callback(false); + return; + } + // Use python3 to write the file safely (avoids heredoc quoting issues) + Proc.runCommand("write-monitors-json", ["python3", "-c", + `import sys; open(sys.argv[1],'w').write(sys.argv[2])`, path, jsonContent], + (output2, exitCode2) => { + callback && callback(exitCode2 === 0); + }); + }); + } - console.log("[Profile Match] Current outputs:", JSON.stringify(currentOutputSet)); + // Find entry in data.configurations array whose output keys match the given output identifiers. + // Normalizes both sides to the current displayNameMode to handle cross-format configs + // (e.g. entries saved in connector mode while current mode is model, or vice versa). + function findConfigEntry(data, outputIdentifiers) { + const targetKey = outputIdentifiers.sort().join("+"); + const configs = data.configurations || []; + for (let i = 0; i < configs.length; i++) { + const entryKey = Object.keys(configs[i].outputs || {}).sort().join("+"); + if (entryKey === targetKey) + return {entry: configs[i], index: i}; + } + return null; + } + + // Find entry by exact virtualId (sorted output identifier keys joined by "+") + function findConfigEntryByKey(data, virtualId) { + const configs = data.configurations || []; + for (let i = 0; i < configs.length; i++) { + const entryKey = Object.keys(configs[i].outputs || {}).sort().join("+"); + if (entryKey === virtualId) + return {entry: configs[i], index: i}; + } + return null; + } + + // Find the config entry whose outputs are the largest subset of outputIdentifiers. + // All outputs in the entry must be present in outputIdentifiers (no extra outputs). + function findPartialConfigEntry(data, outputIdentifiers) { + const currentSet = new Set(outputIdentifiers); + const configs = data.configurations || []; + let bestEntry = null; + let bestCount = 0; + for (let i = 0; i < configs.length; i++) { + const cfgKeys = Object.keys(configs[i].outputs || {}); + if (cfgKeys.length === 0) + continue; + // All config outputs must be present in the current output set + if (!cfgKeys.every(k => currentSet.has(k))) + continue; + if (cfgKeys.length > bestCount) { + bestCount = cfgKeys.length; + bestEntry = {entry: configs[i], index: i}; + } + } + return bestEntry; + } - let bestMatch = ""; - let bestScore = -1; - let bestUpdatedAt = 0; + // Returns {rawName: bool} for all known monitors — true if included in profileId + function getProfileMonitorInclusion(profileId) { + const profile = validatedProfiles[profileId]; + const profileOutputIds = new Set(Object.keys(profile?.outputs || {})); + const result = {}; + for (const rawName in allOutputs) { + const od = allOutputs[rawName]; + const id = od ? getOutputIdentifier(od, rawName) : rawName; + result[rawName] = profileOutputIds.has(id); + } + return result; + } - for (const profileId in profiles) { - const profile = profiles[profileId]; - const profileSet = new Set(profile.outputSet); + // Update which monitors are part of a named profile + function updateProfileMonitors(profileId, enabledRawNames) { + readMonitorsJson(data => { + const match = findConfigEntryByKey(data, profileId); + if (!match) { + profileError(I18n.tr("Profile not found")); + return; + } + const profileName = match.entry.name; + const existingOutputs = match.entry.outputs || {}; + const mergedAll = buildOutputsWithPendingChanges(); + const niriSettings = buildMergedNiriSettings(); + const hyprlandSettings = buildMergedHyprlandSettings(); + const newOutputConfigs = {}; + for (const rawName of enabledRawNames) { + const od = mergedAll[rawName] || allOutputs[rawName]; + if (!od) + continue; + const outputId = getOutputIdentifier(od, rawName); + newOutputConfigs[outputId] = existingOutputs[outputId] + || extractOutputNeutralConfig(rawName, od, niriSettings, hyprlandSettings); + } + const newVirtualId = Object.keys(newOutputConfigs).sort().join("+"); + data.configurations[match.index] = {"name": profileName, "outputs": newOutputConfigs}; + writeMonitorsJson(data, success => { + if (!success) return; + const updated = JSON.parse(JSON.stringify(validatedProfiles)); + delete updated[profileId]; + if (newVirtualId) + updated[newVirtualId] = {name: profileName, outputs: newOutputConfigs}; + validatedProfiles = updated; + if (matchedProfile === profileId) + matchedProfile = newVirtualId; + profileSaved(newVirtualId, profileName); + }); + }); + } - console.log("[Profile Match] Checking", profile.name, "outputSet:", JSON.stringify(profile.outputSet)); + // Extract neutral per-output config from current live state + function extractOutputNeutralConfig(outputName, outputData, niriSettings, hyprlandSettings) { + const modeData = (outputData.modes && outputData.current_mode !== undefined) + ? outputData.modes[outputData.current_mode] : null; + const modeStr = modeData + ? modeData.width + "x" + modeData.height + "@" + (modeData.refresh_rate / 1000).toFixed(3) + : null; + const cfg = { + "mode": modeStr, + "position": {"x": outputData.logical?.x ?? 0, "y": outputData.logical?.y ?? 0}, + "scale": outputData.logical?.scale || 1.0, + "transform": outputData.logical?.transform ?? "Normal", + "vrr": outputData.vrr_enabled ?? false, + "disabled": false + }; + if (CompositorService.isNiri) { + cfg.niri = Object.assign({}, niriSettings?.[getNiriOutputIdentifier(outputData, outputName)] || {}); + if (cfg.niri.disabled) { + delete cfg.niri.disabled; + cfg.disabled = true; + } + } + if (CompositorService.isHyprland) { + cfg.hyprland = Object.assign({}, hyprlandSettings?.[getHyprlandOutputIdentifier(outputData, outputName)] || {}); + if (outputData.mirror) + cfg.hyprland.mirror = outputData.mirror; + if (cfg.hyprland.disabled) { + delete cfg.hyprland.disabled; + cfg.disabled = true; + } + } + return cfg; + } - let allCurrentPresent = true; - for (const output of currentOutputSet) { - if (!profileSet.has(output)) { - console.log("[Profile Match] - Missing output:", output); - allCurrentPresent = false; + // Convert monitors.json config entry → internal outputsData map + function generateOutputsDataFromConfig(configEntry) { + const result = {}; + const cfgOutputs = configEntry.outputs || {}; + for (const outputId in cfgOutputs) { + const cfg = cfgOutputs[outputId]; + // Find matching live output to get modes list + let liveOutput = null; + for (const name in outputs) { + if (getOutputIdentifier(outputs[name], name) === outputId || name === outputId) { + liveOutput = outputs[name]; break; } } - if (!allCurrentPresent) { - console.log("[Profile Match] - SKIP: not all current outputs present"); - continue; - } - - const disconnectedCount = profile.outputSet.length - currentOutputSet.length; - const score = currentOutputSet.length * 100 - disconnectedCount; - const updatedAt = profile.updatedAt || profile.createdAt || 0; - console.log("[Profile Match] - MATCH score:", score, "(disconnected:", disconnectedCount, "updatedAt:", updatedAt + ")"); + const liveModes = liveOutput?.modes || []; + let currentMode = liveModes.findIndex(m => { + const s = m.width + "x" + m.height + "@" + (m.refresh_rate / 1000).toFixed(3); + return s === cfg.mode; + }); + if (currentMode < 0 && liveModes.length > 0) + currentMode = 0; + const entry = { + "name": outputId, + "make": liveOutput?.make || "", + "model": liveOutput?.model || "", + "serial": liveOutput?.serial || "", + "modes": liveModes, + "current_mode": currentMode, + "vrr_supported": liveOutput?.vrr_supported ?? false, + "vrr_enabled": cfg.vrr ?? false, + "logical": { + "x": cfg.position?.x ?? 0, + "y": cfg.position?.y ?? 0, + "scale": cfg.scale ?? 1.0, + "transform": cfg.transform ?? "Normal" + } + }; + if (cfg.hyprland?.mirror) + entry.mirror = cfg.hyprland.mirror; + result[outputId] = entry; + } + return result; + } - if (score > bestScore || (score === bestScore && updatedAt > bestUpdatedAt)) { - bestScore = score; - bestMatch = profileId; - bestUpdatedAt = updatedAt; - } + // Extract niri settings map from neutral config entry for generateNiriOutputsKdl + function getNiriSettingsFromConfig(configEntry) { + const result = {}; + for (const outputId in (configEntry.outputs || {})) { + const cfg = configEntry.outputs[outputId]; + const settings = Object.assign({}, cfg.niri || {}); + if (cfg.disabled) + settings.disabled = true; + if (Object.keys(settings).length > 0) + result[outputId] = settings; } - console.log("[Profile Match] Best match:", bestMatch, "score:", bestScore); - return bestMatch; + return result; } - function getProfilesDir() { - const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); - switch (CompositorService.compositor) { - case "niri": - return configDir + "/niri/dms/profiles"; - case "hyprland": - return configDir + "/hypr/dms/profiles"; - case "dwl": - return configDir + "/mango/dms/profiles"; - default: - return ""; + // Extract hyprland settings map from neutral config entry + function getHyprlandSettingsFromConfig(configEntry) { + const result = {}; + for (const outputId in (configEntry.outputs || {})) { + const cfg = configEntry.outputs[outputId]; + const settings = Object.assign({}, cfg.hyprland || {}); + if (cfg.disabled) + settings.disabled = true; + if (Object.keys(settings).length > 0) + result[outputId] = settings; } + return result; } - function getProfileExtension() { - return CompositorService.compositor === "niri" ? ".kdl" : ".conf"; + // Generate hyprland conf content from internal outputsData + settings + function generateHyprConfContent(outputsData, hyprlandSettings) { + const settings = hyprlandSettings || {}; + const lines = ["# Auto-generated by DMS - do not edit manually", ""]; + const monitorv2Blocks = []; + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (!output) + continue; + const identifier = getHyprlandOutputIdentifier(output, outputName); + const outputSettings = settings[identifier] || {}; + if (outputSettings.disabled) { + lines.push("monitor = " + identifier + ", disable"); + continue; + } + let resolution = "preferred"; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) + resolution = mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3); + } + const x = output.logical?.x ?? 0; + const y = output.logical?.y ?? 0; + const scale = output.logical?.scale ?? 1.0; + let line = "monitor = " + identifier + ", " + resolution + ", " + x + "x" + y + ", " + scale; + const transform = mapTransformToWlr(output.logical?.transform ?? "Normal"); + if (transform !== 0) + line += ", transform, " + transform; + if (output.vrr_supported) { + const vrrMode = outputSettings.vrrFullscreenOnly ? 2 : (output.vrr_enabled ? 1 : 0); + line += ", vrr, " + vrrMode; + } + if (output.mirror && output.mirror.length > 0) + line += ", mirror, " + output.mirror; + if (outputSettings.bitdepth && outputSettings.bitdepth !== 8) + line += ", bitdepth, " + outputSettings.bitdepth; + if (outputSettings.colorManagement && outputSettings.colorManagement !== "auto") + line += ", cm, " + outputSettings.colorManagement; + if (outputSettings.sdrBrightness !== undefined && outputSettings.sdrBrightness !== 1.0) + line += ", sdrbrightness, " + outputSettings.sdrBrightness; + if (outputSettings.sdrSaturation !== undefined && outputSettings.sdrSaturation !== 1.0) + line += ", sdrsaturation, " + outputSettings.sdrSaturation; + lines.push(line); + if (outputSettings.supportsHdr || outputSettings.supportsWideColor) { + let block = "monitorv2 {\n"; + block += " output = " + identifier + "\n"; + if (outputSettings.supportsWideColor) + block += " supports_wide_color = true\n"; + if (outputSettings.supportsHdr) + block += " supports_hdr = true\n"; + block += "}"; + monitorv2Blocks.push(block); + } + } + if (monitorv2Blocks.length > 0) { + lines.push(""); + for (const block of monitorv2Blocks) + lines.push(block); + } + lines.push(""); + return lines.join("\n"); } - function createProfile(name) { - const compositor = CompositorService.compositor; - const profileId = "profile_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6); - const outputSet = buildCurrentOutputSet(); - const now = Date.now(); - - const profileData = { - "id": profileId, - "name": name, - "outputSet": outputSet, - "createdAt": now, - "updatedAt": now - }; + // Generate dwl/mango conf content from internal outputsData + function generateDwlConfContent(outputsData) { + const lines = ["# Auto-generated by DMS - do not edit manually", ""]; + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (!output) + continue; + let width = 1920, height = 1080, refreshRate = 60; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) { + width = mode.width || 1920; + height = mode.height || 1080; + refreshRate = Math.round((mode.refresh_rate || 60000) / 1000); + } + } + const x = output.logical?.x ?? 0; + const y = output.logical?.y ?? 0; + const scale = output.logical?.scale ?? 1.0; + const transform = mapTransformToWlr(output.logical?.transform ?? "Normal"); + const vrr = output.vrr_enabled ? 1 : 0; + lines.push("monitorrule=" + ["name:" + outputName, "width:" + width, + "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, + "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(",")); + } + lines.push(""); + return lines.join("\n"); + } + + // Mutates configEntry in place. Returns true if a fix was applied. + function ensureEnabledOutput(configEntry) { + const outputKeys = Object.keys(configEntry.outputs || {}); + if (outputKeys.length === 0) + return false; + const hasEnabled = outputKeys.some(k => !configEntry.outputs[k].disabled); + if (hasEnabled) + return false; + delete configEntry.outputs[outputKeys[0]].disabled; + return true; + } - const profilesDir = getProfilesDir(); - const profileFile = profilesDir + "/" + profileId + getProfileExtension(); + // Write compositor config from a neutral config entry and optionally reload + function applyConfigEntry(configEntry, configId, profileName, isManual) { + ensureEnabledOutput(configEntry); + // Capture the entry being applied so disabled-output settings fields can read + // scale/position/transform back even when wlr reports no logical viewport. + root.lastAppliedEntry = JSON.parse(JSON.stringify(configEntry)); + const outputsData = generateOutputsDataFromConfig(configEntry); const paths = getConfigPaths(); if (!paths) { - profileError(I18n.tr("Compositor not supported")); + if (isManual) { + profilesLoading = false; + manualActivation = false; + } return; } + let configContent = ""; + let reloadCmd = []; + if (CompositorService.isNiri) { + configContent = generateNiriOutputsKdl(outputsData, getNiriSettingsFromConfig(configEntry)); + } else if (CompositorService.isHyprland) { + configContent = generateHyprConfContent(outputsData, getHyprlandSettingsFromConfig(configEntry)); + reloadCmd = ["hyprctl", "reload"]; + } else { + configContent = generateDwlConfContent(outputsData); + reloadCmd = ["mmsg", "-d", "reload_config"]; + } + Proc.runCommand("apply-config-write", ["python3", "-c", + `import sys,os; os.makedirs(os.path.dirname(sys.argv[1]),exist_ok=True); open(sys.argv[1],'w').write(sys.argv[2])`, + paths.outputsFile, configContent], + (output, exitCode) => { + if (exitCode !== 0) { + if (isManual) { + profilesLoading = false; + manualActivation = false; + profileError(I18n.tr("Failed to apply profile")); + } + return; + } + SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId); + const finish = () => { + if (isManual) { + WlrOutputService.requestState(); + profilesLoading = false; + profileActivated(configId, profileName); + manualActivationTimer.restart(); + } else { + saveConfigEntry(configEntry); + } + }; + if (reloadCmd.length > 0) + Proc.runCommand("apply-config-reload", reloadCmd, () => finish()); + else + finish(); + }); + } + + // ── Profile management ───────────────────────────────────────────────── + + function validateProfiles() { + console.warn("Validating profiles against current outputs..."); + readMonitorsJson(data => { + const validated = {}; + let dirty = false; + for (const entry of (data.configurations || [])) { + const virtualId = Object.keys(entry.outputs || {}).sort().join("+"); + if (!virtualId) + continue; + if (ensureEnabledOutput(entry)) + dirty = true; + validated[virtualId] = {name: entry?.name ? entry.name : "", outputs: entry.outputs}; + } + if (dirty) + writeMonitorsJson(data, null); + validatedProfiles = validated; + matchedProfile = findMatchingProfile(); + if (!profilesReady) { + profilesReady = true; + applyAutoConfig(); + } + }); + } + + function findMatchingProfile() { + const currentKey = currentOutputSet.join("+"); + if (validatedProfiles[currentKey]) + return currentKey; + return ""; + } + + function createProfile(profileName) { + const outputConfigs = buildCurrentOutputConfigs(); + const virtualId = Object.keys(outputConfigs).sort().join("+"); profilesLoading = true; - Proc.runCommand("create-profile-dir", ["mkdir", "-p", profilesDir], (output, exitCode) => { - if (exitCode !== 0) { + readMonitorsJson(data => { + const match = findConfigEntry(data, currentOutputSet); + const newEntry = {"name": profileName, "outputs": outputConfigs}; + + if (match) + data.configurations[match.index] = newEntry; + else + data.configurations.push(newEntry); + + writeMonitorsJson(data, success => { profilesLoading = false; - profileError(I18n.tr("Failed to create profiles directory")); - return; - } - Proc.runCommand("copy-profile", ["cp", "-L", paths.outputsFile, profileFile], (output2, exitCode2) => { - if (exitCode2 !== 0) { - profilesLoading = false; - profileError(I18n.tr("Failed to save profile file")); + if (!success) { + profileError(I18n.tr("Failed to save profile")); return; } - SettingsData.setDisplayProfile(compositor, profileId, profileData); - SettingsData.setActiveDisplayProfile(compositor, profileId); + matchedProfile = virtualId; const updated = JSON.parse(JSON.stringify(validatedProfiles)); - updated[profileId] = profileData; + updated[virtualId] = {name: profileName, outputs: outputConfigs}; validatedProfiles = updated; - Proc.runCommand("link-new-profile", ["ln", "-sf", profileFile, paths.outputsFile], () => { - profilesLoading = false; - currentOutputSet = outputSet; - matchedProfile = profileId; - profileSaved(profileId, name); - }); + currentOutputSet = buildCurrentOutputSet(); + SettingsData.setActiveDisplayProfile(CompositorService.compositor, virtualId); + profileSaved(virtualId, profileName); }); }); } function renameProfile(profileId, newName) { - const compositor = CompositorService.compositor; - const profiles = SettingsData.getDisplayProfiles(compositor); - const profile = profiles[profileId]; - if (!profile) { - profileError(I18n.tr("Profile not found")); - return; - } - profile.name = newName; - profile.updatedAt = Date.now(); - SettingsData.setDisplayProfile(compositor, profileId, profile); + const outputSet = profileId.split("+"); + readMonitorsJson(data => { + const match = findConfigEntry(data, outputSet); + if (!match) { + profileError(I18n.tr("Profile not found")); + return; + } + match.entry.name = newName; + data.configurations[match.index] = match.entry; + writeMonitorsJson(data, success => { + if (!success) return; + const updated = JSON.parse(JSON.stringify(validatedProfiles)); + if (updated[profileId]) + updated[profileId].name = newName; + validatedProfiles = updated; + }); + }); } function deleteProfile(profileId) { const compositor = CompositorService.compositor; - const profilesDir = getProfilesDir(); - const profileFile = profilesDir + "/" + profileId + getProfileExtension(); const isActive = SettingsData.getActiveDisplayProfile(compositor) === profileId; + const outputSet = profileId.split("+"); profilesLoading = true; - Proc.runCommand("delete-profile", ["rm", "-f", profileFile], (output, exitCode) => { - profilesLoading = false; - SettingsData.removeDisplayProfile(compositor, profileId); - if (isActive) { - SettingsData.setActiveDisplayProfile(compositor, ""); - backendWriteOutputsConfig(allOutputs); - } - const updated = JSON.parse(JSON.stringify(validatedProfiles)); - delete updated[profileId]; - validatedProfiles = updated; - matchedProfile = findMatchingProfile(); - profileDeleted(profileId); + readMonitorsJson(data => { + const match = findConfigEntry(data, outputSet); + if (match) + data.configurations.splice(match.index, 1); + writeMonitorsJson(data, success => { + profilesLoading = false; + SettingsData.removeDisplayProfile(compositor, profileId); + if (isActive) { + SettingsData.setActiveDisplayProfile(compositor, ""); + backendWriteOutputsConfig(allOutputs); + } + const updated = JSON.parse(JSON.stringify(validatedProfiles)); + delete updated[profileId]; + validatedProfiles = updated; + matchedProfile = findMatchingProfile(); + profileDeleted(profileId); + }); }); } function activateProfile(profileId) { - const compositor = CompositorService.compositor; - const profiles = SettingsData.getDisplayProfiles(compositor); - const profile = profiles[profileId]; - if (!profile) { - profileError(I18n.tr("Profile not found")); - return; - } - - const profilesDir = getProfilesDir(); - const profileFile = profilesDir + "/" + profileId + getProfileExtension(); - const paths = getConfigPaths(); - if (!paths) - return; - manualActivation = true; profilesLoading = true; - Proc.runCommand("activate-profile", ["ln", "-sf", profileFile, paths.outputsFile], (output, exitCode) => { - if (exitCode !== 0) { + const outputSet = profileId.split("+"); + readMonitorsJson(data => { + const match = findConfigEntry(data, outputSet); + if (!match) { profilesLoading = false; manualActivation = false; - profileError(I18n.tr("Failed to activate profile - file not found")); + profileError(I18n.tr("Profile not found in monitors.json")); return; } - SettingsData.setActiveDisplayProfile(compositor, profileId); - - const reloadCmd = CompositorService.isNiri ? ["niri", "msg", "action", "reload-config-or-panic"] : CompositorService.isHyprland ? ["hyprctl", "reload"] : []; - if (reloadCmd.length > 0) { - Proc.runCommand("reload-compositor", reloadCmd, (output2, exitCode2) => { - profilesLoading = false; - WlrOutputService.requestState(); - profileActivated(profileId, profile.name); - manualActivationTimer.restart(); - }); - } else { - profilesLoading = false; - profileActivated(profileId, profile.name); - manualActivationTimer.restart(); - } + applyConfigEntry(match.entry, profileId, match.entry.name || profileId, true); }); } @@ -297,27 +614,74 @@ Singleton { onTriggered: root.manualActivation = false } - Timer { - id: autoSelectDebounceTimer - interval: 800 - onTriggered: root.doAutoSelectProfile() + function applyAutoConfig() { + if (!profilesReady || !SettingsData.displayProfileAutoSelect || manualActivation || !currentOutputSet.length) + return; + + readMonitorsJson(data => { + // 1. Exact match + const match = findConfigEntry(data, currentOutputSet); + if (match) { + const virtualId = Object.keys(match.entry.outputs || {}).sort().join("+"); + applyConfigEntry(match.entry, virtualId, "", false); + return; + } + + // 2. Partial match — largest saved subset of current outputs + // 3. No match — use all current outputs with defaults + const partial = findPartialConfigEntry(data, currentOutputSet); + const niriSettings = buildMergedNiriSettings(); + const hyprlandSettings = buildMergedHyprlandSettings(); + const mergedOutputs = buildOutputsWithPendingChanges(); + + // Start from the partial config outputs (if any) + const outputConfigs = partial ? JSON.parse(JSON.stringify(partial.entry.outputs || {})) : {}; + + // Fill in any current outputs not covered by the partial config + for (const name in outputs) { + const outputId = getOutputIdentifier(outputs[name], name); + const alreadyCovered = Object.keys(outputConfigs).some(k => k === outputId); + if (!alreadyCovered) { + const od = mergedOutputs[name]; + if (od) + outputConfigs[outputId] = extractOutputNeutralConfig(name, od, niriSettings, hyprlandSettings); + } + } + + if (Object.keys(outputConfigs).length === 0) + return; + + const syntheticEntry = {name: "", outputs: outputConfigs}; + const syntheticId = Object.keys(outputConfigs).sort().join("+"); + applyConfigEntry(syntheticEntry, syntheticId, "", false); + }); } - // ! TODO - auto profile switching is buggy on niri and other compositors, might need a longer debounce before updating output configuration idk - function autoSelectProfile() { - return; // disabled - autoSelectDebounceTimer.restart(); + function buildCurrentOutputConfigs() { + const mergedAll = buildOutputsWithPendingChanges(); + const niriSettings = buildMergedNiriSettings(); + const hyprlandSettings = buildMergedHyprlandSettings(); + const outputConfigs = {}; + for (const name in outputs) { + const od = mergedAll[name]; + if (od) + outputConfigs[getOutputIdentifier(od, name)] = extractOutputNeutralConfig(name, od, niriSettings, hyprlandSettings); + } + return outputConfigs; } - function doAutoSelectProfile() { - return; // disabled - if (!SettingsData.displayProfileAutoSelect || manualActivation) - return; - currentOutputSet = buildCurrentOutputSet(); - const matched = findMatchingProfile(); - matchedProfile = matched; - if (matched && matched !== SettingsData.getActiveDisplayProfile(CompositorService.compositor)) - activateProfile(matched); + function saveConfigEntry(configEntry) { + const outputIds = Object.keys(configEntry.outputs || {}); + readMonitorsJson(data => { + const match = findConfigEntry(data, outputIds); + const existingName = match?.entry?.name ?? configEntry.name ?? ""; + const newEntry = {"name": existingName, "outputs": configEntry.outputs}; + if (match) + data.configurations[match.index] = newEntry; + else + data.configurations.push(newEntry); + writeMonitorsJson(data, null); + }); } function deleteDisconnectedOutput(outputName) { @@ -345,22 +709,49 @@ Singleton { }); } for (const name in outputs) { - result[name] = Object.assign({}, outputs[name], { - "connected": true - }); + const entry = JSON.parse(JSON.stringify(outputs[name])); + entry.connected = true; + // For disabled outputs wlr reports scale=0 (no logical viewport). + // Overlay scale/position/transform from the last applied profile so + // the settings UI can display meaningful values. + if (!(entry.logical?.scale > 0)) { + const profileCfg = getProfileOutputConfig(name); + if (profileCfg) { + if (!entry.logical) + entry.logical = {}; + entry.logical.scale = profileCfg.scale ?? 1.0; + entry.logical.x = profileCfg.position?.x ?? entry.logical.x ?? 0; + entry.logical.y = profileCfg.position?.y ?? entry.logical.y ?? 0; + if (profileCfg.transform) + entry.logical.transform = profileCfg.transform; + } else if (entry.logical) { + entry.logical.scale = entry.logical.scale || 1.0; + } + } + result[name] = entry; } return result; } + function getProfileOutputConfig(outputName) { + const sourceEntry = lastAppliedEntry || (matchedProfile ? validatedProfiles[matchedProfile] : null); + if (!sourceEntry) + return null; + const cfgOutputs = sourceEntry.outputs || {}; + const outputId = getOutputIdentifier(outputs[outputName] || {}, outputName); + return Object.entries(cfgOutputs).find(([key]) => key === outputId)?.[1] ?? null; + } + onOutputsChanged: { allOutputs = buildAllOutputsMap(); - currentOutputSet = buildCurrentOutputSet(); - matchedProfile = findMatchingProfile(); - // ! TODO - auto profile switching disabled for now - // if (SettingsData.displayProfileAutoSelect) - // Qt.callLater(autoSelectProfile); + const newOutputSet = buildCurrentOutputSet(); + if (JSON.stringify(newOutputSet) === JSON.stringify(currentOutputSet)) + return; + currentOutputSet = newOutputSet; + applyAutoConfig(); } onSavedOutputsChanged: allOutputs = buildAllOutputsMap() + onLastAppliedEntryChanged: allOutputs = buildAllOutputsMap() Connections { target: WlrOutputService @@ -370,11 +761,16 @@ Singleton { } } + Connections { + target: CompositorService + function onCompositorChanged() { + root.checkIncludeStatus(); + } + } + Component.onCompleted: { outputs = buildOutputsMap(); reloadSavedOutputs(); - checkIncludeStatus(); - currentOutputSet = buildCurrentOutputSet(); validateProfiles(); } @@ -840,7 +1236,7 @@ Singleton { "y": output.y ?? 0, "width": output.currentMode?.width ?? 1920, "height": output.currentMode?.height ?? 1080, - "scale": output.scale ?? 1.0, + "scale": output.scale || 1.0, "transform": mapWlrTransform(output.transform) } }; @@ -1019,14 +1415,7 @@ Singleton { } function getOutputDisplayName(output, outputName) { - if (SettingsData.displayNameMode === "model" && output?.make && output?.model) { - if (CompositorService.isNiri) { - const serial = output.serial || "Unknown"; - return output.make + " " + output.model + " " + serial; - } - return output.make + " " + output.model; - } - return outputName; + return getOutputIdentifier(output, outputName); } function getNiriOutputIdentifier(output, outputName) { @@ -1183,6 +1572,28 @@ Singleton { return pending !== undefined ? pending : originalValue; } + // Returns true if the given output can currently be disabled. + // Prevents disabling all outputs and prevents disabling the only output + // in a single-display configuration. + function canDisableOutput() { + if (!CompositorService.isNiri && !CompositorService.isHyprland) + return false; + const totalOutputs = Object.keys(outputs).length; + if (totalOutputs <= 1) + return false; + let enabledCount = 0; + for (const name in outputs) { + let disabled = false; + if (CompositorService.isNiri) + disabled = getNiriSetting(outputs[name], name, "disabled", false); + else if (CompositorService.isHyprland) + disabled = getHyprlandSetting(outputs[name], name, "disabled", false); + if (!disabled) + enabledCount++; + } + return enabledCount >= 2; + } + function clearPendingChanges() { pendingChanges = {}; pendingNiriChanges = {}; @@ -1321,6 +1732,11 @@ Singleton { merged[outputId][key] = pendingNiriChanges[outputId][key]; } } + // Never disable the only connected output — clear any stale flag + if (Object.keys(outputs).length <= 1) { + for (const id in merged) + delete merged[id].disabled; + } return merged; } @@ -1330,6 +1746,13 @@ Singleton { SettingsData.setNiriOutputSetting(outputId, key, pendingNiriChanges[outputId][key]); } } + // Clear stale disabled from SettingsData so NiriService reads clean state + if (Object.keys(outputs).length <= 1) { + for (const id in SettingsData.niriOutputSettings) { + if (SettingsData.niriOutputSettings[id]?.disabled) + SettingsData.setNiriOutputSetting(id, "disabled", null); + } + } } function buildMergedHyprlandSettings() { @@ -1345,6 +1768,11 @@ Singleton { merged[outputId][key] = val; } } + // Never disable the only connected output — clear any stale flag + if (Object.keys(outputs).length <= 1) { + for (const id in merged) + delete merged[id].disabled; + } return merged; } @@ -1358,6 +1786,13 @@ Singleton { SettingsData.setHyprlandOutputSetting(outputId, key, val); } } + // Clear stale disabled from SettingsData so HyprlandService reads clean state + if (Object.keys(outputs).length <= 1) { + for (const id in SettingsData.hyprlandOutputSettings) { + if (SettingsData.hyprlandOutputSettings[id]?.disabled) + SettingsData.removeHyprlandOutputSetting(id, "disabled"); + } + } } function generateNiriOutputsKdl(outputsData, niriSettings) { @@ -1463,6 +1898,10 @@ Singleton { } function confirmChanges() { + const outputConfigs = buildCurrentOutputConfigs(); + const entry = {name: "", outputs: outputConfigs}; + lastAppliedEntry = JSON.parse(JSON.stringify(entry)); + saveConfigEntry(entry); clearPendingChanges(); changesConfirmed(); } diff --git a/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml b/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml index 7c36e4bee..e8fdedf03 100644 --- a/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml +++ b/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml @@ -74,6 +74,8 @@ Column { DankToggle { width: parent.width text: I18n.tr("Disable Output") + enabled: checked || DisplayConfigState.canDisableOutput() + description: (!checked && !DisplayConfigState.canDisableOutput()) ? (Object.keys(DisplayConfigState.outputs).length <= 1 ? I18n.tr("Cannot disable the only output") : I18n.tr("At least one output must remain enabled")) : "" checked: DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "disabled", false) onToggled: checked => DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "disabled", checked) } diff --git a/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml b/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml index da9a4a71c..bd80b6395 100644 --- a/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml +++ b/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml @@ -61,6 +61,8 @@ Column { DankToggle { width: parent.width text: I18n.tr("Disable Output") + enabled: checked || DisplayConfigState.canDisableOutput() + description: (!checked && !DisplayConfigState.canDisableOutput()) ? (Object.keys(DisplayConfigState.outputs).length <= 1 ? I18n.tr("Cannot disable the only output") : I18n.tr("At least one output must remain enabled")) : "" checked: DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "disabled", false) onToggled: checked => DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "disabled", checked) } diff --git a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml index 0da1175b0..6d14449c0 100644 --- a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml +++ b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml @@ -168,7 +168,7 @@ StyledRect { const pendingScale = DisplayConfigState.getPendingValue(root.outputName, "scale"); if (pendingScale !== undefined) return parseFloat(pendingScale.toFixed(2)).toString(); - const scale = DisplayConfigState.outputs[root.outputName]?.logical?.scale ?? 1.0; + const scale = root.outputData?.logical?.scale || 1.0; return parseFloat(scale.toFixed(2)).toString(); } @@ -251,8 +251,7 @@ StyledRect { const pendingTransform = DisplayConfigState.getPendingValue(root.outputName, "transform"); if (pendingTransform) return DisplayConfigState.getTransformLabel(pendingTransform); - const data = DisplayConfigState.outputs[root.outputName]; - return DisplayConfigState.getTransformLabel(data?.logical?.transform ?? "Normal"); + return DisplayConfigState.getTransformLabel(root.outputData?.logical?.transform ?? "Normal"); } options: [I18n.tr("Normal"), I18n.tr("90°"), I18n.tr("180°"), I18n.tr("270°"), I18n.tr("Flipped"), I18n.tr("Flipped 90°"), I18n.tr("Flipped 180°"), I18n.tr("Flipped 270°")] onValueChanged: value => DisplayConfigState.setPendingChange(root.outputName, "transform", DisplayConfigState.getTransformValue(value)) diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml index 61b373db9..f7977f444 100644 --- a/quickshell/Modules/Settings/DisplayConfigTab.qml +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -15,15 +15,13 @@ Item { property bool showNewProfileDialog: false property bool showDeleteConfirmDialog: false property bool showRenameDialog: false + property bool showEditMonitorsDialog: false property string newProfileName: "" property string renameProfileName: "" + property var editMonitorSelection: ({}) function getProfileOptions() { - const profiles = DisplayConfigState.validatedProfiles; - const options = []; - for (const id in profiles) - options.push(profiles[id].name); - return options; + return Object.values(DisplayConfigState.validatedProfiles).filter(p => p.name !== "").map(p => p.name); } function getProfileIds() { @@ -44,6 +42,13 @@ Item { return profiles[id]?.name || ""; } + function openEditMonitorsDialog() { + if (!root.selectedProfileId) + return; + editMonitorSelection = DisplayConfigState.getProfileMonitorInclusion(root.selectedProfileId); + showEditMonitorsDialog = true; + } + Connections { target: DisplayConfigState function onChangesApplied(changeDescriptions) { @@ -139,10 +144,9 @@ Item { } } - // ! TODO - auto profile switching is buggy on niri and other compositors Column { id: autoSelectColumn - visible: false // disabled for now + visible: true spacing: Theme.spacingXS anchors.verticalCenter: parent.verticalCenter @@ -156,12 +160,12 @@ Item { DankToggle { id: autoSelectToggle - checked: false // disabled for now - enabled: false + checked: SettingsData.displayProfileAutoSelect onToggled: checked => { - // disabled for now - // SettingsData.displayProfileAutoSelect = checked; - // SettingsData.saveSettings(); + SettingsData.displayProfileAutoSelect = checked; + SettingsData.saveSettings(); + if (checked) + DisplayConfigState.applyAutoConfig(); } } } @@ -170,16 +174,17 @@ Item { Row { width: parent.width spacing: Theme.spacingS - visible: !root.showNewProfileDialog && !root.showDeleteConfirmDialog && !root.showRenameDialog + visible: !root.showNewProfileDialog && !root.showDeleteConfirmDialog && !root.showRenameDialog && !root.showEditMonitorsDialog + opacity: SettingsData.displayProfileAutoSelect ? 0.4 : 1.0 DankDropdown { id: profileDropdown - width: parent.width - newButton.width - deleteButton.width - Theme.spacingS * 2 + width: parent.width - newButton.width - editMonitorsButton.width - deleteButton.width - Theme.spacingS * 3 compactMode: true dropdownWidth: width options: root.getProfileOptions() - currentValue: root.getProfileNameById(root.selectedProfileId) emptyText: I18n.tr("No profiles") + enabled: !SettingsData.displayProfileAutoSelect onValueChanged: value => { const profileId = root.getProfileIdByName(value); if (profileId && profileId !== root.selectedProfileId) @@ -187,6 +192,12 @@ Item { } } + Binding { + target: profileDropdown + property: "currentValue" + value: SettingsData.displayProfileAutoSelect ? I18n.tr("Auto") : root.getProfileNameById(root.selectedProfileId) + } + DankButton { id: newButton iconName: "add" @@ -195,12 +206,25 @@ Item { horizontalPadding: Theme.spacingM backgroundColor: Theme.surfaceContainer textColor: Theme.surfaceText + enabled: !SettingsData.displayProfileAutoSelect onClicked: { root.newProfileName = ""; root.showNewProfileDialog = true; } } + DankButton { + id: editMonitorsButton + iconName: "edit" + text: "" + buttonHeight: 40 + horizontalPadding: Theme.spacingM + backgroundColor: Theme.surfaceContainer + textColor: Theme.surfaceText + enabled: root.selectedProfileId !== "" && !SettingsData.displayProfileAutoSelect + onClicked: root.openEditMonitorsDialog() + } + DankButton { id: deleteButton iconName: "delete" @@ -209,7 +233,7 @@ Item { horizontalPadding: Theme.spacingM backgroundColor: Theme.surfaceContainer textColor: Theme.error - enabled: root.selectedProfileId !== "" + enabled: root.selectedProfileId !== "" && !SettingsData.displayProfileAutoSelect onClicked: root.showDeleteConfirmDialog = true } } @@ -307,23 +331,89 @@ Item { } } - Row { + Rectangle { width: parent.width - spacing: Theme.spacingS - visible: DisplayConfigState.matchedProfile !== "" + height: editMonitorsColumn.height + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainer + visible: root.showEditMonitorsDialog - DankIcon { - name: "check_circle" - size: 16 - color: Theme.success - anchors.verticalCenter: parent.verticalCenter - } + Column { + id: editMonitorsColumn + anchors.centerIn: parent + width: parent.width - Theme.spacingM * 2 + spacing: Theme.spacingS - StyledText { - text: I18n.tr("Matches profile: %1").arg(root.getProfileNameById(DisplayConfigState.matchedProfile)) - font.pixelSize: Theme.fontSizeSmall - color: Theme.success - anchors.verticalCenter: parent.verticalCenter + StyledText { + text: I18n.tr("Monitors in \"%1\":").arg(root.getProfileNameById(root.selectedProfileId)) + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + width: parent.width + } + + Repeater { + model: Object.keys(DisplayConfigState.allOutputs || {}) + delegate: Row { + required property string modelData + width: parent.width + spacing: Theme.spacingM + + DankToggle { + id: monitorToggle + checked: root.editMonitorSelection[modelData] ?? false + anchors.verticalCenter: parent.verticalCenter + onToggled: checked => { + const sel = Object.assign({}, root.editMonitorSelection); + sel[modelData] = checked; + root.editMonitorSelection = sel; + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + StyledText { + text: { + const od = DisplayConfigState.allOutputs[modelData]; + return DisplayConfigState.getOutputDisplayName(od, modelData); + } + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + } + + StyledText { + text: DisplayConfigState.allOutputs[modelData]?.connected + ? I18n.tr("Connected") : I18n.tr("Disconnected") + font.pixelSize: Theme.fontSizeSmall + color: DisplayConfigState.allOutputs[modelData]?.connected + ? Theme.success : Theme.surfaceVariantText + } + } + } + } + + Row { + spacing: Theme.spacingS + anchors.right: parent.right + + DankButton { + text: I18n.tr("Save") + enabled: Object.values(root.editMonitorSelection).some(v => v) + onClicked: { + const enabled = Object.keys(root.editMonitorSelection).filter(k => root.editMonitorSelection[k]); + DisplayConfigState.updateProfileMonitors(root.selectedProfileId, enabled); + root.showEditMonitorsDialog = false; + } + } + + DankButton { + text: I18n.tr("Cancel") + backgroundColor: "transparent" + textColor: Theme.surfaceText + onClicked: root.showEditMonitorsDialog = false + } + } } } }