cmux/Sources/GhosttyTerminalView.swift
Lawrence Chen c5bd543fe0 Initial commit: macOS terminal app with vertical tabs using libghostty
Features:
- Vertical tabs sidebar with SwiftUI
- Terminal emulation via GhosttyKit.xcframework (libghostty)
- Keyboard shortcuts: Cmd+T/N, Ctrl+Shift+` (new tab), Cmd+W (close),
  Cmd+Shift+[/], Ctrl+Tab (navigation), Cmd+1-9 (jump to tab)
- Reads Ghostty config from ~/Library/Application Support/com.mitchellh.ghostty/config
- Metal-based rendering
2026-01-22 01:16:24 -08:00

383 lines
12 KiB
Swift

import SwiftUI
import AppKit
import Metal
import QuartzCore
// Minimal Ghostty wrapper for terminal rendering
// This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation
// MARK: - Ghostty App Singleton
class GhosttyApp {
static let shared = GhosttyApp()
private(set) var app: ghostty_app_t?
private(set) var config: ghostty_config_t?
private init() {
initializeGhostty()
}
private func initializeGhostty() {
// Initialize Ghostty library first
let result = ghostty_init(0, nil)
if result != GHOSTTY_SUCCESS {
print("Failed to initialize ghostty: \(result)")
return
}
// Load config
config = ghostty_config_new()
guard let config = config else {
print("Failed to create ghostty config")
return
}
// Load default config
ghostty_config_load_default_files(config)
ghostty_config_finalize(config)
// Create runtime config with callbacks
var runtimeConfig = ghostty_runtime_config_s()
runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
runtimeConfig.supports_selection_clipboard = true
runtimeConfig.wakeup_cb = { userdata in
DispatchQueue.main.async {
// Wakeup - trigger redraw if needed
}
}
runtimeConfig.action_cb = { app, target, action in
// Handle actions
return false
}
runtimeConfig.read_clipboard_cb = { userdata, location, state in
// Read clipboard
}
runtimeConfig.write_clipboard_cb = { userdata, location, content, len, confirm in
// Write clipboard
if let content = content {
let data = Data(bytes: content, count: Int(len))
if let string = String(data: data, encoding: .utf8) {
DispatchQueue.main.async {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
}
}
}
}
runtimeConfig.close_surface_cb = { userdata, processAlive in
// Surface closed
}
// Create app
app = ghostty_app_new(&runtimeConfig, config)
if app == nil {
print("Failed to create ghostty app")
}
}
func tick() {
guard let app = app else { return }
ghostty_app_tick(app)
}
}
// 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
override func makeBackingLayer() -> CALayer {
let metalLayer = CAMetalLayer()
metalLayer.device = MTLCreateSystemDefaultDevice()
metalLayer.pixelFormat = .bgra8Unorm
metalLayer.framebufferOnly = true
metalLayer.isOpaque = true
metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
self.metalDevice = metalLayer.device
return metalLayer
}
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
wantsLayer = true
layerContentsRedrawPolicy = .duringViewResize
}
private func createSurfaceIfNeeded() {
// Only create once and when we have a valid size
guard !surfaceCreated 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)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window != nil {
createSurfaceIfNeeded()
updateSurfaceSize()
}
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
createSurfaceIfNeeded()
updateSurfaceSize()
}
override func layout() {
super.layout()
createSurfaceIfNeeded()
}
private func updateSurfaceSize() {
guard let surface = surface else { return }
let scale = window?.screen?.backingScaleFactor ?? 2.0
// 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)
)
}
// 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
}
return chars
}
override func keyDown(with event: NSEvent) {
guard let surface = surface else {
super.keyDown(with: event)
return
}
interpretKeyEvents([event])
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.composing = false
if let text = ghosttyCharacters(from: event) {
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
}
override func keyUp(with event: NSEvent) {
guard let surface = surface else {
super.keyUp(with: event)
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_RELEASE
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = nil
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
override func flagsChanged(with event: NSEvent) {
guard let surface = surface else {
super.flagsChanged(with: event)
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = nil
keyEvent.composing = false
_ = ghostty_surface_key(surface, keyEvent)
}
private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e {
var mods = GHOSTTY_MODS_NONE.rawValue
if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
return ghostty_input_mods_e(rawValue: mods)
}
// MARK: - Mouse Handling
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
}
override func mouseUp(with event: NSEvent) {
guard let surface = surface else { return }
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
}
override func mouseMoved(with event: NSEvent) {
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
}
override func mouseDragged(with event: NSEvent) {
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
}
override func scrollWheel(with event: NSEvent) {
guard let surface = surface else { return }
var mods: Int32 = 0
if event.modifierFlags.contains(.shift) { mods |= Int32(GHOSTTY_MODS_SHIFT.rawValue) }
if event.modifierFlags.contains(.control) { mods |= Int32(GHOSTTY_MODS_CTRL.rawValue) }
if event.modifierFlags.contains(.option) { mods |= Int32(GHOSTTY_MODS_ALT.rawValue) }
if event.modifierFlags.contains(.command) { mods |= Int32(GHOSTTY_MODS_SUPER.rawValue) }
ghostty_surface_mouse_scroll(
surface,
event.scrollingDeltaX,
event.scrollingDeltaY,
ghostty_input_scroll_mods_t(mods)
)
}
deinit {
if let displayLink = displayLink {
CVDisplayLinkStop(displayLink)
}
if let surface = surface {
ghostty_surface_free(surface)
}
}
}
// MARK: - SwiftUI Wrapper
struct GhosttyTerminalView: NSViewRepresentable {
func makeNSView(context: Context) -> GhosttyNSView {
let view = GhosttyNSView(frame: .zero)
// Focus after view is in window
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)
}
}
}