From a4e1f7df644524fd4d9456b930f520f4ce9ebb4a Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Sun, 20 Sep 2026 12:44:55 +0530 Subject: [PATCH] Preserve the brightness offset between displays while syncing (#1781) Brightness sync added the reported delta to each display and clamped the result to 0...1, so whatever part of the change did not fit was dropped. A display parked near the top of its range therefore lost ground on every ambient light swing up but got the whole change back on the way down, and the displays crept together until the sliders were aligned. BrightnessSync keeps an unclamped anchor per display: the position the display would be at if it had no ends. The change is applied to the anchor and the display is driven to the clamped value, so the part that did not fit is given back on the way down instead of being lost. The anchor re-anchors itself whenever the display is no longer where the last sync left it, which is how a brightness the user set by hand becomes the new relationship to keep. Nothing else has to know about it. --- MonitorControl.xcodeproj/project.pbxproj | 4 ++ MonitorControl/Model/Display.swift | 2 + MonitorControl/Support/AppDelegate.swift | 11 ++-- MonitorControl/Support/BrightnessSync.swift | 52 +++++++++++++++++++ .../Preferences/MainPrefsViewController.swift | 5 ++ 5 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 MonitorControl/Support/BrightnessSync.swift diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index c6204059a..a974d2729 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ AAD7DD342CAFF3D90062822F /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = AAD7DD332CAFF3D90062822F /* Settings */; }; AADB625A26BC196900DFFAA5 /* DisplayServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AADB625926BC196900DFFAA5 /* DisplayServices.framework */; }; CB0D15010000000000000001 /* KeyboardShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB0D15010000000000000002 /* KeyboardShortcuts.swift */; }; + CB0D15010000000000000011 /* BrightnessSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB0D15010000000000000012 /* BrightnessSync.swift */; }; F01B0699228221B7008E64DB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F01B0680228221B6008E64DB /* Localizable.strings */; }; F01B069F228221B7008E64DB /* SliderHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01B068F228221B7008E64DB /* SliderHandler.swift */; }; F03A8DF21FFBAA6F0034DC27 /* OtherDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03A8DF11FFBAA6F0034DC27 /* OtherDisplay.swift */; }; @@ -167,6 +168,7 @@ B7FA437F2AC5857A00A94C01 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InternetAccessPolicy.strings; sourceTree = ""; }; B7FA43802AC5857A00A94C01 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; CB0D15010000000000000002 /* KeyboardShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcuts.swift; sourceTree = ""; }; + CB0D15010000000000000012 /* BrightnessSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessSync.swift; sourceTree = ""; }; F01B0682228221B6008E64DB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; F01B0685228221B6008E64DB /* Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Bridging-Header.h"; sourceTree = ""; }; F01B0686228221B6008E64DB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -312,6 +314,7 @@ AA99521826FE49A300612E07 /* MenuHandler.swift */, F01B068F228221B7008E64DB /* SliderHandler.swift */, 6C85EFD922C941B000227EA1 /* DisplayManager.swift */, + CB0D15010000000000000012 /* BrightnessSync.swift */, AA25F6D626E68C160087F3A2 /* MediaKeyTapManager.swift */, CB0D15010000000000000002 /* KeyboardShortcuts.swift */, AA44E70627038F7F00E06865 /* KeyboardShortcutsManager.swift */, @@ -630,6 +633,7 @@ FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */, 6CBFE27A23DB266000D1BC41 /* Display.swift in Sources */, CB0D15010000000000000001 /* KeyboardShortcuts.swift in Sources */, + CB0D15010000000000000011 /* BrightnessSync.swift in Sources */, AA44E70727038F7F00E06865 /* KeyboardShortcutsManager.swift in Sources */, F03A8DF21FFBAA6F0034DC27 /* OtherDisplay.swift in Sources */, AA78BDBD2709FE7B00CA8DF7 /* UpdaterDelegate.swift in Sources */, diff --git a/MonitorControl/Model/Display.swift b/MonitorControl/Model/Display.swift index 9eef114e9..646fdbee5 100644 --- a/MonitorControl/Model/Display.swift +++ b/MonitorControl/Model/Display.swift @@ -22,6 +22,8 @@ class Display: Equatable { var sliderHandler: [Command: SliderHandler] = [:] var brightnessSyncSourceValue: Float = 1 + // Where brightness sync would have put this display if it had no ends - see BrightnessSync. + var brightnessSyncAnchor: Float? var isVirtual: Bool = false var isDummy: Bool = false diff --git a/MonitorControl/Support/AppDelegate.swift b/MonitorControl/Support/AppDelegate.swift index 6a7c9767f..dd816a548 100644 --- a/MonitorControl/Support/AppDelegate.swift +++ b/MonitorControl/Support/AppDelegate.swift @@ -264,11 +264,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { refreshedSomething = true if prefs.bool(forKey: PrefKey.enableBrightnessSync.rawValue) { for targetDisplay in DisplayManager.shared.displays where targetDisplay != display { + let current = targetDisplay.getBrightness() + let step = BrightnessSync.step(current: current, anchor: targetDisplay.brightnessSyncAnchor, delta: delta) + targetDisplay.brightnessSyncAnchor = step.anchor + guard step.value != current else { + continue // The display is at the end of its range, there is nothing to push yet. + } os_log("Updating delta from display %{public}@ to display %{public}@", type: .info, String(display.identifier), String(targetDisplay.identifier)) - let newValue = max(0, min(1, targetDisplay.getBrightness() + delta)) - _ = targetDisplay.setBrightness(newValue) + _ = targetDisplay.setBrightness(step.value) if let slider = targetDisplay.sliderHandler[.brightness] { - slider.setValue(newValue, displayID: targetDisplay.identifier) + slider.setValue(step.value, displayID: targetDisplay.identifier) } } } diff --git a/MonitorControl/Support/BrightnessSync.swift b/MonitorControl/Support/BrightnessSync.swift new file mode 100644 index 000000000..a0b612937 --- /dev/null +++ b/MonitorControl/Support/BrightnessSync.swift @@ -0,0 +1,52 @@ +// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others + +import Foundation + +/// Keeps the other displays in step with a display that changed on its own (ambient +/// light, Touch Bar, System Settings or another app) while keeping the difference +/// the user set up between them. +/// +/// Adding the reported delta straight to a display and clamping the result to 0...1 +/// throws away whatever part of the change did not fit. A display that already sits +/// near the top of its range therefore loses ground on every ambient light swing up +/// but gets the whole change back on the way down, so the displays creep together +/// until every slider is aligned. +/// +/// The anchor below is where the display would be if it had no ends, and it is what +/// the change is applied to. The part that did not fit is given back on the way down +/// instead of being lost, which is what keeps the offset intact. +enum BrightnessSync { + /// A brightness difference smaller than this is neither settable by the user nor + /// visible, so the display still counts as being where the last sync left it. + static let tolerance: Float = 0.01 + + /// How far outside of 0...1 an anchor is allowed to travel. The offset between two + /// displays can never be wider than the whole range, so this only keeps the anchor + /// in bounds if several displays report changes of their own at the same time. + static let headroom: Float = 1 + + struct Step { + /// Where the display should be driven to now. + let value: Float + /// The anchor to hand back the next time this display is synced. + let anchor: Float + } + + /// `current` is where the display is now, `anchor` is what the previous step + /// returned for it (nil if it has not been synced yet) and `delta` is the change + /// the display that moved on its own reported. + static func step(current: Float, anchor: Float?, delta: Float) -> Step { + var anchor = anchor ?? current + if abs(self.clamp(anchor) - current) > self.tolerance { + // The display is not where the last sync left it, so the user moved this one + // on purpose: that is the relationship to keep from here on. + anchor = current + } + anchor = min(max(anchor + delta, -self.headroom), 1 + self.headroom) + return Step(value: self.clamp(anchor), anchor: anchor) + } + + static func clamp(_ value: Float) -> Float { + min(max(value, 0), 1) + } +} diff --git a/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift b/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift index f32c03572..6b5536430 100644 --- a/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift +++ b/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift @@ -131,6 +131,11 @@ class MainPrefsViewController: NSViewController, SettingsPane { } @IBAction func enableBrightnessSync(_ sender: NSButton) { + // While sync was off the user was free to move the displays apart, so drop the + // anchors and let the next change pick the offsets up from where they are now. + for display in DisplayManager.shared.displays { + display.brightnessSyncAnchor = nil + } switch sender.state { case .on: prefs.set(true, forKey: PrefKey.enableBrightnessSync.rawValue)