diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7e33252f..61f6e26f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -58,12 +58,14 @@ struct ContentView: View { } } .frame(minWidth: 800, minHeight: 600) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color.clear) .onAppear { focusedTabId = tabManager.selectedTabId + tabManager.applyWindowBackgroundForSelectedTab() } .onChange(of: tabManager.selectedTabId) { newValue in focusedTabId = newValue + tabManager.applyWindowBackgroundForSelectedTab() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in sidebarSelection = .tabs diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2965f005..ce45f109 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -66,6 +66,15 @@ class GhosttyApp { private(set) var app: ghostty_app_t? private(set) var config: ghostty_config_t? + private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor + private(set) var defaultBackgroundOpacity: Double = 1.0 + let backgroundLogEnabled = { + if ProcessInfo.processInfo.environment["GHOSTTYTABS_DEBUG_BG"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") + }() + private let backgroundLogURL = URL(fileURLWithPath: "/tmp/ghosttytabs-bg.log") private var appObservers: [NSObjectProtocol] = [] private init() { @@ -90,6 +99,7 @@ class GhosttyApp { // Load default config ghostty_config_load_default_files(config) ghostty_config_finalize(config) + updateDefaultBackground(from: config) // Create runtime config with callbacks var runtimeConfig = ghostty_runtime_config_s() @@ -203,6 +213,29 @@ class GhosttyApp { ghostty_app_tick(app) } + private func updateDefaultBackground(from config: ghostty_config_t?) { + guard let config else { return } + + var color = ghostty_config_color_s() + let bgKey = "background" + if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) { + defaultBackgroundColor = NSColor( + red: CGFloat(color.r) / 255, + green: CGFloat(color.g) / 255, + blue: CGFloat(color.b) / 255, + alpha: 1.0 + ) + } + + var opacity: Double = 1.0 + let opacityKey = "background-opacity" + _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) + defaultBackgroundOpacity = opacity + if backgroundLogEnabled { + logBackground("default background updated color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") + } + } + private func performOnMain(_ work: () -> T) -> T { if Thread.isMainThread { return work() @@ -265,6 +298,32 @@ class GhosttyApp { return true } + if action.tag == GHOSTTY_ACTION_COLOR_CHANGE, + action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { + let change = action.action.color_change + defaultBackgroundColor = NSColor( + red: CGFloat(change.r) / 255, + green: CGFloat(change.g) / 255, + blue: CGFloat(change.b) / 255, + alpha: 1.0 + ) + if backgroundLogEnabled { + logBackground("OSC background change (app target) color=\(defaultBackgroundColor)") + } + DispatchQueue.main.async { + GhosttyApp.shared.applyBackgroundToKeyWindow() + } + return true + } + + if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE { + updateDefaultBackground(from: action.action.config_change.config) + DispatchQueue.main.async { + GhosttyApp.shared.applyBackgroundToKeyWindow() + } + return true + } + return false } guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } @@ -379,10 +438,57 @@ class GhosttyApp { ) } return true + case GHOSTTY_ACTION_COLOR_CHANGE: + if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { + let change = action.action.color_change + surfaceView.backgroundColor = NSColor( + red: CGFloat(change.r) / 255, + green: CGFloat(change.g) / 255, + blue: CGFloat(change.b) / 255, + alpha: 1.0 + ) + if backgroundLogEnabled { + logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")") + } + DispatchQueue.main.async { + surfaceView.applyWindowBackgroundIfActive() + } + } + return true + case GHOSTTY_ACTION_CONFIG_CHANGE: + updateDefaultBackground(from: action.action.config_change.config) + DispatchQueue.main.async { + surfaceView.applyWindowBackgroundIfActive() + } + return true default: return false } } + + private func applyBackgroundToKeyWindow() { + guard let window = NSApp.keyWindow ?? NSApp.windows.first else { return } + let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) + window.backgroundColor = color + window.isOpaque = color.alphaComponent >= 1.0 + if backgroundLogEnabled { + logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + } + } + + func logBackground(_ message: String) { + let line = "GhosttyTabs bg: \(message)\n" + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { + FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) + } + if let handle = try? FileHandle(forWritingTo: backgroundLogURL) { + defer { try? handle.close() } + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } + } + } } // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) @@ -505,6 +611,10 @@ class TerminalSurface: Identifiable { } } + func applyWindowBackgroundIfActive() { + surfaceView.applyWindowBackgroundIfActive() + } + func setFocus(_ focused: Bool) { guard let surface = surface else { return } ghostty_surface_set_focus(surface, focused) @@ -530,6 +640,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var desiredFocus: Bool = false var tabId: UUID? var onFocus: (() -> Void)? + var backgroundColor: NSColor? private var eventMonitor: Any? private var trackingArea: NSTrackingArea? @@ -538,7 +649,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { metalLayer.device = MTLCreateSystemDefaultDevice() metalLayer.pixelFormat = .bgra8Unorm metalLayer.framebufferOnly = true - metalLayer.isOpaque = true + metalLayer.isOpaque = false + metalLayer.backgroundColor = NSColor.clear.cgColor metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 return metalLayer } @@ -560,6 +672,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { updateTrackingAreas() } + private func effectiveBackgroundColor() -> NSColor { + let base = backgroundColor ?? GhosttyApp.shared.defaultBackgroundColor + let opacity = GhosttyApp.shared.defaultBackgroundOpacity + return base.withAlphaComponent(opacity) + } + + func applyWindowBackgroundIfActive() { + guard let window else { return } + if let tabId, let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, tabId != selectedId { + return + } + let color = effectiveBackgroundColor() + window.backgroundColor = color + window.isOpaque = color.alphaComponent >= 1.0 + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + } + } + private func installEventMonitor() { guard eventMonitor == nil else { return } eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in @@ -614,6 +745,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if window != nil { attachSurfaceIfNeeded() updateSurfaceSize() + applyWindowBackgroundIfActive() } } @@ -628,6 +760,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { attachSurfaceIfNeeded() } + override var isOpaque: Bool { false } + private func updateSurfaceSize() { guard let terminalSurface = terminalSurface else { return } let scale = window?.screen?.backingScaleFactor ?? 2.0 @@ -1078,10 +1212,15 @@ final class GhosttySurfaceScrollView: NSView { scrollView.usesPredominantAxisScrolling = true scrollView.scrollerStyle = .overlay scrollView.drawsBackground = false + scrollView.backgroundColor = .clear scrollView.contentView.clipsToBounds = false + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear scrollView.surfaceView = surfaceView documentView = NSView(frame: .zero) + documentView.wantsLayer = true + documentView.layer?.backgroundColor = NSColor.clear.cgColor scrollView.documentView = documentView documentView.addSubview(surfaceView) @@ -1206,6 +1345,38 @@ final class GhosttySurfaceScrollView: NSView { } } + func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { + let maxDelay: TimeInterval = 0.5 + guard (delay ?? 0) < maxDelay else { return } + + let nextDelay: TimeInterval = if let delay { + delay * 2 + } else { + 0.05 + } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + guard let window = self.window else { + self.moveFocus(from: previous, delay: nextDelay) + return + } + + if let previous, previous !== self { + _ = previous.surfaceView.resignFirstResponder() + } + + window.makeFirstResponder(self.surfaceView) + } + + let queue = DispatchQueue.main + if let delay { + queue.asyncAfter(deadline: .now() + delay, execute: work) + } else { + queue.async(execute: work) + } + } + private func updateFocusForWindow() { let shouldFocus = isActive && (window?.isKeyWindow ?? false) surfaceView.desiredFocus = shouldFocus diff --git a/Sources/Splits/TerminalSplitTreeView.swift b/Sources/Splits/TerminalSplitTreeView.swift index 44c31802..42925279 100644 --- a/Sources/Splits/TerminalSplitTreeView.swift +++ b/Sources/Splits/TerminalSplitTreeView.swift @@ -57,7 +57,7 @@ fileprivate struct TerminalSplitSubtreeView: View { isActive: isFocused, onFocus: { _ in onFocus(surface.id) } ) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color.clear) if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 { Rectangle() diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 9ea72082..cff25dbe 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -35,6 +35,9 @@ class Tab: Identifiable, ObservableObject { func focusSurface(_ id: UUID) { guard focusedSurfaceId != id else { return } focusedSurfaceId = id + if let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, selectedId == self.id { + focusedSurface?.applyWindowBackgroundIfActive() + } } func updateSplitViewSize(_ size: CGSize) { @@ -125,22 +128,33 @@ class Tab: Identifiable, ObservableObject { return true } + private func findNextFocusTargetAfterClosing( + node: SplitTree.Node + ) -> TerminalSurface? { + guard let root = splitTree.root else { return nil } + + if root.leftmostLeaf() === node.leftmostLeaf() { + return splitTree.focusTarget(for: .next, from: node) + } + + return splitTree.focusTarget(for: .previous, from: node) + } + func closeSurface(_ surfaceId: UUID) -> Bool { guard let root = splitTree.root, let targetNode = root.find(id: surfaceId) else { return false } - let shouldMoveFocus = focusedSurfaceId == surfaceId - let nextFocus: TerminalSurface? = if shouldMoveFocus { - if root.leftmostLeaf() === targetNode.leftmostLeaf() { - splitTree.focusTarget(for: .next, from: targetNode) - } else { - splitTree.focusTarget(for: .previous, from: targetNode) - } + let oldFocusedSurface = focusedSurface + let shouldMoveFocus = if let focusedSurfaceId { + targetNode.find(id: focusedSurfaceId) != nil } else { - nil + false } + let nextFocus: TerminalSurface? = shouldMoveFocus + ? findNextFocusTargetAfterClosing(node: targetNode) + : nil splitTree = splitTree.removing(targetNode) @@ -150,17 +164,23 @@ class Tab: Identifiable, ObservableObject { } if shouldMoveFocus { - if let nextFocus { - focusedSurfaceId = nextFocus.id - } else { - focusedSurfaceId = splitTree.root?.leftmostLeaf().id - } + focusedSurfaceId = nextFocus?.id + } + + if focusedSurfaceId == nil { + focusedSurfaceId = splitTree.root?.leftmostLeaf().id } if !splitTree.isSplit { splitTree = SplitTree(root: splitTree.root, zoomed: nil) } + if shouldMoveFocus, let newFocusedSurface = focusedSurface { + DispatchQueue.main.async { + newFocusedSurface.hostedView.moveFocus(from: oldFocusedSurface?.hostedView) + } + } + return true } } @@ -229,6 +249,13 @@ class TabManager: ObservableObject { tabs.first(where: { $0.id == tabId })?.focusedSurfaceId } + func applyWindowBackgroundForSelectedTab() { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }), + let surface = tab.focusedSurface else { return } + surface.applyWindowBackgroundIfActive() + } + private func updateTabTitle(tabId: UUID, title: String) { guard !title.isEmpty else { return } guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }