Skip to content
Closed
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
68 changes: 68 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -77798,6 +77798,74 @@
}
}
},
"settings.sidebarAppearance.theme": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Sidebar Theme"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サイドバーのテーマ"
}
}
}
},
"settings.sidebarAppearance.theme.custom": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Custom"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "カスタム"
}
}
}
},
"settings.sidebarAppearance.theme.matchGhostty": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Match Ghostty"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Ghostty に合わせる"
}
}
}
},
"settings.sidebarAppearance.theme.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose whether the sidebar follows Ghostty's current background or uses your custom tint settings."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サイドバーを Ghostty の現在の背景に合わせるか、カスタムのティント設定を使うかを選びます。"
}
}
}
},
"settings.sidebarAppearance.tintColorLight": {
"extractionState": "manual",
"localizations": {
Expand Down
186 changes: 181 additions & 5 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13730,6 +13730,7 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable {
}

private struct SidebarBackdrop: View {
@AppStorage(SidebarThemeSettings.modeKey) private var sidebarTheme = SidebarThemeSettings.defaultMode.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
Expand All @@ -13740,26 +13741,63 @@ private struct SidebarBackdrop: View {
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
@Environment(\.colorScheme) private var colorScheme
@State private var refreshGeneration = 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 refreshGeneration is never read in body

refreshGeneration is incremented in both .onReceive handlers to force a re-render when the Ghostty background changes, but it is never accessed inside the body computed property. Today this still triggers a re-render because any @State mutation invalidates a SwiftUI view regardless of body access, but the intent is entirely implicit.

Add let _ = refreshGeneration at the top of body to make the re-render dependency explicit and linter-clean.


var body: some View {
let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial)
let _ = refreshGeneration // explicit dependency for notification-driven re-renders
let preferredColorScheme: GhosttyConfig.ColorSchemePreference = colorScheme == .dark ? .dark : .light
let ghosttyConfig = GhosttyConfig.load(preferredColorScheme: preferredColorScheme)
let selectedSidebarTheme = SidebarThemeSettings.mode(for: sidebarTheme)
let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow
let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active
let resolvedHex: String = {
let resolvedCustomHex: String = {
if colorScheme == .dark, let dark = sidebarTintHexDark {
return dark
} else if colorScheme == .light, let light = sidebarTintHexLight {
return light
}
return sidebarTintHex
}()
let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity)
let customTintColor = (NSColor(hex: resolvedCustomHex) ?? NSColor(hex: sidebarTintHex) ?? .black)
.withAlphaComponent(sidebarTintOpacity)
let usesGhosttyTheme = selectedSidebarTheme == .matchGhostty
let explicitSidebarBackground: NSColor? = {
guard usesGhosttyTheme else { return nil }
if colorScheme == .dark, let dark = ghosttyConfig.sidebarBackgroundDark {
return dark
} else if colorScheme == .light, let light = ghosttyConfig.sidebarBackgroundLight {
return light
}
return ghosttyConfig.sidebarBackground
}()
let hasExplicitSidebarBackground = ghosttyConfig.rawSidebarBackground != nil && explicitSidebarBackground != nil
let tintColor: NSColor = {
guard usesGhosttyTheme else { return customTintColor }
guard let explicitSidebarBackground else { return .clear }
let resolvedOpacity = ghosttyConfig.sidebarTintOpacity ?? sidebarTintOpacity
return explicitSidebarBackground.withAlphaComponent(resolvedOpacity)
}()
let baseColor: NSColor? =
hasExplicitSidebarBackground || selectedSidebarTheme == .custom
? nil
: GhosttyBackgroundTheme.currentColor()
let materialOption: SidebarMaterialOption? = {
if hasExplicitSidebarBackground || selectedSidebarTheme == .custom {
return SidebarMaterialOption(rawValue: sidebarMaterial)
}
return SidebarMaterialOption.none
}()
let cornerRadius = CGFloat(max(0, sidebarCornerRadius))
let useLiquidGlass = materialOption?.usesLiquidGlass ?? false
let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow

return ZStack {
if let material = materialOption?.material {
if let baseColor {
SidebarSolidColorBackground(color: baseColor)
if tintColor.alphaComponent > 0.0001 {
SidebarSolidColorBackground(color: tintColor)
}
Comment on lines +13795 to +13799
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "Match Ghostty" mode still applies the user's tint on top of the background

In the if let baseColor branch (active when selectedSidebarTheme == .matchGhostty and there is no explicit sidebar-background key), tintColor falls through to customTintColor, which defaults to #000000 at 18% opacity (SidebarTintDefaults.opacity = 0.18). This overlay is unconditionally rendered whenever tintColor.alphaComponent > 0.0001 — which is always true for any fresh install.

The result is that the sidebar is always 18% darker than the actual Ghostty terminal background, so "Match Ghostty" does not produce a pixel-accurate match for new users.

The tint overlay should be gated on selectedSidebarTheme == .custom:

if let baseColor {
    SidebarSolidColorBackground(color: baseColor)
    if selectedSidebarTheme == .custom, tintColor.alphaComponent > 0.0001 {
        SidebarSolidColorBackground(color: tintColor)
    }
} else if let material = materialOption?.material {

} else if let material = materialOption?.material {
// When using liquidGlass + behindWindow, window handles glass + tint
// Sidebar is fully transparent
if !useWindowLevelGlass {
Expand All @@ -13777,10 +13815,73 @@ private struct SidebarBackdrop: View {
Color(nsColor: tintColor)
}
}
} else {
// No material — render solid tint color (used when sidebar
// matches the terminal theme background). Uses a custom
// NSView instead of SwiftUI Color because
// makeViewHierarchyTransparent clears layer.backgroundColor
// on all views when the terminal has background-opacity < 1.
SidebarSolidColorBackground(color: tintColor)
}
// When material is none or useWindowLevelGlass, render nothing
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
refreshGeneration &+= 1
}
.onReceive(
NotificationCenter.default
.publisher(for: .ghosttyConfigDidReload)
.receive(on: RunLoop.main)
) { _ in
GhosttyConfig.invalidateLoadCache()
refreshGeneration &+= 1
}
}
}

/// Renders a solid color using the same compositing path as the terminal's
/// CAMetalLayer (bgra8Unorm / sRGB blending). Uses `viewDidMoveToWindow` with a
/// deferred main-queue dispatch to re-apply `layer.backgroundColor` after
/// `makeViewHierarchyTransparent` clears it on transparent windows.
private struct SidebarSolidColorBackground: NSViewRepresentable {
let color: NSColor

final class SolidColorView: NSView {
var fillColor: NSColor = .clear {
didSet { applyColor() }
}

override func makeBackingLayer() -> CALayer {
let layer = CALayer()
layer.isOpaque = false
// Match the terminal's sRGB compositing by using the same
// contentsFormat as CAMetalLayer's bgra8Unorm pixel format.
layer.contentsFormat = .RGBA8Uint
return layer
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// Re-apply after makeViewHierarchyTransparent clears it.
DispatchQueue.main.async { [weak self] in
self?.applyColor()
}
}

func applyColor() {
wantsLayer = true
layer?.backgroundColor = fillColor.cgColor
}
}

func makeNSView(context: Context) -> SolidColorView {
let view = SolidColorView()
view.wantsLayer = true
return view
}

func updateNSView(_ nsView: SolidColorView, context: Context) {
nsView.fillColor = color
}
}

Expand Down Expand Up @@ -13893,6 +13994,81 @@ enum SidebarTintDefaults {
static let opacity = 0.18
}

enum SidebarThemeOption: String, CaseIterable, Identifiable {
case matchGhostty
case custom

var id: String { rawValue }

var title: String {
switch self {
case .matchGhostty:
return String(localized: "settings.sidebarAppearance.theme.matchGhostty", defaultValue: "Match Ghostty")
case .custom:
return String(localized: "settings.sidebarAppearance.theme.custom", defaultValue: "Custom")
}
}
}

enum SidebarThemeSettings {
static let modeKey = "sidebarTheme"
static let defaultMode: SidebarThemeOption = .matchGhostty

static func mode(for rawValue: String?) -> SidebarThemeOption {
guard let rawValue, let mode = SidebarThemeOption(rawValue: rawValue) else {
return defaultMode
}
return mode
}

static func mode(defaults: UserDefaults = .standard) -> SidebarThemeOption {
mode(for: defaults.string(forKey: modeKey))
}

static func ensureStoredMode(defaults: UserDefaults = .standard) {
guard defaults.string(forKey: modeKey) == nil else { return }
defaults.set(inferredMode(defaults: defaults).rawValue, forKey: modeKey)
}

static func inferredMode(defaults: UserDefaults = .standard) -> SidebarThemeOption {
usesCustomAppearance(defaults: defaults) ? .custom : .matchGhostty
}

static func usesCustomAppearance(defaults: UserDefaults = .standard) -> Bool {
let preset = SidebarPresetOption.nativeSidebar
let material = defaults.string(forKey: "sidebarMaterial") ?? preset.material.rawValue
let blendMode = defaults.string(forKey: "sidebarBlendMode") ?? preset.blendMode.rawValue
let state = defaults.string(forKey: "sidebarState") ?? preset.state.rawValue
let tintHex = defaults.string(forKey: "sidebarTintHex") ?? preset.tintHex
let tintOpacity = defaults.object(forKey: "sidebarTintOpacity") as? Double ?? preset.tintOpacity
let blurOpacity = defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? preset.blurOpacity
let cornerRadius = defaults.object(forKey: "sidebarCornerRadius") as? Double ?? preset.cornerRadius
let tintHexLight = defaults.string(forKey: "sidebarTintHexLight")
let tintHexDark = defaults.string(forKey: "sidebarTintHexDark")

return material != preset.material.rawValue ||
blendMode != preset.blendMode.rawValue ||
state != preset.state.rawValue ||
normalizeHex(tintHex) != normalizeHex(preset.tintHex) ||
!approximatelyEqual(tintOpacity, preset.tintOpacity) ||
!approximatelyEqual(blurOpacity, preset.blurOpacity) ||
!approximatelyEqual(cornerRadius, preset.cornerRadius) ||
tintHexLight != nil ||
tintHexDark != nil
}

private static func normalizeHex(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
.uppercased()
}

private static func approximatelyEqual(_ lhs: Double, _ rhs: Double, tolerance: Double = 0.0001) -> Bool {
abs(lhs - rhs) <= tolerance
}
}

enum SidebarPresetOption: String, CaseIterable, Identifiable {
case nativeSidebar
case glassBehind
Expand Down
22 changes: 22 additions & 0 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ struct cmuxApp: App {
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults)
}
migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults)
SidebarThemeSettings.ensureStoredMode(defaults: defaults)

// UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance
// callbacks (e.g. `.onAppear`) are delayed or skipped.
Expand Down Expand Up @@ -1721,6 +1722,7 @@ private enum DebugWindowConfigSnapshot {
static func combinedPayload(defaults: UserDefaults = .standard) -> String {
let sidebarPayload = """
sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue))
sidebarTheme=\(stringValue(defaults, key: SidebarThemeSettings.modeKey, fallback: SidebarThemeSettings.defaultMode.rawValue))
sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue))
sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue))
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
Expand Down Expand Up @@ -2864,6 +2866,7 @@ private struct AboutPanelView: View {

private struct SidebarDebugView: View {
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
@AppStorage(SidebarThemeSettings.modeKey) private var sidebarTheme = SidebarThemeSettings.defaultMode.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
Expand Down Expand Up @@ -3099,6 +3102,7 @@ private struct SidebarDebugView: View {
private func copySidebarConfig() {
let payload = """
sidebarPreset=\(sidebarPreset)
sidebarTheme=\(sidebarTheme)
sidebarMaterial=\(sidebarMaterial)
sidebarBlendMode=\(sidebarBlendMode)
sidebarState=\(sidebarState)
Expand Down Expand Up @@ -3870,6 +3874,7 @@ struct SettingsView: View {
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@AppStorage(SidebarThemeSettings.modeKey) private var sidebarTheme = SidebarThemeSettings.defaultMode.rawValue
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
Expand Down Expand Up @@ -4912,6 +4917,22 @@ struct SettingsView: View {

SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"))
SettingsCard {
SettingsPickerRow(
String(localized: "settings.sidebarAppearance.theme", defaultValue: "Sidebar Theme"),
subtitle: String(
localized: "settings.sidebarAppearance.theme.subtitle",
defaultValue: "Choose whether the sidebar follows Ghostty's current background or uses your custom tint settings."
),
controlWidth: pickerColumnWidth,
selection: $sidebarTheme
) {
ForEach(SidebarThemeOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}

SettingsCardDivider()

SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"),
subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.")
Expand Down Expand Up @@ -5670,6 +5691,7 @@ struct SettingsView: View {
sidebarShowLog = true
sidebarShowProgress = true
sidebarShowMetadata = true
sidebarTheme = SidebarThemeSettings.defaultMode.rawValue
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintHexLight = nil
sidebarTintHexDark = nil
Expand Down
Loading