401ddd6f98
- 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
399 lines
16 KiB
Swift
399 lines
16 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: { _, _, 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 (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) }
|
||
}
|
||
}
|