macos: add titled-visible-menu option to macos-non-native-fullscreen

Non-native fullscreen has certain limitations at the moment regarding
being truly fullscreen (taking all screen surface) and losing no
functionality when activated. Currently, tab functionality is lost when
non-native fullscreen is activated.

This commit introduces the `titled-visible-menu` mode for macOS
non-native fullscreen. Like `visible-menu` mode, it hides the dock and
uses its surface, leaving the menubar visible. This mode makes full use
of the screen (except for the menubar) while retaining the tabbar’s
functionality.

While a truly fullscreen non-native mode without feature loss is ideal,
this implementation provides a functional alternative in the meantime.
This commit is contained in:
Sassan Haradji
2025-01-27 19:28:44 +04:00
parent b5000dcd94
commit d9839dbae5
10 changed files with 197 additions and 35 deletions

View File

@ -515,6 +515,7 @@ typedef enum {
GHOSTTY_FULLSCREEN_NON_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE,
GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
GHOSTTY_FULLSCREEN_NON_NATIVE_TITLED_VISIBLE_MENU,
} ghostty_action_fullscreen_e; } ghostty_action_fullscreen_e;
// apprt.action.FloatWindow // apprt.action.FloatWindow

View File

@ -775,13 +775,79 @@ class BaseTerminalController: NSWindowController,
// We have no previous style // We have no previous style
self.fullscreenStyle = newStyle self.fullscreenStyle = newStyle
} }
guard let fullscreenStyle else { return }
if fullscreenStyle.isFullscreen { if let fullscreenStyle {
fullscreenStyle.exit() if fullscreenStyle.isFullscreen {
} else { fullscreenStyle.exit()
fullscreenStyle.enter() } 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() {} func fullscreenDidChange() {}
@ -921,6 +987,9 @@ class BaseTerminalController: NSWindowController,
// Becoming/losing key means we have to notify our surface(s) that we have focus // 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. // so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree() 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) { func windowDidResignKey(_ notification: Notification) {

View File

@ -204,7 +204,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// fullscreen for the logic later in this method. // fullscreen for the logic later in this method.
c.toggleFullscreen(mode: .native) 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 // If we're non-native then we have to do it on a later loop
// so that the content view is setup. // so that the content view is setup.
DispatchQueue.main.async { DispatchQueue.main.async {
@ -268,7 +268,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) 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 // See: https://github.com/mitchellh/ghostty/issues/392
if let fullscreenStyle = parentController.fullscreenStyle, if let fullscreenStyle = parentController.fullscreenStyle,
fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
@ -285,6 +286,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
guard let window = controller.window else { return controller } 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 // If the parent is miniaturized, then macOS exhibits really strange behaviors
// so we have to bring it back out. // so we have to bring it back out.
if (parent.isMiniaturized) { parent.deminiaturize(self) } if (parent.isMiniaturized) { parent.deminiaturize(self) }

View File

@ -23,7 +23,26 @@ class TerminalWindow: NSWindow {
windowController as? TerminalController 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 // 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? { override var toolbar: NSToolbar? {
didSet { didSet {
@ -446,7 +465,7 @@ extension TerminalWindow {
struct ResetZoomAccessoryView: View { struct ResetZoomAccessoryView: View {
@ObservedObject var viewModel: ViewModel @ObservedObject var viewModel: ViewModel
let action: () -> Void let action: () -> Void
// The padding from the top that the view appears. This was all just manually // The padding from the top that the view appears. This was all just manually
// measured based on the OS. // measured based on the OS.
var topPadding: CGFloat { var topPadding: CGFloat {

View File

@ -16,6 +16,9 @@ extension FullscreenMode {
case GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH: case GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH:
.nonNativePaddedNotch .nonNativePaddedNotch
case GHOSTTY_FULLSCREEN_NON_NATIVE_TITLED_VISIBLE_MENU:
.nonNativeTitledVisibleMenu
default: default:
nil nil
} }

View File

@ -164,7 +164,7 @@ extension Ghostty {
let key = "window-position-x" let key = "window-position-x"
return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil
} }
var windowPositionY: Int16? { var windowPositionY: Int16? {
guard let config = self.config else { return nil } guard let config = self.config else { return nil }
var v: Int16 = 0 var v: Int16 = 0
@ -235,6 +235,8 @@ extension Ghostty {
.nonNativeVisibleMenu .nonNativeVisibleMenu
case "padded-notch": case "padded-notch":
.nonNativePaddedNotch .nonNativePaddedNotch
case "titled-visible-menu":
.nonNativeTitledVisibleMenu
default: default:
defaultValue defaultValue
} }

View File

@ -7,6 +7,7 @@ enum FullscreenMode {
case nonNative case nonNative
case nonNativeVisibleMenu case nonNativeVisibleMenu
case nonNativePaddedNotch case nonNativePaddedNotch
case nonNativeTitledVisibleMenu
/// Initializes the fullscreen style implementation for the mode. This will not toggle any /// 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 /// fullscreen properties. This may fail if the window isn't configured properly for a given
@ -19,11 +20,14 @@ enum FullscreenMode {
case .nonNative: case .nonNative:
return NonNativeFullscreen(window) return NonNativeFullscreen(window)
case .nonNativeVisibleMenu: case .nonNativeVisibleMenu:
return NonNativeFullscreenVisibleMenu(window) return NonNativeFullscreenVisibleMenu(window)
case .nonNativePaddedNotch: case .nonNativePaddedNotch:
return NonNativeFullscreenPaddedNotch(window) return NonNativeFullscreenPaddedNotch(window)
case .nonNativeTitledVisibleMenu:
return NonNativeTitledFullscreenVisibleMenu(window)
} }
} }
} }
@ -33,6 +37,7 @@ protocol FullscreenStyle {
var delegate: FullscreenDelegate? { get set } var delegate: FullscreenDelegate? { get set }
var isFullscreen: Bool { get } var isFullscreen: Bool { get }
var supportsTabs: Bool { get } var supportsTabs: Bool { get }
var fullscreenMode: FullscreenMode { get }
init?(_ window: NSWindow) init?(_ window: NSWindow)
func enter() func enter()
func exit() func exit()
@ -89,6 +94,7 @@ class FullscreenBase {
class NativeFullscreen: FullscreenBase, FullscreenStyle { class NativeFullscreen: FullscreenBase, FullscreenStyle {
var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var isFullscreen: Bool { window.styleMask.contains(.fullScreen) }
var supportsTabs: Bool { true } var supportsTabs: Bool { true }
var fullscreenMode: FullscreenMode { .native }
required init?(_ window: NSWindow) { required init?(_ window: NSWindow) {
// TODO: There are many requirements for native fullscreen we should // 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 // Non-native fullscreen never supports tabs because tabs require
// the "titled" style and we don't have it for non-native fullscreen. // the "titled" style and we don't have it for non-native fullscreen.
var supportsTabs: Bool { false } var supportsTabs: Bool { false }
var fullscreenMode: FullscreenMode { .nonNative }
// isFullscreen is dependent on if we have saved state currently. We // isFullscreen is dependent on if we have saved state currently. We
// could one day try to do fancier stuff like inspecting the window // could one day try to do fancier stuff like inspecting the window
@ -143,6 +150,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
struct Properties { struct Properties {
var hideMenu: Bool = true var hideMenu: Bool = true
var titled: Bool = false
var paddedNotch: Bool = false var paddedNotch: Bool = false
} }
@ -169,6 +177,10 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
} }
func enter() { func enter() {
enter(shouldFocus: true)
}
func enter(shouldFocus: Bool = true) {
// If we are in fullscreen we don't do it again. // If we are in fullscreen we don't do it again.
guard !isFullscreen else { return } guard !isFullscreen else { return }
@ -216,15 +228,20 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
name: NSWindow.didChangeScreenNotification, name: NSWindow.didChangeScreenNotification,
object: window) object: window)
// Being untitled let's our content take up the full frame. if (!properties.titled) {
window.styleMask.remove(.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 // We dont' want the non-native fullscreen window to be resizable
// from the edges. // from the edges.
window.styleMask.remove(.resizable) window.styleMask.remove(.resizable)
// Focus window if (shouldFocus) {
window.makeKeyAndOrderFront(nil) // 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. // 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 // We do this async so that all the style edits above (title removal, dock
@ -242,6 +259,10 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
} }
func exit() { func exit() {
exit(shouldFocus: true)
}
func exit(shouldFocus: Bool = true) {
guard isFullscreen else { return } guard isFullscreen else { return }
guard let savedState else { return } guard let savedState else { return }
@ -254,12 +275,17 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
let firstResponder = window.firstResponder let firstResponder = window.firstResponder
// Unhide our elements // Unhide our elements
if savedState.dock { if (savedState.dock) {
unhideDock() unhideDock()
} }
if (properties.hideMenu && savedState.menu) { if (properties.hideMenu && savedState.menu) {
unhideMenu() 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 // Restore our saved state
window.styleMask = savedState.styleMask window.styleMask = savedState.styleMask
@ -273,7 +299,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
if let window = window as? TerminalWindow, window.isTabBar(c) { if let window = window as? TerminalWindow, window.isTabBar(c) {
continue continue
} }
if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil {
window.addTitlebarAccessoryViewController(c) window.addTitlebarAccessoryViewController(c)
} }
@ -282,23 +308,26 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// Removing "titled" also clears our toolbar // Removing "titled" also clears our toolbar
window.toolbar = savedState.toolbar window.toolbar = savedState.toolbar
window.toolbarStyle = savedState.toolbarStyle window.toolbarStyle = savedState.toolbarStyle
// If the window was previously in a tab group that isn't empty now, if (!properties.titled) {
// we re-add it. We have to do this because our process of doing non-native // If the window was previously in a tab group that isn't empty now,
// fullscreen removes the window from the tab group. // we re-add it. We have to do this because our process of doing non-native
if let tabGroup = savedState.tabGroup, // fullscreen removes the window from the tab group.
let tabIndex = savedState.tabGroupIndex, if let tabGroup = savedState.tabGroup,
!tabGroup.windows.isEmpty { let tabIndex = savedState.tabGroupIndex,
if tabIndex == 0 { !tabGroup.windows.isEmpty
// We were previously the first tab. Add it before ("below") {
// the first window in the tab group currently. if tabIndex == 0 {
tabGroup.windows.first!.addTabbedWindow(window, ordered: .below) // We were previously the first tab. Add it before ("below")
} else if tabIndex <= tabGroup.windows.count { // the first window in the tab group currently.
// We were somewhere in the middle tabGroup.windows.first!.addTabbedWindow(window, ordered: .below)
tabGroup.windows[tabIndex - 1].addTabbedWindow(window, ordered: .above) } else if tabIndex <= tabGroup.windows.count {
} else { // We were somewhere in the middle
// We were at the end tabGroup.windows[tabIndex - 1].addTabbedWindow(window, ordered: .above)
tabGroup.windows.last!.addTabbedWindow(window, ordered: .below) } 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! // Unset our saved state, we're restored!
self.savedState = nil self.savedState = nil
// Focus window if (shouldFocus) {
window.makeKeyAndOrderFront(nil) // Focus window
window.makeKeyAndOrderFront(nil)
}
// Notify the delegate // Notify the delegate
NotificationCenter.default.post(name: .fullscreenDidExit, object: self) NotificationCenter.default.post(name: .fullscreenDidExit, object: self)
self.delegate?.fullscreenDidChange() 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 { private func fullscreenFrame(_ screen: NSScreen) -> NSRect {
// It would make more sense to use "visibleFrame" but visibleFrame // It would make more sense to use "visibleFrame" but visibleFrame
// will omit space by our dock and isn't updated until an event // 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 titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController]
let dock: Bool let dock: Bool
let menu: Bool let menu: Bool
let frameRectConstrained: Bool
init?(_ window: NSWindow) { init?(_ window: NSWindow) {
guard let contentView = window.contentView else { return nil } guard let contentView = window.contentView else { return nil }
@ -409,6 +458,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.toolbarStyle = window.toolbarStyle self.toolbarStyle = window.toolbarStyle
self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers
self.dock = window.screen?.hasDock ?? false self.dock = window.screen?.hasDock ?? false
self.frameRectConstrained = (window as? TerminalWindow)?.frameRectConstrained ?? false
if let cgWindowId = window.cgWindowId { if let cgWindowId = window.cgWindowId {
// We hide the menu only if this window is not on any fullscreen // We hide the menu only if this window is not on any fullscreen
@ -434,10 +484,18 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { class NonNativeFullscreenVisibleMenu: NonNativeFullscreen {
override var properties: Properties { Properties(hideMenu: false) } override var properties: Properties { Properties(hideMenu: false) }
override var fullscreenMode: FullscreenMode { .nonNativeVisibleMenu }
} }
class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { class NonNativeFullscreenPaddedNotch: NonNativeFullscreen {
override var properties: Properties { Properties(paddedNotch: true) } 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 { extension Notification.Name {

View File

@ -4760,6 +4760,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.true => .macos_non_native, .true => .macos_non_native,
.@"visible-menu" => .macos_non_native_visible_menu, .@"visible-menu" => .macos_non_native_visible_menu,
.@"padded-notch" => .macos_non_native_padded_notch, .@"padded-notch" => .macos_non_native_padded_notch,
.@"titled-visible-menu" => .macos_non_native_titled_visible_menu,
}, },
), ),

View File

@ -458,6 +458,7 @@ pub const Fullscreen = enum(c_int) {
macos_non_native, macos_non_native,
macos_non_native_visible_menu, macos_non_native_visible_menu,
macos_non_native_padded_notch, macos_non_native_padded_notch,
macos_non_native_titled_visible_menu,
}; };
pub const FloatWindow = enum(c_int) { pub const FloatWindow = enum(c_int) {

View File

@ -2505,6 +2505,8 @@ keybind: Keybinds = .{},
/// * `false` - Use native macOS fullscreen /// * `false` - Use native macOS fullscreen
/// * `visible-menu` - Use non-native macOS fullscreen, keep the menu bar /// * `visible-menu` - Use non-native macOS fullscreen, keep the menu bar
/// visible /// 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, /// * `padded-notch` - Use non-native macOS fullscreen, hide the menu bar,
/// but ensure the window is not obscured by the notch on applicable /// but ensure the window is not obscured by the notch on applicable
/// devices. The area around the notch will remain transparent currently, /// devices. The area around the notch will remain transparent currently,
@ -4471,6 +4473,7 @@ pub const NonNativeFullscreen = enum(c_int) {
true, true,
@"visible-menu", @"visible-menu",
@"padded-notch", @"padded-notch",
@"titled-visible-menu",
}; };
/// Valid values for macos-option-as-alt. /// Valid values for macos-option-as-alt.