From f969298c6e2f4c2dc6157fa035be60ca1bb4bfe1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 22 Jan 2026 01:47:16 -0800 Subject: [PATCH] Fix tab switching state persistence and keypress beeps - Add TerminalSurface class to own ghostty_surface_t lifecycle per tab - Use ZStack to keep all terminal views alive instead of recreating on switch - Add NSTextInputClient conformance to properly handle interpretKeyEvents - Add ghostty_surface_set_focus calls for proper focus management - Add doCommand(by:) override to silence unhandled key command beeps --- Sources/ContentView.swift | 15 +- Sources/GhosttyTerminalView.swift | 436 ++++++++++++++++++++++-------- Sources/TabManager.swift | 2 + 3 files changed, 333 insertions(+), 120 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 95c07504..b8b698ff 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15,13 +15,14 @@ struct ContentView: View { .fill(Color(nsColor: .separatorColor)) .frame(width: 1) - // Terminal Content - if let selectedId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { - GhosttyTerminalView() - .id(tab.id) - } else { - Color(nsColor: .windowBackgroundColor) + // Terminal Content - use ZStack to keep all surfaces alive + ZStack { + ForEach(tabManager.tabs) { tab in + let isActive = tabManager.selectedTabId == tab.id + GhosttyTerminalView(terminalSurface: tab.terminalSurface, isActive: isActive) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + } } } .frame(minWidth: 800, minHeight: 600) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 60408494..6acead8c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -82,13 +82,146 @@ class GhosttyApp { } } +// MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) + +class TerminalSurface { + private(set) var surface: ghostty_surface_t? + private var displayLink: CVDisplayLink? + private weak var attachedView: GhosttyNSView? + + init() { + // Surface is created when attached to a view + } + + func attachToView(_ view: GhosttyNSView) { + // If already attached to this view, nothing to do + if attachedView === view && surface != nil { + updateMetalLayer(for: view) + return + } + + attachedView = view + + // If surface doesn't exist yet, create it + if surface == nil { + createSurface(for: view) + } else { + // Re-attach existing surface to new view + reattachSurface(to: view) + } + } + + private func createSurface(for view: GhosttyNSView) { + guard let app = GhosttyApp.shared.app else { + print("Ghostty app not initialized") + return + } + + let scale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + + updateMetalLayer(for: view) + + var surfaceConfig = ghostty_surface_config_new() + surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS + surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(view).toOpaque() + surfaceConfig.scale_factor = scale + surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_TAB + + surface = ghostty_surface_new(app, &surfaceConfig) + + if surface == nil { + print("Failed to create ghostty surface") + return + } + + ghostty_surface_set_size( + surface, + UInt32(view.bounds.width * scale), + UInt32(view.bounds.height * scale) + ) + + setupDisplayLink() + } + + private func reattachSurface(to view: GhosttyNSView) { + guard let surface = surface else { return } + + let scale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + + updateMetalLayer(for: view) + + // Update the nsview pointer in the surface + ghostty_surface_set_content_scale(surface, scale, scale) + ghostty_surface_set_size( + surface, + UInt32(view.bounds.width * scale), + UInt32(view.bounds.height * scale) + ) + } + + private func updateMetalLayer(for view: GhosttyNSView) { + let scale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + if let metalLayer = view.layer as? CAMetalLayer { + metalLayer.contentsScale = scale + if view.bounds.width > 0 && view.bounds.height > 0 { + metalLayer.drawableSize = CGSize( + width: view.bounds.width * scale, + height: view.bounds.height * scale + ) + } + } + } + + private func setupDisplayLink() { + guard displayLink == nil else { return } + + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let newLink = link else { return } + + displayLink = newLink + + let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, _ -> CVReturn in + DispatchQueue.main.async { + GhosttyApp.shared.tick() + } + return kCVReturnSuccess + } + + CVDisplayLinkSetOutputCallback(newLink, callback, nil) + CVDisplayLinkStart(newLink) + } + + func updateSize(width: CGFloat, height: CGFloat, scale: CGFloat) { + guard let surface = surface else { return } + ghostty_surface_set_size(surface, UInt32(width * scale), UInt32(height * scale)) + + if let view = attachedView, let metalLayer = view.layer as? CAMetalLayer { + metalLayer.contentsScale = scale + metalLayer.drawableSize = CGSize(width: width * scale, height: height * scale) + } + } + + func setFocus(_ focused: Bool) { + guard let surface = surface else { return } + ghostty_surface_set_focus(surface, focused) + } + + deinit { + if let displayLink = displayLink { + CVDisplayLinkStop(displayLink) + } + if let surface = surface { + ghostty_surface_free(surface) + } + } +} + // MARK: - Ghostty Surface View class GhosttyNSView: NSView { - private var surface: ghostty_surface_t? - private var displayLink: CVDisplayLink? - private var metalDevice: MTLDevice? - private var surfaceCreated = false + var terminalSurface: TerminalSurface? + private var surfaceAttached = false override func makeBackingLayer() -> CALayer { let metalLayer = CAMetalLayer() @@ -97,7 +230,6 @@ class GhosttyNSView: NSView { metalLayer.framebufferOnly = true metalLayer.isOpaque = true metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 - self.metalDevice = metalLayer.device return metalLayer } @@ -116,130 +248,78 @@ class GhosttyNSView: NSView { layerContentsRedrawPolicy = .duringViewResize } - private func createSurfaceIfNeeded() { - // Only create once and when we have a valid size - guard !surfaceCreated else { return } + func attachSurface(_ surface: TerminalSurface) { + terminalSurface = surface + surfaceAttached = false + attachSurfaceIfNeeded() + } + + private func attachSurfaceIfNeeded() { + guard !surfaceAttached else { return } + guard let terminalSurface = terminalSurface else { return } guard bounds.width > 0 && bounds.height > 0 else { return } guard window != nil else { return } - surfaceCreated = true - createSurface() - } - - private func createSurface() { - guard let app = GhosttyApp.shared.app else { - print("Ghostty app not initialized") - return - } - - let scale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 - - // Update Metal layer with initial size - if let metalLayer = layer as? CAMetalLayer { - metalLayer.contentsScale = scale - metalLayer.drawableSize = CGSize( - width: bounds.width * scale, - height: bounds.height * scale - ) - } - - var surfaceConfig = ghostty_surface_config_new() - surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS - - // Pass this view to ghostty - surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(self).toOpaque() - - // Set scale factor - surfaceConfig.scale_factor = scale - - surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW - - // Create the surface - surface = ghostty_surface_new(app, &surfaceConfig) - - if surface == nil { - print("Failed to create ghostty surface") - return - } - - // Set initial size immediately after creation - ghostty_surface_set_size( - surface, - UInt32(bounds.width * scale), - UInt32(bounds.height * scale) - ) - - // Setup display link for rendering - setupDisplayLink() - } - - private func setupDisplayLink() { - var link: CVDisplayLink? - CVDisplayLinkCreateWithActiveCGDisplays(&link) - guard let displayLink = link else { return } - - self.displayLink = displayLink - - let callback: CVDisplayLinkOutputCallback = { displayLink, inNow, inOutputTime, flagsIn, flagsOut, displayLinkContext -> CVReturn in - DispatchQueue.main.async { - GhosttyApp.shared.tick() - } - return kCVReturnSuccess - } - - CVDisplayLinkSetOutputCallback(displayLink, callback, nil) - CVDisplayLinkStart(displayLink) + surfaceAttached = true + terminalSurface.attachToView(self) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window != nil { - createSurfaceIfNeeded() + attachSurfaceIfNeeded() updateSurfaceSize() } } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - createSurfaceIfNeeded() + attachSurfaceIfNeeded() updateSurfaceSize() } override func layout() { super.layout() - createSurfaceIfNeeded() + attachSurfaceIfNeeded() } private func updateSurfaceSize() { - guard let surface = surface else { return } + guard let terminalSurface = terminalSurface else { return } let scale = window?.screen?.backingScaleFactor ?? 2.0 + terminalSurface.updateSize(width: bounds.width, height: bounds.height, scale: scale) + } - // Update Metal layer - if let metalLayer = layer as? CAMetalLayer { - metalLayer.contentsScale = scale - metalLayer.drawableSize = CGSize( - width: bounds.width * scale, - height: bounds.height * scale - ) - } - - ghostty_surface_set_size( - surface, - UInt32(bounds.width * scale), - UInt32(bounds.height * scale) - ) + // Convenience accessor for the ghostty surface + private var surface: ghostty_surface_t? { + terminalSurface?.surface } // MARK: - Input Handling override var acceptsFirstResponder: Bool { true } - private func ghosttyCharacters(from event: NSEvent) -> String? { - guard let chars = event.characters, !chars.isEmpty else { return nil } - for scalar in chars.unicodeScalars where scalar.value < 0x20 { - return nil + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result, let surface = surface { + ghostty_surface_set_focus(surface, true) } - return chars + return result + } + + override func resignFirstResponder() -> Bool { + if let surface = surface { + ghostty_surface_set_focus(surface, false) + } + return super.resignFirstResponder() + } + + // For NSTextInputClient - accumulates text during key events + private var keyTextAccumulator: [String]? = nil + private var markedText = NSMutableAttributedString() + + // Prevents NSBeep for unimplemented actions from interpretKeyEvents + override func doCommand(by selector: Selector) { + // Intentionally empty - prevents system beep on unhandled key commands } override func keyDown(with event: NSEvent) { @@ -248,22 +328,53 @@ class GhosttyNSView: NSView { return } + let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + + // Set up text accumulator for interpretKeyEvents + keyTextAccumulator = [] + defer { keyTextAccumulator = nil } + + // Let the input system handle the event (for IME, dead keys, etc.) interpretKeyEvents([event]) + // Build the key event var keyEvent = ghostty_input_key_s() - keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.action = action keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) keyEvent.consumed_mods = GHOSTTY_MODS_NONE - keyEvent.composing = false + keyEvent.composing = markedText.length > 0 - if let text = ghosttyCharacters(from: event) { - text.withCString { ptr in - keyEvent.text = ptr - _ = ghostty_surface_key(surface, keyEvent) + // Use accumulated text from insertText, or fall back to event characters + if let accumulated = keyTextAccumulator, !accumulated.isEmpty { + for text in accumulated { + text.withCString { ptr in + keyEvent.text = ptr + _ = ghostty_surface_key(surface, keyEvent) + } } } else { - keyEvent.text = nil + // No accumulated text - send the key with event characters + if let chars = event.characters, !chars.isEmpty { + // Filter out control characters + var hasControlChars = false + for scalar in chars.unicodeScalars where scalar.value < 0x20 { + hasControlChars = true + break + } + if hasControlChars { + keyEvent.text = nil + } else { + chars.withCString { ptr in + keyEvent.text = ptr + _ = ghostty_surface_key(surface, keyEvent) + return + } + return + } + } else { + keyEvent.text = nil + } _ = ghostty_surface_key(surface, keyEvent) } } @@ -353,11 +464,96 @@ class GhosttyNSView: NSView { } deinit { - if let displayLink = displayLink { - CVDisplayLinkStop(displayLink) + // Surface lifecycle is managed by TerminalSurface, not the view + terminalSurface = nil + } +} + +// MARK: - NSTextInputClient + +extension GhosttyNSView: NSTextInputClient { + func hasMarkedText() -> Bool { + return markedText.length > 0 + } + + func markedRange() -> NSRange { + guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } + return NSRange(location: 0, length: markedText.length) + } + + func selectedRange() -> NSRange { + return NSRange(location: NSNotFound, length: 0) + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + switch string { + case let v as NSAttributedString: + markedText = NSMutableAttributedString(attributedString: v) + case let v as String: + markedText = NSMutableAttributedString(string: v) + default: + break } + } + + func unmarkText() { + markedText.mutableString.setString("") + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + return [] + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + return nil + } + + func characterIndex(for point: NSPoint) -> Int { + return 0 + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let window = self.window else { + return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) + } + let viewRect = NSRect(x: 0, y: 0, width: 0, height: 0) + let winRect = convert(viewRect, to: nil) + return window.convertToScreen(winRect) + } + + func insertText(_ string: Any, replacementRange: NSRange) { + // Get the string value + var chars = "" + switch string { + case let v as NSAttributedString: + chars = v.string + case let v as String: + chars = v + default: + return + } + + // Clear marked text since we're inserting + unmarkText() + + // If we have an accumulator, we're in a keyDown event - accumulate the text + if keyTextAccumulator != nil { + keyTextAccumulator?.append(chars) + return + } + + // Otherwise send directly to the terminal if let surface = surface { - ghostty_surface_free(surface) + chars.withCString { ptr in + var keyEvent = ghostty_input_key_s() + keyEvent.action = GHOSTTY_ACTION_PRESS + keyEvent.keycode = 0 + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.text = ptr + keyEvent.composing = false + _ = ghostty_surface_key(surface, keyEvent) + } } } } @@ -365,19 +561,33 @@ class GhosttyNSView: NSView { // MARK: - SwiftUI Wrapper struct GhosttyTerminalView: NSViewRepresentable { + let terminalSurface: TerminalSurface + var isActive: Bool = true + func makeNSView(context: Context) -> GhosttyNSView { let view = GhosttyNSView(frame: .zero) + view.attachSurface(terminalSurface) // Focus after view is in window - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - view.window?.makeFirstResponder(view) + if isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + view.window?.makeFirstResponder(view) + } } return view } func updateNSView(_ nsView: GhosttyNSView, context: Context) { - // Focus on tab switch - DispatchQueue.main.async { - nsView.window?.makeFirstResponder(nsView) + // Ensure the surface is attached + nsView.attachSurface(terminalSurface) + + if isActive { + // Focus on tab switch and notify surface + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(nsView) + } + } else { + // Unfocus when tab becomes inactive + terminalSurface.setFocus(false) } } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ac0e431f..74ac20c1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5,10 +5,12 @@ class Tab: Identifiable, ObservableObject { let id = UUID() @Published var title: String @Published var currentDirectory: String + let terminalSurface: TerminalSurface init(title: String = "Terminal") { self.title = title self.currentDirectory = FileManager.default.homeDirectoryForCurrentUser.path + self.terminalSurface = TerminalSurface() } }