import AppKit import CGhostty // MARK: - Hilfen /// Ruft `body` mit einem C-String auf (oder nil, wenn der String nil ist). /// Der Zeiger ist nur innerhalb von `body` gültig. private func withOptionalCString(_ s: String?, _ body: (UnsafePointer?) -> R) -> R { if let s = s { return s.withCString { body($0) } } return body(nil) } // MARK: - libghostty-App /// Kapselt die globale libghostty-App: einmalige Initialisierung, geladene /// Konfiguration, Runtime-Callbacks und der Event-Tick. Eine Instanz pro Prozess. final class GhosttyApp { private(set) var app: ghostty_app_t! private(set) var config: ghostty_config_t! private static var didInit = false init?() { if !GhosttyApp.didInit { _ = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) GhosttyApp.didInit = true } guard let cfg = ghostty_config_new() else { return nil } ghostty_config_load_default_files(cfg) ghostty_config_load_recursive_files(cfg) ghostty_config_finalize(cfg) self.config = cfg var runtime = ghostty_runtime_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), supports_selection_clipboard: false, wakeup_cb: { userdata in guard let userdata = userdata else { return } let me = Unmanaged.fromOpaque(userdata).takeUnretainedValue() // libghostty signalisiert Arbeit -> Tick auf dem Main-Thread. DispatchQueue.main.async { me.tick() } }, action_cb: { _, _, action in // Minimal: die meisten App-Aktionen (neue Fenster, Tabs, …) nicht // behandeln. Endet der Befehl (closeOnExit) bzw. die Shell, meldet // libghostty das über diese Aktionen – dann die App beenden, sonst // bliebe das Fenster mit toter Surface offen. switch action.tag { case GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_SHOW_CHILD_EXITED: DispatchQueue.main.async { NSApp.terminate(nil) } return true default: return false } }, read_clipboard_cb: { userdata, _, state in // libghostty will den Zwischenablage-Inhalt (Paste). Das userdata // ist die SURFACE-userdata (= GhosttySurfaceView, in createSurface // gesetzt). Aus dem System-Pasteboard lesen und die Anfrage // abschließen. confirmed: true = direkt einfügen (kein // Unsafe-Paste-Bestätigungsdialog). guard let userdata = userdata else { return false } let view = Unmanaged.fromOpaque(userdata).takeUnretainedValue() guard let surface = view.surface else { return false } let s = NSPasteboard.general.string(forType: .string) ?? "" s.withCString { ghostty_surface_complete_clipboard_request(surface, $0, state, true) } return true }, confirm_read_clipboard_cb: { _, _, _, _ in }, write_clipboard_cb: { _, _, content, len, _ in // libghostty schreibt in die Zwischenablage (⌘C-Keybind, OSC 52). // Ersten Eintrag mit Textinhalt ins System-Pasteboard übernehmen. guard let content = content, len > 0, let data = content.pointee.data else { return } let s = String(cString: data) NSPasteboard.general.clearContents() NSPasteboard.general.setString(s, forType: .string) }, close_surface_cb: { _, _ in DispatchQueue.main.async { NSApp.terminate(nil) } } ) guard let app = ghostty_app_new(&runtime, cfg) else { ghostty_config_free(cfg) return nil } self.app = app ghostty_app_set_focus(app, NSApp.isActive) } func tick() { guard let app = app else { return } ghostty_app_tick(app) } func setFocus(_ focused: Bool) { guard let app = app else { return } ghostty_app_set_focus(app, focused) } deinit { if let app = app { ghostty_app_free(app) } if let config = config { ghostty_config_free(config) } } } // MARK: - Surface-View /// NSView, in die libghostty per Metal rendert. libghostty erzeugt seinen /// CAMetalLayer selbst aus dem übergebenen NSView-Zeiger; wir leiten Größe, /// Skalierung, Fokus und Eingaben weiter. final class GhosttySurfaceView: NSView { private let ghostty: GhosttyApp private let appConfig: AppConfig // fileprivate, damit die Clipboard-Callbacks in GhosttyApp (gleiche Datei) // die Surface dieser View erreichen. fileprivate var surface: ghostty_surface_t! init(ghostty: GhosttyApp, appConfig: AppConfig, frame: NSRect) { self.ghostty = ghostty self.appConfig = appConfig super.init(frame: frame) wantsLayer = true layerContentsRedrawPolicy = .duringViewResize createSurface() } required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } override var acceptsFirstResponder: Bool { true } override var isFlipped: Bool { true } // MARK: Surface-Lifecycle private func createSurface() { var c = ghostty_surface_config_new() c.platform_tag = GHOSTTY_PLATFORM_MACOS c.platform = ghostty_platform_u( macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(self).toOpaque())) c.userdata = Unmanaged.passUnretained(self).toOpaque() c.scale_factor = Double(window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0) c.font_size = Float(appConfig.fontSize) c.context = GHOSTTY_SURFACE_CONTEXT_WINDOW withOptionalCString(surfaceCommand()) { cmd in withOptionalCString(appConfig.workingDirectory) { wd in c.command = cmd c.working_directory = wd self.surface = ghostty_surface_new(self.ghostty.app, &c) } } } /// Login-Shell-Verhalten wie in der SwiftTerm-Variante: leeres MTCommand => /// nil (libghostty startet selbst eine Login-Shell). Sonst Befehl ausführen /// und danach in eine interaktive Login-Shell übergehen, damit das Fenster /// offen bleibt. Bei closeOnExit endet der Prozess mit dem Befehl; libghostty /// schließt die Surface (close_surface_cb -> NSApp.terminate). private func surfaceCommand() -> String? { let cmd = appConfig.command.trimmingCharacters(in: .whitespacesAndNewlines) if cmd.isEmpty { return nil } let shell = ProcessInfo.processInfo.environment["SHELL"].flatMap { $0.isEmpty ? nil : $0 } ?? "/bin/zsh" if appConfig.closeOnExit { return "\(shell) -lc '\(cmd)'" } return "\(shell) -lc '\(cmd); exec \(shell) -l'" } // MARK: Größe / Skalierung override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) updateSurfaceSize() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() updateSurfaceSize() if let window = window { ghostty_surface_set_content_scale(surface, Double(window.backingScaleFactor), Double(window.backingScaleFactor)) } } override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() guard let surface = surface, let window = window else { return } ghostty_surface_set_content_scale(surface, Double(window.backingScaleFactor), Double(window.backingScaleFactor)) updateSurfaceSize() } private func updateSurfaceSize() { guard let surface = surface else { return } let backing = convertToBacking(bounds).size let w = UInt32(max(0, backing.width)) let h = UInt32(max(0, backing.height)) ghostty_surface_set_size(surface, w, h) } // MARK: Fokus override func becomeFirstResponder() -> Bool { let ok = super.becomeFirstResponder() if let surface = surface { ghostty_surface_set_focus(surface, true) } return ok } override func resignFirstResponder() -> Bool { let ok = super.resignFirstResponder() if let surface = surface { ghostty_surface_set_focus(surface, false) } return ok } // MARK: Tastatur override func keyDown(with event: NSEvent) { // Shift+Return / Shift+Enter (Numpad): Zeilenvorschub statt Submit, // damit CLIs wie Claude Code mehrzeilige Eingaben erlauben. let isReturn = (event.keyCode == 36 || event.keyCode == 76) let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if isReturn && mods == .shift { "\n".withCString { ghostty_surface_text(surface, $0, UInt(strlen($0))) } return } sendKey(event, action: event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS) } override func keyUp(with event: NSEvent) { sendKey(event, action: GHOSTTY_ACTION_RELEASE) } override func flagsChanged(with event: NSEvent) { var k = ghostty_input_key_s() k.action = GHOSTTY_ACTION_PRESS k.mods = Self.translateMods(event.modifierFlags) k.keycode = UInt32(event.keyCode) _ = ghostty_surface_key(surface, k) } private func sendKey(_ event: NSEvent, action: ghostty_input_action_e) { guard let surface = surface else { return } var k = ghostty_input_key_s() k.action = action k.mods = Self.translateMods(event.modifierFlags) k.keycode = UInt32(event.keyCode) // Funktionstasten (Pfeile, F-Tasten, Home/End/Page, …) liefern unter // AppKit Codepoints aus der Private Use Area (0xF700–0xF8FF). Diese NICHT // als Text/Codepoint übergeben – sonst sendet libghostty diese Zeichen // wörtlich statt der passenden Escape-Sequenz aus dem keycode. func isPUAFunctionKey(_ s: String?) -> Bool { guard let v = s?.unicodeScalars.first?.value else { return false } return (0xF700...0xF8FF).contains(v) } let unshifted = event.charactersIgnoringModifiers k.unshifted_codepoint = isPUAFunctionKey(unshifted) ? 0 : (unshifted?.unicodeScalars.first?.value ?? 0) let chars = event.characters ?? "" let text = isPUAFunctionKey(chars) ? "" : chars text.withCString { ptr in k.text = ptr _ = ghostty_surface_key(surface, k) } } private static func translateMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var m: UInt32 = GHOSTTY_MODS_NONE.rawValue if flags.contains(.shift) { m |= GHOSTTY_MODS_SHIFT.rawValue } if flags.contains(.control) { m |= GHOSTTY_MODS_CTRL.rawValue } if flags.contains(.option) { m |= GHOSTTY_MODS_ALT.rawValue } if flags.contains(.command) { m |= GHOSTTY_MODS_SUPER.rawValue } if flags.contains(.capsLock) { m |= GHOSTTY_MODS_CAPS.rawValue } return ghostty_input_mods_e(rawValue: m) } // MARK: Maus private func mousePos(_ event: NSEvent) -> (Double, Double) { let p = convert(event.locationInWindow, from: nil) return (Double(p.x), Double(p.y)) } override func mouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_LEFT) } override func mouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_LEFT) } override func otherMouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_MIDDLE) } override func otherMouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_MIDDLE) } // Rechte Maustaste: Shift -> Kontextmenü; Selektion vorhanden -> kopieren; // sonst -> einfügen (PuTTY/urxvt-Verhalten). Der Klick geht nicht an die // Surface, das Maus-Reporting per Rechtsklick entfällt damit bewusst. override func rightMouseDown(with event: NSEvent) { let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if mods.contains(.shift) { showContextMenu(event) } else if let surface = surface, ghostty_surface_has_selection(surface) { copy(nil) } else { paste(nil) } } override func rightMouseUp(with event: NSEvent) {} private enum MouseAction { case press, release } private func mouseButton(_ event: NSEvent, _ a: MouseAction, _ button: ghostty_input_mouse_button_e) { guard let surface = surface else { return } let state: ghostty_input_mouse_state_e = (a == .press) ? GHOSTTY_MOUSE_PRESS : GHOSTTY_MOUSE_RELEASE _ = ghostty_surface_mouse_button(surface, state, button, Self.translateMods(event.modifierFlags)) } override func mouseMoved(with event: NSEvent) { mouseMove(event) } override func mouseDragged(with event: NSEvent) { mouseMove(event) } override func rightMouseDragged(with event: NSEvent) { mouseMove(event) } override func otherMouseDragged(with event: NSEvent) { mouseMove(event) } private func mouseMove(_ event: NSEvent) { guard let surface = surface else { return } let (x, y) = mousePos(event) ghostty_surface_mouse_pos(surface, x, y, Self.translateMods(event.modifierFlags)) } override func scrollWheel(with event: NSEvent) { guard let surface = surface else { return } // scroll_mods: einfacher Modus, 0 = keine Präzision/Momentum-Flags. ghostty_surface_mouse_scroll(surface, Double(event.scrollingDeltaX), Double(event.scrollingDeltaY), 0) } override func updateTrackingAreas() { super.updateTrackingAreas() trackingAreas.forEach { removeTrackingArea($0) } let area = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], owner: self, userInfo: nil) addTrackingArea(area) } // MARK: Zwischenablage // Markierten Text der Surface in die System-Zwischenablage kopieren. Der // Selektor heißt `copy(_:)`, damit der Edit-Menü-Eintrag (⌘C) greift. @objc func copy(_ sender: Any?) { guard let surface = surface, ghostty_surface_has_selection(surface) else { return } var t = ghostty_text_s() guard ghostty_surface_read_selection(surface, &t), let cstr = t.text else { return } let s = String(cString: cstr) NSPasteboard.general.clearContents() NSPasteboard.general.setString(s, forType: .string) ghostty_surface_free_text(surface, &t) } // Zwischenablage einfügen. Über die Binding-Aktion, damit Bracketed-Paste // erhalten bleibt (mehrzeilige Eingaben). Selektor `paste(_:)` für ⌘V. @objc func paste(_ sender: Any?) { guard let surface = surface else { return } let action = "paste_from_clipboard" _ = action.withCString { ghostty_surface_binding_action(surface, $0, UInt(strlen($0))) } } private func showContextMenu(_ event: NSEvent) { let menu = NSMenu() let hasSelection = surface.map { ghostty_surface_has_selection($0) } ?? false let copyItem = NSMenuItem(title: "Kopieren", action: #selector(copy(_:)), keyEquivalent: "") copyItem.target = self copyItem.isEnabled = hasSelection menu.addItem(copyItem) let pasteItem = NSMenuItem(title: "Einfügen", action: #selector(paste(_:)), keyEquivalent: "") pasteItem.target = self menu.addItem(pasteItem) NSMenu.popUpContextMenu(menu, with: event, for: self) } deinit { if let surface = surface { ghostty_surface_free(surface) } } }