From 78c2f1621d1ce84a745d9c1aec4177bcab6f4f03 Mon Sep 17 00:00:00 2001 From: Gaauwe Rombouts Date: Thu, 28 Aug 2025 05:51:22 +0200 Subject: [PATCH] Add macOS window tabs (#33334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/14722 Closes https://github.com/zed-industries/zed/issues/4948 Closes https://github.com/zed-industries/zed/issues/7136 Follow up of https://github.com/zed-industries/zed/pull/20557 and https://github.com/zed-industries/zed/pull/32238. Based on the discussions in the previous PRs and the pairing session with @ConradIrwin I've decided to rewrite it from scratch, to properly incorporate all the requirements. The feature is opt-in, the settings is set to false by default. Once enabled via the Zed settings, it will behave according to the user’s system preference, without requiring a restart — the next window opened will adopt the new behavior (similar to Ghostty). I’m not entirely sure if the changes to the Window class are the best approach. I’ve tried to keep things flexible enough that other applications built with GPUI won’t be affected (while giving them the option to still use it), but I’d appreciate input on whether this direction makes sense long-term. https://github.com/user-attachments/assets/9573e094-4394-41ad-930c-5375a8204cbf ### Features * System-aware tabbing behavior * Respects the three system modes: Always, Never, and Fullscreen (default on macOS) * Changing the Zed setting does not require a restart — the next window reflects the change * Full theme support * Integrates with light and dark themes * [One Dark](https://github.com/user-attachments/assets/d1f55ff7-2339-4b09-9faf-d3d610ba7ca2) * [One Light](https://github.com/user-attachments/assets/7776e30c-2686-493e-9598-cdcd7e476ecf) * Supports opaque/blurred/transparent themes as best as possible * [One Dark - blurred](https://github.com/user-attachments/assets/c4521311-66cb-4cee-9e37-15146f6869aa) * Dynamic layout adjustments * Only reserves tab bar space when tabs are actually visible * [With tabs](https://github.com/user-attachments/assets/3b6db943-58c5-4f55-bdf4-33d23ca7d820) * [Without tabs](https://github.com/user-attachments/assets/2d175959-5efc-4e4f-a15c-0108925c582e) * VS Code compatibility * Supports the `window.nativeTabs` setting in the VS Code settings importer * Command palette integration * Adds commands for managing tabs to the command palette * These can be assigned to keyboard shortcuts as well, but didn't add defaults as to not reserve precious default key combinations Happy to pair again if things can be improved codewise, or if explanations are necessary for certain choices! Release Notes: * Added support for native macOS window tabbing. When you set `"use_system_window_tabs": true`, Zed will merge windows in the same was as macOS: by default this happens only when full screened, but you can adjust your macOS settings to have this happen on all windows. --------- Co-authored-by: Conrad Irwin --- assets/settings/default.json | 2 + crates/agent_ui/src/ui/agent_notification.rs | 1 + crates/collab_ui/src/collab_ui.rs | 1 + crates/gpui/examples/window_positioning.rs | 1 + crates/gpui/src/app.rs | 305 +++++++++++- crates/gpui/src/platform.rs | 27 +- crates/gpui/src/platform/mac/window.rs | 337 ++++++++++++- crates/gpui/src/window.rs | 118 ++++- crates/rules_library/src/rules_library.rs | 2 +- crates/title_bar/src/platform_title_bar.rs | 22 +- crates/title_bar/src/system_window_tabs.rs | 477 +++++++++++++++++++ crates/title_bar/src/title_bar.rs | 5 +- crates/workspace/src/workspace.rs | 67 ++- crates/workspace/src/workspace_settings.rs | 7 + crates/zed/src/main.rs | 19 +- crates/zed/src/zed.rs | 8 + docs/src/configuring-zed.md | 10 + 17 files changed, 1357 insertions(+), 52 deletions(-) create mode 100644 crates/title_bar/src/system_window_tabs.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 804198090fb4f5649160f6534bd3ed54b1368bce..ef57412842c302571be6827119e4abe6505a43b3 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -363,6 +363,8 @@ // Whether to show code action buttons in the editor toolbar. "code_actions": false }, + // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + "use_system_window_tabs": false, // Titlebar related settings "title_bar": { // Whether to show the branch icon beside branch switcher in the titlebar. diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index b2342a87b5be315dac28212a1ec73d0c054932c3..af2a022f147b79a0a299c17dd26c7e9a8b62aeb9 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -62,6 +62,7 @@ impl AgentNotification { app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, ..Default::default() } } diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index a49e38a8dd96a2e4883ce054477749b5c9f4eb7e..b369d324adb617907d80b773e0982c1723b1bae6 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -66,6 +66,7 @@ fn notification_window_options( app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, ..Default::default() } } diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 8180104e1e3d0315bd213a73122125fdef3ca744..ca6cd535d67aa8b2e700b2d0bc632056e928e0e7 100644 --- a/crates/gpui/examples/window_positioning.rs +++ b/crates/gpui/examples/window_positioning.rs @@ -62,6 +62,7 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window app_id: None, window_min_size: None, window_decorations: None, + tabbing_identifier: None, ..Default::default() } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index b59d7e717ad9dadc222e36fb54f2cc0d01466b75..669a95bd91577577fc460ba30bdacc867e3f3e60 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -7,7 +7,7 @@ use std::{ path::{Path, PathBuf}, rc::{Rc, Weak}, sync::{Arc, atomic::Ordering::SeqCst}, - time::Duration, + time::{Duration, Instant}, }; use anyhow::{Context as _, Result, anyhow}; @@ -17,6 +17,7 @@ use futures::{ channel::oneshot, future::{LocalBoxFuture, Shared}, }; +use itertools::Itertools; use parking_lot::RwLock; use slotmap::SlotMap; @@ -39,8 +40,8 @@ use crate::{ Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, - Reservation, ScreenCaptureSource, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, - Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, + TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -237,6 +238,303 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +#[doc(hidden)] +#[derive(Clone, PartialEq, Eq)] +pub struct SystemWindowTab { + pub id: WindowId, + pub title: SharedString, + pub handle: AnyWindowHandle, + pub last_active_at: Instant, +} + +impl SystemWindowTab { + /// Create a new instance of the window tab. + pub fn new(title: SharedString, handle: AnyWindowHandle) -> Self { + Self { + id: handle.id, + title, + handle, + last_active_at: Instant::now(), + } + } +} + +/// A controller for managing window tabs. +#[derive(Default)] +pub struct SystemWindowTabController { + visible: Option, + tab_groups: FxHashMap>, +} + +impl Global for SystemWindowTabController {} + +impl SystemWindowTabController { + /// Create a new instance of the window tab controller. + pub fn new() -> Self { + Self { + visible: None, + tab_groups: FxHashMap::default(), + } + } + + /// Initialize the global window tab controller. + pub fn init(cx: &mut App) { + cx.set_global(SystemWindowTabController::new()); + } + + /// Get all tab groups. + pub fn tab_groups(&self) -> &FxHashMap> { + &self.tab_groups + } + + /// Get the next tab group window handle. + pub fn get_next_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> { + let controller = cx.global::(); + let current_group = controller + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); + + let current_group = current_group?; + let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); + let idx = group_ids.iter().position(|g| *g == current_group)?; + let next_idx = (idx + 1) % group_ids.len(); + + controller + .tab_groups + .get(group_ids[next_idx]) + .and_then(|tabs| { + tabs.iter() + .max_by_key(|tab| tab.last_active_at) + .or_else(|| tabs.first()) + .map(|tab| &tab.handle) + }) + } + + /// Get the previous tab group window handle. + pub fn get_prev_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> { + let controller = cx.global::(); + let current_group = controller + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); + + let current_group = current_group?; + let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); + let idx = group_ids.iter().position(|g| *g == current_group)?; + let prev_idx = if idx == 0 { + group_ids.len() - 1 + } else { + idx - 1 + }; + + controller + .tab_groups + .get(group_ids[prev_idx]) + .and_then(|tabs| { + tabs.iter() + .max_by_key(|tab| tab.last_active_at) + .or_else(|| tabs.first()) + .map(|tab| &tab.handle) + }) + } + + /// Get all tabs in the same window. + pub fn tabs(&self, id: WindowId) -> Option<&Vec> { + let tab_group = self + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group)); + + if let Some(tab_group) = tab_group { + self.tab_groups.get(&tab_group) + } else { + None + } + } + + /// Initialize the visibility of the system window tab controller. + pub fn init_visible(cx: &mut App, visible: bool) { + let mut controller = cx.global_mut::(); + if controller.visible.is_none() { + controller.visible = Some(visible); + } + } + + /// Get the visibility of the system window tab controller. + pub fn is_visible(&self) -> bool { + self.visible.unwrap_or(false) + } + + /// Set the visibility of the system window tab controller. + pub fn set_visible(cx: &mut App, visible: bool) { + let mut controller = cx.global_mut::(); + controller.visible = Some(visible); + } + + /// Update the last active of a window. + pub fn update_last_active(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + for windows in controller.tab_groups.values_mut() { + for tab in windows.iter_mut() { + if tab.id == id { + tab.last_active_at = Instant::now(); + } + } + } + } + + /// Update the position of a tab within its group. + pub fn update_tab_position(cx: &mut App, id: WindowId, ix: usize) { + let mut controller = cx.global_mut::(); + for (_, windows) in controller.tab_groups.iter_mut() { + if let Some(current_pos) = windows.iter().position(|tab| tab.id == id) { + if ix < windows.len() && current_pos != ix { + let window_tab = windows.remove(current_pos); + windows.insert(ix, window_tab); + } + break; + } + } + } + + /// Update the title of a tab. + pub fn update_tab_title(cx: &mut App, id: WindowId, title: SharedString) { + let controller = cx.global::(); + let tab = controller + .tab_groups + .values() + .flat_map(|windows| windows.iter()) + .find(|tab| tab.id == id); + + if tab.map_or(true, |t| t.title == title) { + return; + } + + let mut controller = cx.global_mut::(); + for windows in controller.tab_groups.values_mut() { + for tab in windows.iter_mut() { + if tab.id == id { + tab.title = title.clone(); + } + } + } + } + + /// Insert a tab into a tab group. + pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec) { + let mut controller = cx.global_mut::(); + let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else { + return; + }; + + let mut expected_tab_ids: Vec<_> = tabs + .iter() + .filter(|tab| tab.id != id) + .map(|tab| tab.id) + .sorted() + .collect(); + + let mut tab_group_id = None; + for (group_id, group_tabs) in &controller.tab_groups { + let tab_ids: Vec<_> = group_tabs.iter().map(|tab| tab.id).sorted().collect(); + if tab_ids == expected_tab_ids { + tab_group_id = Some(*group_id); + break; + } + } + + if let Some(tab_group_id) = tab_group_id { + if let Some(tabs) = controller.tab_groups.get_mut(&tab_group_id) { + tabs.push(tab); + } + } else { + let new_group_id = controller.tab_groups.len(); + controller.tab_groups.insert(new_group_id, tabs); + } + } + + /// Remove a tab from a tab group. + pub fn remove_tab(cx: &mut App, id: WindowId) -> Option { + let mut controller = cx.global_mut::(); + let mut removed_tab = None; + + controller.tab_groups.retain(|_, tabs| { + if let Some(pos) = tabs.iter().position(|tab| tab.id == id) { + removed_tab = Some(tabs.remove(pos)); + } + !tabs.is_empty() + }); + + removed_tab + } + + /// Move a tab to a new tab group. + pub fn move_tab_to_new_window(cx: &mut App, id: WindowId) { + let mut removed_tab = Self::remove_tab(cx, id); + let mut controller = cx.global_mut::(); + + if let Some(tab) = removed_tab { + let new_group_id = controller.tab_groups.keys().max().map_or(0, |k| k + 1); + controller.tab_groups.insert(new_group_id, vec![tab]); + } + } + + /// Merge all tab groups into a single group. + pub fn merge_all_windows(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(initial_tabs) = controller.tabs(id) else { + return; + }; + + let mut all_tabs = initial_tabs.clone(); + for tabs in controller.tab_groups.values() { + all_tabs.extend( + tabs.iter() + .filter(|tab| !initial_tabs.contains(tab)) + .cloned(), + ); + } + + controller.tab_groups.clear(); + controller.tab_groups.insert(0, all_tabs); + } + + /// Selects the next tab in the tab group in the trailing direction. + pub fn select_next_tab(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(tabs) = controller.tabs(id) else { + return; + }; + + let current_index = tabs.iter().position(|tab| tab.id == id).unwrap(); + let next_index = (current_index + 1) % tabs.len(); + + let _ = &tabs[next_index].handle.update(cx, |_, window, _| { + window.activate_window(); + }); + } + + /// Selects the previous tab in the tab group in the leading direction. + pub fn select_previous_tab(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(tabs) = controller.tabs(id) else { + return; + }; + + let current_index = tabs.iter().position(|tab| tab.id == id).unwrap(); + let previous_index = if current_index == 0 { + tabs.len() - 1 + } else { + current_index - 1 + }; + + let _ = &tabs[previous_index].handle.update(cx, |_, window, _| { + window.activate_window(); + }); + } +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. @@ -372,6 +670,7 @@ impl App { }); init_app_menus(platform.as_ref(), &app.borrow()); + SystemWindowTabController::init(&mut app.borrow_mut()); platform.on_keyboard_layout_change(Box::new({ let app = Rc::downgrade(&app); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 8e9c52c2e750e5a158f0eedd461cee8bea02e54b..eb1d73814388a26503e9ada782bc358dc712b53c 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -40,8 +40,8 @@ use crate::{ DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene, - ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window, - WindowControlArea, hash, point, px, size, + ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task, + TaskLabel, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; @@ -502,9 +502,26 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn sprite_atlas(&self) -> Arc; // macOS specific methods + fn get_title(&self) -> String { + String::new() + } + fn tabbed_windows(&self) -> Option> { + None + } + fn tab_bar_visible(&self) -> bool { + false + } fn set_edited(&mut self, _edited: bool) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} + fn on_move_tab_to_new_window(&self, _callback: Box) {} + fn on_merge_all_windows(&self, _callback: Box) {} + fn on_select_previous_tab(&self, _callback: Box) {} + fn on_select_next_tab(&self, _callback: Box) {} + fn on_toggle_tab_bar(&self, _callback: Box) {} + fn merge_all_windows(&self) {} + fn move_tab_to_new_window(&self) {} + fn toggle_window_tab_overview(&self) {} #[cfg(target_os = "windows")] fn get_raw_handle(&self) -> windows::HWND; @@ -1113,6 +1130,9 @@ pub struct WindowOptions { /// Whether to use client or server side decorations. Wayland only /// Note that this may be ignored. pub window_decorations: Option, + + /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together. + pub tabbing_identifier: Option, } /// The variables that can be configured when creating a new window @@ -1160,6 +1180,8 @@ pub(crate) struct WindowParams { pub display_id: Option, pub window_min_size: Option>, + #[cfg(target_os = "macos")] + pub tabbing_identifier: Option, } /// Represents the status of how a window should be opened. @@ -1212,6 +1234,7 @@ impl Default for WindowOptions { app_id: None, window_min_size: None, window_decorations: None, + tabbing_identifier: None, } } } diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index fbea4748a395c7377bd7ef3ca30515b7dbc5ff4b..0262cbb1213ca670cece780959c740f292764630 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -4,8 +4,10 @@ use crate::{ ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ScaledPixels, Size, Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size, + ScaledPixels, SharedString, Size, SystemWindowTab, Timer, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, + dispatch_get_main_queue, dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, + px, size, }; use block::ConcreteBlock; use cocoa::{ @@ -24,6 +26,7 @@ use cocoa::{ NSUserDefaults, }, }; + use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; use ctor::ctor; use futures::channel::oneshot; @@ -82,6 +85,12 @@ type NSDragOperation = NSUInteger; const NSDragOperationNone: NSDragOperation = 0; #[allow(non_upper_case_globals)] const NSDragOperationCopy: NSDragOperation = 1; +#[derive(PartialEq)] +pub enum UserTabbingPreference { + Never, + Always, + InFullScreen, +} #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { @@ -343,6 +352,36 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C conclude_drag_operation as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(addTitlebarAccessoryViewController:), + add_titlebar_accessory_view_controller as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(moveTabToNewWindow:), + move_tab_to_new_window as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(mergeAllWindows:), + merge_all_windows as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(selectNextTab:), + select_next_tab as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(selectPreviousTab:), + select_previous_tab as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(toggleTabBar:), + toggle_tab_bar as extern "C" fn(&Object, Sel, id), + ); + decl.register() } } @@ -375,6 +414,11 @@ struct MacWindowState { // Whether the next left-mouse click is also the focusing click. first_mouse: bool, fullscreen_restore_bounds: Bounds, + move_tab_to_new_window_callback: Option>, + merge_all_windows_callback: Option>, + select_next_tab_callback: Option>, + select_previous_tab_callback: Option>, + toggle_tab_bar_callback: Option>, } impl MacWindowState { @@ -536,6 +580,7 @@ impl MacWindow { show, display_id, window_min_size, + tabbing_identifier, }: WindowParams, executor: ForegroundExecutor, renderer_context: renderer::Context, @@ -543,7 +588,12 @@ impl MacWindow { unsafe { let pool = NSAutoreleasePool::new(nil); - let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + let allows_automatic_window_tabbing = tabbing_identifier.is_some(); + if allows_automatic_window_tabbing { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: YES]; + } else { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + } let mut style_mask; if let Some(titlebar) = titlebar.as_ref() { @@ -668,6 +718,11 @@ impl MacWindow { external_files_dragged: false, first_mouse: false, fullscreen_restore_bounds: Bounds::default(), + move_tab_to_new_window_callback: None, + merge_all_windows_callback: None, + select_next_tab_callback: None, + select_previous_tab_callback: None, + toggle_tab_bar_callback: None, }))); (*native_window).set_ivar( @@ -722,6 +777,11 @@ impl MacWindow { WindowKind::Normal => { native_window.setLevel_(NSNormalWindowLevel); native_window.setAcceptsMouseMovedEvents_(YES); + + if let Some(tabbing_identifier) = tabbing_identifier { + let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; + } } WindowKind::PopUp => { // Use a tracking area to allow receiving MouseMoved events even when @@ -750,6 +810,38 @@ impl MacWindow { } } + let app = NSApplication::sharedApplication(nil); + let main_window: id = msg_send![app, mainWindow]; + if allows_automatic_window_tabbing + && !main_window.is_null() + && main_window != native_window + { + let main_window_is_fullscreen = main_window + .styleMask() + .contains(NSWindowStyleMask::NSFullScreenWindowMask); + let user_tabbing_preference = Self::get_user_tabbing_preference() + .unwrap_or(UserTabbingPreference::InFullScreen); + let should_add_as_tab = user_tabbing_preference == UserTabbingPreference::Always + || user_tabbing_preference == UserTabbingPreference::InFullScreen + && main_window_is_fullscreen; + + if should_add_as_tab { + let main_window_can_tab: BOOL = + msg_send![main_window, respondsToSelector: sel!(addTabbedWindow:ordered:)]; + let main_window_visible: BOOL = msg_send![main_window, isVisible]; + + if main_window_can_tab == YES && main_window_visible == YES { + let _: () = msg_send![main_window, addTabbedWindow: native_window ordered: NSWindowOrderingMode::NSWindowAbove]; + + // Ensure the window is visible immediately after adding the tab, since the tab bar is updated with a new entry at this point. + // Note: Calling orderFront here can break fullscreen mode (makes fullscreen windows exit fullscreen), so only do this if the main window is not fullscreen. + if !main_window_is_fullscreen { + let _: () = msg_send![native_window, orderFront: nil]; + } + } + } + } + if focus && show { native_window.makeKeyAndOrderFront_(nil); } else if show { @@ -804,6 +896,33 @@ impl MacWindow { window_handles } } + + pub fn get_user_tabbing_preference() -> Option { + unsafe { + let defaults: id = NSUserDefaults::standardUserDefaults(); + let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); + let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + + let dict: id = msg_send![defaults, persistentDomainForName: domain]; + let value: id = if !dict.is_null() { + msg_send![dict, objectForKey: key] + } else { + nil + }; + + let value_str = if !value.is_null() { + CStr::from_ptr(NSString::UTF8String(value)).to_string_lossy() + } else { + "".into() + }; + + match value_str.as_ref() { + "manual" => Some(UserTabbingPreference::Never), + "always" => Some(UserTabbingPreference::Always), + _ => Some(UserTabbingPreference::InFullScreen), + } + } + } } impl Drop for MacWindow { @@ -859,6 +978,46 @@ impl PlatformWindow for MacWindow { .detach(); } + fn merge_all_windows(&self) { + let native_window = self.0.lock().native_window; + unsafe extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) { + let native_window = context as id; + let _: () = msg_send![native_window, mergeAllWindows:nil]; + } + + unsafe { + dispatch_async_f( + dispatch_get_main_queue(), + native_window as *mut std::ffi::c_void, + Some(merge_windows_async), + ); + } + } + + fn move_tab_to_new_window(&self) { + let native_window = self.0.lock().native_window; + unsafe extern "C" fn move_tab_async(context: *mut std::ffi::c_void) { + let native_window = context as id; + let _: () = msg_send![native_window, moveTabToNewWindow:nil]; + let _: () = msg_send![native_window, makeKeyAndOrderFront: nil]; + } + + unsafe { + dispatch_async_f( + dispatch_get_main_queue(), + native_window as *mut std::ffi::c_void, + Some(move_tab_async), + ); + } + } + + fn toggle_window_tab_overview(&self) { + let native_window = self.0.lock().native_window; + unsafe { + let _: () = msg_send![native_window, toggleTabOverview:nil]; + } + } + fn scale_factor(&self) -> f32 { self.0.as_ref().lock().scale_factor() } @@ -1059,6 +1218,17 @@ impl PlatformWindow for MacWindow { } } + fn get_title(&self) -> String { + unsafe { + let title: id = msg_send![self.0.lock().native_window, title]; + if title.is_null() { + "".to_string() + } else { + title.to_str().to_string() + } + } + } + fn set_app_id(&mut self, _app_id: &str) {} fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { @@ -1220,6 +1390,62 @@ impl PlatformWindow for MacWindow { self.0.lock().appearance_changed_callback = Some(callback); } + fn tabbed_windows(&self) -> Option> { + unsafe { + let windows: id = msg_send![self.0.lock().native_window, tabbedWindows]; + if windows.is_null() { + return None; + } + + let count: NSUInteger = msg_send![windows, count]; + let mut result = Vec::new(); + for i in 0..count { + let window: id = msg_send![windows, objectAtIndex:i]; + if msg_send![window, isKindOfClass: WINDOW_CLASS] { + let handle = get_window_state(&*window).lock().handle; + let title: id = msg_send![window, title]; + let title = SharedString::from(title.to_str().to_string()); + + result.push(SystemWindowTab::new(title, handle)); + } + } + + Some(result) + } + } + + fn tab_bar_visible(&self) -> bool { + unsafe { + let tab_group: id = msg_send![self.0.lock().native_window, tabGroup]; + if tab_group.is_null() { + false + } else { + let tab_bar_visible: BOOL = msg_send![tab_group, isTabBarVisible]; + tab_bar_visible == YES + } + } + } + + fn on_move_tab_to_new_window(&self, callback: Box) { + self.0.as_ref().lock().move_tab_to_new_window_callback = Some(callback); + } + + fn on_merge_all_windows(&self, callback: Box) { + self.0.as_ref().lock().merge_all_windows_callback = Some(callback); + } + + fn on_select_next_tab(&self, callback: Box) { + self.0.as_ref().lock().select_next_tab_callback = Some(callback); + } + + fn on_select_previous_tab(&self, callback: Box) { + self.0.as_ref().lock().select_previous_tab_callback = Some(callback); + } + + fn on_toggle_tab_bar(&self, callback: Box) { + self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback); + } + fn draw(&self, scene: &crate::Scene) { let mut this = self.0.lock(); this.renderer.draw(scene); @@ -1661,6 +1887,7 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { .occlusionState() .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) { + lock.move_traffic_light(); lock.start_display_link(); } else { lock.stop_display_link(); @@ -1722,7 +1949,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) { extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = window_state.lock(); + let mut lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; // When opening a pop-up while the application isn't active, Cocoa sends a spurious @@ -1743,9 +1970,34 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.executor.clone(); drop(lock); + + // If window is becoming active, trigger immediate synchronous frame request. + if selector == sel!(windowDidBecomeKey:) && is_active { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.lock(); + + if let Some(mut callback) = lock.request_frame_callback.take() { + #[cfg(not(feature = "macos-blade"))] + lock.renderer.set_presents_with_transaction(true); + lock.stop_display_link(); + drop(lock); + callback(Default::default()); + + let mut lock = window_state.lock(); + lock.request_frame_callback = Some(callback); + #[cfg(not(feature = "macos-blade"))] + lock.renderer.set_presents_with_transaction(false); + lock.start_display_link(); + } + } + executor .spawn(async move { let mut lock = window_state.as_ref().lock(); + if is_active { + lock.move_traffic_light(); + } + if let Some(mut callback) = lock.activate_callback.take() { drop(lock); callback(is_active); @@ -2281,3 +2533,80 @@ unsafe fn remove_layer_background(layer: id) { } } } + +extern "C" fn add_titlebar_accessory_view_controller(this: &Object, _: Sel, view_controller: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), addTitlebarAccessoryViewController: view_controller]; + + // Hide the native tab bar and set its height to 0, since we render our own. + let accessory_view: id = msg_send![view_controller, view]; + let _: () = msg_send![accessory_view, setHidden: YES]; + let mut frame: NSRect = msg_send![accessory_view, frame]; + frame.size.height = 0.0; + let _: () = msg_send![accessory_view, setFrame: frame]; + } +} + +extern "C" fn move_tab_to_new_window(this: &Object, _: Sel, _: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), moveTabToNewWindow:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.move_tab_to_new_window_callback.take() { + drop(lock); + callback(); + window_state.lock().move_tab_to_new_window_callback = Some(callback); + } + } +} + +extern "C" fn merge_all_windows(this: &Object, _: Sel, _: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), mergeAllWindows:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.merge_all_windows_callback.take() { + drop(lock); + callback(); + window_state.lock().merge_all_windows_callback = Some(callback); + } + } +} + +extern "C" fn select_next_tab(this: &Object, _sel: Sel, _id: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.select_next_tab_callback.take() { + drop(lock); + callback(); + window_state.lock().select_next_tab_callback = Some(callback); + } +} + +extern "C" fn select_previous_tab(this: &Object, _sel: Sel, _id: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.select_previous_tab_callback.take() { + drop(lock); + callback(); + window_state.lock().select_previous_tab_callback = Some(callback); + } +} + +extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), toggleTabBar:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + lock.move_traffic_light(); + + if let Some(mut callback) = lock.toggle_tab_bar_callback.take() { + drop(lock); + callback(); + window_state.lock().toggle_tab_bar_callback = Some(callback); + } + } +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index cc0db3930329fe748dc1e60cc0d613e7351e5b68..e6ea3fef07b221b96dbfe5b19f794106eb11b4dd 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -12,11 +12,11 @@ use crate::{ PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size, - StrikethroughStyle, Style, SubscriberSet, Subscription, TabHandles, TaffyLayoutEngine, Task, - TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, - transparent_black, + StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabHandles, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, + TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, + point, prelude::*, px, rems, size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -946,6 +946,8 @@ impl Window { app_id, window_min_size, window_decorations, + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + tabbing_identifier, } = options; let bounds = window_bounds @@ -964,8 +966,17 @@ impl Window { show, display_id, window_min_size, + #[cfg(target_os = "macos")] + tabbing_identifier, }, )?; + + let tab_bar_visible = platform_window.tab_bar_visible(); + SystemWindowTabController::init_visible(cx, tab_bar_visible); + if let Some(tabs) = platform_window.tabbed_windows() { + SystemWindowTabController::add_tab(cx, handle.window_id(), tabs); + } + let display_id = platform_window.display().map(|display| display.id()); let sprite_atlas = platform_window.sprite_atlas(); let mouse_position = platform_window.mouse_position(); @@ -995,9 +1006,13 @@ impl Window { } platform_window.on_close(Box::new({ + let window_id = handle.window_id(); let mut cx = cx.to_async(); move || { let _ = handle.update(&mut cx, |_, window, _| window.remove_window()); + let _ = cx.update(|cx| { + SystemWindowTabController::remove_tab(cx, window_id); + }); } })); platform_window.on_request_frame(Box::new({ @@ -1086,7 +1101,11 @@ impl Window { .activation_observers .clone() .retain(&(), |callback| callback(window, cx)); + + window.bounds_changed(cx); window.refresh(); + + SystemWindowTabController::update_last_active(cx, window.handle.id); }) .log_err(); } @@ -1127,6 +1146,57 @@ impl Window { .unwrap_or(None) }) }); + platform_window.on_move_tab_to_new_window({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::move_tab_to_new_window(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_merge_all_windows({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::merge_all_windows(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_select_next_tab({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::select_next_tab(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_select_previous_tab({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::select_previous_tab(cx, handle.window_id()) + }) + .log_err(); + }) + }); + platform_window.on_toggle_tab_bar({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, window, cx| { + let tab_bar_visible = window.platform_window.tab_bar_visible(); + SystemWindowTabController::set_visible(cx, tab_bar_visible); + }) + .log_err(); + }) + }); if let Some(app_id) = app_id { platform_window.set_app_id(&app_id); @@ -4279,11 +4349,47 @@ impl Window { } /// Perform titlebar double-click action. - /// This is MacOS specific. + /// This is macOS specific. pub fn titlebar_double_click(&self) { self.platform_window.titlebar_double_click(); } + /// Gets the window's title at the platform level. + /// This is macOS specific. + pub fn window_title(&self) -> String { + self.platform_window.get_title() + } + + /// Returns a list of all tabbed windows and their titles. + /// This is macOS specific. + pub fn tabbed_windows(&self) -> Option> { + self.platform_window.tabbed_windows() + } + + /// Returns the tab bar visibility. + /// This is macOS specific. + pub fn tab_bar_visible(&self) -> bool { + self.platform_window.tab_bar_visible() + } + + /// Merges all open windows into a single tabbed window. + /// This is macOS specific. + pub fn merge_all_windows(&self) { + self.platform_window.merge_all_windows() + } + + /// Moves the tab to a new containing window. + /// This is macOS specific. + pub fn move_tab_to_new_window(&self) { + self.platform_window.move_tab_to_new_window() + } + + /// Shows or hides the window tab overview. + /// This is macOS specific. + pub fn toggle_window_tab_overview(&self) { + self.platform_window.toggle_window_tab_overview() + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 5ad3996e784a27f0552059bf5a2e55addb11f0fd..3d7962fa17d7fa4a4c3b12e88c90adcecd06667d 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -414,7 +414,7 @@ impl RulesLibrary { }); Self { title_bar: if !cfg!(target_os = "macos") { - Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar"))) + Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))) } else { None }, diff --git a/crates/title_bar/src/platform_title_bar.rs b/crates/title_bar/src/platform_title_bar.rs index ef6ef93eed9ecd648bd5689eb14cb5cd5481463e..bc1057a4d4bd98a21031cb93d71d9f654d090b2c 100644 --- a/crates/title_bar/src/platform_title_bar.rs +++ b/crates/title_bar/src/platform_title_bar.rs @@ -1,28 +1,35 @@ use gpui::{ - AnyElement, Context, Decorations, Hsla, InteractiveElement, IntoElement, MouseButton, + AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px, }; use smallvec::SmallVec; use std::mem; use ui::prelude::*; -use crate::platforms::{platform_linux, platform_mac, platform_windows}; +use crate::{ + platforms::{platform_linux, platform_mac, platform_windows}, + system_window_tabs::SystemWindowTabs, +}; pub struct PlatformTitleBar { id: ElementId, platform_style: PlatformStyle, children: SmallVec<[AnyElement; 2]>, should_move: bool, + system_window_tabs: Entity, } impl PlatformTitleBar { - pub fn new(id: impl Into) -> Self { + pub fn new(id: impl Into, cx: &mut Context) -> Self { let platform_style = PlatformStyle::platform(); + let system_window_tabs = cx.new(|_cx| SystemWindowTabs::new()); + Self { id: id.into(), platform_style, children: SmallVec::new(), should_move: false, + system_window_tabs, } } @@ -66,7 +73,7 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); - h_flex() + let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() .h(height) @@ -162,7 +169,12 @@ impl Render for PlatformTitleBar { title_bar.child(platform_windows::WindowsWindowControls::new(height)) } } - }) + }); + + v_flex() + .w_full() + .child(title_bar) + .child(self.system_window_tabs.clone().into_any_element()) } } diff --git a/crates/title_bar/src/system_window_tabs.rs b/crates/title_bar/src/system_window_tabs.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc50fbc2b99b56c2d8dab95e0c56deb33da2bb4b --- /dev/null +++ b/crates/title_bar/src/system_window_tabs.rs @@ -0,0 +1,477 @@ +use settings::Settings; + +use gpui::{ + AnyWindowHandle, Context, Hsla, InteractiveElement, MouseButton, ParentElement, ScrollHandle, + Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div, +}; + +use theme::ThemeSettings; +use ui::{ + Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label, + LabelSize, Tab, h_flex, prelude::*, right_click_menu, +}; +use workspace::{ + CloseWindow, ItemSettings, Workspace, + item::{ClosePosition, ShowCloseButton}, +}; + +actions!( + window, + [ + ShowNextWindowTab, + ShowPreviousWindowTab, + MergeAllWindows, + MoveTabToNewWindow + ] +); + +#[derive(Clone)] +pub struct DraggedWindowTab { + pub id: WindowId, + pub ix: usize, + pub handle: AnyWindowHandle, + pub title: String, + pub width: Pixels, + pub is_active: bool, + pub active_background_color: Hsla, + pub inactive_background_color: Hsla, +} + +pub struct SystemWindowTabs { + tab_bar_scroll_handle: ScrollHandle, + measured_tab_width: Pixels, + last_dragged_tab: Option, +} + +impl SystemWindowTabs { + pub fn new() -> Self { + Self { + tab_bar_scroll_handle: ScrollHandle::new(), + measured_tab_width: px(0.), + last_dragged_tab: None, + } + } + + pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action_renderer(|div, _, window, cx| { + let window_id = window.window_handle().window_id(); + let controller = cx.global::(); + + let tab_groups = controller.tab_groups(); + let tabs = controller.tabs(window_id); + let Some(tabs) = tabs else { + return div; + }; + + div.when(tabs.len() > 1, |div| { + div.on_action(move |_: &ShowNextWindowTab, window, cx| { + SystemWindowTabController::select_next_tab( + cx, + window.window_handle().window_id(), + ); + }) + .on_action(move |_: &ShowPreviousWindowTab, window, cx| { + SystemWindowTabController::select_previous_tab( + cx, + window.window_handle().window_id(), + ); + }) + .on_action(move |_: &MoveTabToNewWindow, window, cx| { + SystemWindowTabController::move_tab_to_new_window( + cx, + window.window_handle().window_id(), + ); + window.move_tab_to_new_window(); + }) + }) + .when(tab_groups.len() > 1, |div| { + div.on_action(move |_: &MergeAllWindows, window, cx| { + SystemWindowTabController::merge_all_windows( + cx, + window.window_handle().window_id(), + ); + window.merge_all_windows(); + }) + }) + }); + }) + .detach(); + } + + fn render_tab( + &self, + ix: usize, + item: SystemWindowTab, + tabs: Vec, + active_background_color: Hsla, + inactive_background_color: Hsla, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let entity = cx.entity(); + let settings = ItemSettings::get_global(cx); + let close_side = &settings.close_position; + let show_close_button = &settings.show_close_button; + + let rem_size = window.rem_size(); + let width = self.measured_tab_width.max(rem_size * 10); + let is_active = window.window_handle().window_id() == item.id; + let title = item.title.to_string(); + + let label = Label::new(&title) + .size(LabelSize::Small) + .truncate() + .color(if is_active { + Color::Default + } else { + Color::Muted + }); + + let tab = h_flex() + .id(ix) + .group("tab") + .w_full() + .overflow_hidden() + .h(Tab::content_height(cx)) + .relative() + .px(DynamicSpacing::Base16.px(cx)) + .justify_center() + .border_l_1() + .border_color(cx.theme().colors().border) + .cursor_pointer() + .on_drag( + DraggedWindowTab { + id: item.id, + ix, + handle: item.handle, + title: item.title.to_string(), + width, + is_active, + active_background_color, + inactive_background_color, + }, + move |tab, _, _, cx| { + entity.update(cx, |this, _cx| { + this.last_dragged_tab = Some(tab.clone()); + }); + cx.new(|_| tab.clone()) + }, + ) + .drag_over::({ + let tab_ix = ix; + move |element, dragged_tab: &DraggedWindowTab, _, cx| { + let mut styled_tab = element + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + + if tab_ix < dragged_tab.ix { + styled_tab = styled_tab.border_l_2(); + } else if tab_ix > dragged_tab.ix { + styled_tab = styled_tab.border_r_2(); + } + + styled_tab + } + }) + .on_drop({ + let tab_ix = ix; + cx.listener(move |this, dragged_tab: &DraggedWindowTab, _window, cx| { + this.last_dragged_tab = None; + Self::handle_tab_drop(dragged_tab, tab_ix, cx); + }) + }) + .on_click(move |_, _, cx| { + let _ = item.handle.update(cx, |_, window, _| { + window.activate_window(); + }); + }) + .child(label) + .map(|this| match show_close_button { + ShowCloseButton::Hidden => this, + _ => this.child( + div() + .absolute() + .top_2() + .w_4() + .h_4() + .map(|this| match close_side { + ClosePosition::Left => this.left_1(), + ClosePosition::Right => this.right_1(), + }) + .child( + IconButton::new("close", IconName::Close) + .shape(IconButtonShape::Square) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .on_click({ + move |_, window, cx| { + if item.handle.window_id() + == window.window_handle().window_id() + { + window.dispatch_action(Box::new(CloseWindow), cx); + } else { + let _ = item.handle.update(cx, |_, window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }); + } + } + }) + .map(|this| match show_close_button { + ShowCloseButton::Hover => this.visible_on_hover("tab"), + _ => this, + }), + ), + ), + }) + .into_any(); + + let menu = right_click_menu(ix) + .trigger(|_, _, _| tab) + .menu(move |window, cx| { + let focus_handle = cx.focus_handle(); + let tabs = tabs.clone(); + let other_tabs = tabs.clone(); + let move_tabs = tabs.clone(); + let merge_tabs = tabs.clone(); + + ContextMenu::build(window, cx, move |mut menu, _window_, _cx| { + menu = menu.entry("Close Tab", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &tabs, + |tab| tab.id == item.id, + |window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }, + ); + }); + + menu = menu.entry("Close Other Tabs", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &other_tabs, + |tab| tab.id != item.id, + |window, cx| { + window.dispatch_action(Box::new(CloseWindow), cx); + }, + ); + }); + + menu = menu.entry("Move Tab to New Window", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &move_tabs, + |tab| tab.id == item.id, + |window, cx| { + SystemWindowTabController::move_tab_to_new_window( + cx, + window.window_handle().window_id(), + ); + window.move_tab_to_new_window(); + }, + ); + }); + + menu = menu.entry("Show All Tabs", None, move |window, cx| { + Self::handle_right_click_action( + cx, + window, + &merge_tabs, + |tab| tab.id == item.id, + |window, _cx| { + window.toggle_window_tab_overview(); + }, + ); + }); + + menu.context(focus_handle) + }) + }); + + div() + .flex_1() + .min_w(rem_size * 10) + .when(is_active, |this| this.bg(active_background_color)) + .border_t_1() + .border_color(if is_active { + active_background_color + } else { + cx.theme().colors().border + }) + .child(menu) + } + + fn handle_tab_drop(dragged_tab: &DraggedWindowTab, ix: usize, cx: &mut Context) { + SystemWindowTabController::update_tab_position(cx, dragged_tab.id, ix); + } + + fn handle_right_click_action( + cx: &mut App, + window: &mut Window, + tabs: &Vec, + predicate: P, + mut action: F, + ) where + P: Fn(&SystemWindowTab) -> bool, + F: FnMut(&mut Window, &mut App), + { + for tab in tabs { + if predicate(tab) { + if tab.id == window.window_handle().window_id() { + action(window, cx); + } else { + let _ = tab.handle.update(cx, |_view, window, cx| { + action(window, cx); + }); + } + } + } + } +} + +impl Render for SystemWindowTabs { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let active_background_color = cx.theme().colors().title_bar_background; + let inactive_background_color = cx.theme().colors().tab_bar_background; + let entity = cx.entity(); + + let controller = cx.global::(); + let visible = controller.is_visible(); + let current_window_tab = vec![SystemWindowTab::new( + SharedString::from(window.window_title()), + window.window_handle(), + )]; + let tabs = controller + .tabs(window.window_handle().window_id()) + .unwrap_or(¤t_window_tab) + .clone(); + + let tab_items = tabs + .iter() + .enumerate() + .map(|(ix, item)| { + self.render_tab( + ix, + item.clone(), + tabs.clone(), + active_background_color, + inactive_background_color, + window, + cx, + ) + }) + .collect::>(); + + let number_of_tabs = tab_items.len().max(1); + if !window.tab_bar_visible() && !visible { + return h_flex().into_any_element(); + } + + h_flex() + .w_full() + .h(Tab::container_height(cx)) + .bg(inactive_background_color) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _event, window, cx| { + if let Some(tab) = this.last_dragged_tab.take() { + SystemWindowTabController::move_tab_to_new_window(cx, tab.id); + if tab.id == window.window_handle().window_id() { + window.move_tab_to_new_window(); + } else { + let _ = tab.handle.update(cx, |_, window, _cx| { + window.move_tab_to_new_window(); + }); + } + } + }), + ) + .child( + h_flex() + .id("window tabs") + .w_full() + .h(Tab::container_height(cx)) + .bg(inactive_background_color) + .overflow_x_scroll() + .track_scroll(&self.tab_bar_scroll_handle) + .children(tab_items) + .child( + canvas( + |_, _, _| (), + move |bounds, _, _, cx| { + let entity = entity.clone(); + entity.update(cx, |this, cx| { + let width = bounds.size.width / number_of_tabs as f32; + if width != this.measured_tab_width { + this.measured_tab_width = width; + cx.notify(); + } + }); + }, + ) + .absolute() + .size_full(), + ), + ) + .child( + h_flex() + .h_full() + .px(DynamicSpacing::Base06.rems(cx)) + .border_t_1() + .border_l_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("plus", IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(|_event, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenRecent { + create_new_window: true, + }), + cx, + ); + }), + ), + ) + .into_any_element() + } +} + +impl Render for DraggedWindowTab { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); + let label = Label::new(self.title.clone()) + .size(LabelSize::Small) + .truncate() + .color(if self.is_active { + Color::Default + } else { + Color::Muted + }); + + h_flex() + .h(Tab::container_height(cx)) + .w(self.width) + .px(DynamicSpacing::Base16.px(cx)) + .justify_center() + .bg(if self.is_active { + self.active_background_color + } else { + self.inactive_background_color + }) + .border_1() + .border_color(cx.theme().colors().border) + .font(ui_font) + .child(label) + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b08f139b25e53ef7e4761e55ad2686b3425969e9..ac5e9201b3be083fef43e58c2e717cb59a0ba185 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -3,6 +3,7 @@ mod collab; mod onboarding_banner; pub mod platform_title_bar; mod platforms; +mod system_window_tabs; mod title_bar_settings; #[cfg(feature = "stories")] @@ -11,6 +12,7 @@ mod stories; use crate::{ application_menu::{ApplicationMenu, show_menus}, platform_title_bar::PlatformTitleBar, + system_window_tabs::SystemWindowTabs, }; #[cfg(not(target_os = "macos"))] @@ -65,6 +67,7 @@ actions!( pub fn init(cx: &mut App) { TitleBarSettings::register(cx); + SystemWindowTabs::init(cx); cx.observe_new(|workspace: &mut Workspace, window, cx| { let Some(window) = window else { @@ -284,7 +287,7 @@ impl TitleBar { ) }); - let platform_titlebar = cx.new(|_| PlatformTitleBar::new(id)); + let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx)); Self { platform_titlebar, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 25e2cb1cfe934a88ec4cc3811bf3216e0765c0af..1fee2793f0c50efee9f2fd8040d3bcf8df2af08a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -42,9 +42,9 @@ use gpui::{ Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, - PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, Task, - Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas, - point, relative, size, transparent_black, + PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, + SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, + WindowOptions, actions, canvas, point, relative, size, transparent_black, }; pub use history_manager::*; pub use item::{ @@ -4375,6 +4375,11 @@ impl Workspace { return; } window.set_window_title(&title); + SystemWindowTabController::update_tab_title( + cx, + window.window_handle().window_id(), + SharedString::from(&title), + ); self.last_window_title = Some(title); } @@ -5797,17 +5802,22 @@ impl Workspace { return; }; let windows = cx.windows(); - let Some(next_window) = windows - .iter() - .cycle() - .skip_while(|window| window.window_id() != current_window_id) - .nth(1) - else { - return; - }; - next_window - .update(cx, |_, window, _| window.activate_window()) - .ok(); + let next_window = + SystemWindowTabController::get_next_tab_group_window(cx, current_window_id).or_else( + || { + windows + .iter() + .cycle() + .skip_while(|window| window.window_id() != current_window_id) + .nth(1) + }, + ); + + if let Some(window) = next_window { + window + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } } pub fn activate_previous_window(&mut self, cx: &mut Context) { @@ -5815,18 +5825,23 @@ impl Workspace { return; }; let windows = cx.windows(); - let Some(prev_window) = windows - .iter() - .rev() - .cycle() - .skip_while(|window| window.window_id() != current_window_id) - .nth(1) - else { - return; - }; - prev_window - .update(cx, |_, window, _| window.activate_window()) - .ok(); + let prev_window = + SystemWindowTabController::get_prev_tab_group_window(cx, current_window_id).or_else( + || { + windows + .iter() + .rev() + .cycle() + .skip_while(|window| window.window_id() != current_window_id) + .nth(1) + }, + ); + + if let Some(window) = prev_window { + window + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } } pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 3b6bc1ea970d0e7502e36f75630c9e8dd05906b5..0d7fb9bb9c1ae6f8ff4a6644132c4a347da4117d 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -29,6 +29,7 @@ pub struct WorkspaceSettings { pub on_last_window_closed: OnLastWindowClosed, pub resize_all_panels_in_dock: Vec, pub close_on_file_delete: bool, + pub use_system_window_tabs: bool, pub zoomed_padding: bool, } @@ -203,6 +204,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_on_file_delete: Option, + /// Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + /// + /// Default: false + pub use_system_window_tabs: 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). @@ -357,6 +362,8 @@ impl Settings for WorkspaceSettings { current.max_tabs = Some(n) } + vscode.bool_setting("window.nativeTabs", &mut current.use_system_window_tabs); + // some combination of "window.restoreWindows" and "workbench.startupEditor" might // map to our "restore_on_startup" diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e99c8b564b2cd7915fc352f9caee97b77eccaaf5..5e7934c3094755b39535ef054f077dbc9fb180af 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -2,7 +2,7 @@ mod reliability; mod zed; use agent_ui::AgentPanel; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Error, Result}; use clap::{Parser, command}; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; use client::{Client, ProxySettings, UserStore, parse_zed_link}; @@ -947,9 +947,13 @@ async fn installation_id() -> Result { async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp) -> Result<()> { if let Some(locations) = restorable_workspace_locations(cx, &app_state).await { + let use_system_window_tabs = cx + .update(|cx| WorkspaceSettings::get(None, cx).use_system_window_tabs) + .unwrap_or(false); + let mut results: Vec> = Vec::new(); let mut tasks = Vec::new(); - for (location, paths) in locations { + for (index, (location, paths)) in locations.into_iter().enumerate() { match location { SerializedWorkspaceLocation::Local => { let app_state = app_state.clone(); @@ -964,7 +968,14 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp })?; open_task.await.map(|_| ()) }); - tasks.push(task); + + // If we're using system window tabs and this is the first workspace, + // wait for it to finish so that the other windows can be added as tabs. + if use_system_window_tabs && index == 0 { + results.push(task.await); + } else { + tasks.push(task); + } } SerializedWorkspaceLocation::Ssh(ssh) => { let app_state = app_state.clone(); @@ -998,7 +1009,7 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp } // Wait for all workspaces to open concurrently - let results = future::join_all(tasks).await; + results.extend(future::join_all(tasks).await); // Show notifications for any errors that occurred let mut error_count = 0; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index b0146cfd2bec5db501a638e125f3e95711db4842..a12249d6a4841691fb302f8cb26b28951f829c3d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -282,6 +282,8 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO _ => gpui::WindowDecorations::Client, }; + let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs; + WindowOptions { titlebar: Some(TitlebarOptions { title: None, @@ -301,6 +303,11 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO width: px(360.0), height: px(240.0), }), + tabbing_identifier: if use_system_window_tabs { + Some(String::from("zed")) + } else { + None + }, ..Default::default() } } @@ -4507,6 +4514,7 @@ mod tests { "zed", "zed_predict_onboarding", "zeta", + "window", ]; assert_eq!( all_namespaces, diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 9634ca0f6cd93db963111fa464890a3c26478cb3..fb9306acc5a4b21b709904618a6438e58c30039f 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1421,6 +1421,16 @@ or Each option controls displaying of a particular toolbar element. If all elements are hidden, the editor toolbar is not displayed. +## Use System Tabs + +- Description: Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). +- Setting: `use_system_window_tabs` +- Default: `false` + +**Options** + +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. + ## Enable Language Server - Description: Whether or not to use language servers to provide code intelligence.