Skip to content
1 change: 1 addition & 0 deletions macos/Ghostty.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
Features/Terminal/ErrorView.swift,
Features/Terminal/TerminalController.swift,
Features/Terminal/TerminalRestorable.swift,
Features/Terminal/TerminalTabColor.swift,
Features/Terminal/TerminalView.swift,
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
"Features/Terminal/Window Styles/Terminal.xib",
Expand Down
80 changes: 43 additions & 37 deletions macos/Sources/Features/Terminal/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
/// 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> = []

Expand Down Expand Up @@ -148,7 +149,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr

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()
Expand Down Expand Up @@ -195,7 +196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
$0.window?.isMainWindow ?? false
} ?? lastMain ?? all.last
}

// The last controller to be main. We use this when paired with "preferredParent"
// to find the preferred window to attach new tabs, perform actions, etc. We
// always prefer the main window but if there isn't any (because we're triggered
Expand Down Expand Up @@ -517,13 +518,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
fromTopLeftOffsetX: CGFloat(x),
offsetY: CGFloat(y),
windowSize: frame.size)

// Clamp the origin to ensure the window stays fully visible on screen
var safeOrigin = origin
let vf = screen.visibleFrame
safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width)
safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height)

// Return our new origin
var result = frame
result.origin = safeOrigin
Expand Down Expand Up @@ -558,7 +559,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeWindowImmediately()
return
}

// Undo
if let undoManager, let undoState {
// Register undo action to restore the tab
Expand All @@ -579,23 +580,23 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
}

window.close()
}

private func closeOtherTabsImmediately() {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard tabGroup.windows.count > 1 else { return }

// Start an undo grouping
if let undoManager {
undoManager.beginUndoGrouping()
}
defer {
undoManager?.endUndoGrouping()
}

// Iterate through all tabs except the current one.
for window in tabGroup.windows where window != self.window {
// We ignore any non-terminal tabs. They don't currently exist and we can't
Expand All @@ -607,10 +608,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
controller.closeTabImmediately(registerRedo: false)
}
}

if let undoManager {
undoManager.setActionName("Close Other Tabs")

// We need to register an undo that refocuses this window. Otherwise, the
// undo operation above for each tab will steal focus.
undoManager.registerUndo(
Expand All @@ -620,7 +621,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
DispatchQueue.main.async {
target.window?.makeKeyAndOrderFront(nil)
}

// Register redo action
undoManager.registerUndo(
withTarget: target,
Expand Down Expand Up @@ -746,7 +747,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
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.
Expand All @@ -772,12 +773,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
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)
Expand All @@ -786,7 +787,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
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 {
Expand Down Expand Up @@ -852,6 +853,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let focusedSurface: UUID?
let tabIndex: Int?
weak var tabGroup: NSWindowTabGroup?
let tabColor: TerminalTabColor
}

convenience init(_ ghostty: Ghostty.App,
Expand All @@ -863,6 +865,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
showWindow(nil)
if let window {
window.setFrame(undoState.frame, display: true)
if let terminalWindow = window as? TerminalWindow {
terminalWindow.tabColor = undoState.tabColor
}

// If we have a tab group and index, restore the tab to its original position
if let tabGroup = undoState.tabGroup,
Expand Down Expand Up @@ -898,7 +903,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
surfaceTree: surfaceTree,
focusedSurface: focusedSurface?.id,
tabIndex: window.tabGroup?.windows.firstIndex(of: window),
tabGroup: window.tabGroup)
tabGroup: window.tabGroup,
tabColor: (window as? TerminalWindow)?.tabColor ?? .none)
}

//MARK: - NSWindowController
Expand Down Expand Up @@ -939,14 +945,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
viewModel: self,
delegate: self,
))

// If we have a default size, we want to apply it.
if let defaultSize {
switch (defaultSize) {
case .frame:
// Frames can be applied immediately
defaultSize.apply(to: window)

case .contentIntrinsicSize:
// Content intrinsic size requires a short delay so that AppKit
// can layout our SwiftUI views.
Expand All @@ -956,13 +962,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
}

// Store our initial frame so we can know our default later. This MUST
// be after the defaultSize call above so that we don't re-apply our frame.
// Note: we probably want to set this on the first frame change or something
// so it respects cascade.
initialFrame = window.frame

// 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.
Expand Down Expand Up @@ -1073,7 +1079,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if let window {
LastWindowPosition.shared.save(window)
}

// Remember our last main
Self.lastMain = self
}
Expand Down Expand Up @@ -1120,7 +1126,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
@IBAction func closeOtherTabs(_ sender: Any?) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }

// If we only have one window then we have no other tabs to close
guard tabGroup.windows.count > 1 else { return }

Expand Down Expand Up @@ -1219,7 +1225,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}

//MARK: - TerminalViewDelegate

override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
super.focusedSurfaceDidChange(to: to)

Expand Down Expand Up @@ -1283,7 +1289,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr

// Get our target window
let targetWindow = tabbedWindows[finalIndex]

// Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs.
// I believe this is due to messed up constraints for our hacky tab bar. I'd like to
// find a better workaround. For now, this improves things dramatically.
Expand All @@ -1296,7 +1302,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
DispatchQueue.main.async {
selectedWindow.makeKey()
}

return
}
}
Expand Down Expand Up @@ -1451,24 +1457,24 @@ extension TerminalController {
guard let window, let tabGroup = window.tabGroup else { return false }
guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false }
return tabGroup.windows.enumerated().contains { $0.offset > currentIndex }

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.
return defaultSize?.isChanged(for: window) ?? false

default:
return super.validateMenuItem(item)
}
Expand All @@ -1484,10 +1490,10 @@ extension TerminalController {
enum DefaultSize {
/// A frame, set with `window.setFrame`
case frame(NSRect)

/// A content size, set with `window.setContentSize`
case contentIntrinsicSize

func isChanged(for window: NSWindow) -> Bool {
switch self {
case .frame(let rect):
Expand All @@ -1496,11 +1502,11 @@ extension TerminalController {
guard let view = window.contentView else {
return false
}

return view.frame.size != view.intrinsicContentSize
}
}

func apply(to window: NSWindow) {
switch self {
case .frame(let rect):
Expand All @@ -1509,13 +1515,13 @@ extension TerminalController {
guard let size = window.contentView?.intrinsicContentSize else {
return
}

window.setContentSize(size)
window.constrainToScreen()
}
}
}

private var defaultSize: DefaultSize? {
if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main {
// Maximize takes priority, we take up the full screen we're on.
Expand Down
8 changes: 7 additions & 1 deletion macos/Sources/Features/Terminal/TerminalRestorable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import Cocoa
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 5
static let version: Int = 6

let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let effectiveFullscreenMode: FullscreenMode?
let tabColor: TerminalTabColor

init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
}

init?(coder aDecoder: NSCoder) {
Expand All @@ -31,6 +33,7 @@ class TerminalRestorableState: Codable {
self.surfaceTree = v.value.surfaceTree
self.focusedSurface = v.value.focusedSurface
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
self.tabColor = v.value.tabColor
}

func encode(with coder: NSCoder) {
Expand Down Expand Up @@ -94,6 +97,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
return
}

// Restore our tab color
(window as? TerminalWindow)?.tabColor = state.tabColor

// Setup our restored state on the controller
// Find the focused surface in surfaceTree
if let focusedStr = state.focusedSurface {
Expand Down
Loading