Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions Sources/Cloud/PortForward/CloudBrowserAccessView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct CloudBrowserAccessView<Content: View>: View {
message: nil,
onRetry: nil
)
.ghosttyDialogTheme()
} else if state.showsPage || state.failureMessage == nil {
VStack(spacing: 0) {
if state.isDesktop && !state.desktopConnected && state.failureMessage == nil {
Expand All @@ -37,32 +38,34 @@ struct CloudBrowserAccessView<Content: View>: View {
_ = panel.reload()
}
)
.ghosttyDialogTheme()
}
}
} else if let message = state.unavailable {
CloudBrowserConnectionCard(address: "", message: message, onRetry: state.unavailableRetryAction)
.ghosttyDialogTheme()
} else {
content()
}
}
.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") }
Expand Down
11 changes: 3 additions & 8 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 1 addition & 5 deletions Sources/GhosttyConfigDiagnosticsNoticeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
102 changes: 102 additions & 0 deletions Sources/GhosttyDialogTheme.swift
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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))
}
}
29 changes: 25 additions & 4 deletions Sources/Panels/CloudPaneCreationFailureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions Sources/Panels/TerminalPanelUnavailableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ struct TerminalPanelUnavailableView: View {
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: appearance.contentBackgroundColor))
.ghosttyDialogTheme()
}
}
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -6101,6 +6103,8 @@
468120000000000000000004 /* GhosttyDesktopNotificationIngress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDesktopNotificationIngress.swift; sourceTree = "<group>"; };
468130000000000000000002 /* GhosttyDesktopNotificationIngressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDesktopNotificationIngressTests.swift; sourceTree = "<group>"; };
468120000000000000000002 /* GhosttyDesktopNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDesktopNotificationRequest.swift; sourceTree = "<group>"; };
6D1A7C3E0000000000000002 /* GhosttyDialogTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDialogTheme.swift; sourceTree = "<group>"; };
08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyDialogThemeTests.swift"; sourceTree = "<group>"; };
604100010000000000000002 /* GhosttyDrawableSizeRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyDrawableSizeRetryTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
E89950010000000000000002 /* GhosttyFlashOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyFlashOverlayView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -12518,6 +12523,7 @@
31BFCF49D4B667B5C2A0BE3C /* TerminalSSHSessionDetectorProcessSnapshotTests.swift */,
C51DB22258B5393E2AB9104C /* ClaudeHookSessionLivenessTests.swift */,
A43345B5B3C60929B9F661CB /* ExitingProcessCensusReader.swift */,
08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
54 changes: 54 additions & 0 deletions cmuxTests/GhosttyDialogThemeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading