MonitorControl/MonitorControl/Support/MenuHandler.swift
Istvan T 30da9a8020
Version 4.0.0 (#629)
### 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.
2021-09-30 23:05:24 +02:00

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