diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index c6204059..a974d272 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 9eef114e..646fdbee 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 6a7c9767..dd816a54 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 00000000..a0b61293 --- /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 f32c0357..6b553643 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)