mirror of
https://github.com/MonitorControl/MonitorControl.git
synced 2026-05-16 06:05:52 -06:00
- **Menu Bar Slider**: - Redesigned layout to column style (icon above slider). - Aligned icon to left-start. - Increased vertical spacing and view height for better touch target and aesthetics. - Fixed icon vs slider overlap issues. - **Preferences UI**: - **Displays**: Fixed truncated friendly name text field, increased height, and improved vertical alignment. - **About**: Fixed truncated app name text. - **Keyboard**: Improved layout of accessibility troubleshooting buttons (Help/Reset), added container/label for better grid alignment. - **macOS Tahoe Support**: - Added accessibility permission reset functionality. - Implemented Custom HUD fallback for cases where native OSD is broken.
388 lines
16 KiB
Swift
388 lines
16 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: 1, alpha: 1)
|
|
let knobFillColorTracking = NSColor(white: 0.8, alpha: 1)
|
|
let knobStrokeColor = NSColor.systemGray.withAlphaComponent(0.5)
|
|
let knobShadowColor = NSColor(white: 0, alpha: 0.03)
|
|
let barFillColor = NSColor.systemGray.withAlphaComponent(0.2)
|
|
let barStrokeColor = NSColor.systemGray.withAlphaComponent(0.5)
|
|
let barFilledFillColor = NSColor(white: 1, alpha: 1)
|
|
let highlightDisplayIndicatorColor = NSColor(white: 0.85, alpha: 1) // This is visible if there is more the 2 displays
|
|
let tickMarkColor = NSColor.systemGray.withAlphaComponent(0.5)
|
|
|
|
let inset: CGFloat = 3.5
|
|
let offsetX: CGFloat = -1.5
|
|
let offsetY: CGFloat = -1.5
|
|
|
|
let tickMarkKnobExtraInset: CGFloat = 4
|
|
let tickMarkKnobExtraRadiusMultiplier: CGFloat = 0.25
|
|
|
|
var numOfTickmarks: Int = 0
|
|
var isHighlightDisplayItems: Bool = false
|
|
var displayHighlightItems: [CGDirectDisplayID: Float] = [:]
|
|
|
|
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.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(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()
|
|
self.barStrokeColor.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
|
|
}
|
|
}
|
|
}
|
|
|
|
public 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)
|
|
self.slider = slider
|
|
if !DEBUG_MACOS10, #available(macOS 11.0, *) {
|
|
slider.frame.size.width = 180
|
|
slider.frame.origin = NSPoint(x: 15, y: 8)
|
|
let view = NSView(frame: NSRect(x: 0, y: 0, width: slider.frame.width + 30 + (showPercent ? 38 : 0), height: slider.frame.height + 42))
|
|
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 = NSImage(systemSymbolName: iconName, accessibilityDescription: title)
|
|
icon.contentTintColor = NSColor.black.withAlphaComponent(0.6)
|
|
// Position icon at horizontal left (start), 8px above slider
|
|
let iconSize: CGFloat = 18
|
|
icon.frame = NSRect(x: slider.frame.origin.x, y: slider.frame.origin.y + slider.frame.height + 8, width: iconSize, height: iconSize)
|
|
icon.imageAlignment = .alignCenter
|
|
view.addSubview(slider)
|
|
view.addSubview(icon)
|
|
self.icon = icon
|
|
if showPercent {
|
|
let percentageBox = NSTextField(frame: NSRect(x: 15 + 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
|
|
}
|
|
}
|
|
if self.percentageBox == self.percentageBox {
|
|
self.percentageBox?.stringValue = "" + String(Int(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() {
|
|
// This looks hideous so I disable it for now. Maybe after a bit of tinkering it will look better
|
|
/*
|
|
if self.command == .audioSpeakerVolume {
|
|
let value = self.slider?.floatValue ?? 0.5
|
|
if value > 2/3 {
|
|
self.icon?.image = NSImage(systemSymbolName: "speaker.wave.3.fill", accessibilityDescription: "")
|
|
} else if value > 1/3 {
|
|
self.icon?.image = NSImage(systemSymbolName: "speaker.wave.2.fill", accessibilityDescription: "")
|
|
} else if value != 0 {
|
|
self.icon?.image = NSImage(systemSymbolName: "speaker.wave.1.fill", accessibilityDescription: "")
|
|
} else {
|
|
self.icon?.image = NSImage(systemSymbolName: "speaker.slash.fill", accessibilityDescription: "")
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
|
|
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)
|
|
}
|
|
if self.percentageBox == self.percentageBox {
|
|
self.percentageBox?.stringValue = "" + String(Int(value * 100)) + "%"
|
|
}
|
|
}
|
|
}
|
|
}
|