diff --git a/assets/settings/default.json b/assets/settings/default.json index 36c0ecfd74..40e4c325de 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -550,6 +550,14 @@ }, // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). "use_system_window_tabs": false, + // Which fullscreen mode the `zed::ToggleFullScreen` action enters (macOS only). + // + // 1. Use macOS's native fullscreen, which moves the window into its own Mission Control space. + // "native" + // 2. Resize the window to cover the entire screen, including the menu bar and, + // on notched displays, the area around the notch. + // "simple" + "fullscreen_mode": "native", // Titlebar related settings "title_bar": { // Whether to show git status indicators on the branch icon in the titlebar. diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index fe97859f0a..09a3ea8c96 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -857,7 +857,7 @@ impl ThreadsArchiveView { settings::SidebarSide::Left ); let sidebar_on_right = !sidebar_on_left; - let not_fullscreen = !window.is_fullscreen(); + let not_fullscreen = !window.is_fullscreen() && !window.is_simple_fullscreen(); let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; let right_window_controls = diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index ecb1fa40d3..14a0761910 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -865,6 +865,10 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } fn set_edited(&mut self, _edited: bool) {} fn set_document_path(&self, _path: Option<&std::path::Path>) {} + fn toggle_simple_fullscreen(&self) {} + fn is_simple_fullscreen(&self) -> bool { + false + } #[cfg(target_os = "macos")] fn set_traffic_light_position(&self, _position: Point) {} fn show_character_palette(&self) {} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index d0e0b378b7..fb1b2edd9f 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2457,6 +2457,13 @@ impl Window { self.platform_window.is_fullscreen() } + /// Returns whether the window is currently in simple (borderless) fullscreen, + /// where it covers the entire screen including the menu bar and notch area. + /// Always `false` on platforms other than macOS. + pub fn is_simple_fullscreen(&self) -> bool { + self.platform_window.is_simple_fullscreen() + } + pub(crate) fn appearance_changed(&mut self, cx: &mut App) { self.appearance = self.platform_window.appearance(); @@ -5694,6 +5701,14 @@ impl Window { self.platform_window.toggle_fullscreen(); } + /// Toggle simple (borderless) fullscreen, where the window covers the entire + /// screen including the menu bar and, on notched displays, the area around the + /// notch. Unlike [`Window::toggle_fullscreen`], this does not move the window + /// into its own Mission Control space. Only has an effect on macOS. + pub fn toggle_simple_fullscreen(&self) { + self.platform_window.toggle_simple_fullscreen(); + } + /// Updates the IME panel position suggestions for languages like japanese, chinese. pub fn invalidate_character_coordinates(&self) { self.on_next_frame(|window, cx| { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 99c9544736..80f5373eb8 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -497,6 +497,93 @@ struct TrafficLightButtons { zoom: Retained, } +// `NSApplicationPresentationOptions` bits (see `NSApplication.PresentationOptions`). +const NS_APPLICATION_PRESENTATION_AUTO_HIDE_DOCK: NSUInteger = 1 << 0; +const NS_APPLICATION_PRESENTATION_AUTO_HIDE_MENU_BAR: NSUInteger = 1 << 2; + +// State captured when entering simple (borderless) fullscreen, used to restore +// the window on exit. +struct SimpleFullscreenState { + frame: NSRect, + style_mask: NSWindowStyleMask, +} + +enum SimpleFullscreenPlan { + Enter { screen_frame: NSRect }, + Exit(SimpleFullscreenState), +} + +struct SimpleFullscreenAppState { + window_count: usize, + saved_presentation_options: NSUInteger, +} + +static SIMPLE_FULLSCREEN_APP_STATE: Mutex> = Mutex::new(None); + +unsafe fn push_simple_fullscreen_presentation_options() { + let mut app_state = SIMPLE_FULLSCREEN_APP_STATE.lock(); + match app_state.as_mut() { + Some(app_state) => app_state.window_count += 1, + None => unsafe { + let app = NSApplication::sharedApplication(nil); + let saved_presentation_options: NSUInteger = msg_send![app, presentationOptions]; + let _: () = msg_send![ + app, + setPresentationOptions: NS_APPLICATION_PRESENTATION_AUTO_HIDE_DOCK + | NS_APPLICATION_PRESENTATION_AUTO_HIDE_MENU_BAR + ]; + *app_state = Some(SimpleFullscreenAppState { + window_count: 1, + saved_presentation_options, + }); + }, + } +} + +unsafe fn pop_simple_fullscreen_presentation_options() { + let mut app_state = SIMPLE_FULLSCREEN_APP_STATE.lock(); + if let Some(state) = app_state.as_mut() { + state.window_count = state.window_count.saturating_sub(1); + if state.window_count == 0 { + unsafe { + let app = NSApplication::sharedApplication(nil); + let _: () = msg_send![ + app, + setPresentationOptions: state.saved_presentation_options + ]; + } + *app_state = None; + } + } +} + +unsafe fn apply_simple_fullscreen_plan( + native_window: id, + native_view: id, + plan: SimpleFullscreenPlan, +) { + unsafe { + match plan { + SimpleFullscreenPlan::Exit(saved) => { + pop_simple_fullscreen_presentation_options(); + native_window.setStyleMask_(saved.style_mask); + native_window.setFrame_display_(saved.frame, YES); + } + SimpleFullscreenPlan::Enter { screen_frame } => { + push_simple_fullscreen_presentation_options(); + native_window.setStyleMask_(NSWindowStyleMask::NSBorderlessWindowMask); + native_window.setFrame_display_(screen_frame, YES); + } + } + + // Changing the style mask makes AppKit resign the window's key status and + // first responder, so keyboard input stops reaching the editor. Re-make the + // window key and restore the GPUI view as first responder. + native_window.makeKeyAndOrderFront_(nil); + native_window.makeFirstResponder_(native_view); + } +} + struct MacWindowState { handle: AnyWindowHandle, foreground_executor: ForegroundExecutor, @@ -536,6 +623,7 @@ struct MacWindowState { // windows draw their own titlebar and move the window via `start_window_move`. app_owns_titlebar_drag: bool, fullscreen_restore_bounds: Bounds, + simple_fullscreen_state: Option, move_tab_to_new_window_callback: Option>, merge_all_windows_callback: Option>, select_next_tab_callback: Option>, @@ -715,6 +803,31 @@ impl MacWindowState { } } + fn toggle_simple_fullscreen(&mut self) -> Option { + // If the window is in native fullscreen, simple fullscreen would conflict + // with AppKit's own fullscreen handling, so ignore the request. + if self.is_fullscreen() { + return None; + } + + if let Some(saved) = self.simple_fullscreen_state.take() { + Some(SimpleFullscreenPlan::Exit(saved)) + } else { + let screen = unsafe { self.native_window.screen() }; + if screen == nil { + return None; + } + let screen_frame = unsafe { NSScreen::frame(screen) }; + + self.simple_fullscreen_state = Some(SimpleFullscreenState { + frame: unsafe { NSWindow::frame(self.native_window) }, + style_mask: unsafe { self.native_window.styleMask() }, + }); + + Some(SimpleFullscreenPlan::Enter { screen_frame }) + } + } + fn bounds(&self) -> Bounds { let mut window_frame = unsafe { NSWindow::frame(self.native_window) }; let screen = unsafe { NSWindow::screen(self.native_window) }; @@ -937,6 +1050,7 @@ impl MacWindow { first_mouse: false, app_owns_titlebar_drag, fullscreen_restore_bounds: Bounds::default(), + simple_fullscreen_state: None, move_tab_to_new_window_callback: None, merge_all_windows_callback: None, select_next_tab_callback: None, @@ -1651,6 +1765,35 @@ impl PlatformWindow for MacWindow { .detach(); } + fn toggle_simple_fullscreen(&self) { + let state = self.0.clone(); + let (foreground_executor, closed) = { + let this = self.0.lock(); + (this.foreground_executor.clone(), this.closed.clone()) + }; + foreground_executor + .spawn(async move { + if_window_not_closed(closed, move || { + let (native_window, native_view, plan) = { + let mut lock = state.lock(); + ( + lock.native_window, + lock.native_view.as_ptr() as id, + lock.toggle_simple_fullscreen(), + ) + }; + if let Some(plan) = plan { + unsafe { apply_simple_fullscreen_plan(native_window, native_view, plan) }; + } + }) + }) + .detach(); + } + + fn is_simple_fullscreen(&self) -> bool { + self.0.lock().simple_fullscreen_state.is_some() + } + fn is_fullscreen(&self) -> bool { let this = self.0.lock(); let window = this.native_window; @@ -1786,6 +1929,9 @@ impl PlatformWindow for MacWindow { fn titlebar_double_click(&self, is_resizable: bool, is_minimizable: bool) { let this = self.0.lock(); + if this.simple_fullscreen_state.is_some() { + return; + } let window = this.native_window; let closed = this.closed.clone(); this.foreground_executor @@ -1843,6 +1989,9 @@ impl PlatformWindow for MacWindow { fn start_window_move(&self) { let this = self.0.lock(); + if this.simple_fullscreen_state.is_some() { + return; + } let window = this.native_window; unsafe { @@ -2751,13 +2900,20 @@ extern "C" fn window_should_close(this: &Object, _: Sel, _: id) -> BOOL { extern "C" fn close_window(this: &Object, _: Sel) { unsafe { - let close_callback = { + let (close_callback, simple_fullscreen_state) = { let window_state = get_window_state(this); let mut lock = window_state.as_ref().lock(); lock.closed.store(true, Ordering::Release); - lock.close_callback.take() + ( + lock.close_callback.take(), + lock.simple_fullscreen_state.take(), + ) }; + if simple_fullscreen_state.is_some() { + pop_simple_fullscreen_presentation_options(); + } + if let Some(callback) = close_callback { callback(); } diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index d0207fd274..b44f019ff4 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -245,7 +245,7 @@ impl Render for PlatformTitleBar { .map(|this| { let show_left_controls = !(sidebar.open && sidebar.side == SidebarSide::Left); - if window.is_fullscreen() { + if window.is_fullscreen() || window.is_simple_fullscreen() { this.pl_2() } else if self.platform_style == PlatformStyle::Mac && show_left_controls { this.pl(px(TRAFFIC_LIGHT_PADDING)) @@ -296,33 +296,36 @@ impl Render for PlatformTitleBar { .w_full() .children(children), ) - .when(!window.is_fullscreen(), |title_bar| { - let show_right_controls = !(sidebar.open && sidebar.side == SidebarSide::Right); + .when( + !window.is_fullscreen() && !window.is_simple_fullscreen(), + |title_bar| { + let show_right_controls = !(sidebar.open && sidebar.side == SidebarSide::Right); - let title_bar = title_bar.children( - show_right_controls - .then(|| { - render_right_window_controls( - button_layout, - close_action.as_ref().boxed_clone(), - window, - ) - }) - .flatten(), - ); + let title_bar = title_bar.children( + show_right_controls + .then(|| { + render_right_window_controls( + button_layout, + close_action.as_ref().boxed_clone(), + window, + ) + }) + .flatten(), + ); - if self.platform_style == PlatformStyle::Linux - && matches!(decorations, Decorations::Client { .. }) - { - title_bar.when(supported_controls.window_menu, |titlebar| { - titlebar.on_mouse_down(MouseButton::Right, move |ev, window, _| { - window.show_window_menu(ev.position) + if self.platform_style == PlatformStyle::Linux + && matches!(decorations, Decorations::Client { .. }) + { + title_bar.when(supported_controls.window_menu, |titlebar| { + titlebar.on_mouse_down(MouseButton::Right, move |ev, window, _| { + window.show_window_menu(ev.position) + }) }) - }) - } else { - title_bar - } - }); + } else { + title_bar + } + }, + ); v_flex() .w_full() diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 19157787f7..5ac3a5c9a5 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1064,6 +1064,13 @@ impl VsCodeSettings { use_system_path_prompts: self.read_bool("files.simpleDialog.enable").map(|b| !b), use_system_prompts: None, use_system_window_tabs: self.read_bool("window.nativeTabs"), + fullscreen_mode: self.read_bool("window.nativeFullScreen").map(|b| { + if b { + FullscreenMode::Native + } else { + FullscreenMode::Simple + } + }), when_closing_with_no_tabs: self.read_bool("window.closeWhenEmpty").map(|b| { if b { CloseWindowWhenNoItems::CloseWindow diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index e5111f4ce2..991ed3d14f 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -121,6 +121,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub use_system_window_tabs: Option, + /// Which fullscreen mode the `zed::ToggleFullScreen` action enters (macOS only). + /// + /// Default: native + pub fullscreen_mode: Option, /// Whether to show padding for zoomed panels. /// When enabled, zoomed bottom panels will have some top padding, /// while zoomed left/right panels will have padding to the right/left (respectively). @@ -338,6 +342,30 @@ pub enum BottomDockLayout { RightAligned, } +#[derive( + Copy, + Clone, + Default, + Debug, + Serialize, + Deserialize, + PartialEq, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum FullscreenMode { + /// Use macOS's native fullscreen, which moves the window into its own + /// Mission Control space. + #[default] + Native, + /// Resize the window to cover the entire screen, including the menu bar and, + /// on notched displays, the area around the notch. + Simple, +} + /// Configures what draws Zed's window decorations on Linux. /// This setting has no effect on other platforms. #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 9b24982dfb..aea8f72328 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4886,7 +4886,7 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn window_section() -> [SettingsPageItem; 3] { + fn window_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("Window"), // todo(settings_ui): Should we filter by platform.as_ref()? @@ -4906,6 +4906,20 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Fullscreen Mode", + description: "(macOS only) which fullscreen mode the toggle fullscreen action enters.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("fullscreen_mode"), + pick: |settings_content| settings_content.workspace.fullscreen_mode.as_ref(), + write: |settings_content, value, _| { + settings_content.workspace.fullscreen_mode = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Window Decorations", description: "(Linux only) whether Zed or your compositor should draw window decorations.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 3dd94f5c78..221b0ecea9 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -641,6 +641,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_editable_number_field) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 027c319216..4b3e5a0cc0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -7203,7 +7203,7 @@ impl Sidebar { let has_query = self.has_filter_query(cx); let sidebar_on_left = self.side(cx) == SidebarSide::Left; let sidebar_on_right = self.side(cx) == SidebarSide::Right; - let not_fullscreen = !window.is_fullscreen(); + let not_fullscreen = !window.is_fullscreen() && !window.is_simple_fullscreen(); let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left; let right_window_controls = diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 76c0713d29..3df477fb60 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -38,6 +38,7 @@ pub struct WorkspaceSettings { pub close_on_file_delete: bool, pub close_panel_on_toggle: bool, pub use_system_window_tabs: bool, + pub fullscreen_mode: settings::FullscreenMode, pub zoomed_padding: bool, pub window_decorations: settings::WindowDecorations, pub focus_follows_mouse: FocusFollowsMouse, @@ -126,6 +127,7 @@ impl Settings for WorkspaceSettings { close_on_file_delete: workspace.close_on_file_delete.unwrap(), close_panel_on_toggle: workspace.close_panel_on_toggle.unwrap(), use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), + fullscreen_mode: workspace.fullscreen_mode.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), window_decorations: workspace.window_decorations.unwrap(), focus_follows_mouse: FocusFollowsMouse { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index b79d4af605..27cbd24704 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1025,8 +1025,16 @@ fn register_actions( .register_action(|_, _: &Zoom, window, _| { window.zoom_window(); }) - .register_action(|_, _: &ToggleFullScreen, window, _| { - window.toggle_fullscreen(); + .register_action(|_, _: &ToggleFullScreen, window, cx| { + let use_simple_fullscreen = window.is_simple_fullscreen() + || (!window.is_fullscreen() + && WorkspaceSettings::get_global(cx).fullscreen_mode + == settings::FullscreenMode::Simple); + if use_simple_fullscreen { + window.toggle_simple_fullscreen(); + } else { + window.toggle_fullscreen(); + } }) .register_action(|_, _: &zed_actions::dev::ToggleFpsOverlay, window, _| { window.cycle_debug_frame_overlay_mode(); diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index bbdcb0045c..adaf3a9813 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -1693,6 +1693,30 @@ Each option controls displaying of a particular toolbar element. If all elements This setting enables integration with macOS’s native window tabbing feature. When set to `true`, Zed windows can be grouped together as tabs in a single macOS window, following the system-wide tabbing preferences set by the user (such as "Always", "In Full Screen", or "Never"). This setting is only available on macOS. +## Fullscreen Mode + +- Description: Which fullscreen mode the `zed::ToggleFullScreen` action enters (macOS only). +- Setting: `fullscreen_mode` +- Default: `native` + +**Options** + +1. Use macOS's native fullscreen, which moves the window into its own Mission Control space: + +```json +{ + "fullscreen_mode": "native" +} +``` + +2. Resize the window to cover the entire screen, including the menu bar and, on notched displays, the area around the notch: + +```json +{ + "fullscreen_mode": "simple" +} +``` + ## Enable Language Server - Description: Whether or not to use language servers to provide code intelligence.