Add Match Terminal Background sidebar setting (#2293)

* Add "Match Terminal Background" sidebar setting

Adds a toggle in Settings > Sidebar Appearance that makes the sidebar
use the same background color and transparency as the terminal area.

Uses layer-level opacity on a fully opaque background color (the same
technique as TitlebarLayerBackground) with effective opacity formula
`1 - (1-alpha)^2` to account for the terminal's two stacked
semi-transparent layers (Bonsplit chrome + Ghostty Metal surface).

Also adds a 1px trailing border derived from the terminal chrome color,
matching the bonsplit tab bar separator logic.

* Fix sidebar border color not updating on theme change

Add @State + .onReceive(.ghosttyDefaultBackgroundDidChange) to
SidebarTrailingBorder so the separator color recomputes when the
Ghostty theme changes, matching the pattern used in SidebarBackdrop.

* Address review comments: localize debug toggle, fix separator refresh

- Localize the debug panel toggle label (Codex P1)
- Add .onAppear to SidebarTrailingBorder for initial color (Cubic P2)
- Fix stale doc comment on SidebarTerminalBackgroundView (Cubic P3)

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-28 04:36:20 -07:00 committed by GitHub
parent e9afc22353
commit 550d98ca4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 165 additions and 22 deletions

View file

@ -83198,6 +83198,40 @@
}
}
},
"settings.sidebarAppearance.matchTerminalBackground": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Match Terminal Background"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナルの背景に合わせる"
}
}
}
},
"settings.sidebarAppearance.matchTerminalBackground.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Use the same background color and transparency as the terminal."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナルと同じ背景色と透明度を使用します。"
}
}
}
},
"settings.sidebarAppearance.tintColorLight": {
"extractionState": "manual",
"localizations": {

View file

@ -2424,6 +2424,7 @@ struct ContentView: View {
}
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false
// Background glass settings
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@ -2601,7 +2602,11 @@ struct ContentView: View {
private var contentAndSidebarLayout: AnyView {
let layout: AnyView
if sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue {
// When matching terminal background, use HStack so both sidebar and terminal
// sit directly on the window background with no intermediate layers.
let useWithinWindow = sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue
&& !sidebarMatchTerminalBackground
if useWithinWindow {
// Overlay mode: terminal extends full width, sidebar on top
// This allows withinWindow blur to see the terminal content
layout = AnyView(
@ -8648,6 +8653,9 @@ struct VerticalTabsSidebar: View {
.accessibilityIdentifier("Sidebar")
.ignoresSafeArea()
.background(SidebarBackdrop().ignoresSafeArea())
.overlay(alignment: .trailing) {
SidebarTrailingBorder()
}
.background(
WindowAccessor { window in
modifierKeyMonitor.setHostWindow(window)
@ -13760,7 +13768,70 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable {
}
}
/// 1px trailing border on the sidebar, derived from the terminal chrome background
/// using the same logic as bonsplit's TabBarColors.nsColorSeparator:
/// dark bg lighten RGB by 0.16 at 0.36 alpha; light bg darken by 0.12 at 0.26 alpha.
private struct SidebarTrailingBorder: View {
@AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false
@State private var separatorColor: NSColor = chromeSeparatorColor()
var body: some View {
if matchTerminalBackground {
Rectangle()
.fill(Color(nsColor: separatorColor))
.frame(width: 1)
.ignoresSafeArea()
.onAppear {
separatorColor = Self.chromeSeparatorColor()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
separatorColor = Self.chromeSeparatorColor()
}
}
}
/// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background.
private static func chromeSeparatorColor() -> NSColor {
let chrome = GhosttyBackgroundTheme.currentColor()
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
)
}
}
/// Sidebar background that uses the same technique as TitlebarLayerBackground:
/// fully opaque layer color + layer-level opacity. This matches how the terminal's
/// Metal surface composites its background.
private struct SidebarTerminalBackgroundView: NSViewRepresentable {
let backgroundColor: NSColor
let opacity: CGFloat
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor
view.layer?.opacity = Float(opacity)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor
nsView.layer?.opacity = Float(opacity)
}
}
private struct SidebarBackdrop: View {
@AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@ -13771,8 +13842,28 @@ 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 terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor()
var body: some View {
let cornerRadius = CGFloat(max(0, sidebarCornerRadius))
if matchTerminalBackground {
// The terminal area has two stacked semi-transparent layers (Bonsplit chrome +
// Ghostty Metal background). Compute the effective composited opacity to match.
let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity)
let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2)
return AnyView(
SidebarTerminalBackgroundView(
backgroundColor: GhosttyApp.shared.defaultBackgroundColor,
opacity: effective
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
terminalBackgroundColor = GhosttyBackgroundTheme.currentColor()
}
)
}
let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial)
let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow
let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active
@ -13785,33 +13876,34 @@ private struct SidebarBackdrop: View {
return sidebarTintHex
}()
let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity)
let cornerRadius = CGFloat(max(0, sidebarCornerRadius))
let useLiquidGlass = materialOption?.usesLiquidGlass ?? false
let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow
return ZStack {
if let material = materialOption?.material {
// When using liquidGlass + behindWindow, window handles glass + tint
// Sidebar is fully transparent
if !useWindowLevelGlass {
SidebarVisualEffectBackground(
material: material,
blendingMode: blendingMode,
state: state,
opacity: sidebarBlurOpacity,
tintColor: tintColor,
cornerRadius: cornerRadius,
preferLiquidGlass: useLiquidGlass
)
// Tint overlay for NSVisualEffectView fallback
if !useLiquidGlass {
Color(nsColor: tintColor)
return AnyView(
ZStack {
if let material = materialOption?.material {
// When using liquidGlass + behindWindow, window handles glass + tint
// Sidebar is fully transparent
if !useWindowLevelGlass {
SidebarVisualEffectBackground(
material: material,
blendingMode: blendingMode,
state: state,
opacity: sidebarBlurOpacity,
tintColor: tintColor,
cornerRadius: cornerRadius,
preferLiquidGlass: useLiquidGlass
)
// Tint overlay for NSVisualEffectView fallback
if !useLiquidGlass {
Color(nsColor: tintColor)
}
}
}
// When material is none or useWindowLevelGlass, render nothing
}
// When material is none or useWindowLevelGlass, render nothing
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
)
}
}

View file

@ -2863,6 +2863,7 @@ 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
@ -2919,6 +2920,8 @@ private struct SidebarDebugView: View {
Text("Sidebar Appearance")
.font(.headline)
Toggle(String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), isOn: $matchTerminalBackground)
GroupBox("Presets") {
Picker("Preset", selection: $sidebarPreset) {
ForEach(SidebarPresetOption.allCases) { option in
@ -3902,6 +3905,7 @@ struct SettingsView: View {
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
@State private var shortcutResetToken = UUID()
@ -5030,6 +5034,18 @@ struct SettingsView: View {
SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"))
SettingsCard {
SettingsCardRow(
String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"),
subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.")
) {
Toggle("", isOn: $sidebarMatchTerminalBackground)
.labelsHidden()
.toggleStyle(.switch)
.controlSize(.small)
}
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.")
@ -5794,6 +5810,7 @@ struct SettingsView: View {
sidebarTintHexLight = nil
sidebarTintHexDark = nil
sidebarTintOpacity = SidebarTintDefaults.opacity
sidebarMatchTerminalBackground = false
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""