diff --git a/Ice/Bridging/Bridging.swift b/Ice/Bridging/Bridging.swift index d35d7a796..312c7c530 100644 --- a/Ice/Bridging/Bridging.swift +++ b/Ice/Bridging/Bridging.swift @@ -64,6 +64,18 @@ extension Bridging { } return rect } + + /// Marks a window as sticky across spaces. + /// + /// This uses the private WindowServer sticky tag expected by the overlay + /// panels, so it should stay narrowly scoped to those panels. + static func setStickyAcrossSpaces(_ windowID: CGWindowID) { + var tags: UInt64 = 1 << 11 + let result = CGSSetWindowTags(CGSMainConnectionID(), windowID, &tags, 1) + if result != .success { + Logger.bridging.error("CGSSetWindowTags failed with error \(result.logString)") + } + } } // MARK: Private Window List Helpers @@ -244,6 +256,42 @@ extension Bridging { let type = CGSSpaceGetType(CGSMainConnectionID(), spaceID) return type == .fullscreen } + + /// Returns a Boolean value that indicates whether the current space for + /// the display with the given stable identifier is a fullscreen space. + /// + /// This differs from ``activeSpaceID`` in multi-display setups, where each + /// display can show a different current space. + static func isCurrentSpaceFullscreen(forDisplayWithIdentifier displayIdentifier: String?) -> Bool? { + guard + let displayIdentifier, + let managedDisplays = CGSCopyManagedDisplaySpaces(CGSMainConnectionID())?.takeRetainedValue() as? [[String: Any]] + else { + return nil + } + guard + let managedDisplay = managedDisplays.first(where: { $0["Display Identifier"] as? String == displayIdentifier }), + let currentSpace = managedDisplay["Current Space"] as? [String: Any], + let rawType = currentSpace["type"].flatMap({ rawSpaceType(from: $0) }), + let type = CGSSpaceType(rawValue: rawType) + else { + return nil + } + return type == .fullscreen + } + + private static func rawSpaceType(from value: Any) -> UInt32? { + switch value { + case let value as UInt32: + return value + case let value as Int: + return UInt32(value) + case let value as NSNumber: + return value.uint32Value + default: + return nil + } + } } // MARK: - Process Responsivity diff --git a/Ice/Bridging/Shims/Private.swift b/Ice/Bridging/Shims/Private.swift index e03528adb..48dea8af7 100644 --- a/Ice/Bridging/Shims/Private.swift +++ b/Ice/Bridging/Shims/Private.swift @@ -72,6 +72,9 @@ func CGSCopySpacesForWindows( _ windowIDs: CFArray ) -> Unmanaged? +@_silgen_name("CGSCopyManagedDisplaySpaces") +func CGSCopyManagedDisplaySpaces(_ cid: CGSConnectionID) -> Unmanaged? + @_silgen_name("CGSSpaceGetType") func CGSSpaceGetType( _ cid: CGSConnectionID, @@ -127,3 +130,11 @@ func CGSGetScreenRectForWindow( _ wid: CGWindowID, _ outRect: inout CGRect ) -> CGError + +@_silgen_name("CGSSetWindowTags") +func CGSSetWindowTags( + _ cid: CGSConnectionID, + _ wid: CGWindowID, + _ tags: UnsafePointer, + _ tagCount: Int32 +) -> CGError diff --git a/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift b/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift index 9daaeeac6..f9a829cf9 100644 --- a/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift +++ b/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift @@ -3,6 +3,7 @@ // Ice // +import AppKit import CoreGraphics import Foundation @@ -13,6 +14,8 @@ struct MenuBarAppearanceConfigurationV2: Hashable { var shapeKind: MenuBarShapeKind var fullShapeInfo: MenuBarFullShapeInfo var splitShapeInfo: MenuBarSplitShapeInfo + var screenShapeInfo: ScreenShapeInfo + var screenShapeOverrides: [String: ScreenShapeInfo] var isInset: Bool var isDynamic: Bool @@ -45,11 +48,25 @@ extension MenuBarAppearanceConfigurationV2 { shapeKind: .none, fullShapeInfo: .default, splitShapeInfo: .default, + screenShapeInfo: .default, + screenShapeOverrides: [:], isInset: true, isDynamic: false ) } +// MARK: Screen shape helpers +extension MenuBarAppearanceConfigurationV2 { + /// Returns the screen shape settings for the given screen, falling back + /// to the default ``screenShapeInfo`` when there is no override. + func effectiveScreenShapeInfo(for screen: NSScreen) -> ScreenShapeInfo { + if let id = screen.stableIdentifier, let override = screenShapeOverrides[id] { + return override + } + return screenShapeInfo + } +} + extension MenuBarAppearanceConfigurationV2: Codable { private enum CodingKeys: CodingKey { case lightModeConfiguration @@ -58,6 +75,8 @@ extension MenuBarAppearanceConfigurationV2: Codable { case shapeKind case fullShapeInfo case splitShapeInfo + case screenShapeInfo + case screenShapeOverrides case isInset case isDynamic } @@ -71,6 +90,8 @@ extension MenuBarAppearanceConfigurationV2: Codable { shapeKind: container.decodeIfPresent(MenuBarShapeKind.self, forKey: .shapeKind) ?? Self.defaultConfiguration.shapeKind, fullShapeInfo: container.decodeIfPresent(MenuBarFullShapeInfo.self, forKey: .fullShapeInfo) ?? Self.defaultConfiguration.fullShapeInfo, splitShapeInfo: container.decodeIfPresent(MenuBarSplitShapeInfo.self, forKey: .splitShapeInfo) ?? Self.defaultConfiguration.splitShapeInfo, + screenShapeInfo: container.decodeIfPresent(ScreenShapeInfo.self, forKey: .screenShapeInfo) ?? Self.defaultConfiguration.screenShapeInfo, + screenShapeOverrides: container.decodeIfPresent([String: ScreenShapeInfo].self, forKey: .screenShapeOverrides) ?? Self.defaultConfiguration.screenShapeOverrides, isInset: container.decodeIfPresent(Bool.self, forKey: .isInset) ?? Self.defaultConfiguration.isInset, isDynamic: container.decodeIfPresent(Bool.self, forKey: .isDynamic) ?? Self.defaultConfiguration.isDynamic ) @@ -84,6 +105,8 @@ extension MenuBarAppearanceConfigurationV2: Codable { try container.encode(shapeKind, forKey: .shapeKind) try container.encode(fullShapeInfo, forKey: .fullShapeInfo) try container.encode(splitShapeInfo, forKey: .splitShapeInfo) + try container.encode(screenShapeInfo, forKey: .screenShapeInfo) + try container.encode(screenShapeOverrides, forKey: .screenShapeOverrides) try container.encode(isInset, forKey: .isInset) try container.encode(isDynamic, forKey: .isDynamic) } @@ -96,6 +119,7 @@ struct MenuBarAppearancePartialConfiguration: Hashable { var hasBorder: Bool var borderColor: CGColor var borderWidth: Double + var appearanceKind: MenuBarAppearanceKind var tintKind: MenuBarTintKind var tintColor: CGColor var tintGradient: CustomGradient @@ -108,8 +132,9 @@ extension MenuBarAppearancePartialConfiguration { hasBorder: false, borderColor: .black, borderWidth: 1, - tintKind: .none, - tintColor: .black, + appearanceKind: .none, + tintKind: .solid, + tintColor: CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1), tintGradient: .defaultMenuBarTint ) } @@ -124,6 +149,7 @@ extension MenuBarAppearancePartialConfiguration: Codable { case shapeKind case fullShapeInfo case splitShapeInfo + case appearanceKind case tintKind case tintColor case tintGradient @@ -131,12 +157,26 @@ extension MenuBarAppearancePartialConfiguration: Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + let tintKind = try container.decodeIfPresent(MenuBarTintKind.self, forKey: .tintKind) ?? Self.defaultConfiguration.tintKind + let appearanceKind: MenuBarAppearanceKind = { + if let stored = try? container.decodeIfPresent(MenuBarAppearanceKind.self, forKey: .appearanceKind) { + return stored + } + // Legacy data has no appearanceKind. Derive it: a stored tintKind of + // .none means the user had no color treatment; .solid/.gradient maps + // to the pre-existing overlay-style behavior, i.e. .tint. + return tintKind == .none ? .none : .tint + }() + // Normalize legacy .none tintKind to .solid since the picker no longer + // offers .none. The appearanceKind above carries the intent. + let resolvedTintKind: MenuBarTintKind = tintKind == .none ? .solid : tintKind try self.init( hasShadow: container.decodeIfPresent(Bool.self, forKey: .hasShadow) ?? Self.defaultConfiguration.hasShadow, hasBorder: container.decodeIfPresent(Bool.self, forKey: .hasBorder) ?? Self.defaultConfiguration.hasBorder, borderColor: container.decodeIfPresent(CodableColor.self, forKey: .borderColor)?.cgColor ?? Self.defaultConfiguration.borderColor, borderWidth: container.decodeIfPresent(Double.self, forKey: .borderWidth) ?? Self.defaultConfiguration.borderWidth, - tintKind: container.decodeIfPresent(MenuBarTintKind.self, forKey: .tintKind) ?? Self.defaultConfiguration.tintKind, + appearanceKind: appearanceKind, + tintKind: resolvedTintKind, tintColor: container.decodeIfPresent(CodableColor.self, forKey: .tintColor)?.cgColor ?? Self.defaultConfiguration.tintColor, tintGradient: container.decodeIfPresent(CustomGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient ) @@ -148,6 +188,7 @@ extension MenuBarAppearancePartialConfiguration: Codable { try container.encode(hasBorder, forKey: .hasBorder) try container.encode(CodableColor(cgColor: borderColor), forKey: .borderColor) try container.encode(borderWidth, forKey: .borderWidth) + try container.encode(appearanceKind, forKey: .appearanceKind) try container.encode(tintKind, forKey: .tintKind) try container.encode(CodableColor(cgColor: tintColor), forKey: .tintColor) try container.encode(tintGradient, forKey: .tintGradient) diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index e777c935a..94f07ad03 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -72,6 +72,10 @@ struct MenuBarAppearanceEditor: View { shapePicker isInset } + IceSection("Screen Shape") { + ScreenShapePicker() + .fixedSize(horizontal: false, vertical: true) + } if case .settings = location { IceGroupBox { AnnotationView( @@ -131,12 +135,23 @@ struct MenuBarAppearanceEditor: View { private struct UnlabeledPartialEditor: View { @Binding var configuration: MenuBarAppearancePartialConfiguration + @StateObject private var backgroundProbe = MenuBarBackgroundProbe() + + private static let isBackdropSupported: Bool = { + if #available(macOS 26.0, *) { true } else { false } + }() var body: some View { IceSection { - tintPicker + appearancePicker + if configuration.appearanceKind != .none { + colorPicker + } shadowToggle } + if configuration.appearanceKind == .backdrop { + backdropWarnings + } IceSection { borderToggle borderColor @@ -145,29 +160,63 @@ private struct UnlabeledPartialEditor: View { } @ViewBuilder - private var tintPicker: some View { - IceLabeledContent("Tint") { - HStack { - IcePicker("Tint", selection: $configuration.tintKind) { - ForEach(MenuBarTintKind.allCases) { tintKind in - Text(tintKind.localized).tag(tintKind) + private var appearancePicker: some View { + IceLabeledContent("Appearance") { + Menu { + ForEach(MenuBarAppearanceKind.allCases) { kind in + let isUnavailable = kind == .backdrop && !Self.isBackdropSupported + Button { + guard !isUnavailable else { return } + configuration.appearanceKind = kind + } label: { + if isUnavailable { + HStack(spacing: 6) { + Text(kind.localized) + Text("Requires macOS 26") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.tertiary, in: Capsule()) + } + } else { + Text(kind.localized) + } } + .disabled(isUnavailable) + } + } label: { + Text(configuration.appearanceKind.localized) + } + .menuStyle(.borderlessButton) + .fixedSize() + .frame(height: 24) + } + } + + @ViewBuilder + private var colorPicker: some View { + IceLabeledContent("Color") { + HStack { + IcePicker("Color", selection: $configuration.tintKind) { + Text(MenuBarTintKind.solid.localized).tag(MenuBarTintKind.solid) + Text(MenuBarTintKind.gradient.localized).tag(MenuBarTintKind.gradient) } .labelsHidden() + let allowsOpacity = configuration.appearanceKind == .backdrop switch configuration.tintKind { case .none: EmptyView() case .solid: CustomColorPicker( selection: $configuration.tintColor, - supportsOpacity: false, + supportsOpacity: allowsOpacity, mode: .crayon ) case .gradient: CustomGradientPicker( gradient: $configuration.tintGradient, - supportsOpacity: false, + supportsOpacity: allowsOpacity, allowsEmptySelections: false, mode: .crayon ) @@ -177,6 +226,67 @@ private struct UnlabeledPartialEditor: View { } } + @ViewBuilder + private var backdropWarnings: some View { + let blockers = activeBackdropBlockers + if !blockers.isEmpty { + IceGroupBox { + VStack(alignment: .leading, spacing: 12) { + ForEach(blockers) { blocker in + backdropWarningRow(blocker) + } + } + } + } + } + + private var activeBackdropBlockers: [BackdropBlocker] { + var blockers: [BackdropBlocker] = [] + if !Self.isBackdropSupported { + blockers.append(.requiresMacOS26) + } + if backgroundProbe.isOpaqueBackgroundEnabled { + blockers.append(.opaqueMenuBarBackground) + } + if backgroundProbe.isReduceTransparencyEnabled { + blockers.append(.reduceTransparency) + } + return blockers + } + + @ViewBuilder + private func backdropWarningRow(_ blocker: BackdropBlocker) -> some View { + Button { + if let url = blocker.settingsURL { + NSWorkspace.shared.open(url) + } + } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .imageScale(.large) + VStack(alignment: .leading, spacing: 4) { + Text(blocker.title) + .font(.callout.weight(.semibold)) + Text(blocker.body) + .font(.callout) + .foregroundStyle(.secondary) + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + if blocker.settingsURL != nil { + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.secondary) + .imageScale(.medium) + .padding(.top, 2) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(blocker.settingsURL == nil) + } + @ViewBuilder private var shadowToggle: some View { Toggle("Shadow", isOn: $configuration.hasShadow) @@ -215,6 +325,47 @@ private struct UnlabeledPartialEditor: View { } } +private enum BackdropBlocker: Hashable, Identifiable { + case requiresMacOS26 + case opaqueMenuBarBackground + case reduceTransparency + + var id: Self { self } + + var title: String { + switch self { + case .requiresMacOS26: + "Backdrop requires macOS 26" + case .opaqueMenuBarBackground: + "“Show menu bar background” must be off" + case .reduceTransparency: + "“Reduce transparency” must be off" + } + } + + var body: String { + switch self { + case .requiresMacOS26: + "The backdrop effect relies on the transparent menu bar introduced in macOS 26. Upgrade macOS or pick Tint instead." + case .opaqueMenuBarBackground: + "macOS is currently drawing an opaque menu bar background, which will hide the backdrop tint. Open System Settings → Menu Bar and turn off Show menu bar background." + case .reduceTransparency: + "Reduce transparency makes the menu bar opaque, which hides the backdrop tint. Open System Settings → Accessibility → Display and turn off Reduce transparency." + } + } + + var settingsURL: URL? { + switch self { + case .requiresMacOS26: + nil + case .opaqueMenuBarBackground: + URL(string: "x-apple.systempreferences:com.apple.ControlCenter-Settings.extension?MenuBar") + case .reduceTransparency: + URL(string: "x-apple.systempreferences:com.apple.Accessibility-Settings.extension?Display") + } + } +} + private struct LabeledPartialEditor: View { @EnvironmentObject var appearanceManager: MenuBarAppearanceManager @State private var currentAppearance = SystemAppearance.current diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/ScreenShapePicker.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/ScreenShapePicker.swift new file mode 100644 index 000000000..783298f87 --- /dev/null +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/ScreenShapePicker.swift @@ -0,0 +1,335 @@ +// +// ScreenShapePicker.swift +// Ice +// + +import SwiftUI + +struct ScreenShapePicker: View { + @EnvironmentObject var appearanceManager: MenuBarAppearanceManager + + @State private var selectedScreenIdentifier: String? + @State private var screens: [NSScreen] = NSScreen.screens + + private var selectedInfo: ScreenShapeInfo { + if let id = selectedScreenIdentifier, + let override = appearanceManager.configuration.screenShapeOverrides[id] { + return override + } + return appearanceManager.configuration.screenShapeInfo + } + + private var isUsingDefaults: Bool { + guard let id = selectedScreenIdentifier else { + return appearanceManager.configuration.screenShapeInfo == .default + } + guard let override = appearanceManager.configuration.screenShapeOverrides[id] else { + return true + } + return override == appearanceManager.configuration.screenShapeInfo + } + + private func setSelectedInfo(_ info: ScreenShapeInfo) { + var config = appearanceManager.configuration + if let id = selectedScreenIdentifier { + if info == config.screenShapeInfo { + config.screenShapeOverrides.removeValue(forKey: id) + } else { + config.screenShapeOverrides[id] = info + } + } else { + let oldDefaultInfo = config.screenShapeInfo + config.screenShapeInfo = info + removeRedundantScreenShapeOverrides( + from: &config, + matching: [oldDefaultInfo, info] + ) + } + appearanceManager.configuration = config + } + + private func removeRedundantScreenShapeOverrides( + from config: inout MenuBarAppearanceConfigurationV2, + matching defaultInfos: [ScreenShapeInfo] + ) { + let defaults = Set(defaultInfos) + config.screenShapeOverrides = config.screenShapeOverrides.filter { _, info in + !defaults.contains(info) + } + } + + private func bindingFor(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { selectedInfo[keyPath: keyPath] }, + set: { newValue in + var info = selectedInfo + info[keyPath: keyPath] = newValue + setSelectedInfo(info) + } + ) + } + + private var topCornersBinding: Binding { + Binding( + get: { selectedInfo.hasRoundedTopCorners }, + set: { newValue in + var info = selectedInfo + info.hasRoundedTopCorners = newValue + setSelectedInfo(info) + } + ) + } + + var body: some View { + HStack(alignment: .center, spacing: 16) { + togglesColumn + Spacer(minLength: 8) + preview + Spacer(minLength: 8) + settingsColumn + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in + screens = NSScreen.screens + if let id = selectedScreenIdentifier, + !screens.contains(where: { $0.stableIdentifier == id }) { + selectedScreenIdentifier = nil + } + } + .onAppear(perform: removeRedundantScreenShapeOverrides) + } + + // MARK: Toggles column + + @ViewBuilder + private var togglesColumn: some View { + VStack(alignment: .leading, spacing: 8) { + topPicker + bottomPicker + } + } + + @ViewBuilder + private var topPicker: some View { + Picker("Top Corners", selection: topCornersBinding) { + cornerIcon(rounded: false, edge: .top).tag(false) + cornerIcon(rounded: true, edge: .top).tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .help("Rounded screen top corners") + } + + @ViewBuilder + private var bottomPicker: some View { + Picker("Bottom Corners", selection: bindingFor(\.hasRoundedBottomCorners)) { + cornerIcon(rounded: false, edge: .bottom).tag(false) + cornerIcon(rounded: true, edge: .bottom).tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .help("Rounded screen bottom corners") + } + + @ViewBuilder + private func cornerIcon(rounded: Bool, edge: VerticalEdge) -> some View { + Image(size: CGSize(width: 16, height: 12)) { context in + let radius: CGFloat = rounded ? 5 : 0 + let shape: UnevenRoundedRectangle + switch edge { + case .top: + shape = UnevenRoundedRectangle( + topLeadingRadius: radius, + bottomLeadingRadius: 0, + bottomTrailingRadius: 0, + topTrailingRadius: radius, + style: .continuous + ) + case .bottom: + shape = UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: radius, + bottomTrailingRadius: radius, + topTrailingRadius: 0, + style: .continuous + ) + } + context.fill(shape.path(in: context.clipBoundingRect), with: .foreground) + } + .resizable() + } + + // MARK: Preview + + @ViewBuilder + private var preview: some View { + let scaled = min(selectedInfo.cornerRadius * 0.3, 22) + let topRadius: CGFloat = selectedInfo.hasRoundedTopCorners ? scaled : 0 + let bottomRadius: CGFloat = selectedInfo.hasRoundedBottomCorners ? scaled : 0 + UnevenRoundedRectangle( + topLeadingRadius: topRadius, + bottomLeadingRadius: bottomRadius, + bottomTrailingRadius: bottomRadius, + topTrailingRadius: topRadius, + style: .continuous + ) + .fill(.secondary) + .frame(width: 96, height: 60) + } + + // MARK: Settings column + + @ViewBuilder + private var settingsColumn: some View { + VStack(alignment: .leading, spacing: 10) { + monitorPicker + radiusSlider + fullscreenToggle + } + .frame(width: 240, alignment: .leading) + } + + @ViewBuilder + private var monitorPicker: some View { + IceLabeledContent("Monitor") { + HStack(spacing: 4) { + Menu { + Button { + selectedScreenIdentifier = nil + } label: { + Text("Default") + } + if !screens.isEmpty { + Divider() + ForEach(screens, id: \.displayID) { screen in + Button { + selectedScreenIdentifier = screen.stableIdentifier + } label: { + monitorMenuRow(for: screen) + } + } + } + } label: { + monitorSelectedLabel + } + .menuStyle(.borderlessButton) + .fixedSize() + .frame(height: 24) + + Button { + useDefaults() + } label: { + Image(systemName: "arrow.counterclockwise.circle.fill") + } + .buttonStyle(.borderless) + .help(isUsingDefaults ? "Using defaults" : "Reset these screen shape settings") + .disabled(isUsingDefaults) + } + .fixedSize() + } + } + + @ViewBuilder + private var monitorSelectedLabel: some View { + HStack(spacing: 6) { + Text(currentMonitorName) + .foregroundStyle(currentMonitorHasOverride ? .blue : .primary) + if currentMonitorHasOverride { + Circle() + .fill(.blue) + .frame(width: 6, height: 6) + } + } + } + + @ViewBuilder + private func monitorMenuRow(for screen: NSScreen) -> some View { + let hasOverride = screenHasOverride(screen) + HStack(spacing: 6) { + Image(systemName: "display") + .foregroundStyle(hasOverride ? .blue : .primary) + Text(screen.localizedName) + .foregroundStyle(hasOverride ? .blue : .primary) + if hasOverride { + Spacer(minLength: 8) + Circle() + .fill(.blue) + .frame(width: 6, height: 6) + } + } + } + + private var currentMonitorName: String { + if let id = selectedScreenIdentifier, + let screen = screens.first(where: { $0.stableIdentifier == id }) { + return screen.localizedName + } + return "Default" + } + + private var currentMonitorHasOverride: Bool { + guard let id = selectedScreenIdentifier else { + return false + } + guard let override = appearanceManager.configuration.screenShapeOverrides[id] else { + return false + } + return override != appearanceManager.configuration.screenShapeInfo + } + + private func screenHasOverride(_ screen: NSScreen) -> Bool { + guard let id = screen.stableIdentifier else { + return false + } + guard let override = appearanceManager.configuration.screenShapeOverrides[id] else { + return false + } + return override != appearanceManager.configuration.screenShapeInfo + } + + @ViewBuilder + private var radiusSlider: some View { + IceLabeledContent("Radius") { + IceSlider( + "\(Int(selectedInfo.cornerRadius))", + value: bindingFor(\.cornerRadius), + in: 3...35, + step: 1 + ) + .frame(minHeight: .compactSliderMinHeight) + } + } + + @ViewBuilder + private var fullscreenToggle: some View { + Toggle("Display for fullscreen apps", isOn: bindingFor(\.showInFullscreen)) + } + + private func useDefaults() { + var config = appearanceManager.configuration + if let id = selectedScreenIdentifier { + config.screenShapeOverrides.removeValue(forKey: id) + } else { + let oldDefaultInfo = config.screenShapeInfo + config.screenShapeInfo = .default + removeRedundantScreenShapeOverrides( + from: &config, + matching: [oldDefaultInfo, .default] + ) + } + appearanceManager.configuration = config + } + + private func removeRedundantScreenShapeOverrides() { + var config = appearanceManager.configuration + let oldOverrides = config.screenShapeOverrides + removeRedundantScreenShapeOverrides( + from: &config, + matching: [config.screenShapeInfo] + ) + if config.screenShapeOverrides != oldOverrides { + appearanceManager.configuration = config + } + } +} diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceKind.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceKind.swift new file mode 100644 index 000000000..9698db190 --- /dev/null +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceKind.swift @@ -0,0 +1,26 @@ +// +// MenuBarAppearanceKind.swift +// Ice +// + +import SwiftUI + +/// A type that specifies how the menu bar's color treatment is applied. +enum MenuBarAppearanceKind: Int, CaseIterable, Codable, Identifiable { + /// No color treatment is applied. + case none = 0 + /// Color is drawn as an overlay above status items, at a fixed 20% opacity. + case tint = 1 + /// Color is drawn beneath status items, with user-controllable opacity. + case backdrop = 2 + + var id: Int { rawValue } + + var localized: LocalizedStringKey { + switch self { + case .none: "None" + case .tint: "Tint" + case .backdrop: "Backdrop" + } + } +} diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift index 74cda2f4d..97088d8c3 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -30,6 +30,9 @@ final class MenuBarAppearanceManager: ObservableObject { /// The currently managed menu bar overlay panels. private(set) var overlayPanels = Set() + /// The currently managed screen bottom corner overlay panels. + private(set) var bottomCornerOverlayPanels = Set() + /// The amount to inset the menu bar if called for by the configuration. let menuBarInsetAmount: CGFloat = 5 @@ -67,10 +70,47 @@ final class MenuBarAppearanceManager: ObservableObject { return } while let panel = overlayPanels.popFirst() { - panel.orderOut(self) + panel.close() + } + while let panel = bottomCornerOverlayPanels.popFirst() { + panel.close() + } + configureOverlayPanels(with: configuration) + configureBottomCornerOverlayPanels(with: configuration) + } + .store(in: &c) + + NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) + .debounce(for: 0.1, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { + return } - if Set(overlayPanels.map { $0.owningScreen }) != Set(NSScreen.screens) { + if needsOverlayPanels(for: configuration) { configureOverlayPanels(with: configuration) + for panel in overlayPanels { + panel.needsShow = true + } + } + for panel in bottomCornerOverlayPanels { + panel.updateVisibility() + } + } + .store(in: &c) + + // Dynamic appearance flips `current.appearanceKind` without mutating + // the configuration value itself. Trigger a re-show so panels that + // depend on the current appearance (rounded top corners) reframe. + DistributedNotificationCenter.default() + .publisher(for: DistributedNotificationCenter.interfaceThemeChangedNotification) + .debounce(for: 0.15, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { + return + } + for panel in overlayPanels { + panel.needsShow = true } } .store(in: &c) @@ -93,14 +133,39 @@ final class MenuBarAppearanceManager: ObservableObject { guard let self else { return } - // The overlay panels may not have been configured yet. Since some of the - // properties on the manager might call for them, try to configure now. - if overlayPanels.isEmpty { + let needsMenuBar = needsOverlayPanels(for: configuration) + let hasMenuBar = !overlayPanels.isEmpty + if needsMenuBar && !hasMenuBar { + configureOverlayPanels(with: configuration) + } else if needsMenuBar && hasMenuBar { configureOverlayPanels(with: configuration) + // Settings that affect panel frame (e.g. rounded top corners) need a re-show. + for panel in overlayPanels { + panel.needsShow = true + } + } else if !needsMenuBar && hasMenuBar { + while let panel = overlayPanels.popFirst() { + panel.close() + } } + configureBottomCornerOverlayPanels(with: configuration) } .store(in: &c) + if let appState { + appState.$isActiveSpaceFullscreen + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { + return + } + for panel in bottomCornerOverlayPanels { + panel.updateVisibility() + } + } + .store(in: &c) + } + cancellables = c } @@ -117,9 +182,35 @@ final class MenuBarAppearanceManager: ObservableObject { if configuration.shapeKind != .none { return true } - if current.tintKind != .none { + if current.appearanceKind != .none { return true } + for screen in NSScreen.screens { + if hasActiveRoundedTopScreenCorners(for: screen, in: configuration) { + return true + } + } + return false + } + + /// Returns whether the configuration's effective settings for the given + /// screen meet all of the gating conditions required to render rounded + /// top screen corners. + func hasActiveRoundedTopScreenCorners(for screen: NSScreen, in configuration: MenuBarAppearanceConfigurationV2) -> Bool { + let info = configuration.effectiveScreenShapeInfo(for: screen) + return info.hasRoundedTopCorners + && configuration.shapeKind == .none + && configuration.current.appearanceKind != .none + } + + /// Returns a Boolean value that indicates whether a set of screen bottom + /// corner overlay panels is needed for the given configuration. + private func needsBottomCornerOverlayPanels(for configuration: MenuBarAppearanceConfigurationV2) -> Bool { + for screen in NSScreen.screens { + if configuration.effectiveScreenShapeInfo(for: screen).hasRoundedBottomCorners { + return true + } + } return false } @@ -135,14 +226,59 @@ final class MenuBarAppearanceManager: ObservableObject { return } - var overlayPanels = Set() + let existingScreens = Set(overlayPanels.map { $0.owningScreen }) + if existingScreens == Set(NSScreen.screens), overlayPanels.count == NSScreen.screens.count { + return + } + + while let panel = overlayPanels.popFirst() { + panel.close() + } + + var panels = Set() for screen in NSScreen.screens { - let panel = MenuBarOverlayPanel(appState: appState, owningScreen: screen) - overlayPanels.insert(panel) + let panel = MenuBarOverlayPanel( + appState: appState, + owningScreen: screen + ) panel.needsShow = true + panels.insert(panel) + } + overlayPanels = panels + } + + /// Configures the manager's screen bottom corner overlay panels, if + /// required by the given configuration. + private func configureBottomCornerOverlayPanels(with configuration: MenuBarAppearanceConfigurationV2) { + guard + let appState, + needsBottomCornerOverlayPanels(for: configuration) + else { + while let panel = bottomCornerOverlayPanels.popFirst() { + panel.close() + } + return + } + + let existingScreens = Set(bottomCornerOverlayPanels.map { $0.owningScreen }) + if existingScreens == Set(NSScreen.screens) { + for panel in bottomCornerOverlayPanels { + panel.show() + } + return } - self.overlayPanels = overlayPanels + while let panel = bottomCornerOverlayPanels.popFirst() { + panel.close() + } + + var panels = Set() + for screen in NSScreen.screens { + let panel = ScreenBottomCornerOverlayPanel(appState: appState, owningScreen: screen) + panels.insert(panel) + panel.show() + } + self.bottomCornerOverlayPanels = panels } /// Sets the value of ``MenuBarOverlayPanel/isDraggingMenuBarItem`` for each diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index 5b1776c28..7b1663106 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -9,6 +9,15 @@ import Combine // MARK: - Overlay Panel /// A subclass of `NSPanel` that sits atop the menu bar to alter its appearance. +/// +/// The panel is glued to its owning screen via `.stationary + .canJoinAllSpaces + +/// .fullScreenAuxiliary`, mirroring the system menu bar's own behavior. It is +/// never repositioned during Space/fullscreen slide animations; its visibility +/// and frame are recomputed only at steady-state moments (active Space change, +/// presentation-options change, fullscreen-state change, screen-parameters +/// change, config change). The menu bar height is cached on first use and on +/// screen-parameters change so drawing never reads transient WindowServer +/// geometry during a Space transition. final class MenuBarOverlayPanel: NSPanel { /// Flags representing the updatable components of a panel. enum UpdateFlag: String, CustomStringConvertible { @@ -18,10 +27,14 @@ final class MenuBarOverlayPanel: NSPanel { var description: String { rawValue } } - /// The kind of validation that occurs before an update. - private enum ValidationKind { - case showing - case updates + /// The visual mode the panel is currently rendering in. + enum RenderMode: Equatable { + /// Full menu-bar-height backdrop with optional rounded top corners + /// drawn just below the menu bar. + case backdrop + /// Only the rounded top-corner wedges, drawn flush with the top edge + /// of the screen. Used when the system menu bar is hidden. + case topCornersOnly } /// A context that manages panel update tasks. @@ -57,6 +70,10 @@ final class MenuBarOverlayPanel: NSPanel { /// A Boolean value that indicates whether the user is dragging a menu bar item. @Published var isDraggingMenuBarItem = false + /// The current visual mode the panel is rendering in. Published so the + /// content view can request a redraw when it flips. + @Published private(set) var renderMode: RenderMode = .backdrop + /// Flags representing the components of the panel currently in need of an update. @Published private(set) var updateFlags = Set() @@ -72,6 +89,12 @@ final class MenuBarOverlayPanel: NSPanel { /// The context that manages panel update tasks. private let updateTaskContext = UpdateTaskContext() + /// Cached menu bar height for this panel's screen. Captured once at init + /// and refreshed on screen-parameters changes so the visibility/frame + /// logic never has to read live WindowServer geometry during a Space + /// transition. The content view reads this for drawing. + fileprivate var cachedMenuBarHeight: CGFloat + /// The shared app state. private(set) weak var appState: AppState? @@ -82,6 +105,7 @@ final class MenuBarOverlayPanel: NSPanel { init(appState: AppState, owningScreen: NSScreen) { self.appState = appState self.owningScreen = owningScreen + self.cachedMenuBarHeight = owningScreen.getMenuBarHeight() ?? NSStatusBar.system.thickness super.init( contentRect: .zero, styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], @@ -93,20 +117,42 @@ final class MenuBarOverlayPanel: NSPanel { self.backgroundColor = .clear self.hasShadow = false self.ignoresMouseEvents = true - self.collectionBehavior = [.fullScreenNone, .ignoresCycle, .moveToActiveSpace] + // `.stationary` glues the panel to this physical screen position + // through Space/fullscreen slide animations, so a swipe between Spaces + // never tears the overlay off the screen edge mid-animation. The + // sibling `ScreenBottomCornerOverlayPanel` uses the same set. + self.collectionBehavior = [.ignoresCycle, .canJoinAllSpaces, .stationary, .fullScreenAuxiliary] self.contentView = MenuBarOverlayPanelContentView() + updateLevel(for: appState.appearanceManager.configuration.current.appearanceKind) configureCancellables() } + /// Sets the panel's window level based on the appearance kind. Backdrop + /// mode sits one below `.statusBar` so status items render on top. + fileprivate func updateLevel(for kind: MenuBarAppearanceKind) { + let target: NSWindow.Level = switch kind { + case .backdrop: NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1) + case .none, .tint: .statusBar + } + if self.level != target { + self.level = target + } + } + private func configureCancellables() { var c = Set() - // Show the panel on the active space. + // The panel joins every Space. On a space switch, refresh visibility + // for the new steady state and re-arm per-space content updates. NSWorkspace.shared.notificationCenter .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .debounce(for: 0.1, scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.needsShow = true + guard let self, self.updateVisibility() else { + return + } + self.insertUpdateFlag(.applicationMenuFrame) + self.insertUpdateFlag(.desktopWallpaper) } .store(in: &c) @@ -141,12 +187,13 @@ final class MenuBarOverlayPanel: NSPanel { .sink { [weak self] _ in guard let self, - let appState + let appState, + self.updateVisibility() else { return } - let displayID = owningScreen.displayID - updateTaskContext.setTask(for: .applicationMenuFrame, timeout: .seconds(10)) { + let displayID = self.owningScreen.displayID + self.updateTaskContext.setTask(for: .applicationMenuFrame, timeout: .seconds(10)) { var hasDoneInitialUpdate = false while true { try Task.checkCancellation() @@ -174,6 +221,23 @@ final class MenuBarOverlayPanel: NSPanel { } .store(in: &c) + // Re-promote the panel whenever this app's own activation changes. The + // "Hide application menus" feature flips Ice's activation policy + // (.accessory ↔ .regular), and WindowServer can briefly reshuffle the + // order of windows at our level during the transition. + Publishers.Merge( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), + NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification) + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.updateVisibility() else { + return + } + self.orderForCurrentLevel() + } + .store(in: &c) + // Special cases for when the user drags an app onto or clicks into another space. Publishers.Merge( publisher(for: \.isOnActiveSpace) @@ -185,10 +249,27 @@ final class MenuBarOverlayPanel: NSPanel { ) .debounce(for: 0.05, scheduler: DispatchQueue.main) .sink { [weak self] in - self?.insertUpdateFlag(.applicationMenuFrame) + guard let self, self.updateVisibility() else { + return + } + self.insertUpdateFlag(.applicationMenuFrame) } .store(in: &c) + NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification) + .debounce(for: 0.1, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { + return + } + if let height = self.owningScreen.getMenuBarHeight(), height > 0 { + self.cachedMenuBarHeight = height + } + self.updateVisibility() + } + .store(in: &c) + // Continually update the desktop wallpaper. Ideally, we would set up an observer // for a wallpaper change notification, but macOS doesn't post one anymore. Timer.publish(every: 5, on: .main, in: .default) @@ -214,7 +295,7 @@ final class MenuBarOverlayPanel: NSPanel { defer { self.needsShow = false } - show() + updateVisibility() } .store(in: &c) @@ -228,7 +309,7 @@ final class MenuBarOverlayPanel: NSPanel { self.updateFlags.removeAll() } let windows = WindowInfo.getOnScreenWindows() - guard let owningDisplay = self.validate(for: .updates, with: windows) else { + guard let owningDisplay = self.validate(with: windows) else { return } performUpdates(for: flags, windows: windows, display: owningDisplay) @@ -236,11 +317,51 @@ final class MenuBarOverlayPanel: NSPanel { .store(in: &c) if let appState { + // This combines the steady menu-bar policy with transient system + // presentation options such as Mission Control/Expose. appState.menuBarManager.$isMenuBarHiddenBySystem - .sink { [weak self] isHidden in - self?.alphaValue = isHidden ? 0 : 1 + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateVisibility() } .store(in: &c) + + // During Mission Control/Expose, macOS hides the system menu bar + // even on desktop spaces where it normally remains visible. Treat + // that as hidden for overlay layout, then restore when it settles. + appState.menuBarManager.$isMenuBarHiddenBySystemPresentationOptions + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateVisibility() + } + .store(in: &c) + + // Re-evaluate when the active space flips between desktop and + // fullscreen. This only fires once per transition, at the end. + appState.$isActiveSpaceFullscreen + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateVisibility() + } + .store(in: &c) + + // Keep the window level in sync with the current appearance kind. + appState.appearanceManager.$configuration + .map { $0.current.appearanceKind } + .removeDuplicates() + .sink { [weak self] kind in + self?.updateLevel(for: kind) + } + .store(in: &c) + + for section in appState.menuBarManager.sections { + section.controlItem.$windowFrame + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.insertUpdateFlag(.applicationMenuFrame) + } + .store(in: &c) + } } cancellables = c @@ -251,28 +372,145 @@ final class MenuBarOverlayPanel: NSPanel { updateFlags.insert(flag) } - /// Performs validation for the given validation kind. Returns the panel's - /// owning display if successful. Returns `nil` on failure. - private func validate(for kind: ValidationKind, with windows: [WindowInfo]) -> CGDirectDisplayID? { - lazy var actionMessage = switch kind { - case .showing: "Preventing overlay panel from showing." - case .updates: "Preventing overlay panel from updating." + /// Re-evaluates the panel's visibility and frame from steady-state inputs + /// only: the active-space menu-bar policy, fullscreen state, and + /// configuration. This never queries live WindowServer geometry, so it is + /// safe to call during the brief window after + /// `activeSpaceDidChangeNotification` where the menu bar window has not + /// yet finished settling. + /// + /// Returns `true` if the panel is visible after the update. + @discardableResult + private func updateVisibility() -> Bool { + guard + let appState, + !appState.isPreview, + appState.appearanceManager.overlayPanels.contains(self) + else { + return false } - guard let appState else { - Logger.overlayPanel.debug("No app state. \(actionMessage)") - return nil + + let isMenuBarHidden = appState.menuBarManager.isMenuBarHiddenBySystem(on: owningScreen) + let isFullscreen = appState.menuBarManager.isSpaceFullscreen(on: owningScreen) + let info = appState.appearanceManager.configuration.effectiveScreenShapeInfo(for: owningScreen) + let cornerRadius = hasActiveRoundedTopScreenCorners() ? info.cornerRadius : 0 + + // Notched displays cannot use corners-only mode. When the system menu + // bar is hidden in this screen's current space, order the panel out so + // it cannot reappear above the status items during a menu-bar peek. + if owningScreen.hasNotch { + renderMode = .backdrop + if isMenuBarHidden { + alphaValue = 0 + updateFlags.removeAll() + orderOut(nil) + return false + } + applyBackdropFrame(cornerExtension: cornerRadius) + orderForCurrentLevel() + alphaValue = 1 + updateFlags = [.applicationMenuFrame, .desktopWallpaper] + return true + } + + // Non-notched displays switch between backdrop and corners-only + // depending on the steady-state menu-bar visibility for this screen's + // current space. Corners-only is only drawn when rounded top corners are + // configured and the fullscreen setting allows it. + let canShowFullscreenCorners = !isFullscreen || info.showInFullscreen + let shouldShowCornersOnly = isMenuBarHidden && cornerRadius > 0 && canShowFullscreenCorners + + if isMenuBarHidden { + if shouldShowCornersOnly { + renderMode = .topCornersOnly + applyCornersOnlyFrame(cornerExtension: cornerRadius) + orderFrontRegardless() + alphaValue = 1 + updateFlags.removeAll() + contentView?.needsDisplay = true + return true + } + renderMode = .backdrop + orderOut(nil) + return false + } + + renderMode = .backdrop + applyBackdropFrame(cornerExtension: cornerRadius) + orderForCurrentLevel() + alphaValue = 1 + updateFlags = [.applicationMenuFrame, .desktopWallpaper] + return true + } + + /// Orders the panel appropriately for its current level. Backdrop panels + /// sit below the system menu bar window so status items stay on top even + /// after Mission Control/Expose reshuffles WindowServer ordering. + private func orderForCurrentLevel() { + guard + level.rawValue < NSWindow.Level.statusBar.rawValue, + let menuBarWindow = WindowInfo.getMenuBarWindow(for: owningScreen.displayID) + else { + orderFrontRegardless() + return } - guard !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults else { - Logger.overlayPanel.debug("Menu bar is hidden by system. \(actionMessage)") + order(.below, relativeTo: Int(menuBarWindow.windowID)) + } + + /// Positions the panel as a full-height backdrop covering the menu bar + /// with an optional rounded-corner extension below it. + private func applyBackdropFrame(cornerExtension: CGFloat) { + let menuBarHeight = cachedMenuBarHeight + let newFrame = CGRect( + x: owningScreen.frame.minX, + y: (owningScreen.frame.maxY - menuBarHeight) - 5 - cornerExtension, + width: owningScreen.frame.width, + height: menuBarHeight + 5 + cornerExtension + ) + if frame != newFrame { + setFrame(newFrame, display: false) + } + } + + /// Positions the panel as just the rounded top-corner wedges sitting flush + /// against the screen's top edge. + private func applyCornersOnlyFrame(cornerExtension: CGFloat) { + let newFrame = CGRect( + x: owningScreen.frame.minX, + y: owningScreen.frame.maxY - cornerExtension, + width: owningScreen.frame.width, + height: cornerExtension + ) + if frame != newFrame { + setFrame(newFrame, display: false) + } + } + + /// Returns whether rounded top screen corners are active for this panel's + /// screen and current configuration. + private func hasActiveRoundedTopScreenCorners() -> Bool { + guard let appearanceManager = appState?.appearanceManager else { + return false + } + return appearanceManager.hasActiveRoundedTopScreenCorners( + for: owningScreen, + in: appearanceManager.configuration + ) + } + + /// Validates the panel for an update pass. Returns the owning display ID + /// when it is safe to refresh application-menu/wallpaper state, otherwise + /// `nil`. Updates only happen in backdrop mode; corners-only mode does not + /// depend on the menu bar window being valid. + private func validate(with windows: [WindowInfo]) -> CGDirectDisplayID? { + guard let appState else { return nil } - guard !appState.isActiveSpaceFullscreen else { - Logger.overlayPanel.debug("Active space is fullscreen. \(actionMessage)") + guard renderMode == .backdrop else { return nil } let owningDisplay = owningScreen.displayID guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningDisplay) else { - Logger.overlayPanel.debug("No valid menu bar found. \(actionMessage)") return nil } return owningDisplay @@ -280,10 +518,7 @@ final class MenuBarOverlayPanel: NSPanel { /// Stores the frame of the menu bar's application menu. private func updateApplicationMenuFrame(for display: CGDirectDisplayID) { - guard - let menuBarManager = appState?.menuBarManager, - !menuBarManager.isMenuBarHiddenBySystem - else { + guard let menuBarManager = appState?.menuBarManager else { return } applicationMenuFrame = menuBarManager.getApplicationMenuFrame(for: display) @@ -314,42 +549,6 @@ final class MenuBarOverlayPanel: NSPanel { } } - /// Shows the panel. - private func show() { - guard - let appState, - !appState.isPreview - else { - return - } - - guard appState.appearanceManager.overlayPanels.contains(self) else { - Logger.overlayPanel.warning("Overlay panel \(self) not retained") - return - } - - guard let menuBarHeight = owningScreen.getMenuBarHeight() else { - return - } - - let newFrame = CGRect( - x: owningScreen.frame.minX, - y: (owningScreen.frame.maxY - menuBarHeight) - 5, - width: owningScreen.frame.width, - height: menuBarHeight + 5 - ) - - alphaValue = 0 - setFrame(newFrame, display: false) - orderFrontRegardless() - - updateFlags = [.applicationMenuFrame, .desktopWallpaper] - - if !appState.menuBarManager.isMenuBarHiddenBySystem { - animator().alphaValue = 1 - } - } - override func isAccessibilityElement() -> Bool { return false } @@ -445,6 +644,13 @@ private final class MenuBarOverlayPanelContentView: NSView { self?.needsDisplay = true } .store(in: &c) + // Redraw when the panel switches between backdrop and corners-only modes. + overlayPanel.$renderMode + .removeDuplicates() + .sink { [weak self] _ in + self?.needsDisplay = true + } + .store(in: &c) } // Redraw whenever the configurations change. @@ -616,29 +822,129 @@ private final class MenuBarOverlayPanelContentView: NSView { } } - /// Returns the bounds that the view's drawn content can occupy. + /// Returns the bounds that the view's drawn content can occupy. This is + /// always exactly the menu bar's rectangle at the top of the panel, + /// regardless of any extra extension the panel has reserved for wedges. private func getDrawableBounds() -> CGRect { + let menuBarHeight = currentMenuBarHeight() return CGRect( x: bounds.origin.x, - y: bounds.origin.y + 5, + y: bounds.maxY - menuBarHeight, width: bounds.width, - height: bounds.height - 5 + height: menuBarHeight ) } + /// Returns the corner-wedge extension currently reserved by the panel's + /// frame, derived from frame geometry so it stays in sync without polling. + /// In corners-only mode the entire panel height is the wedge extension. + /// In backdrop mode the wedge extension is what remains below the menu + /// bar and the 5pt shadow strip. + private func currentCornerExtension() -> CGFloat { + guard let overlayPanel else { + return 0 + } + switch overlayPanel.renderMode { + case .topCornersOnly: + return bounds.height + case .backdrop: + return max(0, bounds.height - currentMenuBarHeight() - 5) + } + } + + /// Returns the menu bar height appropriate for the panel's current frame. + /// Zero in corners-only mode (the menu bar itself is hidden in that + /// state); otherwise the panel's cached menu bar height for its screen. + private func currentMenuBarHeight() -> CGFloat { + guard let overlayPanel else { + return 0 + } + if overlayPanel.renderMode == .topCornersOnly { + return 0 + } + return overlayPanel.cachedMenuBarHeight + } + + /// Returns whether rounded top screen corners should be drawn for the + /// current configuration on the panel's owning screen. + private func shouldDrawTopScreenCorners() -> Bool { + guard + let appearanceManager = overlayPanel?.appState?.appearanceManager, + let screen = overlayPanel?.owningScreen + else { + return false + } + return appearanceManager.hasActiveRoundedTopScreenCorners(for: screen, in: fullConfiguration) + } + + /// Returns a path containing the menu bar rect unioned with the left and + /// right top-corner wedges that sit just below it. + private func pathForMenuBarWithTopScreenCorners(menuBarRect: CGRect, radius: CGFloat) -> NSBezierPath { + let leftWedge = NSBezierPath() + leftWedge.move(to: CGPoint(x: menuBarRect.minX, y: menuBarRect.minY)) + leftWedge.line(to: CGPoint(x: menuBarRect.minX, y: menuBarRect.minY - radius)) + leftWedge.appendArc( + withCenter: CGPoint(x: menuBarRect.minX + radius, y: menuBarRect.minY - radius), + radius: radius, + startAngle: 180, + endAngle: 90, + clockwise: true + ) + leftWedge.close() + + let rightWedge = NSBezierPath() + rightWedge.move(to: CGPoint(x: menuBarRect.maxX, y: menuBarRect.minY)) + rightWedge.line(to: CGPoint(x: menuBarRect.maxX, y: menuBarRect.minY - radius)) + rightWedge.appendArc( + withCenter: CGPoint(x: menuBarRect.maxX - radius, y: menuBarRect.minY - radius), + radius: radius, + startAngle: 0, + endAngle: 90, + clockwise: false + ) + rightWedge.close() + + return NSBezierPath(rect: menuBarRect) + .union(leftWedge) + .union(rightWedge) + } + /// Draws the tint defined by the given configuration in the given rectangle. private func drawTint(in rect: CGRect) { - switch configuration.tintKind { + switch configuration.appearanceKind { case .none: - break - case .solid: - if let tintColor = NSColor(cgColor: configuration.tintColor)?.withAlphaComponent(0.2) { - tintColor.setFill() - rect.fill() + return + case .tint: + // Tint mode renders above status items at a fixed 20% opacity so the + // icons stay legible. + switch configuration.tintKind { + case .none: + break + case .solid: + if let tintColor = NSColor(cgColor: configuration.tintColor)?.withAlphaComponent(0.2) { + tintColor.setFill() + rect.fill() + } + case .gradient: + if let tintGradient = configuration.tintGradient.withAlphaComponent(0.2).nsGradient { + tintGradient.draw(in: rect, angle: 0) + } } - case .gradient: - if let tintGradient = configuration.tintGradient.withAlphaComponent(0.2).nsGradient { - tintGradient.draw(in: rect, angle: 0) + case .backdrop: + // Backdrop mode renders beneath status items, so we use the color's + // stored alpha verbatim — user-controllable, defaulting to opaque. + switch configuration.tintKind { + case .none: + break + case .solid: + if let tintColor = NSColor(cgColor: configuration.tintColor) { + tintColor.setFill() + rect.fill() + } + case .gradient: + if let tintGradient = configuration.tintGradient.nsGradient { + tintGradient.draw(in: rect, angle: 0) + } } } } @@ -653,6 +959,28 @@ private final class MenuBarOverlayPanelContentView: NSView { let drawableBounds = getDrawableBounds() + if overlayPanel.renderMode == .topCornersOnly { + let cornerExt = currentCornerExtension() + let drawCorners = shouldDrawTopScreenCorners() && cornerExt > 0 + guard drawCorners else { + return + } + let combined = pathForMenuBarWithTopScreenCorners( + menuBarRect: CGRect( + x: bounds.minX, + y: bounds.maxY, + width: bounds.width, + height: 0 + ), + radius: cornerExt + ) + context.saveGraphicsState() + combined.setClip() + drawTint(in: bounds) + context.restoreGraphicsState() + return + } + let shapePath = switch fullConfiguration.shapeKind { case .none: NSBezierPath(rect: drawableBounds) @@ -676,6 +1004,9 @@ private final class MenuBarOverlayPanelContentView: NSView { switch fullConfiguration.shapeKind { case .none: + let cornerExt = currentCornerExtension() + let drawCorners = shouldDrawTopScreenCorners() && cornerExt > 0 + if configuration.hasShadow { let gradient = NSGradient( colors: [ @@ -692,12 +1023,23 @@ private final class MenuBarOverlayPanelContentView: NSView { gradient?.draw(in: shadowBounds, angle: 90) } - drawTint(in: drawableBounds) + if drawCorners { + let combined = pathForMenuBarWithTopScreenCorners( + menuBarRect: drawableBounds, + radius: cornerExt + ) + context.saveGraphicsState() + combined.setClip() + drawTint(in: combined.bounds) + context.restoreGraphicsState() + } else { + drawTint(in: drawableBounds) + } if configuration.hasBorder { let borderBounds = CGRect( x: bounds.minX, - y: bounds.minY + 5, + y: drawableBounds.minY, width: bounds.width, height: configuration.borderWidth ) diff --git a/Ice/MenuBar/Appearance/MenuBarShape.swift b/Ice/MenuBar/Appearance/MenuBarShape.swift index b80355149..e4026a98f 100644 --- a/Ice/MenuBar/Appearance/MenuBarShape.swift +++ b/Ice/MenuBar/Appearance/MenuBarShape.swift @@ -61,3 +61,51 @@ extension MenuBarSplitShapeInfo { extension MenuBarSplitShapeInfo { static let `default` = MenuBarSplitShapeInfo(leading: .default, trailing: .default) } + +/// Information for the rounded screen corner overlays drawn around the menu bar. +struct ScreenShapeInfo: Hashable { + /// Whether to render rounded top corners for the screen. + var hasRoundedTopCorners: Bool + /// Whether to render rounded bottom corners for the screen. + var hasRoundedBottomCorners: Bool + /// The radius applied to the rounded screen corners. + var cornerRadius: CGFloat + /// Whether the overlays remain visible during fullscreen apps. + var showInFullscreen: Bool +} + +extension ScreenShapeInfo { + static let `default` = ScreenShapeInfo( + hasRoundedTopCorners: false, + hasRoundedBottomCorners: false, + cornerRadius: 21, + showInFullscreen: true + ) +} + +extension ScreenShapeInfo: Codable { + private enum CodingKeys: CodingKey { + case hasRoundedTopCorners + case hasRoundedBottomCorners + case cornerRadius + case showInFullscreen + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + hasRoundedTopCorners: container.decodeIfPresent(Bool.self, forKey: .hasRoundedTopCorners) ?? Self.default.hasRoundedTopCorners, + hasRoundedBottomCorners: container.decodeIfPresent(Bool.self, forKey: .hasRoundedBottomCorners) ?? Self.default.hasRoundedBottomCorners, + cornerRadius: container.decodeIfPresent(CGFloat.self, forKey: .cornerRadius) ?? Self.default.cornerRadius, + showInFullscreen: container.decodeIfPresent(Bool.self, forKey: .showInFullscreen) ?? Self.default.showInFullscreen + ) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hasRoundedTopCorners, forKey: .hasRoundedTopCorners) + try container.encode(hasRoundedBottomCorners, forKey: .hasRoundedBottomCorners) + try container.encode(cornerRadius, forKey: .cornerRadius) + try container.encode(showInFullscreen, forKey: .showInFullscreen) + } +} diff --git a/Ice/MenuBar/Appearance/ScreenBottomCornerOverlayPanel.swift b/Ice/MenuBar/Appearance/ScreenBottomCornerOverlayPanel.swift new file mode 100644 index 000000000..be47d1a25 --- /dev/null +++ b/Ice/MenuBar/Appearance/ScreenBottomCornerOverlayPanel.swift @@ -0,0 +1,134 @@ +// +// ScreenBottomCornerOverlayPanel.swift +// Ice +// + +import Cocoa + +/// A panel that draws solid-black wedges at the bottom-left and bottom-right +/// of its owning screen, simulating rounded screen bottom corners. +final class ScreenBottomCornerOverlayPanel: NSPanel { + private(set) weak var appState: AppState? + + /// The screen that owns the panel. + let owningScreen: NSScreen + + init(appState: AppState, owningScreen: NSScreen) { + self.appState = appState + self.owningScreen = owningScreen + super.init( + contentRect: .zero, + styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + self.level = .statusBar + self.title = "Screen Bottom Corner Overlay" + self.backgroundColor = .clear + self.hasShadow = false + self.ignoresMouseEvents = true + // `.canJoinAllSpaces` puts the panel on every desktop space, and + // `.fullScreenAuxiliary` allows it on fullscreen-app spaces too; + // `updateVisibility()` then orders the panel out when the per-screen + // settings say it should be hidden in the current space. + self.collectionBehavior = [.ignoresCycle, .canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + self.contentView = ScreenBottomCornerOverlayPanelContentView() + } + + /// Positions the panel along the bottom edge of the owning screen and + /// orders it to the front (if visibility conditions allow). + func show() { + guard + let appState, + !appState.isPreview + else { + return + } + + let info = appState.appearanceManager.configuration.effectiveScreenShapeInfo(for: owningScreen) + let radius = info.cornerRadius + let newFrame = CGRect( + x: owningScreen.frame.minX, + y: owningScreen.frame.minY, + width: owningScreen.frame.width, + height: radius + ) + setFrame(newFrame, display: false) + contentView?.needsDisplay = true + updateVisibility() + } + + /// Brings the panel to the front when its settings allow it to be + /// visible, or orders it out otherwise. + func updateVisibility() { + guard let appState else { + return + } + let info = appState.appearanceManager.configuration.effectiveScreenShapeInfo(for: owningScreen) + let isFullscreen = appState.menuBarManager.isSpaceFullscreen(on: owningScreen) + let shouldShow = info.hasRoundedBottomCorners + && (info.showInFullscreen || !isFullscreen) + if shouldShow { + orderFrontRegardless() + } else { + orderOut(nil) + } + } + + override func isAccessibilityElement() -> Bool { + return false + } +} + +// MARK: - Content View + +private final class ScreenBottomCornerOverlayPanelContentView: NSView { + private var overlayPanel: ScreenBottomCornerOverlayPanel? { + window as? ScreenBottomCornerOverlayPanel + } + + override func draw(_ dirtyRect: NSRect) { + guard + let overlayPanel, + let appState = overlayPanel.appState + else { + return + } + + let info = appState.appearanceManager.configuration.effectiveScreenShapeInfo(for: overlayPanel.owningScreen) + let radius = info.cornerRadius + guard radius > 0 else { + return + } + let width = bounds.width + + let leftPath = NSBezierPath() + leftPath.move(to: CGPoint(x: 0, y: 0)) + leftPath.line(to: CGPoint(x: radius, y: 0)) + leftPath.appendArc( + withCenter: CGPoint(x: radius, y: radius), + radius: radius, + startAngle: 270, + endAngle: 180, + clockwise: true + ) + leftPath.close() + + let rightPath = NSBezierPath() + rightPath.move(to: CGPoint(x: width, y: radius)) + rightPath.line(to: CGPoint(x: width, y: 0)) + rightPath.line(to: CGPoint(x: width - radius, y: 0)) + rightPath.appendArc( + withCenter: CGPoint(x: width - radius, y: radius), + radius: radius, + startAngle: 270, + endAngle: 0, + clockwise: false + ) + rightPath.close() + + NSColor.black.setFill() + leftPath.fill() + rightPath.fill() + } +} diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index d35f356c9..3fcd8f649 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -66,7 +66,7 @@ final class ControlItem { guard let window else { return nil } - return CGWindowID(window.windowNumber) + return CGWindowID(exactly: window.windowNumber) } /// A Boolean value that indicates whether the control item serves as diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 5feed0ac5..94f49f5e5 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -13,15 +13,20 @@ final class MenuBarManager: ObservableObject { /// Information for the menu bar's average color. @Published private(set) var averageColorInfo: MenuBarAverageColorInfo? - /// A Boolean value that indicates whether the menu bar is either always hidden - /// by the system, or automatically hidden and shown by the system based on the - /// location of the mouse. + /// A Boolean value that indicates whether the menu bar should be treated + /// as hidden by the system in the active space. This is derived from the + /// user's menu bar auto-hide settings, transient system presentation + /// options, and the active space type. @Published private(set) var isMenuBarHiddenBySystem = false /// A Boolean value that indicates whether the menu bar is hidden by the system - /// according to a value stored in UserDefaults. + /// on desktop spaces according to a value stored in UserDefaults. @Published private(set) var isMenuBarHiddenBySystemUserDefaults = false + /// A Boolean value that indicates whether system presentation options are + /// currently hiding the menu bar, such as during Mission Control/Expose. + @Published private(set) var isMenuBarHiddenBySystemPresentationOptions = false + /// The shared app state. private weak var appState: AppState? @@ -40,6 +45,13 @@ final class MenuBarManager: ObservableObject { /// The panel that contains the menu bar search interface. let searchPanel: MenuBarSearchPanel + /// Global defaults keys used by System Settings' "Automatically hide and + /// show the menu bar" setting. + private enum SystemMenuBarDefaultsKey { + static let autoHideOnDesktop = "_HIHideMenuBar" + static let visibleInFullscreen = "AppleMenuBarVisibleInFullscreen" + } + /// A Boolean value that indicates whether the manager can update its stored /// information for the menu bar's average color. private var canUpdateAverageColorInfo: Bool { @@ -84,14 +96,27 @@ final class MenuBarManager: ObservableObject { private func configureCancellables() { var c = Set() + refreshSystemMenuBarHiddenState() + refreshSystemPresentationOptions(NSApp.currentSystemPresentationOptions) + NSApp.publisher(for: \.currentSystemPresentationOptions) .receive(on: DispatchQueue.main) .sink { [weak self] options in - guard let self else { - return - } - let hidden = options.contains(.hideMenuBar) || options.contains(.autoHideMenuBar) - isMenuBarHiddenBySystem = hidden + self?.refreshSystemPresentationOptions(options) + } + .store(in: &c) + + appState?.$isActiveSpaceFullscreen + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSystemMenuBarHiddenState() + } + .store(in: &c) + + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSystemMenuBarHiddenState() } .store(in: &c) @@ -104,13 +129,7 @@ final class MenuBarManager: ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] _ in - guard - let self, - let isMenuBarHidden = Defaults.globalDomain["_HIHideMenuBar"] as? Bool - else { - return - } - isMenuBarHiddenBySystemUserDefaults = isMenuBarHidden + self?.refreshSystemMenuBarHiddenState() } .store(in: &c) } @@ -223,6 +242,89 @@ final class MenuBarManager: ObservableObject { cancellables = c } + /// Refreshes the system menu bar hidden state for the active space. + private func refreshSystemMenuBarHiddenState() { + let isMenuBarHiddenOnDesktop = self.isMenuBarHiddenOnDesktop + let isActiveSpaceFullscreen = appState?.isActiveSpaceFullscreen + ?? Bridging.isSpaceFullscreen(Bridging.activeSpaceID) + let isMenuBarHiddenInActiveSpace = isActiveSpaceFullscreen + ? isMenuBarHiddenInFullscreen + : isMenuBarHiddenOnDesktop + let isMenuBarHidden = isMenuBarHiddenInActiveSpace + || isMenuBarHiddenBySystemPresentationOptions + + if isMenuBarHiddenBySystemUserDefaults != isMenuBarHiddenOnDesktop { + isMenuBarHiddenBySystemUserDefaults = isMenuBarHiddenOnDesktop + } + if isMenuBarHiddenBySystem != isMenuBarHidden { + isMenuBarHiddenBySystem = isMenuBarHidden + } + } + + /// Refreshes the transient presentation-options hidden state. + private func refreshSystemPresentationOptions(_ options: NSApplication.PresentationOptions) { + let isMenuBarHidden = options.contains(.hideMenuBar) || options.contains(.autoHideMenuBar) + if isMenuBarHiddenBySystemPresentationOptions != isMenuBarHidden { + isMenuBarHiddenBySystemPresentationOptions = isMenuBarHidden + } + refreshSystemMenuBarHiddenState() + } + + /// Returns whether the menu bar should be treated as hidden by the system + /// on the given screen's current space. + func isMenuBarHiddenBySystem(on screen: NSScreen) -> Bool { + if isMenuBarHiddenBySystemPresentationOptions { + return true + } + let isFullscreen = isSpaceFullscreen(on: screen) + return isFullscreen ? isMenuBarHiddenInFullscreen : isMenuBarHiddenOnDesktop + } + + /// Returns whether the given screen is currently showing a fullscreen space. + func isSpaceFullscreen(on screen: NSScreen) -> Bool { + Bridging.isCurrentSpaceFullscreen(forDisplayWithIdentifier: screen.stableIdentifier) + ?? appState?.isActiveSpaceFullscreen + ?? Bridging.isSpaceFullscreen(Bridging.activeSpaceID) + } + + /// The desktop-space portion of System Settings' menu bar auto-hide mode. + private var isMenuBarHiddenOnDesktop: Bool { + globalBool( + forKey: SystemMenuBarDefaultsKey.autoHideOnDesktop, + defaultValue: false + ) + } + + /// The fullscreen-space portion of System Settings' menu bar auto-hide mode. + private var isMenuBarHiddenInFullscreen: Bool { + !globalBool( + forKey: SystemMenuBarDefaultsKey.visibleInFullscreen, + defaultValue: false + ) + } + + /// Reads a Boolean from the global domain, accepting the common plist + /// representations used by `defaults`. + private func globalBool(forKey key: String, defaultValue: Bool) -> Bool { + switch Defaults.globalDomain[key] { + case let value as Bool: + return value + case let value as NSNumber: + return value.boolValue + case let value as String: + switch value.lowercased() { + case "1", "true", "yes": + return true + case "0", "false", "no": + return false + default: + return defaultValue + } + default: + return defaultValue + } + } + /// Updates the ``averageColorInfo`` property with the current average color /// of the menu bar. func updateAverageColorInfo() { @@ -288,6 +390,40 @@ final class MenuBarManager: ObservableObject { } } + /// Returns whether the given screen's menu bar is currently visible. + /// + /// `isMenuBarHiddenBySystem` describes policy and transient presentation + /// state, which can remain true while an auto-hidden menu bar is + /// temporarily revealed. This helper checks the live WindowServer/AX state, + /// with Ice's own status-item windows as a short-lived fallback while the + /// menu bar is animating. + func isMenuBarCurrentlyVisible(on screen: NSScreen, in windows: [WindowInfo]) -> Bool { + let displayID = screen.displayID + if + WindowInfo.getMenuBarWindow(from: windows, for: displayID)?.alpha ?? 0 > 0, + hasValidMenuBar(in: windows, for: displayID) + { + return true + } + return hasVisibleControlItemWindow(on: displayID, in: windows) + } + + /// Returns whether one of Ice's control item windows is currently visible + /// on the given display. + private func hasVisibleControlItemWindow(on display: CGDirectDisplayID, in windows: [WindowInfo]) -> Bool { + let displayBounds = CGDisplayBounds(display) + let controlItemWindowIDs = Set(sections.compactMap(\.controlItem.windowID)) + guard !controlItemWindowIDs.isEmpty else { + return false + } + return windows.contains { window in + controlItemWindowIDs.contains(window.windowID) && + window.isOnScreen && + window.alpha > 0 && + displayBounds.intersects(window.frame) + } + } + /// Returns the frame of the application menu for the given display. func getApplicationMenuFrame(for displayID: CGDirectDisplayID) -> CGRect? { let displayBounds = CGDisplayBounds(displayID) diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 40c689829..23fb412dd 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -67,7 +67,12 @@ final class IceBarPanel: NSPanel { // Only continue if the menu bar is automatically hidden, as Ice // can't currently display its menu bar items. appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults, - let info = window.flatMap({ WindowInfo(windowID: CGWindowID($0.windowNumber)) }), + let info = window.flatMap({ window -> WindowInfo? in + guard let windowID = CGWindowID(exactly: window.windowNumber) else { + return nil + } + return WindowInfo(windowID: windowID) + }), // Window being offscreen means the menu bar is currently hidden. // Close the bar, as things will start to look weird if we don't. !info.isOnScreen diff --git a/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift b/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift index aecf28e4a..d42abad8b 100644 --- a/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift +++ b/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift @@ -102,11 +102,11 @@ extension CustomGradient { static let defaultMenuBarTint = CustomGradient( unsortedStops: [ ColorStop( - color: CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1), + color: CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.2), location: 0 ), ColorStop( - color: CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1), + color: CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.2), location: 1 ), ] diff --git a/Ice/UI/ViewModifiers/LayoutBarStyle.swift b/Ice/UI/ViewModifiers/LayoutBarStyle.swift index 67c3783c9..f40a0b097 100644 --- a/Ice/UI/ViewModifiers/LayoutBarStyle.swift +++ b/Ice/UI/ViewModifiers/LayoutBarStyle.swift @@ -38,17 +38,35 @@ extension View { } .overlay { if !appState.isActiveSpaceFullscreen { - switch appState.appearanceManager.configuration.current.tintKind { + let current = appState.appearanceManager.configuration.current + switch current.appearanceKind { case .none: EmptyView() - case .solid: - Color(cgColor: appState.appearanceManager.configuration.current.tintColor) - .opacity(0.2) - .allowsHitTesting(false) - case .gradient: - appState.appearanceManager.configuration.current.tintGradient - .opacity(0.2) - .allowsHitTesting(false) + case .tint: + // Fixed 20% overlay, regardless of stored alpha. + switch current.tintKind { + case .none: + EmptyView() + case .solid: + Color(cgColor: current.tintColor.copy(alpha: 1) ?? current.tintColor) + .opacity(0.2) + .allowsHitTesting(false) + case .gradient: + current.tintGradient.withAlphaComponent(0.2) + .allowsHitTesting(false) + } + case .backdrop: + // Honor the stored alpha (user-controllable, default 100%). + switch current.tintKind { + case .none: + EmptyView() + case .solid: + Color(cgColor: current.tintColor) + .allowsHitTesting(false) + case .gradient: + current.tintGradient + .allowsHitTesting(false) + } } } } diff --git a/Ice/Utilities/Defaults.swift b/Ice/Utilities/Defaults.swift index 8349b99e6..0fac4eb6a 100644 --- a/Ice/Utilities/Defaults.swift +++ b/Ice/Utilities/Defaults.swift @@ -182,6 +182,8 @@ extension Defaults { case hasMigrated0_10_0 = "hasMigrated0_10_0" case hasMigrated0_10_1 = "hasMigrated0_10_1" case hasMigrated0_11_10 = "hasMigrated0_11_10" + case hasMigratedTintAlpha = "hasMigratedTintAlpha" + case hasMigratedAppearanceKind = "hasMigratedAppearanceKind" // MARK: Deprecated diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index fb824db2b..f2476ff29 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -458,6 +458,15 @@ extension NSScreen { let menuBarWindow = WindowInfo.getMenuBarWindow(for: displayID) return menuBarWindow?.frame.height } + + /// A stable identifier derived from the screen's display UUID, suitable + /// for persisting per-display settings across reboots and reconnections. + var stableIdentifier: String? { + guard let uuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { + return nil + } + return CFUUIDCreateString(nil, uuid) as String + } } // MARK: - NSStatusItem diff --git a/Ice/Utilities/MenuBarBackgroundProbe.swift b/Ice/Utilities/MenuBarBackgroundProbe.swift new file mode 100644 index 000000000..76bb51578 --- /dev/null +++ b/Ice/Utilities/MenuBarBackgroundProbe.swift @@ -0,0 +1,95 @@ +// +// MenuBarBackgroundProbe.swift +// Ice +// + +import AppKit +import Combine +import Foundation + +/// Detects system settings that conflict with backdrop mode. +/// +/// - `isOpaqueBackgroundEnabled` reflects macOS's "Show menu bar background" +/// option, read via SkyLight's `SLSGetMenuBarUseBlurredAppearance` (private +/// SPI, since the setting isn't exposed as a documented `UserDefaults` +/// key). Polled because the matching SkyLight notification isn't trivial to +/// bridge to Swift. +/// - `isReduceTransparencyEnabled` reflects Accessibility → Display → Reduce +/// transparency, observed via `NSWorkspace`. +/// +/// If the private symbol lookup fails (e.g. on a future macOS where the SPI +/// is renamed), the opaque-background flag simply reports `false` and the +/// dependent UI hides itself. +@MainActor +final class MenuBarBackgroundProbe: ObservableObject { + @Published private(set) var isOpaqueBackgroundEnabled: Bool = false + @Published private(set) var isReduceTransparencyEnabled: Bool = false + + private var timer: Timer? + private var observer: NSObjectProtocol? + + init() { + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refreshOpaqueBackground() } + } + observer = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.refreshReduceTransparency() } + } + } + + deinit { + timer?.invalidate() + if let observer { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + } + + func refresh() { + refreshOpaqueBackground() + refreshReduceTransparency() + } + + private func refreshOpaqueBackground() { + isOpaqueBackgroundEnabled = SkyLightMenuBarSPI.isBlurredAppearanceEnabled ?? false + } + + private func refreshReduceTransparency() { + isReduceTransparencyEnabled = NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency + } +} + +// MARK: - SkyLight SPI + +private enum SkyLightMenuBarSPI { + private typealias MainConnectionIDFn = @convention(c) () -> Int32 + private typealias GetUseBlurredAppearanceFn = @convention(c) (Int32) -> Bool + + private static let handle: UnsafeMutableRawPointer? = { + dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY) + }() + + private static let mainConnectionID: MainConnectionIDFn? = { + guard let handle, let sym = dlsym(handle, "SLSMainConnectionID") else { return nil } + return unsafeBitCast(sym, to: MainConnectionIDFn.self) + }() + + private static let getUseBlurredAppearance: GetUseBlurredAppearanceFn? = { + guard let handle, let sym = dlsym(handle, "SLSGetMenuBarUseBlurredAppearance") else { return nil } + return unsafeBitCast(sym, to: GetUseBlurredAppearanceFn.self) + }() + + /// Whether the menu bar is currently drawn with its blurred/opaque + /// material — i.e. macOS's "Show menu bar background" is on. Returns + /// `nil` if the SPI couldn't be resolved. + static var isBlurredAppearanceEnabled: Bool? { + guard let getUseBlurredAppearance, let mainConnectionID else { + return nil + } + return getUseBlurredAppearance(mainConnectionID()) + } +} diff --git a/Ice/Utilities/MigrationManager.swift b/Ice/Utilities/MigrationManager.swift index 0141e0bf2..8d31532d8 100644 --- a/Ice/Utilities/MigrationManager.swift +++ b/Ice/Utilities/MigrationManager.swift @@ -31,6 +31,8 @@ extension MigrationManager { let results = [ manager.migrate0_10_1(), manager.migrate0_11_10(), + manager.migrateTintAlpha(), + manager.migrateAppearanceKind(), ] for result in results { @@ -282,7 +284,8 @@ extension MigrationManager { hasBorder: oldConfiguration.hasBorder, borderColor: oldConfiguration.borderColor, borderWidth: oldConfiguration.borderWidth, - tintKind: oldConfiguration.tintKind, + appearanceKind: oldConfiguration.tintKind == .none ? .none : .tint, + tintKind: oldConfiguration.tintKind == .none ? .solid : oldConfiguration.tintKind, tintColor: oldConfiguration.tintColor, tintGradient: oldConfiguration.tintGradient ) @@ -303,6 +306,86 @@ extension MigrationManager { } } +// MARK: - Migrate Tint Alpha + +extension MigrationManager { + /// Scales the alpha of each saved tint color and tint gradient stop down to + /// 0.2, preserving the visual appearance that existed before the alpha + /// component became user-controllable. + private func migrateTintAlpha() -> MigrationResult { + guard !Defaults.bool(forKey: .hasMigratedTintAlpha) else { + return .success + } + guard let oldData = Defaults.data(forKey: .menuBarAppearanceConfigurationV2) else { + Defaults.set(true, forKey: .hasMigratedTintAlpha) + return .success + } + do { + var configuration = try decoder.decode(MenuBarAppearanceConfigurationV2.self, from: oldData) + configuration.lightModeConfiguration = scaleTintAlpha(in: configuration.lightModeConfiguration) + configuration.darkModeConfiguration = scaleTintAlpha(in: configuration.darkModeConfiguration) + configuration.staticConfiguration = scaleTintAlpha(in: configuration.staticConfiguration) + let newData = try encoder.encode(configuration) + Defaults.set(newData, forKey: .menuBarAppearanceConfigurationV2) + Defaults.set(true, forKey: .hasMigratedTintAlpha) + Logger.migration.info("Successfully migrated tint alpha") + } catch { + return .failureAndLogError(.appearanceConfigurationMigrationError(.otherError(error))) + } + return .success + } + + private func scaleTintAlpha(in partial: MenuBarAppearancePartialConfiguration) -> MenuBarAppearancePartialConfiguration { + var copy = partial + if let scaled = copy.tintColor.copy(alpha: copy.tintColor.alpha * 0.2) { + copy.tintColor = scaled + } + copy.tintGradient = copy.tintGradient.withAlphaComponent(0.2) + return copy + } +} + +// MARK: - Migrate Appearance Kind + +extension MigrationManager { + /// Resets stored tint color and gradient alphas to 1.0 now that the new + /// `appearanceKind` field decides whether the color is rendered at a + /// fixed 20% (tint) or at its stored alpha (backdrop). The decoder + /// derives `appearanceKind` from the legacy `tintKind`, so persisting + /// the inferred kind happens automatically on re-encode here. + private func migrateAppearanceKind() -> MigrationResult { + guard !Defaults.bool(forKey: .hasMigratedAppearanceKind) else { + return .success + } + guard let oldData = Defaults.data(forKey: .menuBarAppearanceConfigurationV2) else { + Defaults.set(true, forKey: .hasMigratedAppearanceKind) + return .success + } + do { + var configuration = try decoder.decode(MenuBarAppearanceConfigurationV2.self, from: oldData) + configuration.lightModeConfiguration = resetAlpha(in: configuration.lightModeConfiguration) + configuration.darkModeConfiguration = resetAlpha(in: configuration.darkModeConfiguration) + configuration.staticConfiguration = resetAlpha(in: configuration.staticConfiguration) + let newData = try encoder.encode(configuration) + Defaults.set(newData, forKey: .menuBarAppearanceConfigurationV2) + Defaults.set(true, forKey: .hasMigratedAppearanceKind) + Logger.migration.info("Successfully migrated appearance kind") + } catch { + return .failureAndLogError(.appearanceConfigurationMigrationError(.otherError(error))) + } + return .success + } + + private func resetAlpha(in partial: MenuBarAppearancePartialConfiguration) -> MenuBarAppearancePartialConfiguration { + var copy = partial + if let opaque = copy.tintColor.copy(alpha: 1) { + copy.tintColor = opaque + } + copy.tintGradient = copy.tintGradient.withAlphaComponent(1) + return copy + } +} + // MARK: - Helpers extension MigrationManager {