Merge pull request #239 from Ebullioscopic/feature/liveactivity

Update CAPS Lock live activity
This commit is contained in:
Hariharan Mudaliar 2026-01-08 23:24:44 +05:30 • committed by GitHub
commit c2d9b851d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 273 additions and 25 deletions

View file

@ -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 {

View file

@ -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()

View file

@ -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
@ -206,6 +207,9 @@ class DynamicIslandViewCoordinator: ObservableObject {
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))
guard let self = self, !Task.isCancelled else { return }

View file

@ -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",

View file

@ -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,30 +34,45 @@ 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 type == .bluetoothAudio {
if showBluetoothDeviceNameMarquee {
return enableMinimalisticUI ? 128 : 140
}
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 type == .bluetoothAudio {
if showBluetoothDeviceNameMarquee {
return enableMinimalisticUI ? 112 : 120
}
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 type == .bluetoothAudio {
if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88)
}
@ -65,13 +82,23 @@ struct InlineHUD: View {
}
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108)
}
if type == .capsLock {
if showCapsLockLabel {
return enableMinimalisticUI ? 84 : 96
}
return 0
}
return 100
}()
let trailingWidth: CGFloat = {
var width = baseTrailingWidth + gestureProgress / 2
if !hoverAnimation { width -= 8 }
let minimum: CGFloat = {
guard type == .bluetoothAudio else { return 90 }
if type == .bluetoothAudio {
if !hasBatteryLevel {
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 62 : 88)
}
@ -81,6 +108,13 @@ struct InlineHUD: View {
}
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 72 : 90)
}
if type == .capsLock {
return showCapsLockLabel ? (enableMinimalisticUI ? 68 : 80) : 0
}
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 ""
}

View file

@ -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)
}
}
}
}

View file

@ -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"))

View file

@ -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: ""
)
}
}
}

View file

@ -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]

View file

@ -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<Bool>("enableLockScreenLiveActivity", default: true)
static let enableLockSounds = Key<Bool>("enableLockSounds", default: true)
// MARK: Caps Lock Indicator
static let enableCapsLockIndicator = Key<Bool>("enableCapsLockIndicator", default: true)
static let capsLockIndicatorUseGreenColor = Key<Bool>("capsLockIndicatorUseGreenColor", default: false) // Legacy toggle
static let capsLockIndicatorTintMode = Key<CapsLockIndicatorTintMode>("capsLockIndicatorTintMode", default: .white)
static let didMigrateCapsLockTintMode = Key<Bool>("didMigrateCapsLockTintMode", default: false)
static let showCapsLockLabel = Key<Bool>("showCapsLockLabel", default: true)
// MARK: ImageService
static let didClearLegacyURLCacheV1 = Key<Bool>("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 }