From 4440e0ae108c45a869a336f52671011ad20cc880 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:01:44 -0800 Subject: [PATCH] Match Ghostty split dimming and divider colors --- Sources/GhosttyConfig.swift | 62 +++++++++++ Sources/Splits/TerminalSplitTreeView.swift | 122 +++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 Sources/Splits/TerminalSplitTreeView.swift diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index b4dc27ef..6066077d 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -7,6 +7,9 @@ struct GhosttyConfig { var theme: String? var workingDirectory: String? var scrollbackLimit: Int = 10000 + var unfocusedSplitOpacity: Double = 0.7 + var unfocusedSplitFill: NSColor? + var splitDividerColor: NSColor? // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! @@ -19,6 +22,24 @@ struct GhosttyConfig { // Palette colors (0-15) var palette: [Int: NSColor] = [:] + var unfocusedSplitOverlayOpacity: Double { + let clamped = min(1.0, max(0.15, unfocusedSplitOpacity)) + return min(1.0, max(0.0, 1.0 - clamped)) + } + + var unfocusedSplitOverlayFill: NSColor { + unfocusedSplitFill ?? backgroundColor + } + + var resolvedSplitDividerColor: NSColor { + if let splitDividerColor { + return splitDividerColor + } + + let isLightBackground = backgroundColor.isLightColor + return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4) + } + static func load() -> GhosttyConfig { var config = GhosttyConfig() @@ -96,6 +117,18 @@ struct GhosttyConfig { let color = NSColor(hex: String(paletteParts[1])) { palette[index] = color } + case "unfocused-split-opacity": + if let opacity = Double(value) { + unfocusedSplitOpacity = opacity + } + case "unfocused-split-fill": + if let color = NSColor(hex: value) { + unfocusedSplitFill = color + } + case "split-divider-color": + if let color = NSColor(hex: value) { + splitDividerColor = color + } default: break } @@ -140,4 +173,33 @@ extension NSColor { self.init(red: r, green: g, blue: b, alpha: 1.0) } + + var isLightColor: Bool { + luminance > 0.5 + } + + var luminance: Double { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + guard let rgb = usingColorSpace(.sRGB) else { return 0 } + rgb.getRed(&r, green: &g, blue: &b, alpha: &a) + return (0.299 * r) + (0.587 * g) + (0.114 * b) + } + + func darken(by amount: CGFloat) -> NSColor { + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return NSColor( + hue: h, + saturation: s, + brightness: min(b * (1 - amount), 1), + alpha: a + ) + } } diff --git a/Sources/Splits/TerminalSplitTreeView.swift b/Sources/Splits/TerminalSplitTreeView.swift new file mode 100644 index 00000000..44c31802 --- /dev/null +++ b/Sources/Splits/TerminalSplitTreeView.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct TerminalSplitTreeView: View { + @ObservedObject var tab: Tab + let isTabActive: Bool + @State private var config = GhosttyConfig.load() + + var body: some View { + let appearance = SplitAppearance( + dividerColor: Color(nsColor: config.resolvedSplitDividerColor), + unfocusedOverlayColor: Color(nsColor: config.unfocusedSplitOverlayFill), + unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity + ) + Group { + if let node = tab.splitTree.zoomed ?? tab.splitTree.root { + TerminalSplitSubtreeView( + node: node, + isRoot: node == tab.splitTree.root, + isSplit: tab.splitTree.isSplit, + isTabActive: isTabActive, + focusedSurfaceId: tab.focusedSurfaceId, + appearance: appearance, + onFocus: { tab.focusSurface($0) }, + onResize: { tab.updateSplitRatio(node: $0, ratio: $1) }, + onEqualize: { tab.equalizeSplits() } + ) + .id(node.structuralIdentity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(GeometryReader { proxy in + Color.clear + .onAppear { tab.updateSplitViewSize(proxy.size) } + .onChange(of: proxy.size) { tab.updateSplitViewSize($0) } + }) + } +} + +fileprivate struct TerminalSplitSubtreeView: View { + let node: SplitTree.Node + let isRoot: Bool + let isSplit: Bool + let isTabActive: Bool + let focusedSurfaceId: UUID? + let appearance: SplitAppearance + let onFocus: (UUID) -> Void + let onResize: (SplitTree.Node, Double) -> Void + let onEqualize: () -> Void + + var body: some View { + switch node { + case .leaf(let surface): + let isFocused = isTabActive && focusedSurfaceId == surface.id + ZStack { + GhosttyTerminalView( + terminalSurface: surface, + isActive: isFocused, + onFocus: { _ in onFocus(surface.id) } + ) + .background(Color(nsColor: .windowBackgroundColor)) + + if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 { + Rectangle() + .fill(appearance.unfocusedOverlayColor) + .opacity(appearance.unfocusedOverlayOpacity) + .allowsHitTesting(false) + } + } + case .split(let split): + let splitViewDirection: SplitViewDirection = switch split.direction { + case .horizontal: .horizontal + case .vertical: .vertical + } + + SplitView( + splitViewDirection, + .init(get: { + CGFloat(split.ratio) + }, set: { + onResize(node, Double($0)) + }), + dividerColor: appearance.dividerColor, + resizeIncrements: .init(width: 1, height: 1), + left: { + TerminalSplitSubtreeView( + node: split.left, + isRoot: false, + isSplit: isSplit, + isTabActive: isTabActive, + focusedSurfaceId: focusedSurfaceId, + appearance: appearance, + onFocus: onFocus, + onResize: onResize, + onEqualize: onEqualize + ) + }, + right: { + TerminalSplitSubtreeView( + node: split.right, + isRoot: false, + isSplit: isSplit, + isTabActive: isTabActive, + focusedSurfaceId: focusedSurfaceId, + appearance: appearance, + onFocus: onFocus, + onResize: onResize, + onEqualize: onEqualize + ) + }, + onEqualize: { + onEqualize() + } + ) + } + } +} + +private struct SplitAppearance { + let dividerColor: Color + let unfocusedOverlayColor: Color + let unfocusedOverlayOpacity: Double +}