mirror of
https://github.com/MonitorControl/MonitorControl.git
synced 2026-05-16 06:05:52 -06:00
### Added/improved functionality - Added proper support for controlling Apple displays. - Added option to show/hide brightness slider. - Added option to show brightness slider for internal display and apple displays (enabled by default). - Replication of built-in and Apple display brightness to corresponding brightness slider. - Added suffix to similarly named displays for better differentiation. - Option to disable slider snapping for finer control + disable slider snapping by default. - Added option to show slider tick marks for better accuracy. - Added option to use window focus instead of mouse to determine which display to control. - Ctrl+Command+Brightness now controls external displays only (Ctrl+Brightness continues to control internal display only) - Added separate tab for menu options. - Added option to restore last saved values upon startup. - Added option for audio device name matching for display volume control selection. - Separated option to change all screens for brightness and volume. - Added option for keyboard fine scale for brightness. - Added option for keyboard fine scale for volume. - Added version check upon startup for mandatory preferences reset upon downgrade or incompatible previous version + notification about this. - Added implementation for Command+F1 macOS shortcut to enable/disable mirroring. - Added safer 'Assume last saved settings are valid' option as default instead of startup DDC read (or restore). - Streamlined preference panes, 'Show advanced settings' now affect all tabs. This leads to a better and safer first timer experience (especially because of the influx of many new features). - Added a Quit button to Preferences if menu is hidden (it was not passible to quit the application until this time in this mode only by re-enabling the menu). - Lowered default first-run volume DDC default from 75% to 15% if read is not possible or disabled to prevent unexpectedly loud sound. - Added slider skew setting on a per control basis to have the ability to manipulate DDC slider balance and OSD scale if display control is not linear. - Added the ability to set min. and max. DDC bounds on a per display, per control basis. - Audio device name override option for a display (manually assign a specific audio device to a display). - Advanced setting to invert DDC control range (some displays have the scale reversed). - Advanced setting to remap DDC control code (some displays have contrast and brightness mixed up). - Ability to mark a DDC control as available or unavailable in advanced settings under Displays. - Ability to automatically hide menu icon if there is no slider present in the menu. - Option to show slider percentage for more precision. - Option to set combined or separate OSD scale when combined hardware+software brightness is used. - Apple like smooth brightness change (both for software, hardware, mixed and DisplayServices). - Added support for DisplayLink, AirPlay, Sidecar, screen sharing etc. using window shades (this is an inferior technique to the existing software implementation - gamma control - but still better than nothing). Disabled for any kind of mirroring setups. _(Only on Big Sur and above)_ - Brightness change synchronisation from Built-In and Apple displays to other displays. This makes Touch Bar, Ambient light sensor, Control Center and System Preferences induced changes affect all displays. Synchronisation uses a sophisticated indirect delta method + the user can intervene and adjust individual screen brightness at any time to easily compensate mismatching native brightness levels. - Preferences pane tab selector has a simpler look on Catalina. - All menu sliders are now scrollable using a magic mouse/trackpad swipes or mouse wheel. - Added option for menu to show only items that are relevant to display which shows the menu currently. - Added option to enable combined sliders (note: this option combined with enabled Apple/built-in display syncing and enabled 'change all' keyboard settings finally provides full synchronised control of all displays). - Combined sliders can now display multiple displays when keyboard and brightness syncing is not enabled. _(Only on Big Sur and above)_ - Redesigned sliders to look like Big Sur/Monterey Control Center's sliders. _(Only on Big Sur and above)_ - Quit and Preferences... are now icons for a much cleaner look. _(Only on Big Sur and above)_ - Added option to change additional menu options style or hide them. _(Only on Big Sur and above)_ - Multiple displays are now in nice Big Sur styled blocks - no more ugly separators. _(Only on Big Sur and above)_ - Added customisable gamma/ddc switchover point for combined brightness in the advanced section of Displays. - Added comma separated list for control code override to enable edge cases like controlling Brightness and Contrast at the same time (use VCP list entry `10, 12` for that) - Contrast can now be controlled from keyboard via <kbd>control</kbd> + <kbd>option</kbd> + <kbd>command</kbd> + brightness up/down. - Custom keyboard shortcuts for brightness, contrast, volume and mute - Added automatic update. ### Other under the hood changes and bug fixes - Standardised internal scale among various displays and DDC ranges for ranged controls. - Uses the new internal scale for combined hardware-software brightness mode. - Migrated scales to internal float representation to prevent loss of fine detail on transformations. - Fixed double sound when muting multiple external displays at the same time. - Fixed lack of initial volume configuration if slider is not shown in menu. - Fixed wrong settings being applied to a display when replaced on Apple Silicon (UserDefaults preferences are now tied to specific display strings instead of CGDirectDisplayID - which is no longer semi-unique on arm64). - A lot of refactoring, streamlining and general optimisations.
273 lines
14 KiB
Swift
273 lines
14 KiB
Swift
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others
|
|
|
|
import AppKit
|
|
import os.log
|
|
|
|
class MenuHandler: NSMenu, NSMenuDelegate {
|
|
var combinedSliderHandler: [Command: SliderHandler] = [:]
|
|
|
|
var lastMenuRelevantDisplayId: CGDirectDisplayID = 0
|
|
|
|
func clearMenu() {
|
|
var items: [NSMenuItem] = []
|
|
for i in 0 ..< self.items.count {
|
|
items.append(self.items[i])
|
|
}
|
|
for item in items {
|
|
self.removeItem(item)
|
|
}
|
|
self.combinedSliderHandler.removeAll()
|
|
}
|
|
|
|
func menuWillOpen(_: NSMenu) {
|
|
self.updateMenuRelevantDisplay()
|
|
}
|
|
|
|
func updateMenus(dontClose: Bool = false) {
|
|
os_log("Menu update initiated", type: .debug)
|
|
if !dontClose {
|
|
self.cancelTrackingWithoutAnimation()
|
|
}
|
|
app.statusItem.isVisible = prefs.integer(forKey: PrefKey.menuIcon.rawValue) == MenuIcon.show.rawValue ? true : false
|
|
self.clearMenu()
|
|
let currentDisplay = DisplayManager.shared.getCurrentDisplay()
|
|
var displays: [Display] = []
|
|
if !prefs.bool(forKey: PrefKey.hideAppleFromMenu.rawValue) {
|
|
displays.append(contentsOf: DisplayManager.shared.getAppleDisplays())
|
|
}
|
|
if !prefs.bool(forKey: PrefKey.disableSoftwareFallback.rawValue) {
|
|
displays.append(contentsOf: DisplayManager.shared.getOtherDisplays())
|
|
} else {
|
|
displays.append(contentsOf: DisplayManager.shared.getDdcCapableDisplays())
|
|
}
|
|
let relevant = prefs.bool(forKey: PrefKey.slidersRelevant.rawValue)
|
|
let combine = prefs.bool(forKey: PrefKey.slidersCombine.rawValue)
|
|
let numOfDisplays = displays.count
|
|
if numOfDisplays != 0 {
|
|
let asSubMenu: Bool = (displays.count > 3 && !relevant && !combine && app.macOS10()) ? true : false
|
|
var iterator = 0
|
|
for display in displays where !relevant || display == currentDisplay {
|
|
iterator += 1
|
|
if !relevant, !combine, iterator != 1, app.macOS10() {
|
|
self.insertItem(NSMenuItem.separator(), at: 0)
|
|
}
|
|
self.updateDisplayMenu(display: display, asSubMenu: asSubMenu, numOfDisplays: numOfDisplays)
|
|
}
|
|
if combine {
|
|
self.addCombinedDisplayMenuBlock()
|
|
}
|
|
}
|
|
self.addDefaultMenuOptions()
|
|
}
|
|
|
|
func addSliderItem(monitorSubMenu: NSMenu, sliderHandler: SliderHandler) {
|
|
let item = NSMenuItem()
|
|
item.view = sliderHandler.view
|
|
monitorSubMenu.insertItem(item, at: 0)
|
|
if app.macOS10() {
|
|
let sliderHeaderItem = NSMenuItem()
|
|
let attrs: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.systemGray, .font: NSFont.systemFont(ofSize: 12)]
|
|
sliderHeaderItem.attributedTitle = NSAttributedString(string: sliderHandler.title, attributes: attrs)
|
|
monitorSubMenu.insertItem(sliderHeaderItem, at: 0)
|
|
}
|
|
}
|
|
|
|
func setupMenuSliderHandler(command: Command, display: Display, title: String) -> SliderHandler {
|
|
if prefs.bool(forKey: PrefKey.slidersCombine.rawValue), let combinedHandler = self.combinedSliderHandler[command] {
|
|
combinedHandler.addDisplay(display)
|
|
display.sliderHandler[command] = combinedHandler
|
|
return combinedHandler
|
|
} else {
|
|
let sliderHandler = SliderHandler(display: display, command: command, title: title)
|
|
if prefs.bool(forKey: PrefKey.slidersCombine.rawValue) {
|
|
self.combinedSliderHandler[command] = sliderHandler
|
|
}
|
|
display.sliderHandler[command] = sliderHandler
|
|
return sliderHandler
|
|
}
|
|
}
|
|
|
|
func addDisplayMenuBlock(addedSliderHandlers: [SliderHandler], blockName: String, monitorSubMenu: NSMenu, numOfDisplays: Int, asSubMenu: Bool) {
|
|
if numOfDisplays > 1, !prefs.bool(forKey: PrefKey.slidersRelevant.rawValue), !DEBUG_MACOS10, #available(macOS 11.0, *) {
|
|
class BlockView: NSView {
|
|
override func draw(_: NSRect) {
|
|
let radius = CGFloat(11)
|
|
let outerMargin = CGFloat(15)
|
|
let blockRect = self.frame.insetBy(dx: outerMargin, dy: outerMargin / 2 + 2).offsetBy(dx: 0, dy: outerMargin / 2 * -1 + 7)
|
|
for i in 1 ... 5 {
|
|
let blockPath = NSBezierPath(roundedRect: blockRect.insetBy(dx: CGFloat(i) * -1, dy: CGFloat(i) * -1), xRadius: radius + CGFloat(i) * 0.5, yRadius: radius + CGFloat(i) * 0.5)
|
|
NSColor.black.withAlphaComponent(0.1 / CGFloat(i)).setStroke()
|
|
blockPath.stroke()
|
|
}
|
|
let blockPath = NSBezierPath(roundedRect: blockRect, xRadius: radius, yRadius: radius)
|
|
if [NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(effectiveAppearance.name) {
|
|
NSColor.systemGray.withAlphaComponent(0.3).setStroke()
|
|
blockPath.stroke()
|
|
}
|
|
if ![NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(effectiveAppearance.name) {
|
|
NSColor.white.withAlphaComponent(0.5).setFill()
|
|
blockPath.fill()
|
|
}
|
|
}
|
|
}
|
|
var contentWidth: CGFloat = 0
|
|
var contentHeight: CGFloat = 0
|
|
for addedSliderHandler in addedSliderHandlers {
|
|
contentWidth = max(addedSliderHandler.view!.frame.width, contentWidth)
|
|
contentHeight += addedSliderHandler.view!.frame.height
|
|
}
|
|
var blockNameView: NSTextField?
|
|
if blockName != "" {
|
|
contentHeight += 21
|
|
let attrs: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.textColor, .font: NSFont.boldSystemFont(ofSize: 12)]
|
|
blockNameView = NSTextField(labelWithAttributedString: NSAttributedString(string: blockName, attributes: attrs))
|
|
blockNameView?.alphaValue = 0.5
|
|
}
|
|
let margin = CGFloat(13)
|
|
let itemView = BlockView(frame: NSRect(x: 0, y: 0, width: contentWidth + margin * 2, height: contentHeight + margin * 2))
|
|
var sliderPosition = CGFloat(margin * -1 + 1)
|
|
for addedSliderHandler in addedSliderHandlers {
|
|
addedSliderHandler.view!.setFrameOrigin(NSPoint(x: margin, y: margin + sliderPosition + 13))
|
|
itemView.addSubview(addedSliderHandler.view!)
|
|
sliderPosition += addedSliderHandler.view!.frame.height
|
|
}
|
|
if let blockNameView = blockNameView {
|
|
blockNameView.setFrameOrigin(NSPoint(x: margin + 13, y: contentHeight - 8))
|
|
itemView.addSubview(blockNameView)
|
|
}
|
|
let item = NSMenuItem()
|
|
item.view = itemView
|
|
monitorSubMenu.insertItem(item, at: 0)
|
|
} else {
|
|
for addedSliderHandler in addedSliderHandlers {
|
|
self.addSliderItem(monitorSubMenu: monitorSubMenu, sliderHandler: addedSliderHandler)
|
|
}
|
|
}
|
|
self.appendMenuHeader(friendlyName: blockName, monitorSubMenu: monitorSubMenu, asSubMenu: asSubMenu, numOfDisplays: numOfDisplays)
|
|
}
|
|
|
|
func addCombinedDisplayMenuBlock() {
|
|
if let sliderHandler = self.combinedSliderHandler[.audioSpeakerVolume] {
|
|
self.addSliderItem(monitorSubMenu: self, sliderHandler: sliderHandler)
|
|
}
|
|
if let sliderHandler = self.combinedSliderHandler[.contrast] {
|
|
self.addSliderItem(monitorSubMenu: self, sliderHandler: sliderHandler)
|
|
}
|
|
if let sliderHandler = self.combinedSliderHandler[.brightness] {
|
|
self.addSliderItem(monitorSubMenu: self, sliderHandler: sliderHandler)
|
|
}
|
|
}
|
|
|
|
func updateDisplayMenu(display: Display, asSubMenu: Bool, numOfDisplays: Int) {
|
|
os_log("Addig menu items for display %{public}@", type: .info, "\(display.identifier)")
|
|
let monitorSubMenu: NSMenu = asSubMenu ? NSMenu() : self
|
|
var addedSliderHandlers: [SliderHandler] = []
|
|
display.sliderHandler[.audioSpeakerVolume] = nil
|
|
if let otherDisplay = display as? OtherDisplay, !otherDisplay.isSw(), !display.readPrefAsBool(key: .unavailableDDC, for: .audioSpeakerVolume), !prefs.bool(forKey: PrefKey.hideVolume.rawValue) {
|
|
let title = NSLocalizedString("Volume", comment: "Shown in menu")
|
|
addedSliderHandlers.append(self.setupMenuSliderHandler(command: .audioSpeakerVolume, display: display, title: title))
|
|
}
|
|
display.sliderHandler[.contrast] = nil
|
|
if let otherDisplay = display as? OtherDisplay, !otherDisplay.isSw(), !display.readPrefAsBool(key: .unavailableDDC, for: .contrast), prefs.bool(forKey: PrefKey.showContrast.rawValue) {
|
|
let title = NSLocalizedString("Contrast", comment: "Shown in menu")
|
|
addedSliderHandlers.append(self.setupMenuSliderHandler(command: .contrast, 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")
|
|
addedSliderHandlers.append(self.setupMenuSliderHandler(command: .brightness, display: display, title: title))
|
|
}
|
|
if !prefs.bool(forKey: PrefKey.slidersCombine.rawValue) {
|
|
self.addDisplayMenuBlock(addedSliderHandlers: addedSliderHandlers, blockName: display.readPrefAsString(key: .friendlyName) != "" ? display.readPrefAsString(key: .friendlyName) : display.name, monitorSubMenu: monitorSubMenu, numOfDisplays: numOfDisplays, asSubMenu: asSubMenu)
|
|
}
|
|
if addedSliderHandlers.count > 0, prefs.integer(forKey: PrefKey.menuIcon.rawValue) == MenuIcon.sliderOnly.rawValue {
|
|
app.statusItem.isVisible = true
|
|
}
|
|
}
|
|
|
|
private func appendMenuHeader(friendlyName: String, monitorSubMenu: NSMenu, asSubMenu: Bool, numOfDisplays: Int) {
|
|
let monitorMenuItem = NSMenuItem()
|
|
if asSubMenu {
|
|
monitorMenuItem.title = "\(friendlyName)"
|
|
monitorMenuItem.submenu = monitorSubMenu
|
|
self.insertItem(monitorMenuItem, at: 0)
|
|
} else if app.macOS10(), numOfDisplays > 1 {
|
|
let attrs: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.systemGray, .font: NSFont.boldSystemFont(ofSize: 12)]
|
|
monitorMenuItem.attributedTitle = NSAttributedString(string: "\(friendlyName)", attributes: attrs)
|
|
self.insertItem(monitorMenuItem, at: 0)
|
|
}
|
|
}
|
|
|
|
func updateMenuRelevantDisplay() {
|
|
if prefs.bool(forKey: PrefKey.slidersRelevant.rawValue) {
|
|
if let display = DisplayManager.shared.getCurrentDisplay(), display.identifier != self.lastMenuRelevantDisplayId {
|
|
os_log("Menu must be refreshed as relevant display changed since last time.")
|
|
self.lastMenuRelevantDisplayId = display.identifier
|
|
self.updateMenus(dontClose: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func addDefaultMenuOptions() {
|
|
if !DEBUG_MACOS10, #available(macOS 11.0, *), prefs.integer(forKey: PrefKey.menuItemStyle.rawValue) == MenuItemStyle.icon.rawValue {
|
|
let iconSize = CGFloat(22)
|
|
let viewWidth = max(120, self.size.width)
|
|
var compensateForBlock: CGFloat = 0
|
|
if viewWidth > 230 { // if there are display blocks, we need to compensate a bit for the negative inset of the blocks
|
|
compensateForBlock = 4
|
|
}
|
|
|
|
let menuItemView = NSView(frame: NSRect(x: 0, y: 0, width: viewWidth, height: iconSize + 10))
|
|
|
|
let preferencesIcon = NSButton()
|
|
preferencesIcon.bezelStyle = .regularSquare
|
|
preferencesIcon.isBordered = false
|
|
preferencesIcon.setButtonType(.momentaryChange)
|
|
preferencesIcon.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: NSLocalizedString("Preferences...", comment: "Shown in menu"))
|
|
preferencesIcon.alternateImage = NSImage(systemSymbolName: "ellipsis.circle.fill", accessibilityDescription: NSLocalizedString("Preferences...", comment: "Shown in menu"))
|
|
preferencesIcon.alphaValue = 0.3
|
|
preferencesIcon.frame = NSRect(x: menuItemView.frame.maxX - iconSize - 16 + compensateForBlock, y: menuItemView.frame.origin.y + 5, width: iconSize, height: iconSize)
|
|
preferencesIcon.imageScaling = .scaleProportionallyUpOrDown
|
|
preferencesIcon.action = #selector(app.prefsClicked)
|
|
|
|
let updateIcon = NSButton()
|
|
updateIcon.bezelStyle = .regularSquare
|
|
updateIcon.isBordered = false
|
|
updateIcon.setButtonType(.momentaryChange)
|
|
updateIcon.image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath.circle", accessibilityDescription: NSLocalizedString("Check for updates...", comment: "Shown in menu"))
|
|
updateIcon.alternateImage = NSImage(systemSymbolName: "arrow.triangle.2.circlepath.circle.fill", accessibilityDescription: NSLocalizedString("Check for updates...", comment: "Shown in menu"))
|
|
updateIcon.alphaValue = 0.3
|
|
updateIcon.frame = NSRect(x: menuItemView.frame.maxX - iconSize * 2 - 10 - 16 + compensateForBlock, y: menuItemView.frame.origin.y + 5, width: iconSize, height: iconSize)
|
|
updateIcon.imageScaling = .scaleProportionallyUpOrDown
|
|
updateIcon.action = #selector(app.updaterController.checkForUpdates(_:))
|
|
updateIcon.target = app.updaterController
|
|
|
|
let quitIcon = NSButton()
|
|
quitIcon.bezelStyle = .regularSquare
|
|
quitIcon.isBordered = false
|
|
quitIcon.setButtonType(.momentaryChange)
|
|
quitIcon.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: NSLocalizedString("Quit", comment: "Shown in menu"))
|
|
quitIcon.alternateImage = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: NSLocalizedString("Quit", comment: "Shown in menu"))
|
|
quitIcon.alphaValue = 0.3
|
|
quitIcon.frame = NSRect(x: menuItemView.frame.maxX - iconSize * 3 - 20 - 16 + compensateForBlock, y: menuItemView.frame.origin.y + 5, width: iconSize, height: iconSize)
|
|
quitIcon.imageScaling = .scaleProportionallyUpOrDown
|
|
quitIcon.action = #selector(app.quitClicked)
|
|
|
|
menuItemView.addSubview(preferencesIcon)
|
|
menuItemView.addSubview(updateIcon)
|
|
menuItemView.addSubview(quitIcon)
|
|
let item = NSMenuItem()
|
|
item.view = menuItemView
|
|
self.insertItem(item, at: self.items.count)
|
|
} else if prefs.integer(forKey: PrefKey.menuItemStyle.rawValue) != MenuItemStyle.hide.rawValue {
|
|
if app.macOS10() {
|
|
self.insertItem(NSMenuItem.separator(), at: self.items.count)
|
|
}
|
|
self.insertItem(withTitle: NSLocalizedString("Preferences...", comment: "Shown in menu"), action: #selector(app.prefsClicked), keyEquivalent: "", at: self.items.count)
|
|
let updateItem = NSMenuItem(title: NSLocalizedString("Check for updates...", comment: "Shown in menu"), action: #selector(app.updaterController.checkForUpdates(_:)), keyEquivalent: "")
|
|
updateItem.target = app.updaterController
|
|
self.insertItem(updateItem, at: self.items.count)
|
|
self.insertItem(withTitle: NSLocalizedString("Quit", comment: "Shown in menu"), action: #selector(app.quitClicked), keyEquivalent: "", at: self.items.count)
|
|
}
|
|
}
|
|
}
|