diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 206c6a2..ebcc50e 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -28,6 +28,7 @@ struct ContentView: View { @ObservedObject var privacyManager = PrivacyIndicatorManager.shared @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared + @ObservedObject var capsLockManager = CapsLockManager.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -484,7 +485,14 @@ struct ContentView: View { .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) - .transition(.opacity) + .transition( + coordinator.sneakPeek.type == .capsLock + ? .move(edge: .trailing).combined(with: .opacity) + : .opacity + ) + } else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { + InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress) + .transition(.move(edge: .trailing).combined(with: .opacity)) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked { MusicLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 59981a7..0640878 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -345,6 +345,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { Defaults.Keys.migrateProgressBarStyle() Defaults.Keys.migrateMusicAuxControls() Defaults.Keys.migrateMusicControlSlots() + Defaults.Keys.migrateCapsLockTintMode() // Initialize idle animations (load bundled + built-in face) idleAnimationManager.initializeDefaultAnimations() diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 2bc22e6..66aec7e 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -24,6 +24,7 @@ enum SneakContentType { case bluetoothAudio case privacy case lockScreen + case capsLock } struct sneakPeek { @@ -190,7 +191,7 @@ class DynamicIslandViewCoordinator: ObservableObject { return } DispatchQueue.main.async { - withAnimation(.smooth) { + withAnimation(.smooth(duration: 0.3)) { self.sneakPeek.show = status self.sneakPeek.type = type self.sneakPeek.value = value @@ -205,6 +206,9 @@ class DynamicIslandViewCoordinator: ObservableObject { // Helper function to manage sneakPeek timer using Swift Concurrency private func scheduleSneakPeekHide(after duration: TimeInterval) { sneakPeekTask?.cancel() + + // Don't schedule auto-hide if duration is infinite (for persistent indicators like Caps Lock) + guard duration.isFinite else { return } sneakPeekTask = Task { [weak self] in try? await Task.sleep(for: .seconds(duration)) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 459d989..0f8860d 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2362,6 +2362,9 @@ } } } + }, + "Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : { + }, "Adds the AirPlay/route picker button back to the customizable controls palette." : { @@ -4622,6 +4625,15 @@ } } } + }, + "Caps Lock" : { + + }, + "Caps Lock color" : { + + }, + "Caps Lock Indicator" : { + }, "Center" : { "extractionState" : "stale", diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index fed58ea..310ec35 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -25,6 +25,8 @@ struct InlineHUD: View { @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.showCapsLockLabel) var showCapsLockLabel + @Default(.capsLockIndicatorTintMode) var capsLockTintMode @ObservedObject var bluetoothManager = BluetoothAudioManager.shared @State private var displayName: String = "" @@ -32,55 +34,87 @@ struct InlineHUD: View { var body: some View { let useCircularIndicator = useCircularBluetoothBatteryIndicator let hasBatteryLevel = value > 0 + let capsLockAccentColor = capsLockTintMode.color let baseInfoWidth: CGFloat = { - guard type == .bluetoothAudio else { return 100 } - if showBluetoothDeviceNameMarquee { - return enableMinimalisticUI ? 128 : 140 + if type == .bluetoothAudio { + if showBluetoothDeviceNameMarquee { + return enableMinimalisticUI ? 128 : 140 + } + return enableMinimalisticUI ? 64 : 72 } - return enableMinimalisticUI ? 64 : 72 + + if type == .capsLock && !showCapsLockLabel { + return enableMinimalisticUI ? 56 : 64 + } + + return 100 }() let infoWidth: CGFloat = { var width = baseInfoWidth + gestureProgress / 2 if !hoverAnimation { width -= 8 } let minimum: CGFloat = { - guard type == .bluetoothAudio else { return 88 } - if showBluetoothDeviceNameMarquee { - return enableMinimalisticUI ? 112 : 120 + if type == .bluetoothAudio { + if showBluetoothDeviceNameMarquee { + return enableMinimalisticUI ? 112 : 120 + } + return enableMinimalisticUI ? 56 : 68 } - return enableMinimalisticUI ? 56 : 68 + + if type == .capsLock && !showCapsLockLabel { + return enableMinimalisticUI ? 44 : 52 + } + + return 88 }() return max(width, minimum) }() let baseTrailingWidth: CGFloat = { - guard type == .bluetoothAudio else { return 100 } - if !hasBatteryLevel { - return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88) + if type == .bluetoothAudio { + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88) + } + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 72 : 84) + } + + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108) } - if useCircularIndicator { - return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 72 : 84) + if type == .capsLock { + if showCapsLockLabel { + return enableMinimalisticUI ? 84 : 96 + } + return 0 } - return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108) + return 100 }() let trailingWidth: CGFloat = { var width = baseTrailingWidth + gestureProgress / 2 if !hoverAnimation { width -= 8 } let minimum: CGFloat = { - guard type == .bluetoothAudio else { return 90 } - if !hasBatteryLevel { - return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 62 : 88) + if type == .bluetoothAudio { + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 62 : 88) + } + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 56 : 72) + } + + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 72 : 90) } - if useCircularIndicator { - return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 56 : 72) + if type == .capsLock { + return showCapsLockLabel ? (enableMinimalisticUI ? 68 : 80) : 0 } - return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 72 : 90) + return 90 }() return max(width, minimum) }() @@ -128,6 +162,12 @@ struct InlineHUD: View { .symbolRenderingMode(.hierarchical) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) + case .capsLock: + Image(systemName: "capslock.fill") + .symbolRenderingMode(.hierarchical) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + .foregroundStyle(capsLockAccentColor) default: EmptyView() } @@ -147,7 +187,7 @@ struct InlineHUD: View { frameWidth: infoWidth ) } - } else { + } else if type != .capsLock { Text(Type2Name(type)) .font(.subheadline) .fontWeight(.medium) @@ -180,6 +220,18 @@ struct InlineHUD: View { .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity, alignment: .trailing) .contentTransition(.interpolate) + } else if (type == .capsLock) { + if showCapsLockLabel { + Text("Caps Lock") + .foregroundStyle(capsLockAccentColor) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .allowsTightening(true) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + .contentTransition(.interpolate) + } } else if (type == .bluetoothAudio) { if hasBatteryLevel { let indicatorSpacing: CGFloat = { @@ -374,6 +426,8 @@ struct InlineHUD: View { return "Mic" case .bluetoothAudio: return BluetoothAudioManager.shared.lastConnectedDevice?.name ?? "Bluetooth" + case .capsLock: + return "Caps Lock" default: return "" } diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index 7b0b796..50aba39 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -26,6 +26,8 @@ struct LockScreenLiveActivityOverlay: View { Self.collapsedScale(for: notchSize) } + @State private var isHovering: Bool = false + var body: some View { HStack(spacing: 0) { Color.clear @@ -52,8 +54,18 @@ struct LockScreenLiveActivityOverlay: View { ) ) .frame(width: totalWidth, height: notchSize.height) - .scaleEffect(x: max(model.scale, collapsedScale), y: 1, anchor: .center) + .scaleEffect(x: max(model.scale, collapsedScale) * (isHovering ? 1.03 : 1.0), + y: 1 * (isHovering ? 1.03 : 1.0), + anchor: .center) .opacity(model.opacity) + .onHover { hovering in + withAnimation(.smooth(duration: 0.2)) { + isHovering = hovering + } + if hovering { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default) + } + } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 59de231..546eefd 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2538,6 +2538,7 @@ struct LiveActivitiesSettings: View { @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent + @Default(.capsLockIndicatorTintMode) var capsLockTintMode private func highlightID(_ title: String) -> String { SettingsTab.liveActivities.highlightID(for: title) @@ -2639,6 +2640,28 @@ struct LiveActivitiesSettings: View { Text("Listens for Focus session changes via distributed notifications") } + Section { + Defaults.Toggle("Show Caps Lock Indicator", key: .enableCapsLockIndicator) + .settingsHighlight(id: highlightID("Show Caps Lock Indicator")) + + Defaults.Toggle("Show Caps Lock label", key: .showCapsLockLabel) + .disabled(!Defaults[.enableCapsLockIndicator]) + .settingsHighlight(id: highlightID("Show Caps Lock label")) + + Picker("Caps Lock color", selection: $capsLockTintMode) { + ForEach(CapsLockIndicatorTintMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!Defaults[.enableCapsLockIndicator]) + .settingsHighlight(id: highlightID("Caps Lock color")) + } header: { + Text("Caps Lock Indicator") + } footer: { + Text("Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls.") + } + Section { Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection) .settingsHighlight(id: highlightID("Enable Camera Detection")) diff --git a/DynamicIsland/managers/CapsLockManager.swift b/DynamicIsland/managers/CapsLockManager.swift new file mode 100644 index 0000000..489d037 --- /dev/null +++ b/DynamicIsland/managers/CapsLockManager.swift @@ -0,0 +1,89 @@ +// +// CapsLockManager.swift +// DynamicIsland +// +// Created for Caps Lock indicator feature +// Monitors Caps Lock state and integrates with Dynamic Island +// + +import Foundation +import Combine +import AppKit +import Defaults +import SwiftUI + +@MainActor +class CapsLockManager: ObservableObject { + static let shared = CapsLockManager() + + @Published var isCapsLockActive: Bool = false + + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + private let coordinator = DynamicIslandViewCoordinator.shared + private let capsLockAnimation = Animation.spring(response: 0.32, dampingFraction: 0.85) + + private init() { + // Get initial state + isCapsLockActive = NSEvent.modifierFlags.contains(.capsLock) + + // Monitor flag changes when app is focused + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.handleFlagsChanged(event) + return event + } + + // Monitor flag changes globally (even when app is not focused) + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + Task { @MainActor in + self?.handleFlagsChanged(event) + } + } + + print("CapsLockManager: ✅ Initialized with Caps Lock \(isCapsLockActive ? "ON" : "OFF")") + } + + deinit { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + } + if let monitor = globalEventMonitor { + NSEvent.removeMonitor(monitor) + } + } + + private func handleFlagsChanged(_ event: NSEvent) { + let newState = event.modifierFlags.contains(.capsLock) + + guard newState != isCapsLockActive else { return } + + withAnimation(capsLockAnimation) { + isCapsLockActive = newState + } + + print("CapsLockManager: Caps Lock \(newState ? "ACTIVATED" : "DEACTIVATED")") + + // Only show/hide if feature is enabled + guard Defaults[.enableCapsLockIndicator] else { return } + + if newState { + // Show inline indicator + coordinator.toggleSneakPeek( + status: true, + type: .capsLock, + duration: .infinity, // Stay visible until deactivated + value: 1.0, + icon: "" + ) + } else { + // Hide indicator + coordinator.toggleSneakPeek( + status: false, + type: .capsLock, + duration: 0, + value: 0, + icon: "" + ) + } + } +} diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index 4cc22a2..dda84e4 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -68,7 +68,7 @@ class LockScreenLiveActivityWindowManager { window.isOpaque = false window.backgroundColor = .clear window.isReleasedWhenClosed = false - window.ignoresMouseEvents = true + window.ignoresMouseEvents = false window.hasShadow = false window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 4f00e16..39e655d 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -191,6 +191,36 @@ enum SneakPeekStyle: String, CaseIterable, Identifiable, Defaults.Serializable { var id: String { self.rawValue } } +enum CapsLockIndicatorTintMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case green + case accent + case white + + var id: String { rawValue } + + var displayName: String { + switch self { + case .green: + return "Green" + case .accent: + return "Accent" + case .white: + return "White" + } + } + + var color: Color { + switch self { + case .green: + return .green + case .accent: + return .accentColor + case .white: + return .white + } + } +} + enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable { case hierarchical = "Hierarchical" case gradient = "Gradient" @@ -738,6 +768,13 @@ extension Defaults.Keys { static let enableLockScreenLiveActivity = Key("enableLockScreenLiveActivity", default: true) static let enableLockSounds = Key("enableLockSounds", default: true) + // MARK: Caps Lock Indicator + static let enableCapsLockIndicator = Key("enableCapsLockIndicator", default: true) + static let capsLockIndicatorUseGreenColor = Key("capsLockIndicatorUseGreenColor", default: false) // Legacy toggle + static let capsLockIndicatorTintMode = Key("capsLockIndicatorTintMode", default: .white) + static let didMigrateCapsLockTintMode = Key("didMigrateCapsLockTintMode", default: false) + static let showCapsLockLabel = Key("showCapsLockLabel", default: true) + // MARK: ImageService static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false) @@ -788,6 +825,14 @@ extension Defaults.Keys { normalizeMusicAuxControls() } + static func migrateCapsLockTintMode() { + guard Defaults[.didMigrateCapsLockTintMode] == false else { return } + + let legacyGreen = Defaults[.capsLockIndicatorUseGreenColor] + Defaults[.capsLockIndicatorTintMode] = legacyGreen ? .green : .white + Defaults[.didMigrateCapsLockTintMode] = true + } + static func migrateMusicControlSlots() { guard Defaults[.didMigrateMusicControlSlots] == false else { return }