c8f5714b1e
- 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
296 lines
11 KiB
Swift
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) }
|
|
}
|
|
}
|