MonitorControl/MonitorControl/Support/SliderHandler.swift

433 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others
import Cocoa
import os.log
class SliderHandler {
var slider: MCSlider?
var view: NSView?
var percentageBox: NSTextField?
var displays: [Display] = []
var values: [CGDirectDisplayID: Float] = [:]
var title: String
let command: Command
var icon: ClickThroughImageView?
class MCSliderCell: NSSliderCell {
let knobFillColor = NSColor.white.withAlphaComponent(0.9)
let knobFillColorTracking = NSColor.white.withAlphaComponent(0.75)
let knobStrokeColor = NSColor.systemGray.withAlphaComponent(0.4)
let knobShadowColor = NSColor(white: 0, alpha: 0.05)
let barFillColor = NSColor.systemGray.withAlphaComponent(0.25)
let barStrokeColor = NSColor.systemGray.withAlphaComponent(0.4)
let barFilledFillColor = NSColor.white.withAlphaComponent(0.85)
let highlightDisplayIndicatorColor = NSColor.white.withAlphaComponent(0.85) // This is visible if there is more the 2 displays
let tickMarkColor = NSColor.systemGray.withAlphaComponent(0.4)
let inset: CGFloat = 3.5
let offsetX: CGFloat = -1.5
let offsetY: CGFloat = -1.5
let tickMarkKnobExtraInset: CGFloat = 4
let tickMarkKnobExtraRadiusMultiplier: CGFloat = 0.75
var numOfTickmarks: Int = 0
var isHighlightDisplayItems: Bool = false
var displayHighlightItems: [CGDirectDisplayID: Float] = [:]
/// Matches Control Centerstyle volume (accent) vs. brightness (neutral fill).
var useAccentFill: Bool = false
var isTracking: Bool = false
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override init() {
super.init()
}
override func barRect(flipped: Bool) -> NSRect {
let bar = super.barRect(flipped: flipped)
let knob = super.knobRect(flipped: flipped)
return NSRect(x: bar.origin.x, y: knob.origin.y, width: bar.width, height: knob.height).insetBy(dx: 0, dy: self.inset).offsetBy(dx: self.offsetX, dy: self.offsetY)
}
override func startTracking(at startPoint: NSPoint, in controlView: NSView) -> Bool {
self.isTracking = true
return super.startTracking(at: startPoint, in: controlView)
}
override func stopTracking(last lastPoint: NSPoint, current stopPoint: NSPoint, in controlView: NSView, mouseIsUp flag: Bool) {
self.isTracking = false
return super.stopTracking(last: lastPoint, current: stopPoint, in: controlView, mouseIsUp: flag)
}
override func drawKnob(_ knobRect: NSRect) {
guard !DEBUG_MACOS10, #available(macOS 11.0, *) else {
super.drawKnob(knobRect)
return
}
// This is intentionally empty as the knob is inside the bar. Please leave it like this!
}
override func drawBar(inside aRect: NSRect, flipped: Bool) {
guard !DEBUG_MACOS10, #available(macOS 11.0, *) else {
super.drawBar(inside: aRect, flipped: flipped)
return
}
var maxValue: Float = self.floatValue
var minValue: Float = self.floatValue
if self.isHighlightDisplayItems {
maxValue = max(self.displayHighlightItems.values.max() ?? 0, maxValue)
minValue = min(self.displayHighlightItems.values.min() ?? 1, minValue)
}
let barRadius = aRect.height * 0.5 * (self.numOfTickmarks == 0 ? 1 : self.tickMarkKnobExtraRadiusMultiplier)
let bar = NSBezierPath(roundedRect: aRect, xRadius: barRadius, yRadius: barRadius)
self.barFillColor.setFill()
bar.fill()
let barFilledWidth = (aRect.width - aRect.height) * CGFloat(maxValue) + aRect.height
let barFilledRect = NSRect(x: aRect.origin.x, y: aRect.origin.y, width: barFilledWidth, height: aRect.height)
let barFilled = NSBezierPath(roundedRect: barFilledRect, xRadius: barRadius, yRadius: barRadius)
(self.useAccentFill ? NSColor.controlAccentColor : self.barFilledFillColor).setFill()
barFilled.fill()
let knobMinX = aRect.origin.x + (aRect.width - aRect.height) * CGFloat(minValue)
let knobMaxX = aRect.origin.x + (aRect.width - aRect.height) * CGFloat(maxValue)
let knobRect = NSRect(x: knobMinX + (self.numOfTickmarks == 0 ? CGFloat(0) : self.tickMarkKnobExtraInset), y: aRect.origin.y, width: aRect.height + CGFloat(knobMaxX - knobMinX), height: aRect.height).insetBy(dx: self.numOfTickmarks == 0 ? CGFloat(0) : self.tickMarkKnobExtraInset, dy: 0)
let knobRadius = knobRect.height * 0.5 * (self.numOfTickmarks == 0 ? 1 : self.tickMarkKnobExtraRadiusMultiplier)
if self.numOfTickmarks > 0 {
for i in 1 ... self.numOfTickmarks - 2 {
let currentMarkLocation = CGFloat((Float(1) / Float(self.numOfTickmarks - 1)) * Float(i))
let tickMarkBounds = NSRect(x: aRect.origin.x + aRect.height + self.tickMarkKnobExtraInset - knobRect.height + self.tickMarkKnobExtraInset * 2 + CGFloat(Float((aRect.width - self.tickMarkKnobExtraInset * 5) * currentMarkLocation)), y: aRect.origin.y + aRect.height * (1 / 3), width: 4, height: aRect.height / 3)
let tickmark = NSBezierPath(roundedRect: tickMarkBounds, xRadius: 1, yRadius: 1)
self.tickMarkColor.setFill()
tickmark.fill()
}
}
let knobAlpha = CGFloat(max(0, min(1, (minValue - 0.08) * 5)))
for i in 1 ... 3 {
let knobShadow = NSBezierPath(roundedRect: knobRect.offsetBy(dx: CGFloat(-1 * 2 * i), dy: 0), xRadius: knobRadius, yRadius: knobRadius)
self.knobShadowColor.withAlphaComponent(self.knobShadowColor.alphaComponent * knobAlpha).setFill()
knobShadow.fill()
}
let knob = NSBezierPath(roundedRect: knobRect, xRadius: knobRadius, yRadius: knobRadius)
(self.isTracking ? self.knobFillColorTracking : self.knobFillColor).withAlphaComponent(self.knobFillColor.alphaComponent * knobAlpha).setFill()
knob.fill()
if self.isHighlightDisplayItems, self.displayHighlightItems.count > 2 {
for currentMarkLocation in self.displayHighlightItems.values {
let highlightKnobX = aRect.origin.x + (aRect.width - aRect.height) * CGFloat(currentMarkLocation)
let highlightKnobRect = NSRect(x: highlightKnobX + (self.numOfTickmarks == 0 ? CGFloat(0) : self.tickMarkKnobExtraInset), y: aRect.origin.y, width: aRect.height, height: aRect.height).insetBy(dx: (self.numOfTickmarks == 0 ? CGFloat(0) : self.tickMarkKnobExtraInset) + CGFloat(self.numOfTickmarks == 0 ? 6 : 3), dy: CGFloat(self.numOfTickmarks == 0 ? 6 : 6))
let highlightKnobRadius = highlightKnobRect.height * 0.5 * (self.numOfTickmarks == 0 ? 1 : self.tickMarkKnobExtraRadiusMultiplier)
let highlightKnob = NSBezierPath(roundedRect: highlightKnobRect, xRadius: highlightKnobRadius, yRadius: highlightKnobRadius)
let highlightDisplayIndicatorAlpha = CGFloat(max(0, min(1, (currentMarkLocation - 0.08) * 5)))
self.highlightDisplayIndicatorColor.withAlphaComponent(self.highlightDisplayIndicatorColor.alphaComponent * highlightDisplayIndicatorAlpha).setFill()
highlightKnob.fill()
}
}
self.knobStrokeColor.withAlphaComponent(self.knobStrokeColor.alphaComponent * knobAlpha).setStroke()
knob.stroke()
// Use a subtle stroke for the bar as well
self.barStrokeColor.withAlphaComponent(self.barStrokeColor.alphaComponent * (1 - knobAlpha * 0.5)).setStroke()
bar.stroke()
}
}
class MCSlider: NSSlider {
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.cell = MCSliderCell()
}
func setNumOfCustomTickmarks(_ numOfCustomTickmarks: Int) {
if let cell = self.cell as? MCSliderCell {
cell.numOfTickmarks = numOfCustomTickmarks
}
}
func setDisplayHighlightItems(_ isHighlightDisplayItems: Bool) {
if let cell = self.cell as? MCSliderCell {
cell.isHighlightDisplayItems = isHighlightDisplayItems
}
}
func setHighlightItem(_ displayID: CGDirectDisplayID, value: Float) {
if let cell = self.cell as? MCSliderCell {
cell.displayHighlightItems[displayID] = value
}
}
func removeHighlightItem(_ displayID: CGDirectDisplayID) {
if let cell = self.cell as? MCSliderCell {
if cell.displayHighlightItems[displayID] != nil {
cell.displayHighlightItems[displayID] = nil
}
}
}
func resetHighlightItems() {
if let cell = self.cell as? MCSliderCell {
cell.displayHighlightItems.removeAll()
}
}
// Credits for this class go to @thompsonate - https://github.com/thompsonate/Scrollable-NSSlider
override func scrollWheel(with event: NSEvent) {
guard self.isEnabled else { return }
let range = Float(self.maxValue - self.minValue)
var delta = Float(0)
if self.isVertical, self.sliderType == .linear {
delta = Float(event.deltaY)
} else if self.userInterfaceLayoutDirection == .rightToLeft {
delta = Float(event.deltaY + event.deltaX)
} else {
delta = Float(event.deltaY - event.deltaX)
}
if event.isDirectionInvertedFromDevice {
delta *= -1
}
let increment = range * delta / 100
let value = self.floatValue + increment
self.floatValue = value
self.sendAction(self.action, to: self.target)
}
}
class ClickThroughImageView: NSImageView {
override func hitTest(_ point: NSPoint) -> NSView? {
subviews.first { subview in subview.hitTest(point) != nil
}
}
func configureForMenuSymbol() {
self.wantsLayer = true
self.layer?.isOpaque = false
self.layer?.backgroundColor = NSColor.clear.cgColor
}
}
/// Renders menu SF Symbols without multicolor white matting around fills (e.g. sun.max.fill).
@available(macOS 11.0, *)
private static func menuSymbolImage(named name: String, pointSize: CGFloat, command: Command) -> NSImage? {
guard let base = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil }
let weight: NSFont.Weight = .medium
let baseConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight)
if #available(macOS 12.0, *) {
let paletteColor: NSColor
switch command {
case .brightness: paletteColor = .labelColor.withAlphaComponent(0.85)
default: paletteColor = .labelColor.withAlphaComponent(0.72)
}
let palette = NSImage.SymbolConfiguration(paletteColors: [paletteColor])
return base.withSymbolConfiguration(baseConfig.applying(palette))
}
return base.withSymbolConfiguration(baseConfig)
}
init(display: Display?, command: Command, title: String = "", position _: Int = 0) {
self.command = command
self.title = title
let slider = SliderHandler.MCSlider(value: 0, minValue: 0, maxValue: 1, target: self, action: #selector(SliderHandler.valueChanged))
let showPercent = prefs.bool(forKey: PrefKey.enableSliderPercent.rawValue)
slider.isEnabled = true
slider.setNumOfCustomTickmarks(prefs.bool(forKey: PrefKey.showTickMarks.rawValue) ? 5 : 0)
if let cell = slider.cell as? MCSliderCell {
cell.useAccentFill = command == .audioSpeakerVolume
}
self.slider = slider
if !DEBUG_MACOS10, #available(macOS 11.0, *) {
let iconSize: CGFloat = 18
let iconPadding: CGFloat = 10
slider.frame.size.width = 180
var sliderFrame = slider.frame
sliderFrame.size.height = max(sliderFrame.size.height, 22)
slider.frame = sliderFrame
// Horizontal layout: Icon then Slider
let iconX: CGFloat = 15
let sliderX = iconX + iconSize + iconPadding
slider.frame.origin = NSPoint(x: sliderX, y: 8)
let viewWidth = sliderX + slider.frame.width + 15 + (showPercent ? 38 : 0)
let viewHeight = slider.frame.height + 16
let view = NSView(frame: NSRect(x: 0, y: 0, width: viewWidth, height: viewHeight))
view.frame.origin = NSPoint(x: 12, y: 0)
var iconName = "circle.dashed"
switch command {
case .audioSpeakerVolume: iconName = "speaker.wave.2.fill"
case .brightness: iconName = "sun.max.fill"
case .contrast: iconName = "circle.lefthalf.fill"
default: break
}
let icon = SliderHandler.ClickThroughImageView()
icon.image = SliderHandler.menuSymbolImage(named: iconName, pointSize: iconSize, command: command)
icon.imageScaling = .scaleProportionallyDown
if #available(macOS 12.0, *) {
icon.contentTintColor = nil
} else {
icon.contentTintColor = .labelColor.withAlphaComponent(0.72)
}
// Position icon to the left of the slider, vertically centered
icon.frame = NSRect(x: iconX, y: slider.frame.origin.y + (slider.frame.height - iconSize) / 2, width: iconSize, height: iconSize)
icon.imageAlignment = .alignCenter
icon.configureForMenuSymbol()
view.addSubview(slider)
view.addSubview(icon)
self.icon = icon
if showPercent {
let percentageBox = NSTextField(frame: NSRect(x: sliderX + slider.frame.size.width + 2, y: slider.frame.origin.y + (slider.frame.height - 12) / 2, width: 40, height: 12))
self.setupPercentageBox(percentageBox)
self.percentageBox = percentageBox
view.addSubview(percentageBox)
}
self.view = view
} else {
slider.frame.size.width = 180
slider.frame.origin = NSPoint(x: 15, y: 5)
let view = NSView(frame: NSRect(x: 0, y: 0, width: slider.frame.width + 30 + (showPercent ? 38 : 0), height: slider.frame.height + 10))
view.addSubview(slider)
if showPercent {
let percentageBox = NSTextField(frame: NSRect(x: 15 + slider.frame.size.width - 2, y: 18, width: 40, height: 12))
self.setupPercentageBox(percentageBox)
self.percentageBox = percentageBox
view.addSubview(percentageBox)
}
self.view = view
}
slider.maxValue = 1
if let displayToAppend = display {
self.addDisplay(displayToAppend)
}
}
func addDisplay(_ display: Display) {
self.displays.append(display)
if let otherDisplay = display as? OtherDisplay {
let value = otherDisplay.setupSliderCurrentValue(command: self.command)
self.setValue(value, displayID: otherDisplay.identifier)
} else if let appleDisplay = display as? AppleDisplay {
if self.command == .brightness {
self.setValue(appleDisplay.getAppleBrightness(), displayID: appleDisplay.identifier)
}
}
}
func setupPercentageBox(_ percentageBox: NSTextField) {
percentageBox.font = NSFont.systemFont(ofSize: 12)
percentageBox.isEditable = false
percentageBox.isBordered = false
percentageBox.drawsBackground = false
percentageBox.alignment = .right
percentageBox.alphaValue = 0.7
}
func valueChangedOtherDisplay(otherDisplay: OtherDisplay, value: Float) {
// For the speaker volume slider, also set/unset the mute command when the value is changed from/to 0
if self.command == .audioSpeakerVolume, (otherDisplay.readPrefAsInt(for: .audioMuteScreenBlank) == 1 && value > 0) || (otherDisplay.readPrefAsInt(for: .audioMuteScreenBlank) != 1 && value == 0) {
otherDisplay.toggleMute(fromVolumeSlider: true)
}
if self.command == Command.brightness {
_ = otherDisplay.setBrightness(value)
return
} else if !otherDisplay.isSw() {
if self.command == Command.audioSpeakerVolume {
if !otherDisplay.readPrefAsBool(key: .enableMuteUnmute) || value != 0 {
otherDisplay.writeDDCValues(command: self.command, value: otherDisplay.convValueToDDC(for: self.command, from: value))
}
} else {
otherDisplay.writeDDCValues(command: self.command, value: otherDisplay.convValueToDDC(for: self.command, from: value))
}
otherDisplay.savePref(value, for: self.command)
}
}
@objc func valueChanged(slider: MCSlider) {
guard app.sleepID == 0, app.reconfigureID == 0 else {
return
}
var value = slider.floatValue
self.updateIcon()
if prefs.bool(forKey: PrefKey.enableSliderSnap.rawValue) {
let intPercent = Int(value * 100)
let snapInterval = 25
let snapThreshold = 3
let closest = (intPercent + snapInterval / 2) / snapInterval * snapInterval
if abs(closest - intPercent) <= snapThreshold {
value = Float(closest) / 100
slider.floatValue = value
}
}
self.percentageBox?.stringValue = String(format: "%.0f%%", Double(value) * 100)
for display in self.displays {
slider.setHighlightItem(display.identifier, value: value)
if self.command == .brightness, let appleDisplay = display as? AppleDisplay {
_ = appleDisplay.setBrightness(value)
} else if let otherDisplay = display as? OtherDisplay {
self.valueChangedOtherDisplay(otherDisplay: otherDisplay, value: value)
}
}
slider.setDisplayHighlightItems(false)
}
func updateIcon() {
if #available(macOS 11.0, *), self.command == .audioSpeakerVolume {
let value = self.slider?.floatValue ?? 0.5
let iconName: String
if value > 2/3 {
iconName = "speaker.wave.3.fill"
} else if value > 1/3 {
iconName = "speaker.wave.2.fill"
} else if value > 0 {
iconName = "speaker.wave.1.fill"
} else {
iconName = "speaker.slash.fill"
}
let iconSize: CGFloat = 18
self.icon?.image = SliderHandler.menuSymbolImage(named: iconName, pointSize: iconSize, command: self.command)
}
}
func setValue(_ value: Float, displayID: CGDirectDisplayID = 0) {
if let slider = self.slider {
if displayID != 0 {
self.values[displayID] = value
slider.setHighlightItem(displayID, value: value)
}
var sumVal: Float = 0
var maxVal: Float = 0
var minVal: Float = 1
var num = 0
for key in self.values.keys {
if let val = values[key] {
sumVal += val
maxVal = max(maxVal, val)
minVal = min(minVal, val)
num += 1
}
}
// let average = sumVal / Float(num)
slider.floatValue = value
self.updateIcon()
if abs(maxVal - minVal) > 0.001 {
slider.setDisplayHighlightItems(true)
} else {
slider.setDisplayHighlightItems(false)
}
self.percentageBox?.stringValue = String(format: "%.0f%%", Double(value) * 100)
}
}
}