diff --git a/assets/settings/default.json b/assets/settings/default.json index 804198090f..ef57412842 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 68480c047f..948552540a 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, } } } diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index f9a2fa4925..dbb9106e17 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -66,5 +66,6 @@ fn notification_window_options( app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, } } diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 0f0bb8ac28..25a3ff1e09 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, } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index bbd59fa7bc..d6f471b3f4 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, Point, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, - SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, - WindowHandle, WindowId, WindowInvalidator, + 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]. @@ -369,6 +667,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 4d2feeaf1d..410ee3f531 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; @@ -500,9 +500,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; @@ -1105,6 +1122,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 @@ -1144,6 +1164,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. @@ -1194,6 +1216,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 4425d4fe24..9a0af28d18 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 { @@ -534,6 +578,7 @@ impl MacWindow { show, display_id, window_min_size, + tabbing_identifier, }: WindowParams, executor: ForegroundExecutor, renderer_context: renderer::Context, @@ -541,7 +586,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() { @@ -660,6 +710,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( @@ -714,6 +769,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 @@ -742,6 +802,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 { @@ -796,6 +888,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 { @@ -851,6 +970,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() } @@ -1051,6 +1210,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) { @@ -1212,6 +1382,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); @@ -1653,6 +1879,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(); @@ -1714,7 +1941,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 @@ -1735,9 +1962,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); @@ -2273,3 +2525,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 0791dcc621..6ddb9c83b4 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}; @@ -944,6 +944,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 @@ -960,8 +962,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(); @@ -991,9 +1002,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({ @@ -1082,7 +1097,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(); } @@ -1123,6 +1142,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); @@ -4275,11 +4345,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 5ad3996e78..3d7962fa17 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 ef6ef93eed..bc1057a4d4 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 0000000000..cc50fbc2b9 --- /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 b84a2800b6..1b65ab2d12 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 044601df97..061258022a 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 3b6bc1ea97..0d7fb9bb9c 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 e99c8b564b..5e7934c309 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 638e1dca0e..f442e032e6 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 + }, } } diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index a8a4689689..a169968f69 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1249,6 +1249,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.