Files
claude-app-builder-4-mac/Sources/MiniTerm/Ghostty.swift
T
marcus.hinz c8f5714b1e libghostty als zweite Terminal-Engine integriert (Default ghostty, SwiftTerm Fallback)
- vendor/libghostty: vorgebaute macOS-libghostty.a (arm64) + Header + Build-Patches + README
- Sources/CGhostty: C-Modul der libghostty-C-API
- Sources/MiniTerm/Ghostty.swift: GhosttyApp + GhosttySurfaceView (Surface, Input, Größe, Fokus)
- main.swift: Engine-Wahl per Info.plist MTEngine
- Package.swift: CGhostty-Target + Link gegen libghostty + Frameworks
- STATUS.md: Stand, Build-Weg (macOS-15-VM), offene Punkte
2026-06-21 21:40:00 +02:00

296 lines
11 KiB
Swift

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<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> 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<GhosttyApp>.fromOpaque(userdata).takeUnretainedValue()
// libghostty signalisiert Arbeit -> Tick auf dem Main-Thread.
DispatchQueue.main.async { me.tick() }
},
action_cb: { _, _, _ in
// Minimal: keine App-Aktionen (neue Fenster, Tabs, ) behandeln.
return false
},
read_clipboard_cb: { _, _, _ in
return false
},
confirm_read_clipboard_cb: { _, _, _, _ in },
write_clipboard_cb: { _, _, _, _, _ in },
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
private 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.
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"
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)
k.unshifted_codepoint = (event.charactersIgnoringModifiers?.unicodeScalars.first?.value) ?? 0
let text = event.characters ?? ""
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 rightMouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_RIGHT) }
override func rightMouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_RIGHT) }
override func otherMouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_MIDDLE) }
override func otherMouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_MIDDLE) }
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)
}
deinit {
if let surface = surface { ghostty_surface_free(surface) }
}
}