diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 527459837ab2..e1047bae58b3 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,18 +3,18 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34475 CLI/cmux.swift 17583 Sources/AppDelegate.swift -16578 Sources/ContentView.swift +15920 Sources/ContentView.swift 14225 Sources/TerminalController.swift -12645 Sources/Workspace.swift +12646 Sources/Workspace.swift 12115 cmuxTests/AppDelegateShortcutRoutingTests.swift -11718 Sources/GhosttyTerminalView.swift -11387 Sources/Panels/BrowserPanel.swift +11731 Sources/GhosttyTerminalView.swift +11388 Sources/Panels/BrowserPanel.swift 9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7931 Sources/Panels/BrowserPanelView.swift 7356 cmuxTests/WorkspaceUnitTests.swift 7221 cmuxTests/WorkspaceRemoteConnectionTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6222 cmuxTests/GhosttyConfigTests.swift +6223 cmuxTests/GhosttyConfigTests.swift 6154 Sources/TabManager.swift 6153 CLI/cmux_open.swift 6074 Sources/TextBoxInput.swift @@ -24,10 +24,10 @@ 4516 Sources/cmuxApp.swift 4467 Sources/Panels/FilePreviewPanel.swift 4401 cmuxTests/BrowserPanelTests.swift -4227 Sources/BrowserWindowPortal.swift +4225 Sources/BrowserWindowPortal.swift 3937 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift -3903 cmuxTests/WindowAndDragTests.swift +3896 cmuxTests/WindowAndDragTests.swift 3734 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift 3397 Sources/CmuxConfig.swift @@ -42,7 +42,7 @@ 2395 Sources/Mobile/MobileHostService.swift 2355 Sources/FileExplorerView.swift 2328 cmuxTests/CJKIMEInputTests.swift -2259 Sources/TerminalWindowPortal.swift +2257 Sources/TerminalWindowPortal.swift 2236 Sources/TerminalNotificationStore.swift 2117 cmuxTests/CmuxConfigTests.swift 2092 cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -185,7 +185,6 @@ 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift 556 Sources/Panels/BrowserAutomation.swift 551 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift -547 Sources/Windowing/WindowGlassEffect.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 539 CLI/CMUXCLI+Themes.swift diff --git a/Packages/macOS/CmuxAppKitSupportUI/Package.swift b/Packages/macOS/CmuxAppKitSupportUI/Package.swift index 9a80dd45d841..6fbc62fc41ec 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Package.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Package.swift @@ -13,9 +13,17 @@ let package = Package( targets: ["CmuxAppKitSupportUI"] ), ], + dependencies: [ + .package(path: "../CmuxFoundation"), + .package(path: "../CmuxWorkspaceWindow"), + ], targets: [ .target( name: "CmuxAppKitSupportUI", + dependencies: [ + "CmuxFoundation", + "CmuxWorkspaceWindow", + ], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/macOS/CmuxAppKitSupportUI/README.md b/Packages/macOS/CmuxAppKitSupportUI/README.md new file mode 100644 index 000000000000..6b82c5ed3c28 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/README.md @@ -0,0 +1,86 @@ +# CmuxAppKitSupportUI + +AppKit/SwiftUI support pieces for the macOS app, grouped by concern. Each top-level folder is +one concern: `WindowChrome/` (this is what the ContentView extraction added), plus the +pre-existing `AboutTitlebarDebug/`, `Mouse/`, `Popover/`, and `Scroll/`. Most types are small +`Sendable` value types or narrow `@MainActor` controllers, so resolution logic can be unit +tested without a live window. + +## WindowChrome/ + +"Window chrome" is everything cmux paints around and behind the terminal: the window +background, the native titlebar backdrop, the sidebar material, the hairline borders, and the +macOS 26 glass effect. There are many small types because the chrome is resolved in stages so +each stage stays testable: read current state into a `*Snapshot`, resolve it into a +`*Plan`/`*Policy`, then a `@MainActor` controller applies the plan to a real `NSWindow` and +returns a `*Result`. The value types are pure and unit-tested; the AppKit mutation lives only +in the controllers. + +Files are grouped into subfolders by the chrome concern they serve. One major public type per +file, named after the type. + +### Appearance/ +Resolves the window's overall appearance for one render pass. +- `WindowTerminalAppearanceSnapshot.swift`: current terminal colors/opacity chrome must match. +- `WindowAppearanceUserSettingsSnapshot.swift`: the user settings that influence appearance. +- `WindowAppearanceResolver.swift`: combines those two snapshots into a `WindowAppearanceSnapshot`. +- `WindowAppearanceSnapshot.swift`: the resolved appearance value (colors, opacity, rendering-mode helpers) for one pass. +- `WindowRootBackdropResolution.swift`: the root-backdrop result returned when a pane supplies its own color. + +### Backdrop/ +The window background fill and how it is applied to AppKit. +- `WindowBackdropRole.swift`: which chrome surface a backdrop targets (root, titlebar, etc.). +- `WindowBackdropPolicy.swift`: the rendering policy chosen for a surface. +- `WindowBackdropHostingPhase.swift`: which AppKit hosting strategy is used. +- `WindowBackdropGlassPlan.swift`: tint and glass style when the backdrop uses native glass. +- `WindowBackdropPlan.swift`: the full set of `NSWindow` mutations for a resolved backdrop. +- `WindowBackdropControllerDependencies.swift`: protocol of app-provided side effects the controller needs (injected). +- `WindowBackdropController.swift`: `@MainActor` type that applies a plan/snapshot to an `NSWindow`. +- `WindowBackdropApplicationResult.swift`: what changed after the controller ran. +- `WindowBackdropLayer.swift`: SwiftUI view that renders the resolved backdrop for one role. +- `LayerBackedBackdropColor.swift`: internal non-hit-testing AppKit color fill for transparent windows. + +### Glass/ +The macOS 26 `NSGlassEffectView` window glass, with an `NSVisualEffectView` fallback. +- `WindowGlassEffectStyle.swift`: the native glass style applied when glass is available. +- `WindowGlassSettingsSnapshot.swift`: persisted plus terminal-driven settings for the glass root. +- `WindowGlassEffectManaging.swift`: protocol seam for applying and inspecting the glass hierarchy. +- `WindowGlassEffect.swift`: `@MainActor` implementation of that seam (native glass + fallback). +- `WindowGlassEffect+Views.swift`: internal AppKit view helpers used by `WindowGlassEffect`. +- `GhosttyBackgroundBlur+WindowGlassEffectStyle.swift`: maps a Ghostty blur mode to a glass style. + +### Titlebar/ +The native AppKit titlebar. +- `NativeTitlebarBackdropCoordinator.swift`: `@MainActor` type that hides/restores the native titlebar backdrop. +- `TitlebarLeadingInsetReader.swift`: SwiftUI reader for the inset needed to clear traffic lights and accessories. + +### Border/ +The hairline borders between chrome surfaces. +- `WindowChromeBorderOrientation.swift`: orientation of a one-pixel border. +- `WindowChromeBorder.swift`: SwiftUI one-pixel border derived from the chrome background color. + +### Color/ +Color math shared across chrome. +- `WindowChromeColorResolver.swift`: separator color, compositing, and readable-scheme math. + +### Sidebar/ +The sidebar backdrop material and its persisted options. +- `SidebarBackdropSettingsSnapshot.swift`: persisted sidebar backdrop settings as a value. +- `SidebarBackdropMaterialPolicy.swift`: the resolved AppKit material settings for the sidebar. +- `SidebarVisualEffectBackground.swift`: internal wrapper view (native glass, falls back to visual effect). +- `WindowChromeSidebarPresetOption.swift`: `sidebarPreset` setting values. +- `WindowChromeSidebarMaterialOption.swift`: `sidebarMaterial` setting values. +- `WindowChromeSidebarBlendModeOption.swift`: `sidebarBlendMode` setting values. +- `WindowChromeSidebarStateOption.swift`: `sidebarState` setting values. +- `WindowChromeSidebarTintDefaults.swift`: legacy default sidebar tint constants. + +### TerminalSurface/ +How an individual terminal surface paints its own background. +- `GhosttyTerminalBackdropRenderingMode.swift`: who owns the terminal backdrop pixels. +- `TerminalSurfaceBackgroundFillOwner.swift`: which layer paints a surface background. +- `TerminalSurfaceBackgroundFillPlan.swift`: the resolved fill decision for one surface. + +### Overlay/ +Where window-level overlays are inserted in the AppKit hierarchy. +- `WindowContentOverlayInstallationTarget.swift`: the container/reference pair to install into. +- `WindowContentOverlayTargetResolver.swift`: resolves the insertion point for a window. diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift new file mode 100644 index 000000000000..2953babcf19e --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift @@ -0,0 +1,72 @@ +import AppKit +public import SwiftUI +import CmuxFoundation + +/// Builds `WindowAppearanceSnapshot` values from injected terminal and user settings. +public struct WindowAppearanceResolver { + private let terminalAppearance: WindowTerminalAppearanceSnapshot + + /// Creates a resolver with the current terminal appearance injected by the app. + public init(terminalAppearance: WindowTerminalAppearanceSnapshot) { + self.terminalAppearance = terminalAppearance + } + + /// Resolves window appearance from explicit user settings. + public func current(settings: WindowAppearanceUserSettingsSnapshot) -> WindowAppearanceSnapshot { + WindowAppearanceSnapshot( + terminalBackgroundColor: terminalAppearance.backgroundColor, + terminalBackgroundOpacity: WindowAppearanceSnapshot.clampedOpacity(terminalAppearance.backgroundOpacity), + terminalBackgroundBlur: terminalAppearance.backgroundBlur, + terminalRenderingMode: WindowAppearanceSnapshot.terminalRenderingMode( + usesHostLayerBackground: terminalAppearance.usesHostLayerBackground + ), + unifySurfaceBackdrops: settings.unifySurfaceBackdrops, + sidebarSettings: SidebarBackdropSettingsSnapshot( + materialRawValue: settings.sidebarMaterial, + blendModeRawValue: settings.sidebarBlendMode, + stateRawValue: settings.sidebarState, + tintHex: settings.sidebarTintHex, + tintHexLight: settings.sidebarTintHexLight, + tintHexDark: settings.sidebarTintHexDark, + tintOpacity: settings.sidebarTintOpacity, + cornerRadius: settings.sidebarCornerRadius, + blurOpacity: settings.sidebarBlurOpacity, + colorScheme: settings.colorScheme + ), + windowGlassSettings: WindowGlassSettingsSnapshot( + sidebarBlendModeRawValue: settings.sidebarBlendMode, + isEnabled: settings.bgGlassEnabled, + tintHex: settings.bgGlassTintHex, + tintOpacity: settings.bgGlassTintOpacity, + terminalBackgroundBlur: terminalAppearance.backgroundBlur, + terminalGlassTintColor: terminalAppearance.backgroundColor.withAlphaComponent( + WindowAppearanceSnapshot.clampedOpacity(terminalAppearance.backgroundOpacity) + ) + ) + ) + } + + /// Resolves window appearance from a `UserDefaults` store. + public func currentFromUserDefaults( + defaults: UserDefaults, + colorScheme: ColorScheme + ) -> WindowAppearanceSnapshot { + let tintDefaults = WindowChromeSidebarTintDefaults() + return current(settings: WindowAppearanceUserSettingsSnapshot( + unifySurfaceBackdrops: defaults.object(forKey: "sidebarMatchTerminalBackground") as? Bool ?? false, + colorScheme: colorScheme, + sidebarMaterial: defaults.string(forKey: "sidebarMaterial") ?? WindowChromeSidebarMaterialOption.sidebar.rawValue, + sidebarBlendMode: defaults.string(forKey: "sidebarBlendMode") ?? WindowChromeSidebarBlendModeOption.withinWindow.rawValue, + sidebarState: defaults.string(forKey: "sidebarState") ?? WindowChromeSidebarStateOption.followWindow.rawValue, + sidebarTintHex: defaults.string(forKey: "sidebarTintHex") ?? tintDefaults.hex, + sidebarTintHexLight: defaults.string(forKey: "sidebarTintHexLight"), + sidebarTintHexDark: defaults.string(forKey: "sidebarTintHexDark"), + sidebarTintOpacity: defaults.object(forKey: "sidebarTintOpacity") as? Double ?? tintDefaults.opacity, + sidebarCornerRadius: defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0, + sidebarBlurOpacity: defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 1.0, + bgGlassEnabled: defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false, + bgGlassTintHex: defaults.string(forKey: "bgGlassTintHex") ?? "#000000", + bgGlassTintOpacity: defaults.object(forKey: "bgGlassTintOpacity") as? Double ?? 0.03 + )) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift new file mode 100644 index 000000000000..cb95409e334d --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift @@ -0,0 +1,188 @@ +public import AppKit +public import SwiftUI +public import CmuxFoundation +public import CmuxWorkspaceWindow + +/// Resolved window chrome appearance for a single render pass. +public struct WindowAppearanceSnapshot { + /// Current terminal background color. + public let terminalBackgroundColor: NSColor + + /// Current terminal background opacity. + public let terminalBackgroundOpacity: CGFloat + + /// Current terminal background blur. + public let terminalBackgroundBlur: GhosttyBackgroundBlur + + /// Current terminal backdrop rendering owner. + public let terminalRenderingMode: GhosttyTerminalBackdropRenderingMode + + /// Whether sidebars share the terminal root backdrop. + public let unifySurfaceBackdrops: Bool + + /// Resolved sidebar backdrop settings. + public let sidebarSettings: SidebarBackdropSettingsSnapshot + + /// Resolved window glass settings. + public let windowGlassSettings: WindowGlassSettingsSnapshot + + /// Creates a resolved window appearance snapshot. + public init( + terminalBackgroundColor: NSColor, + terminalBackgroundOpacity: CGFloat, + terminalBackgroundBlur: GhosttyBackgroundBlur, + terminalRenderingMode: GhosttyTerminalBackdropRenderingMode, + unifySurfaceBackdrops: Bool, + sidebarSettings: SidebarBackdropSettingsSnapshot, + windowGlassSettings: WindowGlassSettingsSnapshot + ) { + self.terminalBackgroundColor = terminalBackgroundColor + self.terminalBackgroundOpacity = terminalBackgroundOpacity + self.terminalBackgroundBlur = terminalBackgroundBlur + self.terminalRenderingMode = terminalRenderingMode + self.unifySurfaceBackdrops = unifySurfaceBackdrops + self.sidebarSettings = sidebarSettings + self.windowGlassSettings = windowGlassSettings + } + + /// Clamps opacity into the visible `0...1` range. + public static func clampedOpacity(_ opacity: Double) -> CGFloat { + CGFloat(max(0.0, min(1.0, opacity))) + } + + /// Returns `backgroundColor` composited over `baseColor` with the given opacity. + public static func compositedTerminalColor( + backgroundColor: NSColor, + opacity: Double, + over baseColor: NSColor = .windowBackgroundColor + ) -> NSColor { + WindowChromeColorResolver().compositedColor( + backgroundColor.withAlphaComponent(clampedOpacity(opacity)), + over: baseColor + ) + } + + /// Returns the terminal backdrop rendering mode. + public static func terminalRenderingMode( + usesHostLayerBackground: Bool + ) -> GhosttyTerminalBackdropRenderingMode { + usesHostLayerBackground ? .windowHostBackdrop : .ghosttyRendererOwnedBackgroundImage + } + + /// Terminal background composited over the window background. + public var compositedTerminalBackgroundColor: NSColor { + Self.compositedTerminalColor( + backgroundColor: terminalBackgroundColor, + opacity: Double(terminalBackgroundOpacity) + ) + } + + /// Color scheme readable against the chrome background. + public var chromeColorScheme: ColorScheme { + WindowChromeColorResolver().readableColorScheme(for: compositedTerminalBackgroundColor) + } + + /// Color scheme used for sidebar content. + public var sidebarContentColorScheme: ColorScheme { + unifySurfaceBackdrops ? chromeColorScheme : sidebarSettings.colorScheme + } + + /// Returns the backdrop policy for one chrome role. + public func policy(for role: WindowBackdropRole) -> WindowBackdropPolicy { + switch role { + case .windowRoot: + return terminalBackdropPolicy() + case .terminalCanvas, .bonsplitChrome, .titlebar, .browserSurface: + return .clear + case .leftSidebar, .rightSidebar: + if unifySurfaceBackdrops { + return .clear + } + return .sidebarMaterial(sidebarSettings.materialPolicy) + } + } + + /// Returns the root terminal backdrop policy. + public func terminalBackdropPolicy() -> WindowBackdropPolicy { + if terminalBackgroundBlur.isMacOSGlassStyle { + return .clear + } + return .ghosttyTerminalBackdrop( + color: terminalBackgroundColor, + opacity: terminalBackgroundOpacity, + renderingMode: terminalRenderingMode + ) + } + + /// Whether AppKit hosting must be transparent for this snapshot. + public func shouldUseTransparentHosting( + glassEffectAvailable: Bool, + windowBackgroundPolicy: WindowBackgroundPolicy + ) -> Bool { + backdropPlan( + glassEffectAvailable: glassEffectAvailable, + windowBackgroundPolicy: windowBackgroundPolicy + ).usesTransparentWindow + } + + /// Returns the AppKit window mutation plan for this snapshot. + public func backdropPlan( + glassEffectAvailable: Bool, + windowBackgroundPolicy: WindowBackgroundPolicy + ) -> WindowBackdropPlan { + let rootPolicy = terminalBackdropPolicy() + if windowGlassSettings.shouldApply( + glassEffectAvailable: glassEffectAvailable, + windowBackgroundPolicy: windowBackgroundPolicy + ) { + return WindowBackdropPlan( + hostingPhase: .windowGlass, + windowBackgroundColor: windowBackgroundPolicy.transparentWindowBaseColor, + windowIsOpaque: false, + rootPolicy: rootPolicy, + glass: WindowBackdropGlassPlan( + tintColor: windowGlassSettings.tintColor, + style: windowGlassSettings.style + ), + shouldApplyGhosttyCompositorBlur: false + ) + } + + if terminalBackgroundOpacity < 0.999 { + return WindowBackdropPlan( + hostingPhase: .transparentRootBackdrop, + windowBackgroundColor: windowBackgroundPolicy.transparentWindowBaseColor, + windowIsOpaque: false, + rootPolicy: rootPolicy, + glass: nil, + shouldApplyGhosttyCompositorBlur: !terminalBackgroundBlur.isMacOSGlassStyle + ) + } + + return WindowBackdropPlan( + hostingPhase: .opaqueWindowFill, + windowBackgroundColor: compositedTerminalBackgroundColor, + windowIsOpaque: true, + rootPolicy: rootPolicy, + glass: nil, + shouldApplyGhosttyCompositorBlur: false + ) + } + + /// Returns the window root backdrop resolution for a pane-local surface color. + public func windowRootBackdropResolution(surfaceBackgroundColor color: NSColor?) -> WindowRootBackdropResolution { + WindowRootBackdropResolution( + snapshot: self, + source: color == nil ? "defaultBackground" : "defaultBackground(surfaceOverrideLocal)", + overrideHex: color?.hexString() ?? "nil" + ) + } + + /// Stable identity for AppKit window mutations. + public func appKitWindowMutationID(windowBackgroundPolicy: WindowBackgroundPolicy) -> String { + backdropPlan( + glassEffectAvailable: false, + windowBackgroundPolicy: windowBackgroundPolicy + ).appKitMutationID + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift new file mode 100644 index 000000000000..fce360bc80eb --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift @@ -0,0 +1,79 @@ +public import SwiftUI + +/// User settings needed to resolve window appearance. +public struct WindowAppearanceUserSettingsSnapshot { + /// Whether sidebars share the terminal root backdrop. + public let unifySurfaceBackdrops: Bool + + /// Color scheme selected for sidebar tint resolution. + public let colorScheme: ColorScheme + + /// Raw `sidebarMaterial` value. + public let sidebarMaterial: String + + /// Raw `sidebarBlendMode` value. + public let sidebarBlendMode: String + + /// Raw `sidebarState` value. + public let sidebarState: String + + /// Base sidebar tint hex. + public let sidebarTintHex: String + + /// Light-mode sidebar tint override. + public let sidebarTintHexLight: String? + + /// Dark-mode sidebar tint override. + public let sidebarTintHexDark: String? + + /// Sidebar tint opacity. + public let sidebarTintOpacity: Double + + /// Sidebar corner radius. + public let sidebarCornerRadius: Double + + /// Sidebar blur opacity. + public let sidebarBlurOpacity: Double + + /// Whether background glass is enabled. + public let bgGlassEnabled: Bool + + /// Background glass tint hex. + public let bgGlassTintHex: String + + /// Background glass tint opacity. + public let bgGlassTintOpacity: Double + + /// Creates a user settings snapshot for window appearance. + public init( + unifySurfaceBackdrops: Bool, + colorScheme: ColorScheme, + sidebarMaterial: String, + sidebarBlendMode: String, + sidebarState: String, + sidebarTintHex: String, + sidebarTintHexLight: String?, + sidebarTintHexDark: String?, + sidebarTintOpacity: Double, + sidebarCornerRadius: Double, + sidebarBlurOpacity: Double, + bgGlassEnabled: Bool, + bgGlassTintHex: String, + bgGlassTintOpacity: Double + ) { + self.unifySurfaceBackdrops = unifySurfaceBackdrops + self.colorScheme = colorScheme + self.sidebarMaterial = sidebarMaterial + self.sidebarBlendMode = sidebarBlendMode + self.sidebarState = sidebarState + self.sidebarTintHex = sidebarTintHex + self.sidebarTintHexLight = sidebarTintHexLight + self.sidebarTintHexDark = sidebarTintHexDark + self.sidebarTintOpacity = sidebarTintOpacity + self.sidebarCornerRadius = sidebarCornerRadius + self.sidebarBlurOpacity = sidebarBlurOpacity + self.bgGlassEnabled = bgGlassEnabled + self.bgGlassTintHex = bgGlassTintHex + self.bgGlassTintOpacity = bgGlassTintOpacity + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowRootBackdropResolution.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowRootBackdropResolution.swift new file mode 100644 index 000000000000..19842d6f835d --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowRootBackdropResolution.swift @@ -0,0 +1,18 @@ +/// Root backdrop resolution returned when a pane-local color is present. +public struct WindowRootBackdropResolution { + /// Snapshot that remains authoritative for the root backdrop. + public let snapshot: WindowAppearanceSnapshot + + /// Debug source label. + public let source: String + + /// Pane-local override hex value, or `"nil"`. + public let overrideHex: String + + /// Creates a root backdrop resolution. + public init(snapshot: WindowAppearanceSnapshot, source: String, overrideHex: String) { + self.snapshot = snapshot + self.source = source + self.overrideHex = overrideHex + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowTerminalAppearanceSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowTerminalAppearanceSnapshot.swift new file mode 100644 index 000000000000..cf90c8a99bc1 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowTerminalAppearanceSnapshot.swift @@ -0,0 +1,30 @@ +public import AppKit +public import CmuxFoundation + +/// Current terminal appearance values needed by window chrome policy. +public struct WindowTerminalAppearanceSnapshot { + /// Current default terminal background color. + public let backgroundColor: NSColor + + /// Current default terminal background opacity. + public let backgroundOpacity: Double + + /// Current default terminal background blur. + public let backgroundBlur: GhosttyBackgroundBlur + + /// Whether terminal host layers own background fills. + public let usesHostLayerBackground: Bool + + /// Creates a terminal appearance snapshot. + public init( + backgroundColor: NSColor, + backgroundOpacity: Double, + backgroundBlur: GhosttyBackgroundBlur, + usesHostLayerBackground: Bool + ) { + self.backgroundColor = backgroundColor + self.backgroundOpacity = backgroundOpacity + self.backgroundBlur = backgroundBlur + self.usesHostLayerBackground = usesHostLayerBackground + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/LayerBackedBackdropColor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/LayerBackedBackdropColor.swift new file mode 100644 index 000000000000..e62c5273a90f --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/LayerBackedBackdropColor.swift @@ -0,0 +1,49 @@ +import AppKit +import SwiftUI + +/// Non-hit-testing AppKit color fill used where SwiftUI colors blend poorly +/// with transparent window backdrops. +struct LayerBackedBackdropColor: NSViewRepresentable { + let color: NSColor + + func makeNSView(context _: Context) -> NSView { + let view = NonHitTestingLayerBackedColorView() + view.setBackdropColor(color) + return view + } + + func updateNSView(_ nsView: NSView, context _: Context) { + (nsView as? NonHitTestingLayerBackedColorView)?.setBackdropColor(color) + } + + private final class NonHitTestingLayerBackedColorView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + layer?.isOpaque = false + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.masksToBounds = true + layer?.isOpaque = false + } + + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func setBackdropColor(_ color: NSColor) { + wantsLayer = true + CATransaction.begin() + CATransaction.setDisableActions(true) + layer?.backgroundColor = color.cgColor + layer?.isOpaque = color.alphaComponent >= 1 + CATransaction.commit() + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropApplicationResult.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropApplicationResult.swift new file mode 100644 index 000000000000..a43d6574dd22 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropApplicationResult.swift @@ -0,0 +1,14 @@ +/// Result of applying a window backdrop plan. +public struct WindowBackdropApplicationResult { + /// Whether the glass root hierarchy changed. + public let didChangeGlassRoot: Bool + + /// Whether the resulting window uses glass. + public let usesWindowGlass: Bool + + /// Creates a backdrop application result. + public init(didChangeGlassRoot: Bool, usesWindowGlass: Bool) { + self.didChangeGlassRoot = didChangeGlassRoot + self.usesWindowGlass = usesWindowGlass + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift new file mode 100644 index 000000000000..d393910eea31 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift @@ -0,0 +1,76 @@ +public import AppKit +public import CmuxWorkspaceWindow + +/// Applies resolved backdrop plans to `NSWindow` instances. +@MainActor +public final class WindowBackdropController { + private let dependencies: any WindowBackdropControllerDependencies + + /// Creates a controller with app-provided side-effect dependencies. + public init(dependencies: any WindowBackdropControllerDependencies) { + self.dependencies = dependencies + } + + /// Resolves and applies a snapshot to a window. + public func apply( + snapshot: WindowAppearanceSnapshot, + to window: NSWindow, + windowBackgroundPolicy: WindowBackgroundPolicy + ) -> WindowBackdropApplicationResult { + apply( + plan: snapshot.backdropPlan( + glassEffectAvailable: dependencies.glassEffect.isAvailable, + windowBackgroundPolicy: windowBackgroundPolicy + ), + to: window + ) + } + + /// Applies a precomputed plan to a window. + public func apply( + plan: WindowBackdropPlan, + to window: NSWindow + ) -> WindowBackdropApplicationResult { + let didChangeGlassRoot: Bool + + switch plan.hostingPhase { + case .opaqueWindowFill: + didChangeGlassRoot = dependencies.glassEffect.remove(from: window) + window.backgroundColor = plan.windowBackgroundColor + window.isOpaque = plan.windowIsOpaque + dependencies.resetCompositorBackgroundBlur(windowNumber: window.windowNumber) + case .transparentRootBackdrop: + didChangeGlassRoot = dependencies.glassEffect.remove(from: window) + window.backgroundColor = plan.windowBackgroundColor + window.isOpaque = false + if plan.shouldApplyGhosttyCompositorBlur { + dependencies.applyGhosttyCompositorBlurIfNeeded(to: window) + } else { + dependencies.resetCompositorBackgroundBlur(windowNumber: window.windowNumber) + } + case .windowGlass: + window.backgroundColor = plan.windowBackgroundColor + window.isOpaque = false + dependencies.resetCompositorBackgroundBlur(windowNumber: window.windowNumber) + if let glass = plan.glass { + didChangeGlassRoot = dependencies.glassEffect.apply( + to: window, + tintColor: glass.tintColor, + style: glass.style + ) + } else { + didChangeGlassRoot = dependencies.glassEffect.remove(from: window) + } + } + + return WindowBackdropApplicationResult( + didChangeGlassRoot: didChangeGlassRoot, + usesWindowGlass: plan.usesWindowGlass + ) + } + + /// Updates the glass tint for a window. + public func updateGlassTint(to window: NSWindow, color: NSColor?) { + dependencies.glassEffect.updateTint(to: window, color: color) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift new file mode 100644 index 000000000000..65523987f15e --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift @@ -0,0 +1,14 @@ +public import AppKit + +/// App-provided side effects needed by `WindowBackdropController`. +@MainActor +public protocol WindowBackdropControllerDependencies: AnyObject { + /// Glass-effect service used to install or remove the window glass hierarchy. + var glassEffect: any WindowGlassEffectManaging { get } + + /// Resets compositor blur for a window number. + func resetCompositorBackgroundBlur(windowNumber: Int) + + /// Applies Ghostty's compositor blur to a transparent window when needed. + func applyGhosttyCompositorBlurIfNeeded(to window: NSWindow) +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropGlassPlan.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropGlassPlan.swift new file mode 100644 index 000000000000..d458dde35efa --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropGlassPlan.swift @@ -0,0 +1,16 @@ +public import AppKit + +/// Native glass tint and style for a window backdrop plan. +public struct WindowBackdropGlassPlan { + /// Tint applied to the glass hierarchy. + public let tintColor: NSColor + + /// Native glass style. + public let style: WindowGlassEffectStyle + + /// Creates a glass plan. + public init(tintColor: NSColor, style: WindowGlassEffectStyle) { + self.tintColor = tintColor + self.style = style + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropHostingPhase.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropHostingPhase.swift new file mode 100644 index 000000000000..64214b02137f --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropHostingPhase.swift @@ -0,0 +1,11 @@ +/// AppKit hosting strategy for a window backdrop. +public enum WindowBackdropHostingPhase: String, Equatable, Sendable { + /// The window is opaque and filled directly. + case opaqueWindowFill + + /// The window is transparent and uses a root backdrop layer. + case transparentRootBackdrop + + /// The window uses native or fallback glass. + case windowGlass +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropLayer.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropLayer.swift new file mode 100644 index 000000000000..9a2642a9769d --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropLayer.swift @@ -0,0 +1,55 @@ +public import SwiftUI +import AppKit + +/// SwiftUI layer that renders the resolved backdrop for one chrome role. +public struct WindowBackdropLayer: View { + private let role: WindowBackdropRole + private let snapshot: WindowAppearanceSnapshot + + /// Creates a backdrop layer for a chrome role. + public init(role: WindowBackdropRole, snapshot: WindowAppearanceSnapshot) { + self.role = role + self.snapshot = snapshot + } + + /// Rendered backdrop body. + public var body: some View { + backdrop(for: snapshot.policy(for: role)) + } + + @ViewBuilder + private func backdrop(for policy: WindowBackdropPolicy) -> some View { + switch policy { + case let .ghosttyTerminalBackdrop(color, opacity, _): + let backdropColor = color.withAlphaComponent(opacity) + switch role { + case .windowRoot: + Color(nsColor: backdropColor) + case .terminalCanvas, .bonsplitChrome, .titlebar, .leftSidebar, .rightSidebar, .browserSurface: + LayerBackedBackdropColor(color: backdropColor) + } + case let .sidebarMaterial(materialPolicy): + ZStack { + let usingNativeLiquidGlass = materialPolicy.preferLiquidGlass && + SidebarVisualEffectBackground.liquidGlassAvailable + if let material = materialPolicy.material, + !materialPolicy.usesWindowLevelGlass { + SidebarVisualEffectBackground( + material: material, + blendingMode: materialPolicy.blendingMode, + state: materialPolicy.state, + opacity: materialPolicy.opacity, + tintColor: materialPolicy.tintColor, + cornerRadius: materialPolicy.cornerRadius, + preferLiquidGlass: materialPolicy.preferLiquidGlass + ) + } + if !materialPolicy.usesWindowLevelGlass && !usingNativeLiquidGlass { + Color(nsColor: materialPolicy.tintColor) + } + } + case .clear: + Color.clear + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift new file mode 100644 index 000000000000..f073c3c8b5e6 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift @@ -0,0 +1,63 @@ +public import AppKit +import CmuxFoundation + +/// AppKit window mutation plan for a resolved backdrop. +public struct WindowBackdropPlan { + /// Hosting phase to apply. + public let hostingPhase: WindowBackdropHostingPhase + + /// Window background color. + public let windowBackgroundColor: NSColor + + /// Whether the window should be opaque. + public let windowIsOpaque: Bool + + /// Root backdrop policy. + public let rootPolicy: WindowBackdropPolicy + + /// Glass plan, when `hostingPhase` is `.windowGlass`. + public let glass: WindowBackdropGlassPlan? + + /// Whether Ghostty compositor blur should be applied. + public let shouldApplyGhosttyCompositorBlur: Bool + + /// Creates a window backdrop plan. + public init( + hostingPhase: WindowBackdropHostingPhase, + windowBackgroundColor: NSColor, + windowIsOpaque: Bool, + rootPolicy: WindowBackdropPolicy, + glass: WindowBackdropGlassPlan?, + shouldApplyGhosttyCompositorBlur: Bool + ) { + self.hostingPhase = hostingPhase + self.windowBackgroundColor = windowBackgroundColor + self.windowIsOpaque = windowIsOpaque + self.rootPolicy = rootPolicy + self.glass = glass + self.shouldApplyGhosttyCompositorBlur = shouldApplyGhosttyCompositorBlur + } + + /// Whether the window should be transparent. + public var usesTransparentWindow: Bool { + hostingPhase != .opaqueWindowFill + } + + /// Whether the plan uses window glass. + public var usesWindowGlass: Bool { + hostingPhase == .windowGlass + } + + /// Stable identity for AppKit mutations. + public var appKitMutationID: String { + [ + hostingPhase.rawValue, + windowBackgroundColor.hexString(includeAlpha: true), + String(windowIsOpaque), + rootPolicy.identityComponent, + glass?.tintColor.hexString(includeAlpha: true) ?? "nil", + glass.map { String(describing: $0.style) } ?? "nil", + String(shouldApplyGhosttyCompositorBlur), + ].joined(separator: "|") + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPolicy.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPolicy.swift new file mode 100644 index 000000000000..b4a86e0a941b --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPolicy.swift @@ -0,0 +1,55 @@ +public import AppKit +import CmuxFoundation + +/// Backdrop rendering policy for one chrome surface. +public enum WindowBackdropPolicy { + /// A terminal-colored backdrop with the resolved opacity and renderer owner. + case ghosttyTerminalBackdrop( + color: NSColor, + opacity: CGFloat, + renderingMode: GhosttyTerminalBackdropRenderingMode + ) + + /// A sidebar AppKit material or tint policy. + case sidebarMaterial(SidebarBackdropMaterialPolicy) + + /// A fully clear surface. + case clear + + /// The host-layer color to apply for this policy, when one is needed. + public var hostLayerBackgroundColor: NSColor? { + switch self { + case let .ghosttyTerminalBackdrop(color, opacity, renderingMode): + guard renderingMode.usesWindowHostBackdrop else { return nil } + return color.withAlphaComponent(opacity) + case .sidebarMaterial, .clear: + return nil + } + } + + var identityComponent: String { + switch self { + case let .ghosttyTerminalBackdrop(color, opacity, renderingMode): + return [ + "ghosttyTerminalBackdrop", + color.hexString(includeAlpha: true), + String(format: "%.4f", Double(opacity)), + String(describing: renderingMode), + ].joined(separator: ":") + case let .sidebarMaterial(materialPolicy): + return [ + "sidebarMaterial", + String(describing: materialPolicy.material), + String(describing: materialPolicy.blendingMode), + String(describing: materialPolicy.state), + String(format: "%.4f", materialPolicy.opacity), + materialPolicy.tintColor.hexString(includeAlpha: true), + String(format: "%.4f", Double(materialPolicy.cornerRadius)), + String(materialPolicy.preferLiquidGlass), + String(materialPolicy.usesWindowLevelGlass), + ].joined(separator: ":") + case .clear: + return "clear" + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropRole.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropRole.swift new file mode 100644 index 000000000000..dbb9425c4d2b --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropRole.swift @@ -0,0 +1,23 @@ +/// A chrome surface that can receive a resolved backdrop policy. +public enum WindowBackdropRole: Sendable { + /// The full window root backdrop. + case windowRoot + + /// Terminal content canvas. + case terminalCanvas + + /// Bonsplit tab and split chrome. + case bonsplitChrome + + /// Custom titlebar band. + case titlebar + + /// Left workspace sidebar. + case leftSidebar + + /// Right tools sidebar. + case rightSidebar + + /// Browser surface background. + case browserSurface +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Border/WindowChromeBorder.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Border/WindowChromeBorder.swift new file mode 100644 index 000000000000..3dd44e28d503 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Border/WindowChromeBorder.swift @@ -0,0 +1,70 @@ +public import AppKit +public import SwiftUI + +/// One-pixel border derived from the terminal chrome background. +public struct WindowChromeBorder: View { + private let orientation: WindowChromeBorderOrientation + private let ignoresSafeAreaValue: Bool + private let backgroundColorProvider: @MainActor () -> NSColor + private let refreshNotificationName: Notification.Name? + @State private var separatorColor: NSColor + + /// Creates a chrome border with an injected background color provider. + public init( + orientation: WindowChromeBorderOrientation, + ignoresSafeArea: Bool = true, + refreshNotificationName: Notification.Name? = nil, + backgroundColorProvider: @escaping @MainActor () -> NSColor + ) { + self.orientation = orientation + self.ignoresSafeAreaValue = ignoresSafeArea + self.refreshNotificationName = refreshNotificationName + self.backgroundColorProvider = backgroundColorProvider + _separatorColor = State( + initialValue: WindowChromeColorResolver().separatorColor(forChromeBackground: backgroundColorProvider()) + ) + } + + /// Rendered border body. + public var body: some View { + if ignoresSafeAreaValue { + border.ignoresSafeArea() + } else { + border + } + } + + @ViewBuilder + private var border: some View { + let base = borderShape + .onAppear { + refreshSeparatorColor() + } + + if let refreshNotificationName { + base.onReceive(NotificationCenter.default.publisher(for: refreshNotificationName)) { _ in + refreshSeparatorColor() + } + } else { + base + } + } + + private var borderShape: some View { + Rectangle() + .fill(Color(nsColor: separatorColor)) + .frame( + maxWidth: orientation == .horizontal ? .infinity : nil, + maxHeight: orientation == .vertical ? .infinity : nil + ) + .frame( + width: orientation == .vertical ? 1 : nil, + height: orientation == .horizontal ? 1 : nil + ) + } + + private func refreshSeparatorColor() { + separatorColor = WindowChromeColorResolver() + .separatorColor(forChromeBackground: backgroundColorProvider()) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Border/WindowChromeBorderOrientation.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Border/WindowChromeBorderOrientation.swift new file mode 100644 index 000000000000..98c7a9b8f552 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Border/WindowChromeBorderOrientation.swift @@ -0,0 +1,8 @@ +/// Orientation for a one-pixel window chrome border. +public enum WindowChromeBorderOrientation: Sendable { + /// A vertical border. + case vertical + + /// A horizontal border. + case horizontal +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Color/WindowChromeColorResolver.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Color/WindowChromeColorResolver.swift new file mode 100644 index 000000000000..6a0d3b6a8aad --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Color/WindowChromeColorResolver.swift @@ -0,0 +1,88 @@ +public import AppKit +public import SwiftUI + +/// Resolves color math used by window chrome, titlebar, and backdrop policy. +public struct WindowChromeColorResolver: Sendable { + /// Creates a color resolver. + public init() {} + + /// Returns a separator color readable against the given chrome background. + public func separatorColor(forChromeBackground chrome: NSColor) -> NSColor { + let srgb = chrome.usingColorSpace(.sRGB) ?? chrome + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let luminance = 0.299 * red + 0.587 * green + 0.114 * blue + let isLight = luminance > 0.5 + let amount: CGFloat = isLight ? -0.12 : 0.16 + let separatorAlpha: CGFloat = isLight ? 0.26 : 0.36 + return NSColor( + red: min(1.0, max(0.0, red + amount)), + green: min(1.0, max(0.0, green + amount)), + blue: min(1.0, max(0.0, blue + amount)), + alpha: separatorAlpha + ) + } + + /// Returns `foreground` composited over `background` in sRGB. + public func compositedColor(_ foreground: NSColor, over background: NSColor) -> NSColor { + let foregroundColor = foreground.usingColorSpace(.sRGB) ?? foreground + let backgroundColor = background.usingColorSpace(.sRGB) ?? background + var foregroundRed: CGFloat = 0 + var foregroundGreen: CGFloat = 0 + var foregroundBlue: CGFloat = 0 + var foregroundAlpha: CGFloat = 0 + var backgroundRed: CGFloat = 0 + var backgroundGreen: CGFloat = 0 + var backgroundBlue: CGFloat = 0 + var backgroundAlpha: CGFloat = 0 + foregroundColor.getRed(&foregroundRed, green: &foregroundGreen, blue: &foregroundBlue, alpha: &foregroundAlpha) + backgroundColor.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: &backgroundAlpha) + _ = backgroundAlpha + + let alpha = max(0, min(foregroundAlpha, 1)) + return NSColor( + srgbRed: foregroundRed * alpha + backgroundRed * (1 - alpha), + green: foregroundGreen * alpha + backgroundGreen * (1 - alpha), + blue: foregroundBlue * alpha + backgroundBlue * (1 - alpha), + alpha: 1 + ) + } + + /// Returns the color scheme with stronger contrast against `backgroundColor`. + public func readableColorScheme(for backgroundColor: NSColor) -> ColorScheme { + let backgroundLuminance = relativeLuminance(backgroundColor) + let whiteContrast = contrastRatio(backgroundLuminance, 1.0) + let blackContrast = contrastRatio(backgroundLuminance, 0.0) + return whiteContrast >= blackContrast ? .dark : .light + } + + private func contrastRatio(_ lhs: CGFloat, _ rhs: CGFloat) -> CGFloat { + let lighter = max(lhs, rhs) + let darker = min(lhs, rhs) + return (lighter + 0.05) / (darker + 0.05) + } + + private func relativeLuminance(_ color: NSColor) -> CGFloat { + let srgb = color.usingColorSpace(.sRGB) ?? color + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + _ = alpha + + let linearizedRed = linearized(red) + let linearizedGreen = linearized(green) + let linearizedBlue = linearized(blue) + return 0.2126 * linearizedRed + 0.7152 * linearizedGreen + 0.0722 * linearizedBlue + } + + private func linearized(_ component: CGFloat) -> CGFloat { + component <= 0.03928 + ? component / 12.92 + : CGFloat(pow(Double((component + 0.055) / 1.055), 2.4)) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/GhosttyBackgroundBlur+WindowGlassEffectStyle.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/GhosttyBackgroundBlur+WindowGlassEffectStyle.swift new file mode 100644 index 000000000000..fc770db72388 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/GhosttyBackgroundBlur+WindowGlassEffectStyle.swift @@ -0,0 +1,16 @@ +public import CmuxFoundation + +extension GhosttyBackgroundBlur { + /// The window-glass style for this blur mode, or `nil` when the mode is not + /// backed by native macOS glass. + public var windowGlassStyle: WindowGlassEffectStyle? { + switch self { + case .macosGlassRegular: + return .regular + case .macosGlassClear: + return .clear + case .disabled, .radius: + return nil + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffect+Views.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffect+Views.swift new file mode 100644 index 000000000000..4b18e0c3cf90 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffect+Views.swift @@ -0,0 +1,318 @@ +import AppKit +import CmuxFoundation + +@MainActor +extension WindowGlassEffect { + @MainActor + final class OriginalContentLayoutState: NSObject { + let translatesAutoresizingMaskIntoConstraints: Bool + let autoresizingMask: NSView.AutoresizingMask + + init(view: NSView) { + translatesAutoresizingMaskIntoConstraints = view.translatesAutoresizingMaskIntoConstraints + autoresizingMask = view.autoresizingMask + } + + func restore(to view: NSView) { + view.translatesAutoresizingMaskIntoConstraints = translatesAutoresizingMaskIntoConstraints + view.autoresizingMask = autoresizingMask + } + } + + final class GlassBackgroundView: NSView { + private let effectView: NSView + private let tintOverlay: NSView + private let usesNativeGlass: Bool + private var effectTopConstraint: NSLayoutConstraint! + private weak var observedWindow: NSWindow? + private var currentTintColor: NSColor? + + init( + frame: NSRect, + topOffset: CGFloat, + tintColor: NSColor?, + style: WindowGlassEffectStyle?, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + if let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { + effectView = glassClass.init(frame: .zero) + usesNativeGlass = true + } else { + let fallbackView = NSVisualEffectView(frame: .zero) + fallbackView.blendingMode = .behindWindow + fallbackView.material = .underWindowBackground + fallbackView.state = .active + effectView = fallbackView + usesNativeGlass = false + } + tintOverlay = NSView(frame: .zero) + + super.init(frame: frame) + + identifier = WindowGlassEffect.backgroundIdentifier + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.isOpaque = false + + effectView.translatesAutoresizingMaskIntoConstraints = false + effectView.wantsLayer = true + addSubview(effectView) + effectTopConstraint = effectView.topAnchor.constraint(equalTo: topAnchor, constant: topOffset) + NSLayoutConstraint.activate([ + effectTopConstraint, + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + tintOverlay.translatesAutoresizingMaskIntoConstraints = false + tintOverlay.wantsLayer = true + tintOverlay.alphaValue = 0 + addSubview(tintOverlay, positioned: .above, relativeTo: effectView) + NSLayoutConstraint.activate([ + tintOverlay.topAnchor.constraint(equalTo: effectView.topAnchor), + tintOverlay.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), + tintOverlay.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), + tintOverlay.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), + ]) + + configure( + tintColor: tintColor, + style: style, + cornerRadius: cornerRadius, + isKeyWindow: isKeyWindow + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateObservedWindow(window) + } + + func updateTopOffset(_ offset: CGFloat) { + effectTopConstraint.constant = offset + } + + func configure( + tintColor: NSColor?, + style: WindowGlassEffectStyle?, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + currentTintColor = tintColor + effectView.layer?.cornerRadius = cornerRadius ?? 0 + if usesNativeGlass { + updateNativeGlassConfiguration( + on: effectView, + color: tintColor, + style: style, + cornerRadius: cornerRadius + ) + updateInactiveTintOverlay(tintColor: tintColor, isKeyWindow: isKeyWindow) + } else if let tintColor { + effectView.layer?.masksToBounds = cornerRadius != nil + let fallbackTint = tintColor.withAlphaComponent(min(tintColor.alphaComponent, 0.45)) + tintOverlay.layer?.backgroundColor = fallbackTint.cgColor + tintOverlay.alphaValue = 1 + } else { + effectView.layer?.masksToBounds = cornerRadius != nil + tintOverlay.layer?.backgroundColor = nil + tintOverlay.alphaValue = 0 + } + } + + private func updateObservedWindow(_ window: NSWindow?) { + guard usesNativeGlass else { return } + if let observedWindow, observedWindow === window { + updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: observedWindow.isKeyWindow) + return + } + + if let observedWindow { + NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeKeyNotification, object: observedWindow) + NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: observedWindow) + } + observedWindow = window + guard let window else { return } + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: window + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResignKey(_:)), + name: NSWindow.didResignKeyNotification, + object: window + ) + updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: window.isKeyWindow) + } + + @objc private func windowDidBecomeKey(_ notification: Notification) { + updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: true) + } + + @objc private func windowDidResignKey(_ notification: Notification) { + updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: false) + } + + private func updateInactiveTintOverlay(tintColor: NSColor?, isKeyWindow: Bool) { + guard let tintColor else { + tintOverlay.layer?.backgroundColor = nil + tintOverlay.alphaValue = 0 + return + } + + tintOverlay.layer?.backgroundColor = tintColor.withAdjustedSaturation(by: 1.2).cgColor + tintOverlay.alphaValue = isKeyWindow ? 0 : (tintColor.isLightColor ? 0.35 : 0.85) + } + + private func updateNativeGlassConfiguration( + on glassView: NSView, + color: NSColor?, + style: WindowGlassEffectStyle?, + cornerRadius: CGFloat? + ) { + let tintSelector = NSSelectorFromString("setTintColor:") + if glassView.responds(to: tintSelector) { + glassView.perform(tintSelector, with: color) + } + + if let cornerRadius { + let cornerRadiusSelector = NSSelectorFromString("setCornerRadius:") + if glassView.responds(to: cornerRadiusSelector) { + typealias CornerRadiusSetter = @convention(c) (AnyObject, Selector, CGFloat) -> Void + guard let implementation = glassView.method(for: cornerRadiusSelector) else { return } + let setter = unsafeBitCast(implementation, to: CornerRadiusSetter.self) + setter(glassView, cornerRadiusSelector, cornerRadius) + } + } + + if let style { + let styleSelector = NSSelectorFromString("setStyle:") + guard glassView.responds(to: styleSelector) else { return } + typealias StyleSetter = @convention(c) (AnyObject, Selector, Int) -> Void + guard let implementation = glassView.method(for: styleSelector) else { return } + let setter = unsafeBitCast(implementation, to: StyleSetter.self) + setter(glassView, styleSelector, style.rawNSGlassEffectViewStyle) + } + } + } + + final class GlassRootView: NSView { + let foregroundContainer = NSView(frame: .zero) + weak var originalContentView: NSView? + + private let backgroundView: GlassBackgroundView + + override var isOpaque: Bool { false } + + init( + frame: NSRect, + topOffset: CGFloat, + tintColor: NSColor?, + style: WindowGlassEffectStyle?, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + backgroundView = GlassBackgroundView( + frame: frame, + topOffset: topOffset, + tintColor: tintColor, + style: style, + cornerRadius: cornerRadius, + isKeyWindow: isKeyWindow + ) + + super.init(frame: frame) + + identifier = WindowGlassEffect.rootIdentifier + autoresizingMask = [.width, .height] + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.isOpaque = false + + backgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(backgroundView) + + foregroundContainer.identifier = WindowGlassEffect.foregroundContainerViewIdentifier + foregroundContainer.frame = bounds + foregroundContainer.translatesAutoresizingMaskIntoConstraints = false + foregroundContainer.wantsLayer = true + foregroundContainer.layer?.backgroundColor = NSColor.clear.cgColor + foregroundContainer.layer?.isOpaque = false + addSubview(foregroundContainer, positioned: .above, relativeTo: backgroundView) + + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + + foregroundContainer.topAnchor.constraint(equalTo: topAnchor), + foregroundContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + foregroundContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + foregroundContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func attachOriginalContentView(_ contentView: NSView) { + originalContentView = contentView + contentView.removeFromSuperview() + contentView.frame = foregroundContainer.bounds + contentView.translatesAutoresizingMaskIntoConstraints = true + contentView.autoresizingMask = [.width, .height] + foregroundContainer.addSubview(contentView, positioned: .below, relativeTo: nil) + } + + func configure( + topOffset: CGFloat, + tintColor: NSColor?, + style: WindowGlassEffectStyle?, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + backgroundView.updateTopOffset(topOffset) + backgroundView.configure( + tintColor: tintColor, + style: style, + cornerRadius: cornerRadius, + isKeyWindow: isKeyWindow + ) + } + } +} + +private extension NSColor { + func withAdjustedSaturation(by factor: CGFloat) -> NSColor { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + let color = usingColorSpace(.sRGB) ?? self + color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + return NSColor( + hue: hue, + saturation: min(max(saturation * factor, 0), 1), + brightness: brightness, + alpha: alpha + ) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffect.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffect.swift new file mode 100644 index 000000000000..3b0203fb5e49 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffect.swift @@ -0,0 +1,259 @@ +public import AppKit +import ObjectiveC + +/// Applies native `NSGlassEffectView` window chrome when available and preserves +/// the legacy `NSVisualEffectView` fallback for older macOS releases. +@MainActor +public final class WindowGlassEffect: WindowGlassEffectManaging { + static let backgroundIdentifier = NSUserInterfaceItemIdentifier("cmux.windowGlassBackground") + static let rootIdentifier = NSUserInterfaceItemIdentifier("cmux.windowGlassRoot") + static let foregroundContainerViewIdentifier = NSUserInterfaceItemIdentifier("cmux.windowGlassForeground") + + private static var glassRootViewKey: UInt8 = 0 + private static var fallbackBackgroundViewKey: UInt8 = 0 + private static var originalContentViewKey: UInt8 = 0 + private static var originalContentLayoutStateKey: UInt8 = 0 + + /// Creates a window glass effect service. + public init() {} + + /// Identifier assigned to the native or fallback glass background view. + public var backgroundViewIdentifier: NSUserInterfaceItemIdentifier { + Self.backgroundIdentifier + } + + /// Identifier assigned to the root glass container view. + public var rootViewIdentifier: NSUserInterfaceItemIdentifier { + Self.rootIdentifier + } + + /// Identifier assigned to the foreground content container view. + public var foregroundContainerIdentifier: NSUserInterfaceItemIdentifier { + Self.foregroundContainerViewIdentifier + } + + /// Whether native `NSGlassEffectView` is present on this macOS runtime. + public var isAvailable: Bool { + NSClassFromString("NSGlassEffectView") != nil + } + + /// Applies the glass treatment to `window`. + /// + /// - Returns: `true` when the window's root glass hierarchy changed. + @discardableResult + public func apply( + to window: NSWindow, + tintColor: NSColor? = nil, + style: WindowGlassEffectStyle? = nil + ) -> Bool { + guard let currentContentView = window.contentView else { return false } + guard isAvailable else { + return applyFallback(to: window, contentView: currentContentView, tintColor: tintColor) + } + removeFallback(from: window) + + let topOffset = glassTopOffset(for: window, contentView: currentContentView) + let cornerRadius = windowCornerRadius(for: window) + + if let rootView = activeRootView(for: window) { + rootView.configure( + topOffset: topOffset, + tintColor: tintColor, + style: style, + cornerRadius: cornerRadius, + isKeyWindow: window.isKeyWindow + ) + return false + } + + let originalContentView = currentContentView + let layoutState = OriginalContentLayoutState(view: originalContentView) + let rootView = GlassRootView( + frame: originalContentView.frame, + topOffset: topOffset, + tintColor: tintColor, + style: style, + cornerRadius: cornerRadius, + isKeyWindow: window.isKeyWindow + ) + window.contentView = rootView + rootView.attachOriginalContentView(originalContentView) + + objc_setAssociatedObject(window, &Self.glassRootViewKey, rootView, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, &Self.originalContentViewKey, originalContentView, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, &Self.originalContentLayoutStateKey, layoutState, .OBJC_ASSOCIATION_RETAIN) + return true + } + + /// Updates the tint on an already-applied native or fallback glass effect. + public func updateTint(to window: NSWindow, color: NSColor?) { + if let rootView = activeRootView(for: window) { + rootView.configure( + topOffset: glassTopOffset(for: window, contentView: window.contentView), + tintColor: color, + style: nil, + cornerRadius: windowCornerRadius(for: window), + isKeyWindow: window.isKeyWindow + ) + } else if let fallbackView = fallbackBackgroundView(for: window) { + fallbackView.configure( + tintColor: color, + style: nil, + cornerRadius: windowCornerRadius(for: window), + isKeyWindow: window.isKeyWindow + ) + } + } + + /// Returns the foreground container installed above the glass background. + public func foregroundContainer(for window: NSWindow) -> NSView? { + activeRootView(for: window)?.foregroundContainer + } + + /// Returns the original content view preserved by the glass root. + public func originalContentView(for window: NSWindow) -> NSView? { + if let rootView = activeRootView(for: window), + let originalContentView = rootView.originalContentView { + return originalContentView + } + return objc_getAssociatedObject(window, &Self.originalContentViewKey) as? NSView + } + + /// Returns the overlay installation target inside the glass foreground. + public func portalInstallationTarget(for window: NSWindow) -> WindowContentOverlayInstallationTarget? { + guard let rootView = activeRootView(for: window), + let originalContentView = originalContentView(for: window), + originalContentView.superview === rootView.foregroundContainer else { + return nil + } + return WindowContentOverlayInstallationTarget( + container: rootView.foregroundContainer, + reference: originalContentView + ) + } + + /// Removes native or fallback glass from `window`. + /// + /// - Returns: `true` when any glass hierarchy was removed. + @discardableResult + public func remove(from window: NSWindow) -> Bool { + if !removeNativeRoot(from: window) { + return removeFallback(from: window) + } + removeFallback(from: window) + return true + } + + private func activeRootView(for window: NSWindow) -> GlassRootView? { + if let rootView = window.contentView as? GlassRootView { + return rootView + } + guard let rootView = objc_getAssociatedObject(window, &Self.glassRootViewKey) as? GlassRootView, + window.contentView === rootView else { + return nil + } + return rootView + } + + private func fallbackBackgroundView(for window: NSWindow) -> GlassBackgroundView? { + objc_getAssociatedObject(window, &Self.fallbackBackgroundViewKey) as? GlassBackgroundView + } + + @discardableResult + private func applyFallback( + to window: NSWindow, + contentView: NSView, + tintColor: NSColor? + ) -> Bool { + guard let themeFrame = contentView.superview else { return false } + let cornerRadius = windowCornerRadius(for: window) + if let fallbackView = fallbackBackgroundView(for: window) { + if fallbackView.superview !== themeFrame { + fallbackView.removeFromSuperview() + attachFallback(fallbackView, to: themeFrame, below: contentView) + } + fallbackView.configure( + tintColor: tintColor, + style: nil, + cornerRadius: cornerRadius, + isKeyWindow: window.isKeyWindow + ) + return false + } + + let fallbackView = GlassBackgroundView( + frame: themeFrame.bounds, + topOffset: 0, + tintColor: tintColor, + style: nil, + cornerRadius: cornerRadius, + isKeyWindow: window.isKeyWindow + ) + attachFallback(fallbackView, to: themeFrame, below: contentView) + objc_setAssociatedObject(window, &Self.fallbackBackgroundViewKey, fallbackView, .OBJC_ASSOCIATION_RETAIN) + return true + } + + private func attachFallback( + _ fallbackView: GlassBackgroundView, + to themeFrame: NSView, + below contentView: NSView + ) { + fallbackView.removeFromSuperview() + fallbackView.translatesAutoresizingMaskIntoConstraints = false + themeFrame.addSubview(fallbackView, positioned: .below, relativeTo: contentView) + NSLayoutConstraint.activate([ + fallbackView.topAnchor.constraint(equalTo: themeFrame.topAnchor), + fallbackView.bottomAnchor.constraint(equalTo: themeFrame.bottomAnchor), + fallbackView.leadingAnchor.constraint(equalTo: themeFrame.leadingAnchor), + fallbackView.trailingAnchor.constraint(equalTo: themeFrame.trailingAnchor), + ]) + } + + private func glassTopOffset(for window: NSWindow, contentView: NSView?) -> CGFloat { + guard let themeFrame = contentView?.superview ?? window.contentView?.superview else { + return 0 + } + return -max(0, themeFrame.safeAreaInsets.top) + } + + private func windowCornerRadius(for window: NSWindow) -> CGFloat? { + guard window.responds(to: Selector(("_cornerRadius"))) else { + return nil + } + return window.value(forKey: "_cornerRadius") as? CGFloat + } + + @discardableResult + private func removeNativeRoot(from window: NSWindow) -> Bool { + guard let rootView = activeRootView(for: window) else { + return false + } + + if let originalContentView = originalContentView(for: window) { + originalContentView.removeFromSuperview() + originalContentView.frame = rootView.bounds + if let layoutState = objc_getAssociatedObject( + window, + &Self.originalContentLayoutStateKey + ) as? OriginalContentLayoutState { + layoutState.restore(to: originalContentView) + } + window.contentView = originalContentView + } + + objc_setAssociatedObject(window, &Self.glassRootViewKey, nil, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, &Self.originalContentViewKey, nil, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, &Self.originalContentLayoutStateKey, nil, .OBJC_ASSOCIATION_RETAIN) + return true + } + + @discardableResult + private func removeFallback(from window: NSWindow) -> Bool { + guard let fallbackView = fallbackBackgroundView(for: window) else { return false } + fallbackView.removeFromSuperview() + objc_setAssociatedObject(window, &Self.fallbackBackgroundViewKey, nil, .OBJC_ASSOCIATION_RETAIN) + return true + } + +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffectManaging.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffectManaging.swift new file mode 100644 index 000000000000..53815471f659 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffectManaging.swift @@ -0,0 +1,35 @@ +public import AppKit + +/// Seam for applying and inspecting the window glass hierarchy. +@MainActor +public protocol WindowGlassEffectManaging: AnyObject { + /// Identifier assigned to the glass background view. + var backgroundViewIdentifier: NSUserInterfaceItemIdentifier { get } + + /// Whether native `NSGlassEffectView` is available on this runtime. + var isAvailable: Bool { get } + + /// Applies the glass hierarchy to a window. + @discardableResult + func apply( + to window: NSWindow, + tintColor: NSColor?, + style: WindowGlassEffectStyle? + ) -> Bool + + /// Updates the tint on the current glass hierarchy. + func updateTint(to window: NSWindow, color: NSColor?) + + /// Removes the current glass hierarchy from a window. + @discardableResult + func remove(from window: NSWindow) -> Bool + + /// Returns the foreground container created above the glass background. + func foregroundContainer(for window: NSWindow) -> NSView? + + /// Returns the original window content view preserved by the glass root. + func originalContentView(for window: NSWindow) -> NSView? + + /// Returns the overlay installation target inside the glass foreground. + func portalInstallationTarget(for window: NSWindow) -> WindowContentOverlayInstallationTarget? +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffectStyle.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffectStyle.swift new file mode 100644 index 000000000000..8f285e83b8ab --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassEffectStyle.swift @@ -0,0 +1,17 @@ +/// Native window-glass style applied when `NSGlassEffectView` is available. +public enum WindowGlassEffectStyle: Equatable, Sendable { + /// The default macOS glass appearance. + case regular + + /// The clear macOS glass appearance used by Ghostty's clear glass mode. + case clear + + var rawNSGlassEffectViewStyle: Int { + switch self { + case .regular: + return 0 + case .clear: + return 1 + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassSettingsSnapshot.swift new file mode 100644 index 000000000000..4ff9ddf770b7 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Glass/WindowGlassSettingsSnapshot.swift @@ -0,0 +1,81 @@ +public import AppKit +public import CmuxFoundation +public import CmuxWorkspaceWindow + +/// Persisted and terminal-driven settings for the window-level glass root. +public struct WindowGlassSettingsSnapshot { + /// Raw `sidebarBlendMode` value. + public let sidebarBlendModeRawValue: String + + /// Whether background glass is explicitly enabled. + public let isEnabled: Bool + + /// Glass tint hex value. + public let tintHex: String + + /// Glass tint opacity. + public let tintOpacity: Double + + /// Current Ghostty background blur mode. + public let terminalBackgroundBlur: GhosttyBackgroundBlur + + /// Current terminal-derived glass tint, when a Ghostty glass mode owns it. + public let terminalGlassTintColor: NSColor? + + /// Creates a window glass settings snapshot. + public init( + sidebarBlendModeRawValue: String, + isEnabled: Bool, + tintHex: String, + tintOpacity: Double, + terminalBackgroundBlur: GhosttyBackgroundBlur = .disabled, + terminalGlassTintColor: NSColor? = nil + ) { + self.sidebarBlendModeRawValue = sidebarBlendModeRawValue + self.isEnabled = isEnabled + self.tintHex = tintHex + self.tintOpacity = tintOpacity + self.terminalBackgroundBlur = terminalBackgroundBlur + self.terminalGlassTintColor = terminalGlassTintColor + } + + /// Resolved tint color for window glass. + public var tintColor: NSColor { + if let terminalGlassTintColor, terminalBackgroundBlur.isMacOSGlassStyle { + return terminalGlassTintColor + } + return (NSColor(hex: tintHex) ?? .black).withAlphaComponent(tintOpacity) + } + + /// Native glass style for the current settings. + public var style: WindowGlassEffectStyle { + terminalBackgroundBlur.windowGlassStyle ?? .regular + } + + /// Whether these settings request window glass. + public func shouldApply( + glassEffectAvailable: Bool, + windowBackgroundPolicy: WindowBackgroundPolicy + ) -> Bool { + if terminalBackgroundBlur.isMacOSGlassStyle { + return true + } + return windowBackgroundPolicy.shouldApplyWindowGlass( + sidebarBlendMode: sidebarBlendModeRawValue, + bgGlassEnabled: isEnabled, + glassEffectAvailable: glassEffectAvailable + ) + } + + /// Stable identity for AppKit mutations. + public var appKitMutationID: String { + [ + sidebarBlendModeRawValue, + String(isEnabled), + tintHex, + String(format: "%.4f", tintOpacity), + String(describing: terminalBackgroundBlur), + terminalGlassTintColor?.hexString(includeAlpha: true) ?? "nil", + ].joined(separator: "|") + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Overlay/WindowContentOverlayInstallationTarget.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Overlay/WindowContentOverlayInstallationTarget.swift new file mode 100644 index 000000000000..d2904e9baeed --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Overlay/WindowContentOverlayInstallationTarget.swift @@ -0,0 +1,16 @@ +public import AppKit + +/// AppKit container/reference pair used when installing overlays above window content. +public struct WindowContentOverlayInstallationTarget { + /// Container that should receive the overlay view. + public let container: NSView + + /// Sibling reference view used to position the overlay in the AppKit hierarchy. + public let reference: NSView + + /// Creates an overlay installation target. + public init(container: NSView, reference: NSView) { + self.container = container + self.reference = reference + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Overlay/WindowContentOverlayTargetResolver.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Overlay/WindowContentOverlayTargetResolver.swift new file mode 100644 index 000000000000..de82a4f53b0a --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Overlay/WindowContentOverlayTargetResolver.swift @@ -0,0 +1,26 @@ +public import AppKit + +/// Resolves the AppKit insertion point for window-level overlays. +@MainActor +public struct WindowContentOverlayTargetResolver { + private let glassEffect: any WindowGlassEffectManaging + + /// Creates a resolver using an injected glass-effect seam. + public init(glassEffect: any WindowGlassEffectManaging) { + self.glassEffect = glassEffect + } + + /// Returns the glass foreground target when glass is installed, otherwise + /// falls back to the window theme frame below `window.contentView`. + public func installationTarget(for window: NSWindow) -> WindowContentOverlayInstallationTarget? { + if let glassTarget = glassEffect.portalInstallationTarget(for: window) { + return glassTarget + } + + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + return WindowContentOverlayInstallationTarget(container: themeFrame, reference: contentView) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropMaterialPolicy.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropMaterialPolicy.swift new file mode 100644 index 000000000000..3eb36b4a2aa5 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropMaterialPolicy.swift @@ -0,0 +1,49 @@ +public import AppKit + +/// Resolved AppKit material settings for a sidebar backdrop. +public struct SidebarBackdropMaterialPolicy { + /// AppKit material to use, or `nil` for tint-only rendering. + public let material: NSVisualEffectView.Material? + + /// AppKit blending mode for the material. + public let blendingMode: NSVisualEffectView.BlendingMode + + /// AppKit active/inactive state for the material. + public let state: NSVisualEffectView.State + + /// Opacity applied to the material view. + public let opacity: Double + + /// Tint color applied above the material or into native glass. + public let tintColor: NSColor + + /// Corner radius applied to the material view. + public let cornerRadius: CGFloat + + /// Whether native `NSGlassEffectView` should be preferred. + public let preferLiquidGlass: Bool + + /// Whether the material is supplied by the window-level glass root. + public let usesWindowLevelGlass: Bool + + /// Creates resolved material settings for a sidebar backdrop. + public init( + material: NSVisualEffectView.Material?, + blendingMode: NSVisualEffectView.BlendingMode, + state: NSVisualEffectView.State, + opacity: Double, + tintColor: NSColor, + cornerRadius: CGFloat, + preferLiquidGlass: Bool, + usesWindowLevelGlass: Bool + ) { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.opacity = opacity + self.tintColor = tintColor + self.cornerRadius = cornerRadius + self.preferLiquidGlass = preferLiquidGlass + self.usesWindowLevelGlass = usesWindowLevelGlass + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift new file mode 100644 index 000000000000..7574818fca77 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift @@ -0,0 +1,111 @@ +import AppKit +public import SwiftUI +import CmuxFoundation + +/// Persisted sidebar backdrop settings captured as a value. +public struct SidebarBackdropSettingsSnapshot { + /// Raw `sidebarMaterial` value. + public let materialRawValue: String + + /// Raw `sidebarBlendMode` value. + public let blendModeRawValue: String + + /// Raw `sidebarState` value. + public let stateRawValue: String + + /// Base tint hex value. + public let tintHex: String + + /// Light-mode tint override. + public let tintHexLight: String? + + /// Dark-mode tint override. + public let tintHexDark: String? + + /// Tint opacity. + public let tintOpacity: Double + + /// Material corner radius. + public let cornerRadius: Double + + /// Material blur opacity. + public let blurOpacity: Double + + /// Color scheme used to pick light/dark tint overrides. + public let colorScheme: ColorScheme + + /// Creates a sidebar backdrop settings snapshot. + public init( + materialRawValue: String, + blendModeRawValue: String, + stateRawValue: String, + tintHex: String, + tintHexLight: String?, + tintHexDark: String?, + tintOpacity: Double, + cornerRadius: Double, + blurOpacity: Double, + colorScheme: ColorScheme + ) { + self.materialRawValue = materialRawValue + self.blendModeRawValue = blendModeRawValue + self.stateRawValue = stateRawValue + self.tintHex = tintHex + self.tintHexLight = tintHexLight + self.tintHexDark = tintHexDark + self.tintOpacity = tintOpacity + self.cornerRadius = cornerRadius + self.blurOpacity = blurOpacity + self.colorScheme = colorScheme + } + + /// Resolved AppKit material policy for these settings. + public var materialPolicy: SidebarBackdropMaterialPolicy { + let materialOption = WindowChromeSidebarMaterialOption(rawValue: materialRawValue) + let blendingMode = WindowChromeSidebarBlendModeOption(rawValue: blendModeRawValue)?.mode ?? .behindWindow + let state = WindowChromeSidebarStateOption(rawValue: stateRawValue)?.state ?? .active + let resolvedHex: String + if colorScheme == .dark, let tintHexDark { + resolvedHex = tintHexDark + } else if colorScheme == .light, let tintHexLight { + resolvedHex = tintHexLight + } else { + resolvedHex = tintHex + } + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) + .withAlphaComponent(tintOpacity) + let preferLiquidGlass = materialOption?.usesLiquidGlass ?? false + let usesWindowLevelGlass = preferLiquidGlass && blendingMode == .behindWindow + + return SidebarBackdropMaterialPolicy( + material: materialOption?.material, + blendingMode: blendingMode, + state: state, + opacity: blurOpacity, + tintColor: tintColor, + cornerRadius: CGFloat(max(0, cornerRadius)), + preferLiquidGlass: preferLiquidGlass, + usesWindowLevelGlass: usesWindowLevelGlass + ) + } + + /// Stable identity for AppKit mutations. + public var appKitMutationID: String { + [ + materialRawValue, + blendModeRawValue, + stateRawValue, + tintHex, + tintHexLight ?? "nil", + tintHexDark ?? "nil", + identityComponent(tintOpacity), + identityComponent(cornerRadius), + identityComponent(blurOpacity), + String(describing: colorScheme), + ].joined(separator: "|") + } + + private func identityComponent(_ value: Double) -> String { + String(format: "%.4f", value) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarVisualEffectBackground.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarVisualEffectBackground.swift new file mode 100644 index 000000000000..a35e0a943caf --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarVisualEffectBackground.swift @@ -0,0 +1,73 @@ +import AppKit +import SwiftUI + +/// Wrapper view that tries `NSGlassEffectView` when requested and falls back to +/// `NSVisualEffectView`. +struct SidebarVisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let state: NSVisualEffectView.State + let opacity: Double + let tintColor: NSColor? + let cornerRadius: CGFloat + let preferLiquidGlass: Bool + + init( + material: NSVisualEffectView.Material = .hudWindow, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + state: NSVisualEffectView.State = .active, + opacity: Double = 1.0, + tintColor: NSColor? = nil, + cornerRadius: CGFloat = 0, + preferLiquidGlass: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.opacity = opacity + self.tintColor = tintColor + self.cornerRadius = cornerRadius + self.preferLiquidGlass = preferLiquidGlass + } + + static var liquidGlassAvailable: Bool { + NSClassFromString("NSGlassEffectView") != nil + } + + func makeNSView(context: Context) -> NSView { + if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let glass = glassClass.init(frame: .zero) + glass.autoresizingMask = [.width, .height] + glass.wantsLayer = true + return glass + } + + let view = NSVisualEffectView() + view.autoresizingMask = [.width, .height] + view.wantsLayer = true + view.layerContentsRedrawPolicy = .onSetNeedsDisplay + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + let clampedOpacity = max(0.0, min(1.0, opacity)) + if nsView.className == "NSGlassEffectView" { + nsView.alphaValue = clampedOpacity + nsView.layer?.cornerRadius = cornerRadius + nsView.layer?.masksToBounds = cornerRadius > 0 + + let selector = NSSelectorFromString("setTintColor:") + if nsView.responds(to: selector) { + nsView.perform(selector, with: tintColor) + } + } else if let visualEffect = nsView as? NSVisualEffectView { + visualEffect.material = material + visualEffect.blendingMode = blendingMode + visualEffect.state = state + visualEffect.alphaValue = clampedOpacity + visualEffect.layer?.cornerRadius = cornerRadius + visualEffect.layer?.masksToBounds = cornerRadius > 0 + visualEffect.needsDisplay = true + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarBlendModeOption.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarBlendModeOption.swift new file mode 100644 index 000000000000..92062dbc6b09 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarBlendModeOption.swift @@ -0,0 +1,29 @@ +public import AppKit + +/// Sidebar material blend mode values persisted in `sidebarBlendMode`. +public enum WindowChromeSidebarBlendModeOption: String, CaseIterable, Identifiable, Sendable { + /// Blend the material behind the window. + case behindWindow + + /// Blend the material within the window. + case withinWindow + + /// Stable identity equal to the persisted raw value. + public var id: String { rawValue } + + /// Localized display title. + public var title: String { + switch self { + case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") + case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") + } + } + + /// AppKit blending mode for this option. + public var mode: NSVisualEffectView.BlendingMode { + switch self { + case .behindWindow: return .behindWindow + case .withinWindow: return .withinWindow + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarMaterialOption.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarMaterialOption.swift new file mode 100644 index 000000000000..8ebb27d617b8 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarMaterialOption.swift @@ -0,0 +1,89 @@ +public import AppKit + +/// Sidebar material option values persisted in `sidebarMaterial`. +public enum WindowChromeSidebarMaterialOption: String, CaseIterable, Identifiable, Sendable { + /// No AppKit material. + case none + + /// Native macOS liquid glass when available. + case liquidGlass + + /// AppKit sidebar material. + case sidebar + + /// AppKit HUD window material. + case hudWindow + + /// AppKit menu material. + case menu + + /// AppKit popover material. + case popover + + /// AppKit under-window background material. + case underWindowBackground + + /// AppKit window background material. + case windowBackground + + /// AppKit content background material. + case contentBackground + + /// AppKit full-screen UI material. + case fullScreenUI + + /// AppKit sheet material. + case sheet + + /// AppKit header view material. + case headerView + + /// AppKit tooltip material. + case toolTip + + /// Stable identity equal to the persisted raw value. + public var id: String { rawValue } + + /// Localized display title. + public var title: String { + switch self { + case .none: return String(localized: "settings.material.none", defaultValue: "None") + case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") + case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") + case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") + case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") + case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") + case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") + case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") + case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") + case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") + case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") + case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") + case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") + } + } + + /// Whether this option prefers native `NSGlassEffectView`. + public var usesLiquidGlass: Bool { + self == .liquidGlass + } + + /// AppKit material for this option, or `nil` when no material is drawn. + public var material: NSVisualEffectView.Material? { + switch self { + case .none: return nil + case .liquidGlass: return .underWindowBackground + case .sidebar: return .sidebar + case .hudWindow: return .hudWindow + case .menu: return .menu + case .popover: return .popover + case .underWindowBackground: return .underWindowBackground + case .windowBackground: return .windowBackground + case .contentBackground: return .contentBackground + case .fullScreenUI: return .fullScreenUI + case .sheet: return .sheet + case .headerView: return .headerView + case .toolTip: return .toolTip + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarPresetOption.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarPresetOption.swift new file mode 100644 index 000000000000..3bb2ccaed81b --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarPresetOption.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Sidebar appearance preset values persisted in `sidebarPreset`. +public enum WindowChromeSidebarPresetOption: String, CaseIterable, Identifiable, Sendable { + /// Native sidebar style. + case nativeSidebar + + /// Raycast-like dark glass behind the window. + case glassBehind + + /// Soft translucent blur. + case softBlur + + /// Popover glass style. + case popoverGlass + + /// HUD glass style. + case hudGlass + + /// Under-window style. + case underWindow + + /// Stable identity equal to the persisted raw value. + public var id: String { rawValue } + + /// Localized display title. + public var title: String { + switch self { + case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") + case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") + case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") + case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") + case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") + case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") + } + } + + /// Material selected by this preset. + public var material: WindowChromeSidebarMaterialOption { + switch self { + case .nativeSidebar: return .sidebar + case .glassBehind: return .sidebar + case .softBlur: return .sidebar + case .popoverGlass: return .popover + case .hudGlass: return .hudWindow + case .underWindow: return .underWindowBackground + } + } + + /// Blend mode selected by this preset. + public var blendMode: WindowChromeSidebarBlendModeOption { + switch self { + case .nativeSidebar: return .withinWindow + case .glassBehind: return .behindWindow + case .softBlur: return .behindWindow + case .popoverGlass: return .behindWindow + case .hudGlass: return .withinWindow + case .underWindow: return .withinWindow + } + } + + /// Material state selected by this preset. + public var state: WindowChromeSidebarStateOption { + switch self { + case .nativeSidebar: return .followWindow + case .glassBehind: return .active + case .softBlur: return .active + case .popoverGlass: return .active + case .hudGlass: return .active + case .underWindow: return .followWindow + } + } + + /// Tint hex selected by this preset. + public var tintHex: String { + switch self { + case .nativeSidebar: return "#000000" + case .glassBehind: return "#000000" + case .softBlur: return "#000000" + case .popoverGlass: return "#000000" + case .hudGlass: return "#000000" + case .underWindow: return "#000000" + } + } + + /// Tint opacity selected by this preset. + public var tintOpacity: Double { + switch self { + case .nativeSidebar: return 0.18 + case .glassBehind: return 0.36 + case .softBlur: return 0.28 + case .popoverGlass: return 0.10 + case .hudGlass: return 0.62 + case .underWindow: return 0.14 + } + } + + /// Corner radius selected by this preset. + public var cornerRadius: Double { + switch self { + case .nativeSidebar: return 0.0 + case .glassBehind: return 0.0 + case .softBlur: return 0.0 + case .popoverGlass: return 10.0 + case .hudGlass: return 10.0 + case .underWindow: return 6.0 + } + } + + /// Blur opacity selected by this preset. + public var blurOpacity: Double { + switch self { + case .nativeSidebar: return 1.0 + case .glassBehind: return 0.6 + case .softBlur: return 0.45 + case .popoverGlass: return 0.9 + case .hudGlass: return 0.98 + case .underWindow: return 0.9 + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarStateOption.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarStateOption.swift new file mode 100644 index 000000000000..b1454e862034 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarStateOption.swift @@ -0,0 +1,34 @@ +public import AppKit + +/// Sidebar material state values persisted in `sidebarState`. +public enum WindowChromeSidebarStateOption: String, CaseIterable, Identifiable, Sendable { + /// Force the material active. + case active + + /// Force the material inactive. + case inactive + + /// Follow the containing window's active state. + case followWindow + + /// Stable identity equal to the persisted raw value. + public var id: String { rawValue } + + /// Localized display title. + public var title: String { + switch self { + case .active: return String(localized: "settings.state.active", defaultValue: "Active") + case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") + case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") + } + } + + /// AppKit visual-effect state for this option. + public var state: NSVisualEffectView.State { + switch self { + case .active: return .active + case .inactive: return .inactive + case .followWindow: return .followsWindowActiveState + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift new file mode 100644 index 000000000000..0c55e2b9196e --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift @@ -0,0 +1,17 @@ +/// Legacy default sidebar tint values. +public struct WindowChromeSidebarTintDefaults: Sendable { + /// Default tint hex value. + public let hex: String + + /// Default tint opacity. + public let opacity: Double + + /// Creates sidebar tint defaults. + public init( + hex: String = "#000000", + opacity: Double = 0.18 + ) { + self.hex = hex + self.opacity = opacity + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/GhosttyTerminalBackdropRenderingMode.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/GhosttyTerminalBackdropRenderingMode.swift new file mode 100644 index 000000000000..1bedd4bb4cbb --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/GhosttyTerminalBackdropRenderingMode.swift @@ -0,0 +1,13 @@ +/// Rendering owner for terminal backdrop pixels. +public enum GhosttyTerminalBackdropRenderingMode: Equatable, Sendable { + /// The AppKit window host paints the terminal backdrop. + case windowHostBackdrop + + /// Ghostty's renderer paints the terminal background image. + case ghosttyRendererOwnedBackgroundImage + + /// Whether the AppKit window host owns the backdrop pixels. + public var usesWindowHostBackdrop: Bool { + self == .windowHostBackdrop + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/TerminalSurfaceBackgroundFillOwner.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/TerminalSurfaceBackgroundFillOwner.swift new file mode 100644 index 000000000000..d61be4866239 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/TerminalSurfaceBackgroundFillOwner.swift @@ -0,0 +1,14 @@ +/// Layer responsible for painting a terminal surface background. +public enum TerminalSurfaceBackgroundFillOwner: Equatable, Sendable { + /// The terminal hosting view should paint the resolved background color. + case surfaceHostLayer + + /// The shared root backdrop should remain the only visible background fill. + case sharedWindowBackdrop + + /// The Bonsplit pane backdrop should remain the only visible background fill. + case bonsplitPaneBackdrop + + /// Ghostty's renderer owns the background instead of cmux host layers. + case ghosttyNativeRenderer +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/TerminalSurfaceBackgroundFillPlan.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/TerminalSurfaceBackgroundFillPlan.swift new file mode 100644 index 000000000000..c9c4803fe88f --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/TerminalSurface/TerminalSurfaceBackgroundFillPlan.swift @@ -0,0 +1,90 @@ +public import AppKit + +/// Resolved background painting decision for one terminal surface. +public struct TerminalSurfaceBackgroundFillPlan { + /// The layer or renderer that owns the visible terminal background. + public let owner: TerminalSurfaceBackgroundFillOwner + + /// Color to apply to the terminal host layer, or clear when another layer owns the fill. + public let hostLayerColor: NSColor + + /// Whether a host-layer fill must subtract itself from the shared window backdrop. + public let clearsSharedWindowBackdrop: Bool + + /// Creates a terminal surface background fill plan. + public init( + owner: TerminalSurfaceBackgroundFillOwner, + hostLayerColor: NSColor, + clearsSharedWindowBackdrop: Bool + ) { + self.owner = owner + self.hostLayerColor = hostLayerColor + self.clearsSharedWindowBackdrop = clearsSharedWindowBackdrop + } + + /// Whether the terminal host layer should paint a non-clear fill. + public var usesHostLayerFill: Bool { + owner == .surfaceHostLayer + } + + /// Compact debug label for the selected backdrop owner. + public var logBackdropLabel: String { + switch owner { + case .surfaceHostLayer: + return "terminal" + case .sharedWindowBackdrop: + return "shared" + case .bonsplitPaneBackdrop: + return "bonsplit-pane" + case .ghosttyNativeRenderer: + return "ghostty-native" + } + } + + /// Returns the debug-log source label for the selected owner. + public func logSource(hasSurfaceOverride: Bool) -> String { + switch owner { + case .surfaceHostLayer: + return hasSurfaceOverride ? "surfaceOverride" : "defaultBackground" + case .sharedWindowBackdrop: + return "sharedWindowBackdrop" + case .bonsplitPaneBackdrop: + return "bonsplitPaneBackdrop" + case .ghosttyNativeRenderer: + return "ghosttyNativeBackground" + } + } + + /// Computes the terminal background owner and host-layer color for current appearance state. + public static func resolve( + renderingMode: GhosttyTerminalBackdropRenderingMode, + surfaceBackgroundColor: NSColor?, + defaultBackgroundColor: NSColor, + backgroundOpacity: Double, + sharesWindowBackdrop: Bool, + usesBonsplitPaneBackdrop: Bool + ) -> Self { + let resolvedColor = (surfaceBackgroundColor ?? defaultBackgroundColor) + .withAlphaComponent(WindowAppearanceSnapshot.clampedOpacity(backgroundOpacity)) + let owner: TerminalSurfaceBackgroundFillOwner + let usesPaneLocalSurfaceFill = surfaceBackgroundColor != nil && + renderingMode.usesWindowHostBackdrop && + !usesBonsplitPaneBackdrop + if !renderingMode.usesWindowHostBackdrop { + owner = .ghosttyNativeRenderer + } else if usesPaneLocalSurfaceFill { + owner = .surfaceHostLayer + } else if !sharesWindowBackdrop && !usesBonsplitPaneBackdrop { + owner = .surfaceHostLayer + } else if sharesWindowBackdrop { + owner = .sharedWindowBackdrop + } else { + owner = .bonsplitPaneBackdrop + } + return Self( + owner: owner, + hostLayerColor: owner == .surfaceHostLayer ? resolvedColor : .clear, + clearsSharedWindowBackdrop: usesPaneLocalSurfaceFill && sharesWindowBackdrop + ) + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Titlebar/NativeTitlebarBackdropCoordinator.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Titlebar/NativeTitlebarBackdropCoordinator.swift new file mode 100644 index 000000000000..9a6fbb0799a1 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Titlebar/NativeTitlebarBackdropCoordinator.swift @@ -0,0 +1,291 @@ +public import AppKit +import ObjectiveC + +/// Coordinates native AppKit titlebar backdrop hiding and restoration. +@MainActor +public final class NativeTitlebarBackdropCoordinator { + private static var unifiedTitlebarLayerAppliedKey: UInt8 = 0 + private static var unifiedTitlebarLayerColorKey: UInt8 = 0 + private static var unifiedTitlebarLayerOpaqueKey: UInt8 = 0 + private static var unifiedTitlebarHiddenAppliedKey: UInt8 = 0 + private static var unifiedTitlebarHiddenKey: UInt8 = 0 + private static var unifiedTitlebarTransparencyAppliedKey: UInt8 = 0 + private static var unifiedTitlebarTransparencyKey: UInt8 = 0 + + private let fullscreenAuxiliaryWindows: @MainActor () -> [NSWindow] + + /// Creates a coordinator with an injected provider for fullscreen auxiliary windows. + public init(fullscreenAuxiliaryWindows: @escaping @MainActor () -> [NSWindow]) { + self.fullscreenAuxiliaryWindows = fullscreenAuxiliaryWindows + } + + /// Removes the cmux native titlebar backdrop view from a window. + public func removeNativeTitlebarBackdrop(in window: NSWindow) { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { return } + + let identifier = NSUserInterfaceItemIdentifier("cmux.nativeTitlebarBackdrop") + let existing = themeFrame.subviews.first { $0.identifier == identifier } as? NativeTitlebarBackdropView + existing?.removeFromSuperview() + } + + /// Hides or restores AppKit's native titlebar backdrop views. + public func syncNativeTitlebarBackdrop( + in window: NSWindow, + enabled: Bool, + usesGlassStyle: Bool + ) { + guard let titlebarContainer = nativeTitlebarContainer(in: window) else { return } + let titlebarViews = collectNativeTitlebarViews(in: titlebarContainer) + + if enabled { + rememberNativeTitlebarTransparencyState(window) + rememberNativeTitlebarBackdropState( + titlebarContainer: titlebarContainer, + titlebarView: titlebarViews.titlebarView, + titlebarBackgroundViews: titlebarViews.titlebarBackgroundViews, + effectViews: titlebarViews.effectViews + ) + } else { + restoreNativeTitlebarBackdropState( + titlebarContainer: titlebarContainer, + titlebarView: titlebarViews.titlebarView, + titlebarBackgroundViews: titlebarViews.titlebarBackgroundViews, + effectViews: titlebarViews.effectViews + ) + restoreNativeTitlebarTransparencyState(window) + return + } + + titlebarContainer.wantsLayer = true + titlebarContainer.layer?.backgroundColor = usesGlassStyle ? NSColor.clear.cgColor : nil + titlebarContainer.layer?.isOpaque = false + titlebarViews.titlebarView?.wantsLayer = true + titlebarViews.titlebarView?.layer?.backgroundColor = usesGlassStyle ? NSColor.clear.cgColor : nil + titlebarViews.titlebarView?.layer?.isOpaque = false + for titlebarBackgroundView in titlebarViews.titlebarBackgroundViews { + titlebarBackgroundView.isHidden = true + } + for effectView in titlebarViews.effectViews { + effectView.isHidden = true + } + window.titlebarAppearsTransparent = true + } + + /// Hides standard titlebar controls when fullscreen or minimal mode needs custom controls. + public func setTitlebarControlsHidden( + _ hidden: Bool, + in window: NSWindow, + isMinimalMode: Bool + ) { + let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls") + let shouldHide = hidden || isMinimalMode + for accessory in window.titlebarAccessoryViewControllers { + if accessory.view.identifier == controlsId { + accessory.isHidden = shouldHide + accessory.view.alphaValue = shouldHide ? 0 : 1 + } + } + } + + private func rememberNativeTitlebarBackdropState( + titlebarContainer: NSView, + titlebarView: NSView?, + titlebarBackgroundViews: [NSView], + effectViews: [NSView] + ) { + rememberNativeTitlebarLayerState(titlebarContainer) + if let titlebarView { + rememberNativeTitlebarLayerState(titlebarView) + } + for titlebarBackgroundView in titlebarBackgroundViews { + rememberNativeTitlebarHiddenState(titlebarBackgroundView) + } + for effectView in effectViews { + rememberNativeTitlebarHiddenState(effectView) + } + } + + private func restoreNativeTitlebarBackdropState( + titlebarContainer: NSView, + titlebarView: NSView?, + titlebarBackgroundViews: [NSView], + effectViews: [NSView] + ) { + restoreNativeTitlebarLayerState(titlebarContainer) + if let titlebarView { + restoreNativeTitlebarLayerState(titlebarView) + } + for titlebarBackgroundView in titlebarBackgroundViews { + restoreNativeTitlebarHiddenState(titlebarBackgroundView) + } + for effectView in effectViews { + restoreNativeTitlebarHiddenState(effectView) + } + } + + private func rememberNativeTitlebarLayerState(_ view: NSView) { + guard objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey) == nil else { return } + + objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey, NSNumber(value: true), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerColorKey, view.layer?.backgroundColor ?? NSNull(), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerOpaqueKey, view.layer.map { NSNumber(value: $0.isOpaque) } ?? NSNull(), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func restoreNativeTitlebarLayerState(_ view: NSView) { + guard objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey) != nil else { return } + + if let storedColor = objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerColorKey), + !(storedColor is NSNull) { + view.layer?.backgroundColor = (storedColor as! CGColor) + } else { + view.layer?.backgroundColor = nil + } + + if let isOpaque = objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerOpaqueKey) as? NSNumber { + view.layer?.isOpaque = isOpaque.boolValue + } + + objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerColorKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerOpaqueKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func rememberNativeTitlebarHiddenState(_ view: NSView) { + guard objc_getAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey) == nil else { return } + + objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey, NSNumber(value: true), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenKey, NSNumber(value: view.isHidden), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func restoreNativeTitlebarHiddenState(_ view: NSView) { + guard objc_getAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey) != nil else { return } + + if let hidden = objc_getAssociatedObject(view, &Self.unifiedTitlebarHiddenKey) as? NSNumber { + view.isHidden = hidden.boolValue + } + + objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func rememberNativeTitlebarTransparencyState(_ window: NSWindow) { + guard objc_getAssociatedObject(window, &Self.unifiedTitlebarTransparencyAppliedKey) == nil else { return } + objc_setAssociatedObject(window, &Self.unifiedTitlebarTransparencyAppliedKey, NSNumber(value: true), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &Self.unifiedTitlebarTransparencyKey, NSNumber(value: window.titlebarAppearsTransparent), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func restoreNativeTitlebarTransparencyState(_ window: NSWindow) { + guard objc_getAssociatedObject(window, &Self.unifiedTitlebarTransparencyAppliedKey) != nil else { return } + + if let value = objc_getAssociatedObject(window, &Self.unifiedTitlebarTransparencyKey) as? NSNumber { + window.titlebarAppearsTransparent = value.boolValue + } + + objc_setAssociatedObject(window, &Self.unifiedTitlebarTransparencyAppliedKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &Self.unifiedTitlebarTransparencyKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + private func nativeTitlebarContainer(in window: NSWindow) -> NSView? { + if !window.styleMask.contains(.fullScreen) { + return window.contentView.flatMap { + firstNativeDescendant( + in: nativeRootView(from: $0), + className: "NSTitlebarContainerView", + includeRoot: true + ) + } + } + + for candidate in fullscreenAuxiliaryWindows() where candidate.className == "NSToolbarFullScreenWindow" { + guard candidate.parent == window else { continue } + if let contentView = candidate.contentView { + return firstNativeDescendant( + in: nativeRootView(from: contentView), + className: "NSTitlebarContainerView", + includeRoot: true + ) + } + } + + return nil + } + + private func nativeRootView(from view: NSView) -> NSView { + var root = view + while let superview = root.superview { + root = superview + } + return root + } + + private struct NativeTitlebarViews { + var titlebarView: NSView? + var titlebarBackgroundViews: [NSView] = [] + var effectViews: [NSView] = [] + } + + private func collectNativeTitlebarViews(in view: NSView) -> NativeTitlebarViews { + var result = NativeTitlebarViews() + collectNativeTitlebarViews(in: view, result: &result, includeRoot: true) + return result + } + + private func collectNativeTitlebarViews( + in view: NSView, + result: inout NativeTitlebarViews, + includeRoot: Bool = false + ) { + if includeRoot { + collectNativeTitlebarView(view, result: &result) + } + for subview in view.subviews { + collectNativeTitlebarView(subview, result: &result) + collectNativeTitlebarViews(in: subview, result: &result) + } + } + + private func collectNativeTitlebarView(_ view: NSView, result: inout NativeTitlebarViews) { + switch String(describing: type(of: view)) { + case "NSTitlebarView" where result.titlebarView == nil: + result.titlebarView = view + case "NSTitlebarBackgroundView": + result.titlebarBackgroundViews.append(view) + case "NSVisualEffectView": + result.effectViews.append(view) + default: + break + } + } + + private func firstNativeDescendant( + in view: NSView, + className: String, + includeRoot: Bool = false + ) -> NSView? { + if includeRoot, String(describing: type(of: view)) == className { + return view + } + + for subview in view.subviews { + if String(describing: type(of: subview)) == className { + return subview + } + if let found = firstNativeDescendant(in: subview, className: className) { + return found + } + } + + return nil + } +} + +private final class NativeTitlebarBackdropView: NSView { + override var isOpaque: Bool { + layer?.isOpaque ?? false + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Titlebar/TitlebarLeadingInsetReader.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Titlebar/TitlebarLeadingInsetReader.swift new file mode 100644 index 000000000000..e52d5ce5028a --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Titlebar/TitlebarLeadingInsetReader.swift @@ -0,0 +1,44 @@ +public import AppKit +public import SwiftUI + +/// Reads the leading inset required to clear traffic lights and titlebar accessories. +public struct TitlebarLeadingInsetReader: NSViewRepresentable { + @Binding private var inset: CGFloat + private let baseLeadingInset: @MainActor () -> CGFloat + + /// Creates a titlebar leading inset reader. + public init( + inset: Binding, + baseLeadingInset: @escaping @MainActor () -> CGFloat + ) { + _inset = inset + self.baseLeadingInset = baseLeadingInset + } + + /// Creates the passthrough AppKit reader view. + public func makeNSView(context: Context) -> NSView { + let view = TitlebarLeadingInsetPassthroughView() + view.setFrameSize(.zero) + return view + } + + /// Updates the SwiftUI binding with the current leading inset. + public func updateNSView(_ nsView: NSView, context: Context) { + Task { @MainActor in + guard let window = nsView.window else { return } + var leading = baseLeadingInset() + for accessory in window.titlebarAccessoryViewControllers + where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { + leading += accessory.view.frame.width + } + if leading != inset { + inset = leading + } + } + } +} + +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift new file mode 100644 index 000000000000..8c9d3af9c611 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift @@ -0,0 +1,111 @@ +import AppKit +import CmuxFoundation +import CmuxWorkspaceWindow +import SwiftUI +import Testing + +@testable import CmuxAppKitSupportUI + +@Suite struct WindowAppearanceResolverTests { + @Test func resolverBuildsSnapshotFromInjectedTerminalAppearanceAndSettings() { + let resolver = WindowAppearanceResolver( + terminalAppearance: WindowTerminalAppearanceSnapshot( + backgroundColor: NSColor(hex: "#272822") ?? .black, + backgroundOpacity: 0.72, + backgroundBlur: .disabled, + usesHostLayerBackground: true + ) + ) + + let snapshot = resolver.current(settings: makeSettings( + unifySurfaceBackdrops: false, + sidebarBlendMode: "behindWindow", + sidebarTintHexDark: "#FF0000", + sidebarTintOpacity: 0.4, + bgGlassEnabled: true + )) + + #expect(snapshot.terminalRenderingMode == .windowHostBackdrop) + #expect(snapshot.terminalBackgroundOpacity == 0.72) + #expect(snapshot.sidebarContentColorScheme == .dark) + + guard case let .sidebarMaterial(sidebarPolicy) = snapshot.policy(for: .leftSidebar) else { + Issue.record("Expected separate left sidebar material policy") + return + } + #expect(sidebarPolicy.blendingMode == .behindWindow) + #expect(sidebarPolicy.tintColor.hexString(includeAlpha: true) == "#FF000066") + + let plan = snapshot.backdropPlan( + glassEffectAvailable: true, + windowBackgroundPolicy: makeWindowBackgroundPolicy() + ) + #expect(plan.hostingPhase == .windowGlass) + #expect(plan.usesWindowGlass) + #expect(plan.glass?.style == .regular) + } + + @Test func ghosttyMacOSGlassStyleForcesClearRootAndTerminalTintedGlass() { + let resolver = WindowAppearanceResolver( + terminalAppearance: WindowTerminalAppearanceSnapshot( + backgroundColor: NSColor(hex: "#272822") ?? .black, + backgroundOpacity: 1, + backgroundBlur: .macosGlassClear, + usesHostLayerBackground: true + ) + ) + + let snapshot = resolver.current(settings: makeSettings( + unifySurfaceBackdrops: true, + sidebarBlendMode: "withinWindow", + bgGlassEnabled: false + )) + + guard case .clear = snapshot.policy(for: .windowRoot) else { + Issue.record("Ghostty glass styles should leave the window root clear") + return + } + #expect(snapshot.windowGlassSettings.style == .clear) + + let plan = snapshot.backdropPlan( + glassEffectAvailable: true, + windowBackgroundPolicy: makeWindowBackgroundPolicy() + ) + #expect(plan.hostingPhase == .windowGlass) + #expect(plan.glass?.tintColor.hexString(includeAlpha: true) == "#272822FF") + } + + private func makeSettings( + unifySurfaceBackdrops: Bool, + sidebarBlendMode: String, + sidebarTintHexDark: String? = nil, + sidebarTintOpacity: Double = WindowChromeSidebarTintDefaults().opacity, + bgGlassEnabled: Bool + ) -> WindowAppearanceUserSettingsSnapshot { + WindowAppearanceUserSettingsSnapshot( + unifySurfaceBackdrops: unifySurfaceBackdrops, + colorScheme: .dark, + sidebarMaterial: WindowChromeSidebarMaterialOption.sidebar.rawValue, + sidebarBlendMode: sidebarBlendMode, + sidebarState: WindowChromeSidebarStateOption.followWindow.rawValue, + sidebarTintHex: WindowChromeSidebarTintDefaults().hex, + sidebarTintHexLight: nil, + sidebarTintHexDark: sidebarTintHexDark, + sidebarTintOpacity: sidebarTintOpacity, + sidebarCornerRadius: 0, + sidebarBlurOpacity: 1, + bgGlassEnabled: bgGlassEnabled, + bgGlassTintHex: "#000000", + bgGlassTintOpacity: 0.03 + ) + } + + private func makeWindowBackgroundPolicy() -> WindowBackgroundPolicy { + WindowBackgroundPolicy(settings: FakeWindowBackgroundSettings()) + } +} + +private struct FakeWindowBackgroundSettings: WindowBackgroundSettingsReading { + var sidebarBlendModeRawValue = "withinWindow" + var isBackgroundGlassEnabled = false +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift new file mode 100644 index 000000000000..95f31164e57c --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift @@ -0,0 +1,172 @@ +import AppKit +import Testing + +@testable import CmuxAppKitSupportUI + +@MainActor +@Suite struct WindowBackdropControllerTests { + @Test func opaqueWindowFillRemovesGlassAndResetsCompositorBlur() { + let dependencies = FakeBackdropDependencies() + dependencies.glass.removeResult = true + let controller = WindowBackdropController(dependencies: dependencies) + let window = makeWindow() + + let result = controller.apply( + plan: WindowBackdropPlan( + hostingPhase: .opaqueWindowFill, + windowBackgroundColor: .systemRed, + windowIsOpaque: true, + rootPolicy: .clear, + glass: nil, + shouldApplyGhosttyCompositorBlur: false + ), + to: window + ) + + #expect(result.didChangeGlassRoot) + #expect(!result.usesWindowGlass) + #expect(dependencies.glass.removeCallCount == 1) + #expect(dependencies.glass.applyCalls.isEmpty) + #expect(dependencies.resetBlurWindowNumbers.count == 1) + #expect(dependencies.appliedBlurWindows.isEmpty) + #expect(window.isOpaque) + #expect(window.backgroundColor == .systemRed) + } + + @Test func transparentRootBackdropRemovesGlassAndAppliesGhosttyBlurWhenRequested() { + let dependencies = FakeBackdropDependencies() + let controller = WindowBackdropController(dependencies: dependencies) + let window = makeWindow() + + let result = controller.apply( + plan: WindowBackdropPlan( + hostingPhase: .transparentRootBackdrop, + windowBackgroundColor: .clear, + windowIsOpaque: false, + rootPolicy: .clear, + glass: nil, + shouldApplyGhosttyCompositorBlur: true + ), + to: window + ) + + #expect(!result.usesWindowGlass) + #expect(dependencies.glass.removeCallCount == 1) + #expect(dependencies.resetBlurWindowNumbers.isEmpty) + #expect(dependencies.appliedBlurWindows.first === window) + #expect(!window.isOpaque) + #expect(window.backgroundColor == .clear) + } + + @Test func windowGlassPlanAppliesInjectedGlassEffectAndResetsCompositorBlur() { + let dependencies = FakeBackdropDependencies() + dependencies.glass.applyResult = true + let controller = WindowBackdropController(dependencies: dependencies) + let window = makeWindow() + let tintColor = NSColor.systemBlue.withAlphaComponent(0.4) + + let result = controller.apply( + plan: WindowBackdropPlan( + hostingPhase: .windowGlass, + windowBackgroundColor: .white.withAlphaComponent(0.001), + windowIsOpaque: false, + rootPolicy: .clear, + glass: WindowBackdropGlassPlan(tintColor: tintColor, style: .clear), + shouldApplyGhosttyCompositorBlur: false + ), + to: window + ) + + #expect(result.didChangeGlassRoot) + #expect(result.usesWindowGlass) + #expect(dependencies.glass.removeCallCount == 0) + #expect(dependencies.glass.applyCalls.count == 1) + #expect(dependencies.glass.applyCalls.first?.window === window) + #expect(dependencies.glass.applyCalls.first?.tintColor == tintColor) + #expect(dependencies.glass.applyCalls.first?.style == .clear) + #expect(dependencies.resetBlurWindowNumbers.count == 1) + #expect(dependencies.appliedBlurWindows.isEmpty) + #expect(!window.isOpaque) + } + + private func makeWindow() -> NSWindow { + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let window = NSWindow( + contentRect: contentView.bounds, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = contentView + return window + } +} + +@MainActor +private final class FakeBackdropDependencies: WindowBackdropControllerDependencies { + let glass = FakeGlassEffect() + var resetBlurWindowNumbers: [Int] = [] + var appliedBlurWindows: [NSWindow] = [] + + var glassEffect: any WindowGlassEffectManaging { + glass + } + + func resetCompositorBackgroundBlur(windowNumber: Int) { + resetBlurWindowNumbers.append(windowNumber) + } + + func applyGhosttyCompositorBlurIfNeeded(to window: NSWindow) { + appliedBlurWindows.append(window) + } +} + +@MainActor +private final class FakeGlassEffect: WindowGlassEffectManaging { + struct ApplyCall { + let window: NSWindow + let tintColor: NSColor? + let style: WindowGlassEffectStyle? + } + + var backgroundViewIdentifier = NSUserInterfaceItemIdentifier("fake.background") + var isAvailable = true + var applyResult = false + var removeResult = false + var applyCalls: [ApplyCall] = [] + var updateTintCalls: [(window: NSWindow, color: NSColor?)] = [] + var removeCallCount = 0 + var foregroundContainerResult: NSView? + var originalContentViewResult: NSView? + var portalInstallationTargetResult: WindowContentOverlayInstallationTarget? + + func apply( + to window: NSWindow, + tintColor: NSColor?, + style: WindowGlassEffectStyle? + ) -> Bool { + applyCalls.append(ApplyCall(window: window, tintColor: tintColor, style: style)) + return applyResult + } + + func updateTint(to window: NSWindow, color: NSColor?) { + updateTintCalls.append((window: window, color: color)) + } + + func remove(from window: NSWindow) -> Bool { + removeCallCount += 1 + return removeResult + } + + func foregroundContainer(for window: NSWindow) -> NSView? { + foregroundContainerResult + } + + func originalContentView(for window: NSWindow) -> NSView? { + originalContentViewResult + } + + func portalInstallationTarget(for window: NSWindow) -> WindowContentOverlayInstallationTarget? { + portalInstallationTargetResult + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Overlay/WindowContentOverlayTargetResolverTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Overlay/WindowContentOverlayTargetResolverTests.swift new file mode 100644 index 000000000000..3a77a720af79 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Overlay/WindowContentOverlayTargetResolverTests.swift @@ -0,0 +1,81 @@ +import AppKit +import Testing + +@testable import CmuxAppKitSupportUI + +@MainActor +@Suite struct WindowContentOverlayTargetResolverTests { + @Test func resolverPrefersInjectedGlassForegroundTarget() { + let glass = FakeOverlayGlassEffect() + let resolver = WindowContentOverlayTargetResolver(glassEffect: glass) + let window = makeWindow() + let container = NSView() + let reference = NSView() + glass.portalInstallationTargetResult = WindowContentOverlayInstallationTarget( + container: container, + reference: reference + ) + + let target = resolver.installationTarget(for: window) + + #expect(target?.container === container) + #expect(target?.reference === reference) + } + + @Test func resolverFallsBackToThemeFrameAndContentView() { + let glass = FakeOverlayGlassEffect() + let resolver = WindowContentOverlayTargetResolver(glassEffect: glass) + let window = makeWindow() + let contentView = window.contentView + + let target = resolver.installationTarget(for: window) + + #expect(target?.container === contentView?.superview) + #expect(target?.reference === contentView) + } + + private func makeWindow() -> NSWindow { + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let window = NSWindow( + contentRect: contentView.bounds, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = contentView + return window + } +} + +@MainActor +private final class FakeOverlayGlassEffect: WindowGlassEffectManaging { + var backgroundViewIdentifier = NSUserInterfaceItemIdentifier("fake.overlay.background") + var isAvailable = true + var portalInstallationTargetResult: WindowContentOverlayInstallationTarget? + + func apply( + to window: NSWindow, + tintColor: NSColor?, + style: WindowGlassEffectStyle? + ) -> Bool { + false + } + + func updateTint(to window: NSWindow, color: NSColor?) {} + + func remove(from window: NSWindow) -> Bool { + false + } + + func foregroundContainer(for window: NSWindow) -> NSView? { + nil + } + + func originalContentView(for window: NSWindow) -> NSView? { + nil + } + + func portalInstallationTarget(for window: NSWindow) -> WindowContentOverlayInstallationTarget? { + portalInstallationTargetResult + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Titlebar/TitlebarLeadingInsetPassthroughViewTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Titlebar/TitlebarLeadingInsetPassthroughViewTests.swift new file mode 100644 index 000000000000..08ef2c9c1ad7 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Titlebar/TitlebarLeadingInsetPassthroughViewTests.swift @@ -0,0 +1,17 @@ +import AppKit +import Testing + +@testable import CmuxAppKitSupportUI + +@MainActor +@Suite struct TitlebarLeadingInsetPassthroughViewTests { + @Test func leadingInsetViewDoesNotParticipateInHitTesting() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + #expect(view.hitTest(NSPoint(x: 20, y: 10)) == nil) + } + + @Test func leadingInsetViewCannotMoveWindowViaMouseDown() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + #expect(view.mouseDownCanMoveWindow == false) + } +} diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index bd43f6801465..b59d0dd5970d 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import CmuxAppKitSupportUI import ObjectiveC import SwiftUI import WebKit @@ -2041,6 +2042,7 @@ final class WindowBrowserPortal: NSObject { private weak var window: NSWindow? private let hostView = WindowBrowserHostView(frame: .zero) + private let chromeComposition = AppWindowChromeComposition() private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var hasDeferredFullSyncScheduled = false @@ -2333,14 +2335,10 @@ final class WindowBrowserPortal: NSObject { } private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { - if let glassTarget = WindowGlassEffect.portalInstallationTarget(for: window) { - return glassTarget - } - - guard let contentView = window.contentView else { return nil } - - guard let themeFrame = contentView.superview else { return nil } - return (themeFrame, contentView) + guard let target = chromeComposition + .contentOverlayTargetResolver + .installationTarget(for: window) else { return nil } + return (target.container, target.reference) } private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 20b23d022ef8..d6fd84edd8f0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -37,18 +37,6 @@ private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") let tmuxWorkspacePaneOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.tmuxWorkspacePane.overlay.container") -private func windowContentOverlayInstallationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { - if let glassTarget = WindowGlassEffect.portalInstallationTarget(for: window) { - return glassTarget - } - - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { - return nil - } - return (themeFrame, contentView) -} - @MainActor private final class CommandPaletteOverlayContainerView: NSView { var capturesMouseEvents = false @@ -71,17 +59,6 @@ private final class PassthroughWindowOverlayContainerView: NSView { } } -@MainActor -private final class NativeTitlebarBackdropView: NSView { - override var isOpaque: Bool { - layer?.isOpaque ?? false - } - - override func hitTest(_ point: NSPoint) -> NSView? { - nil - } -} - #if DEBUG private func debugCommandPaletteWindowSummary(_ window: NSWindow?) -> String { guard let window else { return "nil" } @@ -152,6 +129,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { private weak var window: NSWindow? private let containerView = CommandPaletteOverlayContainerView(frame: .zero) private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private let chromeComposition = AppWindowChromeComposition() private var installConstraints: [NSLayoutConstraint] = [] private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? @@ -189,7 +167,9 @@ private final class WindowCommandPaletteOverlayController: NSObject { @discardableResult private func ensureInstalled() -> Bool { guard let window, - let target = windowContentOverlayInstallationTarget(for: window) else { return false } + let target = chromeComposition + .contentOverlayTargetResolver + .installationTarget(for: window) else { return false } if containerView.superview !== target.container || installedReferenceView !== target.reference { NSLayoutConstraint.deactivate(installConstraints) @@ -209,7 +189,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { cmuxDebugLog( "palette.overlay.install container=\(String(describing: type(of: target.container))) " + "reference=\(String(describing: type(of: target.reference))) " + - "glass=\(WindowGlassEffect.portalInstallationTarget(for: window) != nil ? 1 : 0)" + "glass=\(chromeComposition.glassEffect.portalInstallationTarget(for: window) != nil ? 1 : 0)" ) #endif } @@ -615,7 +595,9 @@ private final class WindowCommandPaletteOverlayController: NSObject { func underlyingResponder(atWindowPoint windowPoint: NSPoint) -> NSResponder? { guard let window, - let target = windowContentOverlayInstallationTarget(for: window) else { + let target = chromeComposition + .contentOverlayTargetResolver + .installationTarget(for: window) else { return nil } @@ -646,6 +628,7 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { private let containerView = PassthroughWindowOverlayContainerView(frame: .zero) private let model = TmuxWorkspacePaneOverlayModel() private let hostingView: NSHostingView + private let chromeComposition = AppWindowChromeComposition() private var installConstraints: [NSLayoutConstraint] = [] private weak var installedReferenceView: NSView? private var lastRenderState: TmuxWorkspacePaneOverlayRenderState? @@ -683,7 +666,9 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { @discardableResult private func ensureInstalled() -> Bool { guard let window, - let target = windowContentOverlayInstallationTarget(for: window) else { return false } + let target = chromeComposition + .contentOverlayTargetResolver + .installationTarget(for: window) else { return false } if containerView.superview !== target.container || installedReferenceView !== target.reference { NSLayoutConstraint.deactivate(installConstraints) @@ -846,8 +831,11 @@ private func fileDropOverlay( } @discardableResult +@MainActor func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) -> Bool { - guard let target = windowContentOverlayInstallationTarget(for: window) else { return false } + guard let target = AppWindowChromeComposition() + .contentOverlayTargetResolver + .installationTarget(for: window) else { return false } let existingOverlay = (objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView) @@ -873,6 +861,7 @@ func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) -> Bool return true } +@MainActor private func installFileDropOverlayWhenReady( on window: NSWindow, tabManager: TabManager, @@ -1087,6 +1076,7 @@ struct ContentView: View { @State private var commandPaletteShouldFocusWorkspaceDescriptionEditor = false @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool + private let windowChrome = AppWindowChromeComposition() private struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID @@ -1918,7 +1908,11 @@ struct ContentView: View { } .overlay(alignment: .leading) { if rightSidebarVisible { - WindowChromeBorder(orientation: .vertical) + WindowChromeBorder( + orientation: .vertical, + refreshNotificationName: .ghosttyDefaultBackgroundDidChange, + backgroundColorProvider: { GhosttyBackgroundTheme.currentColor() } + ) } } @@ -1979,8 +1973,8 @@ struct ContentView: View { @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults().opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults().hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue @@ -1996,21 +1990,23 @@ struct ContentView: View { private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } private var windowAppearanceSnapshot: WindowAppearanceSnapshot { _ = titlebarThemeGeneration - return WindowAppearanceSnapshot.current( - unifySurfaceBackdrops: sidebarMatchTerminalBackground, - colorScheme: AppearanceSettings.colorScheme(for: appearanceMode, fallback: colorScheme), - sidebarMaterial: sidebarMaterial, - sidebarBlendMode: sidebarBlendMode, - sidebarState: sidebarStateSetting, - sidebarTintHex: sidebarTintHex, - sidebarTintHexLight: sidebarTintHexLight, - sidebarTintHexDark: sidebarTintHexDark, - sidebarTintOpacity: sidebarTintOpacity, - sidebarCornerRadius: sidebarCornerRadius, - sidebarBlurOpacity: sidebarBlurOpacity, - bgGlassEnabled: bgGlassEnabled, - bgGlassTintHex: bgGlassTintHex, - bgGlassTintOpacity: bgGlassTintOpacity + return windowChrome.appearanceSnapshot( + settings: WindowAppearanceUserSettingsSnapshot( + unifySurfaceBackdrops: sidebarMatchTerminalBackground, + colorScheme: AppearanceSettings.colorScheme(for: appearanceMode, fallback: colorScheme), + sidebarMaterial: sidebarMaterial, + sidebarBlendMode: sidebarBlendMode, + sidebarState: sidebarStateSetting, + sidebarTintHex: sidebarTintHex, + sidebarTintHexLight: sidebarTintHexLight, + sidebarTintHexDark: sidebarTintHexDark, + sidebarTintOpacity: sidebarTintOpacity, + sidebarCornerRadius: sidebarCornerRadius, + sidebarBlurOpacity: sidebarBlurOpacity, + bgGlassEnabled: bgGlassEnabled, + bgGlassTintHex: bgGlassTintHex, + bgGlassTintOpacity: bgGlassTintOpacity + ) ) } @@ -2095,7 +2091,10 @@ struct ContentView: View { // view draggable (which breaks drag gestures like tab reordering). WindowDragHandleView() - TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) + TitlebarLeadingInsetReader( + inset: $titlebarLeadingInset, + baseLeadingInset: { MinimalModeTitlebarDebugSettings.trafficLightTitlebarLeadingInset() } + ) .allowsHitTesting(false) HStack(spacing: 8) { @@ -2135,7 +2134,11 @@ struct ContentView: View { .contentShape(Rectangle()) .background(TitlebarDoubleClickMonitorView()) .overlay(alignment: .bottom) { - WindowChromeBorder(orientation: .horizontal) + WindowChromeBorder( + orientation: .horizontal, + refreshNotificationName: .ghosttyDefaultBackgroundDidChange, + backgroundColorProvider: { GhosttyBackgroundTheme.currentColor() } + ) .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) } } @@ -2982,7 +2985,11 @@ struct ContentView: View { guard let window = notification.object as? NSWindow, window === observedWindow else { return } isFullScreen = true - setTitlebarControlsHidden(true, in: window) + windowChrome.nativeTitlebarBackdropCoordinator.setTitlebarControlsHidden( + true, + in: window, + isMinimalMode: isMinimalMode + ) AppDelegate.shared?.fullscreenControlsViewModel = fullscreenControlsViewModel syncTrafficLightInset() }) @@ -2991,7 +2998,11 @@ struct ContentView: View { guard let window = notification.object as? NSWindow, window === observedWindow else { return } isFullScreen = false - setTitlebarControlsHidden(false, in: window) + windowChrome.nativeTitlebarBackdropCoordinator.setTitlebarControlsHidden( + false, + in: window, + isMinimalMode: isMinimalMode + ) AppDelegate.shared?.fullscreenControlsViewModel = nil syncTrafficLightInset() }) @@ -3073,7 +3084,11 @@ struct ContentView: View { view = AnyView(view.onChange(of: isMinimalMode) { _, _ in if let observedWindow { - setTitlebarControlsHidden(isFullScreen, in: observedWindow) + windowChrome.nativeTitlebarBackdropCoordinator.setTitlebarControlsHidden( + isFullScreen, + in: observedWindow, + isMinimalMode: isMinimalMode + ) AppDelegate.shared?.applyWindowDecorations(to: observedWindow) refreshWindowChromeMetrics(for: observedWindow) observedWindow.contentView?.needsLayout = true @@ -3119,77 +3134,98 @@ struct ContentView: View { removeSidebarResizerPointerMonitor() }) - view = AnyView(view.background(WindowAccessor(refreshID: appearance.appKitWindowMutationID) { [appearance] window in - window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.isRestorable = false - setMinimalModeSidebarTitlebarControlsAvailable(sidebarState.isVisible, in: window) - window.titlebarAppearsTransparent = true - // Native AppKit titlebar dragging steals pane-tab drags in minimal - // mode. Keep the main window immovable by default; explicit chrome - // drag zones temporarily enable performDrag for real app moves. - configureCmuxMainWindowDragBehavior(window) - window.styleMask.insert(.fullSizeContentView) - - // Track this window for fullscreen notifications - if observedWindow !== window { - DispatchQueue.main.async { - observedWindow = window - isFullScreen = window.styleMask.contains(.fullScreen) - let availableWidth = window.contentView?.bounds.width ?? window.contentLayoutRect.width - clampSidebarWidthIfNeeded(availableWidth: availableWidth) - clampRightSidebarWidthIfNeeded(availableWidth: availableWidth) - syncCommandPaletteDebugStateForObservedWindow() - installSidebarResizerPointerMonitorIfNeeded() - updateSidebarResizerBandState() - } - } - - refreshWindowChromeMetrics(for: window) - // Keep content below the titlebar so drags on Bonsplit's tab bar don't - // get interpreted as window drags. - // User settings decide whether window glass is active. The native Tahoe - // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen - // inside WindowGlassEffect.apply. - let backdropPlan = appearance.backdropPlan() - removeNativeTitlebarBackdrop(in: window) -#if DEBUG - if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { - AppDelegate.shared?.updateLog.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") - } -#endif - let backdropResult = WindowBackdropController.apply(plan: backdropPlan, to: window) - if backdropResult.didChangeGlassRoot { - let tmuxOverlayState = tmuxWorkspacePaneWindowOverlayState(for: window) - tmuxWorkspacePaneWindowOverlayController(for: window, createIfNeeded: tmuxOverlayState != nil)?.update(state: tmuxOverlayState) - commandPaletteWindowOverlayController(for: window) - .update(isVisible: isCommandPalettePresented) { AnyView(commandPaletteOverlay) } - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - BrowserWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - } - AppDelegate.shared?.attachUpdateAccessory(to: window) - AppDelegate.shared?.applyWindowDecorations(to: window) - // Let cmux supply the translucent titlebar fills. AppKit's native - // material otherwise blends a lighter strip over the terminal area. - syncNativeTitlebarBackdrop( - in: window, - enabled: true, - usesGlassStyle: backdropResult.usesWindowGlass - ) - AppDelegate.shared?.registerMainWindow( + let commandPaletteOverlayView = AnyView(commandPaletteOverlay) + let appKitWindowMutationID = appearance.appKitWindowMutationID( + windowBackgroundPolicy: windowChrome.windowBackgroundPolicy + ) + let mainWindowAccessor = WindowAccessor(refreshID: appKitWindowMutationID) { [appearance, commandPaletteOverlayView] window in + configureMainWindowChrome( window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState, - fileExplorerState: fileExplorerState, - cmuxConfigStore: cmuxConfigStore + appearance: appearance, + commandPaletteOverlayView: commandPaletteOverlayView ) - installFileDropOverlayWhenReady(on: window, tabManager: tabManager) - })) + } + view = AnyView(view.background(mainWindowAccessor)) return AnyView(view.cmuxAppearanceColorScheme(appearanceMode)) } + @MainActor + private func configureMainWindowChrome( + _ window: NSWindow, + appearance: WindowAppearanceSnapshot, + commandPaletteOverlayView: AnyView + ) { + window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) + window.isRestorable = false + setMinimalModeSidebarTitlebarControlsAvailable(sidebarState.isVisible, in: window) + window.titlebarAppearsTransparent = true + // Native AppKit titlebar dragging steals pane-tab drags in minimal + // mode. Keep the main window immovable by default; explicit chrome + // drag zones temporarily enable performDrag for real app moves. + configureCmuxMainWindowDragBehavior(window) + window.styleMask.insert(.fullSizeContentView) + + // Track this window for fullscreen notifications + if observedWindow !== window { + DispatchQueue.main.async { + observedWindow = window + isFullScreen = window.styleMask.contains(.fullScreen) + let availableWidth = window.contentView?.bounds.width ?? window.contentLayoutRect.width + clampSidebarWidthIfNeeded(availableWidth: availableWidth) + clampRightSidebarWidthIfNeeded(availableWidth: availableWidth) + syncCommandPaletteDebugStateForObservedWindow() + installSidebarResizerPointerMonitorIfNeeded() + updateSidebarResizerBandState() + } + } + + refreshWindowChromeMetrics(for: window) + // Keep content below the titlebar so drags on Bonsplit's tab bar don't + // get interpreted as window drags. + // User settings decide whether window glass is active. The native Tahoe + // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen + // inside WindowGlassEffect.apply. + let backdropPlan = appearance.backdropPlan( + glassEffectAvailable: windowChrome.glassEffect.isAvailable, + windowBackgroundPolicy: windowChrome.windowBackgroundPolicy + ) + windowChrome.nativeTitlebarBackdropCoordinator.removeNativeTitlebarBackdrop(in: window) +#if DEBUG + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { + AppDelegate.shared?.updateLog.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") + } +#endif + let backdropResult = windowChrome.backdropController.apply(plan: backdropPlan, to: window) + if backdropResult.didChangeGlassRoot { + let tmuxOverlayState = tmuxWorkspacePaneWindowOverlayState(for: window) + tmuxWorkspacePaneWindowOverlayController(for: window, createIfNeeded: tmuxOverlayState != nil)?.update(state: tmuxOverlayState) + commandPaletteWindowOverlayController(for: window) + .update(isVisible: isCommandPalettePresented) { commandPaletteOverlayView } + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) + BrowserWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) + } + AppDelegate.shared?.attachUpdateAccessory(to: window) + AppDelegate.shared?.applyWindowDecorations(to: window) + // Let cmux supply the translucent titlebar fills. AppKit's native + // material otherwise blends a lighter strip over the terminal area. + windowChrome.nativeTitlebarBackdropCoordinator.syncNativeTitlebarBackdrop( + in: window, + enabled: true, + usesGlassStyle: backdropResult.usesWindowGlass + ) + AppDelegate.shared?.registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState, + fileExplorerState: fileExplorerState, + cmuxConfigStore: cmuxConfigStore + ) + installFileDropOverlayWhenReady(on: window, tabManager: tabManager) + } + private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) { let currentTabs = tabs ?? tabManager.tabs let orderedTabIds = currentTabs.map { $0.id } @@ -3252,228 +3288,7 @@ struct ContentView: View { // Find this view's main window by identifier (keyWindow might be a debug panel/settings). guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == windowIdentifier }) else { return } let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowBackdropController.updateGlassTint(to: window, color: tintColor) - } - - private func removeNativeTitlebarBackdrop(in window: NSWindow) { - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { return } - - let identifier = NSUserInterfaceItemIdentifier("cmux.nativeTitlebarBackdrop") - let existing = themeFrame.subviews.first { $0.identifier == identifier } as? NativeTitlebarBackdropView - existing?.removeFromSuperview() - } - - private func syncNativeTitlebarBackdrop( - in window: NSWindow, - enabled: Bool, - usesGlassStyle: Bool - ) { - guard let titlebarContainer = nativeTitlebarContainer(in: window) else { return } - let titlebarView = firstNativeDescendant( - in: titlebarContainer, - className: "NSTitlebarView", - includeRoot: true - ) - let titlebarBackgroundViews = nativeDescendants( - in: titlebarContainer, - className: "NSTitlebarBackgroundView" - ) - let effectViews = nativeDescendants(in: titlebarContainer, className: "NSVisualEffectView") - - if enabled { - rememberNativeTitlebarBackdropState( - titlebarContainer: titlebarContainer, - titlebarView: titlebarView, - titlebarBackgroundViews: titlebarBackgroundViews, - effectViews: effectViews - ) - } else { - restoreNativeTitlebarBackdropState( - titlebarContainer: titlebarContainer, - titlebarView: titlebarView, - titlebarBackgroundViews: titlebarBackgroundViews, - effectViews: effectViews - ) - return - } - - titlebarContainer.wantsLayer = true - titlebarContainer.layer?.backgroundColor = usesGlassStyle ? NSColor.clear.cgColor : nil - titlebarContainer.layer?.isOpaque = false - titlebarView?.wantsLayer = true - titlebarView?.layer?.backgroundColor = usesGlassStyle ? NSColor.clear.cgColor : nil - titlebarView?.layer?.isOpaque = false - for titlebarBackgroundView in titlebarBackgroundViews { - titlebarBackgroundView.isHidden = true - } - for effectView in effectViews { - effectView.isHidden = true - } - window.titlebarAppearsTransparent = true - } - - private static var unifiedTitlebarLayerAppliedKey: UInt8 = 0 - private static var unifiedTitlebarLayerColorKey: UInt8 = 0 - private static var unifiedTitlebarLayerOpaqueKey: UInt8 = 0 - private static var unifiedTitlebarHiddenAppliedKey: UInt8 = 0 - private static var unifiedTitlebarHiddenKey: UInt8 = 0 - - private func rememberNativeTitlebarBackdropState( - titlebarContainer: NSView, - titlebarView: NSView?, - titlebarBackgroundViews: [NSView], - effectViews: [NSView] - ) { - rememberNativeTitlebarLayerState(titlebarContainer) - if let titlebarView { - rememberNativeTitlebarLayerState(titlebarView) - } - for titlebarBackgroundView in titlebarBackgroundViews { - rememberNativeTitlebarHiddenState(titlebarBackgroundView) - } - for effectView in effectViews { - rememberNativeTitlebarHiddenState(effectView) - } - } - - private func restoreNativeTitlebarBackdropState( - titlebarContainer: NSView, - titlebarView: NSView?, - titlebarBackgroundViews: [NSView], - effectViews: [NSView] - ) { - restoreNativeTitlebarLayerState(titlebarContainer) - if let titlebarView { - restoreNativeTitlebarLayerState(titlebarView) - } - for titlebarBackgroundView in titlebarBackgroundViews { - restoreNativeTitlebarHiddenState(titlebarBackgroundView) - } - for effectView in effectViews { - restoreNativeTitlebarHiddenState(effectView) - } - } - - private func rememberNativeTitlebarLayerState(_ view: NSView) { - guard objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey) == nil else { return } - - objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey, NSNumber(value: true), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerColorKey, view.layer?.backgroundColor ?? NSNull(), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerOpaqueKey, view.layer.map { NSNumber(value: $0.isOpaque) } ?? NSNull(), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - - private func restoreNativeTitlebarLayerState(_ view: NSView) { - guard objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey) != nil else { return } - - if let storedColor = objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerColorKey), - !(storedColor is NSNull) { - view.layer?.backgroundColor = storedColor as! CGColor - } else { - view.layer?.backgroundColor = nil - } - - if let isOpaque = objc_getAssociatedObject(view, &Self.unifiedTitlebarLayerOpaqueKey) as? NSNumber { - view.layer?.isOpaque = isOpaque.boolValue - } - - objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerAppliedKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerColorKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - objc_setAssociatedObject(view, &Self.unifiedTitlebarLayerOpaqueKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - - private func rememberNativeTitlebarHiddenState(_ view: NSView) { - guard objc_getAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey) == nil else { return } - - objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey, NSNumber(value: true), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenKey, NSNumber(value: view.isHidden), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - - private func restoreNativeTitlebarHiddenState(_ view: NSView) { - guard objc_getAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey) != nil else { return } - - if let hidden = objc_getAssociatedObject(view, &Self.unifiedTitlebarHiddenKey) as? NSNumber { - view.isHidden = hidden.boolValue - } - - objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenAppliedKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - objc_setAssociatedObject(view, &Self.unifiedTitlebarHiddenKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - - private func nativeTitlebarContainer(in window: NSWindow) -> NSView? { - if !window.styleMask.contains(.fullScreen) { - return window.contentView.flatMap { - firstNativeDescendant( - in: nativeRootView(from: $0), - className: "NSTitlebarContainerView", - includeRoot: true - ) - } - } - - for candidate in NSApp.windows where candidate.className == "NSToolbarFullScreenWindow" { - guard candidate.parent == window else { continue } - if let contentView = candidate.contentView { - return firstNativeDescendant( - in: nativeRootView(from: contentView), - className: "NSTitlebarContainerView", - includeRoot: true - ) - } - } - - return nil - } - - private func nativeRootView(from view: NSView) -> NSView { - var root = view - while let superview = root.superview { - root = superview - } - return root - } - - private func firstNativeDescendant( - in view: NSView, - className: String, - includeRoot: Bool = false - ) -> NSView? { - if includeRoot, String(describing: type(of: view)) == className { - return view - } - - for subview in view.subviews { - if String(describing: type(of: subview)) == className { - return subview - } - if let found = firstNativeDescendant(in: subview, className: className) { - return found - } - } - - return nil - } - - private func nativeDescendants(in view: NSView, className: String) -> [NSView] { - var result: [NSView] = [] - for subview in view.subviews { - if String(describing: type(of: subview)) == className { - result.append(subview) - } - result.append(contentsOf: nativeDescendants(in: subview, className: className)) - } - return result - } - - private func setTitlebarControlsHidden(_ hidden: Bool, in window: NSWindow) { - let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls") - let shouldHide = hidden || isMinimalMode - for accessory in window.titlebarAccessoryViewControllers { - if accessory.view.identifier == controlsId { - accessory.isHidden = shouldHide - accessory.view.alphaValue = shouldHide ? 0 : 1 - } - } + windowChrome.backdropController.updateGlassTint(to: window, color: tintColor) } private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { @@ -10550,7 +10365,11 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .overlay(alignment: .trailing) { - SidebarTrailingBorder() + WindowChromeBorder( + orientation: .vertical, + refreshNotificationName: .ghosttyDefaultBackgroundDidChange, + backgroundColorProvider: { GhosttyBackgroundTheme.currentColor() } + ) } .background( WindowAccessor(refreshID: showModifierHoldHints) { window in @@ -16099,480 +15918,3 @@ enum SidebarSelection { case tabs case notifications } - -/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested -private struct SidebarVisualEffectBackground: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - let state: NSVisualEffectView.State - let opacity: Double - let tintColor: NSColor? - let cornerRadius: CGFloat - let preferLiquidGlass: Bool - - init( - material: NSVisualEffectView.Material = .hudWindow, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - state: NSVisualEffectView.State = .active, - opacity: Double = 1.0, - tintColor: NSColor? = nil, - cornerRadius: CGFloat = 0, - preferLiquidGlass: Bool = false - ) { - self.material = material - self.blendingMode = blendingMode - self.state = state - self.opacity = opacity - self.tintColor = tintColor - self.cornerRadius = cornerRadius - self.preferLiquidGlass = preferLiquidGlass - } - - static var liquidGlassAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil - } - - func makeNSView(context: Context) -> NSView { - // Try NSGlassEffectView if preferred or if we want to test availability - if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - let glass = glassClass.init(frame: .zero) - glass.autoresizingMask = [.width, .height] - glass.wantsLayer = true - return glass - } - - // Use NSVisualEffectView - let view = NSVisualEffectView() - view.autoresizingMask = [.width, .height] - view.wantsLayer = true - view.layerContentsRedrawPolicy = .onSetNeedsDisplay - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - let clampedOpacity = max(0.0, min(1.0, opacity)) - // Configure based on view type - if nsView.className == "NSGlassEffectView" { - // NSGlassEffectView configuration via private API - nsView.alphaValue = clampedOpacity - nsView.layer?.cornerRadius = cornerRadius - nsView.layer?.masksToBounds = cornerRadius > 0 - - // Try to set tint color via private selector - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if nsView.responds(to: selector) { - nsView.perform(selector, with: color) - } - } - } else if let visualEffect = nsView as? NSVisualEffectView { - // NSVisualEffectView configuration - visualEffect.material = material - visualEffect.blendingMode = blendingMode - visualEffect.state = state - visualEffect.alphaValue = clampedOpacity - visualEffect.layer?.cornerRadius = cornerRadius - visualEffect.layer?.masksToBounds = cornerRadius > 0 - visualEffect.needsDisplay = true - } - } -} - -/// Reads the leading inset required to clear traffic lights + left titlebar accessories. -final class TitlebarLeadingInsetPassthroughView: NSView { - override var mouseDownCanMoveWindow: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private struct TitlebarLeadingInsetReader: NSViewRepresentable { - @Binding var inset: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = TitlebarLeadingInsetPassthroughView() - view.setFrameSize(.zero) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let window = nsView.window else { return } - // Start past the traffic lights - var leading = MinimalModeTitlebarDebugSettings.trafficLightTitlebarLeadingInset() - // Add width of all left-aligned titlebar accessories - for accessory in window.titlebarAccessoryViewControllers - where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { - leading += accessory.view.frame.width - } - if leading != inset { - inset = leading - } - } - } -} - -enum WindowChromeSeparatorColor { - static func color(forChromeBackground chrome: NSColor) -> NSColor { - let srgb = chrome.usingColorSpace(.sRGB) ?? chrome - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - srgb.getRed(&r, green: &g, blue: &b, alpha: &a) - let luminance = 0.299 * r + 0.587 * g + 0.114 * b - let isLight = luminance > 0.5 - let amount: CGFloat = isLight ? -0.12 : 0.16 - let alpha: CGFloat = isLight ? 0.26 : 0.36 - return NSColor( - red: min(1.0, max(0.0, r + amount)), - green: min(1.0, max(0.0, g + amount)), - blue: min(1.0, max(0.0, b + amount)), - alpha: alpha - ) - } - - static func current() -> NSColor { - color(forChromeBackground: GhosttyBackgroundTheme.currentColor()) - } -} - -struct WindowChromeBorder: View { - enum Orientation { - case vertical - case horizontal - } - - let orientation: Orientation - var ignoresSafeArea = true - @State private var separatorColor = WindowChromeSeparatorColor.current() - - var body: some View { - if ignoresSafeArea { - border.ignoresSafeArea() - } else { - border - } - } - - private var border: some View { - Rectangle() - .fill(Color(nsColor: separatorColor)) - .frame( - maxWidth: orientation == .horizontal ? .infinity : nil, - maxHeight: orientation == .vertical ? .infinity : nil - ) - .frame( - width: orientation == .vertical ? 1 : nil, - height: orientation == .horizontal ? 1 : nil - ) - .onAppear { - separatorColor = WindowChromeSeparatorColor.current() - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - separatorColor = WindowChromeSeparatorColor.current() - } - } -} - -/// 1px trailing border on the sidebar, derived from the terminal chrome background. -private struct SidebarTrailingBorder: View { - var body: some View { - WindowChromeBorder(orientation: .vertical) - } -} - -private struct WindowBackdropLayer: View { - let role: WindowBackdropRole - let snapshot: WindowAppearanceSnapshot - - var body: some View { - backdrop(for: snapshot.policy(for: role)) - } - - @ViewBuilder - private func backdrop(for policy: WindowBackdropPolicy) -> some View { - switch policy { - case let .ghosttyTerminalBackdrop(color, opacity, _): - let backdropColor = color.withAlphaComponent(opacity) - switch role { - case .windowRoot: - Color(nsColor: backdropColor) - case .terminalCanvas, .bonsplitChrome, .titlebar, .leftSidebar, .rightSidebar, .browserSurface: - LayerBackedBackdropColor(color: backdropColor) - } - case let .sidebarMaterial(materialPolicy): - ZStack { - let usingNativeLiquidGlass = materialPolicy.preferLiquidGlass && - SidebarVisualEffectBackground.liquidGlassAvailable - if let material = materialPolicy.material, - !materialPolicy.usesWindowLevelGlass { - SidebarVisualEffectBackground( - material: material, - blendingMode: materialPolicy.blendingMode, - state: materialPolicy.state, - opacity: materialPolicy.opacity, - tintColor: materialPolicy.tintColor, - cornerRadius: materialPolicy.cornerRadius, - preferLiquidGlass: materialPolicy.preferLiquidGlass - ) - } - // Tint overlay for tint-only materials and NSVisualEffectView - // fallback. Native liquid glass receives its tint in AppKit. - if !materialPolicy.usesWindowLevelGlass && !usingNativeLiquidGlass { - Color(nsColor: materialPolicy.tintColor) - } - } - case .clear: - Color.clear - } - } -} - -private struct LayerBackedBackdropColor: NSViewRepresentable { - let color: NSColor - - func makeNSView(context _: Context) -> NSView { - let view = NonHitTestingLayerBackedColorView() - view.setBackdropColor(color) - return view - } - - func updateNSView(_ nsView: NSView, context _: Context) { - (nsView as? NonHitTestingLayerBackedColorView)?.setBackdropColor(color) - } - - private final class NonHitTestingLayerBackedColorView: NSView { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.masksToBounds = true - layer?.isOpaque = false - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - wantsLayer = true - layer?.masksToBounds = true - layer?.isOpaque = false - } - - override var isOpaque: Bool { false } - - override func hitTest(_ point: NSPoint) -> NSView? { - nil - } - - func setBackdropColor(_ color: NSColor) { - wantsLayer = true - CATransaction.begin() - CATransaction.setDisableActions(true) - layer?.backgroundColor = color.cgColor - layer?.isOpaque = color.alphaComponent >= 1 - CATransaction.commit() - } - } -} - -enum SidebarMaterialOption: String, CaseIterable, Identifiable { - case none - case liquidGlass // macOS 26+ NSGlassEffectView - case sidebar - case hudWindow - case menu - case popover - case underWindowBackground - case windowBackground - case contentBackground - case fullScreenUI - case sheet - case headerView - case toolTip - - var id: String { rawValue } - - var title: String { - switch self { - case .none: return String(localized: "settings.material.none", defaultValue: "None") - case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") - case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") - case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") - case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") - case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") - case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") - case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") - case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") - case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") - case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") - case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") - case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") - } - } - - /// Returns true if this option should use NSGlassEffectView (macOS 26+) - var usesLiquidGlass: Bool { - self == .liquidGlass - } - - var material: NSVisualEffectView.Material? { - switch self { - case .none: return nil - case .liquidGlass: return .underWindowBackground // Fallback material - case .sidebar: return .sidebar - case .hudWindow: return .hudWindow - case .menu: return .menu - case .popover: return .popover - case .underWindowBackground: return .underWindowBackground - case .windowBackground: return .windowBackground - case .contentBackground: return .contentBackground - case .fullScreenUI: return .fullScreenUI - case .sheet: return .sheet - case .headerView: return .headerView - case .toolTip: return .toolTip - } - } -} - -enum SidebarBlendModeOption: String, CaseIterable, Identifiable { - case behindWindow - case withinWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") - case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") - } - } - - var mode: NSVisualEffectView.BlendingMode { - switch self { - case .behindWindow: return .behindWindow - case .withinWindow: return .withinWindow - } - } -} - -enum SidebarStateOption: String, CaseIterable, Identifiable { - case active - case inactive - case followWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .active: return String(localized: "settings.state.active", defaultValue: "Active") - case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") - case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") - } - } - - var state: NSVisualEffectView.State { - switch self { - case .active: return .active - case .inactive: return .inactive - case .followWindow: return .followsWindowActiveState - } - } -} - -enum SidebarTintDefaults { - static let hex = "#000000" - static let opacity = 0.18 -} - -enum SidebarPresetOption: String, CaseIterable, Identifiable { - case nativeSidebar - case glassBehind - case softBlur - case popoverGlass - case hudGlass - case underWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") - case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") - case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") - case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") - case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") - case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") - } - } - - var material: SidebarMaterialOption { - switch self { - case .nativeSidebar: return .sidebar - case .glassBehind: return .sidebar - case .softBlur: return .sidebar - case .popoverGlass: return .popover - case .hudGlass: return .hudWindow - case .underWindow: return .underWindowBackground - } - } - - var blendMode: SidebarBlendModeOption { - switch self { - case .nativeSidebar: return .withinWindow - case .glassBehind: return .behindWindow - case .softBlur: return .behindWindow - case .popoverGlass: return .behindWindow - case .hudGlass: return .withinWindow - case .underWindow: return .withinWindow - } - } - - var state: SidebarStateOption { - switch self { - case .nativeSidebar: return .followWindow - case .glassBehind: return .active - case .softBlur: return .active - case .popoverGlass: return .active - case .hudGlass: return .active - case .underWindow: return .followWindow - } - } - - var tintHex: String { - switch self { - case .nativeSidebar: return "#000000" - case .glassBehind: return "#000000" - case .softBlur: return "#000000" - case .popoverGlass: return "#000000" - case .hudGlass: return "#000000" - case .underWindow: return "#000000" - } - } - - var tintOpacity: Double { - switch self { - case .nativeSidebar: return 0.18 - case .glassBehind: return 0.36 - case .softBlur: return 0.28 - case .popoverGlass: return 0.10 - case .hudGlass: return 0.62 - case .underWindow: return 0.14 - } - } - - var cornerRadius: Double { - switch self { - case .nativeSidebar: return 0.0 - case .glassBehind: return 0.0 - case .softBlur: return 0.0 - case .popoverGlass: return 10.0 - case .hudGlass: return 10.0 - case .underWindow: return 6.0 - } - } - - var blurOpacity: Double { - switch self { - case .nativeSidebar: return 1.0 - case .glassBehind: return 0.6 - case .softBlur: return 0.45 - case .popoverGlass: return 0.9 - case .hudGlass: return 0.98 - case .underWindow: return 0.9 - } - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8190e1c6ea45..05da87aa85eb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxAppKitSupportUI import CmuxTerminal import CmuxFoundation import CmuxPanes @@ -3221,11 +3222,16 @@ class GhosttyApp { } } + @MainActor private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } - let snapshot = WindowAppearanceSnapshot.currentFromUserDefaults(app: self) - let plan = snapshot.backdropPlan() - _ = WindowBackdropController.apply(plan: plan, to: window) + let windowChrome = AppWindowChromeComposition() + let snapshot = windowChrome.appearanceSnapshotFromUserDefaults(app: self) + let plan = snapshot.backdropPlan( + glassEffectAvailable: windowChrome.glassEffect.isAvailable, + windowBackgroundPolicy: windowChrome.windowBackgroundPolicy + ) + _ = windowChrome.backdropController.apply(plan: plan, to: window) if backgroundLogEnabled { logBackground( "applied window backdrop phase=\(plan.hostingPhase.rawValue) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) blur=\(defaultBackgroundBlur)" @@ -3716,6 +3722,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } + @MainActor func applyWindowBackgroundIfActive() { guard let window else { return } let appDelegate = AppDelegate.shared @@ -3731,12 +3738,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return } applySurfaceBackground() - let windowRoot = WindowAppearanceSnapshot - .currentFromUserDefaults(app: GhosttyApp.shared) + let windowChrome = AppWindowChromeComposition() + let windowRoot = windowChrome + .appearanceSnapshotFromUserDefaults(app: GhosttyApp.shared) .windowRootBackdropResolution(surfaceBackgroundColor: backgroundColor) - let plan = windowRoot.snapshot.backdropPlan() + let plan = windowRoot.snapshot.backdropPlan( + glassEffectAvailable: windowChrome.glassEffect.isAvailable, + windowBackgroundPolicy: windowChrome.windowBackgroundPolicy + ) let color = windowRoot.snapshot.compositedTerminalBackgroundColor - _ = WindowBackdropController.apply(plan: plan, to: window) + _ = windowChrome.backdropController.apply(plan: plan, to: window) if GhosttyApp.shared.backgroundLogEnabled { let signature = "\(plan.hostingPhase.rawValue):\(color.hexString()):\(String(format: "%.3f", color.alphaComponent)):\(GhosttyApp.shared.defaultBackgroundBlur)" if signature != lastLoggedWindowBackgroundSignature { @@ -7499,7 +7510,9 @@ private final class TerminalViewportBorderOverlayView: NSView { // sidebar trailing edge, and tab-bar separators (one source of truth), so the // iOS-connected viewport border is pixel-identical to every other border in the // app instead of the previous hardcoded near-white separator stroke. - WindowChromeSeparatorColor.current().setStroke() + WindowChromeColorResolver() + .separatorColor(forChromeBackground: GhosttyBackgroundTheme.currentColor()) + .setStroke() path.stroke() } } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 8dfe6aacde8a..0e2362e75878 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -158,10 +158,10 @@ extension CmuxSettingsFileStore { [ "sidebarAppearance": [ "matchTerminalBackground": false, - "tintColor": SidebarTintDefaults.hex, + "tintColor": SidebarTintDefaults().hex, "lightModeTintColor": NSNull(), "darkModeTintColor": NSNull(), - "tintOpacity": SidebarTintDefaults.opacity, + "tintOpacity": SidebarTintDefaults().opacity, ], ], [ diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 777a9ba32d8d..41de736e7c06 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4,6 +4,7 @@ import CmuxBrowser import CmuxBrowserImport import CmuxSettings import Combine +import CmuxAppKitSupportUI import WebKit import AppKit import Bonsplit @@ -4491,7 +4492,7 @@ final class BrowserPanel: Panel, ObservableObject { opacity: GhosttyApp.shared.defaultBackgroundOpacity, usesGhosttyGlassStyle: GhosttyApp.shared.defaultBackgroundBlur.isMacOSGlassStyle, usesTransparentWindow: WindowBackgroundComposition.policy - .shouldUseTransparentBackgroundWindow(glassEffectAvailable: WindowGlassEffect.isAvailable) + .shouldUseTransparentBackgroundWindow(glassEffectAvailable: false) ) } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 5704fe922f8c..c711d85819ef 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -2,6 +2,7 @@ import SwiftUI import Foundation import AppKit import Bonsplit +import CmuxAppKitSupportUI import CmuxTestSupport import CmuxTerminal import CmuxFoundation @@ -269,7 +270,7 @@ struct PanelAppearance { fromConfig( config, usesTransparentWindow: WindowBackgroundComposition.policy - .shouldUseTransparentBackgroundWindow(glassEffectAvailable: WindowGlassEffect.isAvailable) + .shouldUseTransparentBackgroundWindow(glassEffectAvailable: false) ) } diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 522b8f9cdc00..5771b1c7f08e 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -1,3 +1,4 @@ +import CmuxAppKitSupportUI import SwiftUI enum HeaderChromeIconStyle { @@ -144,7 +145,12 @@ struct RightSidebarChromePillModifier: ViewModifier { struct RightSidebarChromeBottomBorderModifier: ViewModifier { func body(content: Content) -> some View { content.overlay(alignment: .bottom) { - WindowChromeBorder(orientation: .horizontal, ignoresSafeArea: false) + WindowChromeBorder( + orientation: .horizontal, + ignoresSafeArea: false, + refreshNotificationName: .ghosttyDefaultBackgroundDidChange, + backgroundColorProvider: { GhosttyBackgroundTheme.currentColor() } + ) } } } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 109be05d924a..ac083554f7e0 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppKitSupportUI import CmuxFoundation import Foundation import SwiftUI @@ -49,10 +50,25 @@ func sidebarActiveForegroundNSColor( return baseColor.withAlphaComponent(clampedOpacity) } -func titlebarControlForegroundNSColor( - opacity: CGFloat, - appearance: WindowAppearanceSnapshot = .currentFromUserDefaults() -) -> NSColor { +func titlebarControlForegroundNSColor(opacity: CGFloat) -> NSColor { + let app = GhosttyApp.shared + let bestMatch = NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) + let colorScheme: ColorScheme = bestMatch == .darkAqua ? .dark : .light + let appearance = WindowAppearanceResolver( + terminalAppearance: WindowTerminalAppearanceSnapshot( + backgroundColor: app.defaultBackgroundColor, + backgroundOpacity: app.defaultBackgroundOpacity, + backgroundBlur: app.defaultBackgroundBlur, + usesHostLayerBackground: app.usesHostLayerBackground + ) + ).currentFromUserDefaults(defaults: .standard, colorScheme: colorScheme) + return titlebarControlForegroundNSColor( + opacity: opacity, + appearance: appearance + ) +} + +func titlebarControlForegroundNSColor(opacity: CGFloat, appearance: WindowAppearanceSnapshot) -> NSColor { cmuxReadableForegroundNSColor( on: appearance.compositedTerminalBackgroundColor, opacity: opacity diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2d0d052a7bc0..0f50ce15e67a 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1,5 +1,6 @@ import AppKit import ObjectiveC +import CmuxAppKitSupportUI import CmuxTerminal #if DEBUG import Bonsplit @@ -664,6 +665,7 @@ final class WindowTerminalPortal: NSObject { private weak var window: NSWindow? private let hostView = WindowTerminalHostView(frame: .zero) private let dividerOverlayView = SplitDividerOverlayView(frame: .zero) + private let chromeComposition = AppWindowChromeComposition() private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] @@ -957,14 +959,10 @@ final class WindowTerminalPortal: NSObject { } private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { - if let glassTarget = WindowGlassEffect.portalInstallationTarget(for: window) { - return glassTarget - } - - guard let contentView = window.contentView else { return nil } - - guard let themeFrame = contentView.superview else { return nil } - return (themeFrame, contentView) + guard let target = chromeComposition + .contentOverlayTargetResolver + .installationTarget(for: window) else { return nil } + return (target.container, target.reference) } private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { diff --git a/Sources/Windowing/AppWindowBackdropControllerDependencies.swift b/Sources/Windowing/AppWindowBackdropControllerDependencies.swift new file mode 100644 index 000000000000..3a2aac67ff9d --- /dev/null +++ b/Sources/Windowing/AppWindowBackdropControllerDependencies.swift @@ -0,0 +1,20 @@ +import AppKit +import CmuxAppKitSupportUI +import CmuxWorkspaceWindow + +@MainActor +final class AppWindowBackdropControllerDependencies: WindowBackdropControllerDependencies { + let glassEffect: any WindowGlassEffectManaging + + init(glassEffect: any WindowGlassEffectManaging) { + self.glassEffect = glassEffect + } + + func resetCompositorBackgroundBlur(windowNumber: Int) { + WindowBackgroundComposition.blurController.resetBackgroundBlur(windowNumber: windowNumber) + } + + func applyGhosttyCompositorBlurIfNeeded(to window: NSWindow) { + GhosttyApp.shared.applyWindowBlurIfNeeded(window) + } +} diff --git a/Sources/Windowing/AppWindowChromeComposition.swift b/Sources/Windowing/AppWindowChromeComposition.swift new file mode 100644 index 000000000000..8231e7753a65 --- /dev/null +++ b/Sources/Windowing/AppWindowChromeComposition.swift @@ -0,0 +1,88 @@ +import AppKit +import CmuxAppKitSupportUI +import CmuxWorkspaceWindow +import SwiftUI + +typealias SidebarMaterialOption = WindowChromeSidebarMaterialOption +typealias SidebarBlendModeOption = WindowChromeSidebarBlendModeOption +typealias SidebarStateOption = WindowChromeSidebarStateOption +typealias SidebarTintDefaults = WindowChromeSidebarTintDefaults +typealias SidebarPresetOption = WindowChromeSidebarPresetOption + +@MainActor +struct AppWindowChromeComposition { + let glassEffect: WindowGlassEffect + let nativeTitlebarBackdropCoordinator: NativeTitlebarBackdropCoordinator + + init(fullscreenAuxiliaryWindows: (@MainActor @Sendable () -> [NSWindow])? = nil) { + self.init( + glassEffect: WindowGlassEffect(), + fullscreenAuxiliaryWindows: fullscreenAuxiliaryWindows + ) + } + + init( + glassEffect: WindowGlassEffect, + fullscreenAuxiliaryWindows: (@MainActor @Sendable () -> [NSWindow])? = nil + ) { + self.glassEffect = glassEffect + let resolvedFullscreenAuxiliaryWindows: @MainActor @Sendable () -> [NSWindow] = + fullscreenAuxiliaryWindows ?? { NSApp.windows } + nativeTitlebarBackdropCoordinator = NativeTitlebarBackdropCoordinator( + fullscreenAuxiliaryWindows: resolvedFullscreenAuxiliaryWindows + ) + } + + var windowBackgroundPolicy: WindowBackgroundPolicy { + WindowBackgroundComposition.policy + } + + var backdropController: WindowBackdropController { + WindowBackdropController( + dependencies: AppWindowBackdropControllerDependencies(glassEffect: glassEffect) + ) + } + + var contentOverlayTargetResolver: WindowContentOverlayTargetResolver { + WindowContentOverlayTargetResolver(glassEffect: glassEffect) + } + + func terminalAppearanceSnapshot(app: GhosttyApp = .shared) -> WindowTerminalAppearanceSnapshot { + WindowTerminalAppearanceSnapshot( + backgroundColor: app.defaultBackgroundColor, + backgroundOpacity: app.defaultBackgroundOpacity, + backgroundBlur: app.defaultBackgroundBlur, + usesHostLayerBackground: app.usesHostLayerBackground + ) + } + + func appearanceResolver(app: GhosttyApp = .shared) -> WindowAppearanceResolver { + WindowAppearanceResolver(terminalAppearance: terminalAppearanceSnapshot(app: app)) + } + + func appearanceSnapshot( + settings: WindowAppearanceUserSettingsSnapshot, + app: GhosttyApp = .shared + ) -> WindowAppearanceSnapshot { + appearanceResolver(app: app).current(settings: settings) + } + + func appearanceSnapshotFromUserDefaults( + defaults: UserDefaults = .standard, + app: GhosttyApp = .shared, + colorScheme: ColorScheme? = nil + ) -> WindowAppearanceSnapshot { + appearanceResolver(app: app).currentFromUserDefaults( + defaults: defaults, + colorScheme: colorScheme ?? Self.currentAppColorScheme() + ) + } + + @MainActor + private static func currentAppColorScheme( + appearance: NSAppearance? = nil + ) -> ColorScheme { + let resolved = appearance ?? NSApplication.shared.effectiveAppearance + return resolved.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? .dark : .light + } +} diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift deleted file mode 100644 index 32a40caf6418..000000000000 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ /dev/null @@ -1,403 +0,0 @@ -import AppKit -import CmuxFoundation -import SwiftUI - -enum GhosttyTerminalBackdropRenderingMode { - case windowHostBackdrop - case ghosttyRendererOwnedBackgroundImage - - var usesWindowHostBackdrop: Bool { - self == .windowHostBackdrop - } -} - -enum WindowBackdropRole { - case windowRoot - case terminalCanvas - case bonsplitChrome - case titlebar - case leftSidebar - case rightSidebar - case browserSurface -} - -extension GhosttyBackgroundBlur { - /// The window-chrome glass style for this blur mode, or `nil` when the mode - /// is a compositor blur or disabled. Lives app-side because - /// `WindowGlassEffect` is a window-domain type the terminal core must not - /// depend on. - var windowGlassStyle: WindowGlassEffect.Style? { - switch self { - case .macosGlassRegular: - return .regular - case .macosGlassClear: - return .clear - case .disabled, .radius: - return nil - } - } -} - -struct SidebarBackdropMaterialPolicy { - let material: NSVisualEffectView.Material? - let blendingMode: NSVisualEffectView.BlendingMode - let state: NSVisualEffectView.State - let opacity: Double - let tintColor: NSColor - let cornerRadius: CGFloat - let preferLiquidGlass: Bool - let usesWindowLevelGlass: Bool -} - -enum WindowBackdropPolicy { - case ghosttyTerminalBackdrop( - color: NSColor, - opacity: CGFloat, - renderingMode: GhosttyTerminalBackdropRenderingMode - ) - case sidebarMaterial(SidebarBackdropMaterialPolicy) - case clear - - var hostLayerBackgroundColor: NSColor? { - switch self { - case let .ghosttyTerminalBackdrop(color, opacity, renderingMode): - guard renderingMode.usesWindowHostBackdrop else { return nil } - return color.withAlphaComponent(opacity) - case .sidebarMaterial, .clear: - return nil - } - } -} - -/// Identifies the layer responsible for painting a terminal surface background. -enum TerminalSurfaceBackgroundFillOwner: Equatable { - /// The terminal hosting view should paint the resolved background color. - case surfaceHostLayer - - /// The shared root backdrop should remain the only visible background fill. - case sharedWindowBackdrop - - /// The Bonsplit pane backdrop should remain the only visible background fill. - case bonsplitPaneBackdrop - - /// Ghostty's renderer owns the background instead of cmux's host layers. - case ghosttyNativeRenderer -} - -/// Resolved background painting decision for one terminal surface. -struct TerminalSurfaceBackgroundFillPlan { - /// The layer or renderer that owns the visible terminal background. - let owner: TerminalSurfaceBackgroundFillOwner - - /// The color to apply to the terminal host layer, or clear when another layer owns the fill. - let hostLayerColor: NSColor - - /// Whether a host-layer fill must subtract itself from the shared window backdrop. - let clearsSharedWindowBackdrop: Bool - - /// Whether the terminal host layer should paint a non-clear fill. - var usesHostLayerFill: Bool { - owner == .surfaceHostLayer - } - - /// Compact label used by debug logging for the selected backdrop owner. - var logBackdropLabel: String { - switch owner { - case .surfaceHostLayer: - return "terminal" - case .sharedWindowBackdrop: - return "shared" - case .bonsplitPaneBackdrop: - return "bonsplit-pane" - case .ghosttyNativeRenderer: - return "ghostty-native" - } - } - - /// Returns the debug-log source label for the selected owner. - func logSource(hasSurfaceOverride: Bool) -> String { - switch owner { - case .surfaceHostLayer: - return hasSurfaceOverride ? "surfaceOverride" : "defaultBackground" - case .sharedWindowBackdrop: - return "sharedWindowBackdrop" - case .bonsplitPaneBackdrop: - return "bonsplitPaneBackdrop" - case .ghosttyNativeRenderer: - return "ghosttyNativeBackground" - } - } - - /// Computes the terminal background owner and host-layer color for current appearance state. - static func resolve( - renderingMode: GhosttyTerminalBackdropRenderingMode, - surfaceBackgroundColor: NSColor?, - defaultBackgroundColor: NSColor, - backgroundOpacity: Double, - sharesWindowBackdrop: Bool, - usesBonsplitPaneBackdrop: Bool - ) -> Self { - let resolvedColor = (surfaceBackgroundColor ?? defaultBackgroundColor) - .withAlphaComponent(WindowAppearanceSnapshot.clampedOpacity(backgroundOpacity)) - let owner: TerminalSurfaceBackgroundFillOwner - let usesPaneLocalSurfaceFill = surfaceBackgroundColor != nil && - renderingMode.usesWindowHostBackdrop && - !usesBonsplitPaneBackdrop - if !renderingMode.usesWindowHostBackdrop { - owner = .ghosttyNativeRenderer - } else if usesPaneLocalSurfaceFill { - owner = .surfaceHostLayer - } else if !sharesWindowBackdrop && !usesBonsplitPaneBackdrop { - owner = .surfaceHostLayer - } else if sharesWindowBackdrop { - owner = .sharedWindowBackdrop - } else { - owner = .bonsplitPaneBackdrop - } - return Self( - owner: owner, - hostLayerColor: owner == .surfaceHostLayer ? resolvedColor : .clear, - clearsSharedWindowBackdrop: usesPaneLocalSurfaceFill && sharesWindowBackdrop - ) - } -} - -struct SidebarBackdropSettingsSnapshot { - let materialRawValue: String - let blendModeRawValue: String - let stateRawValue: String - let tintHex: String - let tintHexLight: String? - let tintHexDark: String? - let tintOpacity: Double - let cornerRadius: Double - let blurOpacity: Double - let colorScheme: ColorScheme - - var materialPolicy: SidebarBackdropMaterialPolicy { - let materialOption = SidebarMaterialOption(rawValue: materialRawValue) - let blendingMode = SidebarBlendModeOption(rawValue: blendModeRawValue)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: stateRawValue)?.state ?? .active - let resolvedHex: String - if colorScheme == .dark, let tintHexDark { - resolvedHex = tintHexDark - } else if colorScheme == .light, let tintHexLight { - resolvedHex = tintHexLight - } else { - resolvedHex = tintHex - } - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) - .withAlphaComponent(tintOpacity) - let preferLiquidGlass = materialOption?.usesLiquidGlass ?? false - let usesWindowLevelGlass = preferLiquidGlass && blendingMode == .behindWindow - - return SidebarBackdropMaterialPolicy( - material: materialOption?.material, - blendingMode: blendingMode, - state: state, - opacity: blurOpacity, - tintColor: tintColor, - cornerRadius: CGFloat(max(0, cornerRadius)), - preferLiquidGlass: preferLiquidGlass, - usesWindowLevelGlass: usesWindowLevelGlass - ) - } - - var appKitMutationID: String { - [ - materialRawValue, - blendModeRawValue, - stateRawValue, - tintHex, - tintHexLight ?? "nil", - tintHexDark ?? "nil", - Self.identityComponent(tintOpacity), - Self.identityComponent(cornerRadius), - Self.identityComponent(blurOpacity), - String(describing: colorScheme), - ].joined(separator: "|") - } - - private static func identityComponent(_ value: Double) -> String { - String(format: "%.4f", value) - } -} - -struct WindowGlassSettingsSnapshot { - let sidebarBlendModeRawValue: String - let isEnabled: Bool - let tintHex: String - let tintOpacity: Double - let terminalBackgroundBlur: GhosttyBackgroundBlur - let terminalGlassTintColor: NSColor? - - init( - sidebarBlendModeRawValue: String, - isEnabled: Bool, - tintHex: String, - tintOpacity: Double, - terminalBackgroundBlur: GhosttyBackgroundBlur = .disabled, - terminalGlassTintColor: NSColor? = nil - ) { - self.sidebarBlendModeRawValue = sidebarBlendModeRawValue - self.isEnabled = isEnabled - self.tintHex = tintHex - self.tintOpacity = tintOpacity - self.terminalBackgroundBlur = terminalBackgroundBlur - self.terminalGlassTintColor = terminalGlassTintColor - } - - var tintColor: NSColor { - if let terminalGlassTintColor, terminalBackgroundBlur.isMacOSGlassStyle { - return terminalGlassTintColor - } - return (NSColor(hex: tintHex) ?? .black).withAlphaComponent(tintOpacity) - } - - var style: WindowGlassEffect.Style { - terminalBackgroundBlur.windowGlassStyle ?? .regular - } - - func shouldApply(glassEffectAvailable: Bool = WindowGlassEffect.isAvailable) -> Bool { - if terminalBackgroundBlur.isMacOSGlassStyle { - return true - } - return WindowBackgroundComposition.policy.shouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendModeRawValue, - bgGlassEnabled: isEnabled, - glassEffectAvailable: glassEffectAvailable - ) - } - - var appKitMutationID: String { - [ - sidebarBlendModeRawValue, - String(isEnabled), - tintHex, - String(format: "%.4f", tintOpacity), - String(describing: terminalBackgroundBlur), - terminalGlassTintColor?.hexString(includeAlpha: true) ?? "nil", - ].joined(separator: "|") - } -} - -struct WindowAppearanceSnapshot { - let terminalBackgroundColor: NSColor - let terminalBackgroundOpacity: CGFloat - let terminalBackgroundBlur: GhosttyBackgroundBlur - let terminalRenderingMode: GhosttyTerminalBackdropRenderingMode - let unifySurfaceBackdrops: Bool - let sidebarSettings: SidebarBackdropSettingsSnapshot - let windowGlassSettings: WindowGlassSettingsSnapshot - - static func current( - unifySurfaceBackdrops: Bool, - colorScheme: ColorScheme, - sidebarMaterial: String, - sidebarBlendMode: String, - sidebarState: String, - sidebarTintHex: String, - sidebarTintHexLight: String?, - sidebarTintHexDark: String?, - sidebarTintOpacity: Double, - sidebarCornerRadius: Double, - sidebarBlurOpacity: Double, - bgGlassEnabled: Bool, - bgGlassTintHex: String, - bgGlassTintOpacity: Double, - app: GhosttyApp = .shared - ) -> Self { - Self( - terminalBackgroundColor: app.defaultBackgroundColor, - terminalBackgroundOpacity: Self.clampedOpacity(app.defaultBackgroundOpacity), - terminalBackgroundBlur: app.defaultBackgroundBlur, - terminalRenderingMode: Self.terminalRenderingMode( - usesHostLayerBackground: app.usesHostLayerBackground - ), - unifySurfaceBackdrops: unifySurfaceBackdrops, - sidebarSettings: SidebarBackdropSettingsSnapshot( - materialRawValue: sidebarMaterial, - blendModeRawValue: sidebarBlendMode, - stateRawValue: sidebarState, - tintHex: sidebarTintHex, - tintHexLight: sidebarTintHexLight, - tintHexDark: sidebarTintHexDark, - tintOpacity: sidebarTintOpacity, - cornerRadius: sidebarCornerRadius, - blurOpacity: sidebarBlurOpacity, - colorScheme: colorScheme - ), - windowGlassSettings: WindowGlassSettingsSnapshot( - sidebarBlendModeRawValue: sidebarBlendMode, - isEnabled: bgGlassEnabled, - tintHex: bgGlassTintHex, - tintOpacity: bgGlassTintOpacity, - terminalBackgroundBlur: app.defaultBackgroundBlur, - terminalGlassTintColor: app.defaultBackgroundColor.withAlphaComponent( - Self.clampedOpacity(app.defaultBackgroundOpacity) - ) - ) - ) - } - - static func clampedOpacity(_ opacity: Double) -> CGFloat { - CGFloat(max(0.0, min(1.0, opacity))) - } - - static func compositedTerminalColor( - backgroundColor: NSColor, - opacity: Double, - over baseColor: NSColor = .windowBackgroundColor - ) -> NSColor { - cmuxCompositedNSColor( - backgroundColor.withAlphaComponent(clampedOpacity(opacity)), - over: baseColor - ) - } - - static func terminalRenderingMode( - usesHostLayerBackground: Bool - ) -> GhosttyTerminalBackdropRenderingMode { - usesHostLayerBackground ? .windowHostBackdrop : .ghosttyRendererOwnedBackgroundImage - } - - var compositedTerminalBackgroundColor: NSColor { - Self.compositedTerminalColor( - backgroundColor: terminalBackgroundColor, - opacity: terminalBackgroundOpacity - ) - } - - var chromeColorScheme: ColorScheme { - cmuxReadableColorScheme(for: compositedTerminalBackgroundColor) - } - - var sidebarContentColorScheme: ColorScheme { - unifySurfaceBackdrops ? chromeColorScheme : sidebarSettings.colorScheme - } - - func policy(for role: WindowBackdropRole) -> WindowBackdropPolicy { - switch role { - case .windowRoot: - return terminalBackdropPolicy() - case .terminalCanvas, .bonsplitChrome, .titlebar, .browserSurface: - return .clear - case .leftSidebar, .rightSidebar: - if unifySurfaceBackdrops { - return .clear - } - return .sidebarMaterial(sidebarSettings.materialPolicy) - } - } - - func terminalBackdropPolicy() -> WindowBackdropPolicy { - if terminalBackgroundBlur.isMacOSGlassStyle { - return .clear - } - return .ghosttyTerminalBackdrop( - color: terminalBackgroundColor, - opacity: terminalBackgroundOpacity, - renderingMode: terminalRenderingMode - ) - } -} diff --git a/Sources/Windowing/WindowBackdropController.swift b/Sources/Windowing/WindowBackdropController.swift deleted file mode 100644 index eb2eff4137c7..000000000000 --- a/Sources/Windowing/WindowBackdropController.swift +++ /dev/null @@ -1,222 +0,0 @@ -import AppKit -import CmuxFoundation -import SwiftUI - -enum WindowBackdropHostingPhase: String, Equatable { - case opaqueWindowFill - case transparentRootBackdrop - case windowGlass -} - -struct WindowBackdropGlassPlan { - let tintColor: NSColor - let style: WindowGlassEffect.Style -} - -struct WindowBackdropPlan { - let hostingPhase: WindowBackdropHostingPhase - let windowBackgroundColor: NSColor - let windowIsOpaque: Bool - let rootPolicy: WindowBackdropPolicy - let glass: WindowBackdropGlassPlan? - let shouldApplyGhosttyCompositorBlur: Bool - - var usesTransparentWindow: Bool { - hostingPhase != .opaqueWindowFill - } - - var usesWindowGlass: Bool { - hostingPhase == .windowGlass - } - - var appKitMutationID: String { - [ - hostingPhase.rawValue, - windowBackgroundColor.hexString(includeAlpha: true), - String(windowIsOpaque), - rootPolicy.identityComponent, - glass?.tintColor.hexString(includeAlpha: true) ?? "nil", - glass.map { String(describing: $0.style) } ?? "nil", - String(shouldApplyGhosttyCompositorBlur), - ].joined(separator: "|") - } -} - -struct WindowBackdropApplicationResult { - let didChangeGlassRoot: Bool - let usesWindowGlass: Bool -} - -enum WindowBackdropController { - static func apply( - snapshot: WindowAppearanceSnapshot, - to window: NSWindow, - glassEffectAvailable: Bool = WindowGlassEffect.isAvailable - ) -> WindowBackdropApplicationResult { - apply(plan: snapshot.backdropPlan(glassEffectAvailable: glassEffectAvailable), to: window) - } - - static func apply( - plan: WindowBackdropPlan, - to window: NSWindow - ) -> WindowBackdropApplicationResult { - var didChangeGlassRoot = false - - switch plan.hostingPhase { - case .opaqueWindowFill: - didChangeGlassRoot = WindowGlassEffect.remove(from: window) - window.backgroundColor = plan.windowBackgroundColor - window.isOpaque = plan.windowIsOpaque - WindowBackgroundComposition.blurController.resetBackgroundBlur(windowNumber: window.windowNumber) - case .transparentRootBackdrop: - didChangeGlassRoot = WindowGlassEffect.remove(from: window) - window.backgroundColor = plan.windowBackgroundColor - window.isOpaque = false - if plan.shouldApplyGhosttyCompositorBlur { - GhosttyApp.shared.applyWindowBlurIfNeeded(window) - } else { - WindowBackgroundComposition.blurController.resetBackgroundBlur(windowNumber: window.windowNumber) - } - case .windowGlass: - window.backgroundColor = plan.windowBackgroundColor - window.isOpaque = false - WindowBackgroundComposition.blurController.resetBackgroundBlur(windowNumber: window.windowNumber) - if let glass = plan.glass { - didChangeGlassRoot = WindowGlassEffect.apply( - to: window, - tintColor: glass.tintColor, - style: glass.style - ) - } else { - didChangeGlassRoot = WindowGlassEffect.remove(from: window) - } - } - - return WindowBackdropApplicationResult( - didChangeGlassRoot: didChangeGlassRoot, - usesWindowGlass: plan.usesWindowGlass - ) - } - - static func updateGlassTint(to window: NSWindow, color: NSColor?) { - WindowGlassEffect.updateTint(to: window, color: color) - } -} - -extension WindowAppearanceSnapshot { - static func currentFromUserDefaults( - defaults: UserDefaults = .standard, - app: GhosttyApp = .shared, - colorScheme: ColorScheme? = nil - ) -> Self { - current( - unifySurfaceBackdrops: defaults.object(forKey: "sidebarMatchTerminalBackground") as? Bool ?? false, - colorScheme: colorScheme ?? currentAppColorScheme(), - sidebarMaterial: defaults.string(forKey: "sidebarMaterial") ?? SidebarMaterialOption.sidebar.rawValue, - sidebarBlendMode: defaults.string(forKey: "sidebarBlendMode") ?? SidebarBlendModeOption.withinWindow.rawValue, - sidebarState: defaults.string(forKey: "sidebarState") ?? SidebarStateOption.followWindow.rawValue, - sidebarTintHex: defaults.string(forKey: "sidebarTintHex") ?? SidebarTintDefaults.hex, - sidebarTintHexLight: defaults.string(forKey: "sidebarTintHexLight"), - sidebarTintHexDark: defaults.string(forKey: "sidebarTintHexDark"), - sidebarTintOpacity: defaults.object(forKey: "sidebarTintOpacity") as? Double ?? SidebarTintDefaults.opacity, - sidebarCornerRadius: defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0, - sidebarBlurOpacity: defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 1.0, - bgGlassEnabled: defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false, - bgGlassTintHex: defaults.string(forKey: "bgGlassTintHex") ?? "#000000", - bgGlassTintOpacity: defaults.object(forKey: "bgGlassTintOpacity") as? Double ?? 0.03, - app: app - ) - } - - func windowRootBackdropResolution(surfaceBackgroundColor color: NSColor?) -> ( - snapshot: Self, - source: String, - overrideHex: String - ) { - // OSC 11 is pane-local state; the shared root remains the workspace backdrop. - ( - snapshot: self, - source: color == nil ? "defaultBackground" : "defaultBackground(surfaceOverrideLocal)", - overrideHex: color?.hexString() ?? "nil" - ) - } - - var appKitWindowMutationID: String { - backdropPlan().appKitMutationID - } - - func shouldUseTransparentHosting(glassEffectAvailable: Bool = WindowGlassEffect.isAvailable) -> Bool { - backdropPlan(glassEffectAvailable: glassEffectAvailable).usesTransparentWindow - } - - func backdropPlan(glassEffectAvailable: Bool = WindowGlassEffect.isAvailable) -> WindowBackdropPlan { - let rootPolicy = terminalBackdropPolicy() - if windowGlassSettings.shouldApply(glassEffectAvailable: glassEffectAvailable) { - return WindowBackdropPlan( - hostingPhase: .windowGlass, - windowBackgroundColor: WindowBackgroundComposition.policy.transparentWindowBaseColor, - windowIsOpaque: false, - rootPolicy: rootPolicy, - glass: WindowBackdropGlassPlan( - tintColor: windowGlassSettings.tintColor, - style: windowGlassSettings.style - ), - shouldApplyGhosttyCompositorBlur: false - ) - } - - if terminalBackgroundOpacity < 0.999 { - return WindowBackdropPlan( - hostingPhase: .transparentRootBackdrop, - windowBackgroundColor: WindowBackgroundComposition.policy.transparentWindowBaseColor, - windowIsOpaque: false, - rootPolicy: rootPolicy, - glass: nil, - shouldApplyGhosttyCompositorBlur: !terminalBackgroundBlur.isMacOSGlassStyle - ) - } - - return WindowBackdropPlan( - hostingPhase: .opaqueWindowFill, - windowBackgroundColor: compositedTerminalBackgroundColor, - windowIsOpaque: true, - rootPolicy: rootPolicy, - glass: nil, - shouldApplyGhosttyCompositorBlur: false - ) - } - - private static func currentAppColorScheme( - appearance: NSAppearance = NSApplication.shared.effectiveAppearance - ) -> ColorScheme { - appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? .dark : .light - } -} - -private extension WindowBackdropPolicy { - var identityComponent: String { - switch self { - case let .ghosttyTerminalBackdrop(color, opacity, renderingMode): - return [ - "ghosttyTerminalBackdrop", - color.hexString(includeAlpha: true), - String(format: "%.4f", Double(opacity)), - String(describing: renderingMode), - ].joined(separator: ":") - case let .sidebarMaterial(materialPolicy): - return [ - "sidebarMaterial", - String(describing: materialPolicy.material), - String(describing: materialPolicy.blendingMode), - String(describing: materialPolicy.state), - String(format: "%.4f", materialPolicy.opacity), - materialPolicy.tintColor.hexString(includeAlpha: true), - String(format: "%.4f", Double(materialPolicy.cornerRadius)), - String(materialPolicy.preferLiquidGlass), - String(materialPolicy.usesWindowLevelGlass), - ].joined(separator: ":") - case .clear: - return "clear" - } - } -} diff --git a/Sources/Windowing/WindowGlassEffect.swift b/Sources/Windowing/WindowGlassEffect.swift deleted file mode 100644 index 863362552d8a..000000000000 --- a/Sources/Windowing/WindowGlassEffect.swift +++ /dev/null @@ -1,547 +0,0 @@ -import AppKit -import CmuxFoundation -import ObjectiveC - -/// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView -enum WindowGlassEffect { - enum Style: Equatable { - case regular - case clear - - fileprivate var rawNSGlassEffectViewStyle: Int { - switch self { - case .regular: return 0 - case .clear: return 1 - } - } - } - - static let backgroundViewIdentifier = NSUserInterfaceItemIdentifier("cmux.windowGlassBackground") - static let rootViewIdentifier = NSUserInterfaceItemIdentifier("cmux.windowGlassRoot") - static let foregroundContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.windowGlassForeground") - - private final class OriginalContentLayoutState: NSObject { - let translatesAutoresizingMaskIntoConstraints: Bool - let autoresizingMask: NSView.AutoresizingMask - - init(view: NSView) { - translatesAutoresizingMaskIntoConstraints = view.translatesAutoresizingMaskIntoConstraints - autoresizingMask = view.autoresizingMask - } - - func restore(to view: NSView) { - view.translatesAutoresizingMaskIntoConstraints = translatesAutoresizingMaskIntoConstraints - view.autoresizingMask = autoresizingMask - } - } - - private final class GlassBackgroundView: NSView { - private let effectView: NSView - private let tintOverlay: NSView - private let usesNativeGlass: Bool - private var effectTopConstraint: NSLayoutConstraint! - private weak var observedWindow: NSWindow? - private var currentTintColor: NSColor? - - init( - frame: NSRect, - topOffset: CGFloat, - tintColor: NSColor?, - style: Style?, - cornerRadius: CGFloat?, - isKeyWindow: Bool - ) { - if let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - effectView = glassClass.init(frame: .zero) - usesNativeGlass = true - } else { - let fallbackView = NSVisualEffectView(frame: .zero) - fallbackView.blendingMode = .behindWindow - fallbackView.material = .underWindowBackground - fallbackView.state = .active - effectView = fallbackView - usesNativeGlass = false - } - tintOverlay = NSView(frame: .zero) - - super.init(frame: frame) - - identifier = WindowGlassEffect.backgroundViewIdentifier - translatesAutoresizingMaskIntoConstraints = false - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - layer?.isOpaque = false - - effectView.translatesAutoresizingMaskIntoConstraints = false - effectView.wantsLayer = true - addSubview(effectView) - effectTopConstraint = effectView.topAnchor.constraint(equalTo: topAnchor, constant: topOffset) - NSLayoutConstraint.activate([ - effectTopConstraint, - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - - tintOverlay.translatesAutoresizingMaskIntoConstraints = false - tintOverlay.wantsLayer = true - tintOverlay.alphaValue = 0 - addSubview(tintOverlay, positioned: .above, relativeTo: effectView) - NSLayoutConstraint.activate([ - tintOverlay.topAnchor.constraint(equalTo: effectView.topAnchor), - tintOverlay.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), - tintOverlay.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), - tintOverlay.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), - ]) - - configure( - tintColor: tintColor, - style: style, - cornerRadius: cornerRadius, - isKeyWindow: isKeyWindow - ) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - updateObservedWindow(window) - } - - func updateTopOffset(_ offset: CGFloat) { - effectTopConstraint.constant = offset - } - - func configure( - tintColor: NSColor?, - style: Style?, - cornerRadius: CGFloat?, - isKeyWindow: Bool - ) { - currentTintColor = tintColor - effectView.layer?.cornerRadius = cornerRadius ?? 0 - if usesNativeGlass { - updateNativeGlassConfiguration( - on: effectView, - color: tintColor, - style: style, - cornerRadius: cornerRadius - ) - updateInactiveTintOverlay(tintColor: tintColor, isKeyWindow: isKeyWindow) - } else if let tintColor { - effectView.layer?.masksToBounds = cornerRadius != nil - let fallbackTint = tintColor.withAlphaComponent(min(tintColor.alphaComponent, 0.45)) - tintOverlay.layer?.backgroundColor = fallbackTint.cgColor - tintOverlay.alphaValue = 1 - } else { - effectView.layer?.masksToBounds = cornerRadius != nil - tintOverlay.layer?.backgroundColor = nil - tintOverlay.alphaValue = 0 - } - } - - private func updateObservedWindow(_ window: NSWindow?) { - guard usesNativeGlass else { return } - if let observedWindow, observedWindow === window { - updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: observedWindow.isKeyWindow) - return - } - - if let observedWindow { - NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeKeyNotification, object: observedWindow) - NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: observedWindow) - } - observedWindow = window - guard let window else { return } - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidBecomeKey(_:)), - name: NSWindow.didBecomeKeyNotification, - object: window - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidResignKey(_:)), - name: NSWindow.didResignKeyNotification, - object: window - ) - updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: window.isKeyWindow) - } - - @objc private func windowDidBecomeKey(_ notification: Notification) { - updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: true) - } - - @objc private func windowDidResignKey(_ notification: Notification) { - updateInactiveTintOverlay(tintColor: currentTintColor, isKeyWindow: false) - } - - private func updateInactiveTintOverlay(tintColor: NSColor?, isKeyWindow: Bool) { - guard let tintColor else { - tintOverlay.layer?.backgroundColor = nil - tintOverlay.alphaValue = 0 - return - } - - tintOverlay.layer?.backgroundColor = tintColor.adjustingSaturation(by: 1.2).cgColor - tintOverlay.alphaValue = isKeyWindow ? 0 : (tintColor.isLightColor ? 0.35 : 0.85) - } - } - - private final class GlassRootView: NSView { - let foregroundContainer = NSView(frame: .zero) - weak var originalContentView: NSView? - - private let backgroundView: GlassBackgroundView - - override var isOpaque: Bool { false } - - init( - frame: NSRect, - topOffset: CGFloat, - tintColor: NSColor?, - style: Style?, - cornerRadius: CGFloat?, - isKeyWindow: Bool - ) { - backgroundView = GlassBackgroundView( - frame: frame, - topOffset: topOffset, - tintColor: tintColor, - style: style, - cornerRadius: cornerRadius, - isKeyWindow: isKeyWindow - ) - - super.init(frame: frame) - - identifier = WindowGlassEffect.rootViewIdentifier - autoresizingMask = [.width, .height] - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - layer?.isOpaque = false - - backgroundView.translatesAutoresizingMaskIntoConstraints = false - addSubview(backgroundView) - - foregroundContainer.identifier = WindowGlassEffect.foregroundContainerIdentifier - foregroundContainer.frame = bounds - foregroundContainer.translatesAutoresizingMaskIntoConstraints = false - foregroundContainer.wantsLayer = true - foregroundContainer.layer?.backgroundColor = NSColor.clear.cgColor - foregroundContainer.layer?.isOpaque = false - addSubview(foregroundContainer, positioned: .above, relativeTo: backgroundView) - - NSLayoutConstraint.activate([ - backgroundView.topAnchor.constraint(equalTo: topAnchor), - backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), - backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), - backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), - - foregroundContainer.topAnchor.constraint(equalTo: topAnchor), - foregroundContainer.bottomAnchor.constraint(equalTo: bottomAnchor), - foregroundContainer.leadingAnchor.constraint(equalTo: leadingAnchor), - foregroundContainer.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func attachOriginalContentView(_ contentView: NSView) { - originalContentView = contentView - contentView.removeFromSuperview() - contentView.frame = foregroundContainer.bounds - contentView.translatesAutoresizingMaskIntoConstraints = true - contentView.autoresizingMask = [.width, .height] - foregroundContainer.addSubview(contentView, positioned: .below, relativeTo: nil) - } - - func configure( - topOffset: CGFloat, - tintColor: NSColor?, - style: Style?, - cornerRadius: CGFloat?, - isKeyWindow: Bool - ) { - backgroundView.updateTopOffset(topOffset) - backgroundView.configure( - tintColor: tintColor, - style: style, - cornerRadius: cornerRadius, - isKeyWindow: isKeyWindow - ) - } - } - - private static var glassRootViewKey: UInt8 = 0 - private static var fallbackBackgroundViewKey: UInt8 = 0 - private static var originalContentViewKey: UInt8 = 0 - private static var originalContentLayoutStateKey: UInt8 = 0 - - static var isAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil - } - - @discardableResult - static func apply(to window: NSWindow, tintColor: NSColor? = nil, style: Style? = nil) -> Bool { - guard let currentContentView = window.contentView else { return false } - guard isAvailable else { - return applyFallback(to: window, contentView: currentContentView, tintColor: tintColor) - } - removeFallback(from: window) - - let topOffset = glassTopOffset(for: window, contentView: currentContentView) - let cornerRadius = windowCornerRadius(for: window) - - if let rootView = activeRootView(for: window) { - rootView.configure( - topOffset: topOffset, - tintColor: tintColor, - style: style, - cornerRadius: cornerRadius, - isKeyWindow: window.isKeyWindow - ) - return false - } - - let originalContentView = currentContentView - let layoutState = OriginalContentLayoutState(view: originalContentView) - let rootView = GlassRootView( - frame: originalContentView.frame, - topOffset: topOffset, - tintColor: tintColor, - style: style, - cornerRadius: cornerRadius, - isKeyWindow: window.isKeyWindow - ) - window.contentView = rootView - rootView.attachOriginalContentView(originalContentView) - - objc_setAssociatedObject(window, &glassRootViewKey, rootView, .OBJC_ASSOCIATION_RETAIN) - objc_setAssociatedObject(window, &originalContentViewKey, originalContentView, .OBJC_ASSOCIATION_RETAIN) - objc_setAssociatedObject(window, &originalContentLayoutStateKey, layoutState, .OBJC_ASSOCIATION_RETAIN) - return true - } - - /// Update the tint color on an existing glass effect - static func updateTint(to window: NSWindow, color: NSColor?) { - if let rootView = activeRootView(for: window) { - rootView.configure( - topOffset: glassTopOffset(for: window, contentView: window.contentView), - tintColor: color, - style: nil, - cornerRadius: windowCornerRadius(for: window), - isKeyWindow: window.isKeyWindow - ) - } else if let fallbackView = fallbackBackgroundView(for: window) { - fallbackView.configure( - tintColor: color, - style: nil, - cornerRadius: windowCornerRadius(for: window), - isKeyWindow: window.isKeyWindow - ) - } - } - - private static func updateNativeGlassConfiguration( - on glassView: NSView, - color: NSColor?, - style: Style?, - cornerRadius: CGFloat? - ) { - let tintSelector = NSSelectorFromString("setTintColor:") - if glassView.responds(to: tintSelector) { - glassView.perform(tintSelector, with: color) - } - - if let cornerRadius { - let cornerRadiusSelector = NSSelectorFromString("setCornerRadius:") - if glassView.responds(to: cornerRadiusSelector) { - typealias CornerRadiusSetter = @convention(c) (AnyObject, Selector, CGFloat) -> Void - guard let implementation = glassView.method(for: cornerRadiusSelector) else { return } - let setter = unsafeBitCast(implementation, to: CornerRadiusSetter.self) - setter(glassView, cornerRadiusSelector, cornerRadius) - } - } - - if let style { - let styleSelector = NSSelectorFromString("setStyle:") - guard glassView.responds(to: styleSelector) else { return } - typealias StyleSetter = @convention(c) (AnyObject, Selector, Int) -> Void - guard let implementation = glassView.method(for: styleSelector) else { return } - let setter = unsafeBitCast(implementation, to: StyleSetter.self) - setter(glassView, styleSelector, style.rawNSGlassEffectViewStyle) - } - } - - static func foregroundContainer(for window: NSWindow) -> NSView? { - activeRootView(for: window)?.foregroundContainer - } - - static func originalContentView(for window: NSWindow) -> NSView? { - if let rootView = activeRootView(for: window), - let originalContentView = rootView.originalContentView { - return originalContentView - } - return objc_getAssociatedObject(window, &originalContentViewKey) as? NSView - } - - static func portalInstallationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { - guard let rootView = activeRootView(for: window), - let originalContentView = originalContentView(for: window), - originalContentView.superview === rootView.foregroundContainer else { - return nil - } - return (rootView.foregroundContainer, originalContentView) - } - - private static func activeRootView(for window: NSWindow) -> GlassRootView? { - if let rootView = window.contentView as? GlassRootView { - return rootView - } - guard let rootView = objc_getAssociatedObject(window, &glassRootViewKey) as? GlassRootView, - window.contentView === rootView else { - return nil - } - return rootView - } - - private static func fallbackBackgroundView(for window: NSWindow) -> GlassBackgroundView? { - objc_getAssociatedObject(window, &fallbackBackgroundViewKey) as? GlassBackgroundView - } - - @discardableResult - private static func applyFallback( - to window: NSWindow, - contentView: NSView, - tintColor: NSColor? - ) -> Bool { - guard let themeFrame = contentView.superview else { return false } - let cornerRadius = windowCornerRadius(for: window) - if let fallbackView = fallbackBackgroundView(for: window) { - if fallbackView.superview !== themeFrame { - fallbackView.removeFromSuperview() - attachFallback(fallbackView, to: themeFrame, below: contentView) - } - fallbackView.configure( - tintColor: tintColor, - style: nil, - cornerRadius: cornerRadius, - isKeyWindow: window.isKeyWindow - ) - return false - } - - let fallbackView = GlassBackgroundView( - frame: themeFrame.bounds, - topOffset: 0, - tintColor: tintColor, - style: nil, - cornerRadius: cornerRadius, - isKeyWindow: window.isKeyWindow - ) - attachFallback(fallbackView, to: themeFrame, below: contentView) - objc_setAssociatedObject(window, &fallbackBackgroundViewKey, fallbackView, .OBJC_ASSOCIATION_RETAIN) - return true - } - - private static func attachFallback( - _ fallbackView: GlassBackgroundView, - to themeFrame: NSView, - below contentView: NSView - ) { - fallbackView.removeFromSuperview() - fallbackView.translatesAutoresizingMaskIntoConstraints = false - themeFrame.addSubview(fallbackView, positioned: .below, relativeTo: contentView) - NSLayoutConstraint.activate([ - fallbackView.topAnchor.constraint(equalTo: themeFrame.topAnchor), - fallbackView.bottomAnchor.constraint(equalTo: themeFrame.bottomAnchor), - fallbackView.leadingAnchor.constraint(equalTo: themeFrame.leadingAnchor), - fallbackView.trailingAnchor.constraint(equalTo: themeFrame.trailingAnchor), - ]) - } - - private static func glassTopOffset(for window: NSWindow, contentView: NSView?) -> CGFloat { - guard let themeFrame = contentView?.superview ?? window.contentView?.superview else { - return 0 - } - return -max(0, themeFrame.safeAreaInsets.top) - } - - private static func windowCornerRadius(for window: NSWindow) -> CGFloat? { - guard window.responds(to: Selector(("_cornerRadius"))) else { - return nil - } - return window.value(forKey: "_cornerRadius") as? CGFloat - } - - @discardableResult - static func remove(from window: NSWindow) -> Bool { - if !removeNativeRoot(from: window) { - return removeFallback(from: window) - } - removeFallback(from: window) - return true - } - - @discardableResult - private static func removeNativeRoot(from window: NSWindow) -> Bool { - guard let rootView = activeRootView(for: window) else { - return false - } - - if let originalContentView = originalContentView(for: window) { - originalContentView.removeFromSuperview() - originalContentView.frame = rootView.bounds - if let layoutState = objc_getAssociatedObject( - window, - &originalContentLayoutStateKey - ) as? OriginalContentLayoutState { - layoutState.restore(to: originalContentView) - } - window.contentView = originalContentView - } - - objc_setAssociatedObject(window, &glassRootViewKey, nil, .OBJC_ASSOCIATION_RETAIN) - objc_setAssociatedObject(window, &originalContentViewKey, nil, .OBJC_ASSOCIATION_RETAIN) - objc_setAssociatedObject(window, &originalContentLayoutStateKey, nil, .OBJC_ASSOCIATION_RETAIN) - return true - } - - @discardableResult - private static func removeFallback(from window: NSWindow) -> Bool { - guard let fallbackView = fallbackBackgroundView(for: window) else { return false } - fallbackView.removeFromSuperview() - objc_setAssociatedObject(window, &fallbackBackgroundViewKey, nil, .OBJC_ASSOCIATION_RETAIN) - return true - } - -} - -private extension NSColor { - func adjustingSaturation(by factor: CGFloat) -> NSColor { - var hue: CGFloat = 0 - var saturation: CGFloat = 0 - var brightness: CGFloat = 0 - var alpha: CGFloat = 0 - let color = usingColorSpace(.sRGB) ?? self - color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - return NSColor( - hue: hue, - saturation: min(max(saturation * factor, 0), 1), - brightness: brightness, - alpha: alpha - ) - } -} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e3bbefa0d766..632123d29745 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1,3 +1,4 @@ +import CmuxAppKitSupportUI import CmuxFoundation import Foundation import CmuxCore @@ -2682,8 +2683,8 @@ final class Workspace: Identifiable, ObservableObject { backgroundOpacity: backgroundOpacity, sharesWindowBackdrop: sharesWindowBackdrop ) - let borderHex = WindowChromeSeparatorColor - .color(forChromeBackground: backgroundColor) + let borderHex = WindowChromeColorResolver() + .separatorColor(forChromeBackground: backgroundColor) .hexString(includeAlpha: true) if sharesWindowBackdrop { @@ -2719,8 +2720,8 @@ final class Workspace: Identifiable, ObservableObject { // Keep this signature aligned with bonsplitChromeHex for settings tests // and future background-image handling. let backgroundHex = backgroundColor.hexString() - let borderHex = WindowChromeSeparatorColor - .color(forChromeBackground: backgroundColor) + let borderHex = WindowChromeColorResolver() + .separatorColor(forChromeBackground: backgroundColor) .hexString(includeAlpha: true) if sharesWindowBackdrop { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index df729d34b0e2..a20b9e1b2189 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2550,8 +2550,8 @@ private struct AboutPanelView: View { private struct SidebarDebugView: View { @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false @AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults().opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults().hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue @@ -2707,8 +2707,8 @@ private struct SidebarDebugView: View { HStack(spacing: 12) { Button("Reset Tint") { - sidebarTintOpacity = 0.62 - sidebarTintHex = SidebarTintDefaults.hex + sidebarTintOpacity = SidebarTintDefaults().opacity + sidebarTintHex = SidebarTintDefaults().hex sidebarTintHexLight = nil sidebarTintHexDark = nil } @@ -3094,7 +3094,7 @@ private struct TabBarBackdropLabView: View { } private var separatorColor: NSColor { - WindowChromeSeparatorColor.color(forChromeBackground: terminalColor) + WindowChromeColorResolver().separatorColor(forChromeBackground: terminalColor) } private var candidateBackdropEffect: BonsplitConfiguration.Appearance.SplitButtonBackdropEffect { @@ -3793,7 +3793,7 @@ private struct BackgroundDebugView: View { }() guard let window else { return } let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowBackdropController.updateGlassTint(to: window, color: tintColor) + AppWindowChromeComposition().backdropController.updateGlassTint(to: window, color: tintColor) } private var tintColorBinding: Binding { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b63370c71d0b..9643833a3545 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -73,6 +73,8 @@ A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAA000000000000000001 /* AppearanceSettingsTests.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; + A5001A02A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */; }; + A5001A00A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */; }; A47E00010000000000000001 /* AuthEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A47E00010000000000000002 /* AuthEnvironmentTests.swift */; }; @@ -83,6 +85,7 @@ 74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */; }; C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; + B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */ = {isa = PBXBuildFile; productRef = A5001262 /* Bonsplit */; }; D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */; }; D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */; }; AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */; }; @@ -882,15 +885,12 @@ C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.swift */; }; A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; }; 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; - A5001700A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */; }; A59170000000000000000001 /* WindowAppearanceSnapshotPaneBackgroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59170000000000000000002 /* WindowAppearanceSnapshotPaneBackgroundTests.swift */; }; F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */; }; - A5001800A1B2C3D4E5F60718 /* WindowBackdropController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */; }; A5001900A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001901A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift */; }; B7F9A602B7F9A602B7F9A602 /* WindowChromeMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9A603B7F9A603B7F9A603 /* WindowChromeMetrics.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; - 807E058A23061EFB70A1B7F8 /* WindowGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */; }; D0B10024A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10025A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift */; }; 313585583035A1E685010A97 /* WindowKeyDownReplayGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B8CFAF8FCA4231C702D16A /* WindowKeyDownReplayGuard.swift */; }; 8770D0F2D2BB45359D6BE5E3 /* WindowKeyDownReplayGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */; }; @@ -1097,6 +1097,8 @@ IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = ""; }; A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; + A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/AppWindowBackdropControllerDependencies.swift; sourceTree = ""; }; + A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/AppWindowChromeComposition.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthEnvironment.swift; sourceTree = ""; }; A47E00010000000000000002 /* AuthEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthEnvironmentTests.swift; sourceTree = ""; }; @@ -1819,15 +1821,12 @@ C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.swift; sourceTree = ""; }; A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; - A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowAppearanceSnapshot.swift; sourceTree = ""; }; A59170000000000000000002 /* WindowAppearanceSnapshotPaneBackgroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotPaneBackgroundTests.swift; sourceTree = ""; }; F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotTests.swift; sourceTree = ""; }; - A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowBackdropController.swift; sourceTree = ""; }; A5001901A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowBackgroundComposition.swift; sourceTree = ""; }; B7F9A603B7F9A603B7F9A603 /* WindowChromeMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowChromeMetrics.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; - 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowGlassEffect.swift; sourceTree = ""; }; D0B10025A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowInputRoutingContext.swift; sourceTree = ""; }; 81B8CFAF8FCA4231C702D16A /* WindowKeyDownReplayGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WindowKeyDownReplayGuard.swift; sourceTree = ""; }; B79482F1ECA54E98BE5C8953 /* WindowKeyDownReplayGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowKeyDownReplayGuardTests.swift; sourceTree = ""; }; @@ -2008,6 +2007,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */, C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */, CA52A007CA52A007CA52A007 /* CmuxCanvas in Frameworks */, 5E55500000000000000000E4 /* CmuxCommandPalette in Frameworks */, @@ -2254,11 +2254,10 @@ C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */, 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */, A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */, + A5001A01A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift */, + A5001A03A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift */, 6042D0046042D0046042D004 /* ShortcutHintTitlebarPolicy.swift */, - 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */, 6042D0056042D0056042D005 /* WindowScopedShortcutHintModifierMonitor.swift */, - A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */, - A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */, A5001901A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */, @@ -3178,6 +3177,7 @@ 5E55300000000000000000C2 /* CmuxCommandPaletteUI */, C0DE4A000000000000000004 /* CmuxSidebarProviderKit */, CC0DE00000000000000000A2 /* CmuxCore */, + A5001262 /* Bonsplit */, ); productName = cmuxTests; productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; @@ -3500,6 +3500,8 @@ A5001093 /* AppDelegate.swift in Sources */, A11EAB000000000000000000 /* AppearanceSettings.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, + A5001A02A1B2C3D4E5F60718 /* AppWindowBackdropControllerDependencies.swift in Sources */, + A5001A00A1B2C3D4E5F60718 /* AppWindowChromeComposition.swift in Sources */, D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */, C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */, A50012F1 /* Backport.swift in Sources */, @@ -3943,13 +3945,10 @@ ACE4FE1A8F1CC6C0B6A673AB /* VMClient.swift in Sources */, ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, - A5001700A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift in Sources */, - A5001800A1B2C3D4E5F60718 /* WindowBackdropController.swift in Sources */, A5001900A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift in Sources */, B7F9A602B7F9A602B7F9A602 /* WindowChromeMetrics.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, - 807E058A23061EFB70A1B7F8 /* WindowGlassEffect.swift in Sources */, D0B10024A1B2C3D4E5F60001 /* WindowInputRoutingContext.swift in Sources */, 313585583035A1E685010A97 /* WindowKeyDownReplayGuard.swift in Sources */, 6042C0056042C0056042C005 /* WindowScopedShortcutHintModifierMonitor.swift in Sources */, @@ -5339,6 +5338,11 @@ package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; productName = Bonsplit; }; + A5001262 /* Bonsplit */ = { + isa = XCSwiftPackageProductDependency; + package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */; + productName = Bonsplit; + }; A5001271 /* PostHog */ = { isa = XCSwiftPackageProductDependency; package = A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */; diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 2fb7c2185e30..5e1678827bb7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1,4 +1,5 @@ @preconcurrency import XCTest +import CmuxAppKitSupportUI import CmuxSettings import CmuxBrowser import CmuxBrowserImport @@ -1466,7 +1467,7 @@ final class WindowChromeSeparatorColorTests: XCTestCase { return } - let color = WindowChromeSeparatorColor.color(forChromeBackground: backgroundColor) + let color = WindowChromeColorResolver().separatorColor(forChromeBackground: backgroundColor) let rgba = rgbaComponents(color) XCTAssertEqual(rgba.red, CGFloat(39.0 / 255.0) + CGFloat(0.16), accuracy: 0.0001) @@ -1481,7 +1482,7 @@ final class WindowChromeSeparatorColorTests: XCTestCase { return } - let color = WindowChromeSeparatorColor.color(forChromeBackground: backgroundColor) + let color = WindowChromeColorResolver().separatorColor(forChromeBackground: backgroundColor) let rgba = rgbaComponents(color) XCTAssertEqual(rgba.red, CGFloat(253.0 / 255.0) - CGFloat(0.12), accuracy: 0.0001) diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index efe610ce98a0..6db2d6a2bb5b 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxAppKitSupportUI import CmuxFoundation import SwiftUI import XCTest @@ -427,10 +428,10 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { materialRawValue: SidebarMaterialOption.sidebar.rawValue, blendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, stateRawValue: SidebarStateOption.followWindow.rawValue, - tintHex: SidebarTintDefaults.hex, + tintHex: SidebarTintDefaults().hex, tintHexLight: nil, tintHexDark: nil, - tintOpacity: SidebarTintDefaults.opacity, + tintOpacity: SidebarTintDefaults().opacity, cornerRadius: 0, blurOpacity: 1, colorScheme: .light diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index aee64a5f6917..79144b18322e 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import CmuxAppKitSupportUI import CmuxFoundation import Carbon.HIToolbox import Darwin @@ -52,31 +53,33 @@ final class WindowGlassEffectTests: XCTestCase { ) window.contentView = originalContentView - WindowGlassEffect.apply(to: window, tintColor: .systemBlue) + let glassEffect = WindowGlassEffect() + glassEffect.apply(to: window, tintColor: .systemBlue) - if WindowGlassEffect.isAvailable { + if glassEffect.isAvailable { XCTAssertFalse(window.contentView === originalContentView) - XCTAssertTrue(WindowGlassEffect.originalContentView(for: window) === originalContentView) - XCTAssertTrue(originalContentView.superview === WindowGlassEffect.foregroundContainer(for: window)) - XCTAssertNotNil(WindowGlassEffect.portalInstallationTarget(for: window)) + XCTAssertTrue(glassEffect.originalContentView(for: window) === originalContentView) + XCTAssertTrue(originalContentView.superview === glassEffect.foregroundContainer(for: window)) + XCTAssertNotNil(glassEffect.portalInstallationTarget(for: window)) } else { XCTAssertTrue(window.contentView === originalContentView) - XCTAssertNil(WindowGlassEffect.originalContentView(for: window)) - XCTAssertNil(WindowGlassEffect.foregroundContainer(for: window)) - XCTAssertNil(WindowGlassEffect.portalInstallationTarget(for: window)) + XCTAssertNil(glassEffect.originalContentView(for: window)) + XCTAssertNil(glassEffect.foregroundContainer(for: window)) + XCTAssertNil(glassEffect.portalInstallationTarget(for: window)) } XCTAssertTrue(Self.windowContainsGlassBackground(window)) - WindowGlassEffect.remove(from: window) + glassEffect.remove(from: window) XCTAssertTrue(window.contentView === originalContentView) - XCTAssertNil(WindowGlassEffect.foregroundContainer(for: window)) - XCTAssertNil(WindowGlassEffect.originalContentView(for: window)) + XCTAssertNil(glassEffect.foregroundContainer(for: window)) + XCTAssertNil(glassEffect.originalContentView(for: window)) XCTAssertFalse(Self.windowContainsGlassBackground(window)) } func testNativeGlassTintFollowsWindowKeyNotifications() throws { - guard WindowGlassEffect.isAvailable else { + let glassEffect = WindowGlassEffect() + guard glassEffect.isAvailable else { throw XCTSkip("NSGlassEffectView is unavailable on this macOS version") } _ = NSApplication.shared @@ -90,7 +93,7 @@ final class WindowGlassEffectTests: XCTestCase { ) window.contentView = originalContentView - WindowGlassEffect.apply(to: window, tintColor: .black, style: .clear) + glassEffect.apply(to: window, tintColor: .black, style: .clear) guard let backgroundView = Self.glassBackgroundView(in: window.contentView), let tintOverlay = backgroundView.subviews.last else { @@ -113,7 +116,7 @@ final class WindowGlassEffectTests: XCTestCase { private static func glassBackgroundView(in view: NSView?) -> NSView? { guard let view else { return nil } - if view.identifier == WindowGlassEffect.backgroundViewIdentifier { + if view.identifier == WindowGlassEffect().backgroundViewIdentifier { return view } return view.subviews.lazy.compactMap(glassBackgroundView(in:)).first @@ -1963,17 +1966,7 @@ final class DraggableFolderHitTests: XCTestCase { } @MainActor -final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { - func testLeadingInsetViewDoesNotParticipateInHitTesting() { - let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) - XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) - } - - func testLeadingInsetViewCannotMoveWindowViaMouseDown() { - let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) - XCTAssertFalse(view.mouseDownCanMoveWindow) - } - +final class MainWindowDragBehaviorTests: XCTestCase { func testMainWindowHostingViewCannotMoveWindowViaMouseDown() { let view = MainWindowHostingView(rootView: Color.clear) XCTAssertFalse( diff --git a/cmuxTests/WindowAppearanceSnapshotPaneBackgroundTests.swift b/cmuxTests/WindowAppearanceSnapshotPaneBackgroundTests.swift index 211f967ab8ca..8382d4f7fbe8 100644 --- a/cmuxTests/WindowAppearanceSnapshotPaneBackgroundTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotPaneBackgroundTests.swift @@ -1,4 +1,7 @@ import AppKit +import CmuxAppKitSupportUI +import CmuxFoundation +import CmuxWorkspaceWindow import SwiftUI import Testing diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index af1a22dc9bf2..775902b3f042 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -1,6 +1,8 @@ import XCTest import AppKit +import CmuxAppKitSupportUI import CmuxFoundation +import CmuxWorkspaceWindow import SwiftUI #if canImport(cmux_DEV) @@ -53,12 +55,21 @@ final class WindowAppearanceSnapshotTests: XCTestCase { backgroundBlur: .macosGlassClear ) - XCTAssertTrue(snapshot.shouldUseTransparentHosting(glassEffectAvailable: true)) - XCTAssertTrue(snapshot.windowGlassSettings.shouldApply(glassEffectAvailable: true)) + XCTAssertTrue(snapshot.shouldUseTransparentHosting( + glassEffectAvailable: true, + windowBackgroundPolicy: WindowBackgroundComposition.policy + )) + XCTAssertTrue(snapshot.windowGlassSettings.shouldApply( + glassEffectAvailable: true, + windowBackgroundPolicy: WindowBackgroundComposition.policy + )) XCTAssertEqual(snapshot.windowGlassSettings.style, .clear) XCTAssertEqual(snapshot.windowGlassSettings.tintColor.hexString(includeAlpha: true), "#272822FF") assertClearBackdrop(snapshot.policy(for: .windowRoot)) - XCTAssertEqual(snapshot.backdropPlan(glassEffectAvailable: true).hostingPhase, .windowGlass) + XCTAssertEqual(snapshot.backdropPlan( + glassEffectAvailable: true, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ).hostingPhase, .windowGlass) } func testTranslucentTerminalWithSidebarTintKeepsRootBackdropOwner() { @@ -68,7 +79,10 @@ final class WindowAppearanceSnapshotTests: XCTestCase { sidebarTintHexDark: "#FF0000", sidebarTintOpacity: 0.4 ) - let plan = snapshot.backdropPlan(glassEffectAvailable: false) + let plan = snapshot.backdropPlan( + glassEffectAvailable: false, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ) XCTAssertEqual(plan.hostingPhase, .transparentRootBackdrop) XCTAssertTrue(plan.usesTransparentWindow) @@ -90,7 +104,10 @@ final class WindowAppearanceSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.compositedTerminalBackgroundColor.alphaComponent, 1, accuracy: 0.0001) - let plan = snapshot.backdropPlan(glassEffectAvailable: false) + let plan = snapshot.backdropPlan( + glassEffectAvailable: false, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ) XCTAssertEqual(plan.hostingPhase, .transparentRootBackdrop) XCTAssertTrue(plan.usesTransparentWindow) } @@ -110,8 +127,14 @@ final class WindowAppearanceSnapshotTests: XCTestCase { ) XCTAssertEqual( - red.backdropPlan(glassEffectAvailable: false).appKitMutationID, - blue.backdropPlan(glassEffectAvailable: false).appKitMutationID + red.backdropPlan( + glassEffectAvailable: false, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ).appKitMutationID, + blue.backdropPlan( + glassEffectAvailable: false, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ).appKitMutationID ) } @@ -208,7 +231,10 @@ final class WindowAppearanceSnapshotTests: XCTestCase { func testOpaqueTerminalUsesOpaqueWindowFill() { let snapshot = makeSnapshot(unifySurfaceBackdrops: false, backgroundOpacity: 1.0) - let plan = snapshot.backdropPlan(glassEffectAvailable: false) + let plan = snapshot.backdropPlan( + glassEffectAvailable: false, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ) XCTAssertEqual(plan.hostingPhase, .opaqueWindowFill) XCTAssertFalse(plan.usesTransparentWindow) @@ -222,7 +248,10 @@ final class WindowAppearanceSnapshotTests: XCTestCase { sidebarBlendMode: SidebarBlendModeOption.behindWindow.rawValue, bgGlassEnabled: true ) - let plan = snapshot.backdropPlan(glassEffectAvailable: true) + let plan = snapshot.backdropPlan( + glassEffectAvailable: true, + windowBackgroundPolicy: WindowBackgroundComposition.policy + ) XCTAssertEqual(plan.hostingPhase, .windowGlass) XCTAssertTrue(plan.usesTransparentWindow)