From 91edbcdb2d339ec38cb463b1043b8223fd0f2d2b Mon Sep 17 00:00:00 2001 From: Clay Cantrell <77699867+claycantrell@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:57:22 -0800 Subject: [PATCH] v1 Color Temperature Control --- MonitorControl/Enums/PrefKey.swift | 7 +++- MonitorControl/Model/OtherDisplay.swift | 12 ++++-- MonitorControl/Support/DisplayManager.swift | 42 +++++++++---------- MonitorControl/Support/MenuHandler.swift | 8 ++++ MonitorControl/Support/SliderHandler.swift | 14 ++++++- MonitorControl/UI/Base.lproj/Main.storyboard | 15 +++++++ .../UI/en.lproj/Localizable.strings | 3 ++ .../MenuslidersPrefsViewController.swift | 17 +++++++- 8 files changed, 89 insertions(+), 29 deletions(-) diff --git a/MonitorControl/Enums/PrefKey.swift b/MonitorControl/Enums/PrefKey.swift index 76b5f29..6377099 100644 --- a/MonitorControl/Enums/PrefKey.swift +++ b/MonitorControl/Enums/PrefKey.swift @@ -33,10 +33,13 @@ enum PrefKey: String { // Hide brightness sliders case hideBrightness - // Show volume sliders + // Show contrast sliders case showContrast - // Show volume sliders + // Show color temperature sliders + case showColorTemperature + + // Hide volume sliders case hideVolume // Lower via software after brightness diff --git a/MonitorControl/Model/OtherDisplay.swift b/MonitorControl/Model/OtherDisplay.swift index d920990..15e8dc9 100644 --- a/MonitorControl/Model/OtherDisplay.swift +++ b/MonitorControl/Model/OtherDisplay.swift @@ -377,7 +377,7 @@ class OtherDisplay: Display { return intCodes } - public func writeDDCValues(command: Command, value: UInt16) { + func writeDDCValues(command: Command, value: UInt16) { guard app.sleepID == 0, app.reconfigureID == 0, !self.readPrefAsBool(key: .forceSw), !self.readPrefAsBool(key: .unavailableDDC, for: command) else { return } @@ -486,7 +486,10 @@ class OtherDisplay: Display { } let curveMultiplier = self.getCurveMultiplier(self.readPrefAsInt(key: .curveDDC, for: command)) let minDDCValue = Float(self.readPrefAsInt(key: .minDDCOverride, for: command)) - let maxDDCValue = Float(self.readPrefAsInt(key: .maxDDC, for: command)) + var maxDDCValue = Float(self.readPrefAsInt(key: .maxDDC, for: command)) + if maxDDCValue <= minDDCValue { + maxDDCValue = Float(DDC_MAX_DETECT_LIMIT) + } let curvedValue = pow(max(min(value, 1), 0), curveMultiplier) let deNormalizedValue = (maxDDCValue - minDDCValue) * curvedValue + minDDCValue var intDDCValue = UInt16(min(max(deNormalizedValue, minDDCValue), maxDDCValue)) @@ -499,7 +502,10 @@ class OtherDisplay: Display { func convDDCToValue(for command: Command, from: UInt16) -> Float { let curveMultiplier = self.getCurveMultiplier(self.readPrefAsInt(key: .curveDDC, for: command)) let minDDCValue = Float(self.readPrefAsInt(key: .minDDCOverride, for: command)) - let maxDDCValue = Float(self.readPrefAsInt(key: .maxDDC, for: command)) + var maxDDCValue = Float(self.readPrefAsInt(key: .maxDDC, for: command)) + if maxDDCValue <= minDDCValue { + maxDDCValue = Float(DDC_MAX_DETECT_LIMIT) + } let normalizedValue = ((min(max(Float(from), minDDCValue), maxDDCValue) - minDDCValue) / (maxDDCValue - minDDCValue)) let deCurvedValue = pow(normalizedValue, 1.0 / curveMultiplier) var value = deCurvedValue diff --git a/MonitorControl/Support/DisplayManager.swift b/MonitorControl/Support/DisplayManager.swift index f53d5bb..9fcb09e 100644 --- a/MonitorControl/Support/DisplayManager.swift +++ b/MonitorControl/Support/DisplayManager.swift @@ -5,7 +5,7 @@ import CoreGraphics import os.log class DisplayManager { - public static let shared = DisplayManager() + static let shared = DisplayManager() var displays: [Display] = [] var audioControlTargetDisplays: [OtherDisplay] = [] @@ -177,6 +177,7 @@ class DisplayManager { let isDummy: Bool = DisplayManager.isDummy(displayID: onlineDisplayID) let isVirtual: Bool = DisplayManager.isVirtual(displayID: onlineDisplayID) if !DEBUG_SW, DisplayManager.isAppleDisplay(displayID: onlineDisplayID) { // MARK: (point of interest for testing) + let appleDisplay = AppleDisplay(id, name: name, vendorNumber: vendorNumber, modelNumber: modelNumber, serialNumber: serialNumber, isVirtual: isVirtual, isDummy: isDummy) os_log("Apple display found - %{public}@", type: .info, "ID: \(appleDisplay.identifier), Name: \(appleDisplay.name) (Vendor: \(appleDisplay.vendorNumber ?? 0), Model: \(appleDisplay.modelNumber ?? 0))") self.addDisplay(display: appleDisplay) @@ -190,7 +191,7 @@ class DisplayManager { func setupOtherDisplays(firstrun: Bool = false) { for otherDisplay in self.getOtherDisplays() { - for command in [Command.audioSpeakerVolume, Command.contrast] where !otherDisplay.readPrefAsBool(key: .unavailableDDC, for: command) && !otherDisplay.isSw() { + for command in [Command.audioSpeakerVolume, Command.contrast, Command.colorTemperatureRequest] where !otherDisplay.readPrefAsBool(key: .unavailableDDC, for: command) && !otherDisplay.isSw() { otherDisplay.setupCurrentAndMaxValues(command: command, firstrun: firstrun) } if (!otherDisplay.isSw() && !otherDisplay.readPrefAsBool(key: .unavailableDDC, for: .brightness)) || otherDisplay.isSw() { @@ -242,36 +243,34 @@ class DisplayManager { func sortDisplays() { // Opsiyonel: sıralamadan önce log al - let before = displays.map { $0.name } + let before = self.displays.map(\.name) os_log("Displays before sorting: %{public}@", before) - + // In‑place sıralama - displays.sort { lhs, rhs in + self.displays.sort { lhs, rhs in lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } - + // Opsiyonel: sıralamadan sonra log al - let after = displays.map { $0.name } + let after = self.displays.map(\.name) os_log("Displays after sorting: %{public}@", after) } - + func sortDisplaysByFriendlyName() -> [Display] { - return displays.sorted { lhs, rhs in - let lhsTitle = lhs.readPrefAsString(key: .friendlyName).isEmpty - ? lhs.name - : lhs.readPrefAsString(key: .friendlyName) - let rhsTitle = rhs.readPrefAsString(key: .friendlyName).isEmpty - ? rhs.name - : rhs.readPrefAsString(key: .friendlyName) - return lhsTitle.localizedStandardCompare(rhsTitle) == .orderedDescending - } + self.displays.sorted { lhs, rhs in + let lhsTitle = lhs.readPrefAsString(key: .friendlyName).isEmpty + ? lhs.name + : lhs.readPrefAsString(key: .friendlyName) + let rhsTitle = rhs.readPrefAsString(key: .friendlyName).isEmpty + ? rhs.name + : rhs.readPrefAsString(key: .friendlyName) + return lhsTitle.localizedStandardCompare(rhsTitle) == .orderedDescending + } } - - /// displays dizisini sıralar ve döner func getAllDisplays() -> [Display] { - return displays + self.displays } func getDdcCapableDisplays() -> [OtherDisplay] { @@ -313,7 +312,7 @@ class DisplayManager { func clearDisplays() { self.displays = [] } - + func addDisplayCounterSuffixes() { var nameDisplays: [String: [Display]] = [:] for display in self.displays { @@ -549,6 +548,7 @@ class DisplayManager { } } if let screen = getByDisplayID(displayID: displayID) { // MARK: This, and NSScreen+Extension.swift will not be needed when we drop MacOS 10 support. + if #available(macOS 10.15, *) { return screen.localizedName } else { diff --git a/MonitorControl/Support/MenuHandler.swift b/MonitorControl/Support/MenuHandler.swift index 6fcd486..fc6c51b 100644 --- a/MonitorControl/Support/MenuHandler.swift +++ b/MonitorControl/Support/MenuHandler.swift @@ -167,6 +167,9 @@ class MenuHandler: NSMenu, NSMenuDelegate { if let sliderHandler = self.combinedSliderHandler[.audioSpeakerVolume] { self.addSliderItem(monitorSubMenu: self, sliderHandler: sliderHandler) } + if let sliderHandler = self.combinedSliderHandler[.colorTemperatureRequest] { + self.addSliderItem(monitorSubMenu: self, sliderHandler: sliderHandler) + } if let sliderHandler = self.combinedSliderHandler[.contrast] { self.addSliderItem(monitorSubMenu: self, sliderHandler: sliderHandler) } @@ -189,6 +192,11 @@ class MenuHandler: NSMenu, NSMenuDelegate { let title = NSLocalizedString("Contrast", comment: "Shown in menu") addedSliderHandlers.append(self.setupMenuSliderHandler(command: .contrast, display: display, title: title)) } + display.sliderHandler[.colorTemperatureRequest] = nil + if let otherDisplay = display as? OtherDisplay, !otherDisplay.isSw(), !display.readPrefAsBool(key: .unavailableDDC, for: .colorTemperatureRequest), prefs.bool(forKey: PrefKey.showColorTemperature.rawValue) { + let title = NSLocalizedString("Color Temperature", comment: "Shown in menu") + addedSliderHandlers.append(self.setupMenuSliderHandler(command: .colorTemperatureRequest, display: display, title: title)) + } display.sliderHandler[.brightness] = nil if !display.readPrefAsBool(key: .unavailableDDC, for: .brightness), !prefs.bool(forKey: PrefKey.hideBrightness.rawValue) { let title = NSLocalizedString("Brightness", comment: "Shown in menu") diff --git a/MonitorControl/Support/SliderHandler.swift b/MonitorControl/Support/SliderHandler.swift index 1d7d20e..8f061c0 100644 --- a/MonitorControl/Support/SliderHandler.swift +++ b/MonitorControl/Support/SliderHandler.swift @@ -209,7 +209,7 @@ class SliderHandler { } } - public init(display: Display?, command: Command, title: String = "", position _: Int = 0) { + init(display: Display?, command: Command, title: String = "", position _: Int = 0) { self.command = command self.title = title let slider = SliderHandler.MCSlider(value: 0, minValue: 0, maxValue: 1, target: self, action: #selector(SliderHandler.valueChanged)) @@ -227,6 +227,7 @@ class SliderHandler { case .audioSpeakerVolume: iconName = "speaker.wave.2.fill" case .brightness: iconName = "sun.max.fill" case .contrast: iconName = "circle.lefthalf.fill" + case .colorTemperatureRequest: iconName = "thermometer.medium" default: break } let icon = SliderHandler.ClickThroughImageView() @@ -297,6 +298,17 @@ class SliderHandler { if !otherDisplay.readPrefAsBool(key: .enableMuteUnmute) || value != 0 { otherDisplay.writeDDCValues(command: self.command, value: otherDisplay.convValueToDDC(for: self.command, from: value)) } + } else if self.command == Command.colorTemperatureRequest { + // Color temperature: adjust red and blue gains + // Based on Kelvin to RGB conversion (Tanner Helland algorithm) + // Blue changes more than red (asymmetric like real color temp) + // value 0 = cool (~9300K), value 0.5 = neutral (~6500K), value 1 = warm (~2700K) + // Red range: 40-60 (±10 from neutral 50) + // Blue range: 30-70 (±20 from neutral 50) + let redValue = UInt16(40 + value * 20) // 40 at cool, 50 at neutral, 60 at warm + let blueValue = UInt16(70 - value * 40) // 70 at cool, 50 at neutral, 30 at warm + otherDisplay.writeDDCValues(command: .videoGainRed, value: redValue) + otherDisplay.writeDDCValues(command: .videoGainBlue, value: blueValue) } else { otherDisplay.writeDDCValues(command: self.command, value: otherDisplay.convValueToDDC(for: self.command, from: value)) } diff --git a/MonitorControl/UI/Base.lproj/Main.storyboard b/MonitorControl/UI/Base.lproj/Main.storyboard index f5a9bb4..8d3fbd8 100644 --- a/MonitorControl/UI/Base.lproj/Main.storyboard +++ b/MonitorControl/UI/Base.lproj/Main.storyboard @@ -333,6 +333,7 @@ + @@ -540,6 +541,19 @@ + + + + @@ -696,6 +710,7 @@ + diff --git a/MonitorControl/UI/en.lproj/Localizable.strings b/MonitorControl/UI/en.lproj/Localizable.strings index a59a9fb..7288e9b 100644 --- a/MonitorControl/UI/en.lproj/Localizable.strings +++ b/MonitorControl/UI/en.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* Shown in menu */ "Check for updates…" = "Check for updates…"; +/* Shown in menu */ +"Color Temperature" = "Color Temperature"; + /* Shown in menu */ "Contrast" = "Contrast"; diff --git a/MonitorControl/View Controllers/Preferences/MenuslidersPrefsViewController.swift b/MonitorControl/View Controllers/Preferences/MenuslidersPrefsViewController.swift index 3d132ea..7a1e4e7 100644 --- a/MonitorControl/View Controllers/Preferences/MenuslidersPrefsViewController.swift +++ b/MonitorControl/View Controllers/Preferences/MenuslidersPrefsViewController.swift @@ -25,6 +25,7 @@ class MenuslidersPrefsViewController: NSViewController, SettingsPane { @IBOutlet var showAppleFromMenu: NSButton! @IBOutlet var showVolumeSlider: NSButton! @IBOutlet var showContrastSlider: NSButton! + @IBOutlet var showColorTemperatureSlider: NSButton! @IBOutlet var multiSliders: NSPopUpButton! @@ -95,6 +96,7 @@ class MenuslidersPrefsViewController: NSViewController, SettingsPane { self.showAppleFromMenu.isEnabled = false } self.showContrastSlider.state = prefs.bool(forKey: PrefKey.showContrast.rawValue) ? .on : .off + self.showColorTemperatureSlider.state = prefs.bool(forKey: PrefKey.showColorTemperature.rawValue) ? .on : .off self.multiSliders.selectItem(withTag: prefs.integer(forKey: PrefKey.multiSliders.rawValue)) @@ -170,6 +172,17 @@ class MenuslidersPrefsViewController: NSViewController, SettingsPane { self.updateGridLayout() } + @IBAction func showColorTemperatureSliderClicked(_ sender: NSButton) { + switch sender.state { + case .on: + prefs.set(true, forKey: PrefKey.showColorTemperature.rawValue) + case .off: + prefs.set(false, forKey: PrefKey.showColorTemperature.rawValue) + default: break + } + app.updateMenusAndKeys() + } + @IBAction func enableSliderSnapClicked(_ sender: NSButton) { switch sender.state { case .on: @@ -211,8 +224,8 @@ class MenuslidersPrefsViewController: NSViewController, SettingsPane { app.updateMenusAndKeys() self.updateGridLayout() } - - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change _: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) { guard let object = object as? AnyObject else { return } if object === prefs, keyPath == PrefKey.menuIcon.rawValue { self.populateSettings()