From 83cca50a665dda6ee5882c8bd2818fb64bc25b7d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 13 Dec 2025 00:54:17 +0530 Subject: [PATCH] Update DND UI for toast mode --- .../Focus/DoNotDisturbLiveActivity.swift | 153 +++++++++++++++--- .../LockScreenLiveActivityOverlay.swift | 16 +- .../managers/DoNotDisturbManager.swift | 7 +- .../LockScreenLiveActivityWindowManager.swift | 19 ++- 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index ef430b9..76e1c01 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -6,6 +6,7 @@ // an icon-first layout that collapses gracefully when Focus ends. // +import AppKit import Defaults import SwiftUI @@ -30,12 +31,12 @@ struct DoNotDisturbLiveActivity: View { Rectangle() .fill(Color.black) - .frame(width: vm.closedNotchSize.width) + .frame(width: centerSegmentWidth) labelWing .frame(width: labelWingWidth, height: wingHeight) } - .frame(height: vm.effectiveClosedNotchHeight) + .frame(width: notchEnvelopeWidth, height: vm.effectiveClosedNotchHeight) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) .onAppear(perform: handleInitialState) @@ -45,6 +46,30 @@ struct DoNotDisturbLiveActivity: View { // MARK: - Layout helpers + private var collapsedNotchWidth: CGFloat { + let width = currentClosedNotchWidth + let scale = (resolvedScreen?.backingScaleFactor).flatMap { $0 > 0 ? $0 : nil } + ?? NSScreen.main?.backingScaleFactor + ?? 2 + let aligned = (width * scale).rounded(.down) / scale + return max(0, aligned) + } + + private var currentClosedNotchWidth: CGFloat { + if vm.notchState == .closed, vm.notchSize.width > 0 { + return vm.notchSize.width + } + return vm.closedNotchSize.width + } + + private var resolvedScreen: NSScreen? { + if let name = vm.screen, + let match = NSScreen.screens.first(where: { $0.localizedName == name }) { + return match + } + return NSScreen.main + } + private var wingHeight: CGFloat { max(vm.effectiveClosedNotchHeight - 10, 20) } @@ -65,13 +90,32 @@ struct DoNotDisturbLiveActivity: View { return max(desiredLabelWidth, minimalWingWidth) } + private var notchEnvelopeWidth: CGFloat { + centerSegmentWidth + iconWingWidth + labelWingWidth + } + private var minimalWingWidth: CGFloat { max(vm.effectiveClosedNotchHeight - 12, 24) } + private var closedNotchContentInset: CGFloat { + cornerRadiusInsets.closed.top + cornerRadiusInsets.closed.bottom + } + + private var collapsedToastBaseWidth: CGFloat { + max(0, collapsedNotchWidth - closedNotchContentInset) + } + + private var centerSegmentWidth: CGFloat { + if focusToastMode && iconWingWidth == 0 && labelWingWidth == 0 { + return collapsedToastBaseWidth + } + return collapsedNotchWidth + } + private var desiredLabelWidth: CGFloat { let measuredWidth = labelIntrinsicWidth + 8 // horizontal padding inside the label - let fallbackWidth = max(vm.closedNotchSize.width * 0.52, 136) + let fallbackWidth = max(collapsedNotchWidth * 0.52, 136) var width = max(measuredWidth, fallbackWidth) if focusMode == .doNotDisturb && shouldShowLabel { @@ -128,14 +172,45 @@ struct DoNotDisturbLiveActivity: View { private var currentIcon: Image { if manager.isDoNotDisturbActive { - return focusMode.activeIcon + return focusMode.resolvedActiveIcon(usePrivateSymbol: true) } else if showInactiveIcon { - return Image(systemName: focusMode.inactiveSymbol) + return inactiveIconMatchingActiveStyle } else { - return focusMode.activeIcon + return focusMode.resolvedActiveIcon(usePrivateSymbol: true) } } + private var inactiveIconMatchingActiveStyle: Image { + if focusMode == .work { + return focusMode.resolvedActiveIcon(usePrivateSymbol: true).renderingMode(.template) + } + + if focusMode == .gaming, + SymbolAvailabilityCache.shared.isSymbolAvailable("rocket.circle.fill") { + return Image(systemName: "rocket.circle.fill") + } + + if let internalName = focusMode.internalSymbolName { + if let outlineName = outlineVariant(for: internalName), + SymbolAvailabilityCache.shared.isSymbolAvailable(outlineName), + let outlinedImage = Image(internalSystemName: outlineName) { + return outlinedImage.renderingMode(.template) + } + + if let filledImage = Image(internalSystemName: internalName) { + return filledImage.renderingMode(.template) + } + } + + return Image(systemName: focusMode.inactiveSymbol) + } + + private func outlineVariant(for internalName: String) -> String? { + guard internalName.hasSuffix(".fill") else { return nil } + let trimmed = String(internalName.dropLast(5)) + return trimmed.isEmpty ? nil : trimmed + } + private var currentIconColor: Color { if manager.isDoNotDisturbActive { return activeAccentColor @@ -169,7 +244,7 @@ struct DoNotDisturbLiveActivity: View { if shouldShowLabel { Text(labelText) .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundColor(activeAccentColor) + .foregroundColor(labelColor) .lineLimit(1) .minimumScaleFactor(0.8) .contentTransition(.opacity) @@ -188,6 +263,13 @@ struct DoNotDisturbLiveActivity: View { } } + private var labelColor: Color { + if focusToastMode { + return manager.isDoNotDisturbActive ? activeAccentColor : .white + } + return activeAccentColor + } + // MARK: - State transitions private func handleInitialState() { @@ -227,22 +309,23 @@ struct DoNotDisturbLiveActivity: View { showInactiveIcon = true } - withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { - iconScale = 1.2 - } + if focusToastMode { + iconScale = 1.0 + scaleResetTask?.cancel() + } else { + withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { + iconScale = 1.2 + } - let toastMode = focusToastMode - scaleResetTask?.cancel() - scaleResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(toastMode ? 650 : 450)) - withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { - iconScale = 1.0 - } - if toastMode { - return - } - withAnimation(.smooth(duration: 0.2)) { - showInactiveIcon = false + scaleResetTask?.cancel() + scaleResetTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(450)) + withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { + iconScale = 1.0 + } + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false + } } } @@ -304,3 +387,29 @@ private struct FocusLabelWidthPreferenceKey: PreferenceKey { value = max(value, nextValue()) } } + +private final class SymbolAvailabilityCache { + static let shared = SymbolAvailabilityCache() + private var cache: [String: Bool] = [:] + private let lock = NSLock() + + func isSymbolAvailable(_ name: String) -> Bool { + lock.lock() + if let cached = cache[name] { + lock.unlock() + return cached + } + lock.unlock() + + #if canImport(AppKit) + let available = NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + #else + let available = false + #endif + + lock.lock() + cache[name] = available + lock.unlock() + return available + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index 512e475..7b0b796 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -22,6 +22,10 @@ struct LockScreenLiveActivityOverlay: View { notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) } + private var collapsedScale: CGFloat { + Self.collapsedScale(for: notchSize) + } + var body: some View { HStack(spacing: 0) { Color.clear @@ -48,7 +52,17 @@ struct LockScreenLiveActivityOverlay: View { ) ) .frame(width: totalWidth, height: notchSize.height) - .scaleEffect(x: model.scale, y: 1, anchor: .center) + .scaleEffect(x: max(model.scale, collapsedScale), y: 1, anchor: .center) .opacity(model.opacity) } } + +extension LockScreenLiveActivityOverlay { + static func collapsedScale(for notchSize: CGSize) -> CGFloat { + let indicatorSize = max(0, notchSize.height - 12) + let horizontalPadding = cornerRadiusInsets.closed.bottom + let totalWidth = notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + guard totalWidth > 0 else { return 1 } + return notchSize.width / totalWidth + } +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index e323fed..0afcc92 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -352,7 +352,12 @@ enum FocusModeType: String, CaseIterable { } var activeIcon: Image { - if let internalSymbolName, + resolvedActiveIcon() + } + + func resolvedActiveIcon(usePrivateSymbol: Bool = true) -> Image { + if usePrivateSymbol, + let internalSymbolName, let image = Image(internalSystemName: internalSymbolName) { return image } diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index b5fb65a..4cc22a2 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -24,6 +24,7 @@ class LockScreenLiveActivityWindowManager { private weak var viewModel: DynamicIslandViewModel? private var screenChangeObserver: NSObjectProtocol? private var workspaceObservers: [NSObjectProtocol] = [] + private var currentNotchSize: CGSize? private init() { registerScreenChangeObservers() @@ -136,6 +137,8 @@ class LockScreenLiveActivityWindowManager { hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize) } + currentNotchSize = context.notchSize + print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)") } @@ -175,14 +178,18 @@ class LockScreenLiveActivityWindowManager { window.orderFrontRegardless() window.alphaValue = 1 + + currentNotchSize = notchSize } func showLocked() { hideTask?.cancel() guard let context = lockContext() else { return } + let collapsedScale = LockScreenLiveActivityOverlay.collapsedScale(for: context.notchSize) + overlayAnimator.update(isLocked: true) - overlayModel.scale = 0.6 + overlayModel.scale = collapsedScale overlayModel.opacity = 0 present(notchSize: context.notchSize, on: context.screen) @@ -229,9 +236,16 @@ class LockScreenLiveActivityWindowManager { private func hideWithAnimation() { guard let window else { return } + let targetScale: CGFloat + if let notchSize = currentNotchSize { + targetScale = LockScreenLiveActivityOverlay.collapsedScale(for: notchSize) + } else { + targetScale = 0.7 + } + withAnimation(.smooth(duration: LockScreenAnimationTimings.unlockCollapse)) { overlayModel.opacity = 0 - overlayModel.scale = 0.7 + overlayModel.scale = targetScale } NSAnimationContext.runAnimationGroup { context in @@ -242,6 +256,7 @@ class LockScreenLiveActivityWindowManager { DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) { window.orderOut(nil) + self.currentNotchSize = nil } print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden")