From 2eb022de208c0c4dfd5c32eabaefad1ba5c49b05 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 17 Sep 2026 15:38:33 -0700 Subject: [PATCH] fix: preserve an explicitly empty display selection --- ScreenSlanger/config.swift | 31 +++++++----- Tests/ConfigSelection.swift | 96 +++++++++++++++++++++++++++++++++++++ scripts/check-config.sh | 21 ++++++++ 3 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 Tests/ConfigSelection.swift create mode 100755 scripts/check-config.sh diff --git a/ScreenSlanger/config.swift b/ScreenSlanger/config.swift index 377393f..36c1e1f 100644 --- a/ScreenSlanger/config.swift +++ b/ScreenSlanger/config.swift @@ -53,12 +53,15 @@ class Config: Codable { var shaderParameters: [String: [String: Float]] = [:] /// Set of display IDs that should have the shader applied (CGDirectDisplayID as UInt32) - /// If empty, all displays are enabled by default + /// Legacy configurations use an empty set to enable all displays by default. var enabledDisplayIDs: Set = [] + /// Optional so existing saved configurations retain their original selection behavior. + /// Once the user selects displays, an empty set means no displays are enabled. + var displaySelectionIsExplicit: Bool? = nil /// Returns whether the given display should have the shader applied func isDisplayEnabled(_ displayID: CGDirectDisplayID) -> Bool { - if enabledDisplayIDs.isEmpty { + if enabledDisplayIDs.isEmpty && displaySelectionIsExplicit != true { return true // All enabled by default } return enabledDisplayIDs.contains(UInt32(displayID)) @@ -66,23 +69,27 @@ class Config: Codable { /// Toggle whether a display is enabled func toggleDisplay(_ displayID: CGDirectDisplayID) { - let id = UInt32(displayID) - if enabledDisplayIDs.isEmpty { - // First time toggling - populate with all current displays, then remove this one - var maxDisplays: UInt32 = 16 - var activeDisplays = [CGDirectDisplayID](repeating: 0, count: Int(maxDisplays)) - var displayCount: UInt32 = 0 - CGGetActiveDisplayList(maxDisplays, &activeDisplays, &displayCount) - - for i in 0.. CGDirectDisplayID? in + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil } + return CGDirectDisplayID(number.uint32Value) + } + toggleDisplay(displayID, availableDisplayIDs: availableDisplayIDs) + } + + func toggleDisplay(_ displayID: CGDirectDisplayID, availableDisplayIDs: [CGDirectDisplayID]) { + let id = UInt32(displayID) + if enabledDisplayIDs.isEmpty && displaySelectionIsExplicit != true { + // Convert the default "all" selection into the current explicit selection. + enabledDisplayIDs = Set(availableDisplayIDs.map { UInt32($0) }) enabledDisplayIDs.remove(id) } else if enabledDisplayIDs.contains(id) { enabledDisplayIDs.remove(id) } else { enabledDisplayIDs.insert(id) } + displaySelectionIsExplicit = true } static func getFileURL() -> URL { diff --git a/Tests/ConfigSelection.swift b/Tests/ConfigSelection.swift new file mode 100644 index 0000000..e4fa41c --- /dev/null +++ b/Tests/ConfigSelection.swift @@ -0,0 +1,96 @@ +import Foundation + +private struct SelectionFailure: Error, LocalizedError { + let message: String + var errorDescription: String? { message } +} + +private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { + if !condition() { throw SelectionFailure(message: message) } +} + +@main +struct ConfigSelection { + static func main() { + do { + try run() + } catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) + } + } + + private static func legacyConfig(displayIDs: [UInt32]) throws -> Config { + // Match the version 4 format, before displaySelectionIsExplicit existed. + let data = try JSONSerialization.data(withJSONObject: [ + "configVersion": 4, + "shaderPath": "/tmp/example.slangp", + "active": true, + "targetFPS": 120, + "shaderParameters": ["/tmp/example.slangp": ["GAIN": 0.75]], + "enabledDisplayIDs": displayIDs + ]) + return try JSONDecoder().decode(Config.self, from: data) + } + + private static func roundTrip(_ config: Config) throws -> Config { + try JSONDecoder().decode(Config.self, from: JSONEncoder().encode(config)) + } + + private static func run() throws { + let defaults = Config() + try require(defaults.isDisplayEnabled(1) && defaults.isDisplayEnabled(2), + "A new configuration should enable every display") + + let legacyAll = try legacyConfig(displayIDs: []) + try require(legacyAll.isDisplayEnabled(1) && legacyAll.isDisplayEnabled(2), + "A legacy empty selection should still enable every display") + let legacyAllReloaded = try roundTrip(legacyAll) + try require(legacyAllReloaded.isDisplayEnabled(3), + "Saving an untouched legacy default should still enable future displays") + try require(legacyAllReloaded.shaderPath == "/tmp/example.slangp" + && legacyAllReloaded.active && legacyAllReloaded.targetFPS == 120 + && legacyAllReloaded.getParameterValue(name: "GAIN") == 0.75, + "Reading and writing a legacy configuration changed unrelated preferences") + print("PASS: new and legacy default selections enable all displays without changing preferences") + + let legacySelected = try legacyConfig(displayIDs: [1]) + try require(legacySelected.isDisplayEnabled(1) && !legacySelected.isDisplayEnabled(2), + "A legacy nonempty selection should remain restricted to its saved displays") + let legacySelectedReloaded = try roundTrip(legacySelected) + try require(legacySelectedReloaded.isDisplayEnabled(1) && !legacySelectedReloaded.isDisplayEnabled(2), + "Saving a legacy explicit selection changed its enabled displays") + print("PASS: legacy nonempty selections retain their saved displays") + + defaults.toggleDisplay(1, availableDisplayIDs: [1]) + try require(!defaults.isDisplayEnabled(1) && !defaults.isDisplayEnabled(2), + "Disabling the only display should leave every display disabled") + let disabledReloaded = try roundTrip(defaults) + try require(!disabledReloaded.isDisplayEnabled(1) && !disabledReloaded.isDisplayEnabled(2), + "An explicit empty selection should survive saving and reloading") + disabledReloaded.toggleDisplay(1, availableDisplayIDs: [1]) + try require(disabledReloaded.isDisplayEnabled(1) && !disabledReloaded.isDisplayEnabled(2), + "Re-enabling one display should not enable every display") + print("PASS: the only display can be disabled, persisted, and re-enabled") + + legacySelected.toggleDisplay(1, availableDisplayIDs: [1, 2]) + try require(!legacySelected.isDisplayEnabled(1) && !legacySelected.isDisplayEnabled(2), + "Removing the last legacy-selected display should not restore all displays") + let legacyDisabledReloaded = try roundTrip(legacySelected) + try require(!legacyDisabledReloaded.isDisplayEnabled(1) && !legacyDisabledReloaded.isDisplayEnabled(2), + "A legacy selection explicitly cleared by the user should remain empty on reload") + print("PASS: clearing the last legacy-selected display persists an empty selection") + + legacyAll.toggleDisplay(1, availableDisplayIDs: [1, 2]) + try require(!legacyAll.isDisplayEnabled(1) && legacyAll.isDisplayEnabled(2), + "Disabling one of two default displays should leave the other enabled") + legacyAll.toggleDisplay(2, availableDisplayIDs: [1, 2]) + try require(!legacyAll.isDisplayEnabled(1) && !legacyAll.isDisplayEnabled(2), + "Disabling the second display should leave both disabled") + legacyAll.toggleDisplay(1, availableDisplayIDs: [1, 2]) + try require(legacyAll.isDisplayEnabled(1) && !legacyAll.isDisplayEnabled(2), + "Re-enabling one of two displays should leave the other disabled") + print("PASS: multiple displays toggle independently through an empty selection") + print("All configuration selection checks passed.") + } +} diff --git a/scripts/check-config.sh b/scripts/check-config.sh new file mode 100755 index 0000000..750c3f8 --- /dev/null +++ b/scripts/check-config.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +build_directory="$(mktemp -d "${TMPDIR:-/tmp}/screenslanger-config.XXXXXX")" +trap 'rm -rf "$build_directory"' EXIT + +# Compile the real configuration and its shader-state dependencies. The checks +# only use in-memory JSON and supplied display IDs; saved user settings are untouched. +xcrun --sdk macosx swiftc \ + -sdk "$(xcrun --sdk macosx --show-sdk-path)" \ + -module-cache-path "$build_directory/ModuleCache" \ + -parse-as-library \ + "$project_dir/ScreenSlanger/config.swift" \ + "$project_dir/ScreenSlanger/slang_compiler.swift" \ + "$project_dir/ScreenSlanger/retroarch_shader.swift" \ + "$project_dir/ScreenSlanger/renderer.swift" \ + "$project_dir/Tests/ConfigSelection.swift" \ + -o "$build_directory/check-config" + +"$build_directory/check-config" -- 2.51.2