Update HUDs

This commit is contained in:
Hariharan Mudaliar 2025-12-20 12:49:23 +05:30
commit 7e9fc2ecb8
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
12 changed files with 1515 additions and 263 deletions

View file

@ -2842,10 +2842,6 @@
"comment" : "A label indicating a feature that is still in development.", "comment" : "A label indicating a feature that is still in development.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Alpha Feature Warning" : {
"comment" : "The title of an alert that warns users about an alpha feature.",
"isCommentAutoGenerated" : true
},
"Always hide in fullscreen" : { "Always hide in fullscreen" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -3973,6 +3969,9 @@
} }
} }
} }
},
"Behavior & Style" : {
}, },
"BETA" : { "BETA" : {
"comment" : "A label indicating a beta version.", "comment" : "A label indicating a beta version.",
@ -5071,6 +5070,9 @@
} }
} }
} }
},
"Choose directly on which side of the screen the vertical bar appears." : {
}, },
"Choose File" : { "Choose File" : {
"localizations" : { "localizations" : {
@ -6325,13 +6327,6 @@
"comment" : "A description of the Custom OSD feature, visible only on macOS 15 or later.", "comment" : "A description of the Custom OSD feature, visible only on macOS 15 or later.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it." : {
},
"Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD." : {
"comment" : "A message displayed below the toggle to inform the user that they cannot enable Custom OSD if HUDs are enabled.",
"isCommentAutoGenerated" : true
},
"Custom Timer" : { "Custom Timer" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -6835,6 +6830,9 @@
"Diagnostics" : { "Diagnostics" : {
"comment" : "A section header in the Lock Screen Settings view, describing diagnostics-related functionality.", "comment" : "A section header in the Lock Screen Settings view, describing diagnostics-related functionality.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Dimensions" : {
}, },
"Disable" : { "Disable" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -7061,10 +7059,6 @@
} }
} }
}, },
"Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight." : {
"comment" : "A description of the functionality of the Custom OSD feature.",
"isCommentAutoGenerated" : true
},
"Display Mode" : { "Display Mode" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -7713,10 +7707,6 @@
"comment" : "A description of the media panel settings in the lock screen settings view.", "comment" : "A description of the media panel settings in the lock screen settings view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Enable Anyway" : {
"comment" : "A button that enables the Custom OSD feature despite the warning.",
"isCommentAutoGenerated" : true
},
"Enable Built-in System HUD" : { "Enable Built-in System HUD" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@ -7850,10 +7840,6 @@
} }
} }
}, },
"Enable Custom OSD" : {
"comment" : "A toggle that enables or disables the custom OSD feature.",
"isCommentAutoGenerated" : true
},
"Enable customizable controls" : { "Enable customizable controls" : {
"comment" : "A toggle that allows users to enable or disable customizable controls for media playback.", "comment" : "A toggle that allows users to enable or disable customizable controls for media playback.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -8067,10 +8053,6 @@
} }
} }
}, },
"Enable HUDs" : {
"comment" : "A toggle that enables or disables the use of HUDs.",
"isCommentAutoGenerated" : true
},
"Enable media panel blur" : { "Enable media panel blur" : {
"comment" : "A toggle switch that enables or disables the blur effect on the media panel.", "comment" : "A toggle switch that enables or disables the blur effect on the media panel.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -9640,12 +9622,6 @@
} }
} }
} }
},
"Grant Accessibility permission in System Settings to control macOS HUD replacements from here." : {
},
"Grant Accessibility permission in System Settings to customize the on-screen display replacements here." : {
}, },
"Graph Visibility" : { "Graph Visibility" : {
"localizations" : { "localizations" : {
@ -9666,6 +9642,9 @@
} }
} }
} }
},
"Height: %lldpx" : {
}, },
"Hide" : { "Hide" : {
"localizations" : { "localizations" : {
@ -10130,6 +10109,9 @@
} }
} }
} }
},
"HUD Position" : {
}, },
"HUD style" : { "HUD style" : {
"localizations" : { "localizations" : {
@ -10331,9 +10313,8 @@
} }
} }
}, },
"HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs." : { "HUDs & OSD" : {
"comment" : "A message displayed below the toggle to disable HUDs, explaining that it's disabled because Custom OSD is enabled.",
"isCommentAutoGenerated" : true
}, },
"Icon & Progress Color" : { "Icon & Progress Color" : {
"comment" : "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.", "comment" : "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.",
@ -10727,6 +10708,9 @@
} }
} }
} }
},
"Interactive (Drag to Change)" : {
}, },
"IPv4 · %@" : { "IPv4 · %@" : {
"comment" : "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.", "comment" : "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.",
@ -10896,6 +10880,9 @@
}, },
"Layout Preview" : { "Layout Preview" : {
},
"Left" : {
}, },
"License key" : { "License key" : {
"localizations" : { "localizations" : {
@ -10906,6 +10893,9 @@
} }
} }
} }
},
"Line Width: %lldpx" : {
}, },
"LinkedIn" : { "LinkedIn" : {
"localizations" : { "localizations" : {
@ -15802,10 +15792,6 @@
}, },
"Remove from shelf after dragging" : { "Remove from shelf after dragging" : {
},
"Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight." : {
"comment" : "A footer description of the HUDs feature.",
"isCommentAutoGenerated" : true
}, },
"Request Access" : { "Request Access" : {
"localizations" : { "localizations" : {
@ -15987,6 +15973,9 @@
} }
} }
} }
},
"Right" : {
}, },
"Rotation:" : { "Rotation:" : {
"localizations" : { "localizations" : {
@ -16177,6 +16166,9 @@
} }
} }
} }
},
"Screen Padding: %lldpx" : {
}, },
"Screen Recording" : { "Screen Recording" : {
@ -17407,6 +17399,9 @@
} }
} }
} }
},
"Show Percentage" : {
}, },
"Show preset list in timer tab" : { "Show preset list in timer tab" : {
"comment" : "A toggle that lets users decide whether to show a list of presets in the timer tab.", "comment" : "A toggle that lets users decide whether to show a list of presets in the timer tab.",
@ -17552,6 +17547,9 @@
} }
} }
} }
},
"Size: %lldpx" : {
}, },
"Skip buttons" : { "Skip buttons" : {
"comment" : "A section header that lists the skip behaviour options.", "comment" : "A section header that lists the skip behaviour options.",
@ -18458,6 +18456,9 @@
} }
} }
} }
},
"Style" : {
}, },
"Support Us" : { "Support Us" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -19748,6 +19749,9 @@
} }
} }
} }
},
"Use Accent Color" : {
}, },
"Use music visualizer spectrogram" : { "Use music visualizer spectrogram" : {
"localizations" : { "localizations" : {
@ -20478,6 +20482,9 @@
} }
} }
} }
},
"Width: %lldpx" : {
}, },
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : { "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {

View file

@ -98,10 +98,12 @@ extension ColorCodedProgressBar {
// MARK: - Palette Helpers // MARK: - Palette Helpers
private enum ColorCodedPalette { // MARK: - Palette Helpers
private static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
private static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2) enum ColorCodedPalette {
private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3) static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2)
static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
static func color(for intensity: CGFloat, smooth: Bool) -> Color { static func color(for intensity: CGFloat, smooth: Bool) -> Color {
if smooth { if smooth {

View file

@ -0,0 +1,157 @@
#if os(macOS)
import SwiftUI
import Defaults
import CoreAudio
struct CircularHUDView: View {
@Binding var type: SneakContentType
@Binding var value: CGFloat
@Binding var icon: String
@Default(.circularHUDShowValue) var showValue
@Default(.circularHUDSize) var size
@Default(.circularHUDStrokeWidth) var strokeWidth
@Default(.circularHUDUseAccentColor) var useAccentColor
@Environment(\.colorScheme) var colorScheme
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolume
@Default(.useSmoothColorGradient) var useSmoothGradient
var body: some View {
ZStack {
// Background circle
Circle()
.fill(.ultraThinMaterial)
.overlay {
Circle()
.stroke(.white.opacity(0.1), lineWidth: 1)
}
// Progress ring background
Circle()
.stroke(Color.white.opacity(0.2), lineWidth: strokeWidth)
.padding(strokeWidth / 2)
// Progress ring
Circle()
.trim(from: 0, to: value)
.stroke(
strokeStyle,
style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.padding(strokeWidth / 2)
.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.8, blendDuration: 0), value: value)
// Content
VStack(spacing: 2) {
Image(systemName: symbolName)
.font(.system(size: size * 0.35, weight: .semibold))
.foregroundStyle(useAccentColor ? (value > 0.9 ? .white : .primary) : .white)
.symbolRenderingMode(.hierarchical)
if showValue {
Text("\(Int(value * 100))")
.font(.system(size: size * 0.18, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.8))
.contentTransition(.numericText())
}
}
}
.frame(width: size, height: size)
.shadow(color: .black.opacity(0.2), radius: 15, x: 0, y: 5)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var strokeStyle: AnyShapeStyle {
if type == .volume && useColorCodedVolume {
return ColorCodedProgressBar.shapeStyle(for: value, mode: .volume, smoothGradient: useSmoothGradient)
}
return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white)
}
private var symbolName: String {
if !icon.isEmpty { return icon }
switch type {
case .volume:
// Check if headphones/AirPods are connected
let deviceInfo = getAudioDeviceInfo()
if deviceInfo.isAirPods {
// Use AirPods icon when AirPods are connected
if value < 0.01 { return "airpods" }
else { return "airpods" }
} else if deviceInfo.isHeadphones {
// Use headphone icons when other headphones are connected
if value < 0.01 { return "headphones.slash" }
else { return "headphones" }
} else {
// Use speaker icons for built-in speakers
if value < 0.01 { return "speaker.slash.fill" }
else if value < 0.33 { return "speaker.wave.1.fill" }
else if value < 0.66 { return "speaker.wave.2.fill" }
else { return "speaker.wave.3.fill" }
}
case .brightness:
return "sun.max.fill"
case .backlight:
return "keyboard"
default:
return "questionmark"
}
}
private struct AudioDeviceInfo {
let isAirPods: Bool
let isHeadphones: Bool
}
private func getAudioDeviceInfo() -> AudioDeviceInfo {
#if os(macOS)
// Get default output device
var deviceID = AudioDeviceID()
var size = UInt32(MemoryLayout.size(ofValue: deviceID))
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID) == noErr else {
return AudioDeviceInfo(isAirPods: false, isHeadphones: false)
}
// Get device name
var deviceName: CFString = "" as CFString
var nameSize = UInt32(MemoryLayout<CFString>.size)
var nameAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceNameCFString,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
guard AudioObjectGetPropertyData(deviceID, &nameAddress, 0, nil, &nameSize, &deviceName) == noErr else {
return AudioDeviceInfo(isAirPods: false, isHeadphones: false)
}
let name = (deviceName as String).lowercased()
// Check for AirPods specifically
let isAirPods = name.contains("airpod")
// Check for other headphones
let isHeadphones = name.contains("headphone") ||
name.contains("ear") ||
name.contains("buds") ||
name.contains("beats")
return AudioDeviceInfo(isAirPods: isAirPods, isHeadphones: !isAirPods && isHeadphones)
#else
return AudioDeviceInfo(isAirPods: false, isHeadphones: false)
#endif
}
}
#endif

View file

@ -21,22 +21,30 @@ struct CustomOSDView: View {
var body: some View { var body: some View {
VStack(spacing: spacing) { VStack(spacing: spacing) {
// Icon // Icon
Image(systemName: symbolName) ZStack {
.font(.system(size: iconSize, weight: .regular)) Image(systemName: symbolName)
.foregroundStyle(iconColor) .font(.system(size: iconSize, weight: .regular))
.symbolRenderingMode(.hierarchical) .foregroundStyle(iconColor)
.symbolRenderingMode(.hierarchical)
}
.frame(width: 80, height: 60, alignment: .center) // Fixed container for icon
// Progress/Value Indicator // Progress/Value Indicator
progressView progressView
.frame(width: 140) .frame(width: 140)
.padding(.top, 4) // Lower the bar slightly
} }
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.1)) // Subtle backing for shadow source
.shadow(color: Color.black.opacity(0.25), radius: 24, x: 0, y: 8)
)
.background( .background(
backgroundView backgroundView
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
) )
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.shadow(color: Color.black.opacity(0.25), radius: 24, x: 0, y: 8)
} }
// MARK: - Symbol Logic // MARK: - Symbol Logic

View file

@ -0,0 +1,229 @@
#if os(macOS)
import SwiftUI
import Defaults
import Combine
struct BumpEvent: Equatable {
let direction: Int
let timestamp: Date
}
class VerticalHUDState: ObservableObject {
@Published var type: SneakContentType = .volume
@Published var value: CGFloat = 0
@Published var icon: String = ""
@Published var bumpEvent: BumpEvent?
init(type: SneakContentType = .volume, value: CGFloat = 0, icon: String = "") {
self.type = type
self.value = value
self.icon = icon
}
}
struct VerticalHUDView: View {
@ObservedObject var state: VerticalHUDState
@Default(.verticalHUDShowValue) var showValue
@Default(.verticalHUDHeight) var hudHeight
@Default(.verticalHUDWidth) var hudWidth
@Default(.verticalHUDUseAccentColor) var useAccentColor
@Default(.verticalHUDInteractive) var isInteractive
@Environment(\.colorScheme) var colorScheme
// Interaction State
@State private var isDragging: Bool = false
@State private var isHovering: Bool = false
@State private var stretchAmount: CGFloat = 0
@State private var stretchOffset: CGFloat = 0
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolume
@Default(.useSmoothColorGradient) var useSmoothGradient
// Constants
private let maxStretch: CGFloat = 30 // Reduced max stretch for tighter feel
var body: some View {
// Full Window Container
ZStack {
// The Actual HUD Pill
ZStack {
// Background
Capsule()
.fill(.ultraThinMaterial)
.overlay {
Capsule()
.stroke(.white.opacity(0.1), lineWidth: 1)
}
// Fill Bar
GeometryReader { geo in
VStack(spacing: 0) {
Spacer(minLength: 0)
Rectangle()
.fill(fillStyle)
.frame(height: geo.size.height * state.value)
// Super Smooth Fill
.animation(.interactiveSpring(response: 0.55, dampingFraction: 0.85, blendDuration: 0.3), value: state.value)
}
}
.clipShape(Capsule())
// Icon & Text overlay
VStack {
if showValue {
Text("\(Int(state.value * 100))%")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(state.value > 0.85 ? (useAccentColor ? .white : .black) : .secondary)
.padding(.top, 8 + (stretchOffset < 0 ? abs(stretchOffset/4) : 0))
.contentTransition(.numericText())
}
Spacer()
Image(systemName: symbolName)
.font(.system(size: hudWidth * 0.4, weight: .semibold))
.foregroundStyle(state.value > 0.15 ? (useAccentColor ? .white : .black) : .secondary)
.symbolRenderingMode(.hierarchical)
.padding(.bottom, hudWidth * 0.35)
.offset(y: stretchOffset > 0 ? -stretchOffset/4 : 0)
}
// Always visible
}
.frame(width: currentWidth, height: hudHeight + stretchAmount)
.offset(y: stretchOffset)
.shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 4)
.onHover { hovering in
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isHovering = hovering
}
}
// Listen for Elastic Bump (Keyboard) - Debounced via Task cancellation
.task(id: state.bumpEvent) {
guard let event = state.bumpEvent else { return }
let direction = CGFloat(event.direction)
let stretch: CGFloat = 15 // Constant stretch while holding
// Animate In (Maintain Stretch)
// If a new event comes in, this task is cancelled and restarted, keeping it stretched.
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.6)) {
stretchAmount = stretch
stretchOffset = direction * (-stretch / 2)
}
// Wait for key release (Short debounce window)
try? await Task.sleep(nanoseconds: 150 * 1_000_000) // 150ms
// Animate Back (only if not cancelled)
withAnimation(.spring(response: 0.55, dampingFraction: 0.65)) {
stretchAmount = 0
stretchOffset = 0
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
guard isInteractive else { return }
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
isDragging = true
}
let currentY = gesture.startLocation.y + gesture.translation.height
let percentage = 1.0 - (currentY / hudHeight)
// Elastic Rubber Banding
if percentage > 1.0 {
let excess = (percentage - 1.0) * hudHeight
let stretch = min(sqrt(abs(excess)) * 1.5, maxStretch) // Reduced multiplier (was 3.0)
state.value = 1.0
withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.65)) { // Tighter damping
stretchAmount = stretch
stretchOffset = -stretch / 2
}
} else if percentage < 0.0 {
let excess = abs(percentage) * hudHeight
let stretch = min(sqrt(abs(excess)) * 1.5, maxStretch) // Reduced multiplier
state.value = 0.0
withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.65)) {
stretchAmount = stretch
stretchOffset = stretch / 2
}
} else {
state.value = percentage
withAnimation(.interactiveSpring(response: 0.45, dampingFraction: 0.8)) {
stretchAmount = 0
stretchOffset = 0
}
}
updateSystemLevel(state.value)
}
.onEnded { _ in
withAnimation(.spring(response: 0.55, dampingFraction: 0.7)) { // Tighter release
isDragging = false
stretchAmount = 0
stretchOffset = 0
if state.value > 1.0 { state.value = 1.0 }
if state.value < 0.0 { state.value = 0.0 }
}
updateSystemLevel(state.value)
}
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.edgesIgnoringSafeArea(.all)
}
private var currentWidth: CGFloat {
// Only shrink slightly when dragging, otherwise full size
return isDragging ? hudWidth * 0.9 : hudWidth
}
private func updateSystemLevel(_ level: CGFloat) {
if state.type == .volume {
SystemVolumeController.shared.setVolume(Float(level))
} else if state.type == .brightness {
SystemBrightnessController.shared.setBrightness(Float(level))
}
}
private var symbolName: String {
if !state.icon.isEmpty { return state.icon }
switch state.type {
case .volume:
if state.value < 0.01 { return "speaker.slash.fill" }
else if state.value < 0.33 { return "speaker.wave.1.fill" }
else if state.value < 0.66 { return "speaker.wave.2.fill" }
else { return "speaker.wave.3.fill" }
case .brightness:
return "sun.max.fill"
case .backlight:
return "keyboard"
default:
return "questionmark"
}
}
// MARK: - Helper Computing Properties
private var fillStyle: AnyShapeStyle {
if state.type == .volume && useColorCodedVolume {
let intensity = state.value
if useSmoothGradient {
let endColor = ColorCodedPalette.color(for: intensity, smooth: true)
let startColor = ColorCodedPalette.color(for: max(intensity - 0.15, 0), smooth: true)
return AnyShapeStyle(LinearGradient(colors: [startColor, endColor], startPoint: .bottom, endPoint: .top))
} else {
return AnyShapeStyle(ColorCodedPalette.color(for: intensity, smooth: false))
}
}
return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white)
}
}
#endif

View file

@ -25,8 +25,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case media case media
case timer case timer
case calendar case calendar
case hud case hudAndOSD
case osd
case battery case battery
case stats case stats
case clipboard case clipboard
@ -48,8 +47,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .media: return "Media" case .media: return "Media"
case .timer: return "Timer" case .timer: return "Timer"
case .calendar: return "Calendar" case .calendar: return "Calendar"
case .hud: return "HUDs" case .hudAndOSD: return "HUDs & OSD"
case .osd: return "Custom OSD"
case .battery: return "Battery" case .battery: return "Battery"
case .stats: return "Stats" case .stats: return "Stats"
case .clipboard: return "Clipboard" case .clipboard: return "Clipboard"
@ -71,8 +69,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .media: return "play.laptopcomputer" case .media: return "play.laptopcomputer"
case .timer: return "timer" case .timer: return "timer"
case .calendar: return "calendar" case .calendar: return "calendar"
case .hud: return "dial.medium.fill" case .hudAndOSD: return "dial.medium.fill"
case .osd: return "square.fill.on.square.fill"
case .battery: return "battery.100.bolt" case .battery: return "battery.100.bolt"
case .stats: return "chart.xyaxis.line" case .stats: return "chart.xyaxis.line"
case .clipboard: return "clipboard" case .clipboard: return "clipboard"
@ -94,8 +91,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .media: return .green case .media: return .green
case .timer: return .red case .timer: return .red
case .calendar: return .cyan case .calendar: return .cyan
case .hud: return .indigo case .hudAndOSD: return .indigo
case .osd: return .teal
case .battery: return .yellow case .battery: return .yellow
case .stats: return .teal case .stats: return .teal
case .clipboard: return .mint case .clipboard: return .mint
@ -260,18 +256,7 @@ struct SettingsView: View {
HStack(spacing: 10) { HStack(spacing: 10) {
sidebarIcon(for: tab) sidebarIcon(for: tab)
Text(tab.title) Text(tab.title)
if tab == .osd { if tab == .downloads {
Spacer()
Text("ALPHA")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
Capsule()
.fill(Color.orange)
)
} else if tab == .downloads {
Spacer() Spacer()
Text("BETA") Text("BETA")
.font(.system(size: 9, weight: .bold)) .font(.system(size: 9, weight: .bold))
@ -394,8 +379,7 @@ struct SettingsView: View {
.media, .media,
.timer, .timer,
.calendar, .calendar,
.hud, .hudAndOSD,
.osd,
.battery, .battery,
.stats, .stats,
.clipboard, .clipboard,
@ -607,26 +591,26 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")),
// HUDs // HUDs
SettingsSearchEntry(tab: .hud, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hud.highlightID(for: "Show Bluetooth device connections")), SettingsSearchEntry(tab: .hudAndOSD, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show Bluetooth device connections")),
SettingsSearchEntry(tab: .hud, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.hud.highlightID(for: "Use circular battery indicator")), SettingsSearchEntry(tab: .hudAndOSD, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use circular battery indicator")),
SettingsSearchEntry(tab: .hud, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.hud.highlightID(for: "Show battery percentage text in HUD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show battery percentage text in HUD")),
SettingsSearchEntry(tab: .hud, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.hud.highlightID(for: "Scroll device name in HUD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Scroll device name in HUD")),
SettingsSearchEntry(tab: .hud, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.hud.highlightID(for: "Color-coded battery display")), SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded battery display")),
SettingsSearchEntry(tab: .hud, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hud.highlightID(for: "Color-coded volume display")), SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")),
SettingsSearchEntry(tab: .hud, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hud.highlightID(for: "Smooth color transitions")), SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")),
SettingsSearchEntry(tab: .hud, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hud.highlightID(for: "Show percentages beside progress bars")), SettingsSearchEntry(tab: .hudAndOSD, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show percentages beside progress bars")),
SettingsSearchEntry(tab: .hud, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hud.highlightID(for: "HUD style")), SettingsSearchEntry(tab: .hudAndOSD, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "HUD style")),
SettingsSearchEntry(tab: .hud, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hud.highlightID(for: "Progressbar style")), SettingsSearchEntry(tab: .hudAndOSD, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Progressbar style")),
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hud.highlightID(for: "Enable glowing effect")), SettingsSearchEntry(tab: .hudAndOSD, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable glowing effect")),
SettingsSearchEntry(tab: .hud, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hud.highlightID(for: "Use accent color")), SettingsSearchEntry(tab: .hudAndOSD, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use accent color")),
// Custom OSD // Custom OSD
SettingsSearchEntry(tab: .osd, title: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.osd.highlightID(for: "Enable Custom OSD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable Custom OSD")),
SettingsSearchEntry(tab: .osd, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Volume OSD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume OSD")),
SettingsSearchEntry(tab: .osd, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Brightness OSD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness OSD")),
SettingsSearchEntry(tab: .osd, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Keyboard Backlight OSD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
SettingsSearchEntry(tab: .osd, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Material")), SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
SettingsSearchEntry(tab: .osd, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Icon & Progress Color")), SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
// Media // Media
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")), SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")),
@ -771,31 +755,9 @@ struct SettingsView: View {
SettingsForm(tab: .calendar) { SettingsForm(tab: .calendar) {
CalendarSettings() CalendarSettings()
} }
case .hud: case .hudAndOSD:
SettingsForm(tab: .hud) { SettingsForm(tab: .hudAndOSD) {
HUD() HUDAndOSDSettingsView()
}
case .osd:
SettingsForm(tab: .osd) {
if #available(macOS 15.0, *) {
CustomOSDSettings()
} else {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("macOS 15 or later required")
.font(.headline)
Text("Custom OSD feature requires macOS 15 or later.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
} }
case .battery: case .battery:
SettingsForm(tab: .battery) { SettingsForm(tab: .battery) {
@ -1212,6 +1174,461 @@ struct Downloads: View {
} }
} }
final class HUDPreviewViewModel: ObservableObject {
@Published var level: Float = 0
@Published var iconName: String = "speaker.wave.3.fill"
private var cancellables = Set<AnyCancellable>()
init() {
setup()
}
private func setup() {
// Ensure controllers are active
SystemVolumeController.shared.start()
SystemBrightnessController.shared.start()
SystemKeyboardBacklightController.shared.start()
// Initial state from volume
let vol = SystemVolumeController.shared.currentVolume
self.level = vol
if vol <= 0.01 { self.iconName = "speaker.slash.fill" }
else if vol < 0.33 { self.iconName = "speaker.wave.1.fill" }
else if vol < 0.66 { self.iconName = "speaker.wave.2.fill" }
else { self.iconName = "speaker.wave.3.fill" }
// Listeners
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
.compactMap { $0.userInfo }
.receive(on: DispatchQueue.main)
.sink { [weak self] info in
guard let self else { return }
if let vol = info["value"] as? Float {
self.level = vol
if vol <= 0.01 { self.iconName = "speaker.slash.fill" }
else if vol < 0.33 { self.iconName = "speaker.wave.1.fill" }
else if vol < 0.66 { self.iconName = "speaker.wave.2.fill" }
else { self.iconName = "speaker.wave.3.fill" }
}
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .systemBrightnessDidChange)
.compactMap { $0.userInfo }
.receive(on: DispatchQueue.main)
.sink { [weak self] info in
guard let self else { return }
if let val = info["value"] as? Float {
self.level = val
self.iconName = "sun.max.fill"
}
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .keyboardBacklightDidChange)
.compactMap { $0.userInfo }
.receive(on: DispatchQueue.main)
.sink { [weak self] info in
guard let self else { return }
if let val = info["value"] as? Float {
self.level = val
self.iconName = val > 0.5 ? "light.max" : "light.min"
}
}
.store(in: &cancellables)
}
}
private struct HUDAndOSDSettingsView: View {
@State private var selectedTab: Tab = {
if Defaults[.enableSystemHUD] { return .hud }
if Defaults[.enableCustomOSD] { return .osd }
if Defaults[.enableVerticalHUD] { return .vertical }
if Defaults[.enableCircularHUD] { return .circular }
return .hud
}()
@Default(.enableSystemHUD) var enableSystemHUD
@Default(.enableCustomOSD) var enableCustomOSD
@Default(.enableVerticalHUD) var enableVerticalHUD
@Default(.enableCircularHUD) var enableCircularHUD
@Default(.verticalHUDPosition) var verticalHUDPosition
// Vertical HUD Props
@Default(.verticalHUDShowValue) var verticalHUDShowValue
@Default(.verticalHUDInteractive) var verticalHUDInteractive
@Default(.verticalHUDHeight) var verticalHUDHeight
@Default(.verticalHUDWidth) var verticalHUDWidth
@Default(.verticalHUDPadding) var verticalHUDPadding
@Default(.verticalHUDUseAccentColor) var verticalHUDUseAccentColor
// Circular HUD Props
@Default(.circularHUDShowValue) var circularHUDShowValue
@Default(.circularHUDSize) var circularHUDSize
@Default(.circularHUDStrokeWidth) var circularHUDStrokeWidth
@Default(.circularHUDUseAccentColor) var circularHUDUseAccentColor
@StateObject private var previewModel = HUDPreviewViewModel()
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
private enum Tab: String, CaseIterable, Identifiable {
case hud = "Dynamic Island HUD"
case osd = "Custom OSD"
case vertical = "Vertical Bar"
case circular = "Circular"
var id: String { rawValue }
}
var body: some View {
VStack(spacing: 20) {
HStack(spacing: 16) {
HUDSelectionCard(
title: "Dynamic Island",
isSelected: selectedTab == .hud,
action: {
selectedTab = .hud
enableSystemHUD = true
enableCustomOSD = false
enableVerticalHUD = false
enableCircularHUD = false
}
) {
VStack {
Capsule()
.fill(Color.black)
.frame(width: 64, height: 20)
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
.overlay {
HStack(spacing: 6) {
Image(systemName: previewModel.iconName)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 12)
GeometryReader { geo in
Capsule()
.fill(Color.white.opacity(0.2))
.overlay(alignment: .leading) {
Capsule()
.fill(Color.white)
.frame(width: geo.size.width * CGFloat(previewModel.level))
.animation(.spring(response: 0.3), value: previewModel.level)
}
}
.frame(height: 4)
}
.padding(.horizontal, 8)
}
}
}
HUDSelectionCard(
title: "Custom OSD",
isSelected: selectedTab == .osd,
action: {
selectedTab = .osd
enableCustomOSD = true
enableSystemHUD = false
enableVerticalHUD = false
enableCircularHUD = false
}
) {
RoundedRectangle(cornerRadius: 12)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.white.opacity(0.1), lineWidth: 1)
}
.shadow(color: .black.opacity(0.1), radius: 3, y: 1)
.overlay {
VStack(spacing: 6) {
Image(systemName: previewModel.iconName)
.font(.system(size: 16))
.foregroundStyle(.secondary)
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
GeometryReader { geo in
Capsule()
.fill(Color.secondary.opacity(0.2))
.overlay(alignment: .leading) {
Capsule()
.fill(Color.primary)
.frame(width: geo.size.width * CGFloat(previewModel.level))
.animation(.spring(response: 0.3), value: previewModel.level)
}
}
.frame(width: 36, height: 4)
}
}
.frame(width: 44, height: 44)
}
HUDSelectionCard(
title: "Vertical Bar",
isSelected: selectedTab == .vertical,
action: {
selectedTab = .vertical
enableVerticalHUD = true
enableSystemHUD = false
enableCustomOSD = false
enableCircularHUD = false
}
) {
RoundedRectangle(cornerRadius: 8)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(.white.opacity(0.1), lineWidth: 1)
}
.shadow(color: .black.opacity(0.1), radius: 3, y: 1)
.overlay {
VStack {
GeometryReader { geo in
VStack {
Spacer()
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.white)
.frame(height: max(0, geo.size.height * CGFloat(previewModel.level)))
.animation(.spring(response: 0.3), value: previewModel.level)
}
}
.mask(RoundedRectangle(cornerRadius: 6, style: .continuous))
.padding(.bottom, 2)
Image(systemName: previewModel.iconName)
.font(.system(size: 9))
.foregroundStyle(previewModel.level > 0.15 ? .black : .secondary)
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
}
.padding(4)
}
.frame(width: 22, height: 54)
}
HUDSelectionCard(
title: "Circular",
isSelected: selectedTab == .circular,
action: {
selectedTab = .circular
enableCircularHUD = true
enableSystemHUD = false
enableCustomOSD = false
enableVerticalHUD = false
}
) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 4)
Circle()
.trim(from: 0, to: CGFloat(previewModel.level))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.spring(response: 0.3), value: previewModel.level)
Image(systemName: previewModel.iconName)
.font(.system(size: 16))
.foregroundStyle(.primary)
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
}
.frame(width: 44, height: 44)
}
}
.padding(.top, 8)
switch selectedTab {
case .hud:
HUD()
case .osd:
if #available(macOS 15.0, *) {
CustomOSDSettings()
} else {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("macOS 15 or later required")
.font(.headline)
Text("Custom OSD feature requires macOS 15 or later.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
case .vertical:
Form {
if !accessibilityPermission.isAuthorized {
Section {
SettingsPermissionCallout(
message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.",
requestAction: {
accessibilityPermission.requestAuthorizationPrompt()
},
openSettingsAction: {
accessibilityPermission.openSystemSettings()
}
)
} header: {
Text("Accessibility")
}
}
Section {
Toggle("Show Percentage", isOn: $verticalHUDShowValue)
Toggle("Use Accent Color", isOn: $verticalHUDUseAccentColor)
Toggle("Interactive (Drag to Change)", isOn: $verticalHUDInteractive)
Defaults.Toggle("Color-coded Volume", key: .useColorCodedVolumeDisplay)
} header: {
Text("Behavior & Style")
}
Section {
Picker("HUD Position", selection: $verticalHUDPosition) {
Text("Left").tag("left")
Text("Right").tag("right")
}
.pickerStyle(.menu)
VStack(alignment: .leading) {
Text("Screen Padding: \(Int(verticalHUDPadding))px")
Slider(value: $verticalHUDPadding, in: 0...100, step: 4)
}
} header: {
Text("Position")
} footer: {
Text("Choose directly on which side of the screen the vertical bar appears.")
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
VStack(alignment: .leading) {
Text("Width: \(Int(verticalHUDWidth))px")
Slider(value: $verticalHUDWidth, in: 24...80, step: 2)
}
VStack(alignment: .leading) {
Text("Height: \(Int(verticalHUDHeight))px")
Slider(value: $verticalHUDHeight, in: 100...500, step: 10)
}
Button("Reset to Default") {
verticalHUDWidth = 36
verticalHUDHeight = 160
verticalHUDPadding = 24
}
} header: {
Text("Dimensions")
}
}
.padding(.top, 10)
case .circular:
Form {
if !accessibilityPermission.isAuthorized {
Section {
SettingsPermissionCallout(
message: "Accessibility permission is needed to intercept system controls for the Circular HUD.",
requestAction: {
accessibilityPermission.requestAuthorizationPrompt()
},
openSettingsAction: {
accessibilityPermission.openSystemSettings()
}
)
} header: {
Text("Accessibility")
}
}
Section {
Toggle("Show Percentage", isOn: $circularHUDShowValue)
Toggle("Use Accent Color", isOn: $circularHUDUseAccentColor)
Defaults.Toggle("Color-coded Volume", key: .useColorCodedVolumeDisplay)
} header: {
Text("Style")
}
Section {
VStack(alignment: .leading) {
Text("Size: \(Int(circularHUDSize))px")
Slider(value: $circularHUDSize, in: 40...200, step: 5)
}
VStack(alignment: .leading) {
Text("Line Width: \(Int(circularHUDStrokeWidth))px")
Slider(value: $circularHUDStrokeWidth, in: 2...16, step: 1)
}
Button("Reset to Default") {
circularHUDSize = 65
circularHUDStrokeWidth = 4
}
} header: {
Text("Dimensions")
}
}
.padding(.top, 10)
}
}
.navigationTitle("HUDs & OSD")
}
}
private struct HUDSelectionCard<Preview: View>: View {
let title: String
let isSelected: Bool
let action: () -> Void
@ViewBuilder let preview: Preview
@State private var isHovering = false
var body: some View {
Button(action: action) {
VStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(nsColor: .controlBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : Color.clear,
lineWidth: 2.5
)
)
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
preview
}
.frame(width: 110, height: 80)
VStack(spacing: 4) {
Text(title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(isSelected ? .primary : .secondary)
if isSelected {
Circle()
.fill(Color.accentColor)
.frame(width: 4, height: 4)
} else {
Color.clear
.frame(width: 4, height: 4)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
}
}
struct HUD: View { struct HUD: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@Default(.inlineHUD) var inlineHUD @Default(.inlineHUD) var inlineHUD
@ -1225,7 +1642,7 @@ struct HUD: View {
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
private func highlightID(_ title: String) -> String { private func highlightID(_ title: String) -> String {
SettingsTab.hud.highlightID(for: title) SettingsTab.hudAndOSD.highlightID(for: title)
} }
private var hasAccessibilityPermission: Bool { private var hasAccessibilityPermission: Bool {
@ -1246,26 +1663,7 @@ struct HUD: View {
} }
} }
Section {
Toggle("Enable HUDs", isOn: $enableSystemHUD)
.disabled(Defaults[.enableCustomOSD] || !hasAccessibilityPermission)
} header: {
Text("General")
} footer: {
if Defaults[.enableCustomOSD] {
Text("HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs.")
.foregroundStyle(.secondary)
.font(.caption)
} else if !hasAccessibilityPermission {
Text("Grant Accessibility permission in System Settings to control macOS HUD replacements from here.")
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight.")
.foregroundStyle(.secondary)
.font(.caption)
}
}
if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission { if enableSystemHUD && !Defaults[.enableCustomOSD] && hasAccessibilityPermission {
Section { Section {
@ -4537,7 +4935,7 @@ struct CustomOSDSettings: View {
@State private var previewType: SneakContentType = .volume @State private var previewType: SneakContentType = .volume
private func highlightID(_ title: String) -> String { private func highlightID(_ title: String) -> String {
SettingsTab.osd.highlightID(for: title) SettingsTab.hudAndOSD.highlightID(for: title)
} }
private var hasAccessibilityPermission: Bool { private var hasAccessibilityPermission: Bool {
@ -4558,46 +4956,7 @@ struct CustomOSDSettings: View {
} }
} }
Section { if hasAccessibilityPermission {
Toggle("Enable Custom OSD", isOn: Binding(
get: { enableCustomOSD },
set: { newValue in
guard hasAccessibilityPermission else {
enableCustomOSD = false
return
}
if newValue && !hasSeenOSDAlphaWarning {
showAlphaWarning = true
} else {
enableCustomOSD = newValue
if newValue {
// Disable System HUD when OSD is enabled
enableSystemHUD = false
}
}
}
))
.settingsHighlight(id: highlightID("Enable Custom OSD"))
.disabled(enableSystemHUD || !hasAccessibilityPermission)
} header: {
Text("General")
} footer: {
if enableSystemHUD {
Text("Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD.")
.foregroundStyle(.secondary)
.font(.caption)
} else if !hasAccessibilityPermission {
Text("Grant Accessibility permission in System Settings to customize the on-screen display replacements here.")
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight.")
.foregroundStyle(.secondary)
.font(.caption)
}
}
if enableCustomOSD && hasAccessibilityPermission {
Section { Section {
Toggle("Volume OSD", isOn: $enableOSDVolume) Toggle("Volume OSD", isOn: $enableOSDVolume)
.settingsHighlight(id: highlightID("Volume OSD")) .settingsHighlight(id: highlightID("Volume OSD"))
@ -4696,18 +5055,6 @@ struct CustomOSDSettings: View {
} }
} }
} }
.alert("Alpha Feature Warning", isPresented: $showAlphaWarning) {
Button("Cancel", role: .cancel) {
enableCustomOSD = false
}
Button("Enable Anyway") {
hasSeenOSDAlphaWarning = true
enableCustomOSD = true
enableSystemHUD = false
}
} message: {
Text("Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it.")
}
.navigationTitle("Custom OSD") .navigationTitle("Custom OSD")
.onAppear { .onAppear {
accessibilityPermission.refreshStatus() accessibilityPermission.refreshStatus()

View file

@ -0,0 +1,151 @@
#if os(macOS)
import AppKit
import SwiftUI
import SkyLightWindow
import QuartzCore
import Defaults
import Combine
@MainActor
final class CircularHUDWindowManager {
static let shared = CircularHUDWindowManager()
private var windows: [NSScreen: OSDWindow] = [:]
private var hideWorkItem: DispatchWorkItem?
private let displayDuration: TimeInterval = 2.0
private let animationDuration: TimeInterval = 0.2
private var cancellables = Set<AnyCancellable>()
// Layout constants removed in favor of dynamic calculation
private init() {
setupSizeObserver()
}
private func setupSizeObserver() {
Defaults.publisher(.circularHUDSize, options: []).sink { [weak self] _ in
guard let self = self else { return }
Task { @MainActor in
self.updateWindowLayout()
}
}.store(in: &cancellables)
}
private func updateWindowLayout() {
guard !windows.isEmpty else { return }
for (screen, window) in windows {
let screenFrame = screen.frame
let hudSize = Defaults[.circularHUDSize]
let windowSize = hudSize + 80 // Padding
let x = screenFrame.midX - (windowSize / 2)
let y = screenFrame.midY - (windowSize / 2)
let newFrame = NSRect(x: x, y: y, width: windowSize, height: windowSize)
window.nsWindow.setFrame(newFrame, display: true, animate: true)
}
}
func show(type: SneakContentType, value: CGFloat, icon: String = "") {
guard Defaults[.enableCircularHUD] else { return }
let screens = NSScreen.screens
guard !screens.isEmpty else { return }
// Show on all screens
for screen in screens {
let windowContext = ensureWindow(for: type, screen: screen)
updateContent(window: windowContext, type: type, value: value, icon: icon)
// Ensure visible
windowContext.nsWindow.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = animationDuration
windowContext.nsWindow.animator().alphaValue = 1
}
}
scheduleHide()
}
private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
if let existing = windows[screen] {
return existing
}
let osdView = CircularHUDView(type: .constant(type), value: .constant(0), icon: .constant(""))
let hostingView = NSHostingView(rootView: osdView)
let screenFrame = screen.frame
let hudSize = Defaults[.circularHUDSize]
let windowSize = hudSize + 80 // Padding
// Position at center of screen
let x = screenFrame.midX - (windowSize / 2)
let y = screenFrame.midY - (windowSize / 2)
let frame = NSRect(x: x, y: y, width: windowSize, height: windowSize)
let win = NSWindow(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
win.isOpaque = false
win.backgroundColor = .clear
win.level = .screenSaver
win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
win.hasShadow = false
win.contentView = hostingView
win.alphaValue = 0
SkyLightOperator.shared.delegateWindow(win)
let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, type: type)
windows[screen] = windowStruct
return windowStruct
}
private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) {
let osdView = CircularHUDView(type: .constant(type), value: .constant(value), icon: .constant(icon))
window.hostingView.rootView = osdView
}
private func scheduleHide() {
hideWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
Task { @MainActor in
self.hideWindow()
}
}
hideWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem)
}
private func hideWindow() {
for window in windows.values {
NSAnimationContext.runAnimationGroup { context in
context.duration = animationDuration
window.nsWindow.animator().alphaValue = 0
} completionHandler: {
// Keep window around but hidden for faster subsequent shows
}
}
}
// Helper struct
private struct OSDWindow {
let nsWindow: NSWindow
let hostingView: NSHostingView<CircularHUDView>
let type: SneakContentType
}
}
#endif

View file

@ -11,9 +11,9 @@ import Defaults
final class CustomOSDWindowManager { final class CustomOSDWindowManager {
static let shared = CustomOSDWindowManager() static let shared = CustomOSDWindowManager()
private var volumeWindow: OSDWindow? private var volumeWindows: [NSScreen: OSDWindow] = [:]
private var brightnessWindow: OSDWindow? private var brightnessWindows: [NSScreen: OSDWindow] = [:]
private var backlightWindow: OSDWindow? private var backlightWindows: [NSScreen: OSDWindow] = [:]
private var hideWorkItem: DispatchWorkItem? private var hideWorkItem: DispatchWorkItem?
private let displayDuration: TimeInterval = 2.0 private let displayDuration: TimeInterval = 2.0
@ -50,62 +50,61 @@ final class CustomOSDWindowManager {
// MARK: - Private Implementation // MARK: - Private Implementation
private func show(type: SneakContentType, value: CGFloat, icon: String) { private func show(type: SneakContentType, value: CGFloat, icon: String) {
guard let screen = NSScreen.main else { return } let screens = NSScreen.screens
guard !screens.isEmpty else { return }
// Close other windows first // Close other windows first
hideAllWindowsExcept(type: type) hideAllWindowsExcept(type: type)
let window = ensureWindow(for: type) // Show on all screens
updateContent(window: window, type: type, value: value, icon: icon) for screen in screens {
let window = ensureWindow(for: type, screen: screen)
let targetFrame = calculateFrame(for: screen) updateContent(window: window, type: type, value: value, icon: icon)
if window.nsWindow.alphaValue <= 0.01 { let targetFrame = calculateFrame(for: screen)
// Initial presentation
presentWindow(window, targetFrame: targetFrame) if window.nsWindow.alphaValue <= 0.01 {
} else { // Initial presentation
// Update existing window presentWindow(window, targetFrame: targetFrame)
window.nsWindow.setFrame(targetFrame, display: true) } else {
window.nsWindow.orderFrontRegardless() // Update existing window
window.nsWindow.setFrame(targetFrame, display: true)
window.nsWindow.orderFrontRegardless()
}
} }
scheduleHide(for: type) scheduleHide(for: type)
} }
private func ensureWindow(for type: SneakContentType) -> OSDWindow { private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
let window: OSDWindow
switch type { switch type {
case .volume: case .volume:
if let existing = volumeWindow { if let existing = volumeWindows[screen] {
return existing return existing
} }
window = createWindow(for: type) let window = createWindow(for: type, screen: screen)
volumeWindow = window volumeWindows[screen] = window
return window
case .brightness: case .brightness:
if let existing = brightnessWindow { if let existing = brightnessWindows[screen] {
return existing return existing
} }
window = createWindow(for: type) let window = createWindow(for: type, screen: screen)
brightnessWindow = window brightnessWindows[screen] = window
return window
case .backlight: case .backlight:
if let existing = backlightWindow { if let existing = backlightWindows[screen] {
return existing return existing
} }
window = createWindow(for: type) let window = createWindow(for: type, screen: screen)
backlightWindow = window backlightWindows[screen] = window
return window
default: default:
fatalError("Unsupported OSD type: \(type)") fatalError("Unsupported OSD type: \(type)")
} }
return window
} }
private func createWindow(for type: SneakContentType) -> OSDWindow { private func createWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
guard let screen = NSScreen.main else {
fatalError("No main screen available")
}
let osdView = CustomOSDView(type: .constant(type), value: .constant(0), icon: .constant("")) let osdView = CustomOSDView(type: .constant(type), value: .constant(0), icon: .constant(""))
let hostingView = NSHostingView(rootView: osdView) let hostingView = NSHostingView(rootView: osdView)
@ -161,14 +160,20 @@ final class CustomOSDWindowManager {
} }
private func hideAllWindowsExcept(type: SneakContentType) { private func hideAllWindowsExcept(type: SneakContentType) {
if type != .volume, let volumeWindow = volumeWindow, volumeWindow.nsWindow.alphaValue > 0.01 { if type != .volume {
hideWindowImmediately(volumeWindow) for window in volumeWindows.values where window.nsWindow.alphaValue > 0.01 {
hideWindowImmediately(window)
}
} }
if type != .brightness, let brightnessWindow = brightnessWindow, brightnessWindow.nsWindow.alphaValue > 0.01 { if type != .brightness {
hideWindowImmediately(brightnessWindow) for window in brightnessWindows.values where window.nsWindow.alphaValue > 0.01 {
hideWindowImmediately(window)
}
} }
if type != .backlight, let backlightWindow = backlightWindow, backlightWindow.nsWindow.alphaValue > 0.01 { if type != .backlight {
hideWindowImmediately(backlightWindow) for window in backlightWindows.values where window.nsWindow.alphaValue > 0.01 {
hideWindowImmediately(window)
}
} }
} }
@ -197,20 +202,20 @@ final class CustomOSDWindowManager {
} }
private func hideWindow(for type: SneakContentType) { private func hideWindow(for type: SneakContentType) {
let window: OSDWindow? let windows: [OSDWindow]
switch type { switch type {
case .volume: case .volume:
window = volumeWindow windows = Array(volumeWindows.values)
case .brightness: case .brightness:
window = brightnessWindow windows = Array(brightnessWindows.values)
case .backlight: case .backlight:
window = backlightWindow windows = Array(backlightWindows.values)
default: default:
return return
} }
guard let window else { return } for window in windows {
let currentFrame = window.nsWindow.frame let currentFrame = window.nsWindow.frame
let hideFrame = currentFrame.offsetBy(dx: 0, dy: -20) let hideFrame = currentFrame.offsetBy(dx: 0, dy: -20)
@ -223,6 +228,7 @@ final class CustomOSDWindowManager {
} completionHandler: { } completionHandler: {
window.nsWindow.orderOut(nil) window.nsWindow.orderOut(nil)
} }
}
} }
// MARK: - Cleanup // MARK: - Cleanup
@ -231,13 +237,19 @@ final class CustomOSDWindowManager {
hideWorkItem?.cancel() hideWorkItem?.cancel()
hideWorkItem = nil hideWorkItem = nil
volumeWindow?.nsWindow.orderOut(nil) for window in volumeWindows.values {
brightnessWindow?.nsWindow.orderOut(nil) window.nsWindow.orderOut(nil)
backlightWindow?.nsWindow.orderOut(nil) }
for window in brightnessWindows.values {
window.nsWindow.orderOut(nil)
}
for window in backlightWindows.values {
window.nsWindow.orderOut(nil)
}
volumeWindow = nil volumeWindows.removeAll()
brightnessWindow = nil brightnessWindows.removeAll()
backlightWindow = nil backlightWindows.removeAll()
} }
} }

View file

@ -113,6 +113,17 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
modifiers: NSEvent.ModifierFlags modifiers: NSEvent.ModifierFlags
) { ) {
guard volumeEnabled else { return } guard volumeEnabled else { return }
// Elastic Limit Detection (Vertical HUD)
if Defaults[.enableVerticalHUD] {
let volume = volumeController.currentVolume
if direction == .up && volume >= 0.99 {
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: 1) }
} else if direction == .down && volume <= 0.01 {
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: -1) }
}
}
let baseStep = stepSize(for: step, base: standardVolumeStep) let baseStep = stepSize(for: step, base: standardVolumeStep)
let delta = direction == .up ? baseStep : -baseStep let delta = direction == .up ? baseStep : -baseStep
volumeController.adjust(by: delta) volumeController.adjust(by: delta)
@ -130,6 +141,16 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
isRepeat: Bool, isRepeat: Bool,
modifiers: NSEvent.ModifierFlags modifiers: NSEvent.ModifierFlags
) { ) {
// Elastic Limit Detection (Vertical HUD)
if Defaults[.enableVerticalHUD] {
let brightness = brightnessController.currentBrightness
if direction == .up && brightness >= 0.99 {
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: 1) }
} else if direction == .down && brightness <= 0.01 {
Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: -1) }
}
}
let baseStep = stepSize(for: step, base: standardBrightnessStep) let baseStep = stepSize(for: step, base: standardBrightnessStep)
let delta = direction == .up ? baseStep : -baseStep let delta = direction == .up ? baseStep : -baseStep
if modifiers.contains(.command) && keyboardBacklightEnabled { if modifiers.contains(.command) && keyboardBacklightEnabled {
@ -146,6 +167,22 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
return return
} }
// Send to Circular HUD if enabled
if Defaults[.enableCircularHUD] {
Task { @MainActor in
CircularHUDWindowManager.shared.show(type: .volume, value: CGFloat(value))
}
return
}
// Send to Vertical HUD if enabled
if Defaults[.enableVerticalHUD] {
Task { @MainActor in
VerticalHUDWindowManager.shared.show(type: .volume, value: CGFloat(value))
}
return
}
// Send to custom OSD if enabled // Send to custom OSD if enabled
if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] {
Task { @MainActor in Task { @MainActor in
@ -153,8 +190,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
} }
// Send to notch HUD if enabled and OSD is not enabled // Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] { if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableVolumeHUD] {
Task { @MainActor in Task { @MainActor in
guard let coordinator else { return } guard let coordinator else { return }
coordinator.toggleSneakPeek( coordinator.toggleSneakPeek(
@ -168,6 +205,22 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
private func sendBrightnessNotification(value: Float) { private func sendBrightnessNotification(value: Float) {
// Send to Circular HUD if enabled
if Defaults[.enableCircularHUD] {
Task { @MainActor in
CircularHUDWindowManager.shared.show(type: .brightness, value: CGFloat(value))
}
return
}
// Send to Vertical HUD if enabled
if Defaults[.enableVerticalHUD] {
Task { @MainActor in
VerticalHUDWindowManager.shared.show(type: .brightness, value: CGFloat(value))
}
return
}
// Send to custom OSD if enabled // Send to custom OSD if enabled
if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] {
Task { @MainActor in Task { @MainActor in
@ -175,8 +228,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
} }
// Send to notch HUD if enabled and OSD is not enabled // Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] { if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableBrightnessHUD] {
Task { @MainActor in Task { @MainActor in
guard let coordinator else { return } guard let coordinator else { return }
coordinator.toggleSneakPeek( coordinator.toggleSneakPeek(
@ -190,6 +243,22 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
private func sendKeyboardBacklightNotification(value: Float) { private func sendKeyboardBacklightNotification(value: Float) {
// Send to Circular HUD if enabled
if Defaults[.enableCircularHUD] {
Task { @MainActor in
CircularHUDWindowManager.shared.show(type: .backlight, value: CGFloat(value))
}
return
}
// Send to Vertical HUD if enabled
if Defaults[.enableVerticalHUD] {
Task { @MainActor in
VerticalHUDWindowManager.shared.show(type: .backlight, value: CGFloat(value))
}
return
}
// Send to custom OSD if enabled // Send to custom OSD if enabled
if Defaults[.enableCustomOSD] && Defaults[.enableOSDKeyboardBacklight] { if Defaults[.enableCustomOSD] && Defaults[.enableOSDKeyboardBacklight] {
Task { @MainActor in Task { @MainActor in
@ -197,8 +266,8 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
} }
// Send to notch HUD if enabled and OSD is not enabled // Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] { if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableKeyboardBacklightHUD] {
Task { @MainActor in Task { @MainActor in
guard let coordinator else { return } guard let coordinator else { return }
coordinator.toggleSneakPeek( coordinator.toggleSneakPeek(

View file

@ -23,17 +23,39 @@ class SystemHUDManager {
} }
private func setupSettingsObserver() { private func setupSettingsObserver() {
// Observe Vertical HUD toggle
Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in
guard let self = self, self.isSetupComplete else {
return
}
Task { @MainActor in
if change.newValue {
// Always restart to ensure correct flags are applied
await self.startSystemObserver()
} else if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableCircularHUD] {
// Restart to apply flags for the remaining active HUD
await self.startSystemObserver()
} else {
await self.stopSystemObserver()
}
}
}.store(in: &cancellables)
// Observe master HUD toggle // Observe master HUD toggle
Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in
guard let self = self, self.isSetupComplete else { guard let self = self, self.isSetupComplete else {
return return
} }
Task { @MainActor in Task { @MainActor in
if change.newValue && !Defaults[.enableCustomOSD] { if change.newValue && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
// Always restart to ensure correct flags are applied
await self.startSystemObserver() await self.startSystemObserver()
} else if !Defaults[.enableCustomOSD] { } else if !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] {
// Only stop if OSD is also disabled // Only stop if others are also disabled
await self.stopSystemObserver() await self.stopSystemObserver()
} else if change.newValue == false && (Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD]) {
// If disabling system HUD but others are active, restart to update flags
await self.startSystemObserver()
} }
} }
}.store(in: &cancellables) }.store(in: &cancellables)
@ -45,17 +67,30 @@ class SystemHUDManager {
} }
Task { @MainActor in Task { @MainActor in
if change.newValue { if change.newValue {
// Start observer for OSD if not already running // Always restart to ensure correct flags are applied
if self.changesObserver == nil { await self.startSystemObserver()
await self.startSystemObserver() } else if Defaults[.enableSystemHUD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] {
} // Restart to apply flags for the remaining active HUD
} else if Defaults[.enableSystemHUD] { await self.startSystemObserver()
// Keep observer running for HUD } else {
if self.changesObserver == nil { await self.stopSystemObserver()
await self.startSystemObserver() }
} }
}.store(in: &cancellables)
// Observe Circular HUD toggle
Defaults.publisher(.enableCircularHUD, options: []).sink { [weak self] change in
guard let self = self, self.isSetupComplete else {
return
}
Task { @MainActor in
if change.newValue {
// Always restart to ensure correct flags are applied
await self.startSystemObserver()
} else if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] {
// Restart to apply flags for the remaining active HUD
await self.startSystemObserver()
} else { } else {
// Stop observer if both HUD and OSD are disabled
await self.stopSystemObserver() await self.stopSystemObserver()
} }
} }
@ -145,8 +180,8 @@ class SystemHUDManager {
CustomOSDWindowManager.shared.initialize() CustomOSDWindowManager.shared.initialize()
} }
// Start observer if either HUD or OSD is enabled // Start observer if any HUD/OSD is enabled
if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] { if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] {
Task { @MainActor in Task { @MainActor in
await startSystemObserver() await startSystemObserver()
self.isSetupComplete = true self.isSetupComplete = true
@ -170,7 +205,12 @@ class SystemHUDManager {
let brightnessEnabled: Bool let brightnessEnabled: Bool
let keyboardBacklightEnabled: Bool let keyboardBacklightEnabled: Bool
if Defaults[.enableCustomOSD] { if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] {
// Vertical and Circular HUD support all events
volumeEnabled = true
brightnessEnabled = true
keyboardBacklightEnabled = true
} else if Defaults[.enableCustomOSD] {
volumeEnabled = Defaults[.enableOSDVolume] volumeEnabled = Defaults[.enableOSDVolume]
brightnessEnabled = Defaults[.enableOSDBrightness] brightnessEnabled = Defaults[.enableOSDBrightness]
keyboardBacklightEnabled = Defaults[.enableOSDKeyboardBacklight] keyboardBacklightEnabled = Defaults[.enableOSDKeyboardBacklight]
@ -186,7 +226,10 @@ class SystemHUDManager {
keyboardBacklightEnabled: keyboardBacklightEnabled keyboardBacklightEnabled: keyboardBacklightEnabled
) )
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]))") // Force disable system HUD to ensure no duplicates
SystemOSDManager.disableSystemHUD()
print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]))")
isSystemOperationInProgress = false isSystemOperationInProgress = false
} }
@ -198,6 +241,9 @@ class SystemHUDManager {
changesObserver?.stopObserving() changesObserver?.stopObserving()
changesObserver = nil changesObserver = nil
// Re-enable system HUD when we stop observing
SystemOSDManager.enableSystemHUD()
print("System observer stopped") print("System observer stopped")
isSystemOperationInProgress = false isSystemOperationInProgress = false
} }

View file

@ -0,0 +1,206 @@
#if os(macOS)
import AppKit
import SwiftUI
import SkyLightWindow
import QuartzCore
import Defaults
import Combine
@MainActor
final class VerticalHUDWindowManager {
static let shared = VerticalHUDWindowManager()
// Properties
private var windows: [NSScreen: OSDWindow] = [:]
private var hideWorkItem: DispatchWorkItem?
private let displayDuration: TimeInterval = 2.0
private let animationDuration: TimeInterval = 0.2
private var cancellables = Set<AnyCancellable>()
private init() {
setupPositionObserver()
}
// Helper Struct
private struct OSDWindow {
let nsWindow: NSWindow
let hostingView: NSHostingView<VerticalHUDView>
let state: VerticalHUDState
}
private func setupPositionObserver() {
// Observe layout keys individually
let updateBlock: (Any) -> Void = { [weak self] _ in
Task { @MainActor [weak self] in
self?.updateWindowLayout()
}
}
Defaults.publisher(.verticalHUDPosition, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
Defaults.publisher(.verticalHUDHeight, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
Defaults.publisher(.verticalHUDWidth, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
Defaults.publisher(.verticalHUDPadding, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
Defaults.publisher(.verticalHUDInteractive, options: []).sink(receiveValue: updateBlock).store(in: &cancellables)
}
private func updateWindowLayout() {
guard !windows.isEmpty else { return }
for (screen, window) in windows {
let screenFrame = screen.frame
let position = Defaults[.verticalHUDPosition]
let width = Defaults[.verticalHUDWidth]
let height = Defaults[.verticalHUDHeight]
let padding = Defaults[.verticalHUDPadding]
let interactive = Defaults[.verticalHUDInteractive]
// Add padding for shadow and elastic stretch
// We use 120px total padding (60px per side) to absolutely guarantee no clipping.
let shadowPadding: CGFloat = 120
let totalWidth = width + shadowPadding
let totalHeight = height + shadowPadding
let x: CGFloat
if position == "left" {
// Left edge of HUD is at (minX + padding).
// Window edge = HUD Edge - half shadow padding
x = screenFrame.minX + padding - (shadowPadding / 2)
} else {
// Right edge of HUD is at (maxX - padding).
// Left edge of HUD = maxX - padding - width.
// Window edge = HUD Edge - half shadow padding
x = screenFrame.maxX - width - padding - (shadowPadding / 2)
}
let y = screenFrame.midY - (totalHeight / 2)
let newFrame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight)
window.nsWindow.setFrame(newFrame, display: true, animate: true)
window.nsWindow.ignoresMouseEvents = !interactive
}
}
func show(type: SneakContentType, value: CGFloat, icon: String = "") {
guard Defaults[.enableVerticalHUD] else { return }
let screens = NSScreen.screens
guard !screens.isEmpty else { return }
// Show on all screens
for screen in screens {
let windowContext = ensureWindow(for: type, screen: screen)
updateContent(window: windowContext, type: type, value: value, icon: icon)
// Ensure visible
windowContext.nsWindow.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = animationDuration
windowContext.nsWindow.animator().alphaValue = 1
}
}
scheduleHide()
}
func triggerBump(direction: Int) {
guard Defaults[.enableVerticalHUD] else { return }
Task { @MainActor in
for window in windows.values {
window.state.bumpEvent = BumpEvent(direction: direction, timestamp: Date())
}
}
}
private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow {
if let existing = windows[screen] {
return existing
}
let state = VerticalHUDState(type: type, value: 0, icon: "")
let osdView = VerticalHUDView(state: state)
let hostingView = NSHostingView(rootView: osdView)
// Calculate frame
let screenFrame = screen.frame
let position = Defaults[.verticalHUDPosition]
let width = Defaults[.verticalHUDWidth]
let height = Defaults[.verticalHUDHeight]
let padding = Defaults[.verticalHUDPadding]
// Add padding for shadow and elastic stretch
// We use 120px total padding (60px per side) to absolutely guarantee no clipping.
let shadowPadding: CGFloat = 120
let totalWidth = width + shadowPadding
let totalHeight = height + shadowPadding
let x: CGFloat
if position == "left" {
x = screenFrame.minX + padding - (shadowPadding / 2)
} else {
x = screenFrame.maxX - width - padding - (shadowPadding / 2)
}
// Center vertically
let y = screenFrame.midY - (totalHeight / 2)
let frame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight)
let win = NSWindow(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
win.isOpaque = false
win.backgroundColor = NSColor.clear
win.level = NSWindow.Level.screenSaver
win.collectionBehavior = [NSWindow.CollectionBehavior.canJoinAllSpaces, NSWindow.CollectionBehavior.stationary, NSWindow.CollectionBehavior.ignoresCycle, NSWindow.CollectionBehavior.fullScreenAuxiliary]
win.hasShadow = false
win.contentView = hostingView
win.alphaValue = 0
// Allow mouse interaction if enabled
win.ignoresMouseEvents = !Defaults[.verticalHUDInteractive]
SkyLightOperator.shared.delegateWindow(win)
let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, state: state)
windows[screen] = windowStruct
return windowStruct
}
private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) {
// Update state instead of replacing view
window.state.type = type
window.state.value = value
window.state.icon = icon
}
private func scheduleHide() {
hideWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
Task { @MainActor in
self.hideWindow()
}
}
hideWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem)
}
private func hideWindow() {
for window in windows.values {
NSAnimationContext.runAnimationGroup { context in
context.duration = animationDuration
window.nsWindow.animator().alphaValue = 0
} completionHandler: {
// Keep window around
}
}
}
}
#endif

View file

@ -604,6 +604,24 @@ extension Defaults.Keys {
// MARK: Custom OSD Window Feature // MARK: Custom OSD Window Feature
static let enableCustomOSD = Key<Bool>("enableCustomOSD", default: false) static let enableCustomOSD = Key<Bool>("enableCustomOSD", default: false)
static let enableVerticalHUD = Key<Bool>("enableVerticalHUD", default: false)
static let enableCircularHUD = Key<Bool>("enableCircularHUD", default: false)
static let verticalHUDPosition = Key<String>("verticalHUDPosition", default: "right") // "left" or "right"
// Vertical HUD Customization
static let verticalHUDShowValue = Key<Bool>("verticalHUDShowValue", default: true)
static let verticalHUDInteractive = Key<Bool>("verticalHUDInteractive", default: true)
static let verticalHUDHeight = Key<CGFloat>("verticalHUDHeight", default: 160)
static let verticalHUDWidth = Key<CGFloat>("verticalHUDWidth", default: 36)
static let verticalHUDPadding = Key<CGFloat>("verticalHUDPadding", default: 24)
static let verticalHUDUseAccentColor = Key<Bool>("verticalHUDUseAccentColor", default: false)
// Circular HUD Customization
static let circularHUDShowValue = Key<Bool>("circularHUDShowValue", default: true)
static let circularHUDSize = Key<CGFloat>("circularHUDSize", default: 65)
static let circularHUDStrokeWidth = Key<CGFloat>("circularHUDStrokeWidth", default: 4)
static let circularHUDUseAccentColor = Key<Bool>("circularHUDUseAccentColor", default: true)
static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false) static let hasSeenOSDAlphaWarning = Key<Bool>("hasSeenOSDAlphaWarning", default: false)
static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true) static let enableOSDVolume = Key<Bool>("enableOSDVolume", default: true)
static let enableOSDBrightness = Key<Bool>("enableOSDBrightness", default: true) static let enableOSDBrightness = Key<Bool>("enableOSDBrightness", default: true)