gpui: Implement dynamic window control elements (#30828)

Matin Aniss created

Allows setting element as window control elements which consist of
`Drag`, `Close`, `Max`, or `Min`. This allows you to implement
dynamically sized elements that control the platform window, this is
used for areas such as the title bar. Currently only implemented for
Windows.

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/div.rs                    | 23 +++++
crates/gpui/src/platform.rs                        |  3 
crates/gpui/src/platform/linux/wayland/window.rs   |  8 +
crates/gpui/src/platform/linux/x11/window.rs       |  8 +
crates/gpui/src/platform/mac/window.rs             |  8 +
crates/gpui/src/platform/test/window.rs            |  8 +
crates/gpui/src/platform/windows/events.rs         | 62 ++++-----------
crates/gpui/src/platform/windows/window.rs         | 39 +--------
crates/gpui/src/window.rs                          | 40 ++++++++++
crates/title_bar/src/platforms/platform_windows.rs | 22 +++--
crates/title_bar/src/title_bar.rs                  |  4 
11 files changed, 129 insertions(+), 96 deletions(-)

Detailed changes

crates/gpui/src/elements/div.rs 🔗

@@ -21,7 +21,8 @@ use crate::{
     HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent,
     LayoutId, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
     Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
-    StyleRefinement, Styled, Task, TooltipId, Visibility, Window, point, px, size,
+    StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px,
+    size,
 };
 use collections::HashMap;
 use refineable::Refineable;
@@ -575,6 +576,12 @@ impl Interactivity {
         self.hitbox_behavior = HitboxBehavior::BlockMouse;
     }
 
+    /// Set the bounds of this element as a window control area for the platform window.
+    /// The imperative API equivalent to [`InteractiveElement::window_control_area`]
+    pub fn window_control_area(&mut self, area: WindowControlArea) {
+        self.window_control = Some(area);
+    }
+
     /// Block non-scroll mouse interactions with elements behind this element's hitbox. See
     /// [`Hitbox::is_hovered`] for details.
     ///
@@ -958,6 +965,13 @@ pub trait InteractiveElement: Sized {
         self
     }
 
+    /// Set the bounds of this element as a window control area for the platform window.
+    /// The fluent API equivalent to [`Interactivity::window_control_area`]
+    fn window_control_area(mut self, area: WindowControlArea) -> Self {
+        self.interactivity().window_control_area(area);
+        self
+    }
+
     /// Block non-scroll mouse interactions with elements behind this element's hitbox. See
     /// [`Hitbox::is_hovered`] for details.
     ///
@@ -1447,6 +1461,7 @@ pub struct Interactivity {
     pub(crate) drag_listener: Option<(Arc<dyn Any>, DragListener)>,
     pub(crate) hover_listener: Option<Box<dyn Fn(&bool, &mut Window, &mut App)>>,
     pub(crate) tooltip_builder: Option<TooltipBuilder>,
+    pub(crate) window_control: Option<WindowControlArea>,
     pub(crate) hitbox_behavior: HitboxBehavior,
 
     #[cfg(any(feature = "inspector", debug_assertions))]
@@ -1611,6 +1626,7 @@ impl Interactivity {
 
     fn should_insert_hitbox(&self, style: &Style, window: &Window, cx: &App) -> bool {
         self.hitbox_behavior != HitboxBehavior::Normal
+            || self.window_control.is_some()
             || style.mouse_cursor.is_some()
             || self.group.is_some()
             || self.scroll_offset.is_some()
@@ -1740,6 +1756,11 @@ impl Interactivity {
                                             GroupHitboxes::push(group, hitbox.id, cx);
                                         }
 
+                                        if let Some(area) = self.window_control {
+                                            window
+                                                .insert_window_control_hitbox(area, hitbox.clone());
+                                        }
+
                                         self.paint_mouse_listeners(
                                             hitbox,
                                             element_state.as_mut(),

crates/gpui/src/platform.rs 🔗

@@ -36,7 +36,7 @@ use crate::{
     ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
     Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene,
     ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window,
-    hash, point, px, size,
+    WindowControlArea, hash, point, px, size,
 };
 use anyhow::Result;
 use async_task::Runnable;
@@ -436,6 +436,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn on_resize(&self, callback: Box<dyn FnMut(Size<Pixels>, f32)>);
     fn on_moved(&self, callback: Box<dyn FnMut()>);
     fn on_should_close(&self, callback: Box<dyn FnMut() -> bool>);
+    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 draw(&self, scene: &Scene);

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

@@ -31,8 +31,8 @@ use crate::{
     AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels,
     PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions,
     ResizeEdge, ScaledPixels, Size, Tiling, WaylandClientStatePtr, WindowAppearance,
-    WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowParams, px,
-    size,
+    WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
+    WindowParams, px, size,
 };
 
 #[derive(Default)]
@@ -978,6 +978,10 @@ impl PlatformWindow for WaylandWindow {
         self.0.callbacks.borrow_mut().close = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, _callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        unimplemented!()
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.callbacks.borrow_mut().appearance_changed = Some(callback);
     }

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

@@ -5,8 +5,8 @@ use crate::{
     AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers,
     Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
     Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size,
-    Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
-    WindowKind, WindowParams, X11ClientStatePtr, px, size,
+    Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea,
+    WindowDecorations, WindowKind, WindowParams, X11ClientStatePtr, px, size,
 };
 
 use blade_graphics as gpu;
@@ -1408,6 +1408,10 @@ impl PlatformWindow for X11Window {
         self.0.callbacks.borrow_mut().close = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, _callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        unimplemented!()
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.callbacks.borrow_mut().appearance_changed = Some(callback);
     }

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

@@ -4,8 +4,8 @@ use crate::{
     KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
     MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
     PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ScaledPixels, Size,
-    Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowKind, WindowParams,
-    platform::PlatformInputHandler, point, px, size,
+    Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea,
+    WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size,
 };
 use block::ConcreteBlock;
 use cocoa::{
@@ -1146,6 +1146,10 @@ impl PlatformWindow for MacWindow {
         self.0.as_ref().lock().close_callback = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, _callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        unimplemented!()
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.lock().appearance_changed_callback = Some(callback);
     }

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

@@ -2,7 +2,7 @@ use crate::{
     AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs,
     Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
     Point, PromptButton, RequestFrameOptions, ScaledPixels, Size, TestPlatform, TileId,
-    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowParams,
+    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams,
 };
 use collections::HashMap;
 use parking_lot::Mutex;
@@ -21,6 +21,7 @@ pub(crate) struct TestWindowState {
     platform: Weak<TestPlatform>,
     sprite_atlas: Arc<dyn PlatformAtlas>,
     pub(crate) should_close_handler: Option<Box<dyn FnMut() -> bool>>,
+    hit_test_window_control_callback: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
     input_callback: Option<Box<dyn FnMut(PlatformInput) -> DispatchEventResult>>,
     active_status_change_callback: Option<Box<dyn FnMut(bool)>>,
     hover_status_change_callback: Option<Box<dyn FnMut(bool)>>,
@@ -65,6 +66,7 @@ impl TestWindow {
             title: Default::default(),
             edited: false,
             should_close_handler: None,
+            hit_test_window_control_callback: None,
             input_callback: None,
             active_status_change_callback: None,
             hover_status_change_callback: None,
@@ -254,6 +256,10 @@ impl PlatformWindow for TestWindow {
 
     fn on_close(&self, _callback: Box<dyn FnOnce()>) {}
 
+    fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        self.0.lock().hit_test_window_control_callback = Some(callback);
+    }
+
     fn on_appearance_changed(&self, _callback: Box<dyn FnMut()>) {}
 
     fn draw(&self, _scene: &crate::Scene) {}

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

@@ -35,7 +35,7 @@ pub(crate) fn handle_msg(
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> LRESULT {
     let handled = match msg {
-        WM_ACTIVATE => handle_activate_msg(handle, wparam, state_ptr),
+        WM_ACTIVATE => handle_activate_msg(wparam, state_ptr),
         WM_CREATE => handle_create_msg(handle, state_ptr),
         WM_MOVE => handle_move_msg(handle, lparam, state_ptr),
         WM_SIZE => handle_size_msg(wparam, lparam, state_ptr),
@@ -778,21 +778,8 @@ fn handle_calc_client_size(
     Some(0)
 }
 
-fn handle_activate_msg(
-    handle: HWND,
-    wparam: WPARAM,
-    state_ptr: Rc<WindowsWindowStatePtr>,
-) -> Option<isize> {
+fn handle_activate_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
     let activated = wparam.loword() > 0;
-    if state_ptr.hide_title_bar {
-        if let Some(titlebar_rect) = state_ptr.state.borrow().get_titlebar_rect().log_err() {
-            unsafe {
-                InvalidateRect(Some(handle), Some(&titlebar_rect), false)
-                    .ok()
-                    .log_err()
-            };
-        }
-    }
     let this = state_ptr.clone();
     state_ptr
         .executor
@@ -900,9 +887,6 @@ fn handle_hit_test_msg(
     if !state_ptr.is_movable {
         return None;
     }
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
 
     // default handler for resize areas
     let hit = unsafe { DefWindowProcW(handle, msg, wparam, lparam) };
@@ -938,20 +922,22 @@ fn handle_hit_test_msg(
         return Some(HTTOP as _);
     }
 
-    let titlebar_rect = state_ptr.state.borrow().get_titlebar_rect();
-    if let Ok(titlebar_rect) = titlebar_rect {
-        if cursor_point.y < titlebar_rect.bottom {
-            let caption_btn_width = (state_ptr.state.borrow().caption_button_width().0
-                * state_ptr.state.borrow().scale_factor) as i32;
-            if cursor_point.x >= titlebar_rect.right - caption_btn_width {
-                return Some(HTCLOSE as _);
-            } else if cursor_point.x >= titlebar_rect.right - caption_btn_width * 2 {
-                return Some(HTMAXBUTTON as _);
-            } else if cursor_point.x >= titlebar_rect.right - caption_btn_width * 3 {
-                return Some(HTMINBUTTON as _);
-            }
-
-            return Some(HTCAPTION as _);
+    let mut lock = state_ptr.state.borrow_mut();
+    if let Some(mut callback) = lock.callbacks.hit_test_window_control.take() {
+        drop(lock);
+        let area = callback();
+        state_ptr
+            .state
+            .borrow_mut()
+            .callbacks
+            .hit_test_window_control = Some(callback);
+        if let Some(area) = area {
+            return match area {
+                WindowControlArea::Drag => Some(HTCAPTION as _),
+                WindowControlArea::Close => Some(HTCLOSE as _),
+                WindowControlArea::Max => Some(HTMAXBUTTON as _),
+                WindowControlArea::Min => Some(HTMINBUTTON as _),
+            };
         }
     }
 
@@ -963,10 +949,6 @@ fn handle_nc_mouse_move_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
-
     start_tracking_mouse(handle, &state_ptr, TME_LEAVE | TME_NONCLIENT);
 
     let mut lock = state_ptr.state.borrow_mut();
@@ -997,10 +979,6 @@ fn handle_nc_mouse_down_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
-
     let mut lock = state_ptr.state.borrow_mut();
     if let Some(mut func) = lock.callbacks.input.take() {
         let scale_factor = lock.scale_factor;
@@ -1052,10 +1030,6 @@ fn handle_nc_mouse_up_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
-
     let mut lock = state_ptr.state.borrow_mut();
     if let Some(mut func) = lock.callbacks.input.take() {
         let scale_factor = lock.scale_factor;

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

@@ -190,40 +190,6 @@ impl WindowsWindowState {
     fn content_size(&self) -> Size<Pixels> {
         self.logical_size
     }
-
-    fn title_bar_padding(&self) -> Pixels {
-        // using USER_DEFAULT_SCREEN_DPI because GPUI handles the scale with the scale factor
-        let padding = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, USER_DEFAULT_SCREEN_DPI) };
-        px(padding as f32)
-    }
-
-    fn title_bar_top_offset(&self) -> Pixels {
-        if self.is_maximized() {
-            self.title_bar_padding() * 2
-        } else {
-            px(0.)
-        }
-    }
-
-    fn title_bar_height(&self) -> Pixels {
-        // todo(windows) this is hardcoded to match the ui title bar
-        //               in the future the ui title bar component will report the size
-        px(32.) + self.title_bar_top_offset()
-    }
-
-    pub(crate) fn caption_button_width(&self) -> Pixels {
-        // todo(windows) this is hardcoded to match the ui title bar
-        //               in the future the ui title bar component will report the size
-        px(36.)
-    }
-
-    pub(crate) fn get_titlebar_rect(&self) -> anyhow::Result<RECT> {
-        let height = self.title_bar_height();
-        let mut rect = RECT::default();
-        unsafe { GetClientRect(self.hwnd, &mut rect) }?;
-        rect.bottom = rect.top + ((height.0 * self.scale_factor).round() as i32);
-        Ok(rect)
-    }
 }
 
 impl WindowsWindowStatePtr {
@@ -347,6 +313,7 @@ pub(crate) struct Callbacks {
     pub(crate) moved: Option<Box<dyn FnMut()>>,
     pub(crate) should_close: Option<Box<dyn FnMut() -> bool>>,
     pub(crate) close: Option<Box<dyn FnOnce()>>,
+    pub(crate) hit_test_window_control: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
     pub(crate) appearance_changed: Option<Box<dyn FnMut()>>,
 }
 
@@ -796,6 +763,10 @@ impl PlatformWindow for WindowsWindow {
         self.0.state.borrow_mut().callbacks.close = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        self.0.state.borrow_mut().callbacks.hit_test_window_control = Some(callback);
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.state.borrow_mut().callbacks.appearance_changed = Some(callback);
     }

crates/gpui/src/window.rs 🔗

@@ -418,6 +418,19 @@ pub(crate) struct HitTest {
     pub(crate) hover_hitbox_count: usize,
 }
 
+/// A type of window control area that corresponds to the platform window.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WindowControlArea {
+    /// An area that allows dragging of the platform window.
+    Drag,
+    /// An area that allows closing of the platform window.
+    Close,
+    /// An area that allows maximizing of the platform window.
+    Max,
+    /// An area that allows minimizing of the platform window.
+    Min,
+}
+
 /// An identifier for a [Hitbox] which also includes [HitboxBehavior].
 #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
 pub struct HitboxId(u64);
@@ -604,6 +617,7 @@ pub(crate) struct Frame {
     pub(crate) dispatch_tree: DispatchTree,
     pub(crate) scene: Scene,
     pub(crate) hitboxes: Vec<Hitbox>,
+    pub(crate) window_control_hitboxes: Vec<(WindowControlArea, Hitbox)>,
     pub(crate) deferred_draws: Vec<DeferredDraw>,
     pub(crate) input_handlers: Vec<Option<PlatformInputHandler>>,
     pub(crate) tooltip_requests: Vec<Option<TooltipRequest>>,
@@ -647,6 +661,7 @@ impl Frame {
             dispatch_tree,
             scene: Scene::default(),
             hitboxes: Vec::new(),
+            window_control_hitboxes: Vec::new(),
             deferred_draws: Vec::new(),
             input_handlers: Vec::new(),
             tooltip_requests: Vec::new(),
@@ -673,6 +688,7 @@ impl Frame {
         self.tooltip_requests.clear();
         self.cursor_styles.clear();
         self.hitboxes.clear();
+        self.window_control_hitboxes.clear();
         self.deferred_draws.clear();
         self.focus = None;
 
@@ -1013,6 +1029,22 @@ impl Window {
                     .unwrap_or(DispatchEventResult::default())
             })
         });
+        platform_window.on_hit_test_window_control({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, window, _cx| {
+                        for (area, hitbox) in &window.rendered_frame.window_control_hitboxes {
+                            if window.mouse_hit_test.ids.contains(&hitbox.id) {
+                                return Some(*area);
+                            }
+                        }
+                        None
+                    })
+                    .log_err()
+                    .unwrap_or(None)
+            })
+        });
 
         if let Some(app_id) = app_id {
             platform_window.set_app_id(&app_id);
@@ -3002,6 +3034,14 @@ impl Window {
         hitbox
     }
 
+    /// Set a hitbox which will act as a control area of the platform window.
+    ///
+    /// This method should only be called as part of the paint phase of element drawing.
+    pub fn insert_window_control_hitbox(&mut self, area: WindowControlArea, hitbox: Hitbox) {
+        self.invalidator.debug_assert_paint();
+        self.next_frame.window_control_hitboxes.push((area, hitbox));
+    }
+
     /// Sets the key context for the current element. This context will be used to translate
     /// keybindings into actions.
     ///

crates/title_bar/src/platforms/platform_windows.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{Rgba, WindowAppearance, prelude::*};
+use gpui::{Rgba, WindowAppearance, WindowControlArea, prelude::*};
 
 use ui::prelude::*;
 
@@ -118,17 +118,12 @@ impl WindowsCaptionButton {
 
 impl RenderOnce for WindowsCaptionButton {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        // todo(windows) report this width to the Windows platform API
-        // NOTE: this is intentionally hard coded. An option to use the 'native' size
-        //       could be added when the width is reported to the Windows platform API
-        //       as this could change between future Windows versions.
-        let width = px(36.);
-
         h_flex()
             .id(self.id)
             .justify_center()
             .content_center()
-            .w(width)
+            .occlude()
+            .w(px(36.))
             .h_full()
             .text_size(px(10.0))
             .hover(|style| style.bg(self.hover_background_color))
@@ -138,6 +133,17 @@ impl RenderOnce for WindowsCaptionButton {
 
                 style.bg(active_color)
             })
+            .map(|this| match self.icon {
+                WindowsCaptionButtonIcon::Close => {
+                    this.window_control_area(WindowControlArea::Close)
+                }
+                WindowsCaptionButtonIcon::Maximize | WindowsCaptionButtonIcon::Restore => {
+                    this.window_control_area(WindowControlArea::Max)
+                }
+                WindowsCaptionButtonIcon::Minimize => {
+                    this.window_control_area(WindowControlArea::Min)
+                }
+            })
             .child(match self.icon {
                 WindowsCaptionButtonIcon::Minimize => "\u{e921}",
                 WindowsCaptionButtonIcon::Restore => "\u{e923}",

crates/title_bar/src/title_bar.rs 🔗

@@ -22,7 +22,8 @@ use client::{Client, UserStore};
 use gpui::{
     Action, AnyElement, App, Context, Corner, Decorations, Element, Entity, InteractiveElement,
     Interactivity, IntoElement, MouseButton, ParentElement, Render, Stateful,
-    StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, px,
+    StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, WindowControlArea,
+    actions, div, px,
 };
 use onboarding_banner::OnboardingBanner;
 use project::Project;
@@ -143,6 +144,7 @@ impl Render for TitleBar {
 
         h_flex()
             .id("titlebar")
+            .window_control_area(WindowControlArea::Drag)
             .w_full()
             .h(height)
             .map(|this| {