Detailed changes
@@ -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.
@@ -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()
}
}
@@ -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()
}
}
@@ -62,6 +62,7 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds<Pixels>) -> Window
app_id: None,
window_min_size: None,
window_decorations: None,
+ tabbing_identifier: None,
..Default::default()
}
}
@@ -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<dyn FnMut(&mut App)>;
type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut App) + 'static>;
type NewEntityListener = Box<dyn FnMut(AnyEntity, &mut Option<&mut Window>, &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<bool>,
+ tab_groups: FxHashMap<usize, Vec<SystemWindowTab>>,
+}
+
+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<usize, Vec<SystemWindowTab>> {
+ &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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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<SystemWindowTab>> {
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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<SystemWindowTab>) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ 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<SystemWindowTab> {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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::<SystemWindowTabController>();
+ 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);
@@ -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<dyn PlatformAtlas>;
// macOS specific methods
+ fn get_title(&self) -> String {
+ String::new()
+ }
+ fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+ 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<dyn FnMut()>) {}
+ fn on_merge_all_windows(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_select_previous_tab(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_select_next_tab(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_toggle_tab_bar(&self, _callback: Box<dyn FnMut()>) {}
+ 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<WindowDecorations>,
+
+ /// 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<String>,
}
/// The variables that can be configured when creating a new window
@@ -1160,6 +1180,8 @@ pub(crate) struct WindowParams {
pub display_id: Option<DisplayId>,
pub window_min_size: Option<Size<Pixels>>,
+ #[cfg(target_os = "macos")]
+ pub tabbing_identifier: Option<String>,
}
/// 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,
}
}
}
@@ -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<Pixels>,
+ move_tab_to_new_window_callback: Option<Box<dyn FnMut()>>,
+ merge_all_windows_callback: Option<Box<dyn FnMut()>>,
+ select_next_tab_callback: Option<Box<dyn FnMut()>>,
+ select_previous_tab_callback: Option<Box<dyn FnMut()>>,
+ toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
}
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<UserTabbingPreference> {
+ 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<Vec<SystemWindowTab>> {
+ 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<dyn FnMut()>) {
+ self.0.as_ref().lock().move_tab_to_new_window_callback = Some(callback);
+ }
+
+ fn on_merge_all_windows(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().merge_all_windows_callback = Some(callback);
+ }
+
+ fn on_select_next_tab(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().select_next_tab_callback = Some(callback);
+ }
+
+ fn on_select_previous_tab(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().select_previous_tab_callback = Some(callback);
+ }
+
+ fn on_toggle_tab_bar(&self, callback: Box<dyn FnMut()>) {
+ 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);
+ }
+ }
+}
@@ -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<Vec<SystemWindowTab>> {
+ 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) {
@@ -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
},
@@ -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<SystemWindowTabs>,
}
impl PlatformTitleBar {
- pub fn new(id: impl Into<ElementId>) -> Self {
+ pub fn new(id: impl Into<ElementId>, cx: &mut Context<Self>) -> 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())
}
}
@@ -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<DraggedWindowTab>,
+}
+
+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::<SystemWindowTabController>();
+
+ 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<SystemWindowTab>,
+ active_background_color: Hsla,
+ inactive_background_color: Hsla,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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::<DraggedWindowTab>({
+ 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<Self>) {
+ SystemWindowTabController::update_tab_position(cx, dragged_tab.id, ix);
+ }
+
+ fn handle_right_click_action<F, P>(
+ cx: &mut App,
+ window: &mut Window,
+ tabs: &Vec<SystemWindowTab>,
+ 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<Self>) -> 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::<SystemWindowTabController>();
+ 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::<Vec<_>>();
+
+ 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<Self>,
+ ) -> 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)
+ }
+}
@@ -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,
@@ -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<Self>) {
@@ -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<Self>) {
@@ -29,6 +29,7 @@ pub struct WorkspaceSettings {
pub on_last_window_closed: OnLastWindowClosed,
pub resize_all_panels_in_dock: Vec<DockPosition>,
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<bool>,
+ /// Whether to allow windows to tab together based on the userβs tabbing preference (macOS only).
+ ///
+ /// Default: false
+ pub use_system_window_tabs: Option<bool>,
/// 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"
@@ -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<IdType> {
async fn restore_or_create_workspace(app_state: Arc<AppState>, 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<Result<(), Error>> = 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<AppState>, 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<AppState>, 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;
@@ -282,6 +282,8 @@ pub fn build_window_options(display_uuid: Option<Uuid>, 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<Uuid>, 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,
@@ -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.