From 9c044237c1dfc027411e416095cb16937c631dc9 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 13 Mar 2026 21:40:42 +0530 Subject: [PATCH] Update HUDs for external display parity --- DynamicIsland/ContentView.swift | 29 +++++++--- .../DynamicIslandViewCoordinator.swift | 5 +- .../managers/BetterDisplayManager.swift | 56 +++++++++++++------ DynamicIsland/managers/LunarManager.swift | 46 ++++++++++++--- 4 files changed, 101 insertions(+), 35 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1525df4..7c7a6b4 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -257,22 +257,33 @@ struct ContentView: View { /// 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) + shouldUseDynamicIslandMode(for: currentScreenName) + } + + private var currentScreenName: String { + 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 { + guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else { return true } return screen.safeAreaInsets.top <= 0 } + /// Whether the global sneak peek is visible on this specific screen. + private var isSneakPeekVisibleOnCurrentScreen: Bool { + guard coordinator.sneakPeek.show else { return false } + guard Defaults[.showOnAllDisplays] else { return true } + guard let targetScreenName = coordinator.sneakPeek.targetScreenName else { return true } + return currentScreenName == targetScreenName + } + /// Whether the notch/island should hide off-screen when closed on a non-notch display. /// Temporarily reveals the notch when a sneakPeek HUD (volume, brightness, music, etc.) is active. private var shouldHideUntilHover: Bool { - hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !coordinator.sneakPeek.show + hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !isSneakPeekVisibleOnCurrentScreen } /// Pill shape for Dynamic Island mode with animated corner radius transitions. @@ -696,7 +707,7 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - } else if coordinator.sneakPeek.show && 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) { + } 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( coordinator.sneakPeek.type == .capsLock @@ -743,7 +754,7 @@ struct ContentView: View { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) } - if coordinator.sneakPeek.show { + if isSneakPeekVisibleOnCurrentScreen { if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .capsLock) && !coordinator.sneakPeek.type.isExtensionPayload && !Defaults[.inlineHUD] && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in // @@ -1637,7 +1648,7 @@ struct ContentView: View { let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive && !enableMinimalisticUI guard vm.notchState == .closed, - !coordinator.sneakPeek.show, + !isSneakPeekVisibleOnCurrentScreen, (Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return } hoverTask = Task { @@ -1647,7 +1658,7 @@ struct ContentView: View { await MainActor.run { guard self.vm.notchState == .closed, self.isHovering, - !self.coordinator.sneakPeek.show else { return } + !self.isSneakPeekVisibleOnCurrentScreen else { return } if shouldFocusTimerTab { withAnimation(.smooth) { @@ -2162,7 +2173,7 @@ struct ContentView: View { #endif private func shouldFixSizeForSneakPeek() -> Bool { - guard coordinator.sneakPeek.show else { return false } + guard isSneakPeekVisibleOnCurrentScreen else { return false } let style = resolvedSneakPeekStyle() // Check for extension sneak peek diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 7c55430..c6ed432 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -84,6 +84,7 @@ struct sneakPeek { var subtitle: String = "" var accentColor: Color? var styleOverride: SneakPeekStyle? = nil + var targetScreenName: String? = nil } enum BrowserType { @@ -323,7 +324,8 @@ class DynamicIslandViewCoordinator: ObservableObject { title: String = "", subtitle: String = "", accentColor: Color? = nil, - styleOverride: SneakPeekStyle? = nil + styleOverride: SneakPeekStyle? = nil, + onScreen targetScreen: NSScreen? = nil ) { let resolvedDuration: TimeInterval switch type { @@ -360,6 +362,7 @@ class DynamicIslandViewCoordinator: ObservableObject { self.sneakPeek.subtitle = subtitle self.sneakPeek.accentColor = accentColor self.sneakPeek.styleOverride = styleOverride + self.sneakPeek.targetScreenName = targetScreen?.localizedName } } } diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift index 6b66b2b..88d9834 100644 --- a/DynamicIsland/managers/BetterDisplayManager.swift +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -224,7 +224,7 @@ final class BetterDisplayManager: ObservableObject { let category = classifyControlTarget(osd.controlTarget, systemIconID: osd.systemIconID) let normalizedValue = normalizeValue(osd.value, maxValue: osd.maxValue) let targetScreen = resolveScreen(for: osd.displayID) - let isExternalDisplay = isExternal(displayID: osd.displayID) + let isExternalDisplay = isExternal(displayID: osd.displayID, resolvedScreen: targetScreen) NSLog("📺 BetterDisplay OSD: target=\(osd.controlTarget ?? "nil") displayID=\(osd.displayID.map(String.init) ?? "nil") resolvedScreen=\(targetScreen?.localizedName ?? "nil") isExternal=\(isExternalDisplay) value=\(osd.value ?? -1)") @@ -266,7 +266,8 @@ final class BetterDisplayManager: ObservableObject { status: true, type: .volume, value: value, - icon: "" + icon: "", + onScreen: targetScreen ) } } @@ -289,7 +290,8 @@ final class BetterDisplayManager: ObservableObject { status: true, type: .brightness, value: value, - icon: icon + icon: icon, + onScreen: targetScreen ) } } @@ -302,28 +304,50 @@ final class BetterDisplayManager: ObservableObject { NSLog("📺 BetterDisplay resolveScreen: displayID is nil, falling back to all screens") return nil } + + if let directDisplayID = UInt32(exactly: displayID), + let matchedScreen = NSScreen.screens.first(where: { screenNumber(for: $0) == directDisplayID }) { + return matchedScreen + } + + // Fallback for payloads that use 1-based display indexes (1, 2, ...) + if displayID > 0 { + let index = displayID - 1 + if NSScreen.screens.indices.contains(index) { + let fallback = NSScreen.screens[index] + NSLog("📺 BetterDisplay resolveScreen: displayID=\(displayID) resolved via index fallback to '\(fallback.localizedName)'") + return fallback + } + } + let target = UInt32(displayID) let availableScreens = NSScreen.screens.map { screen -> (String, UInt32) in let num = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 return (screen.localizedName, num) } NSLog("📺 BetterDisplay resolveScreen: looking for displayID=\(displayID) (UInt32=\(target)) among screens: \(availableScreens)") - let matched = NSScreen.screens.first { screen in - guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { - return false - } - return screenNumber.uint32Value == target - } - if matched == nil { - NSLog("📺 BetterDisplay resolveScreen: no match found for displayID=\(displayID), HUD will show on all screens") - } - return matched + NSLog("📺 BetterDisplay resolveScreen: no match found for displayID=\(displayID), HUD will show on all screens") + return nil } /// Whether the given displayID refers to an external (non-built-in) display. - private func isExternal(displayID: Int?) -> Bool { - guard let displayID else { return false } - return CGDisplayIsBuiltin(UInt32(displayID)) == 0 + private func isExternal(displayID: Int?, resolvedScreen: NSScreen?) -> Bool { + if let resolvedScreen, + let resolvedDisplayID = screenNumber(for: resolvedScreen) { + return CGDisplayIsBuiltin(resolvedDisplayID) == 0 + } + guard let displayID, + let displayIDValue = UInt32(exactly: displayID) else { + return false + } + return CGDisplayIsBuiltin(displayIDValue) == 0 + } + + private func screenNumber(for screen: NSScreen) -> UInt32? { + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil + } + return screenNumber.uint32Value } private func classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory { diff --git a/DynamicIsland/managers/LunarManager.swift b/DynamicIsland/managers/LunarManager.swift index 2085fb0..5bd09ab 100644 --- a/DynamicIsland/managers/LunarManager.swift +++ b/DynamicIsland/managers/LunarManager.swift @@ -291,7 +291,7 @@ final class LunarManager: ObservableObject { private func routeToHUD(_ data: LunarDisplayData) { let targetScreen = resolveScreen(for: data.display) - let isExternal = isExternalDisplay(data.display) + let isExternal = isExternalDisplay(data.display, resolvedScreen: targetScreen) // Nits-only events are informational (no user-facing HUD needed). let hasActionableChange = data.brightness != nil || data.contrast != nil || data.volume != nil || data.mute != nil @@ -342,7 +342,8 @@ final class LunarManager: ObservableObject { status: true, type: .volume, value: value, - icon: "" + icon: "", + onScreen: targetScreen ) } } @@ -364,7 +365,8 @@ final class LunarManager: ObservableObject { status: true, type: .brightness, value: value, - icon: icon + icon: icon, + onScreen: targetScreen ) } } @@ -378,16 +380,42 @@ final class LunarManager: ObservableObject { /// Resolve a Lunar display ID (CGDirectDisplayID) to the matching NSScreen. private func resolveScreen(for displayID: Int) -> NSScreen? { - let target = UInt32(displayID) - return NSScreen.screens.first { screen in - guard let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { return false } - return num.uint32Value == target + if let directDisplayID = UInt32(exactly: displayID), + let matchedScreen = NSScreen.screens.first(where: { screenNumber(for: $0) == directDisplayID }) { + return matchedScreen } + + // Fallback for integrations that send 1-based display indexes (e.g. 1, 2) + if displayID > 0 { + let index = displayID - 1 + if NSScreen.screens.indices.contains(index) { + let fallback = NSScreen.screens[index] + NSLog("🌙 Lunar: display=\(displayID) resolved via index fallback to '\(fallback.localizedName)'") + return fallback + } + } + + NSLog("🌙 Lunar: unable to resolve display=\(displayID), HUD will show on all screens") + return nil } /// Whether the given displayID refers to an external (non-built-in) display. - private func isExternalDisplay(_ displayID: Int) -> Bool { - CGDisplayIsBuiltin(UInt32(displayID)) == 0 + private func isExternalDisplay(_ displayID: Int, resolvedScreen: NSScreen?) -> Bool { + if let resolvedScreen, + let resolvedDisplayID = screenNumber(for: resolvedScreen) { + return CGDisplayIsBuiltin(resolvedDisplayID) == 0 + } + if let displayIDValue = UInt32(exactly: displayID) { + return CGDisplayIsBuiltin(displayIDValue) == 0 + } + return false + } + + private func screenNumber(for screen: NSScreen) -> UInt32? { + guard let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil + } + return num.uint32Value } // MARK: - Reconnection