Detailed changes
@@ -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.
@@ -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()
@@ -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.
@@ -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());
+ }
+}
@@ -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
@@ -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")))
}
@@ -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();
@@ -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();
@@ -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.
}
@@ -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();
@@ -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(
@@ -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
}
@@ -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()
+ }
}
}
@@ -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)]
@@ -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>,
+}
@@ -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(),
+ }),
]
}
@@ -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)
@@ -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();
@@ -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(),
}
}
}
@@ -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