diff --git a/Sources/Cloud/PortForward/CloudBrowserAccessView.swift b/Sources/Cloud/PortForward/CloudBrowserAccessView.swift index a0a72c231932..36c21d370dd6 100644 --- a/Sources/Cloud/PortForward/CloudBrowserAccessView.swift +++ b/Sources/Cloud/PortForward/CloudBrowserAccessView.swift @@ -20,6 +20,7 @@ struct CloudBrowserAccessView: View { message: nil, onRetry: nil ) + .ghosttyDialogTheme() } else if state.showsPage || state.failureMessage == nil { VStack(spacing: 0) { if state.isDesktop && !state.desktopConnected && state.failureMessage == nil { @@ -37,10 +38,12 @@ struct CloudBrowserAccessView: View { _ = panel.reload() } ) + .ghosttyDialogTheme() } } } else if let message = state.unavailable { CloudBrowserConnectionCard(address: "", message: message, onRetry: state.unavailableRetryAction) + .ghosttyDialogTheme() } else { content() } @@ -48,21 +51,21 @@ struct CloudBrowserAccessView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: backgroundColor)) .accessibilityIdentifier("CloudBrowserAccess") - .alert( - String(localized: "cloud.overlay.error.title", defaultValue: "Cloud session unavailable"), - isPresented: Binding( - get: { isVisibleInUI && state.showsFailureAlert }, - set: { if !$0 { state.dismissFailure() } } - ) - ) { - if state.model != nil { - Button(String(localized: "common.retry", defaultValue: "Retry")) { - _ = panel.reload() - } + .overlay { + // An in-pane card rather than a system alert, which cannot take + // the Ghostty theme colors. + if isVisibleInUI && state.showsFailureAlert { + CloudFailureCard( + title: String(localized: "cloud.overlay.error.title", defaultValue: "Cloud session unavailable"), + detail: state.failureMessage ?? "", + copyableText: state.failureMessage ?? "", + style: .dialog, + onRetry: state.model == nil ? nil : { _ = panel.reload() }, + onDismiss: { state.dismissFailure() } + ) + .frame(maxWidth: 320) + .padding(12) } - Button(String(localized: "common.close", defaultValue: "Close"), role: .cancel) { state.dismissFailure() } - } message: { - Text(state.failureMessage ?? "") } .onChange(of: showsNativeContent, initial: true) { _, shown in if shown { BrowserWindowPortalRegistry.hide(webView: panel.webView, source: "cloudConnection") } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 60e76f8733af..a06e6b9a6d46 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -4027,14 +4027,7 @@ struct ContentView: View { } .frame(width: targetWidth) .background(CommandPalettePanelHitRegion()) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor).opacity(0.98)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) - ) + .ghosttyDialogSurface(cornerRadius: 8) .shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 5) .padding(.top, 40) } @@ -4522,6 +4515,7 @@ struct ContentView: View { func updateNSView(_ nsView: CommandPaletteNativeTextField, context: Context) { context.coordinator.parent = self context.coordinator.parentField = nsView + nsView.appearance = WindowAppearanceSnapshot.appKitAppearance(for: context.environment.colorScheme) nsView.placeholderString = placeholder nsView.font = GlobalFontMagnification.systemFont(ofSize: 13) @@ -5086,6 +5080,7 @@ struct ContentView: View { func updateNSView(_ nsView: CommandPaletteMultilineTextEditorView, context: Context) { context.coordinator.parent = self + nsView.appearance = WindowAppearanceSnapshot.appKitAppearance(for: context.environment.colorScheme) nsView.placeholder = placeholder nsView.maximumHeight = maxHeight nsView.textView.setAccessibilityLabel(accessibilityLabel) diff --git a/Sources/GhosttyConfigDiagnosticsNoticeView.swift b/Sources/GhosttyConfigDiagnosticsNoticeView.swift index c9da7bf5f65c..41d15d959097 100644 --- a/Sources/GhosttyConfigDiagnosticsNoticeView.swift +++ b/Sources/GhosttyConfigDiagnosticsNoticeView.swift @@ -69,11 +69,7 @@ struct GhosttyConfigDiagnosticsNoticeView: View { } .padding(12) .frame(width: 380, alignment: .leading) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) - ) + .ghosttyDialogSurface(cornerRadius: 10) .accessibilityElement(children: .contain) .accessibilityIdentifier("GhosttyConfigDiagnosticsNotice") } diff --git a/Sources/GhosttyDialogTheme.swift b/Sources/GhosttyDialogTheme.swift new file mode 100644 index 000000000000..94d3ba7ca31c --- /dev/null +++ b/Sources/GhosttyDialogTheme.swift @@ -0,0 +1,102 @@ +import AppKit +import SwiftUI + +/// Colors for in-app dialogs and cards drawn over terminal content. They use +/// the Ghostty theme's background and foreground, so a dark terminal theme on a +/// light macOS appearance (or the reverse) still gets a matching dialog. +struct GhosttyDialogTheme: Equatable { + /// Opaque terminal background. Translucent terminals keep readable dialogs. + let background: NSColor + /// Terminal foreground, adjusted only when it is unreadable on `background`. + let foreground: NSColor + /// The scheme system controls (buttons, progress, dynamic colors) resolve in. + let colorScheme: ColorScheme + + var secondaryForeground: NSColor { foreground.withAlphaComponent(0.65) } + var border: NSColor { foreground.withAlphaComponent(0.2) } + var separator: NSColor { foreground.withAlphaComponent(0.12) } + + static func resolved(background: NSColor, foreground: NSColor) -> GhosttyDialogTheme { + let opaqueBackground = (background.usingColorSpace(.sRGB) ?? background).withAlphaComponent(1) + let opaqueForeground = (foreground.usingColorSpace(.sRGB) ?? foreground).withAlphaComponent(1) + return GhosttyDialogTheme( + background: opaqueBackground, + foreground: cmuxReadableForegroundNSColor(preferred: opaqueForeground, on: opaqueBackground), + colorScheme: cmuxReadableColorScheme(for: opaqueBackground) + ) + } + + static func current() -> GhosttyDialogTheme { + resolved( + background: GhosttyApp.shared.defaultBackgroundColor, + foreground: GhosttyApp.shared.defaultForegroundColor + ) + } +} + +/// Publishes the current ``GhosttyDialogTheme`` and refreshes it when the +/// Ghostty default appearance changes, so every themed dialog shares one +/// observer instead of each view subscribing to notifications. +@MainActor +@Observable +final class GhosttyDialogThemeObserver { + static let shared = GhosttyDialogThemeObserver() + + private(set) var theme: GhosttyDialogTheme + @ObservationIgnored + private var observers: [NSObjectProtocol] = [] + + init(notificationCenter: NotificationCenter = .default) { + theme = .current() + for name in [Notification.Name.ghosttyDefaultBackgroundDidChange, .ghosttyConfigDidReload] { + observers.append(notificationCenter.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + }) + } + } + + func refresh() { + let next = GhosttyDialogTheme.current() + if next != theme { theme = next } + } +} + +private struct GhosttyDialogThemeModifier: ViewModifier { + @State private var observer = GhosttyDialogThemeObserver.shared + + func body(content: Content) -> some View { + let theme = observer.theme + content + .environment(\.colorScheme, theme.colorScheme) + .foregroundStyle(Color(nsColor: theme.foreground)) + } +} + +private struct GhosttyDialogSurfaceModifier: ViewModifier { + let cornerRadius: CGFloat + @State private var observer = GhosttyDialogThemeObserver.shared + + func body(content: Content) -> some View { + let theme = observer.theme + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + content + .background(Color(nsColor: theme.background), in: shape) + .overlay(shape.strokeBorder(Color(nsColor: theme.border), lineWidth: 1)) + .ghosttyDialogTheme() + } +} + +extension View { + /// Resolves this dialog subtree against the Ghostty theme: its color scheme + /// and default text color. Hierarchical styles such as `.secondary` derive + /// from the theme foreground. + func ghosttyDialogTheme() -> some View { + modifier(GhosttyDialogThemeModifier()) + } + + /// Draws a themed dialog panel: Ghostty background, a foreground-tinted + /// border, and ``ghosttyDialogTheme()`` for the content. + func ghosttyDialogSurface(cornerRadius: CGFloat) -> some View { + modifier(GhosttyDialogSurfaceModifier(cornerRadius: cornerRadius)) + } +} diff --git a/Sources/Panels/CloudPaneCreationFailureView.swift b/Sources/Panels/CloudPaneCreationFailureView.swift index 5ceac4bc7be7..b1a8e6e1eae8 100644 --- a/Sources/Panels/CloudPaneCreationFailureView.swift +++ b/Sources/Panels/CloudPaneCreationFailureView.swift @@ -275,6 +275,27 @@ struct CloudFailureCard: View { var style: Style = .compactBordered var onRetry: (() -> Void)? = nil let onDismiss: () -> Void + @State private var themeObserver = GhosttyDialogThemeObserver.shared + + var body: some View { + CloudFailureCardBody( + title: title, detail: detail, copyableText: copyableText, + style: style, onRetry: onRetry, onDismiss: onDismiss, + theme: themeObserver.theme + ) + .ghosttyDialogTheme() + } +} + +private struct CloudFailureCardBody: View { + typealias Style = CloudFailureCard.Style + let title: String + let detail: String + let copyableText: String + let style: Style + let onRetry: (() -> Void)? + let onDismiss: () -> Void + let theme: GhosttyDialogTheme private var cornerRadius: CGFloat { switch style { @@ -303,18 +324,18 @@ struct CloudFailureCard: View { } .padding(12) .frame(maxWidth: .infinity, alignment: style == .dialog ? .center : .leading) - .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: cornerRadius)) + .background(Color(nsColor: theme.background), in: RoundedRectangle(cornerRadius: cornerRadius)) .overlay { if style == .compactBordered { Rectangle() - .strokeBorder(Color.primary.opacity(0.22), lineWidth: 1) + .strokeBorder(Color(nsColor: theme.border), lineWidth: 1) } else if style != .inline { RoundedRectangle(cornerRadius: 9) - .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + .strokeBorder(Color(nsColor: theme.separator), lineWidth: 0.5) } } .overlay(alignment: .leading) { - if style == .inline { Rectangle().fill(Color.secondary.opacity(0.35)).frame(width: 2) } + if style == .inline { Rectangle().fill(Color(nsColor: theme.border)).frame(width: 2) } } .shadow(color: .black.opacity(style == .compact || style == .dialog ? 0.09 : 0), radius: 8, y: 3) .accessibilityIdentifier("CloudPaneCreationFailure") diff --git a/Sources/Panels/TerminalPanelUnavailableView.swift b/Sources/Panels/TerminalPanelUnavailableView.swift index d304a35a9f50..7584c841e7c8 100644 --- a/Sources/Panels/TerminalPanelUnavailableView.swift +++ b/Sources/Panels/TerminalPanelUnavailableView.swift @@ -25,5 +25,6 @@ struct TerminalPanelUnavailableView: View { .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: appearance.contentBackgroundColor)) + .ghosttyDialogTheme() } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1ad9fb805637..30a0a6560c0a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1987,6 +1987,8 @@ 468120000000000000000003 /* GhosttyDesktopNotificationIngress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468120000000000000000004 /* GhosttyDesktopNotificationIngress.swift */; }; 468130000000000000000001 /* GhosttyDesktopNotificationIngressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468130000000000000000002 /* GhosttyDesktopNotificationIngressTests.swift */; }; 468120000000000000000001 /* GhosttyDesktopNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468120000000000000000002 /* GhosttyDesktopNotificationRequest.swift */; }; + 6D1A7C3E0000000000000001 /* GhosttyDialogTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1A7C3E0000000000000002 /* GhosttyDialogTheme.swift */; }; + 1D786365381D3942FFA3EA94 /* GhosttyDialogThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */; }; 604100010000000000000001 /* GhosttyDrawableSizeRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604100010000000000000002 /* GhosttyDrawableSizeRetryTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; E89950010000000000000001 /* GhosttyFlashOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89950010000000000000002 /* GhosttyFlashOverlayView.swift */; }; @@ -6101,6 +6103,8 @@ 468120000000000000000004 /* GhosttyDesktopNotificationIngress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDesktopNotificationIngress.swift; sourceTree = ""; }; 468130000000000000000002 /* GhosttyDesktopNotificationIngressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDesktopNotificationIngressTests.swift; sourceTree = ""; }; 468120000000000000000002 /* GhosttyDesktopNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDesktopNotificationRequest.swift; sourceTree = ""; }; + 6D1A7C3E0000000000000002 /* GhosttyDialogTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDialogTheme.swift; sourceTree = ""; }; + 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyDialogThemeTests.swift"; sourceTree = ""; }; 604100010000000000000002 /* GhosttyDrawableSizeRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDrawableSizeRetryTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; E89950010000000000000002 /* GhosttyFlashOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyFlashOverlayView.swift; sourceTree = ""; }; @@ -10119,6 +10123,7 @@ A6AC72060000000000000002 /* SpinnerGalleryRootView.swift */, A6AC72070000000000000002 /* SpinnerSpec.swift */, A6AC72090000000000000002 /* SpinnerEnergy.swift */, + 6D1A7C3E0000000000000002 /* GhosttyDialogTheme.swift */, A6AC72050000000000000002 /* SpinnerCard.swift */, A6AC72040000000000000002 /* OverlayComparison.swift */, A6AC72030000000000000002 /* NativeSpinner.swift */, @@ -12518,6 +12523,7 @@ 31BFCF49D4B667B5C2A0BE3C /* TerminalSSHSessionDetectorProcessSnapshotTests.swift */, C51DB22258B5393E2AB9104C /* ClaudeHookSessionLivenessTests.swift */, A43345B5B3C60929B9F661CB /* ExitingProcessCensusReader.swift */, + 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -14273,6 +14279,7 @@ D78972000000000000000001 /* GhosttyCurrentDirectoryActionDispatcher.swift in Sources */, 468120000000000000000003 /* GhosttyDesktopNotificationIngress.swift in Sources */, 468120000000000000000001 /* GhosttyDesktopNotificationRequest.swift in Sources */, + 6D1A7C3E0000000000000001 /* GhosttyDialogTheme.swift in Sources */, E89950010000000000000001 /* GhosttyFlashOverlayView.swift in Sources */, B5600E7C40774F36844FBF79 /* GhosttyHostAction.swift in Sources */, B3EC083AAEF1408FA686E674 /* GhosttyHostActionHandler.swift in Sources */, @@ -16687,6 +16694,7 @@ D78976000000000000000001 /* GhosttyCurrentDirectoryActionDispatcherTests.swift in Sources */, D5935001A1B2C3D4E5F60718 /* GhosttyDECCKMArrowKeyTests.swift in Sources */, 468130000000000000000001 /* GhosttyDesktopNotificationIngressTests.swift in Sources */, + 1D786365381D3942FFA3EA94 /* GhosttyDialogThemeTests.swift in Sources */, 604100010000000000000001 /* GhosttyDrawableSizeRetryTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, 002B272099440F41EC5623C4 /* GhosttyHostKeybindDefaultsTests.swift in Sources */, diff --git a/cmuxTests/GhosttyDialogThemeTests.swift b/cmuxTests/GhosttyDialogThemeTests.swift new file mode 100644 index 000000000000..a095f9b6e19d --- /dev/null +++ b/cmuxTests/GhosttyDialogThemeTests.swift @@ -0,0 +1,54 @@ +import AppKit +import CmuxFoundation +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct GhosttyDialogThemeTests { + private func hex(_ color: NSColor) -> String { color.hexString() } + + @Test("A dark Ghostty theme gives a dark dialog even when macOS is light") + func darkThemeDrivesDialogColors() throws { + let background = try #require(NSColor(hex: "#1D1F21")) + let foreground = try #require(NSColor(hex: "#C5C8C6")) + let theme = GhosttyDialogTheme.resolved(background: background, foreground: foreground) + + #expect(hex(theme.background) == "#1D1F21") + #expect(hex(theme.foreground) == "#C5C8C6") + #expect(theme.colorScheme == .dark) + } + + @Test("A light Ghostty theme gives a light dialog") + func lightThemeDrivesDialogColors() throws { + let background = try #require(NSColor(hex: "#FDF6E3")) + let foreground = try #require(NSColor(hex: "#657B83")) + let theme = GhosttyDialogTheme.resolved(background: background, foreground: foreground) + + #expect(hex(theme.background) == "#FDF6E3") + #expect(theme.colorScheme == .light) + } + + @Test("A translucent terminal background still gives an opaque dialog") + func translucentBackgroundIsOpaque() throws { + let background = try #require(NSColor(hex: "#1D1F21")).withAlphaComponent(0.4) + let theme = GhosttyDialogTheme.resolved(background: background, foreground: .white) + + #expect(theme.background.alphaComponent == 1) + #expect(hex(theme.background) == "#1D1F21") + } + + @Test("An unreadable foreground is replaced by a readable one") + func unreadableForegroundIsAdjusted() throws { + let background = try #require(NSColor(hex: "#101010")) + let foreground = try #require(NSColor(hex: "#151515")) + let theme = GhosttyDialogTheme.resolved(background: background, foreground: foreground) + + #expect(hex(theme.foreground) != "#151515") + #expect(cmuxContrastRatio(foreground: theme.foreground, background: theme.background) >= 4.5) + } +}