Files
claude-app-builder-4-mac/Sources/MiniTerm/Ghostty.swift
T
marcus.hinz 401ddd6f98 Ghostty-per-Projekt-Builder: Template + make/rebuild-Skripte
- ghostty-template/Ghostty.app: lokal gebautes Ghostty v1.3.1 mit DockTilePlugin-Patch
  (eigenes Dock-Icon ueber Contents/Resources/DockIcon.png)
- make-ghostty-app.sh: erzeugt pro Projekt eine App; Slots fuer
  icon/workdir/command/theme/bg/fg/font/font-size
- rebuild-template.sh: baut Template lokal neu (nur Xcode, kein Zig/VM),
  gepinnt auf vendor/libghostty v1.3.1
- ghostty-template/dock-icon.patch: der DockTilePlugin-Patch
- icons/robotunits.png
- enthaelt zudem die vorausgehende MiniTerm/libghostty-Integration
2026-06-24 18:50:53 +02:00

399 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: { _, _, 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<GhosttySurfaceView>.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 (0xF7000xF8FF). 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) }
}
}