From 1340dcf3e9642ffdbd98cb3059d166d932a882c8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 3 Nov 2026 08:15:23 +0700 Subject: [PATCH] add theme titlebar height and app-owned strip dragging --- crates/layout/src/dock.rs | 34 +++++++++++------- crates/layout/src/shell.rs | 16 ++++++--- crates/layout/src/window_controls.rs | 52 +++++++++++++++++++++++----- crates/theme/src/lib.rs | 3 ++ crates/theme/src/resolve.rs | 21 +++++++++++ crates/theme/src/source/kde.rs | 1 + crates/theme/src/source/linux.rs | 2 ++ crates/theme/src/source/macos.rs | 1 + crates/theme/src/source/omarchy.rs | 1 + crates/theme/src/source/windows.rs | 3 ++ crates/theme/src/system.rs | 3 ++ examples/gallery/src/main.rs | 3 ++ 12 files changed, 114 insertions(+), 26 deletions(-) diff --git a/crates/layout/src/dock.rs b/crates/layout/src/dock.rs index 09228e7..9b92726 100644 --- a/crates/layout/src/dock.rs +++ b/crates/layout/src/dock.rs @@ -7,9 +7,9 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, AnyView, App, AppContext as _, Axis, Context, ElementId, Entity, - InteractiveElement as _, IntoElement, ParentElement as _, Render, RenderOnce, Role, - SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, - WindowControlArea, canvas, div, px, relative, size, + InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, RenderOnce, Role, + SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, canvas, + div, px, relative, size, }; use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; @@ -19,7 +19,7 @@ use gpui_native_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::resize_handle::ResizeHandle; -use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, maximize_on_double_click}; +use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, titlebar_strip}; /// The narrowest either side of a split can get, as a fraction of the split. const MIN_SPLIT_RATIO: f32 = 0.1; @@ -789,6 +789,7 @@ impl RenderOnce for Dock { let titlebar = Titlebar { window_controls: self.window_controls, traffic_light_pad: self.traffic_light_pad, + height: cx.theme().titlebar_height, }; let root = render_node(&self.id, &self.state, &tree, &titlebar, window, cx); @@ -811,6 +812,7 @@ impl RenderOnce for Dock { struct Titlebar { window_controls: bool, traffic_light_pad: bool, + height: Pixels, } /// The right sidebar content of the dock's active panel. @@ -997,7 +999,7 @@ fn render_pane( let pane = pane.clone(); let titlebar = *titlebar; let padding_x = metrics.dense_padding_x; - move |_, _| { + move |window, cx| { let selected = pane .active .as_ref() @@ -1074,20 +1076,28 @@ fn render_pane( }) .flex_none() .w_full() + .h(titlebar.height) .items_center() - .window_control_area(WindowControlArea::Drag) - .on_click(maximize_on_double_click()) .when(titlebar.traffic_light_pad, |bar| { bar.pl(px(TRAFFIC_LIGHT_PAD)) }) .pr(padding_x) .child(tabs) .when(titlebar.window_controls, |bar| { - bar.child(div().flex_1()) - .child(WindowControls::new(SharedString::from(format!( - "{dock_id}-pane-{}-controls", - pane.key - )))) + bar.child(titlebar_strip( + div() + .id(SharedString::from(format!( + "{dock_id}-pane-{}-strip", + pane.key + ))) + .flex_1(), + window, + cx, + )) + .child(WindowControls::new(SharedString::from(format!( + "{dock_id}-pane-{}-controls", + pane.key + )))) }) .into_any_element() } diff --git a/crates/layout/src/shell.rs b/crates/layout/src/shell.rs index 548df91..4b7f1bd 100644 --- a/crates/layout/src/shell.rs +++ b/crates/layout/src/shell.rs @@ -4,8 +4,8 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _, - IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, - StatefulInteractiveElement as _, Styled as _, Window, WindowControlArea, div, px, + IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, Styled as _, Window, div, + px, }; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; @@ -14,7 +14,7 @@ use gpui_native_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::resize_handle::ResizeHandle; -use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, maximize_on_double_click}; +use crate::window_controls::{TRAFFIC_LIGHT_PAD, WindowControls, titlebar_strip}; use crate::window_frame::frame; /// The width a sidebar opens at. @@ -207,6 +207,8 @@ impl RenderOnce for Shell { &state, metrics.strip_height, metrics.dense_padding_x, + window, + cx, )) .child(ResizeHandle::new((id.clone(), "left-resize")).on_resize({ let state = state.clone(); @@ -239,6 +241,8 @@ impl RenderOnce for Shell { &state, metrics.strip_height, metrics.dense_padding_x, + window, + cx, )) }); @@ -248,6 +252,7 @@ impl RenderOnce for Shell { } /// One sidebar: a header strip with the hide toggle over the app's content. +#[allow(clippy::too_many_arguments)] fn sidebar_region( width: Pixels, content: AnyElement, @@ -255,6 +260,8 @@ fn sidebar_region( state: &Entity, strip_height: Pixels, padding_x: Pixels, + window: &mut Window, + cx: &mut App, ) -> AnyElement { let strip = h_flex() .id(SharedString::from(format!("sidebar-strip-{}", side.name()))) @@ -263,8 +270,7 @@ fn sidebar_region( .h(strip_height) .items_center() .justify_end() - .window_control_area(WindowControlArea::Drag) - .on_click(maximize_on_double_click()) + .map(|strip| titlebar_strip(strip, window, cx)) .map(|strip| match side { Side::Left => strip.when(cfg!(target_os = "macos"), |strip| { strip.pl(px(TRAFFIC_LIGHT_PAD)) diff --git a/crates/layout/src/window_controls.rs b/crates/layout/src/window_controls.rs index e7a1cff..a4745e8 100644 --- a/crates/layout/src/window_controls.rs +++ b/crates/layout/src/window_controls.rs @@ -1,17 +1,17 @@ //! The window controls: minimize, maximize/restore, close — in the platform's -//! own dress and behavior. The titlebar strips drag the window themselves via -//! `WindowControlArea::Drag`; macOS draws its traffic lights in the shell's -//! reserved pad and needs no controls of ours. +//! own dress and behavior. Titlebar strips are app-owned: they move the +//! window themselves via [`titlebar_strip`], so a dragged tab or button press +//! never moves it; macOS draws its traffic lights in the shell's reserved pad +//! and needs no controls of ours. -use gpui::{App, ClickEvent, ElementId, IntoElement, RenderOnce, Window, div}; +use gpui::WindowControlArea; +use gpui::{App, ClickEvent, Div, ElementId, IntoElement, RenderOnce, Stateful, Window, div}; +use gpui::{InteractiveElement as _, MouseButton, StatefulInteractiveElement as _}; #[cfg(any(target_os = "windows", target_os = "linux"))] use gpui::prelude::FluentBuilder as _; #[cfg(any(target_os = "windows", target_os = "linux"))] -use gpui::{ - AnyElement, InteractiveElement as _, ParentElement as _, StatefulInteractiveElement as _, - Styled as _, -}; +use gpui::{AnyElement, ParentElement as _}; #[cfg(any(target_os = "windows", target_os = "linux"))] use gpui_native_icons::{IconRole, icon}; #[cfg(any(target_os = "windows", target_os = "linux"))] @@ -20,7 +20,7 @@ use gpui_native_theme::ActiveTheme as _; #[cfg(target_os = "linux")] use gpui::Decorations; #[cfg(target_os = "windows")] -use gpui::{WindowControlArea, px, rgba}; +use gpui::rgba; /// macOS traffic-lights zone: the pad a leading titlebar strip reserves. pub const TRAFFIC_LIGHT_PAD: f32 = 78.0; @@ -164,6 +164,40 @@ pub fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) } } +/// Makes an element a titlebar strip: pressing and dragging it moves the +/// window, double-clicking maximizes, and Windows keeps its native caption +/// behavior through the `Drag` control area. +pub fn titlebar_strip(this: Stateful
, window: &mut Window, cx: &mut App) -> Stateful
{ + let should_move = window.use_state(cx, |_, _| false); + this.window_control_area(WindowControlArea::Drag) + .on_mouse_down_out(window.listener_for(&should_move, |should_move, _, _, _| { + *should_move = false; + })) + .on_mouse_down( + MouseButton::Left, + window.listener_for(&should_move, |should_move, _, _, _| { + *should_move = true; + }), + ) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&should_move, |should_move, _, _, _| { + *should_move = false; + }), + ) + .on_mouse_move( + window.listener_for(&should_move, |should_move, _, window, cx| { + if *should_move { + *should_move = false; + if !cx.has_active_drag() { + window.start_window_move(); + } + } + }), + ) + .on_click(maximize_on_double_click()) +} + /// One Windows caption button. #[cfg(target_os = "windows")] fn caption_button( diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 0bb3027..9103b66 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -53,6 +53,8 @@ pub struct Theme { pub motion: Motion, /// Overlay scrollbar geometry. pub scrollbar: Scrollbar, + /// The height the window's titlebar strips draw at, per the platform. + pub titlebar_height: Pixels, /// The platform's native root font size; rem-based sizing scales with it. pub root_font_size: Pixels, pub sizes: SizeTable, @@ -403,6 +405,7 @@ impl Theme { elevations: resolved.elevations, motion: resolved.motion, scrollbar: resolved.scrollbar, + titlebar_height: resolved.titlebar_height, root_font_size: resolved.root_font_size, sizes: resolved.sizes, accessibility: resolved.accessibility, diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 74afaea..f0af057 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -4,6 +4,10 @@ use crate::system::SystemTheme; use crate::{ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, ThemeVariant}; use gpui::px; +/// The floor of a custom titlebar's height, in px at 100% scaling: the height +/// macOS and Linux titlebars draw at regardless of the control scale. +const TITLEBAR_HEIGHT_MIN_PX: f32 = 34.0; + /// Derived state shades for the colors that get pressed and hovered. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Ramps { @@ -25,6 +29,8 @@ pub struct ResolvedTheme { pub elevations: Elevations, pub motion: Motion, pub scrollbar: Scrollbar, + /// The height the window's titlebar strips draw at, per the platform. + pub titlebar_height: gpui::Pixels, /// The platform's native root font size; the fallback theme uses 16. pub root_font_size: gpui::Pixels, pub sizes: SizeTable, @@ -196,6 +202,11 @@ pub(crate) fn resolve( sizes.set_input_height(input_height); } + // A platform that states its titlebar height wins. + let titlebar_height = system + .titlebar_height + .unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX))); + let mut radius = Radius::default(); if let Some(control) = preset .and_then(|def| def.control_radius) @@ -228,6 +239,7 @@ pub(crate) fn resolve( elevations: Elevations::for_platform(system.source, variant), motion: Motion::default(), scrollbar: Scrollbar::default(), + titlebar_height, root_font_size, sizes, accessibility, @@ -255,6 +267,7 @@ mod tests { palette, control_radius: None, input_height: None, + titlebar_height: None, root_font_size: None, accessibility: AccessibilityPreferences::default(), source: SourceKind::Macos, @@ -303,9 +316,17 @@ mod tests { assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75)); assert_eq!(resolved.sizes.medium.plate_inset, px(4.)); assert_eq!(resolved.sizes.medium.strip_height, px(32.)); + assert_eq!(resolved.titlebar_height, px(34.)); } } + #[test] + fn a_stated_titlebar_height_wins_over_the_control_scale() { + let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None); + stated.titlebar_height = Some(px(32.)); + assert_eq!(resolve(&stated, None, None).titlebar_height, px(32.)); + } + #[test] fn a_stated_root_font_size_scales_the_size_table() { // macOS states 13pt; the medium control shrinks to native proportions. diff --git a/crates/theme/src/source/kde.rs b/crates/theme/src/source/kde.rs index 42a7411..48720ab 100644 --- a/crates/theme/src/source/kde.rs +++ b/crates/theme/src/source/kde.rs @@ -79,6 +79,7 @@ impl KdeScheme { palette: self.palette, control_radius: None, input_height: None, + titlebar_height: None, root_font_size: None, accessibility, source: SourceKind::Kde, diff --git a/crates/theme/src/source/linux.rs b/crates/theme/src/source/linux.rs index e0ec096..5e7f9d5 100644 --- a/crates/theme/src/source/linux.rs +++ b/crates/theme/src/source/linux.rs @@ -142,6 +142,7 @@ impl LinuxSource { palette: PartialPalette::default(), control_radius: None, input_height: None, + titlebar_height: None, root_font_size: None, accessibility, source: SourceKind::Gnome, @@ -154,6 +155,7 @@ impl LinuxSource { palette: PartialPalette::default(), control_radius: None, input_height: None, + titlebar_height: None, root_font_size: None, accessibility, source: SourceKind::Gnome, diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs index 290a9ba..9b4caf1 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -113,6 +113,7 @@ impl ThemeSource for MacosSource { palette, control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)), input_height: Some(gpui::px(INPUT_HEIGHT_PTS)), + titlebar_height: None, root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)), accessibility, source: SourceKind::Macos, diff --git a/crates/theme/src/source/omarchy.rs b/crates/theme/src/source/omarchy.rs index 40f527e..f411c44 100644 --- a/crates/theme/src/source/omarchy.rs +++ b/crates/theme/src/source/omarchy.rs @@ -62,6 +62,7 @@ impl OmarchyTheme { palette: self.palette, control_radius: None, input_height: None, + titlebar_height: None, root_font_size: None, accessibility, source: SourceKind::Omarchy, diff --git a/crates/theme/src/source/windows.rs b/crates/theme/src/source/windows.rs index a6f2df0..198d2b8 100644 --- a/crates/theme/src/source/windows.rs +++ b/crates/theme/src/source/windows.rs @@ -16,6 +16,8 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System const CONTROL_RADIUS_PX: f32 = 4.0; /// WinUI 11's default text-box height, in pixels at 100% scaling. const INPUT_HEIGHT_PX: f32 = 32.0; +/// The window caption's height, in pixels at 100% scaling, as Win32 reports it. +const TITLEBAR_HEIGHT_PX: f32 = 32.0; /// WinUI 11's default body text size, in pixels at 100% scaling. const ROOT_FONT_SIZE_PX: f32 = 14.0; @@ -158,6 +160,7 @@ impl ThemeSource for WindowsSource { }, control_radius: Some(gpui::px(CONTROL_RADIUS_PX)), input_height: Some(gpui::px(INPUT_HEIGHT_PX)), + titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)), root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)), accessibility: self.accessibility(), source: SourceKind::Windows, diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 2c6a23b..4af7ab8 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -78,6 +78,8 @@ pub struct SystemTheme { pub control_radius: Option, /// The platform's stated text-field height, if it has one. pub input_height: Option, + /// The platform's native titlebar height, if it states one. + pub titlebar_height: Option, /// The platform's native root font size, if it has one. pub root_font_size: Option, pub accessibility: AccessibilityPreferences, @@ -92,6 +94,7 @@ impl SystemTheme { palette: PartialPalette::default(), control_radius: None, input_height: None, + titlebar_height: None, root_font_size: None, accessibility: AccessibilityPreferences::default(), source: SourceKind::Fallback, diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index c187b64..47a5a29 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -2435,6 +2435,9 @@ fn main() { appears_transparent: true, ..Default::default() }), + // The strips move the window themselves, so a tab dragged inside + // the titlebar band never drags the window with it. + app_owns_titlebar_drag: true, ..Default::default() }, |window, cx| {