mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 08:16:13 +03:00
1391 lines
55 KiB
Swift
1391 lines
55 KiB
Swift
import Foundation
|
|
import Cocoa
|
|
import SwiftUI
|
|
import Combine
|
|
import GhosttyKit
|
|
|
|
/// A classic, tabbed terminal experience.
|
|
class TerminalController: BaseTerminalController {
|
|
override var windowNibName: NSNib.Name? {
|
|
//NOTE(mitchellh): switch to this when we've transitioned all legacy logic out
|
|
//let defaultValue = "Terminal"
|
|
let defaultValue = "TerminalLegacy"
|
|
|
|
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
|
|
let config = appDelegate.ghostty.config
|
|
let nib = switch config.macosTitlebarStyle {
|
|
case "native": "Terminal"
|
|
case "tabs": defaultValue
|
|
case "hidden": "TerminalHiddenTitlebar"
|
|
case "transparent": "TerminalTransparentTitlebar"
|
|
default: defaultValue
|
|
}
|
|
|
|
return nib
|
|
}
|
|
|
|
/// This is set to true when we care about frame changes. This is a small optimization since
|
|
/// this controller registers a listener for ALL frame change notifications and this lets us bail
|
|
/// early if we don't care.
|
|
private var tabListenForFrame: Bool = false
|
|
|
|
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
|
|
/// changes in the list.
|
|
private var tabWindowsHash: Int = 0
|
|
|
|
/// 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.
|
|
private var restorable: Bool = true
|
|
|
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
|
private(set) var derivedConfig: DerivedConfig
|
|
|
|
/// The notification cancellable for focused surface property changes.
|
|
private var surfaceAppearanceCancellables: Set<AnyCancellable> = []
|
|
|
|
/// This will be set to the initial frame of the window from the xib on load.
|
|
private var initialFrame: NSRect? = nil
|
|
|
|
init(_ ghostty: Ghostty.App,
|
|
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
|
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
|
|
parent: NSWindow? = nil
|
|
) {
|
|
// 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.
|
|
self.restorable = (base?.command ?? "") == ""
|
|
|
|
// Setup our initial derived config based on the current app config
|
|
self.derivedConfig = DerivedConfig(ghostty.config)
|
|
|
|
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
|
|
|
// Setup our notifications for behaviors
|
|
let center = NotificationCenter.default
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onToggleFullscreen),
|
|
name: Ghostty.Notification.ghosttyToggleFullscreen,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onMoveTab),
|
|
name: .ghosttyMoveTab,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onGotoTab),
|
|
name: Ghostty.Notification.ghosttyGotoTab,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onCloseTab),
|
|
name: .ghosttyCloseTab,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onResetWindowSize),
|
|
name: .ghosttyResetWindowSize,
|
|
object: nil
|
|
)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyConfigDidChange(_:)),
|
|
name: .ghosttyConfigDidChange,
|
|
object: nil
|
|
)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onFrameDidChange),
|
|
name: NSView.frameDidChangeNotification,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onCloseWindow),
|
|
name: .ghosttyCloseWindow,
|
|
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)
|
|
}
|
|
|
|
// MARK: Base Controller Overrides
|
|
|
|
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
|
super.surfaceTreeDidChange(from: from, to: to)
|
|
|
|
// Whenever our surface tree changes in any way (new split, close split, etc.)
|
|
// we want to invalidate our state.
|
|
invalidateRestorableState()
|
|
|
|
// Update our zoom state
|
|
if let window = window as? LegacyTerminalWindow {
|
|
window.surfaceIsZoomed = to.zoomed != nil
|
|
}
|
|
if let window = window as? TerminalWindow {
|
|
window.surfaceIsZoomed2 = to.zoomed != nil
|
|
}
|
|
|
|
// If our surface tree is now nil then we close our window.
|
|
if (to.isEmpty) {
|
|
self.window?.close()
|
|
}
|
|
}
|
|
|
|
|
|
func fullscreenDidChange() {
|
|
// When our fullscreen state changes, we resync our appearance because some
|
|
// properties change when fullscreen or not.
|
|
guard let focusedSurface else { return }
|
|
|
|
syncAppearance(focusedSurface.derivedConfig)
|
|
}
|
|
|
|
// MARK: Terminal Creation
|
|
|
|
/// Returns all the available terminal controllers present in the app currently.
|
|
static var all: [TerminalController] {
|
|
return NSApplication.shared.windows.compactMap {
|
|
$0.windowController as? TerminalController
|
|
}
|
|
}
|
|
|
|
// Keep track of the last point that our window was launched at so that new
|
|
// windows "cascade" over each other and don't just launch directly on top
|
|
// of each other.
|
|
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
|
|
|
|
// The preferred parent terminal controller.
|
|
private static var preferredParent: TerminalController? {
|
|
all.first {
|
|
$0.window?.isMainWindow ?? false
|
|
} ?? all.last
|
|
}
|
|
|
|
/// The "new window" action.
|
|
static func newWindow(
|
|
_ ghostty: Ghostty.App,
|
|
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
|
|
withParent explicitParent: NSWindow? = nil
|
|
) -> TerminalController {
|
|
let c = TerminalController.init(ghostty, withBaseConfig: baseConfig)
|
|
|
|
// Get our parent. Our parent is the one explicitly given to us,
|
|
// otherwise the focused terminal, otherwise an arbitrary one.
|
|
let parent: NSWindow? = explicitParent ?? preferredParent?.window
|
|
|
|
if let parent {
|
|
if parent.styleMask.contains(.fullScreen) {
|
|
parent.toggleFullScreen(nil)
|
|
} else if ghostty.config.windowFullscreen {
|
|
switch (ghostty.config.windowFullscreenMode) {
|
|
case .native:
|
|
// Native has to be done immediately so that our stylemask contains
|
|
// fullscreen for the logic later in this method.
|
|
c.toggleFullscreen(mode: .native)
|
|
|
|
case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch:
|
|
// If we're non-native then we have to do it on a later loop
|
|
// so that the content view is setup.
|
|
DispatchQueue.main.async {
|
|
c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
|
// take effect. Our best theory is there is some next-event-loop-tick logic
|
|
// that Cocoa is doing that we need to be after.
|
|
DispatchQueue.main.async {
|
|
// Only cascade if we aren't fullscreen.
|
|
if let window = c.window {
|
|
if (!window.styleMask.contains(.fullScreen)) {
|
|
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
|
}
|
|
}
|
|
|
|
c.showWindow(self)
|
|
}
|
|
|
|
// Setup our undo
|
|
if let undoManager = c.undoManager {
|
|
undoManager.setActionName("New Window")
|
|
undoManager.registerUndo(
|
|
withTarget: c,
|
|
expiresAfter: c.undoExpiration
|
|
) { target in
|
|
// Close the window when undoing
|
|
undoManager.disableUndoRegistration {
|
|
target.closeWindow(nil)
|
|
}
|
|
|
|
// Register redo action
|
|
undoManager.registerUndo(
|
|
withTarget: ghostty,
|
|
expiresAfter: target.undoExpiration
|
|
) { ghostty in
|
|
_ = TerminalController.newWindow(
|
|
ghostty,
|
|
withBaseConfig: baseConfig,
|
|
withParent: explicitParent)
|
|
}
|
|
}
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
static func newTab(
|
|
_ ghostty: Ghostty.App,
|
|
from parent: NSWindow? = nil,
|
|
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil
|
|
) -> TerminalController? {
|
|
// Making sure that we're dealing with a TerminalController. If not,
|
|
// then we just create a new window.
|
|
guard let parent,
|
|
let parentController = parent.windowController as? TerminalController else {
|
|
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent)
|
|
}
|
|
|
|
// If our parent is in non-native fullscreen, then new tabs do not work.
|
|
// See: https://github.com/mitchellh/ghostty/issues/392
|
|
if let fullscreenStyle = parentController.fullscreenStyle,
|
|
fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Cannot Create New Tab"
|
|
alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
|
|
alert.addButton(withTitle: "OK")
|
|
alert.alertStyle = .warning
|
|
alert.beginSheetModal(for: parent)
|
|
return nil
|
|
}
|
|
|
|
// Create a new window and add it to the parent
|
|
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
|
|
guard let window = controller.window else { return controller }
|
|
|
|
// If the parent is miniaturized, then macOS exhibits really strange behaviors
|
|
// so we have to bring it back out.
|
|
if (parent.isMiniaturized) { parent.deminiaturize(self) }
|
|
|
|
// If our parent tab group already has this window, macOS added it and
|
|
// we need to remove it so we can set the correct order in the next line.
|
|
// If we don't do this, macOS gets really confused and the tabbedWindows
|
|
// state becomes incorrect.
|
|
//
|
|
// At the time of writing this code, the only known case this happens
|
|
// is when the "+" button is clicked in the tab bar.
|
|
if let tg = parent.tabGroup,
|
|
tg.windows.firstIndex(of: window) != nil {
|
|
tg.removeWindow(window)
|
|
}
|
|
|
|
// If we don't allow tabs then we create a new window instead.
|
|
if (window.tabbingMode != .disallowed) {
|
|
// Add the window to the tab group and show it.
|
|
switch ghostty.config.windowNewTabPosition {
|
|
case "end":
|
|
// If we already have a tab group and we want the new tab to open at the end,
|
|
// then we use the last window in the tab group as the parent.
|
|
if let last = parent.tabGroup?.windows.last {
|
|
last.addTabbedWindow(window, ordered: .above)
|
|
} else {
|
|
fallthrough
|
|
}
|
|
|
|
case "current": fallthrough
|
|
default:
|
|
parent.addTabbedWindow(window, ordered: .above)
|
|
}
|
|
}
|
|
|
|
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
|
// take effect. Our best theory is there is some next-event-loop-tick logic
|
|
// that Cocoa is doing that we need to be after.
|
|
DispatchQueue.main.async {
|
|
// Only cascade if we aren't fullscreen and are alone in the tab group.
|
|
if !window.styleMask.contains(.fullScreen) &&
|
|
window.tabGroup?.windows.count ?? 1 == 1 {
|
|
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
|
|
}
|
|
|
|
controller.showWindow(self)
|
|
window.makeKeyAndOrderFront(self)
|
|
}
|
|
|
|
// It takes an event loop cycle until the macOS tabGroup state becomes
|
|
// consistent which causes our tab labeling to be off when the "+" button
|
|
// is used in the tab bar. This fixes that. If we can find a more robust
|
|
// solution we should do that.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
controller.relabelTabs()
|
|
}
|
|
|
|
// Setup our undo
|
|
if let undoManager = parentController.undoManager {
|
|
undoManager.setActionName("New Tab")
|
|
undoManager.registerUndo(
|
|
withTarget: controller,
|
|
expiresAfter: controller.undoExpiration
|
|
) { target in
|
|
// Close the tab when undoing
|
|
undoManager.disableUndoRegistration {
|
|
target.closeTab(nil)
|
|
}
|
|
|
|
// Register redo action
|
|
undoManager.registerUndo(
|
|
withTarget: ghostty,
|
|
expiresAfter: target.undoExpiration
|
|
) { ghostty in
|
|
_ = TerminalController.newTab(
|
|
ghostty,
|
|
from: parent,
|
|
withBaseConfig: baseConfig)
|
|
}
|
|
}
|
|
}
|
|
|
|
return controller
|
|
}
|
|
|
|
//MARK: - Methods
|
|
|
|
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
|
// Get our managed configuration object out
|
|
guard let config = notification.userInfo?[
|
|
Notification.Name.GhosttyConfigChangeKey
|
|
] as? Ghostty.Config else { return }
|
|
|
|
// If this is an app-level config update then we update some things.
|
|
if (notification.object == nil) {
|
|
// Update our derived config
|
|
self.derivedConfig = DerivedConfig(config)
|
|
|
|
// If we have no surfaces in our window (is that possible?) then we update
|
|
// our window appearance based on the root config. If we have surfaces, we
|
|
// don't call this because focused surface changes will trigger appearance updates.
|
|
if surfaceTree.isEmpty {
|
|
syncAppearance(.init(config))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// This is a surface-level config update. If we have the surface, we
|
|
// update our appearance based on it.
|
|
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.contains(surfaceView) else { return }
|
|
|
|
// We can't use surfaceView.derivedConfig because it may not be updated
|
|
// yet since it also responds to notifications.
|
|
syncAppearance(.init(config))
|
|
}
|
|
|
|
/// Update the accessory view of each tab according to the keyboard
|
|
/// shortcut that activates it (if any). This is called when the key window
|
|
/// changes, when a window is closed, and when tabs are reordered
|
|
/// with the mouse.
|
|
func relabelTabs() {
|
|
// We only listen for frame changes if we have more than 1 window,
|
|
// otherwise the accessory view doesn't matter.
|
|
tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1
|
|
|
|
if let windows = window?.tabbedWindows as? [TerminalWindow] {
|
|
for (tab, window) in zip(1..., windows) {
|
|
// We need to clear any windows beyond this because they have had
|
|
// a keyEquivalent set previously.
|
|
guard tab <= 9 else {
|
|
window.keyEquivalent2 = ""
|
|
continue
|
|
}
|
|
|
|
let action = "goto_tab:\(tab)"
|
|
if let equiv = ghostty.config.keyboardShortcut(for: action) {
|
|
window.keyEquivalent2 = "\(equiv)"
|
|
} else {
|
|
window.keyEquivalent2 = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy
|
|
if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] {
|
|
for (tab, window) in zip(1..., windows) {
|
|
// We need to clear any windows beyond this because they have had
|
|
// a keyEquivalent set previously.
|
|
guard tab <= 9 else {
|
|
window.keyEquivalent = ""
|
|
continue
|
|
}
|
|
|
|
let action = "goto_tab:\(tab)"
|
|
if let equiv = ghostty.config.keyboardShortcut(for: action) {
|
|
window.keyEquivalent = "\(equiv)"
|
|
} else {
|
|
window.keyEquivalent = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fixTabBar() {
|
|
// We do this to make sure that the tab bar will always re-composite. If we don't,
|
|
// then the it will "drag" pieces of the background with it when a transparent
|
|
// window is moved around.
|
|
//
|
|
// There might be a better way to make the tab bar "un-lazy", but I can't find it.
|
|
if let window = window, !window.isOpaque {
|
|
window.isOpaque = true
|
|
window.isOpaque = false
|
|
}
|
|
}
|
|
|
|
@objc private func onFrameDidChange(_ notification: NSNotification) {
|
|
// This is a huge hack to set the proper shortcut for tab selection
|
|
// on tab reordering using the mouse. There is no event, delegate, etc.
|
|
// as far as I can tell for when a tab is manually reordered with the
|
|
// mouse in a macOS-native tab group, so the way we detect it is setting
|
|
// the accessoryView "postsFrameChangedNotification" to true, listening
|
|
// for the view frame to change, comparing the windows list, and
|
|
// relabeling the tabs.
|
|
guard tabListenForFrame else { return }
|
|
guard let v = self.window?.tabbedWindows?.hashValue else { return }
|
|
guard tabWindowsHash != v else { return }
|
|
tabWindowsHash = v
|
|
self.relabelTabs()
|
|
}
|
|
|
|
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
|
// Let our window handle its own appearance
|
|
if let window = window as? TerminalWindow {
|
|
// Sync our zoom state for splits
|
|
window.surfaceIsZoomed2 = surfaceTree.zoomed != nil
|
|
|
|
// Set the font for the window and tab titles.
|
|
if let titleFontName = surfaceConfig.windowTitleFontFamily {
|
|
window.titlebarFont2 = NSFont(name: titleFontName, size: NSFont.systemFontSize)
|
|
} else {
|
|
window.titlebarFont2 = nil
|
|
}
|
|
|
|
// Call this last in case it uses any of the properties above.
|
|
window.syncAppearance(surfaceConfig)
|
|
}
|
|
|
|
guard let window else { return }
|
|
|
|
if let window = window as? LegacyTerminalWindow {
|
|
// Update our window light/darkness based on our updated background color
|
|
window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
|
|
|
|
// Sync our zoom state for splits
|
|
window.surfaceIsZoomed = surfaceTree.zoomed != nil
|
|
|
|
// Set the font for the window and tab titles.
|
|
if let titleFontName = surfaceConfig.windowTitleFontFamily {
|
|
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
|
|
} else {
|
|
window.titlebarFont = nil
|
|
}
|
|
}
|
|
|
|
// If our window is not visible, then we do nothing. Some things such as blurring
|
|
// have no effect if the window is not visible. Ultimately, we'll have this called
|
|
// at some point when a surface becomes focused.
|
|
guard window.isVisible else { return }
|
|
|
|
guard let window = window as? LegacyTerminalWindow, window.hasStyledTabs else { return }
|
|
|
|
// Our background color depends on if our focused surface borders the top or not.
|
|
// If it does, we match the focused surface. If it doesn't, we use the app
|
|
// configuration.
|
|
let backgroundColor: OSColor
|
|
if !surfaceTree.isEmpty {
|
|
if let focusedSurface = focusedSurface,
|
|
let treeRoot = surfaceTree.root,
|
|
let focusedNode = treeRoot.node(view: focusedSurface),
|
|
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
|
|
// Similar to above, an alpha component of "0" causes compositor issues, so
|
|
// we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
|
|
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
|
|
} else {
|
|
// We don't have a focused surface or our surface doesn't border the
|
|
// top. We choose to match the color of the top-left most surface.
|
|
let topLeftSurface = surfaceTree.root?.leftmostLeaf()
|
|
backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor)
|
|
}
|
|
} else {
|
|
backgroundColor = OSColor(self.derivedConfig.backgroundColor)
|
|
}
|
|
window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity)
|
|
|
|
if (window.isOpaque) {
|
|
// Bg color is only synced if we have no transparency. This is because
|
|
// the transparency is handled at the surface level (window.backgroundColor
|
|
// ignores alpha components)
|
|
window.backgroundColor = backgroundColor
|
|
|
|
// If there is transparency, calling this will make the titlebar opaque
|
|
// so we only call this if we are opaque.
|
|
window.updateTabBar()
|
|
}
|
|
}
|
|
|
|
/// Returns the default size of the window. This is contextual based on the focused surface because
|
|
/// the focused surface may specify a different default size than others.
|
|
private var defaultSize: NSRect? {
|
|
guard let screen = window?.screen ?? NSScreen.main else { return nil }
|
|
|
|
if derivedConfig.maximize {
|
|
return screen.visibleFrame
|
|
} else if let focusedSurface,
|
|
let initialSize = focusedSurface.initialSize {
|
|
// Get the current frame of the window
|
|
guard var frame = window?.frame else { return nil }
|
|
|
|
// Calculate the chrome size (window size minus view size)
|
|
let chromeWidth = frame.size.width - focusedSurface.frame.size.width
|
|
let chromeHeight = frame.size.height - focusedSurface.frame.size.height
|
|
|
|
// Calculate the new width and height, clamping to the screen's size
|
|
let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width)
|
|
let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height)
|
|
|
|
// Update the frame size while keeping the window's position intact
|
|
frame.size.width = newWidth
|
|
frame.size.height = newHeight
|
|
|
|
// Ensure the window doesn't go outside the screen boundaries
|
|
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
|
|
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
|
|
|
|
return frame
|
|
}
|
|
|
|
guard let initialFrame else { return nil }
|
|
guard var frame = window?.frame else { return nil }
|
|
|
|
// Calculate the new width and height, clamping to the screen's size
|
|
let newWidth = min(initialFrame.size.width, screen.visibleFrame.width)
|
|
let newHeight = min(initialFrame.size.height, screen.visibleFrame.height)
|
|
|
|
// Update the frame size while keeping the window's position intact
|
|
frame.size.width = newWidth
|
|
frame.size.height = newHeight
|
|
|
|
// Ensure the window doesn't go outside the screen boundaries
|
|
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
|
|
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
|
|
|
|
return frame
|
|
}
|
|
|
|
/// This is called anytime a node in the surface tree is being removed.
|
|
override func closeSurfaceNode(
|
|
_ 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.closeSurfaceNode(node, withConfirmation: withConfirmation)
|
|
return
|
|
}
|
|
|
|
// More than 1 window means we have tabs and we're closing a tab
|
|
if window?.tabGroup?.windows.count ?? 0 > 1 {
|
|
closeTab(nil)
|
|
return
|
|
}
|
|
|
|
// 1 window, closing the window
|
|
closeWindow(nil)
|
|
}
|
|
|
|
private func closeTabImmediately() {
|
|
guard let window = window else { return }
|
|
guard let tabGroup = window.tabGroup,
|
|
tabGroup.windows.count > 1 else {
|
|
closeWindowImmediately()
|
|
return
|
|
}
|
|
|
|
// Undo
|
|
if let undoManager, let undoState {
|
|
// Register undo action to restore the tab
|
|
undoManager.setActionName("Close Tab")
|
|
undoManager.registerUndo(
|
|
withTarget: ghostty,
|
|
expiresAfter: undoExpiration
|
|
) { ghostty in
|
|
let newController = TerminalController(ghostty, with: undoState)
|
|
|
|
// Register redo action
|
|
undoManager.registerUndo(
|
|
withTarget: newController,
|
|
expiresAfter: newController.undoExpiration
|
|
) { target in
|
|
target.closeTabImmediately()
|
|
}
|
|
}
|
|
}
|
|
|
|
window.close()
|
|
}
|
|
|
|
/// Closes the current window (including any other tabs) immediately and without
|
|
/// confirmation. This will setup proper undo state so the action can be undone.
|
|
private func closeWindowImmediately() {
|
|
guard let window = window else { return }
|
|
|
|
registerUndoForCloseWindow()
|
|
|
|
if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 {
|
|
tabGroup.windows.forEach { window in
|
|
// Clear out the surfacetree to ensure there is no undo state.
|
|
// This prevents unnecessary undos registered since AppKit may
|
|
// process them on later ticks so we can't just disable undo registration.
|
|
if let controller = window.windowController as? TerminalController {
|
|
controller.surfaceTree = .init()
|
|
}
|
|
|
|
window.close()
|
|
}
|
|
} else {
|
|
window.close()
|
|
}
|
|
}
|
|
|
|
/// Registers undo for closing window(s), handling both single windows and tab groups.
|
|
private func registerUndoForCloseWindow() {
|
|
guard let undoManager, undoManager.isUndoRegistrationEnabled else { return }
|
|
guard let window else { return }
|
|
|
|
// If we don't have a tab group or we don't have multiple tabs, then
|
|
// do a normal single window close.
|
|
guard let tabGroup = window.tabGroup,
|
|
tabGroup.windows.count > 1 else {
|
|
// No tabs, just save this window's state
|
|
if let undoState {
|
|
// Register undo action to restore the window
|
|
undoManager.setActionName("Close Window")
|
|
undoManager.registerUndo(
|
|
withTarget: ghostty,
|
|
expiresAfter: undoExpiration) { ghostty in
|
|
// Restore the undo state
|
|
let newController = TerminalController(ghostty, with: undoState)
|
|
|
|
// Register redo action
|
|
undoManager.registerUndo(
|
|
withTarget: newController,
|
|
expiresAfter: newController.undoExpiration) { target in
|
|
target.closeWindowImmediately()
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Multiple windows in tab group - collect all undo states in sorted order
|
|
// by tab ordering. Also track which window was key.
|
|
let undoStates = tabGroup.windows
|
|
.compactMap { tabWindow -> UndoState? in
|
|
guard let controller = tabWindow.windowController as? TerminalController,
|
|
var undoState = controller.undoState else { return nil }
|
|
// Clear the tab group reference since it is unneeded. It should be
|
|
// garbage collected but we want to be extra sure we don't try to
|
|
// restore into it because we're going to recreate it.
|
|
undoState.tabGroup = nil
|
|
return undoState
|
|
}
|
|
.sorted { (lhs, rhs) in
|
|
switch (lhs.tabIndex, rhs.tabIndex) {
|
|
case let (l?, r?): return l < r
|
|
case (_?, nil): return true
|
|
case (nil, _?): return false
|
|
case (nil, nil): return true
|
|
}
|
|
}
|
|
|
|
// Find the index of the key window in our sorted states. This is a bit verbose
|
|
// but we only need this for this style of undo so we don't want to add it to
|
|
// UndoState.
|
|
let keyWindowIndex: Int?
|
|
if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }),
|
|
let keyController = keyWindow.windowController as? TerminalController,
|
|
let keyUndoState = keyController.undoState {
|
|
keyWindowIndex = undoStates.firstIndex {
|
|
$0.tabIndex == keyUndoState.tabIndex }
|
|
} else {
|
|
keyWindowIndex = nil
|
|
}
|
|
|
|
// Register undo action to restore all windows
|
|
guard !undoStates.isEmpty else { return }
|
|
|
|
undoManager.setActionName("Close Window")
|
|
undoManager.registerUndo(
|
|
withTarget: ghostty,
|
|
expiresAfter: undoExpiration
|
|
) { ghostty in
|
|
// Restore all windows in the tab group
|
|
let controllers = undoStates.map { undoState in
|
|
TerminalController(ghostty, with: undoState)
|
|
}
|
|
|
|
// The first controller becomes the parent window for all tabs.
|
|
// If we don't have a first controller (shouldn't be possible?)
|
|
// then we can't restore tabs.
|
|
guard let firstController = controllers.first else { return }
|
|
|
|
// Add all subsequent controllers as tabs to the first window
|
|
for controller in controllers.dropFirst() {
|
|
controller.showWindow(nil)
|
|
if let firstWindow = firstController.window,
|
|
let newWindow = controller.window {
|
|
firstWindow.addTabbedWindow(newWindow, ordered: .above)
|
|
}
|
|
}
|
|
|
|
// Make the appropriate window key. If we had a key window, restore it.
|
|
// Otherwise, make the last window key.
|
|
if let keyWindowIndex, keyWindowIndex < controllers.count {
|
|
controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil)
|
|
} else {
|
|
controllers.last?.window?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
// Register redo action on the first controller
|
|
undoManager.registerUndo(
|
|
withTarget: firstController,
|
|
expiresAfter: firstController.undoExpiration
|
|
) { target in
|
|
target.closeWindowImmediately()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Close all windows, asking for confirmation if necessary.
|
|
static func closeAllWindows() {
|
|
let needsConfirm: Bool = all.contains {
|
|
$0.surfaceTree.contains { $0.needsConfirmQuit }
|
|
}
|
|
|
|
if (!needsConfirm) {
|
|
closeAllWindowsImmediately()
|
|
return
|
|
}
|
|
|
|
// If we don't have a main window, we just close all windows because
|
|
// we have no window to show the modal on top of. I'm sure there's a way
|
|
// to do an app-level alert but I don't know how and this case should never
|
|
// really happen.
|
|
guard let alertWindow = preferredParent?.window else {
|
|
closeAllWindowsImmediately()
|
|
return
|
|
}
|
|
|
|
// If we need confirmation by any, show one confirmation for all windows
|
|
let alert = NSAlert()
|
|
alert.messageText = "Close All Windows?"
|
|
alert.informativeText = "All terminal sessions will be terminated."
|
|
alert.addButton(withTitle: "Close All Windows")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.alertStyle = .warning
|
|
alert.beginSheetModal(for: alertWindow, completionHandler: { response in
|
|
if (response == .alertFirstButtonReturn) {
|
|
closeAllWindowsImmediately()
|
|
}
|
|
})
|
|
}
|
|
|
|
static private func closeAllWindowsImmediately() {
|
|
let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
|
|
undoManager?.beginUndoGrouping()
|
|
all.forEach { $0.closeWindowImmediately() }
|
|
undoManager?.setActionName("Close All Windows")
|
|
undoManager?.endUndoGrouping()
|
|
}
|
|
|
|
// MARK: Undo/Redo
|
|
|
|
/// The state that we require to recreate a TerminalController from an undo.
|
|
struct UndoState {
|
|
let frame: NSRect
|
|
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
|
let focusedSurface: UUID?
|
|
let tabIndex: Int?
|
|
weak var tabGroup: NSWindowTabGroup?
|
|
}
|
|
|
|
convenience init(_ ghostty: Ghostty.App,
|
|
with undoState: UndoState
|
|
) {
|
|
self.init(ghostty, withSurfaceTree: undoState.surfaceTree)
|
|
|
|
// Show the window and restore its frame
|
|
showWindow(nil)
|
|
if let window {
|
|
window.setFrame(undoState.frame, display: true)
|
|
|
|
// If we have a tab group and index, restore the tab to its original position
|
|
if let tabGroup = undoState.tabGroup,
|
|
let tabIndex = undoState.tabIndex {
|
|
if tabIndex < tabGroup.windows.count {
|
|
// Find the window that is currently at that index
|
|
let currentWindow = tabGroup.windows[tabIndex]
|
|
currentWindow.addTabbedWindow(window, ordered: .below)
|
|
} else {
|
|
tabGroup.windows.last?.addTabbedWindow(window, ordered: .above)
|
|
}
|
|
|
|
// Make it the key window
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
// Restore focus to the previously focused surface
|
|
if let focusedUUID = undoState.focusedSurface,
|
|
let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) {
|
|
DispatchQueue.main.async {
|
|
Ghostty.moveFocus(to: focusTarget, from: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The current undo state for this controller
|
|
var undoState: UndoState? {
|
|
guard let window else { return nil }
|
|
guard !surfaceTree.isEmpty else { return nil }
|
|
return .init(
|
|
frame: window.frame,
|
|
surfaceTree: surfaceTree,
|
|
focusedSurface: focusedSurface?.uuid,
|
|
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
|
|
tabGroup: window.tabGroup)
|
|
}
|
|
|
|
//MARK: - NSWindowController
|
|
|
|
override func windowWillLoad() {
|
|
// We do NOT want to cascade because we handle this manually from the manager.
|
|
shouldCascadeWindows = false
|
|
}
|
|
|
|
override func windowDidLoad() {
|
|
super.windowDidLoad()
|
|
guard let window else { return }
|
|
|
|
// Store our initial frame so we can know our default later.
|
|
initialFrame = window.frame
|
|
|
|
// I copy this because we may change the source in the future but also because
|
|
// I regularly audit our codebase for "ghostty.config" access because generally
|
|
// you shouldn't use it. Its safe in this case because for a new window we should
|
|
// use whatever the latest app-level config is.
|
|
let config = ghostty.config
|
|
|
|
// Setting all three of these is required for restoration to work.
|
|
window.isRestorable = restorable
|
|
if (restorable) {
|
|
window.restorationClass = TerminalWindowRestoration.self
|
|
window.identifier = .init(String(describing: TerminalWindowRestoration.self))
|
|
}
|
|
|
|
// If we have only a single surface (no splits) and there is a default size then
|
|
// we should resize to that default size.
|
|
if case let .leaf(view) = surfaceTree.root {
|
|
// If this is our first surface then our focused surface will be nil
|
|
// so we force the focused surface to the leaf.
|
|
focusedSurface = view
|
|
|
|
if let defaultSize {
|
|
window.setFrame(defaultSize, display: true)
|
|
}
|
|
}
|
|
|
|
// TODO: remove
|
|
if let window = window as? LegacyTerminalWindow {
|
|
// Handle titlebar tabs config option. Something about what we do while setting up the
|
|
// titlebar tabs interferes with the window restore process unless window.tabbingMode
|
|
// is set to .preferred, so we set it, and switch back to automatic as soon as we can.
|
|
if (config.macosTitlebarStyle == "tabs") {
|
|
window.tabbingMode = .preferred
|
|
window.titlebarTabs = true
|
|
DispatchQueue.main.async {
|
|
window.tabbingMode = .automatic
|
|
}
|
|
} else if (config.macosTitlebarStyle == "transparent") {
|
|
window.transparentTabs = true
|
|
}
|
|
|
|
if window.hasStyledTabs {
|
|
// Set the background color of the window
|
|
let backgroundColor = NSColor(config.backgroundColor)
|
|
window.backgroundColor = backgroundColor
|
|
|
|
// This makes sure our titlebar renders correctly when there is a transparent background
|
|
window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
|
|
}
|
|
}
|
|
|
|
// Initialize our content view to the SwiftUI root
|
|
window.contentView = NSHostingView(rootView: TerminalView(
|
|
ghostty: self.ghostty,
|
|
viewModel: self,
|
|
delegate: self
|
|
))
|
|
|
|
// In various situations, macOS automatically tabs new windows. Ghostty handles
|
|
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
|
|
// it.
|
|
//
|
|
// Example scenarios where this happens:
|
|
// - When the system user tabbing preference is "always"
|
|
// - When the "+" button in the tab bar is clicked
|
|
//
|
|
// We don't run this logic in fullscreen because in fullscreen this will end up
|
|
// removing the window and putting it into its own dedicated fullscreen, which is not
|
|
// the expected or desired behavior of anyone I've found.
|
|
if (!window.styleMask.contains(.fullScreen)) {
|
|
// If we have more than 1 window in our tab group we know we're a new window.
|
|
// Since Ghostty manages tabbing manually this will never be more than one
|
|
// at this point in the AppKit lifecycle (we add to the group after this).
|
|
if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 {
|
|
window.tabGroup?.removeWindow(window)
|
|
}
|
|
}
|
|
|
|
// Apply any additional appearance-related properties to the new window. We
|
|
// apply this based on the root config but change it later based on surface
|
|
// config (see focused surface change callback).
|
|
syncAppearance(.init(config))
|
|
}
|
|
|
|
// Shows the "+" button in the tab bar, responds to that click.
|
|
override func newWindowForTab(_ sender: Any?) {
|
|
// Trigger the ghostty core event logic for a new tab.
|
|
guard let surface = self.focusedSurface?.surface else { return }
|
|
ghostty.newTab(surface: surface)
|
|
}
|
|
|
|
//MARK: - NSWindowDelegate
|
|
|
|
override func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
// If we have tabs, then this should only close the tab.
|
|
if window?.tabGroup?.windows.count ?? 0 > 1 {
|
|
closeTab(sender)
|
|
} else {
|
|
closeWindow(sender)
|
|
}
|
|
|
|
// We will always explicitly close the window using the above
|
|
return false
|
|
}
|
|
|
|
override func windowWillClose(_ notification: Notification) {
|
|
super.windowWillClose(notification)
|
|
self.relabelTabs()
|
|
|
|
// If we remove a window, we reset the cascade point to the key window so that
|
|
// the next window cascade's from that one.
|
|
if let focusedWindow = NSApplication.shared.keyWindow {
|
|
// If we are NOT the focused window, then we are a tabbed window. If we
|
|
// are closing a tabbed window, we want to set the cascade point to be
|
|
// the next cascade point from this window.
|
|
if focusedWindow != window {
|
|
// The cascadeTopLeft call below should NOT move the window. Starting with
|
|
// macOS 15, we found that specifically when used with the new window snapping
|
|
// features of macOS 15, this WOULD move the frame. So we keep track of the
|
|
// old frame and restore it if necessary. Issue:
|
|
// https://github.com/ghostty-org/ghostty/issues/2565
|
|
let oldFrame = focusedWindow.frame
|
|
|
|
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
|
|
|
|
if focusedWindow.frame != oldFrame {
|
|
focusedWindow.setFrame(oldFrame, display: true)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// If we are the focused window, then we set the last cascade point to
|
|
// our own frame so that it shows up in the same spot.
|
|
let frame = focusedWindow.frame
|
|
Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
|
|
}
|
|
}
|
|
|
|
override func windowDidBecomeKey(_ notification: Notification) {
|
|
super.windowDidBecomeKey(notification)
|
|
self.relabelTabs()
|
|
self.fixTabBar()
|
|
}
|
|
|
|
override func windowDidMove(_ notification: Notification) {
|
|
super.windowDidMove(notification)
|
|
self.fixTabBar()
|
|
|
|
// Whenever we move save our last position for the next start.
|
|
if let window {
|
|
LastWindowPosition.shared.save(window)
|
|
}
|
|
}
|
|
|
|
func windowDidBecomeMain(_ notification: Notification) {
|
|
// Whenever we get focused, use that as our last window position for
|
|
// restart. This differs from Terminal.app but matches iTerm2 behavior
|
|
// and I think its sensible.
|
|
if let window {
|
|
LastWindowPosition.shared.save(window)
|
|
}
|
|
}
|
|
|
|
// Called when the window will be encoded. We handle the data encoding here in the
|
|
// window controller.
|
|
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
|
|
let data = TerminalRestorableState(from: self)
|
|
data.encode(with: state)
|
|
}
|
|
|
|
// MARK: First Responder
|
|
|
|
@IBAction func newWindow(_ sender: Any?) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.newWindow(surface: surface)
|
|
}
|
|
|
|
@IBAction func newTab(_ sender: Any?) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.newTab(surface: surface)
|
|
}
|
|
|
|
@IBAction func closeTab(_ sender: Any?) {
|
|
guard let window = window else { return }
|
|
guard window.tabGroup?.windows.count ?? 0 > 1 else {
|
|
closeWindow(sender)
|
|
return
|
|
}
|
|
|
|
guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else {
|
|
closeTabImmediately()
|
|
return
|
|
}
|
|
|
|
confirmClose(
|
|
messageText: "Close Tab?",
|
|
informativeText: "The terminal still has a running process. If you close the tab the process will be killed."
|
|
) {
|
|
self.closeTabImmediately()
|
|
}
|
|
}
|
|
|
|
@IBAction func returnToDefaultSize(_ sender: Any?) {
|
|
guard let defaultSize else { return }
|
|
window?.setFrame(defaultSize, display: true)
|
|
}
|
|
|
|
@IBAction override func closeWindow(_ sender: Any?) {
|
|
guard let window = window else { return }
|
|
guard let tabGroup = window.tabGroup else {
|
|
// No tabs, no tab group, just perform a normal close.
|
|
closeWindowImmediately()
|
|
return
|
|
}
|
|
|
|
// If have one window then we just do a normal close
|
|
if tabGroup.windows.count == 1 {
|
|
closeWindowImmediately()
|
|
return
|
|
}
|
|
|
|
// Check if any windows require close confirmation.
|
|
let needsConfirm = tabGroup.windows.contains { tabWindow in
|
|
guard let controller = tabWindow.windowController as? TerminalController else {
|
|
return false
|
|
}
|
|
return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
|
|
}
|
|
|
|
// If none need confirmation then we can just close all the windows.
|
|
if !needsConfirm {
|
|
closeWindowImmediately()
|
|
return
|
|
}
|
|
|
|
confirmClose(
|
|
messageText: "Close Window?",
|
|
informativeText: "All terminal sessions in this window will be terminated."
|
|
) {
|
|
self.closeWindowImmediately()
|
|
}
|
|
}
|
|
|
|
@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: - TerminalViewDelegate
|
|
|
|
override func titleDidChange(to: String) {
|
|
super.titleDidChange(to: to)
|
|
|
|
guard let window = window as? LegacyTerminalWindow else { return }
|
|
|
|
// Custom toolbar-based title used when titlebar tabs are enabled.
|
|
if let toolbar = window.toolbar as? TerminalToolbar {
|
|
if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
|
|
// Updating the title text as above automatically reveals the
|
|
// native title view in macOS 15.0 and above. Since we're using
|
|
// a custom view instead, we need to re-hide it.
|
|
window.titleVisibility = .hidden
|
|
}
|
|
toolbar.titleText = to
|
|
}
|
|
}
|
|
|
|
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
|
|
super.focusedSurfaceDidChange(to: to)
|
|
|
|
// We always cancel our event listener
|
|
surfaceAppearanceCancellables.removeAll()
|
|
|
|
// When our focus changes, we update our window appearance based on the
|
|
// currently focused surface.
|
|
guard let focusedSurface else { return }
|
|
syncAppearance(focusedSurface.derivedConfig)
|
|
|
|
// We also want to get notified of certain changes to update our appearance.
|
|
focusedSurface.$derivedConfig
|
|
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
|
|
.store(in: &surfaceAppearanceCancellables)
|
|
focusedSurface.$backgroundColor
|
|
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
|
|
.store(in: &surfaceAppearanceCancellables)
|
|
}
|
|
|
|
private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) {
|
|
guard let surface else { return }
|
|
DispatchQueue.main.async { [weak self, weak surface] in
|
|
guard let surface else { return }
|
|
guard let self else { return }
|
|
guard self.focusedSurface == surface else { return }
|
|
self.syncAppearance(surface.derivedConfig)
|
|
}
|
|
}
|
|
|
|
//MARK: - Notifications
|
|
|
|
@objc private func onMoveTab(notification: SwiftUI.Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard target == self.focusedSurface else { return }
|
|
guard let window = self.window else { return }
|
|
|
|
// Get the move action
|
|
guard let action = notification.userInfo?[Notification.Name.GhosttyMoveTabKey] as? Ghostty.Action.MoveTab else { return }
|
|
guard action.amount != 0 else { return }
|
|
|
|
// Determine our current selected index
|
|
guard let windowController = window.windowController else { return }
|
|
guard let tabGroup = windowController.window?.tabGroup else { return }
|
|
guard let selectedWindow = tabGroup.selectedWindow else { return }
|
|
let tabbedWindows = tabGroup.windows
|
|
guard tabbedWindows.count > 0 else { return }
|
|
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
|
|
|
|
// Determine the final index we want to insert our tab
|
|
let finalIndex: Int
|
|
if action.amount < 0 {
|
|
finalIndex = selectedIndex - min(selectedIndex, -action.amount)
|
|
} else {
|
|
let remaining: Int = tabbedWindows.count - 1 - selectedIndex
|
|
finalIndex = selectedIndex + min(remaining, action.amount)
|
|
}
|
|
|
|
// If our index is the same we do nothing
|
|
guard finalIndex != selectedIndex else { return }
|
|
|
|
// Get our target window
|
|
let targetWindow = tabbedWindows[finalIndex]
|
|
|
|
// Begin a group of window operations to minimize visual updates
|
|
NSAnimationContext.beginGrouping()
|
|
NSAnimationContext.current.duration = 0
|
|
|
|
// Remove and re-add the window in the correct position
|
|
tabGroup.removeWindow(selectedWindow)
|
|
targetWindow.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above)
|
|
|
|
// Ensure our window remains selected
|
|
selectedWindow.makeKey()
|
|
|
|
NSAnimationContext.endGrouping()
|
|
}
|
|
|
|
@objc private func onGotoTab(notification: SwiftUI.Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard target == self.focusedSurface else { return }
|
|
guard let window = self.window else { return }
|
|
|
|
// Get the tab index from the notification
|
|
guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
|
|
guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return }
|
|
let tabIndex: Int32 = tabEnum.rawValue
|
|
|
|
guard let windowController = window.windowController else { return }
|
|
guard let tabGroup = windowController.window?.tabGroup else { return }
|
|
let tabbedWindows = tabGroup.windows
|
|
|
|
// This will be the index we want to actual go to
|
|
let finalIndex: Int
|
|
|
|
// An index that is invalid is used to signal some special values.
|
|
if (tabIndex <= 0) {
|
|
guard let selectedWindow = tabGroup.selectedWindow else { return }
|
|
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
|
|
|
|
if (tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue) {
|
|
if (selectedIndex == 0) {
|
|
finalIndex = tabbedWindows.count - 1
|
|
} else {
|
|
finalIndex = selectedIndex - 1
|
|
}
|
|
} else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) {
|
|
if (selectedIndex == tabbedWindows.count - 1) {
|
|
finalIndex = 0
|
|
} else {
|
|
finalIndex = selectedIndex + 1
|
|
}
|
|
} else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) {
|
|
finalIndex = tabbedWindows.count - 1
|
|
} else {
|
|
return
|
|
}
|
|
} else {
|
|
// The configured value is 1-indexed.
|
|
guard tabIndex >= 1 else { return }
|
|
|
|
// If our index is outside our boundary then we use the max
|
|
finalIndex = min(Int(tabIndex - 1), tabbedWindows.count - 1)
|
|
}
|
|
|
|
guard finalIndex >= 0 else { return }
|
|
let targetWindow = tabbedWindows[finalIndex]
|
|
targetWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
@objc private func onCloseTab(notification: SwiftUI.Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.contains(target) else { return }
|
|
closeTab(self)
|
|
}
|
|
|
|
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.contains(target) else { return }
|
|
closeWindow(self)
|
|
}
|
|
|
|
@objc private func onResetWindowSize(notification: SwiftUI.Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.contains(target) else { return }
|
|
returnToDefaultSize(nil)
|
|
}
|
|
|
|
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard target == self.focusedSurface else { return }
|
|
|
|
// Get the fullscreen mode we want to toggle
|
|
let fullscreenMode: FullscreenMode
|
|
if let any = notification.userInfo?[Ghostty.Notification.FullscreenModeKey],
|
|
let mode = any as? FullscreenMode {
|
|
fullscreenMode = mode
|
|
} else {
|
|
Ghostty.logger.warning("no fullscreen mode specified or invalid mode, doing nothing")
|
|
return
|
|
}
|
|
|
|
toggleFullscreen(mode: fullscreenMode)
|
|
}
|
|
|
|
struct DerivedConfig {
|
|
let backgroundColor: Color
|
|
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
|
let macosTitlebarStyle: String
|
|
let maximize: Bool
|
|
|
|
init() {
|
|
self.backgroundColor = Color(NSColor.windowBackgroundColor)
|
|
self.macosWindowButtons = .visible
|
|
self.macosTitlebarStyle = "system"
|
|
self.maximize = false
|
|
}
|
|
|
|
init(_ config: Ghostty.Config) {
|
|
self.backgroundColor = config.backgroundColor
|
|
self.macosWindowButtons = config.macosWindowButtons
|
|
self.macosTitlebarStyle = config.macosTitlebarStyle
|
|
self.maximize = config.maximize
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: NSMenuItemValidation
|
|
|
|
extension TerminalController: NSMenuItemValidation {
|
|
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
|
switch item.action {
|
|
case #selector(returnToDefaultSize):
|
|
guard let window else { return false }
|
|
|
|
// Native fullscreen windows can't revert to default size.
|
|
if window.styleMask.contains(.fullScreen) {
|
|
return false
|
|
}
|
|
|
|
// If we're fullscreen at all then we can't change size
|
|
if fullscreenStyle?.isFullscreen ?? false {
|
|
return false
|
|
}
|
|
|
|
// If our window is already the default size or we don't have a
|
|
// default size, then disable.
|
|
guard let defaultSize,
|
|
window.frame.size != .init(
|
|
width: defaultSize.size.width,
|
|
height: defaultSize.size.height
|
|
)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|