This commit is contained in:
Abdul Rehman 2026-05-09 17:47:28 +00:00 committed by GitHub
commit 31599a1815
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 597 additions and 62 deletions

14
.gitignore vendored
View file

@ -5,4 +5,16 @@ Carthage
.DS_Store
### Xcode ###
xcuserdata/
xcuserdata/
*.xcuserstate
*.xccheckout
*.xcscmblueprint
### Xcode / SwiftPM (local build outputs beside the project) ###
.build/
.derivedData/
build/
DerivedData/
### SwiftPM — user-specific; keep Package.resolved tracked ###
.swiftpm/xcode/xcuserdata/

View file

@ -58,6 +58,7 @@
F06792F6200A745F0066C438 /* MonitorControlHelper.app in [Login] Copy Helper to start at Login */ = {isa = PBXBuildFile; fileRef = F06792E7200A73460066C438 /* MonitorControlHelper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
F0A489C4279C71B200BEDFD6 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A489C3279C71B200BEDFD6 /* OnboardingViewController.swift */; };
FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4E0895249D584C003A50BB /* OSDUtils.swift */; };
CE12345678901234003A50BB /* CustomHUD.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE12345678901234003A50BC /* CustomHUD.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -181,6 +182,7 @@
FB5DB28F2AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/InternetAccessPolicy.strings"; sourceTree = "<group>"; };
FB5DB2902AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = "<group>"; };
FE4E0895249D584C003A50BB /* OSDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDUtils.swift; sourceTree = "<group>"; };
CE12345678901234003A50BC /* CustomHUD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomHUD.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -317,6 +319,7 @@
AA16139A26BE772E00DCF027 /* Arm64DDC.swift */,
AA4398A826DD55DA00943F16 /* IntelDDC.swift */,
FE4E0895249D584C003A50BB /* OSDUtils.swift */,
CE12345678901234003A50BC /* CustomHUD.swift */,
);
path = Support;
sourceTree = "<group>";
@ -629,6 +632,7 @@
AA062E8E26CA7BE5007E628C /* DisplaysPrefsCellView.swift in Sources */,
AA25F6D726E68C160087F3A2 /* MediaKeyTapManager.swift in Sources */,
FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */,
CE12345678901234003A50BB /* CustomHUD.swift in Sources */,
6CBFE27A23DB266000D1BC41 /* Display.swift in Sources */,
AA44E70727038F7F00E06865 /* KeyboardShortcutsManager.swift in Sources */,
F03A8DF21FFBAA6F0034DC27 /* OtherDisplay.swift in Sources */,

View file

@ -212,4 +212,6 @@ enum KeyboardVolume: Int {
case custom = 1
case both = 2
case disabled = 3
/// Like `media`, but always captures volume/mute keys even when macOS can control the default output device.
case mediaForce = 4
}

View file

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>7141</string>
<string>7184</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>

View file

@ -161,7 +161,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func checkPermissions(firstAsk: Bool = false) {
let permissionsRequired: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) || [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
let permissionsRequired: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue, KeyboardVolume.mediaForce.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) || [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
if !MediaKeyTapManager.readPrivileges(prompt: false), permissionsRequired {
MediaKeyTapManager.acquirePrivileges(firstAsk: firstAsk)
}

View file

@ -0,0 +1,243 @@
// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others
// CustomHUD.swift - Custom OSD overlay for macOS Tahoe 26+ compatibility
import Cocoa
// MARK: - Custom HUD Manager
/// Manages custom HUD windows for brightness/volume display on macOS 26+
/// where the native OSD API no longer works correctly
class CustomHUDManager {
static let shared = CustomHUDManager()
private var hudWindows: [CGDirectDisplayID: NSWindow] = [:]
private let hudLock = NSLock()
private init() {}
/// Shows a custom HUD on the specified display
func showHUD(displayID: CGDirectDisplayID, type: HUDType, value: Float, maxValue: Float = 1.0) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.hudLock.lock()
defer { self.hudLock.unlock() }
// Get or create HUD window for this display
let window: NSWindow
if let existingWindow = self.hudWindows[displayID] {
window = existingWindow
} else {
window = self.createHUDWindow(displayID: displayID)
self.hudWindows[displayID] = window
}
// Update and show the HUD
self.updateWindowContent(window: window, displayID: displayID, type: type, value: value, maxValue: maxValue)
self.showWindowWithFade(window: window)
}
}
private func createHUDWindow(displayID: CGDirectDisplayID) -> NSWindow {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 280, height: 64),
styleMask: [.nonactivatingPanel, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.level = .floating
window.isFloatingPanel = true
window.hidesOnDeactivate = false
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.isMovable = false
window.isMovableByWindowBackground = false
window.ignoresMouseEvents = true
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
positionWindow(window, onDisplay: displayID)
return window
}
private func positionWindow(_ window: NSWindow, onDisplay displayID: CGDirectDisplayID) {
guard let screen = getScreen(for: displayID) else { return }
let screenFrame = screen.visibleFrame
let windowSize = window.frame.size
// Position at center-bottom of screen, above the dock
let x = screenFrame.origin.x + (screenFrame.width - windowSize.width) / 2
let y = screenFrame.origin.y + 80 // 80 points from bottom for better visibility
window.setFrameOrigin(NSPoint(x: x, y: y))
}
private func getScreen(for displayID: CGDirectDisplayID) -> NSScreen? {
return NSScreen.screens.first { screen in
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else {
return false
}
return CGDirectDisplayID(screenNumber.uint32Value) == displayID
}
}
private func updateWindowContent(window: NSWindow, displayID: CGDirectDisplayID, type: HUDType, value: Float, maxValue: Float) {
// Create content view using AppKit for maximum compatibility
let contentView = createHUDContentView(type: type, value: value, maxValue: maxValue)
window.contentView = contentView
// Re-position in case screen changed
positionWindow(window, onDisplay: displayID)
}
private func createHUDContentView(type: HUDType, value: Float, maxValue: Float) -> NSView {
let w: CGFloat = 280
let h: CGFloat = 64
// Wrap the visual effect view in a transparent root view.
let rootView = NSView(frame: NSRect(x: 0, y: 0, width: w, height: h))
rootView.wantsLayer = true
rootView.layer?.backgroundColor = NSColor.clear.cgColor
let containerView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: w, height: h))
containerView.material = .hudWindow
containerView.blendingMode = .behindWindow
containerView.state = .active
containerView.appearance = NSAppearance(named: .vibrantDark)
containerView.wantsLayer = true
containerView.layer?.cornerRadius = h / 2
containerView.layer?.masksToBounds = true
// Add premium liquid glass edge highlight
containerView.layer?.borderWidth = 0.5
containerView.layer?.borderColor = NSColor.white.withAlphaComponent(0.25).cgColor
rootView.addSubview(containerView)
let iconSize: CGFloat = 24
let iconView = NSImageView(frame: NSRect(x: 20, y: (h - iconSize) / 2, width: iconSize, height: iconSize))
iconView.imageScaling = .scaleProportionallyDown
iconView.wantsLayer = true
iconView.layer?.isOpaque = false
iconView.layer?.backgroundColor = NSColor.clear.cgColor
if #available(macOS 11.0, *) {
if let icon = NSImage(systemSymbolName: type.iconSystemName, accessibilityDescription: nil) {
let config = NSImage.SymbolConfiguration(pointSize: iconSize * 0.9, weight: .bold)
iconView.image = icon.withSymbolConfiguration(config)
iconView.contentTintColor = .white
}
} else {
let fallbackIcon: String
switch type {
case .brightness: fallbackIcon = NSImage.touchBarComposeTemplateName
case .volume: fallbackIcon = NSImage.touchBarAudioOutputVolumeHighTemplateName
case .volumeMuted: fallbackIcon = NSImage.touchBarAudioOutputMuteTemplateName
case .contrast: fallbackIcon = NSImage.touchBarColorPickerFillName
}
iconView.image = NSImage(named: fallbackIcon)
iconView.contentTintColor = .white
}
containerView.addSubview(iconView)
// Thick Liquid Slider
let barX: CGFloat = 20 + iconSize + 16
let barW: CGFloat = w - barX - 24 // More compact, no trailing percentage label by default
let barH: CGFloat = 32 // Thicker, liquid-style bar
let barY = (h - barH) / 2
let progressBg = NSBox(frame: NSRect(x: barX, y: barY, width: barW, height: barH))
progressBg.boxType = .custom
progressBg.borderType = .noBorder
progressBg.fillColor = NSColor.white.withAlphaComponent(0.15)
progressBg.cornerRadius = barH / 2
containerView.addSubview(progressBg)
let normalizedValue = CGFloat(min(max(value / maxValue, 0), 1))
let fillWidth = max(barH, barW * normalizedValue)
let progressFill = NSBox(frame: NSRect(x: barX, y: barY, width: fillWidth, height: barH))
progressFill.boxType = .custom
progressFill.borderType = .noBorder
progressFill.fillColor = NSColor.white.withAlphaComponent(0.9)
progressFill.cornerRadius = barH / 2
containerView.addSubview(progressFill)
return rootView
}
private var fadeTimers: [CGDirectDisplayID: Timer] = [:]
private func showWindowWithFade(window: NSWindow) {
// Find the displayID for this window
guard let displayID = hudWindows.first(where: { $0.value === window })?.key else { return }
// Cancel any existing fade timer
fadeTimers[displayID]?.invalidate()
// Make window fully visible
window.alphaValue = 1.0
window.orderFrontRegardless()
// Schedule fade out after 1.5 seconds (common mode so it still fires during nested run-loop work)
let timer = Timer(timeInterval: 1.5, repeats: false) { [weak self, weak window] _ in
guard let window = window else { return }
self?.fadeOut(window: window)
}
fadeTimers[displayID] = timer
RunLoop.main.add(timer, forMode: .common)
}
private func fadeOut(window: NSWindow) {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
window.animator().alphaValue = 0
} completionHandler: {
window.orderOut(nil)
}
}
/// Cleans up HUD windows for removed displays
func cleanupDisplay(_ displayID: CGDirectDisplayID) {
hudLock.lock()
defer { hudLock.unlock() }
fadeTimers[displayID]?.invalidate()
fadeTimers.removeValue(forKey: displayID)
if let window = hudWindows[displayID] {
window.close()
hudWindows.removeValue(forKey: displayID)
}
}
}
// MARK: - HUD Type
enum HUDType {
case brightness
case volume
case volumeMuted
case contrast
var iconSystemName: String {
switch self {
case .brightness: return "sun.max.fill"
case .volume: return "speaker.wave.2.fill"
case .volumeMuted: return "speaker.slash.fill"
case .contrast: return "circle.lefthalf.filled"
}
}
var iconNSColor: NSColor {
switch self {
case .brightness: return .white.withAlphaComponent(0.9)
case .volume, .volumeMuted: return .white.withAlphaComponent(0.9)
case .contrast: return .white.withAlphaComponent(0.8)
}
}
}

View file

@ -312,6 +312,9 @@ class DisplayManager {
}
func clearDisplays() {
for display in self.displays {
CustomHUDManager.shared.cleanupDisplay(display.identifier)
}
self.displays = []
}

View file

@ -150,7 +150,7 @@ class MediaKeyTapManager: MediaKeyTapDelegate {
if [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue)) {
keys.append(contentsOf: [.brightnessUp, .brightnessDown])
}
if [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) {
if [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue, KeyboardVolume.mediaForce.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue)) {
keys.append(contentsOf: [.mute, .volumeUp, .volumeDown])
}
// Remove brightness keys if no external displays are connected, but only if brightness fine control is not active
@ -166,8 +166,8 @@ class MediaKeyTapManager: MediaKeyTapDelegate {
let keysToDelete: [MediaKey] = [.brightnessUp, .brightnessDown]
keys.removeAll { keysToDelete.contains($0) }
}
// Remove volume related keys if audio device is controllable
if let defaultAudioDevice = app.coreAudio.defaultOutputDevice {
// Remove volume related keys if audio device is controllable (skip when user chose force-capture mode)
if prefs.integer(forKey: PrefKey.keyboardVolume.rawValue) != KeyboardVolume.mediaForce.rawValue, let defaultAudioDevice = app.coreAudio.defaultOutputDevice {
let keysToDelete: [MediaKey] = [.volumeUp, .volumeDown, .mute]
if prefs.integer(forKey: PrefKey.multiKeyboardVolume.rawValue) == MultiKeyboardVolume.audioDeviceNameMatching.rawValue {
if DisplayManager.shared.updateAudioControlTargetDisplays(deviceName: defaultAudioDevice.name) == 0 {

View file

@ -103,25 +103,59 @@ class MenuHandler: NSMenu, NSMenuDelegate {
func addDisplayMenuBlock(addedSliderHandlers: [SliderHandler], blockName: String, monitorSubMenu: NSMenu, numOfDisplays: Int, asSubMenu: Bool) {
if numOfDisplays > 1, prefs.integer(forKey: PrefKey.multiSliders.rawValue) != MultiSliders.relevant.rawValue, !DEBUG_MACOS10, #available(macOS 11.0, *) {
class BlockView: NSView {
override func draw(_: NSRect) {
/// Draws the original soft outer rings + inner edge; layered on top of `NSVisualEffectView` (which does not reliably call `draw(_:)`).
class BlockBorderOverlayView: NSView {
override var isOpaque: Bool { false }
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
self.needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
let radius = prefs.bool(forKey: PrefKey.showTickMarks.rawValue) ? CGFloat(4) : 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)
let blockRect = self.bounds.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.lineWidth = 1
blockPath.stroke()
}
let blockPath = NSBezierPath(roundedRect: blockRect, xRadius: radius, yRadius: radius)
if [NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(effectiveAppearance.name) {
if [NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(self.effectiveAppearance.name) {
NSColor.systemGray.withAlphaComponent(0.3).setStroke()
blockPath.lineWidth = 1
blockPath.stroke()
} else {
// With `NSVisualEffectView` behind, skip the old semi-opaque white fill so the material shows; keep a clear inner edge.
NSColor.separatorColor.withAlphaComponent(0.45).setStroke()
blockPath.lineWidth = 1
blockPath.stroke()
}
if ![NSAppearance.Name.darkAqua, NSAppearance.Name.vibrantDark].contains(effectiveAppearance.name) {
NSColor.white.withAlphaComponent(0.5).setFill()
blockPath.fill()
}
}
}
class BlockView: NSVisualEffectView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
// Match the surrounding NSMenu chrome (display name row above, etc.); `.popover` reads as a different panel.
self.material = .menu
self.blendingMode = .withinWindow
self.state = .active
self.isEmphasized = false
self.wantsLayer = true
let tickMarks = prefs.bool(forKey: PrefKey.showTickMarks.rawValue)
self.layer?.cornerRadius = tickMarks ? CGFloat(10) : CGFloat(14)
self.layer?.masksToBounds = true
let borderOverlay = BlockBorderOverlayView(frame: self.bounds)
borderOverlay.autoresizingMask = [.width, .height]
self.addSubview(borderOverlay)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
var contentWidth: CGFloat = 0

View file

@ -21,7 +21,37 @@ class OSDUtils: NSObject {
return osdImage
}
/// Check if we're running on macOS 26 (Tahoe) or later where native OSD is broken
private static var shouldUseCustomHUD: Bool {
if #available(macOS 26, *) {
return true
}
return false
}
/// Convert Command to HUDType for custom HUD
private static func getHUDType(command: Command, value: Float) -> HUDType {
switch command {
case .audioSpeakerVolume:
return value > 0 ? .volume : .volumeMuted
case .audioMuteScreenBlank:
return .volumeMuted
case .contrast:
return .contrast
default:
return .brightness
}
}
static func showOsd(displayID: CGDirectDisplayID, command: Command, value: Float, maxValue: Float = 1, roundChiclet: Bool = false, lock: Bool = false) {
// Use custom HUD on macOS 26+ where native OSD is broken
if shouldUseCustomHUD {
let hudType = getHUDType(command: command, value: value)
CustomHUDManager.shared.showHUD(displayID: displayID, type: hudType, value: value, maxValue: maxValue)
return
}
// Fallback to native OSD for older macOS versions
guard let manager = OSDManager.sharedManager() as? OSDManager else {
return
}
@ -40,6 +70,11 @@ class OSDUtils: NSObject {
}
static func showOsdVolumeDisabled(displayID: CGDirectDisplayID) {
if shouldUseCustomHUD {
CustomHUDManager.shared.showHUD(displayID: displayID, type: .volumeMuted, value: 0, maxValue: 1)
return
}
guard let manager = OSDManager.sharedManager() as? OSDManager else {
return
}
@ -47,6 +82,11 @@ class OSDUtils: NSObject {
}
static func showOsdMuteDisabled(displayID: CGDirectDisplayID) {
if shouldUseCustomHUD {
CustomHUDManager.shared.showHUD(displayID: displayID, type: .volumeMuted, value: 0, maxValue: 1)
return
}
guard let manager = OSDManager.sharedManager() as? OSDManager else {
return
}
@ -54,6 +94,12 @@ class OSDUtils: NSObject {
}
static func popEmptyOsd(displayID: CGDirectDisplayID, command: Command) {
if shouldUseCustomHUD {
let hudType = getHUDType(command: command, value: 0)
CustomHUDManager.shared.showHUD(displayID: displayID, type: hudType, value: 0, maxValue: 1)
return
}
guard let manager = OSDManager.sharedManager() as? OSDManager else {
return
}
@ -75,3 +121,4 @@ class OSDUtils: NSObject {
abs(chiclet.rounded(.towardZero) - chiclet)
}
}

View file

@ -14,26 +14,28 @@ class SliderHandler {
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 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.25
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
@ -90,7 +92,7 @@ class SliderHandler {
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()
(self.useAccentFill ? NSColor.controlAccentColor : self.barFilledFillColor).setFill()
barFilled.fill()
let knobMinX = aRect.origin.x + (aRect.width - aRect.height) * CGFloat(minValue)
@ -116,7 +118,7 @@ class SliderHandler {
}
let knob = NSBezierPath(roundedRect: knobRect, xRadius: knobRadius, yRadius: knobRadius)
(self.isTracking ? self.knobFillColorTracking : self.knobFillColor).withAlphaComponent(knobAlpha).setFill()
(self.isTracking ? self.knobFillColorTracking : self.knobFillColor).withAlphaComponent(self.knobFillColor.alphaComponent * knobAlpha).setFill()
knob.fill()
if self.isHighlightDisplayItems, self.displayHighlightItems.count > 2 {
@ -133,7 +135,8 @@ class SliderHandler {
self.knobStrokeColor.withAlphaComponent(self.knobStrokeColor.alphaComponent * knobAlpha).setStroke()
knob.stroke()
self.barStrokeColor.setStroke()
// Use a subtle stroke for the bar as well
self.barStrokeColor.withAlphaComponent(self.barStrokeColor.alphaComponent * (1 - knobAlpha * 0.5)).setStroke()
bar.stroke()
}
}
@ -207,6 +210,30 @@ class SliderHandler {
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) {
@ -216,12 +243,28 @@ class SliderHandler {
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
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 + 14))
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"
@ -230,15 +273,23 @@ class SliderHandler {
default: break
}
let icon = SliderHandler.ClickThroughImageView()
icon.image = NSImage(systemSymbolName: iconName, accessibilityDescription: title)
icon.contentTintColor = NSColor.black.withAlphaComponent(0.6)
icon.frame = NSRect(x: view.frame.origin.x + 6.5, y: view.frame.origin.y + 13, width: 15, height: 15)
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: 15 + slider.frame.size.width - 2, y: 17, width: 40, height: 12))
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)
@ -320,9 +371,7 @@ class SliderHandler {
slider.floatValue = value
}
}
if self.percentageBox == self.percentageBox {
self.percentageBox?.stringValue = "" + String(Int(value * 100)) + "%"
}
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 {
@ -335,21 +384,21 @@ class SliderHandler {
}
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: "")
}
}
*/
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) {
@ -378,9 +427,7 @@ class SliderHandler {
} else {
slider.setDisplayHighlightItems(false)
}
if self.percentageBox == self.percentageBox {
self.percentageBox?.stringValue = "\(String(format: "%.0f%%", Double(value) * 100))"
}
self.percentageBox?.stringValue = String(format: "%.0f%%", Double(value) * 100)
}
}
}

View file

@ -709,7 +709,7 @@
<objects>
<viewController storyboardIdentifier="KeyboardPrefsVC" id="eJ2-Cv-Vfp" customClass="KeyboardPrefsViewController" customModule="MonitorControl" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" translatesAutoresizingMaskIntoConstraints="NO" id="yCl-jO-kpd">
<rect key="frame" x="0.0" y="0.0" width="730" height="676"/>
<rect key="frame" x="0.0" y="0.0" width="730" height="726"/>
<subviews>
<gridView xPlacement="leading" yPlacement="fill" rowAlignment="none" rowSpacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="bKX-vi-7gY">
<rect key="frame" x="20" y="20" width="690" height="636"/>
@ -997,13 +997,14 @@
</gridCell>
<gridCell row="6od-Ek-qTW" column="EiG-65-CTv" xPlacement="leading" id="pCd-dP-Y62">
<popUpButton key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9oZ-JM-bxD">
<rect key="frame" x="217" y="187" width="293" height="25"/>
<rect key="frame" x="217" y="187" width="360" height="25"/>
<popUpButtonCell key="cell" type="push" title="Standard keyboard volume and mute keys" bezelStyle="rounded" alignment="left" lineBreakMode="truncatingTail" state="on" borderStyle="borderAndBezel" imageScaling="proportionallyDown" inset="2" selectedItem="1sy-Kd-WL5" id="gTf-PQ-2fA">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="message"/>
<menu key="menu" id="LhY-cM-Msq">
<items>
<menuItem title="Standard keyboard volume and mute keys" state="on" id="1sy-Kd-WL5"/>
<menuItem title="Standard keys (force capture)" tag="4" id="fVk-mR-9Xp"/>
<menuItem title="Custom keyboard shortcuts" tag="1" id="4CG-0I-anB"/>
<menuItem title="Both standard and custom shortcuts" tag="2" id="QDG-SA-mRX">
<modifierMask key="keyEquivalentModifierMask"/>
@ -1164,7 +1165,7 @@
</gridView>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="bKX-vi-7gY" secondAttribute="bottom" constant="20" id="BWt-5s-vf7"/>
<constraint firstAttribute="bottom" secondItem="bKX-vi-7gY" secondAttribute="bottom" constant="55" id="BWt-5s-vf7"/>
<constraint firstItem="bKX-vi-7gY" firstAttribute="leading" secondItem="yCl-jO-kpd" secondAttribute="leading" constant="20" id="Pdh-1U-kqr"/>
<constraint firstItem="bKX-vi-7gY" firstAttribute="top" secondItem="yCl-jO-kpd" secondAttribute="top" constant="20" id="fmV-It-Xaq"/>
<constraint firstAttribute="trailing" secondItem="bKX-vi-7gY" secondAttribute="trailing" constant="20" id="ssM-r3-qK0"/>
@ -1280,7 +1281,7 @@
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" id="jz0-4Z-xVm">
<rect key="frame" x="210" y="449" width="166" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="#bc-ignore!" id="6GJ-6Q-gqz">
<textFieldCell key="cell" lineBreakMode="clipping" alignment="center" title="#bc-ignore!" id="6GJ-6Q-gqz">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
@ -1340,7 +1341,7 @@
</textFieldCell>
</textField>
<textField focusRingType="none" verticalHuggingPriority="750" id="mIA-Wh-7aB">
<rect key="frame" x="78" y="492" width="463" height="21"/>
<rect key="frame" x="78" y="490" width="463" height="28"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" title="#bc-ignore!" id="ibQ-4u-ClE">
<font key="font" textStyle="title2" name=".SFNS-Regular"/>
@ -1987,7 +1988,7 @@
</textFieldCell>
</textField>
<textField identifier="softwareNameLabel" focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" id="dtg-g7-hZb">
<rect key="frame" x="247" y="258" width="147" height="26"/>
<rect key="frame" x="247" y="254" width="200" height="30"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" lineBreakMode="clipping" alignment="left" title="MonitorControl" id="1PJ-14-Bvn">
<font key="font" textStyle="title1" name=".SFNS-Regular"/>

View file

@ -415,5 +415,8 @@
/* Class = "NSButtonCell"; title = "Show percentages"; ObjectID = "ZUu-MR-XwA"; */
"ZUu-MR-XwA.title" = "Show percentages";
/* Class = "NSMenuItem"; title = "Standard keys (force capture)"; ObjectID = "fVk-mR-9Xp"; */
"fVk-mR-9Xp.title" = "Standard keys (force capture)";
/* Class = "NSTextFieldCell"; title = "Combined dimming switchover point:"; ObjectID = "zv8-pZ-OPy"; */
"zv8-pZ-OPy.title" = "Combined dimming switchover point:";

View file

@ -27,7 +27,7 @@ class OnboardingViewController: NSViewController {
// MARK: - Style
private func setPermissionsButtonState() {
let volumePermissions: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
let volumePermissions: Bool = [KeyboardVolume.media.rawValue, KeyboardVolume.both.rawValue, KeyboardVolume.mediaForce.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
let brigthnessPermissions: Bool = [KeyboardBrightness.media.rawValue, KeyboardBrightness.both.rawValue].contains(prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
let permissionsRequired: Bool = volumePermissions || brigthnessPermissions
let enabled: Bool = !MediaKeyTapManager.readPrivileges(prompt: false) && permissionsRequired

View file

@ -4,6 +4,7 @@ import Cocoa
import KeyboardShortcuts
import ServiceManagement
import Settings
import os.log
class KeyboardPrefsViewController: NSViewController, SettingsPane {
let paneIdentifier = Settings.PaneIdentifier.keyboard
@ -46,6 +47,10 @@ class KeyboardPrefsViewController: NSViewController, SettingsPane {
@IBOutlet var rowUseAudioMouseText: NSGridRow!
@IBOutlet var rowUseAudioNameText: NSGridRow!
// Accessibility troubleshooting UI
var accessibilityHelpButton: NSButton?
var resetAccessibilityButton: NSButton?
func updateGridLayout() {
if self.keyboardBrightness.selectedTag() == KeyboardBrightness.media.rawValue {
self.rowKeyboardBrightnessPopUp.bottomPadding = -13
@ -141,9 +146,142 @@ class KeyboardPrefsViewController: NSViewController, SettingsPane {
self.customVolumeDown.addSubview(customVolumeDownRecorder)
self.customMute.addSubview(customMuteRecorder)
self.setupAccessibilityTroubleshootingUI()
self.populateSettings()
}
// MARK: - Accessibility Troubleshooting UI
private func setupAccessibilityTroubleshootingUI() {
// Create container view - height matches other grid rows
let containerHeight: CGFloat = 25
let containerView = NSView()
containerView.translatesAutoresizingMaskIntoConstraints = false
// Create "Troubleshooting:" label to match the existing UI style
let label = NSTextField(labelWithString: NSLocalizedString("Troubleshooting:", comment: "Label for troubleshooting section"))
label.font = NSFont.systemFont(ofSize: 13)
label.textColor = NSColor.labelColor
label.alignment = .right
label.translatesAutoresizingMaskIntoConstraints = false
// Create help button with system help style
let helpButton = NSButton()
helpButton.bezelStyle = .helpButton
helpButton.title = ""
helpButton.toolTip = NSLocalizedString("Keyboard shortcuts troubleshooting", comment: "Tooltip for help button")
helpButton.target = self
helpButton.action = #selector(showAccessibilityHelp(_:))
helpButton.translatesAutoresizingMaskIntoConstraints = false
self.accessibilityHelpButton = helpButton
// Create Reset Accessibility button
let resetButton = NSButton()
resetButton.title = NSLocalizedString("Reset Accessibility Permission", comment: "Button to reset accessibility")
resetButton.bezelStyle = .rounded
resetButton.toolTip = NSLocalizedString("Reset and re-request accessibility permission (fixes keyboard shortcuts on macOS Tahoe)", comment: "Tooltip for reset button")
resetButton.target = self
resetButton.action = #selector(resetAccessibilityPermission(_:))
resetButton.translatesAutoresizingMaskIntoConstraints = false
self.resetAccessibilityButton = resetButton
// Add all elements to container
containerView.addSubview(label)
containerView.addSubview(helpButton)
containerView.addSubview(resetButton)
// Add container to the main view
self.view.addSubview(containerView)
// Layout constraints - positioning to match grid spacing (10px from grid bottom)
NSLayoutConstraint.activate([
// Container positioning - bottom of view with grid-like padding
containerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 20),
containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -20),
containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -18),
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
// Label - right aligned at 212 points width to match storyboard column
label.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
label.widthAnchor.constraint(equalToConstant: 210),
label.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Help button - after label
helpButton.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8),
helpButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reset button - after help button
resetButton.leadingAnchor.constraint(equalTo: helpButton.trailingAnchor, constant: 8),
resetButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
}
@objc func showAccessibilityHelp(_ sender: NSButton) {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Keyboard Shortcuts Not Working?", comment: "Alert title")
alert.informativeText = NSLocalizedString("""
On macOS Tahoe (26+), you may need to reset accessibility permissions for keyboard shortcuts to work.
To fix this:
1. Click "Reset Accessibility Permission" below
2. When prompted, click "Open System Settings"
3. Enable MonitorControl in the Accessibility list
4. Restart MonitorControl if needed
Alternatively, go to:
System Settings Privacy & Security Accessibility
Remove MonitorControl, then add it back.
""", comment: "Accessibility troubleshooting guide")
alert.alertStyle = .informational
if #available(macOS 11.0, *) {
alert.icon = NSImage(systemSymbolName: "keyboard", accessibilityDescription: nil)
}
alert.addButton(withTitle: NSLocalizedString("OK", comment: "OK button"))
alert.addButton(withTitle: NSLocalizedString("Open Accessibility Settings", comment: "Button to open settings"))
let response = alert.runModal()
if response == .alertSecondButtonReturn {
self.openAccessibilitySettings()
}
}
@objc func resetAccessibilityPermission(_ sender: NSButton) {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Reset Accessibility Permission?", comment: "Confirmation alert title")
alert.informativeText = NSLocalizedString("This will reset MonitorControl's accessibility permission. You will need to grant permission again for keyboard shortcuts to work.", comment: "Confirmation message")
alert.alertStyle = .warning
alert.addButton(withTitle: NSLocalizedString("Reset & Re-request", comment: "Reset button"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button"))
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
// Run tccutil to reset accessibility for this app
let bundleId = Bundle.main.bundleIdentifier ?? "me.guillaumeb.MonitorControl"
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
process.arguments = ["reset", "Accessibility", bundleId]
do {
try process.run()
process.waitUntilExit()
os_log("Reset accessibility permission for %{public}@, exit code: %{public}d", type: .info, bundleId, process.terminationStatus)
} catch {
os_log("Failed to reset accessibility: %{public}@", type: .error, error.localizedDescription)
}
// Re-prompt for accessibility permission
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
MediaKeyTapManager.acquirePrivileges()
}
}
private func openAccessibilitySettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
}
func populateSettings() {
self.keyboardBrightness.selectItem(withTag: prefs.integer(forKey: PrefKey.keyboardBrightness.rawValue))
self.keyboardVolume.selectItem(withTag: prefs.integer(forKey: PrefKey.keyboardVolume.rawValue))
@ -224,3 +362,4 @@ class KeyboardPrefsViewController: NSViewController, SettingsPane {
self.updateGridLayout()
}
}

View file

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>7141</string>
<string>7184</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSBackgroundOnly</key>