title_bar: Respect Linux titlebar config (#47506)

Mufeed Ali and Smit Barmase created

Currently, Zed always places three fixed window buttons (Minimize,
Maximize and Close) on the right side of the window in a fixed order
ignoring any user configuration or desktop environment preference (like
elementary).

This PR adds support for GNOME-style layout strings (`minimize:close`).
By default, we pull it from the gsettings portal, but we also allow
manual configuration via `title_bar.button_layout` config key.

<img width="1538" height="797" alt="image"
src="https://github.com/user-attachments/assets/5db6bfa2-3052-4640-9228-95c37f318929"
/>

Closes #46512

I know it's a relatively large PR for my first one and I'm new to Rust.
So, sorry if I've made any huge mistakes. I had just made it for
personal use and then decided to try to clean it up and submit it.

I've tested with different configs on Linux. Untested on other
platforms, but should have no impact.

If it's not up to par, it's okay, feel free to close :)

Release Notes:

- Added support for GNOME's window buttons configuration on Linux.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

assets/settings/default.json                              |   2 
crates/gpui/src/app.rs                                    |   8 
crates/gpui/src/app/context.rs                            |  18 
crates/gpui/src/platform.rs                               | 329 +++++++++
crates/gpui/src/window.rs                                 |  32 
crates/gpui_linux/src/linux/platform.rs                   |   9 
crates/gpui_linux/src/linux/wayland/client.rs             |  17 
crates/gpui_linux/src/linux/wayland/window.rs             |  13 
crates/gpui_linux/src/linux/x11/client.rs                 |  11 
crates/gpui_linux/src/linux/x11/window.rs                 |  13 
crates/gpui_linux/src/linux/xdg_desktop_portal.rs         |  27 
crates/platform_title_bar/src/platform_title_bar.rs       |  57 +
crates/platform_title_bar/src/platforms/platform_linux.rs |  93 +
crates/settings_content/src/settings_content.rs           |  39 -
crates/settings_content/src/title_bar.rs                  | 124 +++
crates/settings_ui/src/page_data.rs                       | 118 +++
crates/settings_ui/src/settings_ui.rs                     |   1 
crates/title_bar/src/title_bar.rs                         |   4 
crates/title_bar/src/title_bar_settings.rs                |   3 
docs/src/reference/all-settings.md                        |   4 
20 files changed, 840 insertions(+), 82 deletions(-)

Detailed changes

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.

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<WindowButtonLayout> {
+        self.platform.button_layout()
+    }
+
     /// Reads data from the platform clipboard.
     pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
         self.platform.read_from_clipboard()

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<T>) + '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.

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<WindowButtonLayout> {
+        None
+    }
+
     fn open_url(&self, url: &str);
     fn on_open_urls(&self, callback: Box<dyn FnMut(Vec<String>)>);
     fn register_url_scheme(&self, url: &str) -> Task<Result<()>>;
@@ -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<WindowButton>; MAX_BUTTONS_PER_SIDE],
+    /// Buttons on the right side of the titlebar.
+    pub right: [Option<WindowButton>; 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<Self> {
+        fn parse_side(
+            s: &str,
+            seen_buttons: &mut [bool; MAX_BUTTONS_PER_SIDE],
+            unrecognized: &mut Vec<String>,
+        ) -> [Option<WindowButton>; 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<WindowButton>; MAX_BUTTONS_PER_SIDE]) -> String {
+            buttons
+                .iter()
+                .flatten()
+                .map(|button| match button {
+                    WindowButton::Minimize => "minimize",
+                    WindowButton::Maximize => "maximize",
+                    WindowButton::Close => "close",
+                })
+                .collect::<Vec<_>>()
+                .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<dyn FnMut() -> Option<WindowControlArea>>);
     fn on_close(&self, callback: Box<dyn FnOnce()>);
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>);
+    fn on_button_layout_changed(&self, _callback: Box<dyn FnMut()>) {}
     fn draw(&self, scene: &Scene);
     fn completed_frame(&self) {}
     fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas>;
@@ -2023,3 +2170,185 @@ impl From<String> 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::<HashSet<_>>();
+        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());
+    }
+}

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<Cell<bool>>,
     hovered: Rc<Cell<bool>>,
     pub(crate) needs_present: Rc<Cell<bool>>,
@@ -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<E>(
         &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

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<dyn PlatformTextSystem>,
     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<OwnedMenu>,
@@ -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<P: LinuxClient + 'static> Platform for LinuxPlatform<P> {
         self.inner.with_common(|common| common.appearance)
     }
 
+    fn button_layout(&self) -> Option<WindowButtonLayout> {
+        Some(self.inner.with_common(|common| common.button_layout))
+    }
+
     fn register_url_scheme(&self, _: &str) -> Task<anyhow::Result<()>> {
         Task::ready(Err(anyhow!("register_url_scheme unimplemented")))
     }

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();

crates/gpui_linux/src/linux/wayland/window.rs 🔗

@@ -50,6 +50,7 @@ pub(crate) struct Callbacks {
     should_close: Option<Box<dyn FnMut() -> bool>>,
     close: Option<Box<dyn FnOnce()>>,
     appearance_changed: Option<Box<dyn FnMut()>>,
+    button_layout_changed: Option<Box<dyn FnMut()>>,
 }
 
 #[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<dyn FnMut()>) {
+        self.0.callbacks.borrow_mut().button_layout_changed = Some(callback);
+    }
+
     fn draw(&self, scene: &Scene) {
         let mut state = self.borrow_mut();
 

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.
                     }

crates/gpui_linux/src/linux/x11/window.rs 🔗

@@ -250,6 +250,7 @@ pub struct Callbacks {
     should_close: Option<Box<dyn FnMut() -> bool>>,
     close: Option<Box<dyn FnOnce()>>,
     appearance_changed: Option<Box<dyn FnMut()>>,
+    button_layout_changed: Option<Box<dyn FnMut()>>,
 }
 
 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<dyn FnMut()>) {
+        self.0.callbacks.borrow_mut().button_layout_changed = Some(callback);
+    }
+
     fn draw(&self, scene: &Scene) {
         let mut inner = self.0.state.borrow_mut();
 

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::<String>("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(

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<SystemWindowTabs>,
+    button_layout: Option<WindowButtonLayout>,
     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<WindowButtonLayout>) {
+        self.button_layout = button_layout;
+    }
+
+    fn effective_button_layout(
+        &self,
+        decorations: &Decorations,
+        cx: &App,
+    ) -> Option<WindowButtonLayout> {
+        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
                         }

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<dyn Action>,
+    id: &'static str,
+    buttons: [Option<WindowButton>; MAX_BUTTONS_PER_SIDE],
+    close_action: Box<dyn Action>,
 }
 
 impl LinuxWindowControls {
-    pub fn new(close_window_action: Box<dyn Action>) -> Self {
+    pub fn new(
+        id: &'static str,
+        buttons: [Option<WindowButton>; MAX_BUTTONS_PER_SIDE],
+        close_action: Box<dyn Action>,
+    ) -> 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<AnyElement> = 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()
+        }
     }
 }
 

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<bool>,
-    /// Whether to show onboarding banners in the title bar.
-    ///
-    /// Default: true
-    pub show_onboarding_banner: Option<bool>,
-    /// Whether to show user avatar in the title bar.
-    ///
-    /// Default: true
-    pub show_user_picture: Option<bool>,
-    /// Whether to show the branch name button in the titlebar.
-    ///
-    /// Default: true
-    pub show_branch_name: Option<bool>,
-    /// Whether to show the project host and name in the titlebar.
-    ///
-    /// Default: true
-    pub show_project_items: Option<bool>,
-    /// Whether to show the sign in button in the title bar.
-    ///
-    /// Default: true
-    pub show_sign_in: Option<bool>,
-    /// Whether to show the user menu button in the title bar.
-    ///
-    /// Default: true
-    pub show_user_menu: Option<bool>,
-    /// Whether to show the menus in the title bar.
-    ///
-    /// Default: false
-    pub show_menus: Option<bool>,
-}
-
 /// Configuration of audio in Zed.
 #[with_fallible_options]
 #[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]

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<WindowButtonLayout> {
+        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<WindowButtonLayout> {
+        None
+    }
+}
+
+fn window_button_layout_schema(_: &mut SchemaGenerator) -> Schema {
+    json_schema!({
+        "anyOf": [
+            { "enum": ["platform_default", "standard"] },
+            { "type": "string" }
+        ]
+    })
+}
+
+impl From<WindowButtonLayoutContent> 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<String> 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<bool>,
+    /// Whether to show onboarding banners in the title bar.
+    ///
+    /// Default: true
+    pub show_onboarding_banner: Option<bool>,
+    /// Whether to show user avatar in the title bar.
+    ///
+    /// Default: true
+    pub show_user_picture: Option<bool>,
+    /// Whether to show the branch name button in the titlebar.
+    ///
+    /// Default: true
+    pub show_branch_name: Option<bool>,
+    /// Whether to show the project host and name in the titlebar.
+    ///
+    /// Default: true
+    pub show_project_items: Option<bool>,
+    /// Whether to show the sign in button in the title bar.
+    ///
+    /// Default: true
+    pub show_sign_in: Option<bool>,
+    /// Whether to show the user menu button in the title bar.
+    ///
+    /// Default: true
+    pub show_user_menu: Option<bool>,
+    /// Whether to show the menus in the title bar.
+    ///
+    /// Default: false
+    pub show_menus: Option<bool>,
+    /// 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<WindowButtonLayoutContent>,
+}

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::WindowButtonLayoutContent>()[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::<settings::WindowButtonLayoutContent>()
+                    .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(),
+            }),
         ]
     }
 

crates/settings_ui/src/settings_ui.rs 🔗

@@ -545,6 +545,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::EditPredictionsMode>(render_dropdown)
         .add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
         .add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
+        .add_basic_renderer::<settings::WindowButtonLayoutContentDiscriminants>(render_dropdown)
         .add_basic_renderer::<settings::FontSize>(render_editable_number_field)
         .add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
         .add_basic_renderer::<settings::SemanticTokens>(render_dropdown)

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<Self>) -> 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();

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<WindowButtonLayout>,
 }
 
 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(),
         }
     }
 }

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