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
This commit is contained in:
parent
c5bd543fe0
commit
f969298c6e
3 changed files with 333 additions and 120 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue