chore: import upstream snapshot with attribution
Test / build-libghostty-vt (x86_64-windows-gnu) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-ios) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt-macos (x86_64-macos) (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-dist (push) Has been cancelled
Test / GTK x11=true wayland=true (push) Has been cancelled
Test / Build -Dsimd=false (push) Has been cancelled
Test / Build -Dsimd=true (push) Has been cancelled
Test / Build -Dsentry=false (push) Has been cancelled
Test / Build -Dsentry=true (push) Has been cancelled
Test / zig-fmt (push) Has been cancelled
Test / GitHub Actions Pins (push) Has been cancelled
Test / prettier (push) Has been cancelled
Test / swiftlint (push) Has been cancelled
Test / alejandra (push) Has been cancelled
Test / typos (push) Has been cancelled
Nix / Required Checks: Nix (push) Has been cancelled
Nix / check-zig-cache-hash (push) Has been cancelled
Test / skip (push) Has been cancelled
Test / Required Checks: Test (push) Has been cancelled
Test / build-bench (push) Has been cancelled
Test / list-examples (push) Has been cancelled
Test / Example ${{ matrix.dir }} (Windows) (push) Has been cancelled
Test / build-cmake (push) Has been cancelled
Test / test-lib-vt-pkgconfig (push) Has been cancelled
Test / build-flatpak (push) Has been cancelled
Test / build-snap (push) Has been cancelled
Test / build-libghostty-vt (aarch64-linux) (push) Has been cancelled
Test / build-libghostty-vt (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt (wasm32-freestanding) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux-musl) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-macos) (push) Has been cancelled
Test / build-libghostty-vt-android (aarch64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-android (arm-linux-androideabi) (push) Has been cancelled
Test / build-libghostty-vt-android (x86_64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-windows (push) Has been cancelled
Test / build-libghostty-windows-gnu (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-linux-libghostty (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-dist-lib-vt (push) Has been cancelled
Test / trigger-snap (push) Has been cancelled
Test / trigger-flatpak (push) Has been cancelled
Test / build-macos (push) Has been cancelled
Test / build-macos-freetype (push) Has been cancelled
Test / test (push) Has been cancelled
Test / test-lib-vt (push) Has been cancelled
Test / GTK x11=false wayland=false (push) Has been cancelled
Test / GTK x11=true wayland=false (push) Has been cancelled
Test / GTK x11=false wayland=true (push) Has been cancelled
Test / test-macos (push) Has been cancelled
Test / test-windows (push) Has been cancelled
Test / Build -Di18n=false (push) Has been cancelled
Test / Build -Di18n=true (push) Has been cancelled
Test / Build test/fuzz-libghostty (push) Has been cancelled
Test / shellcheck (push) Has been cancelled
Test / translations (push) Has been cancelled
Test / blueprint-compiler (push) Has been cancelled
Test / Test pkg/wuffs (push) Has been cancelled
Test / Test build on Debian 13 (push) Has been cancelled
Test / valgrind (push) Has been cancelled
Test / Example ${{ matrix.dir }} (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:48 +08:00
commit bd44b5aa08
5770 changed files with 506778 additions and 0 deletions
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="QuickTerminalController" customModule="Ghostty" customModuleProvider="target">
<connections>
<outlet property="window" destination="QvC-M9-y7g" id="JMU-zX-9Ie"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="QuickTerminalWindow" customModule="Ghostty" customModuleProvider="target">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="196" y="240" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1667"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="-2" id="u5f-FR-jJw"/>
</connections>
<point key="canvasLocation" x="132" y="-82"/>
</window>
</objects>
</document>
@@ -0,0 +1,798 @@
import Foundation
import Cocoa
import SwiftUI
import GhosttyKit
/// Controller for the "quick" terminal.
class QuickTerminalController: BaseTerminalController {
override var windowNibName: NSNib.Name? { "QuickTerminal" }
/// The position for the quick terminal.
let position: QuickTerminalPosition
/// The current state of the quick terminal
private(set) var visible: Bool = false
/// The previously running application when the terminal is shown. This is NEVER Ghostty.
/// If this is set then when the quick terminal is animated out then we will restore this
/// application to the front.
private var previousApp: NSRunningApplication?
// The active space when the quick terminal was last shown.
private var previousActiveSpace: CGSSpace?
/// Cache for per-screen window state.
let screenStateCache: QuickTerminalScreenStateCache
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock?
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
/// Tracks if we're currently handling a manual resize to prevent recursion
private var isHandlingResize: Bool = false
/// This is set to false by init if the window managed by this controller should not be restorable.
/// For example, terminals executing custom scripts are not restorable.
let restorable: Bool
private var restorationState: QuickTerminalRestorableState?
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
restorationState: QuickTerminalRestorableState? = nil,
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
// The window we manage is not restorable if we've specified a command
// to execute. We do this because the restored window is meaningless at the
// time of writing this: it'd just restore to a shell in the same directory
// as the script. We may want to revisit this behavior when we have scrollback
// restoration.
restorable = (base?.command ?? "") == ""
self.restorationState = restorationState
self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:])
// Important detail here: we initialize with an empty surface tree so
// that we don't start a terminal process. This gets started when the
// first terminal is shown in `animateIn`.
super.init(ghostty, baseConfig: base, surfaceTree: .init())
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(applicationWillTerminate(_:)),
name: NSApplication.willTerminateNotification,
object: nil)
center.addObserver(
self,
selector: #selector(onToggleFullscreen(notification:)),
name: Ghostty.Notification.ghosttyToggleFullscreen,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil)
center.addObserver(
self,
selector: #selector(closeWindow(_:)),
name: .ghosttyCloseWindow,
object: nil
)
center.addObserver(
self,
selector: #selector(onNewTab),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
center.addObserver(
self,
selector: #selector(windowDidResize(_:)),
name: NSWindow.didResizeNotification,
object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
// Make sure we restore our hidden dock
hiddenDock = nil
}
// MARK: NSWindowController
override func windowDidLoad() {
super.windowDidLoad()
guard let window = self.window else { return }
// The controller is the window delegate so we can detect events such as
// window close so we can animate out.
window.delegate = self
// The quick window is restored by `screenStateCache`.
// We disable this for better control
window.isRestorable = false
// Setup our configured appearance that we support.
syncAppearance()
// Setup our initial size based on our configured position
position.setLoaded(window, size: derivedConfig.quickTerminalSize)
// Upon first adding this Window to its host view, older SwiftUI
// seems to have a "hiccup" and corrupts the frameRect,
// sometimes setting the size to zero, sometimes corrupting it.
// We pass the actual window's frame as "initial" frame directly
// to the window, so it can use that instead of the frameworks
// "interpretation"
if let qtWindow = window as? QuickTerminalWindow {
qtWindow.initialFrame = window.frame
}
// Setup our content
window.contentView = TerminalViewContainer {
TerminalView(ghostty: ghostty, viewModel: self, delegate: self)
}
// Clear out our frame at this point, the fixup from above is complete.
if let qtWindow = window as? QuickTerminalWindow {
qtWindow.initialFrame = nil
}
// Animate the window in
animateIn()
}
// MARK: NSWindowDelegate
override func windowDidBecomeKey(_ notification: Notification) {
super.windowDidBecomeKey(notification)
// If we're not visible we don't care to run the logic below. It only
// applies if we can be seen.
guard visible else { return }
terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true)
// Re-hide the dock if we were hiding it before.
hiddenDock?.hide()
}
override func windowDidResignKey(_ notification: Notification) {
super.windowDidResignKey(notification)
// If we're not visible then we don't want to run any of the logic below
// because things like resetting our previous app assume we're visible.
// windowDidResignKey will also get called after animateOut so this
// ensures we don't run logic twice.
guard visible else { return }
terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false)
// We don't animate out if there is a modal sheet being shown currently.
// This lets us show alerts without causing the window to disappear.
guard window?.attachedSheet == nil else { return }
// If our app is still active, then it means that we're switching
// to another window within our app, so we remove the previous app
// so we don't restore it.
if NSApp.isActive {
self.previousApp = nil
}
// Regardless of autohide, we always want to bring the dock back
// when we lose focus.
hiddenDock?.restore()
if derivedConfig.quickTerminalAutoHide {
switch derivedConfig.quickTerminalSpaceBehavior {
case .remain:
// If we lose focus on the active space, then we can animate out
animateOut()
case .move:
let currentActiveSpace = CGSSpace.active()
if previousActiveSpace == currentActiveSpace {
// We haven't moved spaces. We lost focus to another app on the
// current space. Animate out.
animateOut()
} else {
// We've moved to a different space.
// If we're fullscreen, we need to exit fullscreen because the visible
// bounds may have changed causing a new behavior.
if let fullscreenStyle, fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
DispatchQueue.main.async {
self.onToggleFullscreen()
}
}
// Make the window visible again on this space
DispatchQueue.main.async {
self.window?.makeKeyAndOrderFront(nil)
}
self.previousActiveSpace = currentActiveSpace
}
}
}
}
override func windowDidResize(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
window == self.window,
visible,
!isHandlingResize else { return }
guard let screen = window.screen ?? NSScreen.main else { return }
// Prevent recursive loops
isHandlingResize = true
defer { isHandlingResize = false }
switch position {
case .top, .bottom, .center:
// For centered positions (top, bottom, center), we need to recenter the window
// when it's manually resized to maintain proper positioning
let newOrigin = position.centeredOrigin(for: window, on: screen)
window.setFrameOrigin(newOrigin)
case .left, .right:
// For side positions, we may need to adjust vertical centering
let newOrigin = position.verticallyCenteredOrigin(for: window, on: screen)
window.setFrameOrigin(newOrigin)
}
}
// MARK: Base Controller Overrides
override func focusSurface(_ view: Ghostty.SurfaceView) {
if visible {
// If we're visible, we just focus the surface as normal.
super.focusSurface(view)
return
}
// Check if target surface belongs to this quick terminal
guard surfaceTree.contains(view) else { return }
// Set the target surface as focused
DispatchQueue.main.async {
Ghostty.moveFocus(to: view)
}
// Animation completion handler will handle window/app activation
animateIn()
}
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
// If our surface tree is nil then we animate the window out. We
// defer reinitializing the tree to save some memory here.
if to.isEmpty {
animateOut()
return
}
// If we're not empty (e.g. this isn't the first set) and we're
// not visible, then we animate in. This allows us to show the quick
// terminal when things such as undo/redo are done.
if !from.isEmpty && !visible {
animateIn()
return
}
}
override func closeSurface(
_ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true
) {
// If this isn't the root then we're dealing with a split closure.
if surfaceTree.root != node {
super.closeSurface(node, withConfirmation: withConfirmation)
return
}
// If this isn't a final leaf then we're dealing with a split closure
guard case .leaf(let surface) = node else {
super.closeSurface(node, withConfirmation: withConfirmation)
return
}
// If its the root, we check if the process exited. If it did,
// then we do empty the tree.
if surface.processExited {
surfaceTree = .init()
return
}
// If its the root then we just animate out. We never actually allow
// the surface to fully close.
animateOut()
}
override func newSplit(
at oldView: Ghostty.SurfaceView,
direction: SplitTree<Ghostty.SurfaceView>.NewDirection,
baseConfig config: Ghostty.SurfaceConfiguration? = nil
) -> Ghostty.SurfaceView? {
var config = config ?? Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
return super.newSplit(at: oldView, direction: direction, baseConfig: config)
}
// MARK: Methods
func toggle() {
if visible {
animateOut()
} else {
animateIn()
}
}
func animateIn() {
guard let window = self.window else { return }
// Set our visibility state
guard !visible else { return }
visible = true
// Notify the change
NotificationCenter.default.post(
name: .quickTerminalDidChangeVisibility,
object: self
)
// If we have a previously focused application and it isn't us, then
// we want to store it so we can restore state later.
if !NSApp.isActive {
if let previousApp = NSWorkspace.shared.frontmostApplication,
previousApp.bundleIdentifier != Bundle.main.bundleIdentifier {
self.previousApp = previousApp
}
}
// Set previous active space
self.previousActiveSpace = CGSSpace.active()
// If our surface tree is empty then we initialize a new terminal. The surface
// tree can be empty if for example we run "exit" in the terminal and force
// animate out.
if surfaceTree.isEmpty,
let ghostty_app = ghostty.app {
if let tree = restorationState?.surfaceTree, !tree.isEmpty {
surfaceTree = tree
let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first!
focusedSurface = view
// Add a short delay to check if the correct surface is focused.
// Each SurfaceWrapper defaults its FocusedValue to itself; without this delay,
// the tree often focuses the first surface instead of the intended one.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if !view.focused {
self.focusedSurface = view
self.makeWindowKey(window)
}
}
} else {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
surfaceTree = SplitTree(view: view)
focusedSurface = view
}
}
// Animate the window in
animateWindowIn(window: window, from: position)
// Clear the restoration state after first use
restorationState = nil
}
func animateOut() {
guard let window = self.window else { return }
// Set our visibility state
guard visible else { return }
visible = false
// Notify the change
NotificationCenter.default.post(
name: .quickTerminalDidChangeVisibility,
object: self
)
animateWindowOut(window: window, to: position)
}
func saveScreenState(exitFullscreen: Bool) {
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
// we have th correct window.frame for the save state below.
if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
}
guard let window else { return }
// Save the current window frame before animating out. This preserves
// the user's preferred window size and position for when the quick
// terminal is reactivated with a new surface. Without this, SwiftUI
// would reset the window to its minimum content size.
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
screenStateCache.save(frame: window.frame, for: screen)
}
}
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Grab our last closed frame to use from the cache.
let closedFrame = screenStateCache.frame(for: screen)
// Move our window off screen to the initial animation position.
position.setInitial(
in: window,
on: screen,
terminalSize: derivedConfig.quickTerminalSize,
closedFrame: closedFrame)
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
window.level = .popUpMenu
// Move it to the visible position since animation requires this
DispatchQueue.main.async {
window.makeKeyAndOrderFront(nil)
}
// If our dock position would conflict with our target location then
// we autohide the dock.
if position.conflictsWithDock(on: screen) {
if hiddenDock == nil {
hiddenDock = .init()
}
hiddenDock?.hide()
} else {
// Ensure we don't have any hidden dock if we don't conflict.
// The deinit will restore.
hiddenDock = nil
}
// Run the animation that moves our window into the proper place and makes
// it visible.
NSAnimationContext.runAnimationGroup({ context in
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
position.setFinal(
in: window.animator(),
on: screen,
terminalSize: derivedConfig.quickTerminalSize,
closedFrame: closedFrame)
}, completionHandler: {
// There is a very minor delay here so waiting at least an event loop tick
// keeps us safe from the view not being on the window.
DispatchQueue.main.async {
// If we canceled our animation clean up some state.
guard self.visible else {
self.hiddenDock = nil
return
}
// After animating in, we reset the window level to a value that
// is above other windows but not as high as popUpMenu. This allows
// things like IME dropdowns to appear properly.
window.level = .floating
// Now that the window is visible, sync our appearance. This function
// requires the window is visible.
self.syncAppearance()
// Once our animation is done, we must grab focus since we can't grab
// focus of a non-visible window.
self.makeWindowKey(window)
// If our application is not active, then we grab focus. Its important
// we do this AFTER our window is animated in and focused because
// otherwise macOS will bring forward another window.
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
// This works around a really funky bug where if the terminal is
// shown on a screen that has no other Ghostty windows, it takes
// a few (variable) event loop ticks until we can actually focus it.
// https://github.com/ghostty-org/ghostty/issues/2409
//
// We wait one event loop tick to try it because under the happy
// path (we have windows on this screen) it takes one event loop
// tick for window.isKeyWindow to return true.
DispatchQueue.main.async {
guard !window.isKeyWindow else { return }
self.makeWindowKey(window, retries: 10)
}
}
}
})
}
/// Attempt to make a window key, supporting retries if necessary. The retries will be attempted
/// on a separate event loop tick.
///
/// The window must contain the focused surface for this terminal controller.
private func makeWindowKey(_ window: NSWindow, retries: UInt8 = 0) {
// We must be visible
guard visible else { return }
// If our focused view is somehow not connected to this window then the
// function calls below do nothing. I don't think this is possible but
// we should guard against it because it is a Cocoa assertion.
guard let focusedSurface, focusedSurface.window == window else { return }
// The window must become top-level
window.makeKeyAndOrderFront(nil)
// The view must gain our keyboard focus
window.makeFirstResponder(focusedSurface)
// If our window is already key then we're done!
guard !window.isKeyWindow else { return }
// If we don't have retries then we're done
guard retries > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) {
self.makeWindowKey(window, retries: retries - 1)
}
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
saveScreenState(exitFullscreen: true)
// If we hid the dock then we unhide it.
hiddenDock = nil
// If the window isn't on our active space then we don't animate, we just
// hide it.
if !window.isOnActiveSpace {
self.previousApp = nil
window.orderOut(self)
// If our application is hidden previously, we hide it again
if (NSApp.delegate as? AppDelegate)?.hiddenState != nil {
NSApp.hide(nil)
}
return
}
// We always animate out to whatever screen the window is actually on.
guard let screen = window.screen ?? NSScreen.main else { return }
// If we have a previously active application, restore focus to it. We
// do this BEFORE the animation below because when the animation completes
// macOS will bring forward another window.
if let previousApp = self.previousApp {
// Make sure we unset the state no matter what
self.previousApp = nil
if !previousApp.isTerminated {
// Ignore the result, it doesn't change our behavior.
_ = previousApp.activate(options: [])
}
}
// We need to set our window level to a high value. In testing, only
// popUpMenu and above do what we want. This gets it above the menu bar
// and lets us render off screen.
window.level = .popUpMenu
NSAnimationContext.runAnimationGroup({ context in
context.duration = derivedConfig.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn)
position.setInitial(
in: window.animator(),
on: screen,
terminalSize: derivedConfig.quickTerminalSize,
closedFrame: window.frame)
}, completionHandler: {
// This causes the window to be removed from the screen list and macOS
// handles what should be focused next.
window.orderOut(self)
// If our application is hidden previously, we hide it again
if (NSApp.delegate as? AppDelegate)?.hiddenState != nil {
NSApp.hide(nil)
}
})
}
override func syncAppearance() {
guard let window else { return }
defer { updateColorSchemeForSurfaceTree() }
// Change the collection behavior of the window depending on the configuration.
window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior
// If our window is not visible, then no need to sync the appearance yet.
// Some APIs such as window blur have no effect unless the window is visible.
guard window.isVisible else { return }
// If we have window transparency then set it transparent. Otherwise set it opaque.
// Also check if the user has overridden transparency to be fully opaque.
if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
// matches Terminal.app much more closer. This lets users transition from
// Terminal.app more easily.
window.backgroundColor = .white.withAlphaComponent(0.001)
if !derivedConfig.backgroundBlur.isGlassStyle {
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
}
} else {
window.isOpaque = true
window.backgroundColor = .windowBackgroundColor
}
terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: nil)
}
private func showNoNewTabAlert() {
guard let window else { return }
let alert = NSAlert()
alert.messageText = "Cannot Create New Tab"
alert.informativeText = "Tabs aren't supported in the Quick Terminal."
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
alert.beginSheetModal(for: window)
}
// MARK: First Responder
@IBAction override func closeWindow(_ sender: Any) {
// Instead of closing the window, we animate it out.
animateOut()
}
@IBAction func newTab(_ sender: Any?) {
showNoNewTabAlert()
}
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func toggleTerminalInspector(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
// MARK: Notifications
@objc private func applicationWillTerminate(_ notification: Notification) {
// If the application is going to terminate we want to make sure we
// restore any global dock state. I think deinit should be called which
// would call this anyways but I can't be sure so I will do this too.
hiddenDock = nil
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
onToggleFullscreen()
}
private func onToggleFullscreen() {
// We ignore the configured fullscreen style and always use non-native
// because the way the quick terminal works doesn't support native.
let mode: FullscreenMode
if NSApp.isFrontmost {
// If we're frontmost and we have a notch then we keep padding
// so all lines of the terminal are visible.
if window?.screen?.hasNotch ?? false {
mode = .nonNativePaddedNotch
} else {
mode = .nonNative
}
} else {
// An additional detail is that if the is NOT frontmost, then our
// NSApp.presentationOptions will not take effect so we must always
// do the visible menu mode since we can't get rid of the menu.
mode = .nonNativeVisibleMenu
}
toggleFullscreen(mode: mode)
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a
// surface-specific 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 }
// Update our derived config
self.derivedConfig = DerivedConfig(config)
syncAppearance()
terminalViewContainer?.ghosttyConfigDidChange(config, preferredBackgroundColor: nil)
}
@objc private func onNewTab(notification: SwiftUI.Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }
guard window.windowController is QuickTerminalController else { return }
// Tabs aren't supported with Quick Terminals or derivatives
showNoNewTabAlert()
}
private struct DerivedConfig {
let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double
let quickTerminalAutoHide: Bool
let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior
let quickTerminalSize: QuickTerminalSize
let backgroundOpacity: Double
let backgroundBlur: Ghostty.Config.BackgroundBlur
init() {
self.quickTerminalScreen = .main
self.quickTerminalAnimationDuration = 0.2
self.quickTerminalAutoHide = true
self.quickTerminalSpaceBehavior = .move
self.quickTerminalSize = QuickTerminalSize()
self.backgroundOpacity = 1.0
self.backgroundBlur = .disabled
}
init(_ config: Ghostty.Config) {
self.quickTerminalScreen = config.quickTerminalScreen
self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration
self.quickTerminalAutoHide = config.quickTerminalAutoHide
self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior
self.quickTerminalSize = config.quickTerminalSize
self.backgroundOpacity = config.backgroundOpacity
self.backgroundBlur = config.backgroundBlur
}
}
/// Hides the dock globally (not just NSApp). This is only used if the quick terminal is
/// in a conflicting position with the dock.
private class HiddenDock {
let previousAutoHide: Bool
private var hidden: Bool = false
init() {
previousAutoHide = Dock.autoHideEnabled
}
deinit {
restore()
}
func hide() {
guard !hidden else { return }
NSApp.acquirePresentationOption(.autoHideDock)
Dock.autoHideEnabled = true
hidden = true
}
func restore() {
guard hidden else { return }
NSApp.releasePresentationOption(.autoHideDock)
Dock.autoHideEnabled = previousAutoHide
hidden = false
}
}
}
extension Notification.Name {
/// The quick terminal did become hidden or visible.
static let quickTerminalDidChangeVisibility = Notification.Name("QuickTerminalDidChangeVisibility")
}
@@ -0,0 +1,186 @@
import Cocoa
enum QuickTerminalPosition: String {
case top
case bottom
case left
case right
case center
/// Set the loaded state for a window. This should only be called when the window is first loaded,
/// usually in `windowDidLoad` or in a similar callback. This is the initial state.
func setLoaded(_ window: NSWindow, size: QuickTerminalSize) {
guard let screen = window.screen ?? NSScreen.main else { return }
window.setFrame(.init(
origin: window.frame.origin,
size: size.calculate(position: self, screenDimensions: screen.visibleFrame.size)
), display: false)
}
/// Set the initial state for a window NOT yet into position (either before animating in or
/// after animating out).
func setInitial(
in window: NSWindow,
on screen: NSScreen,
terminalSize: QuickTerminalSize,
closedFrame: NSRect? = nil
) {
// Invisible
window.alphaValue = 0
// Position depends
window.setFrame(.init(
origin: initialOrigin(for: window, on: screen),
size: closedFrame?.size ?? configuredFrameSize(
on: screen,
terminalSize: terminalSize)
), display: false)
}
/// Set the final state for a window in this position.
func setFinal(
in window: NSWindow,
on screen: NSScreen,
terminalSize: QuickTerminalSize,
closedFrame: NSRect? = nil
) {
// We always end visible
window.alphaValue = 1
// Position depends
window.setFrame(.init(
origin: finalOrigin(for: window, on: screen),
size: closedFrame?.size ?? configuredFrameSize(
on: screen,
terminalSize: terminalSize)
), display: true)
}
/// Get the configured frame size for initial positioning and animations.
func configuredFrameSize(on screen: NSScreen, terminalSize: QuickTerminalSize) -> NSSize {
let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.visibleFrame.size)
return NSSize(width: dimensions.width, height: dimensions.height)
}
/// The initial point origin for this position.
func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch self {
case .top:
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.maxY)
case .bottom:
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: -window.frame.height)
case .left:
return .init(
x: screen.visibleFrame.minX-window.frame.width,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .right:
return .init(
x: screen.visibleFrame.maxX,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .center:
return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: screen.visibleFrame.height - window.frame.width)
}
}
/// The final point origin for this position.
func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch self {
case .top:
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.maxY - window.frame.height)
case .bottom:
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.minY)
case .left:
return .init(
x: screen.visibleFrame.minX,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .right:
return .init(
x: screen.visibleFrame.maxX - window.frame.width,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .center:
return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
}
}
func conflictsWithDock(on screen: NSScreen) -> Bool {
// Screen must have a dock for it to conflict
guard screen.hasDock else { return false }
// Get the dock orientation for this screen
guard let orientation = Dock.orientation else { return false }
// Depending on the orientation of the dock, we conflict if our quick terminal
// would potentially "hit" the dock. In the future we should probably consider
// the frame of the quick terminal.
return switch orientation {
case .top: self == .top || self == .left || self == .right
case .bottom: self == .bottom || self == .left || self == .right
case .left: self == .top || self == .bottom
case .right: self == .top || self == .bottom
}
}
/// Calculate the centered origin for a window, keeping it properly positioned after manual resizing
func centeredOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch self {
case .top:
return CGPoint(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
case .bottom:
return CGPoint(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
case .center:
return CGPoint(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .left, .right:
// For left/right positions, only adjust horizontal centering if needed
return window.frame.origin
}
}
/// Calculate the vertically centered origin for side-positioned windows
func verticallyCenteredOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch self {
case .left:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .right:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .top, .bottom, .center:
// These positions don't need vertical recentering during resize
return window.frame.origin
}
}
}
@@ -0,0 +1,58 @@
import Cocoa
struct QuickTerminalRestorableState: TerminalRestorable {
static var version: Int { 1 }
var focusedSurface: String? {
internalState.focusedSurface
}
var surfaceTree: SplitTree<Ghostty.SurfaceView> {
internalState.surfaceTree
}
var screenStateEntries: QuickTerminalScreenStateCache.Entries {
internalState.screenStateEntries
}
private let internalState: InternalState<Ghostty.SurfaceView>
init(from controller: QuickTerminalController) {
controller.saveScreenState(exitFullscreen: true)
self.internalState = .init(from: controller)
}
init(copy other: QuickTerminalRestorableState) {
self = other
}
var baseConfig: Ghostty.SurfaceConfiguration? {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
return config
}
}
extension QuickTerminalRestorableState {
/// Internal State we use to perform unit tests
///
/// Since we can't really change the type of `QuickTerminalRestorableState`
/// due to `CodableBridge<QuickTerminalRestorableState>` supporting secure coding,
/// we use an internal type to perform migration and tests
struct InternalState<ViewType: NSView & Codable & Identifiable>: Codable {
// MARK: - Version 1 (1.3.0)
let focusedSurface: String?
let surfaceTree: SplitTree<ViewType>
let screenStateEntries: QuickTerminalScreenStateCache.Entries
}
}
extension QuickTerminalRestorableState.InternalState where ViewType == Ghostty.SurfaceView {
init(from controller: QuickTerminalController) {
self.init(
focusedSurface: controller.focusedSurface?.id.uuidString,
surfaceTree: controller.surfaceTree,
screenStateEntries: controller.screenStateCache.stateByDisplay,
)
}
}
@@ -0,0 +1,37 @@
import Cocoa
enum QuickTerminalScreen {
case main
case mouse
case menuBar
init?(fromGhosttyConfig string: String) {
switch string {
case "main":
self = .main
case "mouse":
self = .mouse
case "macos-menu-bar":
self = .menuBar
default:
return nil
}
}
var screen: NSScreen? {
switch self {
case .main:
return NSScreen.main
case .mouse:
let mouseLoc = NSEvent.mouseLocation
return NSScreen.screens.first(where: { $0.frame.contains(mouseLoc) })
case .menuBar:
return NSScreen.screens.first
}
}
}
@@ -0,0 +1,120 @@
import Foundation
import Cocoa
/// Manages cached window state per screen for the quick terminal.
///
/// This cache tracks the last closed window frame for each screen, allowing the quick terminal
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
/// to survive NSScreen garbage collection and automatically prunes stale entries.
class QuickTerminalScreenStateCache {
typealias Entries = [UUID: DisplayEntry]
/// The maximum number of saved screen states we retain. This is to avoid some kind of
/// pathological memory growth in case we get our screen state serializing wrong. I don't
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
private static let maxSavedScreens = 10
/// Time-to-live for screen entries that are no longer present (14 days).
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
/// Keyed by display UUID to survive NSScreen garbage collection.
private(set) var stateByDisplay: Entries = [:]
init(stateByDisplay: Entries = [:]) {
self.stateByDisplay = stateByDisplay
NotificationCenter.default.addObserver(
self,
selector: #selector(onScreensChanged(_:)),
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Save the window frame for a screen.
func save(frame: NSRect, for screen: NSScreen) {
guard let key = screen.displayUUID else { return }
let entry = DisplayEntry(
frame: frame,
screenSize: screen.frame.size,
scale: screen.backingScaleFactor,
lastSeen: Date()
)
stateByDisplay[key] = entry
pruneCapacity()
}
/// Retrieve the last closed frame for a screen, if valid.
func frame(for screen: NSScreen) -> NSRect? {
guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil }
// Drop on dimension/scale change that makes the entry invalid
if !entry.isValid(for: screen) {
stateByDisplay.removeValue(forKey: key)
return nil
}
entry.lastSeen = Date()
stateByDisplay[key] = entry
return entry.frame
}
@objc private func onScreensChanged(_ note: Notification) {
let screens = NSScreen.screens
let now = Date()
let currentIDs = Set(screens.compactMap { $0.displayUUID })
for screen in screens {
guard let key = screen.displayUUID else { continue }
if var entry = stateByDisplay[key] {
// Drop on dimension/scale change that makes the entry invalid. The
// saved frame is only meaningful for the exact geometry it was
// captured on, so a present screen that no longer matches is dropped.
if !entry.isValid(for: screen) {
stateByDisplay.removeValue(forKey: key)
} else {
entry.lastSeen = now
stateByDisplay[key] = entry
}
}
}
// TTL prune for non-present screens
stateByDisplay = stateByDisplay.filter { key, entry in
currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL
}
pruneCapacity()
}
private func pruneCapacity() {
guard stateByDisplay.count > Self.maxSavedScreens else { return }
let toRemove = stateByDisplay
.sorted { $0.value.lastSeen < $1.value.lastSeen }
.prefix(stateByDisplay.count - Self.maxSavedScreens)
for (key, _) in toRemove {
stateByDisplay.removeValue(forKey: key)
}
}
struct DisplayEntry: Codable {
var frame: NSRect
var screenSize: CGSize
var scale: CGFloat
var lastSeen: Date
/// Returns true if this entry is still valid for the given screen.
///
/// An entry is only valid for the exact screen geometry it was captured on: both the
/// backing scale factor and the frame size must match. A saved frame is meaningless once
/// the display's resolution changes, which commonly happens when an external display is
/// disconnected and later reconnected at a different resolution (e.g. after travel). In
/// that case we drop the entry and fall back to the configured `quick-terminal-size`,
/// rather than restoring a stale frame that no longer fills the screen as expected.
func isValid(for screen: NSScreen) -> Bool {
scale == screen.backingScaleFactor && screenSize == screen.frame.size
}
}
}
@@ -0,0 +1,84 @@
import GhosttyKit
/// Represents the Ghostty `quick-terminal-size` configuration. See the documentation for
/// that for more details on exactly how it works. Some of those docs will be reproduced in various comments
/// in this file but that is the best source of truth for it.
///
/// The size determines the size of the quick terminal along the primary and secondary axis. The primary and
/// secondary axis is defined by the `quick-terminal-position`.
struct QuickTerminalSize {
let primary: Size?
let secondary: Size?
init(primary: Size? = nil, secondary: Size? = nil) {
self.primary = primary
self.secondary = secondary
}
init(from cStruct: ghostty_config_quick_terminal_size_s) {
self.primary = Size(from: cStruct.primary)
self.secondary = Size(from: cStruct.secondary)
}
enum Size {
case percentage(Float)
case pixels(UInt32)
init?(from cStruct: ghostty_quick_terminal_size_s) {
switch cStruct.tag {
case GHOSTTY_QUICK_TERMINAL_SIZE_NONE:
return nil
case GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE:
self = .percentage(cStruct.value.percentage)
case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS:
self = .pixels(cStruct.value.pixels)
default:
assertionFailure()
return nil
}
}
func toPixels(parentDimension: CGFloat) -> CGFloat {
switch self {
case .percentage(let value):
return parentDimension * CGFloat(value) / 100.0
case .pixels(let value):
return CGFloat(value)
}
}
}
/// This is an almost direct port of th Zig function QuickTerminalSize.calculate
func calculate(position: QuickTerminalPosition, screenDimensions: CGSize) -> CGSize {
let dims = CGSize(width: screenDimensions.width, height: screenDimensions.height)
switch position {
case .left, .right:
return CGSize(
width: primary?.toPixels(parentDimension: dims.width) ?? 400,
height: secondary?.toPixels(parentDimension: dims.height) ?? dims.height
)
case .top, .bottom:
return CGSize(
width: secondary?.toPixels(parentDimension: dims.width) ?? dims.width,
height: primary?.toPixels(parentDimension: dims.height) ?? 400
)
case .center:
if dims.width >= dims.height {
// Landscape
return CGSize(
width: primary?.toPixels(parentDimension: dims.width) ?? 800,
height: secondary?.toPixels(parentDimension: dims.height) ?? 400
)
} else {
// Portrait
return CGSize(
width: secondary?.toPixels(parentDimension: dims.width) ?? 400,
height: primary?.toPixels(parentDimension: dims.height) ?? 800
)
}
}
}
}
@@ -0,0 +1,36 @@
import Foundation
import Cocoa
enum QuickTerminalSpaceBehavior {
case remain
case move
init?(fromGhosttyConfig string: String) {
switch string {
case "move":
self = .move
case "remain":
self = .remain
default:
return nil
}
}
var collectionBehavior: NSWindow.CollectionBehavior {
let commonBehavior: [NSWindow.CollectionBehavior] = [
.ignoresCycle,
.fullScreenAuxiliary
]
switch self {
case .move:
// We want this to move the window to the active space.
return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior)
case .remain:
// We want this to remain the window in the current space.
return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior)
}
}
}
@@ -0,0 +1,47 @@
import Cocoa
class QuickTerminalWindow: NSPanel {
// Both of these must be true for windows without decorations to be able to
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
override func awakeFromNib() {
super.awakeFromNib()
// Note: almost all of this stuff can be done in the nib/xib directly
// but I prefer to do it programmatically because the properties we
// care about are less hidden.
// Add a custom identifier so third party apps can use the Accessibility
// API to apply special rules to the quick terminal.
self.identifier = .init(rawValue: "com.mitchellh.ghostty.quickTerminal")
// Set the correct AXSubrole of kAXFloatingWindowSubrole (allows
// AeroSpace to treat the Quick Terminal as a floating window)
self.setAccessibilitySubrole(.floatingWindow)
// Remove the title completely. This will make the window square. One
// downside is it also hides the cursor indications of resize but the
// window remains resizable.
self.styleMask.remove(.titled)
// We don't want to activate the owning app when quick terminal is triggered.
self.styleMask.insert(.nonactivatingPanel)
}
/// This is set to the frame prior to setting `contentView`. This is purely a hack to workaround
/// bugs in older macOS versions (Ventura): https://github.com/ghostty-org/ghostty/pull/8026
var initialFrame: NSRect?
override func setFrame(_ frameRect: NSRect, display flag: Bool) {
// Upon first adding this Window to its host view, older SwiftUI
// seems to have a "hiccup" and corrupts the frameRect,
// sometimes setting the size to zero, sometimes corrupting it.
// If we find we have cached the "initial" frame, use that instead
// the propagated one through the framework
//
// https://github.com/ghostty-org/ghostty/pull/8026
super.setFrame(initialFrame ?? frameRect, display: flag)
}
}