From ca3f46588a7430c903764edeeb3194414c3160d1 Mon Sep 17 00:00:00 2001 From: Matin Aniss <76515905+MatinAniss@users.noreply.github.com> Date: Sat, 7 Jun 2025 03:11:24 +1000 Subject: [PATCH] gpui: Implement dynamic window control elements (#30828) 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 --- crates/gpui/src/elements/div.rs | 23 ++++++- crates/gpui/src/platform.rs | 3 +- .../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 ++++++++++++ .../src/platforms/platform_windows.rs | 22 ++++--- crates/title_bar/src/title_bar.rs | 4 +- 11 files changed, 129 insertions(+), 96 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 3e9d1e27a93f8c296eeee4ead12013e01d743f4f..4c96ede3ca118e34484a4519ab67076adde4bdf8 100644 --- a/crates/gpui/src/elements/div.rs +++ b/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, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, + pub(crate) window_control: Option, 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(), diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 9687879006104885f06c91257c7ea25ca0896bbd..7e0b1cea6af8e1687457ef58e540be7907ef9f99 100644 --- a/crates/gpui/src/platform.rs +++ b/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, f32)>); fn on_moved(&self, callback: Box); fn on_should_close(&self, callback: Box bool>); + fn on_hit_test_window_control(&self, callback: Box Option>); fn on_close(&self, callback: Box); fn on_appearance_changed(&self, callback: Box); fn draw(&self, scene: &Scene); diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 3fb8a588fb9f4de498b636b283007846c6328109..e0ee53b983d0bb7f4425d73f674d3f55bf091f8d 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/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 Option>) { + unimplemented!() + } + fn on_appearance_changed(&self, callback: Box) { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index 4ad36460e32f7ae97f0ceb0cf85f59e302fcba03..63285123f51527b10a4dab12ba6c2fde681c8826 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/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 Option>) { + unimplemented!() + } + fn on_appearance_changed(&self, callback: Box) { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 651ee1e4adbb999f4f9f1055fe8f96bf96542804..de9b1b9d1308778a52434e5d9a878bb0071b4d8e 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/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 Option>) { + unimplemented!() + } + fn on_appearance_changed(&self, callback: Box) { self.0.lock().appearance_changed_callback = Some(callback); } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 3dd75ed7bc6d5e93001b189f0a941f5173752608..d29fcca882199774590890d10e4a3f3d58f30052 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/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, sprite_atlas: Arc, pub(crate) should_close_handler: Option bool>>, + hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, active_status_change_callback: Option>, hover_status_change_callback: Option>, @@ -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) {} + fn on_hit_test_window_control(&self, callback: Box Option>) { + self.0.lock().hit_test_window_control_callback = Some(callback); + } + fn on_appearance_changed(&self, _callback: Box) {} fn draw(&self, _scene: &crate::Scene) {} diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 2db2da65611d27501d832f3488d26568a3963168..7b0bde2d0842befd3afa01740e9c0cfaf6db1a04 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -35,7 +35,7 @@ pub(crate) fn handle_msg( state_ptr: Rc, ) -> 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, -) -> Option { +fn handle_activate_msg(wparam: WPARAM, state_ptr: Rc) -> Option { 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, ) -> Option { - 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, ) -> Option { - 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, ) -> Option { - 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; diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index cd55a851cc2433f63b91b0f0269e0e4d216a2675..7f15ced16e15cb15cfd4b8e5eb3164fba171a5ac 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -190,40 +190,6 @@ impl WindowsWindowState { fn content_size(&self) -> Size { 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 { - 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>, pub(crate) should_close: Option bool>>, pub(crate) close: Option>, + pub(crate) hit_test_window_control: Option Option>>, pub(crate) appearance_changed: Option>, } @@ -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 Option>) { + self.0.state.borrow_mut().callbacks.hit_test_window_control = Some(callback); + } + fn on_appearance_changed(&self, callback: Box) { self.0.state.borrow_mut().callbacks.appearance_changed = Some(callback); } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 32d5501557440514f31b3a45eb3c7069535e91b0..071a22128790c354966aca72ad221ea00e10bd43 100644 --- a/crates/gpui/src/window.rs +++ b/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, + pub(crate) window_control_hitboxes: Vec<(WindowControlArea, Hitbox)>, pub(crate) deferred_draws: Vec, pub(crate) input_handlers: Vec>, pub(crate) tooltip_requests: Vec>, @@ -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. /// diff --git a/crates/title_bar/src/platforms/platform_windows.rs b/crates/title_bar/src/platforms/platform_windows.rs index 96ce6d7380ff71b7a9ae3672c53667b672fc9a2c..e56ea1c160e018907ff0b6b4c91d544a4f284062 100644 --- a/crates/title_bar/src/platforms/platform_windows.rs +++ b/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}", diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index c96e38a17902da010a7065573907d24aff835dd0..344556d60df8a88a3a78f79f2bdf310ac666b3c0 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/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| {