ghostty/macos/Sources/AppDelegate.swift
2023-10-04 12:11:23 -07:00

371 lines
14 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AppKit
import OSLog
import GhosttyKit
class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyAppStateDelegate {
// The application logger. We should probably move this at some point to a dedicated
// class/struct but for now it lives here! 🤷
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: AppDelegate.self)
)
/// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config.
@IBOutlet private var menuReloadConfig: NSMenuItem?
@IBOutlet private var menuQuit: NSMenuItem?
@IBOutlet private var menuNewWindow: NSMenuItem?
@IBOutlet private var menuNewTab: NSMenuItem?
@IBOutlet private var menuSplitHorizontal: NSMenuItem?
@IBOutlet private var menuSplitVertical: NSMenuItem?
@IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuToggleFullScreen: NSMenuItem?
@IBOutlet private var menuZoomSplit: NSMenuItem?
@IBOutlet private var menuPreviousSplit: NSMenuItem?
@IBOutlet private var menuNextSplit: NSMenuItem?
@IBOutlet private var menuSelectSplitAbove: NSMenuItem?
@IBOutlet private var menuSelectSplitBelow: NSMenuItem?
@IBOutlet private var menuSelectSplitLeft: NSMenuItem?
@IBOutlet private var menuSelectSplitRight: NSMenuItem?
/// The dock menu
private var dockMenu: NSMenu = NSMenu()
/// The ghostty global state. Only one per process.
private var ghostty: Ghostty.AppState = Ghostty.AppState()
/// Manages windows and tabs, ensuring they're allocated/deallocated correctly
var windowManager: PrimaryWindowManager!
override init() {
super.init()
ghostty.delegate = self
windowManager = PrimaryWindowManager(ghostty: self.ghostty)
}
//MARK: - NSApplicationDelegate
func applicationWillFinishLaunching(_ notification: Notification) {
UserDefaults.standard.register(defaults: [
// Disable the automatic full screen menu item because we handle
// it manually.
"NSFullScreenMenuItemEverywhere": false,
])
}
func applicationDidFinishLaunching(_ notification: Notification) {
// System settings overrides
UserDefaults.standard.register(defaults: [
// Disable this so that repeated key events make it through to our terminal views.
"ApplePressAndHoldEnabled": false,
])
// Let's launch our first window.
// TODO: we should detect if we restored windows and if so not launch a new window.
windowManager.addInitialWindow()
// Initial config loading
configDidReload(ghostty)
// Register our service provider. This must happen after everything
// else is initialized.
NSApp.servicesProvider = ServiceProvider()
}
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.
why: if let event = NSAppleEventManager.shared().currentAppleEvent {
// If all Ghostty windows are in the background (i.e. you Cmd-Q from the Cmd-Tab
// view), then this is null. I don't know why (pun intended) but we have to
// guard against it.
guard let keyword = AEKeyword("why?") else { break why }
if let why = event.attributeDescriptor(forKeyword: keyword) {
switch (why.typeCodeValue) {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// If our app says we don't need to confirm, we can exit now.
if (!ghostty.needsConfirmQuit) { return .terminateNow }
// We have some visible window. Show an app-wide modal to confirm quitting.
let alert = NSAlert()
alert.messageText = "Quit Ghostty?"
alert.informativeText = "All terminal sessions will be terminated."
alert.addButton(withTitle: "Close Ghostty")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
switch (alert.runModal()) {
case .alertFirstButtonReturn:
return .terminateNow
default:
return .terminateCancel
}
}
/// This is called when the application is already open and someone double-clicks the icon
/// or clicks the dock icon.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
// If we have visible windows then we allow macOS to do its default behavior
// of focusing one of them.
guard !flag else { return true }
// No visible windows, open a new one.
windowManager.newWindow()
return false
}
func application(_ sender: NSApplication, openFile filename: String) -> Bool {
// Ghostty will validate as well but we can avoid creating an entirely new
// surface by doing our own validation here. We can also show a useful error
// this way.
var isDirectory = ObjCBool(true)
guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false }
guard isDirectory.boolValue else {
let alert = NSAlert()
alert.messageText = "Dropped File is Not a Directory"
alert.informativeText = "Ghostty can currently only open directory paths."
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
_ = alert.runModal()
return false
}
// Build our config
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = filename
// If we don't have a window open through the window manager, we launch
// a new window.
guard let mainWindow = windowManager.mainWindow else {
windowManager.addNewWindow(withBaseConfig: config)
return true
}
// Add a new tab
windowManager.addNewTab(to: mainWindow, withBaseConfig: config)
return true
}
/// This is called for the dock right-click menu.
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
return dockMenu
}
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
private func syncMenuShortcuts() {
guard ghostty.config != nil else { return }
syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitHorizontal)
syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitVertical)
syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight)
// Dock menu
reloadDockMenu()
}
/// Syncs a single menu shortcut for the given action. The action string is the same
/// action string used for the Ghostty configuration.
private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) {
guard let cfg = ghostty.config else { return }
guard let menu = menuItem else { return }
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
guard let equiv = Ghostty.keyEquivalent(key: trigger.key) else {
Self.logger.debug("no keyboard shorcut set for action=\(action)")
return
}
menu.keyEquivalent = equiv
menu.keyEquivalentModifierMask = Ghostty.eventModifierFlags(mods: trigger.mods)
}
private func focusedSurface() -> ghostty_surface_t? {
guard let window = NSApp.keyWindow as? PrimaryWindow else { return nil }
return window.focusedSurfaceWrapper.surface
}
private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surface = focusedSurface() else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
//MARK: - GhosttyAppStateDelegate
func configDidReload(_ state: Ghostty.AppState) {
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts()
windowManager.relabelTabs()
// Config could change window appearance
syncAppearance()
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.model.errors = state.configErrors()
if (c.model.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance() {
guard let theme = ghostty.windowTheme else { return }
switch (theme) {
case "dark":
let appearance = NSAppearance(named: .darkAqua)
NSApplication.shared.appearance = appearance
case "light":
let appearance = NSAppearance(named: .aqua)
NSApplication.shared.appearance = appearance
default:
NSApplication.shared.appearance = nil
}
}
//MARK: - Dock Menu
private func reloadDockMenu() {
let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "")
let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "")
dockMenu.removeAllItems()
dockMenu.addItem(newWindow)
dockMenu.addItem(newTab)
}
//MARK: - IB Actions
@IBAction func reloadConfig(_ sender: Any?) {
ghostty.reloadConfig()
}
@IBAction func newWindow(_ sender: Any?) {
windowManager.newWindow()
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
}
@IBAction func newTab(_ sender: Any?) {
windowManager.newTab()
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
}
@IBAction func closeWindow(_ sender: Any) {
guard let currentWindow = NSApp.keyWindow else { return }
currentWindow.close()
}
@IBAction func close(_ sender: Any) {
guard let surface = focusedSurface() else {
self.closeWindow(self)
return
}
ghostty.requestClose(surface: surface)
}
@IBAction func splitHorizontally(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
@IBAction func splitVertically(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
}
@IBAction func splitZoom(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.splitToggleZoom(surface: surface)
}
@IBAction func splitMoveFocusPrevious(_ sender: Any) {
splitMoveFocus(direction: .previous)
}
@IBAction func splitMoveFocusNext(_ sender: Any) {
splitMoveFocus(direction: .next)
}
@IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top)
}
@IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom)
}
@IBAction func splitMoveFocusLeft(_ sender: Any) {
splitMoveFocus(direction: .left)
}
@IBAction func splitMoveFocusRight(_ sender: Any) {
splitMoveFocus(direction: .right)
}
@IBAction func showHelp(_ sender: Any) {
guard let url = URL(string: "https://github.com/mitchellh/ghostty") else { return }
NSWorkspace.shared.open(url)
}
@IBAction func toggleFullScreen(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.toggleFullscreen(surface: surface)
}
}