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
This commit is contained in:
marcus.hinz
2026-06-24 18:50:53 +02:00
parent 7b361d3bbf
commit 401ddd6f98
8 changed files with 353 additions and 14 deletions
+115 -12
View File
@@ -41,15 +41,46 @@ final class GhosttyApp {
// 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
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: { _, _, _ in
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: { _, _, _, _, _ 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) }
}
@@ -87,7 +118,9 @@ final class GhosttyApp {
final class GhosttySurfaceView: NSView {
private let ghostty: GhosttyApp
private let appConfig: AppConfig
private var surface: ghostty_surface_t!
// 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
@@ -129,12 +162,16 @@ final class GhosttySurfaceView: NSView {
/// 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.
/// 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'"
}
@@ -218,9 +255,22 @@ final class GhosttySurfaceView: NSView {
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 ?? ""
// 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)
@@ -246,11 +296,24 @@ final class GhosttySurfaceView: NSView {
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) }
// 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 }
@@ -289,6 +352,46 @@ final class GhosttySurfaceView: NSView {
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) }
}