Update DND UI for toast mode

This commit is contained in:
Hariharan Mudaliar 2025-12-13 00:54:17 +05:30
commit 83cca50a66
No known key found for this signature in database
GPG key ID: D04F1C2B9194DDFC
4 changed files with 169 additions and 26 deletions

View file

@ -6,6 +6,7 @@
// an icon-first layout that collapses gracefully when Focus ends. // an icon-first layout that collapses gracefully when Focus ends.
// //
import AppKit
import Defaults import Defaults
import SwiftUI import SwiftUI
@ -30,12 +31,12 @@ struct DoNotDisturbLiveActivity: View {
Rectangle() Rectangle()
.fill(Color.black) .fill(Color.black)
.frame(width: vm.closedNotchSize.width) .frame(width: centerSegmentWidth)
labelWing labelWing
.frame(width: labelWingWidth, height: wingHeight) .frame(width: labelWingWidth, height: wingHeight)
} }
.frame(height: vm.effectiveClosedNotchHeight) .frame(width: notchEnvelopeWidth, height: vm.effectiveClosedNotchHeight)
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription) .accessibilityLabel(accessibilityDescription)
.onAppear(perform: handleInitialState) .onAppear(perform: handleInitialState)
@ -45,6 +46,30 @@ struct DoNotDisturbLiveActivity: View {
// MARK: - Layout helpers // 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 { private var wingHeight: CGFloat {
max(vm.effectiveClosedNotchHeight - 10, 20) max(vm.effectiveClosedNotchHeight - 10, 20)
} }
@ -65,13 +90,32 @@ struct DoNotDisturbLiveActivity: View {
return max(desiredLabelWidth, minimalWingWidth) return max(desiredLabelWidth, minimalWingWidth)
} }
private var notchEnvelopeWidth: CGFloat {
centerSegmentWidth + iconWingWidth + labelWingWidth
}
private var minimalWingWidth: CGFloat { private var minimalWingWidth: CGFloat {
max(vm.effectiveClosedNotchHeight - 12, 24) 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 { private var desiredLabelWidth: CGFloat {
let measuredWidth = labelIntrinsicWidth + 8 // horizontal padding inside the label 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) var width = max(measuredWidth, fallbackWidth)
if focusMode == .doNotDisturb && shouldShowLabel { if focusMode == .doNotDisturb && shouldShowLabel {
@ -128,14 +172,45 @@ struct DoNotDisturbLiveActivity: View {
private var currentIcon: Image { private var currentIcon: Image {
if manager.isDoNotDisturbActive { if manager.isDoNotDisturbActive {
return focusMode.activeIcon return focusMode.resolvedActiveIcon(usePrivateSymbol: true)
} else if showInactiveIcon { } else if showInactiveIcon {
return Image(systemName: focusMode.inactiveSymbol) return inactiveIconMatchingActiveStyle
} else { } 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 { private var currentIconColor: Color {
if manager.isDoNotDisturbActive { if manager.isDoNotDisturbActive {
return activeAccentColor return activeAccentColor
@ -169,7 +244,7 @@ struct DoNotDisturbLiveActivity: View {
if shouldShowLabel { if shouldShowLabel {
Text(labelText) Text(labelText)
.font(.system(size: 12, weight: .semibold, design: .rounded)) .font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundColor(activeAccentColor) .foregroundColor(labelColor)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.8)
.contentTransition(.opacity) .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 // MARK: - State transitions
private func handleInitialState() { private func handleInitialState() {
@ -227,24 +309,25 @@ struct DoNotDisturbLiveActivity: View {
showInactiveIcon = true showInactiveIcon = true
} }
if focusToastMode {
iconScale = 1.0
scaleResetTask?.cancel()
} else {
withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) {
iconScale = 1.2 iconScale = 1.2
} }
let toastMode = focusToastMode
scaleResetTask?.cancel() scaleResetTask?.cancel()
scaleResetTask = Task { @MainActor in scaleResetTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(toastMode ? 650 : 450)) try? await Task.sleep(for: .milliseconds(450))
withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) {
iconScale = 1.0 iconScale = 1.0
} }
if toastMode {
return
}
withAnimation(.smooth(duration: 0.2)) { withAnimation(.smooth(duration: 0.2)) {
showInactiveIcon = false showInactiveIcon = false
} }
} }
}
collapseTask?.cancel() collapseTask?.cancel()
collapseTask = Task { @MainActor in collapseTask = Task { @MainActor in
@ -304,3 +387,29 @@ private struct FocusLabelWidthPreferenceKey: PreferenceKey {
value = max(value, nextValue()) 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
}
}

View file

@ -22,6 +22,10 @@ struct LockScreenLiveActivityOverlay: View {
notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2)
} }
private var collapsedScale: CGFloat {
Self.collapsedScale(for: notchSize)
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Color.clear Color.clear
@ -48,7 +52,17 @@ struct LockScreenLiveActivityOverlay: View {
) )
) )
.frame(width: totalWidth, height: notchSize.height) .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) .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
}
}

View file

@ -352,7 +352,12 @@ enum FocusModeType: String, CaseIterable {
} }
var activeIcon: Image { var activeIcon: Image {
if let internalSymbolName, resolvedActiveIcon()
}
func resolvedActiveIcon(usePrivateSymbol: Bool = true) -> Image {
if usePrivateSymbol,
let internalSymbolName,
let image = Image(internalSystemName: internalSymbolName) { let image = Image(internalSystemName: internalSymbolName) {
return image return image
} }

View file

@ -24,6 +24,7 @@ class LockScreenLiveActivityWindowManager {
private weak var viewModel: DynamicIslandViewModel? private weak var viewModel: DynamicIslandViewModel?
private var screenChangeObserver: NSObjectProtocol? private var screenChangeObserver: NSObjectProtocol?
private var workspaceObservers: [NSObjectProtocol] = [] private var workspaceObservers: [NSObjectProtocol] = []
private var currentNotchSize: CGSize?
private init() { private init() {
registerScreenChangeObservers() registerScreenChangeObservers()
@ -136,6 +137,8 @@ class LockScreenLiveActivityWindowManager {
hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize) hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize)
} }
currentNotchSize = context.notchSize
print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)") print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)")
} }
@ -175,14 +178,18 @@ class LockScreenLiveActivityWindowManager {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1 window.alphaValue = 1
currentNotchSize = notchSize
} }
func showLocked() { func showLocked() {
hideTask?.cancel() hideTask?.cancel()
guard let context = lockContext() else { return } guard let context = lockContext() else { return }
let collapsedScale = LockScreenLiveActivityOverlay.collapsedScale(for: context.notchSize)
overlayAnimator.update(isLocked: true) overlayAnimator.update(isLocked: true)
overlayModel.scale = 0.6 overlayModel.scale = collapsedScale
overlayModel.opacity = 0 overlayModel.opacity = 0
present(notchSize: context.notchSize, on: context.screen) present(notchSize: context.notchSize, on: context.screen)
@ -229,9 +236,16 @@ class LockScreenLiveActivityWindowManager {
private func hideWithAnimation() { private func hideWithAnimation() {
guard let window else { return } 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)) { withAnimation(.smooth(duration: LockScreenAnimationTimings.unlockCollapse)) {
overlayModel.opacity = 0 overlayModel.opacity = 0
overlayModel.scale = 0.7 overlayModel.scale = targetScale
} }
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
@ -242,6 +256,7 @@ class LockScreenLiveActivityWindowManager {
DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) { DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) {
window.orderOut(nil) window.orderOut(nil)
self.currentNotchSize = nil
} }
print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden")