diff --git a/.gitignore b/.gitignore index 639728b..62a6af9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ Carthage .DS_Store ### Xcode ### -xcuserdata/ \ No newline at end of file +xcuserdata/build/ diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index 3ae92ca..b70ba04 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -50,6 +50,8 @@ AACE5E2327050C63006C2A48 /* NSNotification+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACE5E2227050C63006C2A48 /* NSNotification+Extension.swift */; }; AAD7DD342CAFF3D90062822F /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = AAD7DD332CAFF3D90062822F /* Settings */; }; AADB625A26BC196900DFFAA5 /* DisplayServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AADB625926BC196900DFFAA5 /* DisplayServices.framework */; }; + E1D2C3B52F10000100000001 /* EdgeScrollManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2C3B42F10000100000001 /* EdgeScrollManager.swift */; }; + E1D2C3B72F10000100000001 /* MousePrefsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2C3B62F10000100000001 /* MousePrefsViewController.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 */; }; @@ -166,6 +168,8 @@ B7FA437E2AC5857A00A94C01 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; 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 = ""; }; + E1D2C3B42F10000100000001 /* EdgeScrollManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeScrollManager.swift; sourceTree = ""; }; + E1D2C3B62F10000100000001 /* MousePrefsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MousePrefsViewController.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 = ""; }; @@ -314,6 +318,7 @@ 6C85EFD922C941B000227EA1 /* DisplayManager.swift */, AA25F6D626E68C160087F3A2 /* MediaKeyTapManager.swift */, AA44E70627038F7F00E06865 /* KeyboardShortcutsManager.swift */, + E1D2C3B42F10000100000001 /* EdgeScrollManager.swift */, AA16139A26BE772E00DCF027 /* Arm64DDC.swift */, AA4398A826DD55DA00943F16 /* IntelDDC.swift */, FE4E0895249D584C003A50BB /* OSDUtils.swift */, @@ -356,6 +361,7 @@ F0445D3720023E710025AE82 /* MainPrefsViewController.swift */, AA25F6CE26E680510087F3A2 /* MenuslidersPrefsViewController.swift */, AA25F6D026E681D30087F3A2 /* KeyboardPrefsViewController.swift */, + E1D2C3B62F10000100000001 /* MousePrefsViewController.swift */, AA062E8926C9A039007E628C /* DisplaysPrefsViewController.swift */, AA062E8D26CA7BE5007E628C /* DisplaysPrefsCellView.swift */, AA665A5C26C5892800FEF2C1 /* AboutPrefsViewController.swift */, @@ -628,6 +634,7 @@ 6CBFE27C23DB27A200D1BC41 /* AppleDisplay.swift in Sources */, AA062E8E26CA7BE5007E628C /* DisplaysPrefsCellView.swift in Sources */, AA25F6D726E68C160087F3A2 /* MediaKeyTapManager.swift in Sources */, + E1D2C3B52F10000100000001 /* EdgeScrollManager.swift in Sources */, FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */, 6CBFE27A23DB266000D1BC41 /* Display.swift in Sources */, AA44E70727038F7F00E06865 /* KeyboardShortcutsManager.swift in Sources */, @@ -640,6 +647,7 @@ 28D1DDF2227FBE71004CB494 /* NSScreen+Extension.swift in Sources */, AA99521726FE25AB00612E07 /* AppDelegate.swift in Sources */, AA25F6D126E681D30087F3A2 /* KeyboardPrefsViewController.swift in Sources */, + E1D2C3B72F10000100000001 /* MousePrefsViewController.swift in Sources */, F01B069F228221B7008E64DB /* SliderHandler.swift in Sources */, AACE5E2327050C63006C2A48 /* NSNotification+Extension.swift in Sources */, AA25F6CF26E680510087F3A2 /* MenuslidersPrefsViewController.swift in Sources */, @@ -862,6 +870,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = MonitorControl/MonitorControlDebug.entitlements; CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; @@ -869,7 +878,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = HNDG94D2RC; ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( "$(PROJECT_DIR)/**", @@ -900,13 +909,14 @@ buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = HNDG94D2RC; ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( "$(PROJECT_DIR)/**", @@ -946,7 +956,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = HNDG94D2RC; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = MonitorControlHelper/Info.plist; @@ -977,7 +987,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 7100; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 299YSU96J7; + DEVELOPMENT_TEAM = HNDG94D2RC; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = MonitorControlHelper/Info.plist; diff --git a/MonitorControl/Enums/PrefKey.swift b/MonitorControl/Enums/PrefKey.swift index 76b5f29..a59aea5 100644 --- a/MonitorControl/Enums/PrefKey.swift +++ b/MonitorControl/Enums/PrefKey.swift @@ -87,6 +87,18 @@ enum PrefKey: String { // Sliders for multiple displays case multiSliders + // Mouse wheel action on the left screen edge + case edgeScrollLeftAction + + // Mouse wheel action on the right screen edge + case edgeScrollRightAction + + // Mouse wheel edge control precision + case edgeScrollPrecision + + // Play feedback sound when controlling volume from the screen edge + case edgeScrollVolumeSoundFeedback + /* -- Display specific settings */ // Enable mute DDC for display @@ -213,3 +225,25 @@ enum KeyboardVolume: Int { case both = 2 case disabled = 3 } + +enum EdgeScrollAction: Int, CaseIterable { + case disabled = 0 + case brightness = 1 + case volume = 2 +} + +enum EdgeScrollPrecision: Int, CaseIterable { + case standard = 0 + case fine = 1 + case veryFine = 2 + case coarse = 3 + + var step: Float { + switch self { + case .standard: return 0.02 + case .fine: return 0.01 + case .veryFine: return 0.005 + case .coarse: return 0.05 + } + } +} diff --git a/MonitorControl/Extensions/Preferences+Extension.swift b/MonitorControl/Extensions/Preferences+Extension.swift index 2e4e4f0..01e4c49 100644 --- a/MonitorControl/Extensions/Preferences+Extension.swift +++ b/MonitorControl/Extensions/Preferences+Extension.swift @@ -8,6 +8,7 @@ extension Settings.PaneIdentifier { static let main = Self("Main") static let menusliders = Self("Menu & Sliders") static let keyboard = Self("Keyboard") + static let mouse = Self("Mouse") static let displays = Self("Displays") static let about = Self("About") } diff --git a/MonitorControl/Info.plist b/MonitorControl/Info.plist index 79873fe..0836a5b 100644 --- a/MonitorControl/Info.plist +++ b/MonitorControl/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 7141 + 7148 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/MonitorControl/Model/Display.swift b/MonitorControl/Model/Display.swift index b661249..e4f9601 100644 --- a/MonitorControl/Model/Display.swift +++ b/MonitorControl/Model/Display.swift @@ -108,6 +108,20 @@ class Display: Equatable { } } + func adjustBrightness(by delta: Float) { + guard !self.readPrefAsBool(key: .unavailableDDC, for: .brightness) else { + return + } + let value = max(0, min(1, self.getBrightness() + delta)) + if self.setBrightness(value) { + OSDUtils.showOsdProgress(displayID: self.identifier, command: .brightness, value: value) + if let slider = self.sliderHandler[.brightness] { + slider.setValue(value, displayID: self.identifier) + self.brightnessSyncSourceValue = value + } + } + } + func setBrightness(_ to: Float = -1, slow: Bool = false) -> Bool { if !prefs.bool(forKey: PrefKey.disableSmoothBrightness.rawValue) { return self.setSmoothBrightness(to, slow: slow) diff --git a/MonitorControl/Model/OtherDisplay.swift b/MonitorControl/Model/OtherDisplay.swift index 7c579c4..da9ab3f 100644 --- a/MonitorControl/Model/OtherDisplay.swift +++ b/MonitorControl/Model/OtherDisplay.swift @@ -165,14 +165,32 @@ class OtherDisplay: Display { (command == .audioSpeakerVolume && self.readPrefAsBool(key: .enableMuteUnmute) && self.readPrefAsInt(for: .audioMuteScreenBlank) == 1) ? 0 : self.readPrefAsFloat(for: command) } + func canAdjustVolume() -> Bool { + !self.isSw() && !self.readPrefAsBool(key: .unavailableDDC, for: .audioSpeakerVolume) + } + func stepVolume(isUp: Bool, isSmallIncrement: Bool) { - guard !self.readPrefAsBool(key: .unavailableDDC, for: .audioSpeakerVolume) else { + guard self.canAdjustVolume() else { OSDUtils.showOsdVolumeDisabled(displayID: self.identifier) return } let currentValue = self.readPrefAsFloat(for: .audioSpeakerVolume) - var muteValue: Int? let volumeOSDValue = self.calcNewValue(currentValue: currentValue, isUp: isUp, isSmallIncrement: isSmallIncrement) + self.setVolume(volumeOSDValue, roundChiclet: !isSmallIncrement) + } + + func adjustVolume(by delta: Float, forceOsd: Bool = false) { + guard self.canAdjustVolume() else { + OSDUtils.showOsdVolumeDisabled(displayID: self.identifier) + return + } + let currentValue = self.readPrefAsFloat(for: .audioSpeakerVolume) + let volumeOSDValue = max(0, min(1, currentValue + delta)) + self.setVolume(volumeOSDValue, roundChiclet: false, forceOsd: forceOsd) + } + + private func setVolume(_ volumeOSDValue: Float, roundChiclet: Bool, forceOsd: Bool = false) { + var muteValue: Int? if self.readPrefAsInt(for: .audioMuteScreenBlank) == 1, volumeOSDValue > 0 { muteValue = 2 } else if self.readPrefAsInt(for: .audioMuteScreenBlank) != 1, volumeOSDValue == 0 { @@ -188,8 +206,12 @@ class OtherDisplay: Display { self.writeDDCValues(command: .audioSpeakerVolume, value: self.convValueToDDC(for: .audioSpeakerVolume, from: volumeOSDValue)) } } - if !self.readPrefAsBool(key: .hideOsd) { - OSDUtils.showOsd(displayID: self.identifier, command: .audioSpeakerVolume, value: volumeOSDValue, roundChiclet: !isSmallIncrement) + if forceOsd || !self.readPrefAsBool(key: .hideOsd) { + if roundChiclet { + OSDUtils.showOsd(displayID: self.identifier, command: .audioSpeakerVolume, value: volumeOSDValue, roundChiclet: true) + } else { + OSDUtils.showOsdProgress(displayID: self.identifier, command: .audioSpeakerVolume, value: volumeOSDValue) + } } if !isAlreadySet { self.savePref(volumeOSDValue, for: .audioSpeakerVolume) diff --git a/MonitorControl/Support/AppDelegate.swift b/MonitorControl/Support/AppDelegate.swift index 6d1013b..3a768d2 100644 --- a/MonitorControl/Support/AppDelegate.swift +++ b/MonitorControl/Support/AppDelegate.swift @@ -18,6 +18,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { }() var mediaKeyTap = MediaKeyTapManager() var keyboardShortcuts = KeyboardShortcutsManager() + var edgeScrollManager = EdgeScrollManager() let coreAudio = SimplyCoreAudio() var accessibilityObserver: NSObjectProtocol! var statusItemObserver: NSObjectProtocol! @@ -43,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { mainPrefsVc!, menuslidersPrefsVc!, keyboardPrefsVc!, + mousePrefsVc, displaysPrefsVc!, aboutPrefsVc!, ], @@ -62,6 +64,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.setPrefsBuildNumber() self.setDefaultPrefs() self.setMenu() + self.edgeScrollManager.update() CGDisplayRegisterReconfigurationCallback({ _, _, _ in app.displayReconfigured() }, nil) self.configure(firstrun: true) DisplayManager.shared.createGammaActivityEnforcer() @@ -112,6 +115,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Only settings that are not false, 0 or "" by default are set here. Assumes pre-wiped database. prefs.set(true, forKey: PrefKey.appAlreadyLaunched.rawValue) prefs.set(true, forKey: PrefKey.SUEnableAutomaticChecks.rawValue) + prefs.set(EdgeScrollAction.brightness.rawValue, forKey: PrefKey.edgeScrollLeftAction.rawValue) + prefs.set(EdgeScrollAction.volume.rawValue, forKey: PrefKey.edgeScrollRightAction.rawValue) + prefs.set(EdgeScrollPrecision.standard.rawValue, forKey: PrefKey.edgeScrollPrecision.rawValue) + prefs.set(true, forKey: PrefKey.edgeScrollVolumeSoundFeedback.rawValue) + } + if prefs.object(forKey: PrefKey.edgeScrollLeftAction.rawValue) == nil { + prefs.set(EdgeScrollAction.brightness.rawValue, forKey: PrefKey.edgeScrollLeftAction.rawValue) + } + if prefs.object(forKey: PrefKey.edgeScrollRightAction.rawValue) == nil { + prefs.set(EdgeScrollAction.volume.rawValue, forKey: PrefKey.edgeScrollRightAction.rawValue) + } + if prefs.object(forKey: PrefKey.edgeScrollPrecision.rawValue) == nil { + prefs.set(EdgeScrollPrecision.standard.rawValue, forKey: PrefKey.edgeScrollPrecision.rawValue) + } + if prefs.object(forKey: PrefKey.edgeScrollVolumeSoundFeedback.rawValue) == nil { + prefs.set(true, forKey: PrefKey.edgeScrollVolumeSoundFeedback.rawValue) } } @@ -161,7 +180,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func checkPermissions(firstAsk: Bool = false) { - let permissionsRequired: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) || [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue)) + let edgeScrollPermissionsRequired = EdgeScrollAction(rawValue: prefs.integer(forKey: PrefKey.edgeScrollLeftAction.rawValue)) != .disabled || EdgeScrollAction(rawValue: prefs.integer(forKey: PrefKey.edgeScrollRightAction.rawValue)) != .disabled + let permissionsRequired: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) || [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue)) || edgeScrollPermissionsRequired if !MediaKeyTapManager.readPrivileges(prompt: false), permissionsRequired { MediaKeyTapManager.acquirePrivileges(firstAsk: firstAsk) } @@ -174,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.wakeNotification), name: NSWorkspace.screensDidWakeNotification, object: nil) NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.sleepNotification), name: NSWorkspace.willSleepNotification, object: nil) NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.wakeNotification), name: NSWorkspace.didWakeNotification, object: nil) - _ = DistributedNotificationCenter.default().addObserver(forName: NSNotification.Name(rawValue: NSNotification.Name.accessibilityApi.rawValue), object: nil, queue: nil) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.updateMediaKeyTap() } } // listen for accessibility status changes + _ = DistributedNotificationCenter.default().addObserver(forName: NSNotification.Name(rawValue: NSNotification.Name.accessibilityApi.rawValue), object: nil, queue: nil) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.updateMediaKeyTap(); self.edgeScrollManager.update() } } // listen for accessibility status changes self.statusItemObserver = statusItem.observe(\.isVisible, options: [.old, .new]) { _, _ in self.statusItemVisibilityChanged() } } @@ -281,6 +301,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.setDefaultPrefs() self.checkPermissions() self.updateMediaKeyTap() + self.edgeScrollManager.update() self.configure(firstrun: true) } diff --git a/MonitorControl/Support/EdgeScrollManager.swift b/MonitorControl/Support/EdgeScrollManager.swift new file mode 100644 index 0000000..eaa3e60 --- /dev/null +++ b/MonitorControl/Support/EdgeScrollManager.swift @@ -0,0 +1,202 @@ +// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others + +import Cocoa +import CoreGraphics +import os.log +import SimplyCoreAudio + +private enum EdgeScrollSide { + case left + case right +} + +private func edgeScrollEventTapCallback(proxy _: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged? { + guard let refcon = refcon else { + return Unmanaged.passUnretained(event) + } + let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return manager.handleEventTap(type: type, event: event) +} + +class EdgeScrollManager { + private let edgeActivationWidth: CGFloat = 6 + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var globalScrollMonitor: Any? + private var localScrollMonitor: Any? + private var preciseScrollRemainder: CGFloat = 0 + private let volumeSoundFeedbackInterval: TimeInterval = 0.12 + private var lastVolumeSoundFeedbackTime: TimeInterval = 0 + + func update() { + self.stop() + guard self.isEnabled else { + return + } + self.startEventTap() + if self.eventTap == nil { + self.startEventMonitors() + } + } + + private var isEnabled: Bool { + self.action(for: .left) != .disabled || self.action(for: .right) != .disabled + } + + private func stop() { + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + } + if let runLoopSource = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + if let globalScrollMonitor = globalScrollMonitor { + NSEvent.removeMonitor(globalScrollMonitor) + } + if let localScrollMonitor = localScrollMonitor { + NSEvent.removeMonitor(localScrollMonitor) + } + self.eventTap = nil + self.runLoopSource = nil + self.globalScrollMonitor = nil + self.localScrollMonitor = nil + self.preciseScrollRemainder = 0 + } + + private func startEventTap() { + let eventMask = CGEventMask(1 << CGEventType.scrollWheel.rawValue) + guard let eventTap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: eventMask, callback: edgeScrollEventTapCallback, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { + os_log("Edge scroll event tap unavailable. Falling back to event monitors.", type: .info) + return + } + self.eventTap = eventTap + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + self.runLoopSource = runLoopSource + CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + private func startEventMonitors() { + self.globalScrollMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + _ = self?.handleScroll(event) + } + self.localScrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + if self?.handleScroll(event) == true { + return nil + } + return event + } + } + + fileprivate func handleEventTap(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let eventTap = self.eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + return Unmanaged.passUnretained(event) + } + guard type == .scrollWheel, let nsEvent = NSEvent(cgEvent: event) else { + return Unmanaged.passUnretained(event) + } + return self.handleScroll(nsEvent) ? nil : Unmanaged.passUnretained(event) + } + + private func handleScroll(_ event: NSEvent) -> Bool { + guard app.sleepID == 0, app.reconfigureID == 0, let target = self.targetForMouseLocation(), target.display.readPrefAsBool(key: .isDisabled) == false else { + return false + } + let selectedAction = self.action(for: target.side) + guard selectedAction != .disabled else { + return false + } + let stepCount = self.stepCount(from: event) + guard stepCount != 0 else { + return true + } + let delta = self.precision.step * Float(stepCount) + switch selectedAction { + case .brightness: + target.display.adjustBrightness(by: delta) + case .volume: + if !self.adjustSystemVolume(by: delta, displayID: target.display.identifier) { + return false + } + case .disabled: + break + } + return true + } + + private func adjustSystemVolume(by delta: Float, displayID: CGDirectDisplayID) -> Bool { + guard let defaultDevice = app.coreAudio.defaultOutputDevice, + defaultDevice.canSetVirtualMainVolume(scope: .output), + let currentVolume = defaultDevice.virtualMainVolume(scope: .output) else { + OSDUtils.showOsdVolumeDisabled(displayID: displayID) + return false + } + let nextVolume = max(0, min(1, currentVolume + Float32(delta))) + guard defaultDevice.setVirtualMainVolume(nextVolume, scope: .output) else { + OSDUtils.showOsdVolumeDisabled(displayID: displayID) + return false + } + OSDUtils.showOsdProgress(displayID: displayID, command: .audioSpeakerVolume, value: Float(nextVolume)) + self.playVolumeChangedSoundIfNeeded() + return true + } + + private func playVolumeChangedSoundIfNeeded() { + guard prefs.bool(forKey: PrefKey.edgeScrollVolumeSoundFeedback.rawValue) else { + return + } + let now = Date.timeIntervalSinceReferenceDate + guard now - self.lastVolumeSoundFeedbackTime >= self.volumeSoundFeedbackInterval else { + return + } + self.lastVolumeSoundFeedbackTime = now + DispatchQueue.main.async { + app.playVolumeChangedSound() + } + } + + private func action(for side: EdgeScrollSide) -> EdgeScrollAction { + let prefKey = side == .left ? PrefKey.edgeScrollLeftAction : PrefKey.edgeScrollRightAction + return EdgeScrollAction(rawValue: prefs.integer(forKey: prefKey.rawValue)) ?? .disabled + } + + private var precision: EdgeScrollPrecision { + EdgeScrollPrecision(rawValue: prefs.integer(forKey: PrefKey.edgeScrollPrecision.rawValue)) ?? .standard + } + + private func stepCount(from event: NSEvent) -> Int { + let scrollDelta = event.scrollingDeltaY + guard abs(scrollDelta) > 0.01 else { + return 0 + } + if event.hasPreciseScrollingDeltas { + self.preciseScrollRemainder += scrollDelta / 12 + guard abs(self.preciseScrollRemainder) >= 1 else { + return 0 + } + let steps = max(-3, min(3, Int(self.preciseScrollRemainder.rounded(.towardZero)))) + self.preciseScrollRemainder -= CGFloat(steps) + return steps + } + let steps = max(1, min(3, Int(abs(scrollDelta).rounded(.towardZero)))) + return scrollDelta > 0 ? steps : -steps + } + + private func targetForMouseLocation() -> (side: EdgeScrollSide, display: Display)? { + let mouseLocation = NSEvent.mouseLocation + guard let screen = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }), + let display = DisplayManager.shared.displays.first(where: { $0.identifier == screen.displayID }) else { + return nil + } + if mouseLocation.x <= screen.frame.minX + self.edgeActivationWidth { + return (.left, display) + } + if mouseLocation.x >= screen.frame.maxX - self.edgeActivationWidth { + return (.right, display) + } + return nil + } +} diff --git a/MonitorControl/Support/OSDUtils.swift b/MonitorControl/Support/OSDUtils.swift index a250273..c96402a 100644 --- a/MonitorControl/Support/OSDUtils.swift +++ b/MonitorControl/Support/OSDUtils.swift @@ -39,6 +39,10 @@ class OSDUtils: NSObject { manager.showImage(osdImage.rawValue, onDisplayID: displayID, priority: 0x1F4, msecUntilFade: 1000, filledChiclets: UInt32(filledChiclets), totalChiclets: UInt32(totalChiclets), locked: lock) } + static func showOsdProgress(displayID: CGDirectDisplayID, command: Command, value: Float, maxValue: Float = 1, lock: Bool = false) { + self.showOsd(displayID: displayID, command: command, value: value * 64, maxValue: maxValue * 64, roundChiclet: false, lock: lock) + } + static func showOsdVolumeDisabled(displayID: CGDirectDisplayID) { guard let manager = OSDManager.sharedManager() as? OSDManager else { return diff --git a/MonitorControl/UI/en.lproj/Localizable.strings b/MonitorControl/UI/en.lproj/Localizable.strings index a59a9fb..2579139 100644 --- a/MonitorControl/UI/en.lproj/Localizable.strings +++ b/MonitorControl/UI/en.lproj/Localizable.strings @@ -145,8 +145,41 @@ /* Shown in menu */ "Volume" = "Volume"; +/* Shown in Mouse Settings */ +"Volume feedback sound" = "Volume feedback sound"; + /* Shown in the alert dialog */ "Yes" = "Yes"; /* Shown in the alert dialog */ "You need to enable MonitorControl in System Settings > Security and Privacy > Accessibility for the keyboard shortcuts to work" = "You need to enable MonitorControl in System Settings > Security and Privacy > Accessibility for the keyboard shortcuts to work"; + +/* Shown in Mouse Settings */ +"Coarse (5%)" = "Coarse (5%)"; + +/* Shown in Mouse Settings */ +"Disabled" = "Disabled"; + +/* Shown in Mouse Settings */ +"Fine (1%)" = "Fine (1%)"; + +/* Shown in Mouse Settings */ +"Left screen edge" = "Left screen edge"; + +/* Shown in the main prefs window */ +"Mouse" = "Mouse"; + +/* Shown in Mouse Settings */ +"Move the pointer to a screen edge and use the scroll wheel to control the selected value on that screen." = "Move the pointer to a screen edge and use the scroll wheel to control the selected value on that screen."; + +/* Shown in Mouse Settings */ +"Right screen edge" = "Right screen edge"; + +/* Shown in Mouse Settings */ +"Scroll wheel precision" = "Scroll wheel precision"; + +/* Shown in Mouse Settings */ +"Standard (2%)" = "Standard (2%)"; + +/* Shown in Mouse Settings */ +"Very fine (0.5%)" = "Very fine (0.5%)"; diff --git a/MonitorControl/UI/zh-Hans.lproj/Localizable.strings b/MonitorControl/UI/zh-Hans.lproj/Localizable.strings index 90f19b5..58f6dff 100644 --- a/MonitorControl/UI/zh-Hans.lproj/Localizable.strings +++ b/MonitorControl/UI/zh-Hans.lproj/Localizable.strings @@ -147,8 +147,41 @@ /* Shown in menu */ "Volume" = "音量"; +/* Shown in Mouse Settings */ +"Volume feedback sound" = "音量反馈声音"; + /* Shown in the alert dialog */ "Yes" = "是"; /* Shown in the alert dialog */ "You need to enable MonitorControl in System Settings > Security and Privacy > Accessibility for the keyboard shortcuts to work" = "您需要在「系统偏好设置」>「安全性与隐私」>「辅助功能」中启用MonitorControl让键盘快捷键生效"; + +/* Shown in Mouse Settings */ +"Coarse (5%)" = "粗略 (5%)"; + +/* Shown in Mouse Settings */ +"Disabled" = "关闭"; + +/* Shown in Mouse Settings */ +"Fine (1%)" = "精细 (1%)"; + +/* Shown in Mouse Settings */ +"Left screen edge" = "屏幕左边缘"; + +/* Shown in the main prefs window */ +"Mouse" = "鼠标"; + +/* Shown in Mouse Settings */ +"Move the pointer to a screen edge and use the scroll wheel to control the selected value on that screen." = "将指针移到屏幕边缘,然后使用滚轮控制该屏幕上的选定项目。"; + +/* Shown in Mouse Settings */ +"Right screen edge" = "屏幕右边缘"; + +/* Shown in Mouse Settings */ +"Scroll wheel precision" = "滚轮调节精度"; + +/* Shown in Mouse Settings */ +"Standard (2%)" = "标准 (2%)"; + +/* Shown in Mouse Settings */ +"Very fine (0.5%)" = "非常精细 (0.5%)"; diff --git a/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift b/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift index f32c035..45e4179 100644 --- a/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift +++ b/MonitorControl/View Controllers/Preferences/MainPrefsViewController.swift @@ -64,6 +64,7 @@ class MainPrefsViewController: NSViewController, SettingsPane { // Preload Display settings to some extent to properly set up size in orther that animation won't fail menuslidersPrefsVc?.view.layoutSubtreeIfNeeded() keyboardPrefsVc?.view.layoutSubtreeIfNeeded() + mousePrefsVc.view.layoutSubtreeIfNeeded() displaysPrefsVc?.view.layoutSubtreeIfNeeded() aboutPrefsVc?.view.layoutSubtreeIfNeeded() self.updateGridLayout() @@ -152,6 +153,7 @@ class MainPrefsViewController: NSViewController, SettingsPane { self.populateSettings() menuslidersPrefsVc?.populateSettings() keyboardPrefsVc?.populateSettings() + mousePrefsVc.populateSettings() displaysPrefsVc?.populateSettings() } } diff --git a/MonitorControl/View Controllers/Preferences/MousePrefsViewController.swift b/MonitorControl/View Controllers/Preferences/MousePrefsViewController.swift new file mode 100644 index 0000000..4ce1fdf --- /dev/null +++ b/MonitorControl/View Controllers/Preferences/MousePrefsViewController.swift @@ -0,0 +1,153 @@ +// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others + +import Cocoa +import Settings + +private final class MousePrefsRootView: NSView { + override var intrinsicContentSize: NSSize { + NSSize(width: 480, height: 220) + } +} + +class MousePrefsViewController: NSViewController, SettingsPane { + let paneIdentifier = Settings.PaneIdentifier.mouse + let paneTitle: String = NSLocalizedString("Mouse", comment: "Shown in the main prefs window") + + var toolbarItemIcon: NSImage { + if !DEBUG_MACOS10, #available(macOS 11.0, *) { + return NSImage(systemSymbolName: "computermouse", accessibilityDescription: "Mouse") ?? NSImage(named: NSImage.infoName)! + } else { + return NSImage(named: NSImage.infoName)! + } + } + + private let leftEdgeAction = NSPopUpButton() + private let rightEdgeAction = NSPopUpButton() + private let scrollPrecision = NSPopUpButton() + private let volumeSoundFeedback = NSButton(checkboxWithTitle: "", target: nil, action: nil) + + override func loadView() { + self.view = MousePrefsRootView(frame: NSRect(x: 0, y: 0, width: 480, height: 220)) + self.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.view.widthAnchor.constraint(equalToConstant: 480), + self.view.heightAnchor.constraint(equalToConstant: 220), + ]) + self.buildView() + } + + override func viewDidLoad() { + super.viewDidLoad() + self.populateSettings() + } + + func populateSettings() { + self.leftEdgeAction.selectItem(withTag: prefs.integer(forKey: PrefKey.edgeScrollLeftAction.rawValue)) + self.rightEdgeAction.selectItem(withTag: prefs.integer(forKey: PrefKey.edgeScrollRightAction.rawValue)) + self.scrollPrecision.selectItem(withTag: prefs.integer(forKey: PrefKey.edgeScrollPrecision.rawValue)) + self.volumeSoundFeedback.state = prefs.bool(forKey: PrefKey.edgeScrollVolumeSoundFeedback.rawValue) ? .on : .off + } + + private func buildView() { + self.configureActionPopUp(self.leftEdgeAction) + self.configureActionPopUp(self.rightEdgeAction) + self.configurePrecisionPopUp(self.scrollPrecision) + + self.leftEdgeAction.target = self + self.leftEdgeAction.action = #selector(self.leftEdgeActionChanged(_:)) + self.rightEdgeAction.target = self + self.rightEdgeAction.action = #selector(self.rightEdgeActionChanged(_:)) + self.scrollPrecision.target = self + self.scrollPrecision.action = #selector(self.scrollPrecisionChanged(_:)) + self.volumeSoundFeedback.target = self + self.volumeSoundFeedback.action = #selector(self.volumeSoundFeedbackChanged(_:)) + self.volumeSoundFeedback.setAccessibilityLabel(NSLocalizedString("Volume feedback sound", comment: "Shown in Mouse Settings")) + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 14 + stack.edgeInsets = NSEdgeInsets(top: 24, left: 28, bottom: 24, right: 28) + stack.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(stack) + + stack.addArrangedSubview(self.makeRow(title: NSLocalizedString("Left screen edge", comment: "Shown in Mouse Settings"), control: self.leftEdgeAction)) + stack.addArrangedSubview(self.makeRow(title: NSLocalizedString("Right screen edge", comment: "Shown in Mouse Settings"), control: self.rightEdgeAction)) + stack.addArrangedSubview(self.makeRow(title: NSLocalizedString("Scroll wheel precision", comment: "Shown in Mouse Settings"), control: self.scrollPrecision)) + stack.addArrangedSubview(self.makeRow(title: NSLocalizedString("Volume feedback sound", comment: "Shown in Mouse Settings"), control: self.volumeSoundFeedback)) + + let infoLabel = NSTextField(labelWithString: NSLocalizedString("Move the pointer to a screen edge and use the scroll wheel to control the selected value on that screen.", comment: "Shown in Mouse Settings")) + infoLabel.textColor = .secondaryLabelColor + infoLabel.maximumNumberOfLines = 2 + infoLabel.lineBreakMode = .byWordWrapping + infoLabel.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(infoLabel) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + stack.topAnchor.constraint(equalTo: self.view.topAnchor), + stack.bottomAnchor.constraint(lessThanOrEqualTo: self.view.bottomAnchor), + infoLabel.widthAnchor.constraint(equalToConstant: 420), + ]) + } + + private func makeRow(title: String, control: NSView) -> NSStackView { + let label = NSTextField(labelWithString: title) + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + control.translatesAutoresizingMaskIntoConstraints = false + let row = NSStackView(views: [label, control]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 12 + NSLayoutConstraint.activate([ + label.widthAnchor.constraint(equalToConstant: 160), + control.widthAnchor.constraint(equalToConstant: 190), + ]) + return row + } + + private func configureActionPopUp(_ popUpButton: NSPopUpButton) { + popUpButton.removeAllItems() + self.addItem(to: popUpButton, title: NSLocalizedString("Disabled", comment: "Shown in Mouse Settings"), tag: EdgeScrollAction.disabled.rawValue) + self.addItem(to: popUpButton, title: NSLocalizedString("Brightness", comment: "Shown in Mouse Settings"), tag: EdgeScrollAction.brightness.rawValue) + self.addItem(to: popUpButton, title: NSLocalizedString("Volume", comment: "Shown in Mouse Settings"), tag: EdgeScrollAction.volume.rawValue) + } + + private func configurePrecisionPopUp(_ popUpButton: NSPopUpButton) { + popUpButton.removeAllItems() + self.addItem(to: popUpButton, title: NSLocalizedString("Standard (2%)", comment: "Shown in Mouse Settings"), tag: EdgeScrollPrecision.standard.rawValue) + self.addItem(to: popUpButton, title: NSLocalizedString("Fine (1%)", comment: "Shown in Mouse Settings"), tag: EdgeScrollPrecision.fine.rawValue) + self.addItem(to: popUpButton, title: NSLocalizedString("Very fine (0.5%)", comment: "Shown in Mouse Settings"), tag: EdgeScrollPrecision.veryFine.rawValue) + self.addItem(to: popUpButton, title: NSLocalizedString("Coarse (5%)", comment: "Shown in Mouse Settings"), tag: EdgeScrollPrecision.coarse.rawValue) + } + + private func addItem(to popUpButton: NSPopUpButton, title: String, tag: Int) { + popUpButton.addItem(withTitle: title) + popUpButton.lastItem?.tag = tag + } + + @objc private func leftEdgeActionChanged(_ sender: NSPopUpButton) { + prefs.set(sender.selectedTag(), forKey: PrefKey.edgeScrollLeftAction.rawValue) + self.edgeScrollSettingsChanged() + } + + @objc private func rightEdgeActionChanged(_ sender: NSPopUpButton) { + prefs.set(sender.selectedTag(), forKey: PrefKey.edgeScrollRightAction.rawValue) + self.edgeScrollSettingsChanged() + } + + @objc private func scrollPrecisionChanged(_ sender: NSPopUpButton) { + prefs.set(sender.selectedTag(), forKey: PrefKey.edgeScrollPrecision.rawValue) + self.edgeScrollSettingsChanged() + } + + @objc private func volumeSoundFeedbackChanged(_ sender: NSButton) { + prefs.set(sender.state == .on, forKey: PrefKey.edgeScrollVolumeSoundFeedback.rawValue) + } + + private func edgeScrollSettingsChanged() { + app.checkPermissions() + app.edgeScrollManager.update() + } +} diff --git a/MonitorControl/main.swift b/MonitorControl/main.swift index 0b628c7..e4a9678 100644 --- a/MonitorControl/main.swift +++ b/MonitorControl/main.swift @@ -25,6 +25,7 @@ let mainPrefsVc = storyboard.instantiateController(withIdentifier: "MainPrefsVC" let displaysPrefsVc = storyboard.instantiateController(withIdentifier: "DisplaysPrefsVC") as? DisplaysPrefsViewController let menuslidersPrefsVc = storyboard.instantiateController(withIdentifier: "MenuslidersPrefsVC") as? MenuslidersPrefsViewController let keyboardPrefsVc = storyboard.instantiateController(withIdentifier: "KeyboardPrefsVC") as? KeyboardPrefsViewController +let mousePrefsVc = MousePrefsViewController() let aboutPrefsVc = storyboard.instantiateController(withIdentifier: "AboutPrefsVC") as? AboutPrefsViewController let onboardingVc = storyboard.instantiateController(withIdentifier: "onboardingViewController") as? NSWindowController diff --git a/MonitorControlHelper/Info.plist b/MonitorControlHelper/Info.plist index e6cc0a2..4f5c7a5 100644 --- a/MonitorControlHelper/Info.plist +++ b/MonitorControlHelper/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 7141 + 7148 LSApplicationCategoryType public.app-category.utilities LSBackgroundOnly