wayland: Window controls and drag (#11525)

apricotbucket28 and Akilan Elango created

Based on https://github.com/zed-industries/zed/pull/11046

- Partially fixes #10346 
- Fixes https://github.com/zed-industries/zed/issues/9964

## Features
Window buttons

![image](https://github.com/zed-industries/zed/assets/71973804/1b7e0504-3925-45ba-90b5-5adb55e0d739)

Window drag

![image](https://github.com/zed-industries/zed/assets/71973804/9c509a37-e5a5-484c-9f80-c722aeee4380)

Native window context menu

![image](https://github.com/zed-industries/zed/assets/71973804/048ecf52-e277-49bb-a106-91cad226fd8a)

### Limitations

- No resizing
- Wayland only (though X11 always has window decorations)

### Technical

This PR adds three APIs to gpui.

1. `show_window_menu`: Triggers the native title bar context menu.
2. `start_system_move`: Tells the compositor to start dragging the
window.
3. `should_render_window_controls`: Whether the compositor doesn't
support server side decorations.

These APIs have only been implemented for Wayland, but they should be
portable to other platforms.

Release Notes:

- N/A

---------

Co-authored-by: Akilan Elango <akilan1997@gmail.com>

Change summary

crates/collab_ui/src/collab_titlebar_item.rs                |   7 
crates/gpui/src/platform.rs                                 |   4 
crates/gpui/src/platform/linux/wayland/client.rs            |  17 
crates/gpui/src/platform/linux/wayland/window.rs            |  26 +
crates/gpui/src/platform/linux/x11/window.rs                |  11 
crates/gpui/src/platform/mac/window.rs                      |   8 
crates/gpui/src/platform/test/window.rs                     |  12 
crates/gpui/src/platform/windows/window.rs                  |   8 
crates/gpui/src/window.rs                                   |  17 
crates/ui/src/components/stories/title_bar.rs               |   8 
crates/ui/src/components/title_bar.rs                       |   1 
crates/ui/src/components/title_bar/linux_window_controls.rs | 145 +++++++
crates/ui/src/components/title_bar/title_bar.rs             |  24 +
13 files changed, 277 insertions(+), 11 deletions(-)

Detailed changes

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -58,7 +58,7 @@ impl Render for CollabTitlebarItem {
         let project_id = self.project.read(cx).remote_id();
         let workspace = self.workspace.upgrade();
 
-        TitleBar::new("collab-titlebar")
+        TitleBar::new("collab-titlebar", Box::new(workspace::CloseWindow))
             // note: on windows titlebar behaviour is handled by the platform implementation
             .when(cfg!(not(windows)), |this| {
                 this.on_click(|event, cx| {
@@ -73,7 +73,8 @@ impl Render for CollabTitlebarItem {
                     .gap_1()
                     .children(self.render_project_host(cx))
                     .child(self.render_project_name(cx))
-                    .children(self.render_project_branch(cx)),
+                    .children(self.render_project_branch(cx))
+                    .on_mouse_move(|_, cx| cx.stop_propagation()),
             )
             .child(
                 h_flex()
@@ -105,6 +106,7 @@ impl Render for CollabTitlebarItem {
 
                             this.children(current_user_face_pile.map(|face_pile| {
                                 v_flex()
+                                    .on_mouse_move(|_, cx| cx.stop_propagation())
                                     .child(face_pile)
                                     .child(render_color_ribbon(player_colors.local().cursor))
                             }))
@@ -167,6 +169,7 @@ impl Render for CollabTitlebarItem {
                 h_flex()
                     .gap_1()
                     .pr_1()
+                    .on_mouse_move(|_, cx| cx.stop_propagation())
                     .when_some(room, |this, room| {
                         let room = room.read(cx);
                         let project = self.project.read(cx);

crates/gpui/src/platform.rs 🔗

@@ -226,6 +226,10 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     #[cfg(target_os = "windows")]
     fn get_raw_handle(&self) -> windows::HWND;
 
+    fn show_window_menu(&self, position: Point<Pixels>);
+    fn start_system_move(&self);
+    fn should_render_window_controls(&self) -> bool;
+
     #[cfg(any(test, feature = "test-support"))]
     fn as_test(&mut self) -> Option<&mut TestWindow> {
         None

crates/gpui/src/platform/linux/wayland/client.rs 🔗

@@ -23,6 +23,7 @@ use wayland_client::globals::{registry_queue_init, GlobalList, GlobalListContent
 use wayland_client::protocol::wl_callback::{self, WlCallback};
 use wayland_client::protocol::wl_data_device_manager::DndAction;
 use wayland_client::protocol::wl_pointer::{AxisRelativeDirection, AxisSource};
+use wayland_client::protocol::wl_seat::WlSeat;
 use wayland_client::protocol::{
     wl_data_device, wl_data_device_manager, wl_data_offer, wl_data_source, wl_output, wl_region,
 };
@@ -80,6 +81,7 @@ pub struct Globals {
     pub data_device_manager: Option<wl_data_device_manager::WlDataDeviceManager>,
     pub wm_base: xdg_wm_base::XdgWmBase,
     pub shm: wl_shm::WlShm,
+    pub seat: wl_seat::WlSeat,
     pub viewporter: Option<wp_viewporter::WpViewporter>,
     pub fractional_scale_manager:
         Option<wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1>,
@@ -93,6 +95,7 @@ impl Globals {
         globals: GlobalList,
         executor: ForegroundExecutor,
         qh: QueueHandle<WaylandClientStatePtr>,
+        seat: wl_seat::WlSeat,
     ) -> Self {
         Globals {
             activation: globals.bind(&qh, 1..=1, ()).ok(),
@@ -113,6 +116,7 @@ impl Globals {
                 )
                 .ok(),
             shm: globals.bind(&qh, 1..=1, ()).unwrap(),
+            seat,
             wm_base: globals.bind(&qh, 1..=1, ()).unwrap(),
             viewporter: globals.bind(&qh, 1..=1, ()).ok(),
             fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(),
@@ -193,6 +197,10 @@ impl WaylandClientStatePtr {
             .expect("The pointer should always be valid when dispatching in wayland")
     }
 
+    pub fn get_serial(&self, kind: SerialKind) -> u32 {
+        self.0.upgrade().unwrap().borrow().serial_tracker.get(kind)
+    }
+
     pub fn drop_window(&self, surface_id: &ObjectId) {
         let mut client = self.get_client();
         let mut state = client.borrow_mut();
@@ -303,7 +311,12 @@ impl WaylandClient {
         });
 
         let seat = seat.unwrap();
-        let globals = Globals::new(globals, common.foreground_executor.clone(), qh.clone());
+        let globals = Globals::new(
+            globals,
+            common.foreground_executor.clone(),
+            qh.clone(),
+            seat.clone(),
+        );
 
         let data_device = globals
             .data_device_manager
@@ -962,6 +975,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
             } => {
                 state.serial_tracker.update(SerialKind::MouseEnter, serial);
                 state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
+                state.button_pressed = None;
 
                 if let Some(window) = get_window(&mut state, &surface.id()) {
                     state.mouse_focused_window = Some(window.clone());
@@ -990,6 +1004,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
                     });
                     state.mouse_focused_window = None;
                     state.mouse_location = None;
+                    state.button_pressed = None;
 
                     drop(state);
                     focused_window.handle_input(input);

crates/gpui/src/platform/linux/wayland/window.rs 🔗

@@ -23,12 +23,13 @@ use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blu
 
 use crate::platform::blade::{BladeRenderer, BladeSurfaceConfig};
 use crate::platform::linux::wayland::display::WaylandDisplay;
+use crate::platform::linux::wayland::serial::SerialKind;
 use crate::platform::{PlatformAtlas, PlatformInputHandler, PlatformWindow};
 use crate::scene::Scene;
 use crate::{
     px, size, Bounds, DevicePixels, Globals, Modifiers, Pixels, PlatformDisplay, PlatformInput,
-    Point, PromptLevel, Size, WaylandClientState, WaylandClientStatePtr, WindowAppearance,
-    WindowBackgroundAppearance, WindowBounds, WindowParams,
+    Point, PromptLevel, Size, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance,
+    WindowBounds, WindowParams,
 };
 
 #[derive(Default)]
@@ -753,6 +754,27 @@ impl PlatformWindow for WaylandWindow {
         let state = self.borrow();
         state.renderer.sprite_atlas().clone()
     }
+
+    fn show_window_menu(&self, position: Point<Pixels>) {
+        let state = self.borrow();
+        let serial = state.client.get_serial(SerialKind::MousePress);
+        state.toplevel.show_window_menu(
+            &state.globals.seat,
+            serial,
+            position.x.0 as i32,
+            position.y.0 as i32,
+        );
+    }
+
+    fn start_system_move(&self) {
+        let state = self.borrow();
+        let serial = state.client.get_serial(SerialKind::MousePress);
+        state.toplevel._move(&state.globals.seat, serial);
+    }
+
+    fn should_render_window_controls(&self) -> bool {
+        self.borrow().decoration_state == WaylandDecorationState::Client
+    }
 }
 
 #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]

crates/gpui/src/platform/linux/x11/window.rs 🔗

@@ -8,6 +8,7 @@ use crate::{
     Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowOptions,
     WindowParams, X11Client, X11ClientState, X11ClientStatePtr,
 };
+
 use blade_graphics as gpu;
 use parking_lot::Mutex;
 use raw_window_handle as rwh;
@@ -719,4 +720,14 @@ impl PlatformWindow for X11Window {
         let inner = self.0.state.borrow();
         inner.renderer.sprite_atlas().clone()
     }
+
+    // todo(linux)
+    fn show_window_menu(&self, _position: Point<Pixels>) {}
+
+    // todo(linux)
+    fn start_system_move(&self) {}
+
+    fn should_render_window_controls(&self) -> bool {
+        false
+    }
 }

crates/gpui/src/platform/mac/window.rs 🔗

@@ -1100,6 +1100,14 @@ impl PlatformWindow for MacWindow {
     fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas> {
         self.0.lock().renderer.sprite_atlas().clone()
     }
+
+    fn show_window_menu(&self, _position: Point<Pixels>) {}
+
+    fn start_system_move(&self) {}
+
+    fn should_render_window_controls(&self) -> bool {
+        false
+    }
 }
 
 impl rwh::HasWindowHandle for MacWindow {

crates/gpui/src/platform/test/window.rs 🔗

@@ -257,6 +257,18 @@ impl PlatformWindow for TestWindow {
     fn get_raw_handle(&self) -> windows::Win32::Foundation::HWND {
         unimplemented!()
     }
+
+    fn show_window_menu(&self, _position: Point<Pixels>) {
+        unimplemented!()
+    }
+
+    fn start_system_move(&self) {
+        unimplemented!()
+    }
+
+    fn should_render_window_controls(&self) -> bool {
+        false
+    }
 }
 
 pub(crate) struct TestAtlasState {

crates/gpui/src/platform/windows/window.rs 🔗

@@ -628,6 +628,14 @@ impl PlatformWindow for WindowsWindow {
     fn get_raw_handle(&self) -> HWND {
         self.0.hwnd
     }
+
+    fn show_window_menu(&self, _position: Point<Pixels>) {}
+
+    fn start_system_move(&self) {}
+
+    fn should_render_window_controls(&self) -> bool {
+        false
+    }
 }
 
 #[implement(IDropTarget)]

crates/gpui/src/window.rs 🔗

@@ -1143,6 +1143,23 @@ impl<'a> WindowContext<'a> {
         self.window.platform_window.zoom();
     }
 
+    /// Opens the native title bar context menu, useful when implementing client side decorations (Wayland only)
+    pub fn show_window_menu(&self, position: Point<Pixels>) {
+        self.window.platform_window.show_window_menu(position)
+    }
+
+    /// Tells the compositor to take control of window movement (Wayland only)
+    ///
+    /// Events may not be received during a move operation.
+    pub fn start_system_move(&self) {
+        self.window.platform_window.start_system_move()
+    }
+
+    /// Returns whether the title bar window controls need to be rendered by the application (Wayland and X11)
+    pub fn should_render_window_controls(&self) -> bool {
+        self.window.platform_window.should_render_window_controls()
+    }
+
     /// Updates the window's title at the platform level.
     pub fn set_window_title(&mut self, title: &str) {
         self.window.platform_window.set_title(title);

crates/ui/src/components/stories/title_bar.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::Render;
+use gpui::{NoAction, Render};
 use story::{StoryContainer, StoryItem, StorySection};
 
 use crate::{prelude::*, PlatformStyle, TitleBar};
@@ -19,7 +19,7 @@ impl Render for TitleBarStory {
                 StorySection::new().child(
                     StoryItem::new(
                         "Default (macOS)",
-                        TitleBar::new("macos")
+                        TitleBar::new("macos", Box::new(NoAction))
                             .platform_style(PlatformStyle::Mac)
                             .map(add_sample_children),
                     )
@@ -31,7 +31,7 @@ impl Render for TitleBarStory {
                 StorySection::new().child(
                     StoryItem::new(
                         "Default (Linux)",
-                        TitleBar::new("linux")
+                        TitleBar::new("linux", Box::new(NoAction))
                             .platform_style(PlatformStyle::Linux)
                             .map(add_sample_children),
                     )
@@ -43,7 +43,7 @@ impl Render for TitleBarStory {
                 StorySection::new().child(
                     StoryItem::new(
                         "Default (Windows)",
-                        TitleBar::new("windows")
+                        TitleBar::new("windows", Box::new(NoAction))
                             .platform_style(PlatformStyle::Windows)
                             .map(add_sample_children),
                     )

crates/ui/src/components/title_bar/linux_window_controls.rs 🔗

@@ -0,0 +1,145 @@
+use gpui::{prelude::*, Action, Rgba, WindowAppearance};
+
+use crate::prelude::*;
+
+#[derive(IntoElement)]
+pub struct LinuxWindowControls {
+    button_height: Pixels,
+    close_window_action: Box<dyn Action>,
+}
+
+impl LinuxWindowControls {
+    pub fn new(button_height: Pixels, close_window_action: Box<dyn Action>) -> Self {
+        Self {
+            button_height,
+            close_window_action,
+        }
+    }
+}
+
+impl RenderOnce for LinuxWindowControls {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let close_button_hover_color = Rgba {
+            r: 232.0 / 255.0,
+            g: 17.0 / 255.0,
+            b: 32.0 / 255.0,
+            a: 1.0,
+        };
+
+        let button_hover_color = match cx.appearance() {
+            WindowAppearance::Light | WindowAppearance::VibrantLight => Rgba {
+                r: 0.1,
+                g: 0.1,
+                b: 0.1,
+                a: 0.2,
+            },
+            WindowAppearance::Dark | WindowAppearance::VibrantDark => Rgba {
+                r: 0.9,
+                g: 0.9,
+                b: 0.9,
+                a: 0.1,
+            },
+        };
+
+        div()
+            .id("linux-window-controls")
+            .flex()
+            .flex_row()
+            .justify_center()
+            .content_stretch()
+            .max_h(self.button_height)
+            .min_h(self.button_height)
+            .child(TitlebarButton::new(
+                "minimize",
+                TitlebarButtonType::Minimize,
+                button_hover_color,
+                self.close_window_action.boxed_clone(),
+            ))
+            .child(TitlebarButton::new(
+                "maximize-or-restore",
+                if cx.is_maximized() {
+                    TitlebarButtonType::Restore
+                } else {
+                    TitlebarButtonType::Maximize
+                },
+                button_hover_color,
+                self.close_window_action.boxed_clone(),
+            ))
+            .child(TitlebarButton::new(
+                "close",
+                TitlebarButtonType::Close,
+                close_button_hover_color,
+                self.close_window_action,
+            ))
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+enum TitlebarButtonType {
+    Minimize,
+    Restore,
+    Maximize,
+    Close,
+}
+
+#[derive(IntoElement)]
+struct TitlebarButton {
+    id: ElementId,
+    icon: TitlebarButtonType,
+    hover_background_color: Rgba,
+    close_window_action: Box<dyn Action>,
+}
+
+impl TitlebarButton {
+    pub fn new(
+        id: impl Into<ElementId>,
+        icon: TitlebarButtonType,
+        hover_background_color: Rgba,
+        close_window_action: Box<dyn Action>,
+    ) -> Self {
+        Self {
+            id: id.into(),
+            icon,
+            hover_background_color,
+            close_window_action,
+        }
+    }
+}
+
+impl RenderOnce for TitlebarButton {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        let width = px(36.);
+
+        h_flex()
+            .id(self.id)
+            .justify_center()
+            .content_center()
+            .w(width)
+            .h_full()
+            .hover(|style| style.bg(self.hover_background_color))
+            .active(|style| {
+                let mut active_color = self.hover_background_color;
+                active_color.a *= 0.2;
+
+                style.bg(active_color)
+            })
+            .child(Icon::new(match self.icon {
+                TitlebarButtonType::Minimize => IconName::Dash,
+                TitlebarButtonType::Restore => IconName::Minimize,
+                TitlebarButtonType::Maximize => IconName::Maximize,
+                TitlebarButtonType::Close => IconName::Close,
+            }))
+            .on_mouse_move(|_, cx| cx.stop_propagation())
+            .on_click(move |_, cx| {
+                cx.stop_propagation();
+                match self.icon {
+                    TitlebarButtonType::Minimize => cx.minimize_window(),
+                    TitlebarButtonType::Restore => cx.zoom_window(),
+                    TitlebarButtonType::Maximize => cx.zoom_window(),
+                    TitlebarButtonType::Close => {
+                        cx.dispatch_action(self.close_window_action.boxed_clone())
+                    }
+                }
+            })
+    }
+}

crates/ui/src/components/title_bar/title_bar.rs 🔗

@@ -1,6 +1,7 @@
-use gpui::{AnyElement, Interactivity, Stateful};
+use gpui::{Action, AnyElement, Interactivity, Stateful};
 use smallvec::SmallVec;
 
+use crate::components::title_bar::linux_window_controls::LinuxWindowControls;
 use crate::components::title_bar::windows_window_controls::WindowsWindowControls;
 use crate::prelude::*;
 
@@ -9,6 +10,7 @@ pub struct TitleBar {
     platform_style: PlatformStyle,
     content: Stateful<Div>,
     children: SmallVec<[AnyElement; 2]>,
+    close_window_action: Box<dyn Action>,
 }
 
 impl TitleBar {
@@ -45,11 +47,12 @@ impl TitleBar {
         }
     }
 
-    pub fn new(id: impl Into<ElementId>) -> Self {
+    pub fn new(id: impl Into<ElementId>, close_window_action: Box<dyn Action>) -> Self {
         Self {
             platform_style: PlatformStyle::platform(),
             content: div().id(id.into()),
             children: SmallVec::new(),
+            close_window_action,
         }
     }
 
@@ -111,5 +114,22 @@ impl RenderOnce for TitleBar {
                 self.platform_style == PlatformStyle::Windows && !cx.is_fullscreen(),
                 |title_bar| title_bar.child(WindowsWindowControls::new(height)),
             )
+            .when(
+                self.platform_style == PlatformStyle::Linux
+                    && !cx.is_fullscreen()
+                    && cx.should_render_window_controls(),
+                |title_bar| {
+                    title_bar
+                        .child(LinuxWindowControls::new(height, self.close_window_action))
+                        .on_mouse_down(gpui::MouseButton::Right, move |ev, cx| {
+                            cx.show_window_menu(ev.position)
+                        })
+                        .on_mouse_move(move |ev, cx| {
+                            if ev.dragging() {
+                                cx.start_system_move();
+                            }
+                        })
+                },
+            )
     }
 }