From 9c9f68399eb58cf29615d8aee29fec59645ec604 Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Fri, 24 Apr 2026 09:14:41 +0800 Subject: [PATCH 1/4] Terminal opacity fix --- .../xcshareddata/swiftpm/Package.resolved | 8 +- DynamicIsland/Localizable.xcstrings | 9 +++ .../components/Notch/NotchTerminalView.swift | 40 ++++++++-- .../components/Settings/SettingsView.swift | 6 +- DynamicIsland/managers/TerminalManager.swift | 79 ++++++++++++++++--- DynamicIsland/sizing/matters.swift | 40 ++++++++++ 6 files changed, 161 insertions(+), 21 deletions(-) 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/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 1687df1..2d2e3bb 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5619,6 +5619,9 @@ } } } + }, + "Amazon Music" : { + }, "Animation" : { "extractionState" : "stale", @@ -41010,6 +41013,9 @@ } } } + }, + "Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window." : { + }, "Text" : { "comment" : "Name of a clipboard item type that contains plain text.", @@ -43754,6 +43760,9 @@ } } } + }, + "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." : { + }, "Uses the frosted blur treatment while glass mode is enabled." : { "comment" : "A description of the frosted blur treatment when glass mode is enabled. | A description of the frosted blur treatment when glass mode is enabled. | merge:ko:alt='Glass 모드가 활성화되어 있는 동안에는 Frosted 블러 효과가 적용됩니다.'", diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift index a3f4336..4fa99f9 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,15 @@ 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(.horizontal, notchTerminalContentEdgePadding.horizontal) + .padding(.bottom, notchTerminalContentEdgePadding.bottom) + .padding(.top, notchTerminalContentEdgePadding.top) .onHover { hovering in updateSuppression(for: hovering) } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 73c2559..6cb4300 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -897,7 +897,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")), @@ -7862,6 +7862,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/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift index 4ec7224..1b047d7 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 @@ -89,12 +90,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 @@ -107,7 +120,7 @@ class TerminalManager: ObservableObject { 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 + synchronizeTerminalTranslucencyPresentation(to: existing) return } @@ -119,6 +132,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,14 +143,16 @@ 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 } } @@ -196,6 +214,40 @@ 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) + } + // MARK: - Settings Application /// Applies all persisted settings to a terminal view at creation time. @@ -205,11 +257,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 +268,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 +282,8 @@ class TerminalManager: ObservableObject { // Rendering view.useBrightColors = Defaults[.terminalBoldAsBright] + + synchronizeTerminalTranslucencyPresentation(to: view) } /// Updates font size on the live terminal view. @@ -248,10 +300,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)) + synchronizeTerminalTranslucencyPresentation(to: view) } /// Updates cursor style on the live terminal view. @@ -289,13 +343,16 @@ 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)) + synchronizeTerminalTranslucencyPresentation(to: 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) } /// Updates cursor color on the live terminal view. 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, From f39785f9205afbb35c76fb7a68bb1f8bd562189a Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Fri, 24 Apr 2026 16:31:45 +0800 Subject: [PATCH 2/4] Fix terminal notch lifecycle so open/close, hover, outside-click, focus, and opacity stay consistent. fix: stabilize terminal notch open/close, focus, and opacity Improve terminal tab behavior in notch by preventing hover re-open races, keeping outside-click close logic accurate, and reliably restoring terminal opacity/translucency after remounts. Also ensure terminal receives focus on open and resigns cleanly on close so keyboard input and close/open cycles behave consistently. --- DynamicIsland/ContentView.swift | 29 +++++-- DynamicIsland/DynamicIslandApp.swift | 8 ++ .../DynamicIslandViewCoordinator.swift | 9 +++ .../components/Notch/NotchTerminalView.swift | 15 +++- DynamicIsland/managers/TerminalManager.swift | 81 +++++++++++++++++-- 5 files changed, 128 insertions(+), 14 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 444f3e3..0d413f8 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -507,7 +507,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() } } @@ -1715,6 +1715,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] { @@ -1766,6 +1767,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() } } @@ -1818,7 +1823,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) { @@ -1843,14 +1849,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 { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 902d5e6..8650f79 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -1046,13 +1046,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..be70f58 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -102,6 +102,7 @@ struct ExpandedItem { 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 +247,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] { diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift index 4fa99f9..2db4e1f 100644 --- a/DynamicIsland/components/Notch/NotchTerminalView.swift +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -157,9 +157,14 @@ struct NotchTerminalView: View { // notch close/open cycles; updateNSView handles restart. TerminalNSViewRepresentable(terminalManager: terminalManager) .clipShape(terminalClipShape) - .padding(.horizontal, notchTerminalContentEdgePadding.horizontal) - .padding(.bottom, notchTerminalContentEdgePadding.bottom) - .padding(.top, notchTerminalContentEdgePadding.top) + .padding( + EdgeInsets( + top: notchTerminalContentEdgePadding.top, + leading: notchTerminalContentEdgePadding.horizontal, + bottom: notchTerminalContentEdgePadding.bottom, + trailing: notchTerminalContentEdgePadding.horizontal + ) + ) .onHover { hovering in updateSuppression(for: hovering) } @@ -184,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/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift index 1b047d7..3275f0e 100644 --- a/DynamicIsland/managers/TerminalManager.swift +++ b/DynamicIsland/managers/TerminalManager.swift @@ -60,6 +60,9 @@ final class StableTerminalContainerView: NSView { for child in subviews { child.needsDisplay = true } + Task { @MainActor in + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + } } } @@ -118,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. - synchronizeTerminalTranslucencyPresentation(to: existing) + // Re-apply opacity/translucency after hide/show cycles (e.g. shortcut toggle). + refreshTerminalOpacityAndTranslucency(for: existing) return } @@ -155,6 +157,15 @@ class TerminalManager: ObservableObject { 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. @@ -248,6 +259,12 @@ class TerminalManager: ObservableObject { 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. @@ -283,7 +300,7 @@ class TerminalManager: ObservableObject { // Rendering view.useBrightColors = Defaults[.terminalBoldAsBright] - synchronizeTerminalTranslucencyPresentation(to: view) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates font size on the live terminal view. @@ -305,7 +322,7 @@ class TerminalManager: ObservableObject { guard let view = terminalView else { return } view.layer?.opacity = 1 view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity)) - synchronizeTerminalTranslucencyPresentation(to: view) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates cursor style on the live terminal view. @@ -345,7 +362,7 @@ class TerminalManager: ObservableObject { guard let view = terminalView else { return } view.layer?.opacity = 1 view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color)) - synchronizeTerminalTranslucencyPresentation(to: view) + refreshTerminalOpacityAndTranslucency(for: view) } /// Updates foreground color on the live terminal view. @@ -355,6 +372,58 @@ class TerminalManager: ObservableObject { 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. func applyCursorColor(_ color: SwiftUI.Color) { guard let view = terminalView else { return } From 47c784f284c726f579d9478c194cfbe69214f4c3 Mon Sep 17 00:00:00 2001 From: delli Date: Sun, 10 May 2026 14:55:58 +0200 Subject: [PATCH 3/4] new file tray hud --- DynamicIsland.xcodeproj/project.pbxproj | 8 +-- DynamicIsland/ContentView.swift | 28 ++++++++++ DynamicIsland/Localizable.xcstrings | 3 - .../Shelf/Views/ShelfInlineLiveActivity.swift | 56 +++++++++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 02e0162..3d4e01b 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -454,12 +454,12 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = 9Y64TRM77N; + DEVELOPMENT_TEAM = RPC964BP8A; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -526,13 +526,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + "DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1a40e0d..4ab32a2 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 @@ -774,6 +775,8 @@ struct ContentView: View { return false case .extensionPayload: return false + case .shelf: + return false } }() let canShowMusicDuringExpansion = !coordinator.expandingView.show @@ -849,6 +852,9 @@ 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 { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { @@ -1192,6 +1198,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 +1223,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 +1327,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 +1389,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, @@ -2474,6 +2499,7 @@ private enum MusicSecondaryLiveActivity: Equatable { case focus(FocusModeType) case capsLock(showLabel: Bool) case extensionPayload(ExtensionLiveActivityPayload) + case shelf(count: Int) var id: String { switch self { @@ -2489,6 +2515,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/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 7d726a5..3c9788b 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5622,9 +5622,6 @@ } } } - }, - "Amazon Music" : { - }, "Animation" : { "extractionState" : "stale", 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) + } +} From 945707425a6ea3ac61c6c05091f88634975a88d0 Mon Sep 17 00:00:00 2001 From: delli Date: Wed, 29 Apr 2026 15:48:03 +0200 Subject: [PATCH 4/4] batteria finale, waiting for approve --- DynamicIsland/ContentView.swift | 186 ++++++-- DynamicIsland/DynamicIslandApp.swift | 37 ++ .../DynamicIslandViewCoordinator.swift | 7 +- DynamicIsland/Localizable.xcstrings | 97 +++- .../DynamicIslandBattery.swift | 444 +++++++++++++++++- .../Notch/DynamicIslandHeader.swift | 10 +- .../components/Settings/SettingsView.swift | 216 +++++++++ .../models/BatteryStatusViewModel.swift | 155 +++++- DynamicIsland/models/Constants.swift | 28 +- 9 files changed, 1091 insertions(+), 89 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 4ab32a2..1a73dbe 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -85,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 @@ -101,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 } @@ -383,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) } @@ -764,13 +875,13 @@ 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: @@ -779,34 +890,26 @@ struct ContentView: View { 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( @@ -821,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( @@ -856,6 +959,7 @@ struct ContentView: View { 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() @@ -1631,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 @@ -1754,7 +1858,7 @@ struct ContentView: View { && !vm.hideOnClosed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock - && !coordinator.expandingView.show + && !isCurrentScreenExpansionVisible && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && !coordinator.firstLaunch } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 8650f79..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 diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index be70f58..c6ebb3a 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -97,6 +97,7 @@ struct ExpandedItem { var type: SneakContentType = .battery var value: CGFloat = 0 var browser: BrowserType = .chromium + var autoHideDuration: TimeInterval? = nil } class DynamicIslandViewCoordinator: ObservableObject { @@ -413,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) { @@ -421,6 +423,7 @@ class DynamicIslandViewCoordinator: ObservableObject { self.expandingView.type = type self.expandingView.value = value self.expandingView.browser = browser + self.expandingView.autoHideDuration = autoHideDuration } } } @@ -433,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 3c9788b..67ed55a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1739,6 +1739,9 @@ } } } + }, + "%llds" : { + }, "%lldx" : { "localizations" : { @@ -2314,7 +2317,6 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3138,7 +3140,6 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3696,7 +3697,6 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5623,6 +5623,10 @@ } } }, + "Amazon Music" : { + "comment" : "Spotify music service.", + "isCommentAutoGenerated" : true + }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6016,7 +6020,6 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6920,7 +6923,6 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -7158,6 +7160,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.", @@ -9032,6 +9037,12 @@ } } } + }, + "Charging duration" : { + + }, + "Charging HUD" : { + }, "Charging on Hold: Desktop Mode" : { "localizations" : { @@ -11426,6 +11437,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" : { @@ -14521,7 +14542,6 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -19732,6 +19752,15 @@ } } } + }, + "Full Battery" : { + + }, + "Full battery duration" : { + + }, + "Full battery style" : { + }, "Full charge" : { "comment" : "Text displayed in the status text when the battery is fully charged.", @@ -19756,6 +19785,9 @@ } } } + }, + "Full charge threshold" : { + }, "Full Disk Access required" : { "extractionState" : "stale", @@ -19830,6 +19862,9 @@ } } } + }, + "Fully charged HUD" : { + }, "Gemini API Key" : { "localizations" : { @@ -20829,7 +20864,6 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -21699,6 +21733,9 @@ } }, "shouldTranslate" : false + }, + "HUD Duration" : { + }, "HUD icon style" : { "comment" : "A label describing the style of the 3D or symbol-based Bluetooth HUD icon.", @@ -21851,6 +21888,9 @@ } } } + }, + "HUD Tests" : { + }, "HUDs" : { "extractionState" : "stale", @@ -24396,6 +24436,21 @@ } } } + }, + "Low Battery" : { + + }, + "Low battery duration" : { + + }, + "Low battery HUD" : { + + }, + "Low battery style" : { + + }, + "Low battery threshold" : { + }, "Low Power Mode" : { "localizations" : { @@ -29944,7 +29999,6 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30690,7 +30744,6 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -34233,7 +34286,6 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -34385,6 +34437,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" : { @@ -35755,7 +35810,6 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38188,7 +38242,6 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38213,7 +38266,6 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38441,7 +38493,6 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39165,7 +39216,6 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39402,7 +39452,6 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -41029,6 +41078,15 @@ }, "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.", @@ -41148,6 +41206,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을 사용하는 타사 앱은 실시간 현황, 잠금 화면 위젯 및 노치 전용 기능을 표시할 수 있습니다. 위에서 기능을 켜고 끄거나 아래에서 개별 앱 권한을 관리할 수 있습니다.'", @@ -42233,7 +42294,6 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45598,7 +45658,6 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45724,4 +45783,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/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2a4464b..9e4def1 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")), @@ -1305,10 +1318,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() { @@ -1340,6 +1405,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) { 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 198cd85..d29ff4e 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 @@ -937,7 +953,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()