From 60a29857f12cba0d24209be6568f75b171bd7f45 Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Mon, 23 Mar 2026 22:01:12 +0530 Subject: [PATCH] title_bar: Respect Linux titlebar config (#47506) 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. image 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 --- 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 + .../src/linux/xdg_desktop_portal.rs | 27 ++ .../src/platform_title_bar.rs | 57 ++- .../src/platforms/platform_linux.rs | 93 +++-- .../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(-) create mode 100644 crates/settings_content/src/title_bar.rs 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