ghostty/macos/Sources/App/macOS/AppDelegate.swift
Mitchell Hashimoto c6da845f33 macos: ensure previously key window regains key on toggle_visibility
Fixes #5690

When we hide the app and then show it again, the previously key window
is lost. This is because we are not using unhide and are manually
doing it (and we're not using unhide for good reasons commented in the
source already).

Modify our hidden state to include what the key window was (as a weak
ref) and restore it when we show the app again.
2025-02-11 11:13:19 -08:00

782 lines
31 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 UserNotifications
import OSLog
import Sparkle
import GhosttyKit
class AppDelegate: NSObject,
ObservableObject,
NSApplicationDelegate,
UNUserNotificationCenterDelegate,
GhosttyAppDelegate
{
// 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 menuServices: NSMenu?
@IBOutlet private var menuCheckForUpdates: NSMenuItem?
@IBOutlet private var menuOpenConfig: NSMenuItem?
@IBOutlet private var menuReloadConfig: NSMenuItem?
@IBOutlet private var menuSecureInput: NSMenuItem?
@IBOutlet private var menuQuit: NSMenuItem?
@IBOutlet private var menuNewWindow: NSMenuItem?
@IBOutlet private var menuNewTab: NSMenuItem?
@IBOutlet private var menuSplitRight: NSMenuItem?
@IBOutlet private var menuSplitDown: NSMenuItem?
@IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseTab: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuToggleVisibility: 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?
@IBOutlet private var menuIncreaseFontSize: NSMenuItem?
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@IBOutlet private var menuResetFontSize: NSMenuItem?
@IBOutlet private var menuQuickTerminal: NSMenuItem?
@IBOutlet private var menuTerminalInspector: NSMenuItem?
@IBOutlet private var menuEqualizeSplits: NSMenuItem?
@IBOutlet private var menuMoveSplitDividerUp: NSMenuItem?
@IBOutlet private var menuMoveSplitDividerDown: NSMenuItem?
@IBOutlet private var menuMoveSplitDividerLeft: NSMenuItem?
@IBOutlet private var menuMoveSplitDividerRight: NSMenuItem?
/// The dock menu
private var dockMenu: NSMenu = NSMenu()
/// This is only true before application has become active.
private var applicationHasBecomeActive: Bool = false
/// This is set in applicationDidFinishLaunching with the system uptime so we can determine the
/// seconds since the process was launched.
private var applicationLaunchTime: TimeInterval = 0
/// This is the current configuration from the Ghostty configuration that we need.
private var derivedConfig: DerivedConfig = DerivedConfig()
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App()
/// Manages our terminal windows.
let terminalManager: TerminalManager
/// Our quick terminal. This starts out uninitialized and only initializes if used.
private var quickController: QuickTerminalController? = nil
/// Manages updates
let updaterController: SPUStandardUpdaterController
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
}
/// Tracks the windows that we hid for toggleVisibility.
private var hiddenState: ToggleVisibilityState? = nil
/// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation? = nil
/// The custom app icon image that is currently in use.
@Published private(set) var appIcon: NSImage? = nil {
didSet {
NSApplication.shared.applicationIconImage = appIcon
}
}
override init() {
terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController(
// Important: we must not start the updater here because we need to read our configuration
// first to determine whether we're automatically checking, downloading, etc. The updater
// is started later in applicationDidFinishLaunching
startingUpdater: false,
updaterDelegate: updaterDelegate,
userDriverDelegate: nil
)
super.init()
ghostty.delegate = self
}
//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,
])
// Store our start time
applicationLaunchTime = ProcessInfo.processInfo.systemUptime
// Check if secure input was enabled when we last quit.
if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) {
toggleSecureInput(self)
}
// Hook up updater menu
menuCheckForUpdates?.target = updaterController
menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:))
// Initial config loading
ghosttyConfigDidChange(config: ghostty.config)
// Start our update checker.
updaterController.startUpdater()
// Register our service provider. This must happen after everything is initialized.
NSApp.servicesProvider = ServiceProvider()
// This registers the Ghostty => Services menu to exist.
NSApp.servicesMenu = menuServices
// Setup a local event monitor for app-level keyboard shortcuts. See
// localEventHandler for more info why.
_ = NSEvent.addLocalMonitorForEvents(
matching: [.keyDown],
handler: localEventHandler)
// Notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(quickTerminalDidChangeVisibility),
name: .quickTerminalDidChangeVisibility,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
// Configure user notifications
let actions = [
UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show")
]
let center = UNUserNotificationCenter.current()
center.setNotificationCategories([
UNNotificationCategory(
identifier: Ghostty.userNotificationCategory,
actions: actions,
intentIdentifiers: [],
options: [.customDismissAction]
)
])
center.delegate = self
// Observe our appearance so we can report the correct value to libghostty.
self.appearanceObserver = NSApplication.shared.observe(
\.effectiveAppearance,
options: [.new, .initial]
) { _, change in
guard let appearance = change.newValue else { return }
guard let app = self.ghostty.app else { return }
let scheme: ghostty_color_scheme_e
if (appearance.isDark) {
scheme = GHOSTTY_COLOR_SCHEME_DARK
} else {
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
}
ghostty_app_set_color_scheme(app, scheme)
}
}
func applicationDidBecomeActive(_ notification: Notification) {
// If we're back manually then clear the hidden state because macOS handles it.
self.hiddenState = nil
// First launch stuff
if (!applicationHasBecomeActive) {
applicationHasBecomeActive = true
// Let's launch our first window. We only do this if we have no other windows. It
// is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this.
// - if we're restoring from persisted state
if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
terminalManager.newWindow()
}
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return derivedConfig.shouldQuitAfterLastWindowClosed
}
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 }
// If we have any windows in our terminal manager we don't do anything.
// This is possible with flag set to false if there a race where the
// window is still initializing and is not visible but the user clicked
// the dock icon.
guard terminalManager.windows.count == 0 else { return true }
// No visible windows, open a new one.
terminalManager.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 }
// Initialize the surface config which will be used to create the tab or window for the opened file.
var config = Ghostty.SurfaceConfiguration()
if (isDirectory.boolValue) {
// When opening a directory, create a new tab in the main window with that as the working directory.
// If no windows exist, a new one will be created.
config.workingDirectory = filename
terminalManager.newTab(withBaseConfig: config)
} else {
// When opening a file, open a new window with that file as the command,
// and its parent directory as the working directory.
config.command = filename
config.workingDirectory = (filename as NSString).deletingLastPathComponent
terminalManager.newWindow(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(_ config: Ghostty.Config) {
guard ghostty.readiness == .ready else { return }
syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig)
syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits)
syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput)
// This menu item is NOT synced with the configuration because it disables macOS
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
// to work but it won't be reflected in the menu item.
//
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
// 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(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
guard let menu = menuItem else { return }
guard let equiv = config.keyEquivalent(for: action) else {
// No shortcut, clear the menu item
menu.keyEquivalent = ""
menu.keyEquivalentModifierMask = []
return
}
menu.keyEquivalent = equiv.key
menu.keyEquivalentModifierMask = equiv.modifiers
}
private func focusedSurface() -> ghostty_surface_t? {
return terminalManager.focusedSurface?.surface
}
// MARK: Notifications and Events
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get
/// events without any terminal windows open.
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
return switch event.type {
case .keyDown:
localEventKeyDown(event)
default:
event
}
}
private func localEventKeyDown(_ event: NSEvent) -> NSEvent? {
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event }
// If this event as-is would result in a key binding then we send it.
if let app = ghostty.app,
ghostty_app_key_is_binding(
app,
event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
// If the key was handled by Ghostty we stop the event chain. If
// the key wasn't handled then we let it fall through and continue
// processing. This is important because some bindings may have no
// affect at this scope.
if (ghostty_app_key(
app,
event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
return nil
}
}
// If this event would be handled by our menu then we do nothing.
if let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) {
return nil
}
// If we reach this point then we try to process the key event
// through the Ghostty key mechanism.
// Ghostty must be loaded
guard let ghostty = self.ghostty.app else { return event }
// Build our event input and call ghostty
if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) {
// The key was used so we want to stop it from going to our Mac app
Ghostty.logger.debug("local key event handled event=\(event)")
return nil
}
return event
}
@objc private func quickTerminalDidChangeVisibility(_ notification: Notification) {
guard let quickController = notification.object as? QuickTerminalController else { return }
self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off }
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a surface one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
ghosttyConfigDidChange(config: config)
}
private func ghosttyConfigDidChange(config: Ghostty.Config) {
// Update the config we need to store
self.derivedConfig = DerivedConfig(config)
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
// state restoration system.
switch (config.windowSaveState) {
case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows")
case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows")
case "default": fallthrough
default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
// user-based defaults.
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
updaterController.updater.automaticallyChecksForUpdates = false
updaterController.updater.automaticallyDownloadsUpdates = false
} else if let autoUpdate = config.autoUpdate {
updaterController.updater.automaticallyChecksForUpdates =
autoUpdate == .check || autoUpdate == .download
updaterController.updater.automaticallyDownloadsUpdates =
autoUpdate == .download
}
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts(config)
terminalManager.relabelAllTabs()
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
DispatchQueue.main.async { self.syncAppearance(config: config) }
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = config.errors
if (c.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
}
// We need to handle our global event tap depending on if there are global
// events that we care about in Ghostty.
if (ghostty_app_has_global_keybinds(ghostty.app!)) {
if (timeSinceLaunch > 5) {
// If the process has been running for awhile we enable right away
// because no windows are likely to pop up.
GlobalEventTap.shared.enable()
} else {
// If the process just started, we wait a couple seconds to allow
// the initial windows and so on to load so our permissions dialog
// doesn't get buried.
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
GlobalEventTap.shared.enable()
}
}
} else {
GlobalEventTap.shared.disable()
}
switch (config.macosIcon) {
case .official:
self.appIcon = nil
break
case .customStyle:
guard let ghostColor = config.macosIconGhostColor else { break }
guard let screenColors = config.macosIconScreenColor else { break }
guard let icon = ColorizedGhosttyIcon(
screenColors: screenColors,
ghostColor: ghostColor,
frame: config.macosIconFrame
).makeImage() else { break }
self.appIcon = icon
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance(config: Ghostty.Config) {
NSApplication.shared.appearance = .init(ghosttyConfig: config)
}
//MARK: - Restorable State
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
Self.logger.debug("application will save window state")
}
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
Self.logger.debug("application will restore window state")
}
//MARK: - UNUserNotificationCenterDelegate
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive: UNNotificationResponse,
withCompletionHandler: () -> Void
) {
ghostty.handleUserNotification(response: didReceive)
withCompletionHandler()
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent: UNNotification,
withCompletionHandler: (UNNotificationPresentationOptions) -> Void
) {
let shouldPresent = ghostty.shouldPresentNotification(notification: willPresent)
let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : []
withCompletionHandler(options)
}
//MARK: - GhosttyAppDelegate
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
for c in terminalManager.windows {
if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) {
return v
}
}
return 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: - Global State
func setSecureInput(_ mode: Ghostty.SetSecureInput) {
let input = SecureInput.shared
switch (mode) {
case .on:
input.global = true
case .off:
input.global = false
case .toggle:
input.global.toggle()
}
self.menuSecureInput?.state = if (input.global) { .on } else { .off }
UserDefaults.standard.set(input.global, forKey: "SecureInput")
}
//MARK: - IB Actions
@IBAction func openConfig(_ sender: Any?) {
ghostty.openConfig()
}
@IBAction func reloadConfig(_ sender: Any?) {
ghostty.reloadConfig()
}
@IBAction func newWindow(_ sender: Any?) {
terminalManager.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?) {
terminalManager.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 closeAllWindows(_ sender: Any?) {
terminalManager.closeAllWindows()
AboutController.shared.hide()
}
@IBAction func showAbout(_ sender: Any?) {
AboutController.shared.show()
}
@IBAction func showHelp(_ sender: Any) {
guard let url = URL(string: "https://ghostty.org/docs") else { return }
NSWorkspace.shared.open(url)
}
@IBAction func toggleSecureInput(_ sender: Any) {
setSecureInput(.toggle)
}
@IBAction func toggleQuickTerminal(_ sender: Any) {
if quickController == nil {
quickController = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition
)
}
guard let quickController = self.quickController else { return }
quickController.toggle()
}
/// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application
@IBAction func toggleVisibility(_ sender: Any) {
// If we have focus, then we hide all windows.
if NSApp.isActive {
// Toggle visibility doesn't do anything if the focused window is native
// fullscreen. This is only relevant if Ghostty is active.
guard let keyWindow = NSApp.keyWindow,
!keyWindow.styleMask.contains(.fullScreen) else { return }
// Keep track of our hidden state to restore properly
self.hiddenState = .init()
NSApp.hide(nil)
return
}
// If we're not active, we want to become active
NSApp.activate(ignoringOtherApps: true)
// Bring all windows to the front. Note: we don't use NSApp.unhide because
// that will unhide ALL hidden windows. We want to only bring forward the
// ones that we hid.
hiddenState?.restore()
hiddenState = nil
}
private struct DerivedConfig {
let initialWindow: Bool
let shouldQuitAfterLastWindowClosed: Bool
let quickTerminalPosition: QuickTerminalPosition
init() {
self.initialWindow = true
self.shouldQuitAfterLastWindowClosed = false
self.quickTerminalPosition = .top
}
init(_ config: Ghostty.Config) {
self.initialWindow = config.initialWindow
self.shouldQuitAfterLastWindowClosed = config.shouldQuitAfterLastWindowClosed
self.quickTerminalPosition = config.quickTerminalPosition
}
}
private struct ToggleVisibilityState {
let hiddenWindows: [Weak<NSWindow>]
let keyWindow: Weak<NSWindow>?
init() {
// We need to know the key window so that we can bring focus back to the
// right window if it was hidden.
self.keyWindow = if let keyWindow = NSApp.keyWindow {
.init(keyWindow)
} else {
nil
}
// We need to keep track of the windows that were visible because we only
// want to bring back these windows if we remove the toggle.
//
// We also ignore fullscreen windows because they don't hide anyways.
self.hiddenWindows = NSApp.windows.filter {
$0.isVisible &&
!$0.styleMask.contains(.fullScreen)
}.map { Weak($0) }
}
func restore() {
hiddenWindows.forEach { $0.value?.orderFrontRegardless() }
keyWindow?.value?.makeKey()
}
}
}