mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-17 09:16:11 +03:00
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:
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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) }
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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.
|
||||
|
Reference in New Issue
Block a user