diff --git a/include/ghostty.h b/include/ghostty.h index 0c9b840e7..a2e7d01c4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -515,6 +515,7 @@ typedef enum { GHOSTTY_FULLSCREEN_NON_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, + GHOSTTY_FULLSCREEN_NON_NATIVE_TITLED_VISIBLE_MENU, } ghostty_action_fullscreen_e; // apprt.action.FloatWindow diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index c93a9450d..86170a20b 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -775,13 +775,79 @@ class BaseTerminalController: NSWindowController, // We have no previous style self.fullscreenStyle = newStyle } - guard let fullscreenStyle else { return } - if fullscreenStyle.isFullscreen { - fullscreenStyle.exit() - } else { - fullscreenStyle.enter() + if let fullscreenStyle { + if fullscreenStyle.isFullscreen { + fullscreenStyle.exit() + } else { + fullscreenStyle.enter() + } } + + if let tabbedWindows = window.tabbedWindows { + for otherTabWindow in tabbedWindows { + if otherTabWindow != window, + let otherTabController = otherTabWindow.windowController as? BaseTerminalController + { + otherTabController.syncNonNativeTabbedFullscreenState(with: self) + } + } + } + } + + // Update window fullscreen state to match the given controller. + func syncNonNativeTabbedFullscreenState(with controller: BaseTerminalController) { + // We need a window to sync its fullscreen-ness + guard let window = self.window else { return } + + // If the target fullscreen style is not a non-native titled + // fullscreen style, we are not interested in syncing. + guard let targetFullscreenStyle = controller.fullscreenStyle as? NonNativeFullscreen else { return } + if !targetFullscreenStyle.properties.titled { + return + } + + if !targetFullscreenStyle.isFullscreen { + if let oldStyle = self.fullscreenStyle as? NonNativeFullscreen, oldStyle.isFullscreen { + oldStyle.exit(shouldFocus: false) + } + + guard let controllerWindow = controller.window else { return } + window.setFrame(controllerWindow.frame, display: true) + + return + } + + if let oldStyle = self.fullscreenStyle, oldStyle.isFullscreen { + if type(of: oldStyle) == type(of: targetFullscreenStyle) { + // If the styles are the same and the old style is already in fullscreen mode + // we don't need to do anything. + return + } else { + // If it's a different type of fullscreen style, exit fullscreen first + oldStyle.exit() + } + } + + // At this point `targetFullscreenStyle` is `NonNativeFullscreen`, so we know + // `newStyle` will be too, so the next line is solely for the sake of keeping + // the type-checker happy + guard let newStyle = targetFullscreenStyle.fullscreenMode.style(for: window) as? NonNativeFullscreen else { + return + } + newStyle.delegate = self + self.fullscreenStyle = newStyle + + newStyle.enter(shouldFocus: false) + } + + // These are mostly hacks and patches, required to keep the behavior of the tab-supporting + // non-native fullscreen style consistent + func reapplyNonNativeTabbedFullscreen() { + // If the target fullscreen style is not a non-native fullscreen style, + // we are not interested in reapplying fullscreen style. + guard let fullscreenStyle = fullscreenStyle as? NonNativeFullscreen else { return } + fullscreenStyle.reapply() } func fullscreenDidChange() {} @@ -921,6 +987,9 @@ class BaseTerminalController: NSWindowController, // Becoming/losing key means we have to notify our surface(s) that we have focus // so things like cursors blink, pty events are sent, etc. self.syncFocusToSurfaceTree() + // Some non-native fullscreen modes are displaced/repositioned when losing/taking + // focus. So we try to fix their position/size whenever the window takes focus. + self.reapplyNonNativeTabbedFullscreen() } func windowDidResignKey(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c5e1c413f..423f25bf5 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -204,7 +204,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // fullscreen for the logic later in this method. c.toggleFullscreen(mode: .native) - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch, .nonNativeTitledVisibleMenu: // 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 { @@ -268,7 +268,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) } - // If our parent is in non-native fullscreen, then new tabs do not work. + // If our parent is in non-native fullscreen, not supporting tabs, + // then new tabs do not work. // See: https://github.com/mitchellh/ghostty/issues/392 if let fullscreenStyle = parentController.fullscreenStyle, fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { @@ -285,6 +286,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) guard let window = controller.window else { return controller } + // Non-native tab-supporting fullscreen styles should be manually synced. + // It will be a no-op if no sync is needed. + controller.syncNonNativeTabbedFullscreenState(with: parentController) + // 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) } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cec85f06e..a62158dd0 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -23,7 +23,26 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } + /// Whether the window has frame-react constraints applied. + var frameRectConstrained: Bool = false { + didSet { + // If we set this to true, then we need to ensure that the frame is + // within the constraints. + if frameRectConstrained { + setFrame(frame, display: true, animate: false) + } + } + } + // MARK: NSWindow Overrides + override func constrainFrameRect(_ frameRect: NSRect, + to screen: NSScreen?) -> NSRect { + if (frameRectConstrained) { + return super.constrainFrameRect(frameRect, to: screen) + } else { + return frameRect + } + } override var toolbar: NSToolbar? { didSet { @@ -446,7 +465,7 @@ extension TerminalWindow { struct ResetZoomAccessoryView: View { @ObservedObject var viewModel: ViewModel let action: () -> Void - + // The padding from the top that the view appears. This was all just manually // measured based on the OS. var topPadding: CGFloat { diff --git a/macos/Sources/Ghostty/FullscreenMode+Extension.swift b/macos/Sources/Ghostty/FullscreenMode+Extension.swift index 0c0bba908..6acc7e995 100644 --- a/macos/Sources/Ghostty/FullscreenMode+Extension.swift +++ b/macos/Sources/Ghostty/FullscreenMode+Extension.swift @@ -16,6 +16,9 @@ extension FullscreenMode { case GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH: .nonNativePaddedNotch + case GHOSTTY_FULLSCREEN_NON_NATIVE_TITLED_VISIBLE_MENU: + .nonNativeTitledVisibleMenu + default: nil } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 241c10632..d75d341e7 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -164,7 +164,7 @@ extension Ghostty { let key = "window-position-x" return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil } - + var windowPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 @@ -235,6 +235,8 @@ extension Ghostty { .nonNativeVisibleMenu case "padded-notch": .nonNativePaddedNotch + case "titled-visible-menu": + .nonNativeTitledVisibleMenu default: defaultValue } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index f3940a9aa..d3d0dd39c 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -7,6 +7,7 @@ enum FullscreenMode { case nonNative case nonNativeVisibleMenu case nonNativePaddedNotch + case nonNativeTitledVisibleMenu /// 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 @@ -19,11 +20,14 @@ enum FullscreenMode { case .nonNative: return NonNativeFullscreen(window) - case .nonNativeVisibleMenu: + case .nonNativeVisibleMenu: return NonNativeFullscreenVisibleMenu(window) case .nonNativePaddedNotch: return NonNativeFullscreenPaddedNotch(window) + + case .nonNativeTitledVisibleMenu: + return NonNativeTitledFullscreenVisibleMenu(window) } } } @@ -33,6 +37,7 @@ protocol FullscreenStyle { var delegate: FullscreenDelegate? { get set } var isFullscreen: Bool { get } var supportsTabs: Bool { get } + var fullscreenMode: FullscreenMode { get } init?(_ window: NSWindow) func enter() func exit() @@ -89,6 +94,7 @@ class FullscreenBase { class NativeFullscreen: FullscreenBase, FullscreenStyle { var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var supportsTabs: Bool { true } + var fullscreenMode: FullscreenMode { .native } required init?(_ window: NSWindow) { // TODO: There are many requirements for native fullscreen we should @@ -130,6 +136,7 @@ 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 } + var fullscreenMode: FullscreenMode { .nonNative } // isFullscreen is dependent on if we have saved state currently. We // could one day try to do fancier stuff like inspecting the window @@ -143,6 +150,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { struct Properties { var hideMenu: Bool = true + var titled: Bool = false var paddedNotch: Bool = false } @@ -169,6 +177,10 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { } func enter() { + enter(shouldFocus: true) + } + + func enter(shouldFocus: Bool = true) { // If we are in fullscreen we don't do it again. guard !isFullscreen else { return } @@ -216,15 +228,20 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { name: NSWindow.didChangeScreenNotification, object: window) - // Being untitled let's our content take up the full frame. - window.styleMask.remove(.titled) + if (!properties.titled) { + // Being untitled let's our content take up the full frame. + window.styleMask.remove(.titled) + } // We dont' want the non-native fullscreen window to be resizable // from the edges. window.styleMask.remove(.resizable) - // Focus window - window.makeKeyAndOrderFront(nil) + if (shouldFocus) { + // If we are entering fullscreen, we want to focus the window + // so that it is the key 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 @@ -242,6 +259,10 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { } func exit() { + exit(shouldFocus: true) + } + + func exit(shouldFocus: Bool = true) { guard isFullscreen else { return } guard let savedState else { return } @@ -254,12 +275,17 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let firstResponder = window.firstResponder // Unhide our elements - if savedState.dock { + if (savedState.dock) { unhideDock() } if (properties.hideMenu && savedState.menu) { unhideMenu() } + if let window = window as? TerminalWindow { + // If we are a TerminalWindow, we need to restore the frameRectConstrained + // property so that the window can be resized again. + window.frameRectConstrained = savedState.frameRectConstrained + } // Restore our saved state window.styleMask = savedState.styleMask @@ -273,7 +299,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if let window = window as? TerminalWindow, window.isTabBar(c) { continue } - + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { window.addTitlebarAccessoryViewController(c) } @@ -282,23 +308,26 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing "titled" also clears our toolbar window.toolbar = savedState.toolbar window.toolbarStyle = savedState.toolbarStyle - - // 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) + + if (!properties.titled) { + // 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) + } } } @@ -309,14 +338,33 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Unset our saved state, we're restored! self.savedState = nil - // Focus window - window.makeKeyAndOrderFront(nil) + if (shouldFocus) { + // Focus window + window.makeKeyAndOrderFront(nil) + } // Notify the delegate NotificationCenter.default.post(name: .fullscreenDidExit, object: self) self.delegate?.fullscreenDidChange() } + // Some of the tweaks we do to the window in non-native fullscreen need to reapply + // after specific events, like regaining focus. This is specially the case for tab + // supporting styles which use unofficial api of macOS. + func reapply() { + if !self.isFullscreen { + return + } + + if self.properties.titled { + DispatchQueue.main.async { + guard let screen = self.window.screen else { return } + + self.window.setFrame(self.fullscreenFrame(screen), display: true) + } + } + } + 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 @@ -396,6 +444,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool + let frameRectConstrained: Bool init?(_ window: NSWindow) { guard let contentView = window.contentView else { return nil } @@ -409,6 +458,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.toolbarStyle = window.toolbarStyle self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false + self.frameRectConstrained = (window as? TerminalWindow)?.frameRectConstrained ?? false if let cgWindowId = window.cgWindowId { // We hide the menu only if this window is not on any fullscreen @@ -434,10 +484,18 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { override var properties: Properties { Properties(hideMenu: false) } + override var fullscreenMode: FullscreenMode { .nonNativeVisibleMenu } } class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { override var properties: Properties { Properties(paddedNotch: true) } + override var fullscreenMode: FullscreenMode { .nonNativePaddedNotch } +} + +class NonNativeTitledFullscreenVisibleMenu: NonNativeFullscreen { + override var supportsTabs: Bool { true } + override var properties: Properties { Properties(titled: true) } + override var fullscreenMode: FullscreenMode { .nonNativeTitledVisibleMenu } } extension Notification.Name { diff --git a/src/Surface.zig b/src/Surface.zig index af0a742c6..defafc358 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4760,6 +4760,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .true => .macos_non_native, .@"visible-menu" => .macos_non_native_visible_menu, .@"padded-notch" => .macos_non_native_padded_notch, + .@"titled-visible-menu" => .macos_non_native_titled_visible_menu, }, ), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 201d27e31..cdc211fc5 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -458,6 +458,7 @@ pub const Fullscreen = enum(c_int) { macos_non_native, macos_non_native_visible_menu, macos_non_native_padded_notch, + macos_non_native_titled_visible_menu, }; pub const FloatWindow = enum(c_int) { diff --git a/src/config/Config.zig b/src/config/Config.zig index 1e2086876..0b86f1ec2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2505,6 +2505,8 @@ keybind: Keybinds = .{}, /// * `false` - Use native macOS fullscreen /// * `visible-menu` - Use non-native macOS fullscreen, keep the menu bar /// visible +/// * `titled-visible-menu` - Use non-native macOS fullscreen, keep the menu +/// bar and title bar visible /// * `padded-notch` - Use non-native macOS fullscreen, hide the menu bar, /// but ensure the window is not obscured by the notch on applicable /// devices. The area around the notch will remain transparent currently, @@ -4471,6 +4473,7 @@ pub const NonNativeFullscreen = enum(c_int) { true, @"visible-menu", @"padded-notch", + @"titled-visible-menu", }; /// Valid values for macos-option-as-alt.