diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fed7c00..d6b56aa 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/migueldeicaza/SwiftTerm", "state" : { - "revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", - "version" : "1.11.2" + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" } }, { diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8e88c9a..1a73dbe 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -54,6 +54,7 @@ struct ContentView: View { @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @ObservedObject var localSendService = LocalSendService.shared @State private var downloadManager = DownloadManager.shared + @ObservedObject var shelfState = ShelfStateViewModel.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @@ -84,6 +85,15 @@ struct ContentView: View { @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover @Default(.terminalStickyMode) var terminalStickyMode + // Battery settings reactivity + @Default(.showPowerStatusNotifications) var showPowerStatusNotifications + @Default(.showChargingBatteryHUD) var showChargingBatteryHUD + @Default(.showLowBatteryHUD) var showLowBatteryHUD + @Default(.showFullBatteryHUD) var showFullBatteryHUD + @Default(.showOnAllDisplays) var showOnAllDisplays + @Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle + // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize @@ -100,6 +110,39 @@ struct ContentView: View { return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height) } + // Handle battery HUD expansion sizing + if vm.notchState == .closed && + coordinator.expandingView.show && + coordinator.expandingView.type == .battery && + isBatteryHUDVisibleOnCurrentScreen { + + if let kind = batteryModel.activeTemporaryHUDKind { + let style: BatteryNotificationStyle = { + switch kind { + case .charging: return .compact + case .lowBattery: return Defaults[.lowBatteryHUDStyle] + case .fullBattery: return Defaults[.fullBatteryHUDStyle] + } + }() + + var width = vm.closedNotchSize.width + var height = vm.effectiveClosedNotchHeight + + switch (kind, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + width += 180 + case (.lowBattery, .standard): + width += 100 + height += 75 + case (.fullBattery, .standard): + width += 80 + height += 70 + } + + return CGSize(width: width, height: height) + } + } + if coordinator.currentView == .timer { return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets } @@ -382,9 +425,78 @@ struct ContentView: View { return DynamicIslandPillShape(cornerRadius: radius) } + private var isBatteryHUDVisibleOnCurrentScreen: Bool { + guard coordinator.expandingView.show, coordinator.expandingView.type == .battery else { return false } + guard showPowerStatusNotifications else { return false } + guard batteryModel.activeTemporaryHUDKind != nil else { return false } + if showOnAllDisplays { return true } + guard let targetScreenName = batteryModel.activeTemporaryHUDTargetScreenName else { return true } + return currentScreenName == targetScreenName + } + + private var isCurrentScreenExpansionVisible: Bool { + guard coordinator.expandingView.show else { return false } + if coordinator.expandingView.type == .battery { + return isBatteryHUDVisibleOnCurrentScreen + } + return true + } + + private var currentScreenExpansionType: SneakContentType? { + isCurrentScreenExpansionVisible ? coordinator.expandingView.type : nil + } + + private var displayedBatteryHUDLevel: Int { + let resolvedLevel = batteryModel.activeTemporaryHUDLevelOverride + ?? Int(batteryModel.levelBattery.rounded()) + return min(max(resolvedLevel, 0), 100) + } + + private var displayedBatteryHUDUsesLowPowerMode: Bool { + batteryModel.activeTemporaryHUDLowPowerModeOverride ?? batteryModel.isInLowPowerMode + } + + + private var activeClosedBatterySurfaceShape: AnyShape? { + guard vm.notchState == .closed else { return nil } + guard isBatteryHUDVisibleOnCurrentScreen else { return nil } + guard let kind = batteryModel.activeTemporaryHUDKind else { return nil } + + if isDynamicIslandMode { + let radius = dynamicIslandPillCornerRadiusInsets.opened + return AnyShape(DynamicIslandPillShape(cornerRadius: radius)) + } else { + let topRadius = activeCornerRadiusInsets.closed.top + let bottomRadius: CGFloat = { + switch resolvedBatteryNotificationStyle(for: kind) { + case .compact: + return activeCornerRadiusInsets.closed.bottom + case .standard: + return kind == .fullBattery ? 36 : 40 + } + }() + return AnyShape(NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius)) + } + } + + private func resolvedBatteryNotificationStyle(for kind: BatteryTemporaryHUDKind) -> BatteryNotificationStyle { + switch kind { + case .charging: + return .compact + case .lowBattery: + return lowBatteryHUDStyle + case .fullBattery: + return fullBatteryHUDStyle + } + } + + /// Resolves the clip/content shape per-screen: pill on non-notch screens /// when dynamic island mode is active, standard notch shape otherwise. private var resolvedClipShape: AnyShape { + if let activeClosedBatterySurfaceShape { + return activeClosedBatterySurfaceShape + } if isDynamicIslandMode { return AnyShape(currentPillShape) } @@ -549,7 +661,7 @@ struct ContentView: View { // When sneak peek finishes, check if user is still hovering and open notch if needed if !sneakPeekShowing { runAfter(0.2) { - if isHovering && vm.notchState == .closed { + if isHovering && vm.notchState == .closed && !coordinator.isHoverOpenSuppressed { openNotch() } } @@ -763,47 +875,41 @@ struct ContentView: View { guard let musicSecondary else { return false } switch musicSecondary { case .timer: - return coordinator.expandingView.type == .timer + return currentScreenExpansionType == .timer case .reminder: - return coordinator.expandingView.type == .reminder + return currentScreenExpansionType == .reminder case .recording: - return coordinator.expandingView.type == .recording + return currentScreenExpansionType == .recording case .focus: - return coordinator.expandingView.type == .doNotDisturb + return currentScreenExpansionType == .doNotDisturb case .capsLock: return false case .extensionPayload: return false + case .shelf: + return false } }() - let canShowMusicDuringExpansion = !coordinator.expandingView.show - || coordinator.expandingView.type == .music + let canShowMusicDuringExpansion = !isCurrentScreenExpansionVisible + || currentScreenExpansionType == .music || expansionMatchesSecondary - if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { - HStack(spacing: 0) { - HStack { - Text(batteryModel.statusText) - .font(.subheadline) - } - - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width + 10) - - HStack { - DynamicIslandBatteryView( - batteryWidth: 30, - isCharging: batteryModel.isCharging, - isInLowPowerMode: batteryModel.isInLowPowerMode, - isPluggedIn: batteryModel.isPluggedIn, - levelBattery: batteryModel.levelBattery, - isForNotification: true - ) - } - .frame(width: 76, alignment: .trailing) - } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + if currentScreenExpansionType == .battery + && isBatteryHUDVisibleOnCurrentScreen + && vm.notchState == .closed + && Defaults[.showPowerStatusNotifications] + && batteryModel.activeTemporaryHUDKind != nil { + BatteryTemporaryActivityView( + kind: batteryModel.activeTemporaryHUDKind ?? .charging, + batteryLevel: displayedBatteryHUDLevel, + isLowPowerMode: displayedBatteryHUDUsesLowPowerMode, + closedNotchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0), + baseHeight: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), + isDynamicIslandMode: isDynamicIslandMode, + topCornerRadius: activeCornerRadiusInsets.closed.top, + styleOverride: batteryModel.activeTemporaryHUDKind.map { resolvedBatteryNotificationStyle(for: $0) } + ) + .id(batteryModel.activeTemporaryHUDToken) } else if isSneakPeekVisibleOnCurrentScreen && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( @@ -818,25 +924,25 @@ struct ContentView: View { MusicLiveActivity(secondary: musicSecondary) .id("closed-music-live-activity") .transition(closedLiveActivitySwapTransition) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { ReminderLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { RecordingLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { LocalSendLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { LockScreenLiveActivity() .id("lock-screen-live-activity") .transition(closedLiveActivitySwapTransition) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { PrivacyLiveActivity() } else if let extensionPayload = extensionStandalonePayload { let layout = extensionStandaloneLayout( @@ -849,7 +955,11 @@ struct ContentView: View { layout: layout, isHovering: isHovering ) + } else if !coordinator.expandingView.show && vm.notchState == .closed && !shelfState.isEmpty && !vm.hideOnClosed && !lockScreenManager.isLocked && !enableMinimalisticUI { + ShelfInlineLiveActivity() + .transition(.opacity.animation(.smooth(duration: 0.25))) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { + } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { DynamicIslandHeader() @@ -1192,6 +1302,11 @@ struct ContentView: View { return .extensionPayload(extensionPayload) } + // Shelf: show file count as lowest-priority secondary + if !shelfState.isEmpty && !lockScreenManager.isLocked && !enableMinimalisticUI { + return .shelf(count: shelfState.items.count) + } + return nil } @@ -1212,6 +1327,8 @@ struct ContentView: View { case .extensionPayload(let payload): let maxWidth = baseWidth + centerBaseWidth * 0.6 return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth) + case .shelf: + return baseWidth } } @@ -1314,6 +1431,10 @@ struct ContentView: View { accent: payload.descriptor.accentColor.swiftUIColor, size: badgeSize ) + case .shelf: + Image(systemName: "tray.and.arrow.down.fill") + .font(.system(size: badgeSize * 0.50, weight: .semibold)) + .foregroundStyle(.white) } } .frame(width: badgeSize, height: badgeSize) @@ -1372,6 +1493,14 @@ struct ContentView: View { spectrumView(forceSpectrum: true, trailingInset: 6) case .extensionPayload(let payload): ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth) + case .shelf(let count): + // File count badge: bold white number, like a minimal pill + Text("\(count)") + .font(.system(.callout, design: .rounded, weight: .bold)) + .foregroundStyle(.white) + .contentTransition(.numericText(countsDown: false)) + .animation(.smooth(duration: 0.3), value: count) + .frame(alignment: .center) case .none: spectrumView( forceSpectrum: false, @@ -1606,10 +1735,10 @@ struct ContentView: View { return nil } - guard !coordinator.expandingView.show else { + guard !isCurrentScreenExpansionVisible else { ExtensionRoutingDiagnostics.shared.logSuppression( .standalone, - reason: "expanding view \(coordinator.expandingView.type) visible", + reason: "expanding view \(String(describing: currentScreenExpansionType ?? coordinator.expandingView.type)) visible", pendingCount: candidates.count ) return nil @@ -1729,7 +1858,7 @@ struct ContentView: View { && !vm.hideOnClosed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock - && !coordinator.expandingView.show + && !isCurrentScreenExpansionVisible && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && !coordinator.firstLaunch } @@ -1799,6 +1928,7 @@ struct ContentView: View { guard let vm, let lockScreenManager else { return } guard !lockScreenManager.isLocked else { return } guard vm.notchState == .closed else { return } + guard !self.coordinator.isHoverOpenSuppressed else { return } guard self.isHovering else { return } guard !self.handleClosedMusicWaveformTapIfNeeded() else { return } if Defaults[.enableHaptics] { @@ -1850,6 +1980,10 @@ struct ContentView: View { stickyTerminalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak vm] _ in Task { @MainActor in guard let vm, vm.notchState == .open else { return } + let clickLocation = NSEvent.mouseLocation + if self.isPointInsideNotchWindow(clickLocation) { + return + } vm.close() } } @@ -1902,7 +2036,8 @@ struct ContentView: View { await MainActor.run { guard self.vm.notchState == .closed, self.isHovering, - !self.isSneakPeekVisibleOnCurrentScreen else { return } + !self.isSneakPeekVisibleOnCurrentScreen, + !self.coordinator.isHoverOpenSuppressed else { return } if shouldFocusTimerTab { withAnimation(.smooth) { @@ -1927,14 +2062,27 @@ struct ContentView: View { } else if self.vm.notchState == .open && Defaults[.terminalStickyMode] && self.coordinator.currentView == .terminal { - // Terminal sticky mode kept the notch open — install a - // global click monitor so a click outside closes it. - self.installStickyTerminalClickMonitor() + // Re-sync monitor state through one code path to avoid + // monitor lifecycle races between hover and state updates. + self.syncStickyTerminalOutsideClickMonitor() } } } } } + + private func isPointInsideNotchWindow(_ point: CGPoint) -> Bool { + if let appDelegate = AppDelegate.shared { + if Defaults[.showOnAllDisplays] { + return appDelegate.windows.values.contains(where: { $0.frame.contains(point) }) + } + if let window = appDelegate.window { + return window.frame.contains(point) + } + } + + return NSApp.windows.contains(where: { $0.frame.contains(point) }) + } // Helper function to check if any popovers are active private func hasAnyActivePopovers() -> Bool { @@ -2455,6 +2603,7 @@ private enum MusicSecondaryLiveActivity: Equatable { case focus(FocusModeType) case capsLock(showLabel: Bool) case extensionPayload(ExtensionLiveActivityPayload) + case shelf(count: Int) var id: String { switch self { @@ -2470,6 +2619,8 @@ private enum MusicSecondaryLiveActivity: Equatable { return showLabel ? "caps-lock-label" : "caps-lock-icon" case .extensionPayload(let payload): return "extension-\(payload.id)" + case .shelf(let count): + return "shelf-\(count)" } } } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 902d5e6..25a5e95 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -395,6 +395,43 @@ class AppDelegate: NSObject, NSApplicationDelegate { let inlineSneakPeekWidth: CGFloat = 460 return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } + + // Check for battery HUD expansion + if vm.notchState == .closed && + coordinator.expandingView.show && + coordinator.expandingView.type == .battery && + Defaults[.showPowerStatusNotifications] { + + let batteryModel = BatteryStatusViewModel.shared + if let kind = batteryModel.activeTemporaryHUDKind { + let closedNotchHeight = vm.effectiveClosedNotchHeight + let closedNotchWidth = vm.closedNotchSize.width + + let style: BatteryNotificationStyle = { + switch kind { + case .charging: return .compact + case .lowBattery: return Defaults[.lowBatteryHUDStyle] + case .fullBattery: return Defaults[.fullBatteryHUDStyle] + } + }() + + var width = closedNotchWidth + var height = closedNotchHeight + + switch (kind, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + width += 180 + case (.lowBattery, .standard): + width += 100 + height += 75 + case (.fullBattery, .standard): + width += 80 + height += 70 + } + + return addShadowPadding(to: CGSize(width: width, height: height), isMinimalistic: Defaults[.enableMinimalisticUI]) + } + } // Use minimalistic or normal size based on settings var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize @@ -1046,13 +1083,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { closeNotchWorkItem = nil vm.open() coordinator.currentView = .terminal + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + TerminalManager.shared.focusTerminalIfPossible() + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() } else { if coordinator.currentView == .terminal { + coordinator.suppressHoverOpen() + TerminalManager.shared.resignTerminalFirstResponderIfNeeded() vm.close() } else { closeNotchWorkItem?.cancel() closeNotchWorkItem = nil coordinator.currentView = .terminal + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + TerminalManager.shared.focusTerminalIfPossible() + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() } } } diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index c6ed432..c6ebb3a 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -97,11 +97,13 @@ struct ExpandedItem { var type: SneakContentType = .battery var value: CGFloat = 0 var browser: BrowserType = .chromium + var autoHideDuration: TimeInterval? = nil } class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() private var cancellables = Set() + private var hoverOpenSuppressedUntil: Date = .distantPast private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience] @@ -246,6 +248,14 @@ class DynamicIslandViewCoordinator: ObservableObject { enforceMinimumNotchWidth() } + var isHoverOpenSuppressed: Bool { + Date() < hoverOpenSuppressedUntil + } + + func suppressHoverOpen(for duration: TimeInterval = 0.35) { + hoverOpenSuppressedUntil = Date().addingTimeInterval(max(0, duration)) + } + private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { guard oldValue != newValue else { return } if newValue == .stats && Defaults[.enableStatsFeature] { @@ -404,7 +414,8 @@ class DynamicIslandViewCoordinator: ObservableObject { status: Bool, type: SneakContentType, value: CGFloat = 0, - browser: BrowserType = .chromium + browser: BrowserType = .chromium, + autoHideDuration: TimeInterval? = nil ) { Task { @MainActor in withAnimation(.smooth) { @@ -412,6 +423,7 @@ class DynamicIslandViewCoordinator: ObservableObject { self.expandingView.type = type self.expandingView.value = value self.expandingView.browser = browser + self.expandingView.autoHideDuration = autoHideDuration } } } @@ -424,7 +436,7 @@ class DynamicIslandViewCoordinator: ObservableObject { expandingViewTask?.cancel() // Only auto-hide for battery, not for downloads (DownloadManager handles that) if expandingView.type != .download { - let duration: TimeInterval = 3 + let duration = expandingView.autoHideDuration ?? 3 expandingViewTask = Task { [weak self] in try? await Task.sleep(for: .seconds(duration)) guard let self = self, !Task.isCancelled else { return } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index f46d0b3..4548a50 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1739,6 +1739,9 @@ } } } + }, + "%llds" : { + }, "%lldx" : { "localizations" : { @@ -7176,6 +7179,9 @@ } } } + }, + "Battery HUDs" : { + }, "Battery Indicator Styling" : { "comment" : "A section header in the Settings app that lists options for customizing the appearance of the battery indicator.", @@ -9050,6 +9056,12 @@ } } } + }, + "Charging duration" : { + + }, + "Charging HUD" : { + }, "Charging on hold" : { "comment" : "Plugged in but charging is paused by the system" @@ -11446,6 +11458,16 @@ } } } + }, + "Compact" : { + "comment" : "Title for the compact battery notification style.", + "isCommentAutoGenerated" : true + }, + "Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation." : { + + }, + "Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card." : { + }, "Compressed" : { "localizations" : { @@ -19755,6 +19777,15 @@ } } } + }, + "Full Battery" : { + + }, + "Full battery duration" : { + + }, + "Full battery style" : { + }, "Full charge" : { "comment" : "Text displayed in the status text when the battery is fully charged.", @@ -19779,6 +19810,9 @@ } } } + }, + "Full charge threshold" : { + }, "Full Disk Access required" : { "extractionState" : "stale", @@ -19856,6 +19890,9 @@ } } } + }, + "Fully charged HUD" : { + }, "Gemini API Key" : { "localizations" : { @@ -21728,6 +21765,9 @@ } }, "shouldTranslate" : false + }, + "HUD Duration" : { + }, "HUD icon style" : { "comment" : "A label describing the style of the 3D or symbol-based Bluetooth HUD icon.", @@ -21880,6 +21920,9 @@ } } } + }, + "HUD Tests" : { + }, "HUDs" : { "extractionState" : "stale", @@ -24428,6 +24471,21 @@ } } } + }, + "Low Battery" : { + + }, + "Low battery duration" : { + + }, + "Low battery HUD" : { + + }, + "Low battery style" : { + + }, + "Low battery threshold" : { + }, "Low Power Mode" : { "localizations" : { @@ -34443,6 +34501,9 @@ } } } + }, + "Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first." : { + }, "Sampling can continue while the notch is closed when the timeout is disabled." : { "localizations" : { @@ -41101,6 +41162,18 @@ } } } + }, + "Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window." : { + + }, + "Test charging HUD" : { + + }, + "Test full battery HUD" : { + + }, + "Test low battery HUD" : { + }, "Text" : { "comment" : "Name of a clipboard item type that contains plain text.", @@ -41220,6 +41293,9 @@ } } } + }, + "These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts." : { + }, "Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below." : { "comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view. | A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view. | merge:ko:alt='AtollExtensionKit을 사용하는 타사 앱은 실시간 현황, 잠금 화면 위젯 및 노치 전용 기능을 표시할 수 있습니다. 위에서 기능을 켜고 끄거나 아래에서 개별 앱 권한을 관리할 수 있습니다.'", @@ -45805,4 +45881,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift index 10eef4c..af509b6 100644 --- a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift +++ b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift @@ -21,7 +21,7 @@ import Defaults /// A view that displays the battery status with an icon and charging indicator. struct BatteryView: View { - + @Default(.showPowerStatusIcons) var showPowerStatusIcons var levelBattery: Float var isPluggedIn: Bool var isCharging: Bool @@ -79,7 +79,7 @@ struct BatteryView: View { ) .padding(.leading, 2) - if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { + if iconStatus != "" && (isForNotification || showPowerStatusIcons) { ZStack { Image(iconStatus) .resizable() @@ -182,6 +182,7 @@ struct BatteryMenuView: View { /// A view that displays the battery status and allows interaction to show detailed information. struct DynamicIslandBatteryView: View { + @Default(.showBatteryPercentage) var showBatteryPercentage @State var batteryWidth: CGFloat = 26 var isCharging: Bool = false var isInLowPowerMode: Bool = false @@ -199,7 +200,7 @@ struct DynamicIslandBatteryView: View { var body: some View { HStack { - if Defaults[.showBatteryPercentage] { + if showBatteryPercentage { ZStack(alignment: .trailing) { Text("100%") .font(.callout) @@ -268,6 +269,443 @@ struct DynamicIslandBatteryView: View { } } + +private struct BatteryTemporaryHUDMetrics { + let width: CGFloat + let height: CGFloat + let topRadius: CGFloat + let bottomRadius: CGFloat +} + +private extension BatteryTemporaryHUDKind { + func metrics( + style: BatteryNotificationStyle, + closedNotchWidth: CGFloat, + baseHeight: CGFloat + ) -> BatteryTemporaryHUDMetrics { + let compactBaseRadius = max(baseHeight / 2, 16) + let compactTopRadius = max(12, compactBaseRadius - 4) + + switch (self, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + return BatteryTemporaryHUDMetrics( + width: closedNotchWidth + 180, + height: baseHeight, + topRadius: compactTopRadius, + bottomRadius: compactBaseRadius + ) + case (.lowBattery, .standard): + return BatteryTemporaryHUDMetrics( + width: closedNotchWidth + 150, + height: baseHeight + 75, + topRadius: 22, + bottomRadius: 40 + ) + case (.fullBattery, .standard): + return BatteryTemporaryHUDMetrics( + width: closedNotchWidth + 140, + height: baseHeight + 70, + topRadius: 18, + bottomRadius: 36 + ) + } + } +} + +private struct BatteryCompactStatusRow: View { + let title: String + let batteryLevel: Int + let tint: Color + + var body: some View { + HStack { + Text(verbatim: title) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.8)) + + Spacer() + + HStack(spacing: 6) { + Text("\(batteryLevel)%") + .font(.system(size: 14)) + .foregroundColor(tint) + + HStack(spacing: 1.5) { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.3)) + + GeometryReader { geo in + let clamped = max(0, min(batteryLevel, 100)) + let width = CGFloat(clamped) / 100 * geo.size.width + Rectangle() + .fill(tint.gradient) + .frame(width: max(0, width)) + } + } + .frame(width: 28, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(batteryLevel == 100 ? tint.gradient : tint.opacity(0.3).gradient) + .frame(width: 2, height: 6) + } + } + } + .padding(.horizontal, 16) + } +} + +struct BatteryTemporaryActivityView: View { + let kind: BatteryTemporaryHUDKind + let batteryLevel: Int + let isLowPowerMode: Bool + let closedNotchWidth: CGFloat + let baseHeight: CGFloat + let isDynamicIslandMode: Bool + let topCornerRadius: CGFloat + @Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle + var styleOverride: BatteryNotificationStyle? = nil + + @State private var pulse = false + @State private var showBatteryIndicator = false + @State private var changeBatteryIndicator = true + + private var style: BatteryNotificationStyle { + if kind == .charging { + return .compact + } + if let styleOverride { + return styleOverride + } + switch kind { + case .charging: + return .compact + case .lowBattery: + return lowBatteryHUDStyle + case .fullBattery: + return fullBatteryHUDStyle + } + } + + private var metrics: BatteryTemporaryHUDMetrics { + kind.metrics( + style: style, + closedNotchWidth: closedNotchWidth, + baseHeight: baseHeight + ) + } + + private var batteryTint: Color { + switch kind { + case .charging: + if isLowPowerMode { + return .yellow + } else if batteryLevel <= 20 { + return .red + } + return .green + case .lowBattery: + return isLowPowerMode ? .yellow : .red + case .fullBattery: + return isLowPowerMode ? .yellow : .green + } + } + + + private var surfaceShape: AnyShape { + if isDynamicIslandMode { + return AnyShape(DynamicIslandPillShape(cornerRadius: dynamicIslandPillCornerRadiusInsets.opened)) + } else { + return AnyShape(NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: metrics.bottomRadius)) + } + } + + var body: some View { + ZStack(alignment: .bottom) { + content + } + .frame(width: metrics.width, height: metrics.height, alignment: .bottom) + .clipShape(surfaceShape) + .onAppear(perform: prepareAnimations) + } + + @ViewBuilder + private var content: some View { + if style == .compact { + BatteryCompactStatusRow( + title: compactTitle, + batteryLevel: batteryLevel, + tint: batteryTint + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + VStack { + Spacer() + + HStack { + VStack(alignment: .leading, spacing: kind == .lowBattery ? 2 : 3) { + standardTitle + standardDescription + } + + Spacer() + + standardIndicator + } + .padding(.leading, kind == .lowBattery ? 40 : 35) + .padding(.trailing, kind == .lowBattery ? 45 : 40) + .padding(.bottom, 20) + } + } + } + + private var compactTitle: String { + switch kind { + case .charging: + return "Charging" + case .lowBattery: + return "Low Battery" + case .fullBattery: + return "Full Battery" + } + } + + @ViewBuilder + private var standardTitle: some View { + HStack(spacing: 5) { + Text(verbatim: kind == .lowBattery ? "Battery Low" : "Full Battery") + .font(.system(size: kind == .lowBattery ? 13 : 13, weight: .semibold)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(1) + + Text("\(batteryLevel)%") + .font(.system(size: kind == .lowBattery ? 12 : 13, weight: .semibold)) + .foregroundStyle(batteryTint) + } + } + + @ViewBuilder + private var standardDescription: some View { + switch kind { + case .charging: + EmptyView() + case .lowBattery: + if isLowPowerMode { + ( + Text(verbatim: "Low Power Mode enabled") + .foregroundColor(.yellow) + .font(.system(size: 10, weight: .medium)) + + + Text(verbatim: ", it is recommended to charge it.") + .foregroundColor(.gray.opacity(0.6)) + .font(.system(size: 10, weight: .medium)) + ) + } else { + Text(verbatim: "Turn on Low Power Mode or it\nis recommended to charge it.") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.gray.opacity(0.6)) + .lineLimit(2) + } + case .fullBattery: + Text(verbatim: "Your Mac is fully charged.") + .font(.system(size: 10)) + .foregroundStyle(.gray.opacity(0.6)) + .fontWeight(.medium) + .lineLimit(1) + } + } + + @ViewBuilder + private var standardIndicator: some View { + switch kind { + case .charging: + EmptyView() + case .lowBattery: + if isLowPowerMode { + yellowLowIndicator + } else { + redLowIndicator + } + case .fullBattery: + if showBatteryIndicator { + if isLowPowerMode { + yellowFullIndicator + .transition(.opacity.combined(with: .scale)) + } else { + greenFullIndicator + .transition(.opacity.combined(with: .scale)) + } + } else { + magSafeIndicator + .transition(.opacity.combined(with: .scale)) + } + } + } + + private var redLowIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.red.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.red.opacity(0.4)) + .frame(width: 40, height: 24) + + RoundedRectangle(cornerRadius: 10) + .fill(.red.opacity(0.4)) + .frame(width: 3, height: 8) + } + .padding(.trailing, 5) + + RoundedRectangle(cornerRadius: 8) + .fill(Color.red.gradient) + .frame(width: 8, height: 14) + .opacity(pulse ? 1 : 0.3) + .offset(x: -15) + + RoundedRectangle(cornerRadius: 30) + .stroke(Color.red.opacity(0.9), lineWidth: 1.5) + .frame(width: pulse ? 8 : 30, height: pulse ? 14 : 32) + .offset(x: -15) + .opacity(pulse ? 0.3 : 1) + } + } + + private var yellowLowIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.yellow.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 40, height: 24) + + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 3, height: 8) + } + .padding(.trailing, 5) + + RoundedRectangle(cornerRadius: 8) + .fill(.yellow.gradient) + .frame(width: 8, height: 14) + .offset(x: -15) + } + } + + private var greenFullIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.green.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.green.opacity(0.4)) + .frame(width: 44, height: 24) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.green.gradient) + .frame(width: 34, height: 14) + .opacity(pulse ? 1 : 0.4) + ) + + RoundedRectangle(cornerRadius: 10) + .fill(.green.opacity(0.4)) + .frame(width: 3, height: 8) + } + } + } + + private var yellowFullIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.yellow.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 44, height: 24) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.yellow.gradient) + .frame(width: 34, height: 14) + .opacity(pulse ? 1 : 0.4) + ) + + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 3, height: 8) + } + } + } + + private var magSafeIndicator: some View { + HStack(spacing: 0) { + Rectangle() + .fill(.gray.opacity(0.15)) + .frame(width: 30, height: 5) + + ZStack { + RoundedRectangle(cornerRadius: 2) + .fill(.gray.opacity(0.2).gradient) + .frame(width: 30, height: 40) + + Circle() + .fill(changeBatteryIndicator ? .orange : .green) + .shadow(color: changeBatteryIndicator ? .orange : .green, radius: 5) + .frame(width: 5, height: 5) + } + + Rectangle() + .fill(.white.opacity(0.4)) + .frame(width: 3, height: 32) + } + } + + private func prepareAnimations() { + pulse = false + showBatteryIndicator = kind == .fullBattery && style == .standard + changeBatteryIndicator = true + + guard style == .standard else { return } + + switch kind { + case .charging: + break + case .lowBattery: + if !isLowPowerMode { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + pulse = true + } + } + case .fullBattery: + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + pulse = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation(.spring(duration: 0.4)) { + showBatteryIndicator = false + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation(.spring(duration: 0.2)) { + changeBatteryIndicator = false + } + } + } + } +} + #Preview { DynamicIslandBatteryView( batteryWidth: 30, diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index c9eda7b..6e0618f 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -36,10 +36,12 @@ struct DynamicIslandHeader: View { @Default(.showClipboardIcon) var showClipboardIcon @Default(.showColorPickerIcon) var showColorPickerIcon @Default(.clipboardDisplayMode) var clipboardDisplayMode + @Default(.showBatteryIndicator) var showBatteryIndicator + @Default(.enableMinimalisticUI) var enableMinimalisticUI var body: some View { HStack(spacing: 0) { - if !Defaults[.enableMinimalisticUI] { + if !enableMinimalisticUI { HStack { let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty if shouldShowTabs { @@ -55,7 +57,7 @@ struct DynamicIslandHeader: View { } - if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { + if vm.notchState == .open && !enableMinimalisticUI { let spacerWidth = min(vm.closedNotchSize.width, 300) Rectangle() .fill(NSScreen.screens @@ -67,7 +69,7 @@ struct DynamicIslandHeader: View { } HStack(spacing: 4) { - if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { + if vm.notchState == .open && !enableMinimalisticUI { if Defaults[.showMirror] { Button(action: { vm.toggleCameraPreview() @@ -230,7 +232,7 @@ struct DynamicIslandHeader: View { - if Defaults[.showBatteryIndicator] { + if showBatteryIndicator { DynamicIslandBatteryView( batteryWidth: 30, isCharging: batteryModel.isCharging, diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift index a3f4336..2db4e1f 100644 --- a/DynamicIsland/components/Notch/NotchTerminalView.swift +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -86,9 +86,39 @@ struct NotchTerminalView: View { @ObservedObject var terminalManager = TerminalManager.shared @EnvironmentObject var vm: DynamicIslandViewModel @Default(.enableTerminalFeature) var enableTerminalFeature + @Default(.cornerRadiusScaling) var cornerRadiusScaling + @Default(.enableMinimalisticUI) var enableMinimalisticUI @State private var suppressionToken = UUID() @State private var isSuppressing = false + /// Top corners stay mild; bottom follows shell − padding (see `notchTerminalBottomCornerRadii`). + private static let terminalClipTopCornerRadius: CGFloat = 6 + + private var currentScreenName: String { + vm.screen ?? DynamicIslandViewCoordinator.shared.selectedScreen + } + + private var isDynamicIslandMode: Bool { + shouldUseDynamicIslandMode(for: currentScreenName) + } + + private var terminalClipShape: UnevenRoundedRectangle { + let innerBottom = notchTerminalBottomCornerRadii( + isDynamicIslandMode: isDynamicIslandMode, + notchState: vm.notchState, + cornerRadiusScaling: cornerRadiusScaling, + enableMinimalisticUI: enableMinimalisticUI, + closedNotchHeight: vm.closedNotchSize.height + ).innerBottom + return UnevenRoundedRectangle( + topLeadingRadius: Self.terminalClipTopCornerRadius, + bottomLeadingRadius: innerBottom, + bottomTrailingRadius: innerBottom, + topTrailingRadius: Self.terminalClipTopCornerRadius, + style: .continuous + ) + } + var body: some View { VStack(spacing: 0) { if enableTerminalFeature { @@ -121,15 +151,20 @@ struct NotchTerminalView: View { .padding(.bottom, 4) Divider() - .padding(.horizontal, 8) + .padding(.horizontal, notchTerminalContentEdgePadding.horizontal) // Terminal content — the containerView is stable across // notch close/open cycles; updateNSView handles restart. TerminalNSViewRepresentable(terminalManager: terminalManager) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - .padding(.horizontal, 8) - .padding(.bottom, 8) - .padding(.top, 4) + .clipShape(terminalClipShape) + .padding( + EdgeInsets( + top: notchTerminalContentEdgePadding.top, + leading: notchTerminalContentEdgePadding.horizontal, + bottom: notchTerminalContentEdgePadding.bottom, + trailing: notchTerminalContentEdgePadding.horizontal + ) + ) .onHover { hovering in updateSuppression(for: hovering) } @@ -154,6 +189,10 @@ struct NotchTerminalView: View { .onDisappear { updateSuppression(for: false) } + .onAppear { + terminalManager.refreshTerminalAppearanceIfNeeded() + terminalManager.focusTerminalIfPossible() + } } private func updateSuppression(for hovering: Bool) { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 605e671..d630ae9 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -731,6 +731,19 @@ struct SettingsView: View { SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"], highlightID: SettingsTab.battery.highlightID(for: "Show power status notifications")), SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"], highlightID: SettingsTab.battery.highlightID(for: "Show power status icons")), SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), + SettingsSearchEntry(tab: .battery, title: "Charging HUD", keywords: ["battery", "charging", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Charging HUD")), + SettingsSearchEntry(tab: .battery, title: "Low battery HUD", keywords: ["battery", "low", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Low battery HUD")), + SettingsSearchEntry(tab: .battery, title: "Fully charged HUD", keywords: ["battery", "full", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Fully charged HUD")), + SettingsSearchEntry(tab: .battery, title: "Charging duration", keywords: ["charging", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Charging duration")), + SettingsSearchEntry(tab: .battery, title: "Low battery duration", keywords: ["low battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Low battery duration")), + SettingsSearchEntry(tab: .battery, title: "Full battery duration", keywords: ["full battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Full battery duration")), + SettingsSearchEntry(tab: .battery, title: "Test charging HUD", keywords: ["battery", "test", "charging", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Test low battery HUD", keywords: ["battery", "test", "low", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Test full battery HUD", keywords: ["battery", "test", "full", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Low battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Low battery style")), + SettingsSearchEntry(tab: .battery, title: "Low battery threshold", keywords: ["battery", "threshold", "percent"], highlightID: SettingsTab.battery.highlightID(for: "Low battery threshold")), + SettingsSearchEntry(tab: .battery, title: "Full battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Full battery style")), + SettingsSearchEntry(tab: .battery, title: "Full charge threshold", keywords: ["battery", "threshold", "full"], highlightID: SettingsTab.battery.highlightID(for: "Full charge threshold")), // HUDs SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")), @@ -898,7 +911,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .terminal, title: "Enable terminal", keywords: ["terminal", "guake", "shell"], highlightID: SettingsTab.terminal.highlightID(for: "Enable terminal")), SettingsSearchEntry(tab: .terminal, title: "Shell path", keywords: ["shell", "zsh", "bash", "terminal"], highlightID: SettingsTab.terminal.highlightID(for: "Shell path")), SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")), - SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), + SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency", "blur", "background"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")), SettingsSearchEntry(tab: .terminal, title: "Background color", keywords: ["terminal", "background", "color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Background color")), SettingsSearchEntry(tab: .terminal, title: "Foreground color", keywords: ["terminal", "foreground", "text color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Foreground color")), @@ -1306,10 +1319,62 @@ struct GeneralSettings: View { } struct Charge: View { + @ObservedObject private var batteryStatusViewModel = BatteryStatusViewModel.shared + @Default(.showPowerStatusNotifications) private var showPowerStatusNotifications + @Default(.showChargingBatteryHUD) private var showChargingBatteryHUD + @Default(.showLowBatteryHUD) private var showLowBatteryHUD + @Default(.showFullBatteryHUD) private var showFullBatteryHUD + @Default(.chargingBatteryHUDDuration) private var chargingBatteryHUDDuration + @Default(.lowBatteryHUDDuration) private var lowBatteryHUDDuration + @Default(.fullBatteryHUDDuration) private var fullBatteryHUDDuration + @Default(.lowBatteryHUDThreshold) private var lowBatteryHUDThreshold + @Default(.fullBatteryHUDThreshold) private var fullBatteryHUDThreshold + @Default(.lowBatteryHUDStyle) private var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) private var fullBatteryHUDStyle + private func highlightID(_ title: String) -> String { SettingsTab.battery.highlightID(for: title) } + private var chargingDurationBinding: Binding { + Binding( + get: { Double(chargingBatteryHUDDuration) }, + set: { chargingBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryDurationBinding: Binding { + Binding( + get: { Double(lowBatteryHUDDuration) }, + set: { lowBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var fullBatteryDurationBinding: Binding { + Binding( + get: { Double(fullBatteryHUDDuration) }, + set: { fullBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryThresholdBinding: Binding { + Binding( + get: { Double(lowBatteryHUDThreshold) }, + set: { lowBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private var fullBatteryThresholdBinding: Binding { + Binding( + get: { Double(fullBatteryHUDThreshold) }, + set: { fullBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private func sectionOpacity(_ isEnabled: Bool) -> Double { + isEnabled ? 1 : 0.5 + } + var body: some View { Form { if BatteryActivityManager.shared.hasBattery() { @@ -1341,6 +1406,157 @@ struct Charge: View { } header: { Text("Battery Information") } + Section { + Defaults.Toggle(key: .showChargingBatteryHUD) { + Text("Charging HUD") + } + .settingsHighlight(id: highlightID("Charging HUD")) + + Defaults.Toggle(key: .showLowBatteryHUD) { + Text("Low battery HUD") + } + .settingsHighlight(id: highlightID("Low battery HUD")) + + Defaults.Toggle(key: .showFullBatteryHUD) { + Text("Fully charged HUD") + } + .settingsHighlight(id: highlightID("Fully charged HUD")) + } header: { + Text("Battery HUDs") + } footer: { + Text("These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts.") + } + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Charging duration") + Spacer() + Text("\(chargingBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: chargingDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Charging duration")) + .disabled(!showPowerStatusNotifications || !showChargingBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showChargingBatteryHUD)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Low battery duration") + Spacer() + Text("\(lowBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: lowBatteryDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Low battery duration")) + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Full battery duration") + Spacer() + Text("\(fullBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: fullBatteryDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Full battery duration")) + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD)) + } header: { + Text("HUD Duration") + } + Section { + Button { + batteryStatusViewModel.triggerTestHUD(kind: .charging) + } label: { + Label("Test charging HUD", systemImage: "bolt.fill") + } + .disabled(!showPowerStatusNotifications || !showChargingBatteryHUD) + + Button { + batteryStatusViewModel.triggerTestHUD(kind: .lowBattery) + } label: { + Label("Test low battery HUD", systemImage: "battery.25") + } + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + + Button { + batteryStatusViewModel.triggerTestHUD(kind: .fullBattery) + } label: { + Label("Test full battery HUD", systemImage: "battery.100") + } + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + } header: { + Text("HUD Tests") + } footer: { + Text("Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first.") + } + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("Low battery style", selection: $lowBatteryHUDStyle) { + ForEach(BatteryNotificationStyle.allCases) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + Text("Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card.") + .font(.caption) + .foregroundStyle(.secondary) + } + .settingsHighlight(id: highlightID("Low battery style")) + + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Low battery threshold") + Spacer() + Text("\(lowBatteryHUDThreshold)%") + .foregroundStyle(.secondary) + } + Slider(value: lowBatteryThresholdBinding, in: 5...30, step: 1) + } + .settingsHighlight(id: highlightID("Low battery threshold")) + } header: { + Text("Low Battery") + } + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD)) + + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("Full battery style", selection: $fullBatteryHUDStyle) { + ForEach(BatteryNotificationStyle.allCases) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + Text("Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation.") + .font(.caption) + .foregroundStyle(.secondary) + } + .settingsHighlight(id: highlightID("Full battery style")) + + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Full charge threshold") + Spacer() + Text("\(fullBatteryHUDThreshold)%") + .foregroundStyle(.secondary) + } + Slider(value: fullBatteryThresholdBinding, in: 80...100, step: 1) + } + .settingsHighlight(id: highlightID("Full charge threshold")) + } header: { + Text("Full Battery") + } + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD)) } else { ContentUnavailableView { VStack(spacing: 16) { @@ -7930,6 +8146,10 @@ struct TerminalSettings: View { .settingsHighlight(id: highlightID("Maximum height")) } header: { Text("Appearance") + } footer: { + Text("Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window.") + .font(.caption) + .foregroundStyle(.secondary) } // MARK: Colors diff --git a/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift b/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift new file mode 100644 index 0000000..b190182 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift @@ -0,0 +1,56 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +/// Standalone inline HUD shown in the closed Dynamic Island when the +/// File Tray (Shelf) has items and no higher-priority live activity is active. +/// +/// Layout mirrors the battery inline HUD: +/// [tray icon] ─── [ notch ] ─── [bold green count] +struct ShelfInlineLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject private var shelfState = ShelfStateViewModel.shared + + private let sideWidth: CGFloat = 24 + + var body: some View { + HStack(spacing: 0) { + + // LEFT of notch — tray icon (white, same style as the screenshot) + Image(systemName: "tray.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .frame(width: sideWidth, alignment: .leading) + + // Physical notch / pill space + Rectangle() + .fill(Color.black) + .frame(width: vm.closedNotchSize.width) + + // RIGHT of notch — file count in white + Text("\(shelfState.items.count)") + .font(.system(.callout, design: .rounded, weight: .bold)) + .foregroundStyle(.white) + .contentTransition(.numericText(countsDown: false)) + .animation(.smooth(duration: 0.3), value: shelfState.items.count) + .frame(width: sideWidth, alignment: .trailing) + } + .frame(height: vm.effectiveClosedNotchHeight, alignment: .center) + } +} diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift index 4ec7224..3275f0e 100644 --- a/DynamicIsland/managers/TerminalManager.swift +++ b/DynamicIsland/managers/TerminalManager.swift @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import AppKit import Foundation import SwiftUI import SwiftTerm @@ -59,6 +60,9 @@ final class StableTerminalContainerView: NSView { for child in subviews { child.needsDisplay = true } + Task { @MainActor in + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + } } } @@ -89,12 +93,24 @@ class TerminalManager: ObservableObject { let v = StableTerminalContainerView(frame: .zero) v.autoresizingMask = [.width, .height] v.wantsLayer = true + v.layer?.backgroundColor = NSColor.clear.cgColor return v }() /// The actual terminal view (child of `containerView`). private(set) var terminalView: LocalProcessTerminalView? + /// Frosted blur of desktop/content behind the terminal; always below `terminalView`. + private lazy var terminalBackgroundEffectView: NSVisualEffectView = { + let v = NSVisualEffectView() + // Minimal frosted look with very light blur/vibrancy. + v.material = .underWindowBackground + v.blendingMode = .behindWindow + v.state = .active + v.autoresizingMask = [.width, .height] + return v + }() + private init() {} // MARK: - Lifecycle @@ -105,9 +121,8 @@ class TerminalManager: ObservableObject { if let existing = terminalView, existing.superview === containerView { // Already mounted — just re-wire the delegate in case the coordinator changed. existing.processDelegate = delegate - // Force a redraw — the view may have been off-screen (notch closed) - // and needs to re-render the terminal buffer. - existing.needsDisplay = true + // Re-apply opacity/translucency after hide/show cycles (e.g. shortcut toggle). + refreshTerminalOpacityAndTranslucency(for: existing) return } @@ -119,6 +134,9 @@ class TerminalManager: ObservableObject { ? containerView.bounds : CGRect(x: 0, y: 0, width: 400, height: 300) + // Replace only the terminal view - keep the blur underlay between restarts. + terminalView?.removeFromSuperview() + let view = LocalProcessTerminalView(frame: initialFrame) view.autoresizingMask = [.width, .height] @@ -127,16 +145,27 @@ class TerminalManager: ObservableObject { view.processDelegate = delegate - // Mount inside the stable container - containerView.subviews.forEach { $0.removeFromSuperview() } + if terminalBackgroundEffectView.superview == nil { + containerView.addSubview(terminalBackgroundEffectView) + } containerView.addSubview(view) terminalView = view // If the container already has a valid size, snap the child to it. let containerSize = containerView.bounds.size if containerSize.width >= 10, containerSize.height >= 10 { + terminalBackgroundEffectView.frame = containerView.bounds view.frame = containerView.bounds } + + // Apply translucency immediately and again on the next run loop tick. + // The deferred pass handles AppKit/SwiftUI mount timing where layer + // changes can otherwise be lost on first open. + refreshTerminalOpacityAndTranslucency(for: view) + DispatchQueue.main.async { [weak self, weak view] in + guard let self, let view, self.terminalView === view else { return } + self.refreshTerminalOpacityAndTranslucency(for: view) + } } /// Starts the shell process if not already running. @@ -196,6 +225,46 @@ class TerminalManager: ObservableObject { return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } + /// Background color with opacity slider applied to alpha only (glyphs stay fully opaque). + private func resolvedTerminalBackgroundNSColor( + baseColor: NSColor? = nil, + opacitySlider: CGFloat? = nil + ) -> NSColor { + let raw = baseColor ?? NSColor(Defaults[.terminalBackgroundColor]) + let rgb = raw.usingColorSpace(.deviceRGB) ?? raw.usingColorSpace(.sRGB) ?? raw + let opacity = opacitySlider ?? CGFloat(Defaults[.terminalOpacity]) + let alpha = CGFloat(rgb.cgColor.alpha) * opacity + return rgb.withAlphaComponent(alpha) + } + + /// Composites the terminal buffer over `terminalBackgroundEffectView` without dimming glyphs. + /// SwiftTerm’s `TerminalView.isOpaque` is read-only; translucency relies on alpha in `nativeBackgroundColor`. + private func applyTerminalBackgroundAppearance(to view: LocalProcessTerminalView) { + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor() + } + + /// Upstream `TerminalView.setupOptions()` assigns `layer.backgroundColor` from `nativeBackgroundColor`, which + /// prevents the `NSVisualEffectView` under the terminal from showing through. Re-clear after our color updates. + /// Default-cell alpha and attribute-cache invalidation on native color changes may still need a SwiftTerm PR + private func applyTerminalLayerTranslucencyHacks(to view: LocalProcessTerminalView) { + view.layer?.backgroundColor = NSColor.clear.cgColor + view.layer?.isOpaque = false + } + + /// Best-effort redraw. `TerminalView.installColors(_:)` could clear SwiftTerm color caches but needs 16 + /// `SwiftTerm.Color` values; `Terminal.ansiColors` is not public, so we do not duplicate a palette here. + private func synchronizeTerminalTranslucencyPresentation(to view: LocalProcessTerminalView) { + applyTerminalLayerTranslucencyHacks(to: view) + view.setNeedsDisplay(view.bounds) + } + + /// Re-applies backdrop opacity + translucency hacks after remount/reopen paths. + private func refreshTerminalOpacityAndTranslucency(for view: LocalProcessTerminalView) { + applyTerminalBackgroundAppearance(to: view) + synchronizeTerminalTranslucencyPresentation(to: view) + } + // MARK: - Settings Application /// Applies all persisted settings to a terminal view at creation time. @@ -205,11 +274,8 @@ class TerminalManager: ObservableObject { let fontFamily = Defaults[.terminalFontFamily] view.font = resolveFont(family: fontFamily, size: fontSize) - // Opacity - view.layer?.opacity = Float(Defaults[.terminalOpacity]) - - // Colors - view.nativeBackgroundColor = NSColor(Defaults[.terminalBackgroundColor]) + // Colors (background opacity is separate from terminal opacity slider — see `applyTerminalBackgroundAppearance`) + applyTerminalBackgroundAppearance(to: view) view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor]) view.caretColor = NSColor(Defaults[.terminalCursorColor]) @@ -219,6 +285,7 @@ class TerminalManager: ObservableObject { view.getTerminal().setCursorStyle(cursorStyle.swiftTermStyle) // Keep drawing the selected cursor style when TerminalView.hasFocus is false; SwiftTerm + // otherwise uses an unfocused/outline caret while tracksFocus is true. view.caretViewTracksFocus = false // Scrollback @@ -232,6 +299,8 @@ class TerminalManager: ObservableObject { // Rendering view.useBrightColors = Defaults[.terminalBoldAsBright] + + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates font size on the live terminal view. @@ -248,10 +317,12 @@ class TerminalManager: ObservableObject { view.font = resolveFont(family: family, size: fontSize) } - /// Updates opacity on the live terminal view. + /// Updates background opacity on the live terminal view (glyphs stay fully opaque). func applyOpacity(_ opacity: Double) { guard let view = terminalView else { return } - view.layer?.opacity = Float(opacity) + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity)) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates cursor style on the live terminal view. @@ -289,13 +360,68 @@ class TerminalManager: ObservableObject { /// Updates background color on the live terminal view. func applyBackgroundColor(_ color: SwiftUI.Color) { guard let view = terminalView else { return } - view.nativeBackgroundColor = NSColor(color) + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color)) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates foreground color on the live terminal view. func applyForegroundColor(_ color: SwiftUI.Color) { guard let view = terminalView else { return } view.nativeForegroundColor = NSColor(color) + synchronizeTerminalTranslucencyPresentation(to: view) + } + + /// Called by UI lifecycle hooks after tab re-appearance to keep opacity stable. + func refreshTerminalAppearanceIfNeeded() { + guard let view = terminalView else { return } + refreshTerminalOpacityAndTranslucency(for: view) + } + + /// Makes terminal view first responder when terminal tab opens. + /// Retries a few times to cover mount/animation timing so typing works immediately. + func focusTerminalIfPossible() { + focusTerminalIfPossible(attemptsRemaining: 3) + } + + private func focusTerminalIfPossible(attemptsRemaining: Int) { + guard let view = terminalView else { return } + guard let window = containerView.window ?? view.window else { + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.focusTerminalIfPossible(attemptsRemaining: attemptsRemaining - 1) + } + return + } + + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + if !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + + guard window.firstResponder !== view else { return } + if !window.makeFirstResponder(view), attemptsRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.focusTerminalIfPossible(attemptsRemaining: attemptsRemaining - 1) + } + } + } + + /// Resigns terminal focus before closing notch to avoid stale responder state. + func resignTerminalFirstResponderIfNeeded() { + guard let view = terminalView else { return } + guard let window = containerView.window ?? view.window else { return } + guard window.firstResponder === view else { return } + _ = window.makeFirstResponder(nil) + } + + /// True when the terminal view is currently first responder. + func isTerminalFirstResponder() -> Bool { + guard let view = terminalView else { return false } + guard let window = containerView.window ?? view.window else { return false } + return window.firstResponder === view } /// Updates cursor color on the live terminal view. diff --git a/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift index 0b9e792..b83fb42 100644 --- a/DynamicIsland/models/BatteryStatusViewModel.swift +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -26,6 +26,12 @@ import Foundation import IOKit.ps import SwiftUI +enum BatteryTemporaryHUDKind: Equatable { + case charging + case lowBattery + case fullBattery +} + /// A view model that manages and monitors the battery status of the device class BatteryStatusViewModel: ObservableObject { @@ -34,7 +40,6 @@ class BatteryStatusViewModel: ObservableObject { private var runLoopSource: Unmanaged? var animations: DynamicIslandAnimations = DynamicIslandAnimations() private let lowBatteryAlertSoundPlayer = AudioPlayer() - private let lowBatteryAlertThresholds: [Float] = [20, 15, 10, 5] @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @@ -46,6 +51,11 @@ class BatteryStatusViewModel: ObservableObject { @Published private(set) var isInitial: Bool = false @Published private(set) var timeToFullCharge: Int = 0 @Published private(set) var statusText: String = "" + @Published private(set) var activeTemporaryHUDKind: BatteryTemporaryHUDKind? + @Published private(set) var activeTemporaryHUDToken: UUID = UUID() + @Published private(set) var activeTemporaryHUDTargetScreenName: String? + @Published private(set) var activeTemporaryHUDLevelOverride: Int? + @Published private(set) var activeTemporaryHUDLowPowerModeOverride: Bool? private let managerBattery = BatteryActivityManager.shared private var managerBatteryId: Int? @@ -79,33 +89,39 @@ class BatteryStatusViewModel: ObservableObject { switch event { case .powerSourceChanged(let isPluggedIn): print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + let wasPluggedIn = self.isPluggedIn withAnimation { self.isPluggedIn = isPluggedIn self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged") - self.notifyImportanChangeStatus() + } + if !wasPluggedIn && isPluggedIn { + presentTemporaryBatteryHUDIfNeeded(kind: .charging) } case .batteryLevelChanged(let level): print("🔋 Battery level: \(Int(level))%") let previousLevel = self.levelBattery - self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) withAnimation { self.levelBattery = level } + self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) + self.handleFullBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) case .lowPowerModeChanged(let isEnabled): print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") - self.notifyImportanChangeStatus() + let wasEnabled = self.isInLowPowerMode withAnimation { self.isInLowPowerMode = isEnabled self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))") } + if !wasEnabled && isEnabled { + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + } case .isChargingChanged(let isCharging): print("🔌 Charging: \(isCharging ? "Yes" : "No")") print("maxCapacity: \(self.maxCapacity)") print("levelBattery: \(self.levelBattery)") - self.notifyImportanChangeStatus() withAnimation { self.isCharging = isCharging self.statusText = @@ -145,30 +161,131 @@ class BatteryStatusViewModel: ObservableObject { } } - /// Notifies important changes in the battery status with an optional delay - /// - Parameter delay: The delay before notifying the change, default is 0.0 - private func notifyImportanChangeStatus(delay: Double = 0.0) { - Task { - try? await Task.sleep(for: .seconds(delay)) - self.coordinator.toggleExpandingView(status: true, type: .battery) + private func presentTemporaryBatteryHUDIfNeeded(kind: BatteryTemporaryHUDKind) { + presentTemporaryBatteryHUDIfNeeded(kind: kind, force: false) + } + + func triggerTestHUD(kind: BatteryTemporaryHUDKind) { + let previewLevel: Int + + switch kind { + case .charging: + previewLevel = max(12, min(95, Int(levelBattery.rounded()))) + case .lowBattery: + previewLevel = max(5, min(20, Defaults[.lowBatteryHUDThreshold])) + case .fullBattery: + previewLevel = 100 } + + presentTemporaryBatteryHUDIfNeeded( + kind: kind, + force: true, + levelOverride: previewLevel, + lowPowerModeOverride: kind == .lowBattery ? isInLowPowerMode : nil + ) + } + + private func presentTemporaryBatteryHUDIfNeeded( + kind: BatteryTemporaryHUDKind, + force: Bool, + levelOverride: Int? = nil, + lowPowerModeOverride: Bool? = nil + ) { + guard force || Defaults[.showPowerStatusNotifications] else { return } + + let duration: Int + let isEnabled: Bool + + switch kind { + case .charging: + duration = Defaults[.chargingBatteryHUDDuration] + isEnabled = Defaults[.showChargingBatteryHUD] + case .lowBattery: + duration = Defaults[.lowBatteryHUDDuration] + isEnabled = Defaults[.showLowBatteryHUD] + case .fullBattery: + duration = Defaults[.fullBatteryHUDDuration] + isEnabled = Defaults[.showFullBatteryHUD] + } + + guard force || isEnabled else { return } + + activeTemporaryHUDKind = kind + activeTemporaryHUDToken = UUID() + activeTemporaryHUDTargetScreenName = resolvedTemporaryHUDTargetScreenName() + activeTemporaryHUDLevelOverride = levelOverride + activeTemporaryHUDLowPowerModeOverride = lowPowerModeOverride + coordinator.toggleExpandingView( + status: true, + type: .battery, + autoHideDuration: TimeInterval(max(1, duration)) + ) + } + + private func resolvedTemporaryHUDTargetScreenName() -> String? { + if Defaults[.showOnAllDisplays] { + return nil + } + + let preferredNames = [ + coordinator.selectedScreen, + coordinator.preferredScreen, + NSScreen.main?.localizedName + ] + .compactMap { $0 } + + for candidate in preferredNames where NSScreen.screens.contains(where: { $0.localizedName == candidate }) { + return candidate + } + + return NSScreen.screens.first?.localizedName + } + + private func preferredDynamicIslandTargetScreenName() -> String? { + let mainScreenName = NSScreen.main?.localizedName + let preferredNames = [coordinator.selectedScreen, coordinator.preferredScreen] + + for candidate in preferredNames where shouldUseDynamicIslandMode(for: candidate) { + return candidate + } + + if let externalDynamicIslandScreen = NSScreen.screens.first(where: { + $0.localizedName != mainScreenName && shouldUseDynamicIslandMode(for: $0.localizedName) + }) { + return externalDynamicIslandScreen.localizedName + } + + if let anyDynamicIslandScreen = NSScreen.screens.first(where: { + shouldUseDynamicIslandMode(for: $0.localizedName) + }) { + return anyDynamicIslandScreen.localizedName + } + + return nil } private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { - guard Defaults[.playLowBatteryAlertSound] else { return } guard !isPluggedIn, !isCharging else { return } guard newLevel < previousLevel else { return } + let threshold = Float(Defaults[.lowBatteryHUDThreshold]) + guard previousLevel > threshold && newLevel <= threshold else { return } - for threshold in lowBatteryAlertThresholds { - if previousLevel >= threshold && newLevel < threshold { - self.statusText = String(localized: "Low battery") - notifyImportanChangeStatus() - playLowBatteryAlertSound() - break - } + self.statusText = String(localized: "Low battery") + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + if Defaults[.playLowBatteryAlertSound] { + playLowBatteryAlertSound() } } + private func handleFullBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { + guard newLevel > previousLevel else { return } + let threshold = Float(Defaults[.fullBatteryHUDThreshold]) + guard previousLevel < threshold && newLevel >= threshold else { return } + + self.statusText = String(localized: "Full charge") + presentTemporaryBatteryHUDIfNeeded(kind: .fullBattery) + } + private func playLowBatteryAlertSound() { lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3") } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 31fb842..86b4f34 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -473,6 +473,22 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable var id: String { self.rawValue } } +enum BatteryNotificationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case standard + case compact + + var id: String { rawValue } + + var title: String { + switch self { + case .standard: + return String(localized: "Standard") + case .compact: + return String(localized: "Compact") + } + } +} + enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable { case shuffle case repeatMode @@ -941,7 +957,17 @@ extension Defaults.Keys { static let showBatteryPercentage = Key("showBatteryPercentage", default: true) static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) static let playLowBatteryAlertSound = Key("playLowBatteryAlertSound", default: true) - + static let showChargingBatteryHUD = Key("showChargingBatteryHUD", default: true) + static let showLowBatteryHUD = Key("showLowBatteryHUD", default: true) + static let showFullBatteryHUD = Key("showFullBatteryHUD", default: true) + static let chargingBatteryHUDDuration = Key("chargingBatteryHUDDuration", default: 3) + static let lowBatteryHUDDuration = Key("lowBatteryHUDDuration", default: 3) + static let fullBatteryHUDDuration = Key("fullBatteryHUDDuration", default: 3) + static let lowBatteryHUDThreshold = Key("lowBatteryHUDThreshold", default: 20) + static let fullBatteryHUDThreshold = Key("fullBatteryHUDThreshold", default: 100) + static let lowBatteryHUDStyle = Key("lowBatteryHUDStyle", default: .standard) + static let fullBatteryHUDStyle = Key("fullBatteryHUDStyle", default: .standard) + static let lockScreenBatteryShowsBatteryGauge = Key( "lockScreenWeatherShowsBatteryGauge", default: BatteryActivityManager.shared.hasBattery() diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index b9418b2..5017138 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -154,6 +154,46 @@ var minimalisticOpenNotchSize: CGSize { let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) +// MARK: - Terminal tab clip (notch surface) + +/// Padding on the terminal block inside the notch. Inner corner radius = outer shell radius on that edge, minus the matching edge padding. +let notchTerminalContentEdgePadding: (top: CGFloat, horizontal: CGFloat, bottom: CGFloat) = (4, 8, 8) + +/// Bottom radii for the shell (outer) and the terminal ``clipShape`` (inner), per design: inner = outer shell bottom radius − `notchTerminalContentEdgePadding.bottom`. +func notchTerminalBottomCornerRadii( + isDynamicIslandMode: Bool, + notchState: NotchState, + cornerRadiusScaling: Bool, + enableMinimalisticUI: Bool, + closedNotchHeight: CGFloat +) -> (outerBottom: CGFloat, innerBottom: CGFloat) { + let p = notchTerminalContentEdgePadding.bottom + if isDynamicIslandMode { + let outer: CGFloat + if notchState == .open { + outer = enableMinimalisticUI + ? minimalisticCornerRadiusInsets.opened.top + : dynamicIslandPillCornerRadiusInsets.opened + } else { + outer = max(closedNotchHeight / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + } + return (outer, max(0, outer - p)) + } + let active: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = { + if enableMinimalisticUI { + return (opened: minimalisticCornerRadiusInsets.opened, closed: cornerRadiusInsets.closed) + } + return cornerRadiusInsets + }() + let outerBottom: CGFloat + if notchState == .open && cornerRadiusScaling { + outerBottom = active.opened.bottom + } else { + outerBottom = active.closed.bottom + } + return (outerBottom, max(0, outerBottom - p)) +} + func statsAdjustedNotchSize( from baseSize: CGSize, isStatsTabActive: Bool,