v1 Color Temperature Control

This commit is contained in:
Clay Cantrell 2026-01-06 20:57:22 -08:00
parent 195a8e88ec
commit 91edbcdb2d
8 changed files with 89 additions and 29 deletions

View file

@ -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

View file

@ -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

View file

@ -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)
// Inplace 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 {

View file

@ -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")

View file

@ -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))
}

View file

@ -333,6 +333,7 @@
<gridRow bottomPadding="-6" id="d8s-SW-acK"/>
<gridRow bottomPadding="-13" id="xbz-Tf-py0"/>
<gridRow bottomPadding="-10" id="KPA-bi-7h3"/>
<gridRow bottomPadding="-13" id="cTR-Ct-m7T"/>
<gridRow bottomPadding="-13" id="CuW-77-ls4"/>
<gridRow bottomPadding="-10" id="MaK-MK-gRQ"/>
<gridRow bottomPadding="-10" id="7kD-xD-py0"/>
@ -540,6 +541,19 @@
</textFieldCell>
</textField>
</gridCell>
<gridCell row="cTR-Ct-m7T" column="FRJ-Rb-RRh" xPlacement="trailing" id="cTL-Lf-9pQ"/>
<gridCell row="cTR-Ct-m7T" column="V6M-Jv-Agj" xPlacement="leading" id="cTB-Bt-5xX">
<button key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="cTe-mp-Sld">
<rect key="frame" x="218" y="200" width="252" height="18"/>
<buttonCell key="cell" type="check" title="Show color temperature slider in menu" bezelStyle="regularSquare" imagePosition="left" alignment="left" inset="2" id="cTC-ll-7vW">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="showColorTemperatureSliderClicked:" target="tLm-u5-aZ2" id="cTA-ct-8nN"/>
</connections>
</button>
</gridCell>
<gridCell row="CuW-77-ls4" column="FRJ-Rb-RRh" xPlacement="trailing" yPlacement="center" id="l3V-an-ba0">
<textField key="contentView" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="qQD-9e-JNb">
<rect key="frame" x="103" y="190" width="109" height="16"/>
@ -696,6 +710,7 @@
<outlet property="showAppleFromMenu" destination="jnZ-Nf-GaQ" id="d8s-zB-efU"/>
<outlet property="showBrightnessSlider" destination="NaD-fA-S7k" id="QbY-GZ-Z2Z"/>
<outlet property="showContrastSlider" destination="uYF-Oe-H5a" id="II5-CP-AzB"/>
<outlet property="showColorTemperatureSlider" destination="cTe-mp-Sld" id="cTO-ut-9zQ"/>
<outlet property="showTickMarks" destination="1Il-jd-K66" id="VSX-EG-XRh"/>
<outlet property="showVolumeSlider" destination="dbn-VC-UQW" id="sHO-Yd-b3H"/>
</connections>

View file

@ -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";

View file

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