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()