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.
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue