mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update DND UI for toast mode
This commit is contained in:
parent
2ce6a3ae96
commit
83cca50a66
4 changed files with 169 additions and 26 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue