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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user