diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 20d6b7b9e..2d230561b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -4,7 +4,8 @@ import SwiftUI import GhosttyKit /// A classic, tabbed terminal experience. -class TerminalController: BaseTerminalController +class TerminalController: BaseTerminalController, + FullscreenDelegate { override var windowNibName: NSNib.Name? { "Terminal" } @@ -199,6 +200,8 @@ class TerminalController: BaseTerminalController } } + // MARK: Fullscreen + /// Toggle fullscreen for the given mode. func toggleFullscreen(mode: FullscreenMode) { // We need a window to fullscreen @@ -208,11 +211,12 @@ class TerminalController: BaseTerminalController // our mode changed. If it changed and we're in fullscreen, we exit so we can // toggle it next time. If it changed and we're not in fullscreen we can just // switch the handler. - let newStyle = mode.style(for: window) + var newStyle = mode.style(for: window) + newStyle?.delegate = self old: if let oldStyle = self.fullscreenStyle { // If we're not fullscreen, we can nil it out so we get the new style if !oldStyle.isFullscreen { - self.fullscreenStyle = nil + self.fullscreenStyle = newStyle break old } @@ -227,28 +231,25 @@ class TerminalController: BaseTerminalController oldStyle.exit() self.fullscreenStyle = nil - // Fix our focus - if let focusedSurface { - Ghostty.moveFocus(to: focusedSurface) - } - // We're done return } // Style is the same. } else { - // No old style, so set to our new style. + // We have no previous style self.fullscreenStyle = newStyle } - guard let fullscreenStyle else { return } + if fullscreenStyle.isFullscreen { fullscreenStyle.exit() } else { fullscreenStyle.enter() } + } + func fullscreenDidChange() { // For some reason focus can get lost when we change fullscreen. Regardless of // mode above we just move it back. if let focusedSurface { diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 001b5df5e..937e77dce 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -26,6 +26,7 @@ enum FullscreenMode { /// 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) @@ -33,11 +34,23 @@ protocol FullscreenStyle { 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() {} +} + /// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen /// button on regular titlebars. class NativeFullscreen: FullscreenStyle { private let window: NSWindow + weak var delegate: FullscreenDelegate? var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var supportsTabs: Bool { true } @@ -58,6 +71,9 @@ class NativeFullscreen: FullscreenStyle { // Enter fullscreen window.toggleFullScreen(self) + + // Notify the delegate + delegate?.fullscreenDidChange() } func exit() { @@ -67,10 +83,15 @@ class NativeFullscreen: FullscreenStyle { window.titlebarSeparatorStyle = .automatic window.toggleFullScreen(nil) + + // Notify the delegate + delegate?.fullscreenDidChange() } } class NonNativeFullscreen: FullscreenStyle { + weak var delegate: FullscreenDelegate? + // 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 } @@ -157,6 +178,13 @@ class NonNativeFullscreen: FullscreenStyle { object: window) } + // 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) @@ -169,6 +197,7 @@ class NonNativeFullscreen: FullscreenStyle { // https://github.com/ghostty-org/ghostty/issues/1996 DispatchQueue.main.async { self.window.setFrame(self.fullscreenFrame(screen), display: true) + self.delegate?.fullscreenDidChange() } } @@ -176,11 +205,10 @@ class NonNativeFullscreen: FullscreenStyle { guard isFullscreen else { return } guard let savedState else { return } - // Reset all of our dock and menu logic - NotificationCenter.default.removeObserver( - self, name: NSWindow.didBecomeMainNotification, object: window) - NotificationCenter.default.removeObserver( - self, name: NSWindow.didResignMainNotification, object: window) + // Remove all our notifications + NotificationCenter.default.removeObserver(self) + + // Unhide our elements unhideDock() unhideMenu() @@ -219,6 +247,9 @@ class NonNativeFullscreen: FullscreenStyle { // Focus window window.makeKeyAndOrderFront(nil) + + // Notify the delegate + self.delegate?.fullscreenDidChange() } private func fullscreenFrame(_ screen: NSScreen) -> NSRect { @@ -243,6 +274,19 @@ class NonNativeFullscreen: FullscreenStyle { return frame } + // MARK: Window Events + + @objc func windowDidChangeScreen(_ notification: Notification) { + guard isFullscreen 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 @objc private func hideDock() {