diff --git a/assets/settings/default.json b/assets/settings/default.json index 97c74af5ad6b158a8658a944bdc0e5e16982e91f..959af6a021b0312fda29ece92bc3d31b2bd3c7d7 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -460,6 +460,8 @@ "show_sign_in": true, // Whether to show the menus in the titlebar. "show_menus": false, + // The layout of window control buttons in the title bar (Linux only). + "button_layout": "platform_default", }, "audio": { // Opt into the new audio system. diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 882f3532da4a75606335d70a1063a5aff5e320c0..5536410b087b8359aab2bcc14c590ba1afd46798 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -49,7 +49,8 @@ use crate::{ PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, + WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -1177,6 +1178,11 @@ impl App { self.platform.window_appearance() } + /// Returns the window button layout configuration when supported. + pub fn button_layout(&self) -> Option { + self.platform.button_layout() + } + /// Reads data from the platform clipboard. pub fn read_from_clipboard(&self) -> Option { self.platform.read_from_clipboard() diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index c30a76bd9c8861d4d5b4d9dc4b5893ffeb2eb4b8..c2c74a0d57c8f0abff26ff0d19f6ef4de9e95244 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -479,6 +479,24 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Registers a callback to be invoked when the window button layout changes. + pub fn observe_button_layout_changed( + &self, + window: &mut Window, + mut callback: impl FnMut(&mut T, &mut Window, &mut Context) + 'static, + ) -> Subscription { + let view = self.weak_entity(); + let (subscription, activate) = window.button_layout_observers.insert( + (), + Box::new(move |window, cx| { + view.update(cx, |view, cx| callback(view, window, cx)) + .is_ok() + }), + ); + activate(); + subscription + } + /// Register a callback to be invoked when a keystroke is received by the application /// in any window. Note that this fires after all other action and event mechanisms have resolved /// and that this API will not be invoked if the event's propagation is stopped. diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index cd0b74a2c5d2f7d0233aec18509aa0f9f5e5c3a2..4e0ef75536964b88b074584965b1969398a4347d 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -37,6 +37,8 @@ use crate::{ ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use anyhow::bail; use async_task::Runnable; use futures::channel::oneshot; #[cfg(any(test, feature = "test-support"))] @@ -156,6 +158,11 @@ pub trait Platform: 'static { /// Returns the appearance of the application's windows. fn window_appearance(&self) -> WindowAppearance; + /// Returns the window button layout configuration when supported. + fn button_layout(&self) -> Option { + None + } + fn open_url(&self, url: &str); fn on_open_urls(&self, callback: Box)>); fn register_url_scheme(&self, url: &str) -> Task>; @@ -407,6 +414,145 @@ impl Default for WindowControls { } } +/// A window control button type used in [`WindowButtonLayout`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WindowButton { + /// The minimize button + Minimize, + /// The maximize button + Maximize, + /// The close button + Close, +} + +impl WindowButton { + /// Returns a stable element ID for rendering this button. + pub fn id(&self) -> &'static str { + match self { + WindowButton::Minimize => "minimize", + WindowButton::Maximize => "maximize", + WindowButton::Close => "close", + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn index(&self) -> usize { + match self { + WindowButton::Minimize => 0, + WindowButton::Maximize => 1, + WindowButton::Close => 2, + } + } +} + +/// Maximum number of [`WindowButton`]s per side in the titlebar. +pub const MAX_BUTTONS_PER_SIDE: usize = 3; + +/// Describes which [`WindowButton`]s appear on each side of the titlebar. +/// +/// On Linux, this is read from the desktop environment's configuration +/// (e.g. GNOME's `gtk-decoration-layout` gsetting) via [`WindowButtonLayout::parse`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowButtonLayout { + /// Buttons on the left side of the titlebar. + pub left: [Option; MAX_BUTTONS_PER_SIDE], + /// Buttons on the right side of the titlebar. + pub right: [Option; MAX_BUTTONS_PER_SIDE], +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +impl WindowButtonLayout { + /// Returns Zed's built-in fallback button layout for Linux titlebars. + pub fn linux_default() -> Self { + Self { + left: [None; MAX_BUTTONS_PER_SIDE], + right: [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close), + ], + } + } + + /// Parses a GNOME-style `button-layout` string (e.g. `"close,minimize:maximize"`). + pub fn parse(layout_string: &str) -> Result { + fn parse_side( + s: &str, + seen_buttons: &mut [bool; MAX_BUTTONS_PER_SIDE], + unrecognized: &mut Vec, + ) -> [Option; MAX_BUTTONS_PER_SIDE] { + let mut result = [None; MAX_BUTTONS_PER_SIDE]; + let mut i = 0; + for name in s.split(',') { + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + let button = match trimmed { + "minimize" => Some(WindowButton::Minimize), + "maximize" => Some(WindowButton::Maximize), + "close" => Some(WindowButton::Close), + other => { + unrecognized.push(other.to_string()); + None + } + }; + if let Some(button) = button { + if seen_buttons[button.index()] { + continue; + } + if let Some(slot) = result.get_mut(i) { + *slot = Some(button); + seen_buttons[button.index()] = true; + i += 1; + } + } + } + result + } + + let (left_str, right_str) = layout_string.split_once(':').unwrap_or(("", layout_string)); + let mut unrecognized = Vec::new(); + let mut seen_buttons = [false; MAX_BUTTONS_PER_SIDE]; + let layout = Self { + left: parse_side(left_str, &mut seen_buttons, &mut unrecognized), + right: parse_side(right_str, &mut seen_buttons, &mut unrecognized), + }; + + if !unrecognized.is_empty() + && layout.left.iter().all(Option::is_none) + && layout.right.iter().all(Option::is_none) + { + bail!( + "button layout string {:?} contains no valid buttons (unrecognized: {})", + layout_string, + unrecognized.join(", ") + ); + } + + Ok(layout) + } + + /// Formats the layout back into a GNOME-style `button-layout` string. + #[cfg(test)] + pub fn format(&self) -> String { + fn format_side(buttons: &[Option; MAX_BUTTONS_PER_SIDE]) -> String { + buttons + .iter() + .flatten() + .map(|button| match button { + WindowButton::Minimize => "minimize", + WindowButton::Maximize => "maximize", + WindowButton::Close => "close", + }) + .collect::>() + .join(",") + } + + format!("{}:{}", format_side(&self.left), format_side(&self.right)) + } +} + /// A type to describe which sides of the window are currently tiled in some way #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)] pub struct Tiling { @@ -488,6 +634,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn on_hit_test_window_control(&self, callback: Box Option>); fn on_close(&self, callback: Box); fn on_appearance_changed(&self, callback: Box); + fn on_button_layout_changed(&self, _callback: Box) {} fn draw(&self, scene: &Scene); fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; @@ -2023,3 +2170,185 @@ impl From for ClipboardString { } } } + +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_window_button_layout_parse_standard() { + let layout = WindowButtonLayout::parse("close,minimize:maximize").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_right_only() { + let layout = WindowButtonLayout::parse("minimize,maximize,close").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close) + ] + ); + } + + #[test] + fn test_window_button_layout_parse_left_only() { + let layout = WindowButtonLayout::parse("close,minimize,maximize:").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + Some(WindowButton::Maximize) + ] + ); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_with_whitespace() { + let layout = WindowButtonLayout::parse(" close , minimize : maximize ").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_empty() { + let layout = WindowButtonLayout::parse("").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_intentionally_empty() { + let layout = WindowButtonLayout::parse(":").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_invalid_buttons() { + let layout = WindowButtonLayout::parse("close,invalid,minimize:maximize,foo").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_deduplicates_same_side_buttons() { + let layout = WindowButtonLayout::parse("close,close,minimize").unwrap(); + assert_eq!( + layout.right, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.format(), ":close,minimize"); + } + + #[test] + fn test_window_button_layout_parse_deduplicates_buttons_across_sides() { + let layout = WindowButtonLayout::parse("close:maximize,close,minimize").unwrap(); + assert_eq!(layout.left, [Some(WindowButton::Close), None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Maximize), + Some(WindowButton::Minimize), + None + ] + ); + + let button_ids: Vec<_> = layout + .left + .iter() + .chain(layout.right.iter()) + .flatten() + .map(WindowButton::id) + .collect(); + let unique_button_ids = button_ids.iter().copied().collect::>(); + assert_eq!(unique_button_ids.len(), button_ids.len()); + assert_eq!(layout.format(), "close:maximize,minimize"); + } + + #[test] + fn test_window_button_layout_parse_gnome_style() { + let layout = WindowButtonLayout::parse("close").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [Some(WindowButton::Close), None, None]); + } + + #[test] + fn test_window_button_layout_parse_elementary_style() { + let layout = WindowButtonLayout::parse("close:maximize").unwrap(); + assert_eq!(layout.left, [Some(WindowButton::Close), None, None]); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_round_trip() { + let cases = [ + "close:minimize,maximize", + "minimize,maximize,close:", + ":close", + "close:", + "close:maximize", + ":", + ]; + + for case in cases { + let layout = WindowButtonLayout::parse(case).unwrap(); + assert_eq!(layout.format(), case, "Round-trip failed for: {}", case); + } + } + + #[test] + fn test_window_button_layout_linux_default() { + let layout = WindowButtonLayout::linux_default(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close) + ] + ); + + let round_tripped = WindowButtonLayout::parse(&layout.format()).unwrap(); + assert_eq!(round_tripped, layout); + } + + #[test] + fn test_window_button_layout_parse_all_invalid() { + assert!(WindowButtonLayout::parse("asdfghjkl").is_err()); + } +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 8be228c38ccef26115bdfceff69cb0502c564fd7..088dabb3c0cefa2fc6b25c984e46e8e2f6a6b081 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -951,6 +951,7 @@ pub struct Window { pub(crate) bounds_observers: SubscriberSet<(), AnyObserver>, appearance: WindowAppearance, pub(crate) appearance_observers: SubscriberSet<(), AnyObserver>, + pub(crate) button_layout_observers: SubscriberSet<(), AnyObserver>, active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, @@ -1288,6 +1289,14 @@ impl Window { .log_err(); } })); + platform_window.on_button_layout_changed(Box::new({ + let mut cx = cx.to_async(); + move || { + handle + .update(&mut cx, |_, window, cx| window.button_layout_changed(cx)) + .log_err(); + } + })); platform_window.on_active_status_change(Box::new({ let mut cx = cx.to_async(); move |active| { @@ -1442,6 +1451,7 @@ impl Window { bounds_observers: SubscriberSet::new(), appearance, appearance_observers: SubscriberSet::new(), + button_layout_observers: SubscriberSet::new(), active, hovered, needs_present, @@ -1534,6 +1544,22 @@ impl Window { subscription } + /// Registers a callback to be invoked when the window button layout changes. + pub fn observe_button_layout_changed( + &self, + mut callback: impl FnMut(&mut Window, &mut App) + 'static, + ) -> Subscription { + let (subscription, activate) = self.button_layout_observers.insert( + (), + Box::new(move |window, cx| { + callback(window, cx); + true + }), + ); + activate(); + subscription + } + /// Replaces the root entity of the window with a new one. pub fn replace_root( &mut self, @@ -1956,6 +1982,12 @@ impl Window { .retain(&(), |callback| callback(self, cx)); } + pub(crate) fn button_layout_changed(&mut self, cx: &mut App) { + self.button_layout_observers + .clone() + .retain(&(), |callback| callback(self, cx)); + } + /// Returns the appearance of the current window. pub fn appearance(&self) -> WindowAppearance { self.appearance diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 633e0245602cb54c5066c67a1730c4554dfb5960..e3c947bcb9d33389faa354df1a83ae6419650ba8 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -26,7 +26,8 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, + WindowButtonLayout, WindowParams, }; #[cfg(any(feature = "wayland", feature = "x11"))] use gpui::{Pixels, Point, px}; @@ -114,6 +115,7 @@ pub(crate) struct LinuxCommon { pub(crate) text_system: Arc, pub(crate) appearance: WindowAppearance, pub(crate) auto_hide_scrollbars: bool, + pub(crate) button_layout: WindowButtonLayout, pub(crate) callbacks: PlatformHandlers, pub(crate) signal: LoopSignal, pub(crate) menus: Vec, @@ -140,6 +142,7 @@ impl LinuxCommon { text_system, appearance: WindowAppearance::Light, auto_hide_scrollbars: false, + button_layout: WindowButtonLayout::linux_default(), callbacks, signal, menus: Vec::new(), @@ -601,6 +604,10 @@ impl Platform for LinuxPlatform

{ self.inner.with_common(|common| common.appearance) } + fn button_layout(&self) -> Option { + Some(self.inner.with_common(|common| common.button_layout)) + } + fn register_url_scheme(&self, _: &str) -> Task> { Task::ready(Err(anyhow!("register_url_scheme unimplemented"))) } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 49e6e835508e1511771656bdd3b52dcfb86cfaa3..0d07c60650722e6bedc78d3f33d0ac458dca5e02 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -95,8 +95,8 @@ use gpui::{ ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, - ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowParams, point, - profiler, px, size, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowButtonLayout, + WindowParams, point, profiler, px, size, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; use wayland_protocols::wp::linux_dmabuf::zv1::client::{ @@ -567,6 +567,19 @@ impl WaylandClient { } } } + XDPEvent::ButtonLayout(layout_str) => { + if let Some(client) = client.0.upgrade() { + let layout = WindowButtonLayout::parse(&layout_str) + .log_err() + .unwrap_or_else(WindowButtonLayout::linux_default); + let mut client = client.borrow_mut(); + client.common.button_layout = layout; + + for window in client.windows.values_mut() { + window.set_button_layout(); + } + } + } XDPEvent::CursorTheme(theme) => { if let Some(client) = client.0.upgrade() { let mut client = client.borrow_mut(); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 189ef91e6005b73801fa4be3b1f152ffe3952ff9..c79fbbb3eb2f5ec99b808d6265d6536aa72a7a18 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -50,6 +50,7 @@ pub(crate) struct Callbacks { should_close: Option bool>>, close: Option>, appearance_changed: Option>, + button_layout_changed: Option>, } #[derive(Debug, Clone, Copy)] @@ -1038,6 +1039,14 @@ impl WaylandWindowStatePtr { } } + pub fn set_button_layout(&self) { + let callback = self.callbacks.borrow_mut().button_layout_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().button_layout_changed = Some(fun); + } + } + pub fn primary_output_scale(&self) -> i32 { self.state.borrow_mut().primary_output_scale() } @@ -1335,6 +1344,10 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } + fn on_button_layout_changed(&self, callback: Box) { + self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); + } + fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 77f154201d3af6bb7504349e579a5be6b4edcbb5..afe6ee129cbeb393594cf661349bd76bde34ceab 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -62,7 +62,7 @@ use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, - TouchPhase, WindowParams, point, px, + TouchPhase, WindowButtonLayout, WindowParams, point, px, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -472,6 +472,15 @@ impl X11Client { window.window.set_appearance(appearance); } } + XDPEvent::ButtonLayout(layout_str) => { + let layout = WindowButtonLayout::parse(&layout_str) + .log_err() + .unwrap_or_else(WindowButtonLayout::linux_default); + client.with_common(|common| common.button_layout = layout); + for window in client.0.borrow_mut().windows.values_mut() { + window.window.set_button_layout(); + } + } XDPEvent::CursorTheme(_) | XDPEvent::CursorSize(_) => { // noop, X11 manages this for us. } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 689a652b918e7bc1e793d19b66d954eaa6277a6e..e0095716c8fd0670ad93484fb8a21a5b86ed4a43 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -250,6 +250,7 @@ pub struct Callbacks { should_close: Option bool>>, close: Option>, appearance_changed: Option>, + button_layout_changed: Option>, } pub struct X11WindowState { @@ -1256,6 +1257,14 @@ impl X11WindowStatePtr { self.callbacks.borrow_mut().appearance_changed = Some(fun); } } + + pub fn set_button_layout(&self) { + let callback = self.callbacks.borrow_mut().button_layout_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().button_layout_changed = Some(fun); + } + } } impl PlatformWindow for X11Window { @@ -1602,6 +1611,10 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } + fn on_button_layout_changed(&self, callback: Box) { + self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); + } + fn draw(&self, scene: &Scene) { let mut inner = self.0.state.borrow_mut(); diff --git a/crates/gpui_linux/src/linux/xdg_desktop_portal.rs b/crates/gpui_linux/src/linux/xdg_desktop_portal.rs index 911ac319db2b2a803a5e5e715f7a04f8cb128d7a..9b5d72476b61e81ce1d90d79de9286539060c0ba 100644 --- a/crates/gpui_linux/src/linux/xdg_desktop_portal.rs +++ b/crates/gpui_linux/src/linux/xdg_desktop_portal.rs @@ -15,6 +15,7 @@ pub enum Event { CursorTheme(String), #[cfg_attr(feature = "x11", allow(dead_code))] CursorSize(u32), + ButtonLayout(String), } pub struct XDPEventSource { @@ -51,6 +52,13 @@ impl XDPEventSource { sender.send(Event::CursorSize(initial_size as u32))?; } + if let Ok(initial_layout) = settings + .read::("org.gnome.desktop.wm.preferences", "button-layout") + .await + { + sender.send(Event::ButtonLayout(initial_layout))?; + } + if let Ok(mut cursor_theme_changed) = settings .receive_setting_changed_with_args( "org.gnome.desktop.interface", @@ -89,6 +97,25 @@ impl XDPEventSource { .detach(); } + if let Ok(mut button_layout_changed) = settings + .receive_setting_changed_with_args( + "org.gnome.desktop.wm.preferences", + "button-layout", + ) + .await + { + let sender = sender.clone(); + background + .spawn(async move { + while let Some(layout) = button_layout_changed.next().await { + let layout = layout?; + sender.send(Event::ButtonLayout(layout))?; + } + anyhow::Ok(()) + }) + .detach(); + } + let mut appearance_changed = settings.receive_color_scheme_changed().await?; while let Some(scheme) = appearance_changed.next().await { sender.send(Event::WindowAppearance( diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index f315aa411896c5fd80e83da5602000a2b24c2719..c5c9f94290c0e96a63fb71a098ea7ea29ec1e3cd 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -3,9 +3,9 @@ mod system_window_tabs; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, - px, + Action, AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowButtonLayout, + WindowControlArea, div, px, }; use project::DisableAiSettings; use settings::Settings; @@ -31,6 +31,7 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, + button_layout: Option, workspace_sidebar_open: bool, } @@ -45,6 +46,7 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, + button_layout: None, workspace_sidebar_open: false, } } @@ -68,6 +70,24 @@ impl PlatformTitleBar { self.children = children.into_iter().collect(); } + pub fn set_button_layout(&mut self, button_layout: Option) { + self.button_layout = button_layout; + } + + fn effective_button_layout( + &self, + decorations: &Decorations, + cx: &App, + ) -> Option { + if self.platform_style == PlatformStyle::Linux + && matches!(decorations, Decorations::Client { .. }) + { + self.button_layout.or_else(|| cx.button_layout()) + } else { + None + } + } + pub fn init(cx: &mut App) { SystemWindowTabs::init(cx); } @@ -95,6 +115,7 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); + let button_layout = self.effective_button_layout(&decorations, cx); let is_multiworkspace_sidebar_open = PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); @@ -150,6 +171,14 @@ impl Render for PlatformTitleBar { && !is_multiworkspace_sidebar_open { this.pl(px(TRAFFIC_LIGHT_PADDING)) + } else if let Some(button_layout) = + button_layout.filter(|button_layout| button_layout.left[0].is_some()) + { + this.child(platform_linux::LinuxWindowControls::new( + "left-window-controls", + button_layout.left, + close_action.as_ref().boxed_clone(), + )) } else { this.pl_2() } @@ -188,14 +217,22 @@ impl Render for PlatformTitleBar { PlatformStyle::Mac => title_bar, PlatformStyle::Linux => { if matches!(decorations, Decorations::Client { .. }) { - title_bar - .child(platform_linux::LinuxWindowControls::new(close_action)) - .when(supported_controls.window_menu, |titlebar| { - titlebar - .on_mouse_down(MouseButton::Right, move |ev, window, _| { - window.show_window_menu(ev.position) - }) + let mut result = title_bar; + if let Some(button_layout) = button_layout + .filter(|button_layout| button_layout.right[0].is_some()) + { + result = result.child(platform_linux::LinuxWindowControls::new( + "right-window-controls", + button_layout.right, + close_action.as_ref().boxed_clone(), + )); + } + + result.when(supported_controls.window_menu, |titlebar| { + titlebar.on_mouse_down(MouseButton::Right, move |ev, window, _| { + window.show_window_menu(ev.position) }) + }) } else { title_bar } diff --git a/crates/platform_title_bar/src/platforms/platform_linux.rs b/crates/platform_title_bar/src/platforms/platform_linux.rs index 0e7af80f80e8dcbea03a3b3375f1e4dfd7ca2f37..8dd6c6f6787ddab703963188beaaae1288ca6d6f 100644 --- a/crates/platform_title_bar/src/platforms/platform_linux.rs +++ b/crates/platform_title_bar/src/platforms/platform_linux.rs @@ -1,46 +1,83 @@ -use gpui::{Action, Hsla, MouseButton, prelude::*, svg}; +use gpui::{ + Action, AnyElement, Hsla, MAX_BUTTONS_PER_SIDE, MouseButton, WindowButton, prelude::*, svg, +}; use ui::prelude::*; #[derive(IntoElement)] pub struct LinuxWindowControls { - close_window_action: Box, + id: &'static str, + buttons: [Option; MAX_BUTTONS_PER_SIDE], + close_action: Box, } impl LinuxWindowControls { - pub fn new(close_window_action: Box) -> Self { + pub fn new( + id: &'static str, + buttons: [Option; MAX_BUTTONS_PER_SIDE], + close_action: Box, + ) -> Self { Self { - close_window_action, + id, + buttons, + close_action, } } } impl RenderOnce for LinuxWindowControls { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let is_maximized = window.is_maximized(); + let supported_controls = window.window_controls(); + let button_elements: Vec = self + .buttons + .iter() + .filter_map(|b| *b) + .filter(|button| match button { + WindowButton::Minimize => supported_controls.minimize, + WindowButton::Maximize => supported_controls.maximize, + WindowButton::Close => true, + }) + .map(|button| { + create_window_button(button, button.id(), is_maximized, &*self.close_action, cx) + }) + .collect(); + h_flex() - .id("generic-window-controls") - .px_3() - .gap_3() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(WindowControl::new( - "minimize", - WindowControlType::Minimize, - cx, - )) - .child(WindowControl::new( - "maximize-or-restore", - if window.is_maximized() { - WindowControlType::Restore - } else { - WindowControlType::Maximize - }, - cx, - )) - .child(WindowControl::new_close( - "close", - WindowControlType::Close, - self.close_window_action, - cx, - )) + .id(self.id) + .when(!button_elements.is_empty(), |el| { + el.gap_3() + .px_3() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .children(button_elements) + }) + } +} + +fn create_window_button( + button: WindowButton, + id: &'static str, + is_maximized: bool, + close_action: &dyn Action, + cx: &mut App, +) -> AnyElement { + match button { + WindowButton::Minimize => { + WindowControl::new(id, WindowControlType::Minimize, cx).into_any_element() + } + WindowButton::Maximize => WindowControl::new( + id, + if is_maximized { + WindowControlType::Restore + } else { + WindowControlType::Maximize + }, + cx, + ) + .into_any_element(), + WindowButton::Close => { + WindowControl::new_close(id, WindowControlType::Close, close_action.boxed_clone(), cx) + .into_any_element() + } } } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 19ffca06e131c177656e229d2101eb259256f318..731f576653b0f6f6403575edf059c34d722a3d12 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -9,6 +9,7 @@ mod project; mod serde_helper; mod terminal; mod theme; +mod title_bar; mod workspace; pub use agent::*; @@ -26,6 +27,7 @@ pub use serde_helper::{ use settings_json::parse_json_with_comments; pub use terminal::*; pub use theme::*; +pub use title_bar::*; pub use workspace::*; use collections::{HashMap, IndexMap}; @@ -316,43 +318,6 @@ impl strum::VariantNames for BaseKeymapContent { ]; } -#[with_fallible_options] -#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] -pub struct TitleBarSettingsContent { - /// Whether to show the branch icon beside branch switcher in the title bar. - /// - /// Default: false - pub show_branch_icon: Option, - /// Whether to show onboarding banners in the title bar. - /// - /// Default: true - pub show_onboarding_banner: Option, - /// Whether to show user avatar in the title bar. - /// - /// Default: true - pub show_user_picture: Option, - /// Whether to show the branch name button in the titlebar. - /// - /// Default: true - pub show_branch_name: Option, - /// Whether to show the project host and name in the titlebar. - /// - /// Default: true - pub show_project_items: Option, - /// Whether to show the sign in button in the title bar. - /// - /// Default: true - pub show_sign_in: Option, - /// Whether to show the user menu button in the title bar. - /// - /// Default: true - pub show_user_menu: Option, - /// Whether to show the menus in the title bar. - /// - /// Default: false - pub show_menus: Option, -} - /// Configuration of audio in Zed. #[with_fallible_options] #[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] diff --git a/crates/settings_content/src/title_bar.rs b/crates/settings_content/src/title_bar.rs new file mode 100644 index 0000000000000000000000000000000000000000..af5e30f361c7603aba72de3b5734ae78ab366171 --- /dev/null +++ b/crates/settings_content/src/title_bar.rs @@ -0,0 +1,124 @@ +use gpui::WindowButtonLayout; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Serialize}; +use settings_macros::{MergeFrom, with_fallible_options}; + +/// The layout of window control buttons as represented by user settings. +/// +/// Custom layout strings use the GNOME `button-layout` format (e.g. +/// `"close:minimize,maximize"`). +#[derive( + Clone, + PartialEq, + Debug, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + Default, + strum::EnumDiscriminants, +)] +#[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))] +#[schemars(schema_with = "window_button_layout_schema")] +#[serde(from = "String", into = "String")] +pub enum WindowButtonLayoutContent { + /// Follow the system/desktop configuration. + #[default] + PlatformDefault, + /// Use Zed's built-in standard layout, regardless of system config. + Standard, + /// A raw GNOME-style layout string. + Custom(String), +} + +impl WindowButtonLayoutContent { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn into_layout(self) -> Option { + use util::ResultExt; + + match self { + Self::PlatformDefault => None, + Self::Standard => Some(WindowButtonLayout::linux_default()), + Self::Custom(layout) => WindowButtonLayout::parse(&layout).log_err(), + } + } + + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + pub fn into_layout(self) -> Option { + None + } +} + +fn window_button_layout_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "anyOf": [ + { "enum": ["platform_default", "standard"] }, + { "type": "string" } + ] + }) +} + +impl From for String { + fn from(value: WindowButtonLayoutContent) -> Self { + match value { + WindowButtonLayoutContent::PlatformDefault => "platform_default".to_string(), + WindowButtonLayoutContent::Standard => "standard".to_string(), + WindowButtonLayoutContent::Custom(s) => s, + } + } +} + +impl From for WindowButtonLayoutContent { + fn from(layout_string: String) -> Self { + match layout_string.as_str() { + "platform_default" => Self::PlatformDefault, + "standard" => Self::Standard, + _ => Self::Custom(layout_string), + } + } +} + +#[with_fallible_options] +#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] +pub struct TitleBarSettingsContent { + /// Whether to show the branch icon beside branch switcher in the title bar. + /// + /// Default: false + pub show_branch_icon: Option, + /// Whether to show onboarding banners in the title bar. + /// + /// Default: true + pub show_onboarding_banner: Option, + /// Whether to show user avatar in the title bar. + /// + /// Default: true + pub show_user_picture: Option, + /// Whether to show the branch name button in the titlebar. + /// + /// Default: true + pub show_branch_name: Option, + /// Whether to show the project host and name in the titlebar. + /// + /// Default: true + pub show_project_items: Option, + /// Whether to show the sign in button in the title bar. + /// + /// Default: true + pub show_sign_in: Option, + /// Whether to show the user menu button in the title bar. + /// + /// Default: true + pub show_user_menu: Option, + /// Whether to show the menus in the title bar. + /// + /// Default: false + pub show_menus: Option, + /// The layout of window control buttons in the title bar (Linux only). + /// + /// This can be set to "platform_default" to follow the system configuration, or + /// "standard" to use Zed's built-in layout. For custom layouts, use a + /// GNOME-style layout string like "close:minimize,maximize". + /// + /// Default: "platform_default" + pub button_layout: Option, +} diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index f5398b60fe528153c3a6d146fcf1eb9b105f713f..d2132d0a9dcd932ef66a3fa874a5d1e7714fb726 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3481,7 +3481,7 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn title_bar_section() -> [SettingsPageItem; 9] { + fn title_bar_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Title Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3648,6 +3648,122 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + files: USER, + title: "Button Layout", + description: + "(Linux only) choose how window control buttons are laid out in the titlebar.", + field: Box::new(SettingField { + json_path: Some("title_bar.button_layout$"), + pick: |settings_content| { + Some( + &dynamic_variants::()[settings_content + .title_bar + .as_ref()? + .button_layout + .as_ref()? + .discriminant() + as usize], + ) + }, + write: |settings_content, value| { + let Some(value) = value else { + settings_content + .title_bar + .get_or_insert_default() + .button_layout = None; + return; + }; + + let current_custom_layout = settings_content + .title_bar + .as_ref() + .and_then(|title_bar| title_bar.button_layout.as_ref()) + .and_then(|button_layout| match button_layout { + settings::WindowButtonLayoutContent::Custom(layout) => { + Some(layout.clone()) + } + _ => None, + }); + + let button_layout = match value { + settings::WindowButtonLayoutContentDiscriminants::PlatformDefault => { + settings::WindowButtonLayoutContent::PlatformDefault + } + settings::WindowButtonLayoutContentDiscriminants::Standard => { + settings::WindowButtonLayoutContent::Standard + } + settings::WindowButtonLayoutContentDiscriminants::Custom => { + settings::WindowButtonLayoutContent::Custom( + current_custom_layout.unwrap_or_else(|| { + "close:minimize,maximize".to_string() + }), + ) + } + }; + + settings_content + .title_bar + .get_or_insert_default() + .button_layout = Some(button_layout); + }, + }), + metadata: None, + }, + pick_discriminant: |settings_content| { + Some( + settings_content + .title_bar + .as_ref()? + .button_layout + .as_ref()? + .discriminant() as usize, + ) + }, + fields: dynamic_variants::() + .into_iter() + .map(|variant| match variant { + settings::WindowButtonLayoutContentDiscriminants::PlatformDefault => { + vec![] + } + settings::WindowButtonLayoutContentDiscriminants::Standard => vec![], + settings::WindowButtonLayoutContentDiscriminants::Custom => vec![ + SettingItem { + files: USER, + title: "Custom Button Layout", + description: + "GNOME-style layout string such as \"close:minimize,maximize\".", + field: Box::new(SettingField { + json_path: Some("title_bar.button_layout"), + pick: |settings_content| match settings_content + .title_bar + .as_ref()? + .button_layout + .as_ref()? + { + settings::WindowButtonLayoutContent::Custom(layout) => { + Some(layout) + } + _ => DEFAULT_EMPTY_STRING, + }, + write: |settings_content, value| { + settings_content + .title_bar + .get_or_insert_default() + .button_layout = value + .map(settings::WindowButtonLayoutContent::Custom); + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("close:minimize,maximize"), + ..Default::default() + })), + }, + ], + }) + .collect(), + }), ] } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 95a8c215fb593fe3a5d64a11f0d2b14a183dc60c..f2107fb63a7dbd86b3a01c4d222adbd46bd4a083 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -545,6 +545,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_ollama_model_picker) .add_basic_renderer::(render_dropdown) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 9c12e0ca5a0042d7679f5807bab81efbe0ead1eb..9b148a72e94b85ecefedad9bb0c4e06a076e1d9e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -162,6 +162,7 @@ pub struct TitleBar { impl Render for TitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let title_bar_settings = *TitleBarSettings::get_global(cx); + let button_layout = title_bar_settings.button_layout; let show_menus = show_menus(cx); @@ -266,6 +267,7 @@ impl Render for TitleBar { if show_menus { self.platform_titlebar.update(cx, |this, _| { + this.set_button_layout(button_layout); this.set_children( self.application_menu .clone() @@ -293,6 +295,7 @@ impl Render for TitleBar { .into_any_element() } else { self.platform_titlebar.update(cx, |this, _| { + this.set_button_layout(button_layout); this.set_children(children); }); self.platform_titlebar.clone().into_any_element() @@ -360,6 +363,7 @@ impl TitleBar { }), ); subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify())); + subscriptions.push(cx.observe_button_layout_changed(window, |_, _, cx| cx.notify())); if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| { cx.notify(); diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 155b7b7bc797567927a70b12c677372cb92c9453..61f951ca305d1a0bb53100b883a5e77409adb54f 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -1,3 +1,4 @@ +use gpui::WindowButtonLayout; use settings::{RegisterSetting, Settings, SettingsContent}; #[derive(Copy, Clone, Debug, RegisterSetting)] @@ -10,6 +11,7 @@ pub struct TitleBarSettings { pub show_sign_in: bool, pub show_user_menu: bool, pub show_menus: bool, + pub button_layout: Option, } impl Settings for TitleBarSettings { @@ -24,6 +26,7 @@ impl Settings for TitleBarSettings { show_sign_in: content.show_sign_in.unwrap(), show_user_menu: content.show_user_menu.unwrap(), show_menus: content.show_menus.unwrap(), + button_layout: content.button_layout.unwrap_or_default().into_layout(), } } } diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index da93b290b4486599cf3cecc05b08f5f7a7ea1984..7427a1ceb2038cbb2af26e3db7c096d2aaab2bcd 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -4627,7 +4627,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "show_user_picture": true, "show_user_menu": true, "show_sign_in": true, - "show_menus": false + "show_menus": false, + "button_layout": "platform_default" } } ``` @@ -4642,6 +4643,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_user_menu`: Whether to show the user menu button in the titlebar (the one that displays your avatar by default and contains options like Settings, Keymap, Themes, etc.) - `show_sign_in`: Whether to show the sign in button in the titlebar - `show_menus`: Whether to show the menus in the titlebar +- `button_layout`: The layout of window control buttons in the title bar (Linux only). Can be set to `"platform_default"` to follow the system setting, `"standard"` to use Zed's built-in layout, or a custom format like `"close:minimize,maximize"` ## Vim