diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8c2bb5d..8f3673d 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -80,6 +80,8 @@ struct ContentView: View { @Default(.enableCapsLockIndicator) var enableCapsLockIndicator @Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities @Default(.showStandardMediaControls) var showStandardMediaControls + @Default(.externalDisplayStyle) var externalDisplayStyle + @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { @@ -231,8 +233,52 @@ struct ContentView: View { return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius) } + /// Whether the current screen should render as a Dynamic Island pill + /// rather than the standard notch shape. Always false on physical notch screens. + private var isDynamicIslandMode: Bool { + shouldUseDynamicIslandMode(for: vm.screen ?? coordinator.selectedScreen) + } + + /// Whether the current screen lacks a physical notch. + private var isNonNotchScreen: Bool { + let screenName = vm.screen ?? coordinator.selectedScreen + guard let screen = NSScreen.screens.first(where: { $0.localizedName == screenName }) else { + return true + } + return screen.safeAreaInsets.top <= 0 + } + + /// Whether the notch/island should hide off-screen when closed on a non-notch display. + private var shouldHideUntilHover: Bool { + hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed + } + + /// Pill shape for Dynamic Island mode with animated corner radius transitions. + private var currentPillShape: DynamicIslandPillShape { + let radius: CGFloat + if vm.notchState == .open { + radius = enableMinimalisticUI + ? minimalisticCornerRadiusInsets.opened.top + : dynamicIslandPillCornerRadiusInsets.opened + } else { + // Use half the closed height for a true capsule shape + radius = max(vm.closedNotchSize.height / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + } + return DynamicIslandPillShape(cornerRadius: radius) + } + + /// 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 isDynamicIslandMode { + return AnyShape(currentPillShape) + } + return AnyShape(currentNotchShape) + } + var body: some View { let interactionsEnabled = !lockScreenManager.isLocked + let isIslandMode = isDynamicIslandMode let notchHorizontalPadding: CGFloat = { guard vm.notchState == .open else { return activeCornerRadiusInsets.closed.bottom @@ -249,6 +295,8 @@ struct ContentView: View { return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0 }() let notchBottomPadding = currentShadowPadding + hoverAreaPadding + // Extra top padding to detach pill from screen edge in Dynamic Island mode + let pillTopOffset: CGFloat = isIslandMode ? dynamicIslandTopOffset : 0 ZStack(alignment: .top) { let mainLayout = NotchLayout() @@ -256,7 +304,7 @@ struct ContentView: View { .padding(.horizontal, notchHorizontalPadding) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .background(.black) - .clipShape(currentNotchShape) + .clipShape(resolvedClipShape) .compositingGroup() .shadow( color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) @@ -264,7 +312,16 @@ struct ContentView: View { : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5 ) + // Extra horizontal inset for Dynamic Island mode so the shadow + // is not clipped by the outer frame constraint + .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) + .padding(.top, pillTopOffset) .padding(.bottom, notchBottomPadding) + // Hide off-screen when non-notch hide-until-hover is active + .offset(y: shouldHideUntilHover && !isHovering + ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) + : 0 + ) mainLayout .conditionalModifier(!useModernCloseAnimation) { view in @@ -288,7 +345,7 @@ struct ContentView: View { } .conditionalModifier(interactionsEnabled) { view in view - .contentShape(currentNotchShape) + .contentShape(resolvedClipShape) .onHover { hovering in handleHover(hovering) } @@ -410,8 +467,8 @@ struct ContentView: View { } .frame( - maxWidth: dynamicNotchSize.width, - maxHeight: dynamicNotchSize.height + currentShadowPadding, + maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), + maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0), alignment: .top ) .frame(maxHeight: .infinity, alignment: .top) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index ff5c2c4..bee93b2 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -224,7 +224,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { -> NSWindow { // Use the current required size instead of always using openNotchSize - let requiredSize = calculateRequiredNotchSize() + let baseSize = calculateRequiredNotchSize() + let requiredSize = adjustedSizeForScreen(baseSize, screen: screen) let window = DynamicIslandWindow( contentRect: NSRect( @@ -317,10 +318,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { isStatsTabActive: coordinator.currentView == .stats, secondRowProgress: coordinator.statsSecondRowExpansion ) - return addShadowPadding( + var result = addShadowPadding( to: adjustedContentSize, isMinimalistic: Defaults[.enableMinimalisticUI] ) + + return result + } + + /// Adjusts a base notch size for a specific screen by adding Dynamic Island + /// shadow insets and top-offset only when the screen lacks a physical notch + /// and the user has chosen the Dynamic Island style. + private func adjustedSizeForScreen(_ baseSize: CGSize, screen: NSScreen) -> CGSize { + guard shouldUseDynamicIslandMode(for: screen.localizedName) else { + return baseSize + } + var adjusted = baseSize + adjusted.width += dynamicIslandShadowInset * 2 + adjusted.height += dynamicIslandTopOffset + return adjusted } func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) { @@ -332,15 +348,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { if Defaults[.showOnAllDisplays] { for (screen, window) in windows { - if force || window.frame.size != size { - resizeWindow(window, on: screen, to: size, animated: animated) + let screenSize = adjustedSizeForScreen(size, screen: screen) + if force || window.frame.size != screenSize { + resizeWindow(window, on: screen, to: screenSize, animated: animated) } } } else if let window { let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } ?? NSScreen.main ?? NSScreen.screens.first guard let screen else { return } - if force || window.frame.size != size { - resizeWindow(window, on: screen, to: size, animated: animated) + let screenSize = adjustedSizeForScreen(size, screen: screen) + if force || window.frame.size != screenSize { + resizeWindow(window, on: screen, to: screenSize, animated: animated) } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b3c3e64..734637b 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1591,6 +1591,10 @@ } } }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior.", + "isCommentAutoGenerated" : true + }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1907,6 +1911,10 @@ } } }, + "Accent" : { + "comment" : "Name of the Caps Lock Indicator Tint Mode option that uses the accent color.", + "isCommentAutoGenerated" : true + }, "Accent color" : { "localizations" : { "ar" : { @@ -2252,6 +2260,10 @@ } } }, + "Adaptive gradient" : { + "comment" : "Name of the timer icon color mode that uses an adaptive gradient.", + "isCommentAutoGenerated" : true + }, "Add" : { "localizations" : { "ar" : { @@ -3841,6 +3853,10 @@ } } }, + "Apple Music" : { + "comment" : "Names for the different media player types available in the app.", + "isCommentAutoGenerated" : true + }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4252,6 +4268,10 @@ } } }, + "Bar" : { + "comment" : "Localized name for the \"Bar\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Battery" : { "localizations" : { "ar" : { @@ -6234,6 +6254,10 @@ } } }, + "Classic notch shape that blends into the top screen edge" : { + "comment" : "Description of a notch shape that blends into the top screen edge.", + "isCommentAutoGenerated" : true + }, "Clear" : { "extractionState" : "stale", "localizations" : { @@ -8491,6 +8515,10 @@ } } }, + "Digital" : { + "comment" : "The name of the \"Digital\" style of reminder presentation.", + "isCommentAutoGenerated" : true + }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -9256,6 +9284,10 @@ } } }, + "Dynamic Island" : { + "comment" : "Description of the dynamic island style.", + "isCommentAutoGenerated" : true + }, "Dynamic Island Progress Bars" : { "comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.", "isCommentAutoGenerated" : true, @@ -10814,6 +10846,10 @@ } } }, + "External display style" : { + "comment" : "A picker that lets the user choose the style of the external display.", + "isCommentAutoGenerated" : true + }, "Favorites" : { "comment" : "Localized name of the \"Favorites\" tab in the clipboard.", "isCommentAutoGenerated" : true @@ -11861,6 +11897,10 @@ } } }, + "Green" : { + "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", + "isCommentAutoGenerated" : true + }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15527,7 +15567,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17737,6 +17776,10 @@ } } }, + "Now Playing" : { + "comment" : "Localized name for each media controller type.", + "isCommentAutoGenerated" : true + }, "Off" : { "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", "isCommentAutoGenerated" : true @@ -18103,6 +18146,10 @@ } } }, + "Panel" : { + "comment" : "Display name for the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -18232,6 +18279,10 @@ } } }, + "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island" : { + "comment" : "Description of the Dynamic Island style for external displays.", + "isCommentAutoGenerated" : true + }, "Pin items from history to save them here" : { "localizations" : { "pt-BR" : { @@ -19697,6 +19748,10 @@ } } }, + "Ring" : { + "comment" : "Localized name for the \"Ring\" timer progress style.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20517,6 +20572,10 @@ } } }, + "Separate Tab" : { + "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", + "isCommentAutoGenerated" : true + }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21571,6 +21630,14 @@ } } }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the \"popover\" display mode for the screen assistant.", + "isCommentAutoGenerated" : true + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the \"Panel\" screen assistant display mode.", + "isCommentAutoGenerated" : true + }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21633,6 +21700,10 @@ } } }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", + "isCommentAutoGenerated" : true + }, "Slider color" : { "localizations" : { "ar" : { @@ -22200,6 +22271,10 @@ } } }, + "Spotify" : { + "comment" : "Add more media player types here.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -22302,12 +22377,20 @@ }, "Standard" : { + }, + "Standard Notch" : { + "comment" : "Text for the \"Standard Notch\" option in the external display style picker.", + "isCommentAutoGenerated" : true }, "Standard notch media controls are hidden while this toggle is off." : { }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + }, + "Standard previous/next track controls" : { + "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", + "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23657,6 +23740,10 @@ }, "shouldTranslate" : false }, + "Track Skip" : { + "comment" : "Name of the music skip behavior option that skips to the next track.", + "isCommentAutoGenerated" : true + }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -25243,6 +25330,10 @@ } } }, + "When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it." : { + "comment" : "A description of the feature that hides the notch on non-notch displays.", + "isCommentAutoGenerated" : true + }, "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { "localizations" : { "pt-BR" : { @@ -25567,6 +25658,10 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, + "Youtube Music" : { + "comment" : "Name for the \"Youtube Music\" media controller type in settings.", + "isCommentAutoGenerated" : true + }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Notch/DynamicIslandPillShape.swift b/DynamicIsland/components/Notch/DynamicIslandPillShape.swift new file mode 100644 index 0000000..18d8899 --- /dev/null +++ b/DynamicIsland/components/Notch/DynamicIslandPillShape.swift @@ -0,0 +1,50 @@ +/* + * 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 + +/// A pill-shaped (capsule-like) clip shape used for the Dynamic Island mode +/// on external and non-notched displays. Uses `.continuous` rounded corners +/// for an Apple-style squircle appearance, inspired by DynamicNotchKit's +/// floating style. +struct DynamicIslandPillShape: Shape { + var cornerRadius: CGFloat + + var animatableData: CGFloat { + get { cornerRadius } + set { cornerRadius = newValue } + } + + func path(in rect: CGRect) -> Path { + let resolvedRadius = min(cornerRadius, min(rect.width, rect.height) / 2) + return Path(roundedRect: rect, cornerRadius: resolvedRadius, style: .continuous) + } +} + +#Preview { + VStack(spacing: 20) { + DynamicIslandPillShape(cornerRadius: 16) + .fill(.black) + .frame(width: 185, height: 32) + + DynamicIslandPillShape(cornerRadius: 24) + .fill(.black) + .frame(width: 640, height: 200) + } + .padding(20) +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index cb80cb8..4e81f35 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -606,6 +606,8 @@ struct SettingsView: View { SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"], highlightID: SettingsTab.general.highlightID(for: "Extend hover area")), SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"], highlightID: SettingsTab.general.highlightID(for: "Enable haptics")), SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"], highlightID: SettingsTab.general.highlightID(for: "Open notch on hover")), + SettingsSearchEntry(tab: .general, title: "External display style", keywords: ["dynamic island", "pill", "external display", "non-notch", "floating", "capsule"], highlightID: SettingsTab.general.highlightID(for: "External display style")), + SettingsSearchEntry(tab: .general, title: "Hide until hovered", keywords: ["hide", "hover", "external", "non-notch", "auto hide", "slide"], highlightID: SettingsTab.general.highlightID(for: "Hide until hovered")), SettingsSearchEntry(tab: .general, title: "Notch display height", keywords: ["display height", "menu bar size"], highlightID: SettingsTab.general.highlightID(for: "Notch display height")), // Live Activities @@ -905,6 +907,8 @@ struct GeneralSettings: View { @Default(.musicGestureBehavior) var musicGestureBehavior @Default(.reverseSwipeGestures) var reverseSwipeGestures @Default(.reverseScrollGestures) var reverseScrollGestures + @Default(.externalDisplayStyle) var externalDisplayStyle + @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover private func highlightID(_ title: String) -> String { SettingsTab.general.highlightID(for: title) @@ -1118,6 +1122,24 @@ struct GeneralSettings: View { NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) } } + Picker("External display style", selection: $externalDisplayStyle) { + ForEach(ExternalDisplayStyle.allCases) { style in + Text(style.localizedName) + .tag(style) + } + } + .onChange(of: externalDisplayStyle) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + .settingsHighlight(id: highlightID("External display style")) + Text(externalDisplayStyle.description) + .font(.caption) + .foregroundStyle(.secondary) + Defaults.Toggle("Hide until hovered on non-notch displays", key: .hideNonNotchUntilHover) + .settingsHighlight(id: highlightID("Hide until hovered")) + Text("When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it.") + .font(.caption) + .foregroundStyle(.secondary) } header: { Text("Notch behavior") } diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index f9a9b8d..7b7eb52 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -29,6 +29,36 @@ public enum Style { case floating } +/// Controls how Atoll renders on external and non-notched displays. +/// - `notch`: Standard notch shape (concave top corners blending into the screen edge). +/// - `dynamicIsland`: Pill-shaped island with continuously rounded corners, +/// inspired by DynamicNotchKit's floating style. Only applies to screens +/// that do NOT have a physical notch. +enum ExternalDisplayStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case notch = "Standard Notch" + case dynamicIsland = "Dynamic Island" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .notch: + return String(localized: "Standard Notch") + case .dynamicIsland: + return String(localized: "Dynamic Island") + } + } + + var description: String { + switch self { + case .notch: + return String(localized: "Classic notch shape that blends into the top screen edge") + case .dynamicIsland: + return String(localized: "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island") + } + } +} + public enum ContentType: Int, Codable, Hashable, Equatable { case normal case menu diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index b470a79..5e38106 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -699,6 +699,11 @@ extension Defaults.Keys { static let enableHaptics = Key("enableHaptics", default: true) static let openNotchOnHover = Key("openNotchOnHover", default: true) static let extendHoverArea = Key("extendHoverArea", default: false) + static let externalDisplayStyle = Key( + "externalDisplayStyle", + default: .notch + ) + static let hideNonNotchUntilHover = Key("hideNonNotchUntilHover", default: false) static let notchHeightMode = Key( "notchHeightMode", default: WindowHeightMode.matchRealNotchSize diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 5987a3e..3757150 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -104,6 +104,51 @@ func addShadowPadding(to size: CGSize, isMinimalistic: Bool) -> CGSize { CGSize(width: size.width, height: size.height + notchShadowPaddingValue(isMinimalistic: isMinimalistic)) } +/// Determines whether a specific screen should render the Dynamic Island pill +/// shape instead of the standard notch shape. +/// +/// Returns `true` only when ALL of these conditions are met: +/// 1. The user has selected `.dynamicIsland` in `externalDisplayStyle` +/// 2. The screen does NOT have a physical notch (safeAreaInsets.top == 0) +/// +/// Screens with a physical notch always use the standard notch shape. +func shouldUseDynamicIslandMode(for screenName: String?) -> Bool { + guard Defaults[.externalDisplayStyle] == .dynamicIsland else { + return false + } + + var selectedScreen: NSScreen? = NSScreen.main + if let screenName { + selectedScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) + } + + guard let screen = selectedScreen else { + // No screen found — fallback to standard notch + return false + } + + // Physical notch screens always use standard notch shape + return screen.safeAreaInsets.top <= 0 +} + +/// Corner radius insets for the Dynamic Island pill shape. +/// - closed: half the closed notch height for a true capsule look +/// - opened: generous radius for smooth expanded pill +let dynamicIslandPillCornerRadiusInsets: (opened: CGFloat, closed: (standard: CGFloat, minimalistic: CGFloat)) = ( + opened: 24, + closed: (standard: 16, minimalistic: 16) +) + +/// Vertical offset from the top screen edge for the Dynamic Island pill. +/// Creates a visual gap so the pill floats below the menu bar, mimicking +/// the iPhone's Dynamic Island detachment from the physical screen edge. +let dynamicIslandTopOffset: CGFloat = 6 + +/// Extra horizontal padding applied OUTSIDE the pill clip shape in Dynamic +/// Island mode so the drop shadow has room to render without being clipped +/// by the outer frame constraint. +let dynamicIslandShadowInset: CGFloat = 14 + enum MusicPlayerImageSizes { static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0) static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20))