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_VISIBLE_MENU,
GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
GHOSTTY_FULLSCREEN_NON_NATIVE_TITLED_VISIBLE_MENU,
} ghostty_action_fullscreen_e;
// apprt.action.FloatWindow

View File

@ -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) {

View File

@ -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) }

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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,
},
),

View File

@ -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) {

View File

@ -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.