import Cocoa import GhosttyKit /// The fullscreen modes we support define how the fullscreen behaves. enum FullscreenMode { case native case nonNative case nonNativeVisibleMenu /// Initializes the fullscreen style implementation for the mode. This will not toggle any /// fullscreen properties. This may fail if the window isn't configured properly for a given /// mode. func style(for window: NSWindow) -> FullscreenStyle? { switch self { case .native: return NativeFullscreen(window) case .nonNative: return NonNativeFullscreen(window) case .nonNativeVisibleMenu: return NonNativeFullscreenVisibleMenu(window) } } } /// Protocol that must be implemented by all fullscreen styles. protocol FullscreenStyle { var delegate: FullscreenDelegate? { get set } var isFullscreen: Bool { get } var supportsTabs: Bool { get } init?(_ window: NSWindow) func enter() func exit() } /// Delegate that can be implemented for fullscreen implementations. protocol FullscreenDelegate: AnyObject { /// Called whenever the fullscreen state changed. You can call isFullscreen to see /// the current state. func fullscreenDidChange() } extension FullscreenDelegate { func fullscreenDidChange() {} } /// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own. class FullscreenBase { let window: NSWindow weak var delegate: FullscreenDelegate? required init?(_ window: NSWindow) { self.window = window // We want to trigger delegate methods on window native fullscreen // changes (didEnterFullScreenNotification, etc.) no matter what our // fullscreen style is. let center = NotificationCenter.default center.addObserver( self, selector: #selector(didEnterFullScreenNotification), name: NSWindow.didEnterFullScreenNotification, object: window) center.addObserver( self, selector: #selector(didExitFullScreenNotification), name: NSWindow.didExitFullScreenNotification, object: window) } deinit { NotificationCenter.default.removeObserver(self) } @objc private func didEnterFullScreenNotification(_ notification: Notification) { delegate?.fullscreenDidChange() } @objc private func didExitFullScreenNotification(_ notification: Notification) { delegate?.fullscreenDidChange() } } /// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen /// button on regular titlebars. class NativeFullscreen: FullscreenBase, FullscreenStyle { var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var supportsTabs: Bool { true } required init?(_ window: NSWindow) { // TODO: There are many requirements for native fullscreen we should // check here such as the stylemask. super.init(window) } func enter() { guard !isFullscreen else { return } // The titlebar separator shows up erroneously in fullscreen if the tab bar // is made to appear and then disappear by opening and then closing a tab. // We get rid of the separator while in fullscreen to prevent this. window.titlebarSeparatorStyle = .none // Enter fullscreen window.toggleFullScreen(self) // Note: we don't call our delegate here because the base class // will always trigger the delegate on native fullscreen notifications // and we don't want to double notify. } func exit() { guard isFullscreen else { return } // Restore titlebar separator style. See enter for explanation. window.titlebarSeparatorStyle = .automatic window.toggleFullScreen(nil) // Note: we don't call our delegate here because the base class // will always trigger the delegate on native fullscreen notifications // and we don't want to double notify. } } class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Non-native fullscreen never supports tabs because tabs require // the "titled" style and we don't have it for non-native fullscreen. var supportsTabs: Bool { false } // isFullscreen is dependent on if we have saved state currently. We // could one day try to do fancier stuff like inspecting the window // state but there isn't currently a need for it. var isFullscreen: Bool { savedState != nil } // The default properties. Subclasses can override this to change // behavior. This shouldn't be written to (only computed) because // it must be immutable. var properties: Properties { Properties() } struct Properties { var hideMenu: Bool = true } private var savedState: SavedState? func enter() { // If we are in fullscreen we don't do it again. guard !isFullscreen else { return } // If we are in native fullscreen, exit native fullscreen. This is counter // intuitive but if we entered native fullscreen (through the green max button // or an external event) and we press the fullscreen keybind, we probably // want to EXIT fullscreen. if window.styleMask.contains(.fullScreen) { window.toggleFullScreen(nil) return } // This is the screen that we're going to go fullscreen on. We use the // screen the window is currently on. guard let screen = window.screen else { return } // Save the state that we need to exit again guard let savedState = SavedState(window) else { return } self.savedState = savedState // We hide the dock if the window is on a screen with the dock. // This is crazy but at least on macOS 15.0, you must hide the dock // FIRST then hide the menu. If you do the opposite, it does not // work. if (savedState.dock) { hideDock() } // Hide the menu if requested if (properties.hideMenu) { hideMenu() } // When we change screens we need to redo everything. NotificationCenter.default.addObserver( self, selector: #selector(windowDidChangeScreen), name: NSWindow.didChangeScreenNotification, object: window) // Being untitled let's our content take up the full frame. window.styleMask.remove(.titled) // Focus window window.makeKeyAndOrderFront(nil) // Set frame to screen size, accounting for any elements such as the menu bar. // We do this async so that all the style edits above (title removal, dock // hide, menu hide, etc.) take effect. This fixes: // https://github.com/ghostty-org/ghostty/issues/1996 DispatchQueue.main.async { self.window.setFrame(self.fullscreenFrame(screen), display: true) self.delegate?.fullscreenDidChange() } } func exit() { guard isFullscreen else { return } guard let savedState else { return } // Remove all our notifications. We remove them one by one because // we don't want to remove the observers that our superclass sets. let center = NotificationCenter.default center.removeObserver(self, name: NSWindow.didChangeScreenNotification, object: window) // Unhide our elements if savedState.dock { unhideDock() } unhideMenu() // Restore our saved state window.styleMask = savedState.styleMask window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true) // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. if let window = window as? TerminalWindow, window.titlebarTabs { window.titlebarTabs = true } // If the window was previously in a tab group that isn't empty now, // we re-add it. We have to do this because our process of doing non-native // fullscreen removes the window from the tab group. if let tabGroup = savedState.tabGroup, let tabIndex = savedState.tabGroupIndex, !tabGroup.windows.isEmpty { if tabIndex == 0 { // We were previously the first tab. Add it before ("below") // the first window in the tab group currently. tabGroup.windows.first!.addTabbedWindow(window, ordered: .below) } else if tabIndex <= tabGroup.windows.count { // We were somewhere in the middle tabGroup.windows[tabIndex - 1].addTabbedWindow(window, ordered: .above) } else { // We were at the end tabGroup.windows.last!.addTabbedWindow(window, ordered: .below) } } // Unset our saved state, we're restored! self.savedState = nil // Focus window window.makeKeyAndOrderFront(nil) // Notify the delegate self.delegate?.fullscreenDidChange() } private func fullscreenFrame(_ screen: NSScreen) -> NSRect { // It would make more sense to use "visibleFrame" but visibleFrame // will omit space by our dock and isn't updated until an event // loop tick which we don't have time for. So we use frame and // calculate this ourselves. var frame = screen.frame if (!properties.hideMenu) { // We need to subtract the menu height since we're still showing it. frame.size.height -= NSApp.mainMenu?.menuBarHeight ?? 0 // NOTE on macOS bugs: macOS used to have a bug where menuBarHeight // didn't account for the notch. I reported this as a radar and it // was fixed at some point. I don't know when that was so I can't // put an #available check, but it was in a bug fix release so I think // if a bug is reported to Ghostty we can just advise the user to // update. } return frame } // MARK: Window Events @objc func windowDidChangeScreen(_ notification: Notification) { guard isFullscreen else { return } guard let savedState else { return } // This should always be true due to how we register but just be sure guard let object = notification.object as? NSWindow, object == window else { return } // Our screens must have changed guard savedState.screenID != window.screen?.displayID else { return } // When we change screens, we simply exit fullscreen. Changing // screens shouldn't naturally be possible, it can only happen // through external window managers. There's a lot of accounting // to do to get the screen change right so instead of breaking // we just exit out. The user can re-enter fullscreen thereafter. exit() } // MARK: Dock private func hideDock() { NSApp.presentationOptions.insert(.autoHideDock) } private func unhideDock() { NSApp.presentationOptions.remove(.autoHideDock) } // MARK: Menu func hideMenu() { NSApp.presentationOptions.insert(.autoHideMenuBar) } func unhideMenu() { NSApp.presentationOptions.remove(.autoHideMenuBar) } /// The state that must be saved for non-native fullscreen to exit fullscreen. class SavedState { let screenID: UInt32? let tabGroup: NSWindowTabGroup? let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask let dock: Bool init?(_ window: NSWindow) { guard let contentView = window.contentView else { return nil } self.screenID = window.screen?.displayID self.tabGroup = window.tabGroup self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask self.dock = window.screen?.hasDock ?? false } } } class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { override var properties: Properties { Properties(hideMenu: false) } }