mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00

This is my attempt at fixing #63. It works! But: 1. The `NotificationCenter` subscription is triggered once for every open tab. That's obviously wrong. But I'm not sure and could use some pointers where else to put the subscription. That leads me to... 2. I'm _not_ knowledgable in Swift/AppKit/SwiftUI, so I might have put the wrong/right things in the wrong/right places. For example: wasn't sure what's to be handled in Swift and what's to be handled by the core in Zig. Would love some pointers :)
223 lines
9.1 KiB
Swift
223 lines
9.1 KiB
Swift
import OSLog
|
|
import SwiftUI
|
|
import GhosttyKit
|
|
|
|
@main
|
|
struct GhosttyApp: App {
|
|
static let logger = Logger(
|
|
subsystem: Bundle.main.bundleIdentifier!,
|
|
category: String(describing: GhosttyApp.self)
|
|
)
|
|
|
|
/// The ghostty global state. Only one per process.
|
|
@StateObject private var ghostty = Ghostty.AppState()
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
/// The current focused Ghostty surface in this app
|
|
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
|
|
|
|
var body: some Scene {
|
|
let center = NotificationCenter.default
|
|
let gotoTab = center.publisher(for: Ghostty.Notification.ghosttyGotoTab)
|
|
|
|
WindowGroup {
|
|
ContentView(ghostty: ghostty)
|
|
// TODO: This is wrong. This fires for every open tab.
|
|
.onReceive(gotoTab) { onGotoTab(notification: $0) }
|
|
}
|
|
|
|
.backport.defaultSize(width: 800, height: 600)
|
|
|
|
.commands {
|
|
CommandGroup(after: .newItem) {
|
|
Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command])
|
|
Divider()
|
|
Button("Split Horizontally", action: splitHorizontally).keyboardShortcut("d", modifiers: [.command])
|
|
Button("Split Vertically", action: splitVertically).keyboardShortcut("d", modifiers: [.command, .shift])
|
|
Divider()
|
|
Button("Close", action: close).keyboardShortcut("w", modifiers: [.command])
|
|
Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift])
|
|
}
|
|
|
|
CommandGroup(before: .windowArrangement) {
|
|
Divider()
|
|
Button("Select Previous Split") { splitMoveFocus(direction: .previous) }
|
|
.keyboardShortcut("[", modifiers: .command)
|
|
Button("Select Next Split") { splitMoveFocus(direction: .next) }
|
|
.keyboardShortcut("]", modifiers: .command)
|
|
Menu("Select Split") {
|
|
Button("Select Split Above") { splitMoveFocus(direction: .top) }
|
|
.keyboardShortcut(.upArrow, modifiers: [.command, .option])
|
|
Button("Select Split Below") { splitMoveFocus(direction: .bottom) }
|
|
.keyboardShortcut(.downArrow, modifiers: [.command, .option])
|
|
Button("Select Split Left") { splitMoveFocus(direction: .left) }
|
|
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
|
|
Button("Select Split Right") { splitMoveFocus(direction: .right)}
|
|
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
|
|
}
|
|
|
|
Divider()
|
|
}
|
|
}
|
|
|
|
Settings {
|
|
SettingsView()
|
|
}
|
|
}
|
|
|
|
// Create a new tab in the currently active window
|
|
static func newTab() {
|
|
guard let currentWindow = NSApp.keyWindow else { return }
|
|
guard let windowController = currentWindow.windowController else { return }
|
|
windowController.newWindowForTab(nil)
|
|
if let newWindow = NSApp.keyWindow, currentWindow != newWindow {
|
|
currentWindow.addTabbedWindow(newWindow, ordered: .above)
|
|
}
|
|
}
|
|
|
|
static func closeWindow() {
|
|
guard let currentWindow = NSApp.keyWindow else { return }
|
|
currentWindow.close()
|
|
}
|
|
|
|
func close() {
|
|
guard let surfaceView = focusedSurface else {
|
|
Self.closeWindow()
|
|
return
|
|
}
|
|
|
|
guard let surface = surfaceView.surface else { return }
|
|
ghostty.requestClose(surface: surface)
|
|
}
|
|
|
|
func splitHorizontally() {
|
|
guard let surfaceView = focusedSurface else { return }
|
|
guard let surface = surfaceView.surface else { return }
|
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
|
|
}
|
|
|
|
func splitVertically() {
|
|
guard let surfaceView = focusedSurface else { return }
|
|
guard let surface = surfaceView.surface else { return }
|
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
|
|
}
|
|
|
|
func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
|
|
guard let surfaceView = focusedSurface else { return }
|
|
guard let surface = surfaceView.surface else { return }
|
|
ghostty.splitMoveFocus(surface: surface, direction: direction)
|
|
}
|
|
|
|
private func onGotoTab(notification: SwiftUI.Notification) {
|
|
// Get the tab index from the notification
|
|
guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
|
|
guard let tabIndex = tabIndexAny as? Int32 else { return }
|
|
|
|
guard let currentWindow = NSApp.keyWindow else { return }
|
|
guard let windowController = currentWindow.windowController else { return }
|
|
guard let tabGroup = windowController.window?.tabGroup else { return }
|
|
|
|
let tabbedWindows = tabGroup.windows
|
|
|
|
// Tabs are 0-indexed here, so we subtract one from the key the user hit.
|
|
let adjustedIndex = Int(tabIndex - 1);
|
|
guard adjustedIndex >= 0 && adjustedIndex < tabbedWindows.count else { return }
|
|
|
|
let targetWindow = tabbedWindows[adjustedIndex]
|
|
targetWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
}
|
|
|
|
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
|
@Published var confirmQuit: Bool = false
|
|
|
|
// See CursedMenuManager for more information.
|
|
private var menuManager: CursedMenuManager?
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
UserDefaults.standard.register(defaults: [
|
|
// Disable this so that repeated key events make it through to our terminal views.
|
|
"ApplePressAndHoldEnabled": false,
|
|
])
|
|
|
|
// Create our menu manager to create some custom menu items that
|
|
// we can't create from SwiftUI.
|
|
menuManager = CursedMenuManager()
|
|
}
|
|
|
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
|
let windows = NSApplication.shared.windows
|
|
if (windows.isEmpty) { return .terminateNow }
|
|
|
|
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
|
|
// quite work with SwiftUI because windows are retained on close. So instead we check
|
|
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
|
|
if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow }
|
|
|
|
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
|
|
if let event = NSAppleEventManager.shared().currentAppleEvent {
|
|
if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) {
|
|
switch (why.typeCodeValue) {
|
|
case kAEShutDown:
|
|
fallthrough
|
|
|
|
case kAERestart:
|
|
fallthrough
|
|
|
|
case kAEReallyLogOut:
|
|
return .terminateNow
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// We have some visible window, and all our windows will watch the confirmQuit.
|
|
confirmQuit = true
|
|
return .terminateLater
|
|
}
|
|
}
|
|
|
|
/// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created
|
|
/// as part of a WindowGroup. This class is prefixed with "Cursed" because this is a truly cursed
|
|
/// solution to the problem and I think its quite brittle. As soon as SwiftUI supports a better option
|
|
/// we should conditionally compile for that when supported.
|
|
///
|
|
/// The way this works is by setting up KVO on various menu objects and reacting to it. For example,
|
|
/// when SwiftUI tries to add a "Close" menu, we intercept it and delete it. Nice try!
|
|
private class CursedMenuManager {
|
|
var mainToken: NSKeyValueObservation?
|
|
var fileToken: NSKeyValueObservation?
|
|
|
|
init() {
|
|
// If the whole menu changed we want to setup our new KVO
|
|
self.mainToken = NSApp.observe(\.mainMenu, options: .new) { app, change in
|
|
self.onNewMenu()
|
|
}
|
|
|
|
// Initial setup
|
|
onNewMenu()
|
|
}
|
|
|
|
private func onNewMenu() {
|
|
guard let menu = NSApp.mainMenu else { return }
|
|
guard let file = menu.item(withTitle: "File") else { return }
|
|
guard let submenu = file.submenu else { return }
|
|
fileToken = submenu.observe(\.items) { (_, _) in
|
|
let remove = ["Close", "Close All"]
|
|
|
|
// We look for the items in reverse since we're removing only the
|
|
// ones SwiftUI inserts which are at the end. We make replacements
|
|
// which we DON'T want deleted.
|
|
let items = submenu.items.reversed()
|
|
remove.forEach { title in
|
|
if let item = items.first(where: { $0.title.caseInsensitiveCompare(title) == .orderedSame }) {
|
|
submenu.removeItem(item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|