mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update HUDs
This commit is contained in:
commit
7e9fc2ecb8
12 changed files with 1515 additions and 263 deletions
|
|
@ -98,10 +98,12 @@ extension ColorCodedProgressBar {
|
|||
|
||||
// MARK: - Palette Helpers
|
||||
|
||||
private enum ColorCodedPalette {
|
||||
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)
|
||||
private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
|
||||
// MARK: - Palette Helpers
|
||||
|
||||
enum ColorCodedPalette {
|
||||
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 {
|
||||
if smooth {
|
||||
|
|
|
|||
157
DynamicIsland/components/OSD/CircularHUDView.swift
Normal file
157
DynamicIsland/components/OSD/CircularHUDView.swift
Normal 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
|
||||
|
|
@ -21,22 +21,30 @@ struct CustomOSDView: View {
|
|||
var body: some View {
|
||||
VStack(spacing: spacing) {
|
||||
// Icon
|
||||
Image(systemName: symbolName)
|
||||
.font(.system(size: iconSize, weight: .regular))
|
||||
.foregroundStyle(iconColor)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
ZStack {
|
||||
Image(systemName: symbolName)
|
||||
.font(.system(size: iconSize, weight: .regular))
|
||||
.foregroundStyle(iconColor)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.frame(width: 80, height: 60, alignment: .center) // Fixed container for icon
|
||||
|
||||
// Progress/Value Indicator
|
||||
progressView
|
||||
.frame(width: 140)
|
||||
.padding(.top, 4) // Lower the bar slightly
|
||||
}
|
||||
.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(
|
||||
backgroundView
|
||||
.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
|
||||
|
|
|
|||
229
DynamicIsland/components/OSD/VerticalHUDView.swift
Normal file
229
DynamicIsland/components/OSD/VerticalHUDView.swift
Normal 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
|
||||
|
|
@ -25,8 +25,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case media
|
||||
case timer
|
||||
case calendar
|
||||
case hud
|
||||
case osd
|
||||
case hudAndOSD
|
||||
case battery
|
||||
case stats
|
||||
case clipboard
|
||||
|
|
@ -48,8 +47,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .media: return "Media"
|
||||
case .timer: return "Timer"
|
||||
case .calendar: return "Calendar"
|
||||
case .hud: return "HUDs"
|
||||
case .osd: return "Custom OSD"
|
||||
case .hudAndOSD: return "HUDs & OSD"
|
||||
case .battery: return "Battery"
|
||||
case .stats: return "Stats"
|
||||
case .clipboard: return "Clipboard"
|
||||
|
|
@ -71,8 +69,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .media: return "play.laptopcomputer"
|
||||
case .timer: return "timer"
|
||||
case .calendar: return "calendar"
|
||||
case .hud: return "dial.medium.fill"
|
||||
case .osd: return "square.fill.on.square.fill"
|
||||
case .hudAndOSD: return "dial.medium.fill"
|
||||
case .battery: return "battery.100.bolt"
|
||||
case .stats: return "chart.xyaxis.line"
|
||||
case .clipboard: return "clipboard"
|
||||
|
|
@ -94,8 +91,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
|||
case .media: return .green
|
||||
case .timer: return .red
|
||||
case .calendar: return .cyan
|
||||
case .hud: return .indigo
|
||||
case .osd: return .teal
|
||||
case .hudAndOSD: return .indigo
|
||||
case .battery: return .yellow
|
||||
case .stats: return .teal
|
||||
case .clipboard: return .mint
|
||||
|
|
@ -260,18 +256,7 @@ struct SettingsView: View {
|
|||
HStack(spacing: 10) {
|
||||
sidebarIcon(for: tab)
|
||||
Text(tab.title)
|
||||
if tab == .osd {
|
||||
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 {
|
||||
if tab == .downloads {
|
||||
Spacer()
|
||||
Text("BETA")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
|
|
@ -394,8 +379,7 @@ struct SettingsView: View {
|
|||
.media,
|
||||
.timer,
|
||||
.calendar,
|
||||
.hud,
|
||||
.osd,
|
||||
.hudAndOSD,
|
||||
.battery,
|
||||
.stats,
|
||||
.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")),
|
||||
|
||||
// HUDs
|
||||
SettingsSearchEntry(tab: .hud, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hud.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: .hud, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.hud.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: .hud, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.hud.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: .hud, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hud.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: .hud, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hud.highlightID(for: "HUD style")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hud.highlightID(for: "Progressbar style")),
|
||||
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hud.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: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show Bluetooth device connections")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use circular battery indicator")),
|
||||
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: .hudAndOSD, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Scroll device name in HUD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded battery display")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show percentages beside progress bars")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "HUD style")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Progressbar style")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable glowing effect")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use accent color")),
|
||||
|
||||
// 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: .osd, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.osd.highlightID(for: "Volume OSD")),
|
||||
SettingsSearchEntry(tab: .osd, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.osd.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: .osd, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.osd.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: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable Custom OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
|
||||
SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
|
||||
|
||||
// Media
|
||||
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) {
|
||||
CalendarSettings()
|
||||
}
|
||||
case .hud:
|
||||
SettingsForm(tab: .hud) {
|
||||
HUD()
|
||||
}
|
||||
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 .hudAndOSD:
|
||||
SettingsForm(tab: .hudAndOSD) {
|
||||
HUDAndOSDSettingsView()
|
||||
}
|
||||
case .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 {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Default(.inlineHUD) var inlineHUD
|
||||
|
|
@ -1225,7 +1642,7 @@ struct HUD: View {
|
|||
@ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.hud.highlightID(for: title)
|
||||
SettingsTab.hudAndOSD.highlightID(for: title)
|
||||
}
|
||||
|
||||
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 {
|
||||
Section {
|
||||
|
|
@ -4537,7 +4935,7 @@ struct CustomOSDSettings: View {
|
|||
@State private var previewType: SneakContentType = .volume
|
||||
|
||||
private func highlightID(_ title: String) -> String {
|
||||
SettingsTab.osd.highlightID(for: title)
|
||||
SettingsTab.hudAndOSD.highlightID(for: title)
|
||||
}
|
||||
|
||||
private var hasAccessibilityPermission: Bool {
|
||||
|
|
@ -4558,46 +4956,7 @@ struct CustomOSDSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
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 {
|
||||
if hasAccessibilityPermission {
|
||||
Section {
|
||||
Toggle("Volume OSD", isOn: $enableOSDVolume)
|
||||
.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")
|
||||
.onAppear {
|
||||
accessibilityPermission.refreshStatus()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue