mirror of
https://github.com/MonitorControl/MonitorControl.git
synced 2026-05-18 06:05:54 -06:00
433 lines
18 KiB
Swift
433 lines
18 KiB
Swift
// 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 Center–style 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)
|
||
}
|
||
}
|
||
}
|