From a6014b4ff3557e88eace6715c5aa97bcc97ea3d6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 2 Nov 2026 19:04:13 +0700 Subject: [PATCH] layout: make strips the titlebar with window controls --- .github/workflows/ci.yml | 2 +- crates/icons/assets/copy.svg | 14 ++ crates/icons/assets/square.svg | 13 ++ crates/icons/src/lib.rs | 17 ++ crates/icons/src/segoe.rs | 35 ++-- crates/layout/Cargo.toml | 5 +- crates/layout/src/dock.rs | 69 ++++++- crates/layout/src/lib.rs | 3 + crates/layout/src/shell.rs | 72 ++++--- crates/layout/src/window_controls.rs | 274 +++++++++++++++++++++++++ crates/layout/tests/window_controls.rs | 145 +++++++++++++ docs/PLAN.md | 4 +- examples/gallery/src/main.rs | 12 +- 13 files changed, 603 insertions(+), 62 deletions(-) create mode 100644 crates/icons/assets/copy.svg create mode 100644 crates/icons/assets/square.svg create mode 100644 crates/layout/src/window_controls.rs create mode 100644 crates/layout/tests/window_controls.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8ddac3..6a4610b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: run: | cargo check -p gpui-native-layout --no-default-features cargo check -p gpui-native-layout --all-features - for f in dock resize_handle shell; do + for f in dock resize_handle shell window_controls; do cargo check -p gpui-native-layout --no-default-features --features "$f" done - name: Feature checks (facade) diff --git a/crates/icons/assets/copy.svg b/crates/icons/assets/copy.svg new file mode 100644 index 0000000..f62ce99 --- /dev/null +++ b/crates/icons/assets/copy.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/icons/assets/square.svg b/crates/icons/assets/square.svg new file mode 100644 index 0000000..7862733 --- /dev/null +++ b/crates/icons/assets/square.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index e581748..df4053a 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -31,6 +31,9 @@ pub enum IconRole { EyeOff, PanelLeft, PanelRight, + WindowMinimize, + WindowMaximize, + WindowRestore, } impl IconRole { @@ -52,6 +55,9 @@ impl IconRole { Self::EyeOff, Self::PanelLeft, Self::PanelRight, + Self::WindowMinimize, + Self::WindowMaximize, + Self::WindowRestore, ] } @@ -73,6 +79,9 @@ impl IconRole { Self::EyeOff => "icons/eye-off.svg", Self::PanelLeft => "icons/panel-left.svg", Self::PanelRight => "icons/panel-right.svg", + Self::WindowMinimize => "icons/minus.svg", + Self::WindowMaximize => "icons/square.svg", + Self::WindowRestore => "icons/copy.svg", } } @@ -94,6 +103,9 @@ impl IconRole { Self::EyeOff => "eye-off", Self::PanelLeft => "panel-left", Self::PanelRight => "panel-right", + Self::WindowMinimize => "window-minimize", + Self::WindowMaximize => "window-maximize", + Self::WindowRestore => "window-restore", } } @@ -165,6 +177,9 @@ impl IconRole { "panel-right", "sidebar-right", ], + Self::WindowMinimize => &["window-minimize-symbolic", "window-minimize"], + Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"], + Self::WindowRestore => &["window-restore-symbolic", "window-restore"], } } } @@ -263,6 +278,8 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> { match path { "icons/check.svg" => Some(include_bytes!("../assets/check.svg")), "icons/x.svg" => Some(include_bytes!("../assets/x.svg")), + "icons/square.svg" => Some(include_bytes!("../assets/square.svg")), + "icons/copy.svg" => Some(include_bytes!("../assets/copy.svg")), "icons/chevron-down.svg" => Some(include_bytes!("../assets/chevron-down.svg")), "icons/chevron-left.svg" => Some(include_bytes!("../assets/chevron-left.svg")), "icons/chevron-right.svg" => Some(include_bytes!("../assets/chevron-right.svg")), diff --git a/crates/icons/src/segoe.rs b/crates/icons/src/segoe.rs index 37ca0ee..69d6942 100644 --- a/crates/icons/src/segoe.rs +++ b/crates/icons/src/segoe.rs @@ -6,22 +6,25 @@ use crate::IconRole; /// The Segoe Fluent Icons codepoint for the role. pub(crate) fn glyph(role: IconRole) -> Option { let codepoint = match role { - IconRole::Check => 0xE73E, // CheckMark - IconRole::Close => 0xE8BB, // ChromeClose - IconRole::ChevronDown => 0xE70D, // ChevronDown - IconRole::ChevronLeft => 0xE76B, // ChevronLeft - IconRole::ChevronRight => 0xE76C, // ChevronRight - IconRole::ChevronUp => 0xE70E, // ChevronUp - IconRole::Plus => 0xE710, // Add - IconRole::Minus => 0xE738, // Remove - IconRole::Search => 0xE721, // Search - IconRole::Folder => 0xE8B7, // Folder - IconRole::Warning => 0xE7BA, // Warning - IconRole::Info => 0xE946, // Info - IconRole::Eye => 0xE7B3, // RedEye - IconRole::EyeOff => 0xED1A, // Hide - IconRole::PanelLeft => 0xE90C, // DockLeft - IconRole::PanelRight => 0xE90D, // DockRight + IconRole::Check => 0xE73E, // CheckMark + IconRole::Close => 0xE8BB, // ChromeClose + IconRole::ChevronDown => 0xE70D, // ChevronDown + IconRole::ChevronLeft => 0xE76B, // ChevronLeft + IconRole::ChevronRight => 0xE76C, // ChevronRight + IconRole::ChevronUp => 0xE70E, // ChevronUp + IconRole::Plus => 0xE710, // Add + IconRole::Minus => 0xE738, // Remove + IconRole::Search => 0xE721, // Search + IconRole::Folder => 0xE8B7, // Folder + IconRole::Warning => 0xE7BA, // Warning + IconRole::Info => 0xE946, // Info + IconRole::Eye => 0xE7B3, // RedEye + IconRole::EyeOff => 0xED1A, // Hide + IconRole::PanelLeft => 0xE90C, // DockLeft + IconRole::PanelRight => 0xE90D, // DockRight + IconRole::WindowMinimize => 0xE921, // ChromeMinimize + IconRole::WindowMaximize => 0xE922, // ChromeMaximize + IconRole::WindowRestore => 0xE923, // ChromeRestore }; char::from_u32(codepoint) } diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index 68ad3a6..5dc0bc3 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -17,7 +17,7 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["dock", "resize_handle", "shell"] +full = ["dock", "resize_handle", "shell", "window_controls"] dock = [ "resize_handle", "dep:gpui-native-core", @@ -25,11 +25,14 @@ dock = [ "dep:gpui-native-icons", "gpui-native-core/context_menu", "gpui-native-data/tabs", + "window_controls", ] resize_handle = [] shell = [ "resize_handle", + "window_controls", "dep:gpui-native-core", "dep:gpui-native-data", "dep:gpui-native-icons", ] +window_controls = ["dep:gpui-native-icons"] diff --git a/crates/layout/src/dock.rs b/crates/layout/src/dock.rs index 15bf27b..18740ec 100644 --- a/crates/layout/src/dock.rs +++ b/crates/layout/src/dock.rs @@ -7,8 +7,8 @@ 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, canvas, - div, px, relative, size, + SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, + WindowControlArea, canvas, div, px, relative, size, }; use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; @@ -18,6 +18,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}; /// The narrowest either side of a split can get, as a fraction of the split. const MIN_SPLIT_RATIO: f32 = 0.1; @@ -680,6 +681,10 @@ enum TreeView { pub struct Dock { id: ElementId, state: Entity, + /// The pill bar is the window's titlebar: trailing window controls. + window_controls: bool, + /// The pill bar leads the window: pad for the macOS traffic lights. + traffic_light_pad: bool, } impl Dock { @@ -691,14 +696,32 @@ impl Dock { Self { id: id.into(), state, + window_controls: false, + traffic_light_pad: false, } } + + /// Renders the window controls in the pill bar's trailing end. + pub fn window_controls(mut self, window_controls: bool) -> Self { + self.window_controls = window_controls; + self + } + + /// Pads the pill bar's leading edge for the macOS traffic lights. + pub fn traffic_light_pad(mut self, traffic_light_pad: bool) -> Self { + self.traffic_light_pad = traffic_light_pad; + self + } } impl RenderOnce for Dock { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let tree = self.state.read(cx).snapshot(); - let root = render_node(&self.id, &self.state, &tree, window, cx); + let titlebar = Titlebar { + window_controls: self.window_controls, + traffic_light_pad: self.traffic_light_pad, + }; + let root = render_node(&self.id, &self.state, &tree, &titlebar, window, cx); div() .id(self.id.clone()) @@ -714,16 +737,24 @@ impl RenderOnce for Dock { } } +/// Which titlebar duties the pane's pill bar carries. +#[derive(Clone, Copy, Default)] +struct Titlebar { + window_controls: bool, + traffic_light_pad: bool, +} + fn render_node( dock_id: &ElementId, state: &Entity, node: &TreeView, + titlebar: &Titlebar, window: &mut Window, cx: &mut App, ) -> AnyElement { match node { - TreeView::Pane(pane) => render_pane(dock_id, state, pane, cx), - TreeView::Split(split) => render_split(dock_id, state, split, window, cx), + TreeView::Pane(pane) => render_pane(dock_id, state, pane, titlebar, cx), + TreeView::Split(split) => render_split(dock_id, state, split, titlebar, window, cx), } } @@ -731,6 +762,7 @@ fn render_split( dock_id: &ElementId, state: &Entity, split: &SplitView, + titlebar: &Titlebar, window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -790,7 +822,7 @@ fn render_split( .min_w_0() .min_h_0() .overflow_hidden() - .child(render_node(dock_id, state, &split.a, window, cx)), + .child(render_node(dock_id, state, &split.a, titlebar, window, cx)), ) .child(seam) .child( @@ -800,7 +832,7 @@ fn render_split( .min_w_0() .min_h_0() .overflow_hidden() - .child(render_node(dock_id, state, &split.b, window, cx)), + .child(render_node(dock_id, state, &split.b, titlebar, window, cx)), ) .into_any_element() } @@ -809,6 +841,7 @@ fn render_pane( dock_id: &ElementId, state: &Entity, pane: &PaneView, + titlebar: &Titlebar, cx: &App, ) -> AnyElement { if pane.tabs.is_empty() { @@ -854,6 +887,7 @@ fn render_pane( let dock_id = dock_id.clone(); let state = state.clone(); let pane = pane.clone(); + let titlebar = *titlebar; let padding_x = metrics.dense_padding_x; move |_, _| { let selected = pane @@ -921,13 +955,32 @@ fn render_pane( } }); - // Chrome-less like the future titlebar: the bar wraps the pills. h_flex() + .id(SharedString::from(format!( + "{dock_id}-pane-{}-bar", + pane.key + ))) + .debug_selector({ + let id = SharedString::from(format!("{dock_id}-pane-{}-bar", pane.key)); + move || id.to_string() + }) .flex_none() .w_full() .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 + )))) + }) .into_any_element() } }) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 34d5095..c531b38 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -9,4 +9,7 @@ pub mod resize_handle; #[cfg(feature = "shell")] pub mod shell; +#[cfg(feature = "window_controls")] +pub mod window_controls; + mod window_frame; diff --git a/crates/layout/src/shell.rs b/crates/layout/src/shell.rs index 263cbfd..548df91 100644 --- a/crates/layout/src/shell.rs +++ b/crates/layout/src/shell.rs @@ -3,8 +3,9 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, AppContext as _, Context, ElementId, Entity, IntoElement, ParentElement as _, - Pixels, RenderOnce, SharedString, Styled as _, Window, div, px, + AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _, + IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, + StatefulInteractiveElement as _, Styled as _, Window, WindowControlArea, div, px, }; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; @@ -13,6 +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_frame::frame; /// The width a sidebar opens at. @@ -254,37 +256,49 @@ fn sidebar_region( strip_height: Pixels, padding_x: Pixels, ) -> AnyElement { + let strip = h_flex() + .id(SharedString::from(format!("sidebar-strip-{}", side.name()))) + .debug_selector(move || format!("sidebar-strip-{}", side.name())) + .flex_none() + .h(strip_height) + .items_center() + .justify_end() + .window_control_area(WindowControlArea::Drag) + .on_click(maximize_on_double_click()) + .map(|strip| match side { + Side::Left => strip.when(cfg!(target_os = "macos"), |strip| { + strip.pl(px(TRAFFIC_LIGHT_PAD)) + }), + Side::Right => strip.child(WindowControls::new("sidebar-window-controls")), + }) + .px(padding_x) + .child( + Button::new(SharedString::from(format!( + "sidebar-toggle-{}", + side.name() + ))) + .icon(side.toggle_icon()) + .variant(ButtonVariant::Ghost) + .small() + .accessibility_label(side.toggle_label()) + .on_click({ + let state = state.clone(); + move |_, _, cx| match side { + Side::Left => { + state.update(cx, |state, cx| state.set_left_sidebar_visible(false, cx)) + } + Side::Right => { + state.update(cx, |state, cx| state.set_right_sidebar_visible(false, cx)) + } + } + }), + ); + v_flex() .w(width) .h_full() .overflow_hidden() - .child( - h_flex() - .flex_none() - .h(strip_height) - .items_center() - .justify_end() - .px(padding_x) - .child( - Button::new(SharedString::from(format!( - "sidebar-toggle-{}", - side.name() - ))) - .icon(side.toggle_icon()) - .variant(ButtonVariant::Ghost) - .small() - .accessibility_label(side.toggle_label()) - .on_click({ - let state = state.clone(); - move |_, _, cx| match side { - Side::Left => state - .update(cx, |state, cx| state.set_left_sidebar_visible(false, cx)), - Side::Right => state - .update(cx, |state, cx| state.set_right_sidebar_visible(false, cx)), - } - }), - ), - ) + .child(strip) .child(div().flex_1().min_h_0().overflow_hidden().child(content)) .into_any_element() } diff --git a/crates/layout/src/window_controls.rs b/crates/layout/src/window_controls.rs new file mode 100644 index 0000000..e7a1cff --- /dev/null +++ b/crates/layout/src/window_controls.rs @@ -0,0 +1,274 @@ +//! 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. + +use gpui::{App, ClickEvent, ElementId, IntoElement, RenderOnce, Window, div}; + +#[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 _, +}; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use gpui_native_icons::{IconRole, icon}; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use gpui_native_theme::ActiveTheme as _; + +#[cfg(target_os = "linux")] +use gpui::Decorations; +#[cfg(target_os = "windows")] +use gpui::{WindowControlArea, px, rgba}; + +/// macOS traffic-lights zone: the pad a leading titlebar strip reserves. +pub const TRAFFIC_LIGHT_PAD: f32 = 78.0; +/// The width of a Windows caption button. +#[cfg(target_os = "windows")] +const CAPTION_WIDTH: f32 = 36.0; +/// Windows paints its caption close hover red in every theme. +#[cfg(target_os = "windows")] +const WINDOWS_CLOSE_HOVER: (f32, f32, f32) = (232.0 / 255.0, 17.0 / 255.0, 32.0 / 255.0); + +/// The window controls for the trailing end of a titlebar strip. +#[derive(IntoElement)] +pub struct WindowControls { + id: ElementId, +} + +impl WindowControls { + /// Creates the controls row. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { id: id.into() } + } +} + +impl RenderOnce for WindowControls { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id.clone(); + + #[cfg(target_os = "macos")] + { + let _ = (id, window, cx); + div() + } + + #[cfg(target_os = "windows")] + { + let controls = window.window_controls(); + div() + .flex_none() + .flex() + .flex_row() + .h_full() + .when(controls.minimize, |row| { + row.child(caption_button( + cx, + SharedString::from(format!("{id}-minimize")), + IconRole::WindowMinimize, + WindowControlArea::Min, + window.is_minimizable(), + false, + )) + }) + .when(controls.maximize, |row| { + row.child(caption_button( + cx, + SharedString::from(format!("{id}-maximize")), + if window.is_maximized() { + IconRole::WindowRestore + } else { + IconRole::WindowMaximize + }, + WindowControlArea::Max, + window.is_resizable(), + false, + )) + }) + .child(caption_button( + cx, + SharedString::from(format!("{id}-close")), + IconRole::Close, + WindowControlArea::Close, + true, + true, + )) + } + + #[cfg(target_os = "linux")] + { + let _ = id; + let controls = window.window_controls(); + let client_decorated = + matches!(window.window_decorations(), Decorations::Client { .. }); + + div() + .flex_none() + .flex() + .flex_row() + .items_center() + .gap_1() + .when(client_decorated && controls.minimize, |row| { + row.child(circle_button( + cx, + "window-minimize", + IconRole::WindowMinimize, + window.is_minimizable(), + |window, _cx| window.minimize_window(), + )) + }) + .when(client_decorated && controls.maximize, |row| { + row.child(circle_button( + cx, + "window-maximize", + if window.is_maximized() { + IconRole::WindowRestore + } else { + IconRole::WindowMaximize + }, + window.is_resizable(), + |window, _cx| window.zoom_window(), + )) + }) + .when(client_decorated, |row| { + row.child(circle_button( + cx, + "window-close", + IconRole::Close, + true, + |window, _cx| window.remove_window(), + )) + }) + } + } +} + +/// Double-click on a titlebar strip maximizes where the platform does not do it. +pub fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static { + move |event, window, _cx| { + if event.click_count() != 2 { + return; + } + #[cfg(target_os = "macos")] + window.titlebar_double_click(); + #[cfg(target_os = "linux")] + window.zoom_window(); + #[cfg(target_os = "windows")] + { + let _ = (event, window); + } + } +} + +/// One Windows caption button. +#[cfg(target_os = "windows")] +fn caption_button( + cx: &App, + id: ElementId, + role: IconRole, + area: WindowControlArea, + enabled: bool, + close: bool, +) -> AnyElement { + let theme = cx.theme(); + let glyph_size = theme.sizes.small.caption_size; + let (close_r, close_g, close_b) = WINDOWS_CLOSE_HOVER; + + let close_hover = rgba(( + (close_r * 255.0) as u8, + (close_g * 255.0) as u8, + (close_b * 255.0) as u8, + 0xff, + )); + let hover = if close { + close_hover + } else { + theme.colors.muted_hover + }; + + div() + .id(id) + .debug_selector({ + let id = SharedString::from(format!("window-control-{}", role.slug())); + move || id.to_string() + }) + .occlude() + .flex() + .items_center() + .justify_center() + .w(px(CAPTION_WIDTH)) + .h_full() + .window_control_area(area) + .text_color(if enabled { + theme.colors.foreground + } else { + theme.colors.muted_foreground + }) + .when(enabled, |button| { + button + .cursor_pointer() + .hover(|hovered| { + hovered.bg(hover).text_color(if close { + gpui::white() + } else { + theme.colors.foreground + }) + }) + .active(|pressed| { + pressed + .bg(if close { + hover.opacity(0.8) + } else { + theme.ramps.secondary.pressed + }) + .text_color(theme.colors.foreground) + }) + }) + .child(icon(role).size(glyph_size)) + .into_any_element() +} + +/// Adwaita's headerbar control: a small circle around a symbolic glyph. +#[cfg(target_os = "linux")] +fn circle_button( + cx: &App, + id: &'static str, + role: IconRole, + enabled: bool, + action: F, +) -> AnyElement +where + F: Fn(&mut Window, &mut App) + 'static, +{ + let theme = cx.theme(); + let small = theme.sizes.small; + + div() + .id(id) + .debug_selector(move || format!("window-control-{}", role.slug())) + .flex() + .items_center() + .justify_center() + .size(small.control_height) + .rounded(theme.radius.full) + .text_color(if enabled { + theme.colors.foreground + } else { + theme.colors.muted_foreground + }) + .when(enabled, |button| { + button + .cursor_pointer() + .hover(|hovered| hovered.bg(theme.colors.muted_hover)) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + action(window, cx); + }) + }) + .child(icon(role).size(small.dense_icon_size)) + .into_any_element() +} diff --git a/crates/layout/tests/window_controls.rs b/crates/layout/tests/window_controls.rs new file mode 100644 index 0000000..7d73703 --- /dev/null +++ b/crates/layout/tests/window_controls.rs @@ -0,0 +1,145 @@ +//! The titlebar duties: traffic-light pads and window controls, asserted per +//! platform — macOS draws only pads, Windows/Linux only draw their controls. + +use gpui::{ + AppContext as _, Entity, IntoElement, ParentElement as _, Pixels, Render, Styled as _, + TestAppContext, VisualTestContext, Window, div, px, +}; +use gpui_native_layout::dock::{Dock, DockState, PanelDescriptor}; +use gpui_native_layout::window_controls::TRAFFIC_LIGHT_PAD; +use gpui_native_theme::Theme; + +struct Panel { + tag: &'static str, +} + +impl Render for Panel { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + div().size_full().child(self.tag) + } +} + +fn descriptor(tag: &'static str) -> PanelDescriptor { + PanelDescriptor::new(tag, tag, move |_, cx| cx.new(|_| Panel { tag }).into()) +} + +struct Harness { + dock: Entity, + window_controls: bool, + traffic_light_pad: bool, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + div().size_full().child( + Dock::new("test-dock", self.dock.clone()) + .window_controls(self.window_controls) + .traffic_light_pad(self.traffic_light_pad), + ) + } +} + +fn dock_window( + window_controls: bool, + traffic_light_pad: bool, + cx: &mut TestAppContext, +) -> (Entity, VisualTestContext) { + cx.update(Theme::init); + let dock = cx.update(DockState::new); + let dock_for_window = dock.clone(); + let window = cx.add_window(move |_, _| Harness { + dock: dock_for_window, + window_controls, + traffic_light_pad, + }); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.update(|window, cx| window.draw(cx).clear(cx)); + (dock, visual) +} + +fn bounds(visual: &mut VisualTestContext, selector: &str) -> Option> { + visual.debug_bounds(Box::leak(selector.to_string().into_boxed_str())) +} + +fn open(visual: &mut VisualTestContext, dock: &Entity, tag: &'static str) { + visual.update(|window, cx| { + dock.update(cx, |dock, cx| dock.open_panel(tag, window, cx)); + }); + visual.update(|window, cx| window.draw(cx).clear(cx)); +} + +#[cfg(target_os = "macos")] +#[test] +fn the_pill_bar_pads_for_traffic_lights_when_it_leads_the_window() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(false, false, &mut cx); + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.register_panel(descriptor("overview"), cx) + }); + }); + open(&mut visual, &dock, "overview"); + + let bar = bounds(&mut visual, "test-dock-pane-0-bar").expect("pill bar missing"); + let tab = bounds(&mut visual, "test-dock-pane-0-tabs-tab-overview").expect("tab missing"); + let bare_inset = tab.left() - bar.left(); + + let (dock, mut visual) = dock_window(false, true, &mut cx); + let _ = &dock; + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.register_panel(descriptor("overview"), cx) + }); + }); + open(&mut visual, &dock, "overview"); + + let padded_bar = bounds(&mut visual, "test-dock-pane-0-bar").expect("padded bar missing"); + let padded_tab = + bounds(&mut visual, "test-dock-pane-0-tabs-tab-overview").expect("tab missing"); + let padded_inset = padded_tab.left() - padded_bar.left(); + + assert!( + padded_inset - bare_inset >= px(TRAFFIC_LIGHT_PAD) - px(1.), + "the pad must clear the traffic lights (bare {bare_inset:?}, padded {padded_inset:?})" + ); +} + +#[test] +fn window_controls_render_only_when_the_flag_is_set() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(false, false, &mut cx); + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.register_panel(descriptor("overview"), cx) + }); + }); + open(&mut visual, &dock, "overview"); + + // macOS: the system traffic lights own the controls — nothing renders; + // the pad test above covers that mode. + #[cfg(not(target_os = "macos"))] + assert!( + bounds(&mut visual, "window-control-close").is_none(), + "controls must not render without the flag" + ); + #[cfg(target_os = "macos")] + let _ = (&dock, &mut visual); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn the_flag_hosts_the_platform_controls_in_the_bar() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(true, false, &mut cx); + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.register_panel(descriptor("overview"), cx) + }); + }); + open(&mut visual, &dock, "overview"); + + // The maximize control exists once; its restore flip follows the + // window's maximized state, not a separate control. + assert!(bounds(&mut visual, "window-control-close").is_some()); + assert!(bounds(&mut visual, "window-control-maximize").is_some()); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 15a4a4a..46e26fd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -290,7 +290,7 @@ Units, each through the review gate: | L3 | layout | Dock ✅ Landed (2026-11-02) | The center pane tree, controlled: `DockState` holds the `Pane { tabs, active }` / `Split { axis, ratio, a, b }` tree plus a registry of `PanelDescriptor`s (`id` + title + optional icon + a view builder called once per open; the built view is retained, so panel state survives tab switches). `open` / `activate` / `close` / `close_others` / `close_pane` / `split` / `move` are plain state ops keyed by panel id — opening an open panel re-activates it, one instance per descriptor for now, and `active_panel()` reports what the active pane shows. Any op emptying a pane collapses it: the pane detaches and single-child splits are replaced by the surviving subtree, so only the root can sit empty. Split seams are `ResizeHandle`s (arrow keys move them for free); the ratio lives in the state and the seam turns pixel deltas into fractions against a per-split span that a canvas captures into dock-namespaced keyed state each frame — double-click resets to 0.5, both sides clamp at 10% of the split. Panes render a `strip_height` header row composing the data crate's `Tabs` (revised in L4 — see below), `TabList`/`Tab`/`TabPanel` roles, and a core `ContextMenu` on the header — Close / Close Others / Close Pane; the active tab's view fills the pane. Feature `dock` pulls core `button` + `context_menu` and re-uses `resize_handle`; 7 headless tests in `crates/layout/tests/dock.rs` (open/activate, tab clicks, neighbor-on-close, split + collapse, seam drag/reset/clamp with a 1 px flex-rounding tolerance, merge-by-move, close-others/close-pane + reopen); the gallery's shell center hosts a dock with demo panels and open/split buttons | | L4 | layout | Dock drag-to-split ✅ Landed (2026-11-02) | Dragging a tab uses gpui's own drag system: the dock attaches `on_drag` through data `Tabs`' `Tab::wrap` seam with a `PanelDrag { panel }` payload (ghost = a small bordered title card). While `App::has_active_drag()`, each pane's content mounts five absolute drop zones — four edge bands that call the new `DockState::split_panel_at(panel, anchor, direction)` (a split beside the *target* pane, not the dragged tab's own; the empty source still collapses) and a center zone that calls `move_panel`. Zones paint a `selection` wash via `drag_over::` while hovered and are mounted only during a drag, so content clicks are never blocked; gpui cancels the drag on a mouse-up over nothing, which unmounts them. Dropped tabs land active in their new pane. Same pass: pane content renders through `AnyView::cached` (`StyleRefinement` absolute + size full, the gpui-kit pattern) so an un-notified panel never re-renders on dock frames; pane headers moved from hand-rolled tabs to data `Tabs` (`Pill` variant — renamed from `Card` and reshaped to signed's exact tab: leading element via `Tab::lead`, wide title-to-controls gap, `Tab::busy` loading slot standing in with a quiet ring until the Spinner control lands, a ghost core `Button` close control (the first data → core dependency edge, feature-gated: `tabs` pulls `core/button`), and the pill radius is the button radius, not `full`; the pill bar is chrome-less because the dock's tab strip doubles as the L4.5 titlebar), which now ships the two designs (default stays `Underline`); tab strips carry `{tabs-id}-tab-{label}` / `{tabs-id}-close-{label}` debug selectors for headless tests. 10 headless tests in `crates/layout/tests/dock.rs` — the new ones cover a real simulated drag (mouse down → threshold move → drag starts → drop on a right edge band → split) and `split_panel_at`'s target-side placement; the rest carried over with the new tab selectors | | L2.5 | layout | window frame ✅ Landed (2026-11-01) | `window_frame::frame(child, window, cx)` — the client-side window frame for windows the OS does not decorate: under `Decorations::Server` (macOS, Windows, KDE, X11) it passes the child through untouched; under `Decorations::Client` (Wayland/GNOME) it insets the content, draws the hairline border and `Radius::overlay` corners (tiled sides stay square and lose their band), casts the dialog elevation's shadow dimmed to 0.7× when the window is inactive, and turns a 4 px band inside each live edge into resize hits (`start_window_resize`, edge cursors; a maximized window counts as flush everywhere). Requires a transparent window background, and must be the window's root — the bands hit-test against `Window::window_bounds`. **The `Shell` applies it by default**, so the module ships ungated — no separate feature. Adapted from bezel's `window::frame` + gpui-kit's `WindowBorder` (D15/D20), simplified to one inset band and theme-owned tokens | -| L4.5 | layout | Titlebar + window controls | The strips across the shell's top become the window's titlebar: they drag the window (`WindowControlArea::Drag` on the strip) and host the controls where the platform puts them. A `WindowControls` element — minimize, maximize/restore, close, capability-gated by `Window::window_controls()` — renders per platform: **macOS** draws nothing where the system traffic lights sit (they are system-drawn; `TitlebarOptions.appears_transparent` + `traffic_light_position` is app window config), and the shell reserves the pad in the left sidebar's header leading edge — or across the center's strip when the left sidebar is hidden; **Windows** draws native-glyph buttons (`ChromeMinimize`/`ChromeMaximize`/`ChromeClose`, Segoe E921/E922/E8BB verified against the MDL2 table) marked `WindowControlArea::Min/Max/Close` so Windows handles the clicks and snap layouts natively, maximize flipping to Restore via `is_maximized`; **Linux** draws the same ops as click handlers (`minimize_window` / `zoom_window` / `remove_window`) in the platform's own dress — Adwaita's small gray circle wrappers around symbolic glyphs, resolved through the freedesktop cascade with the bundled fallback (`Close` already leads with `window-close-symbolic`; new `WindowMinimize`/`WindowMaximize` roles land in the icons crate). Placement follows the shell's shape: Windows/Linux put the controls at the trailing end of the right-hand strip — the right sidebar's strip when visible, else the center region's titlebar strip, which is now the dock's `Pill` tab bar (the pill strip ships chrome-less for exactly this — in signed the tab bar doubles as the titlebar, 44 px, with 34 px-wide controls and a danger hover on close, which the pill tab already matches apart from the metrics). Reference: signed's `crates/dock/src/window_controls.rs` | +| L4.5 | layout | Titlebar + window controls ✅ Landed (2026-11-02) | The strips are the window's titlebar: every strip (shell sidebars, dock pill bar) marks itself `WindowControlArea::Drag` and double-click-maximizes (`titlebar_double_click` on macOS, `zoom_window` on Linux; Windows does it natively through the caption hit test). New `window_controls` module (feature `window_controls`, pulled by `shell` and `dock`): a `WindowControls` element rendered at the trailing end of the right-hand strip — the right sidebar's strip when visible, else the dock pill bar via `Dock::window_controls(bool)`. **Windows**: caption buttons (36 px wide, Segoe codepoints through the icons crate — new `WindowMinimize`/`WindowMaximize`/`WindowRestore` roles, E921/E922/E923 verified against zed's own table) marked `WindowControlArea::Min/Max/Close` so the OS owns the clicks and snap layouts; maximize flips to restore via `is_maximized`; capability-gated by `Window::window_controls()` + `is_minimizable`/`is_resizable`; close hovers the platform red (a platform convention const, like the Segoe codepoints). **Linux**: Adwaita circular buttons (the platform dress, hand-rolled like zed's) under `Decorations::Client` only, clicking `minimize_window`/`zoom_window`/`remove_window`. **macOS** draws nothing — system traffic lights; the shell reserves `TRAFFIC_LIGHT_PAD` (78 px, Tahoe SDK) in the left sidebar strip's leading edge, and `Dock::traffic_light_pad(bool)` pads the pill bar when no left sidebar sits over the lights. The gpui dispatch fact that makes this safe: Windows forwards `WM_NCLBUTTONDOWN` into gpui's normal event stream, so buttons inside a Drag-marked strip still get their clicks, and only unconsumed presses fall through to native caption behavior. Adapted from zed's `platform_title_bar` (D15/D20); the gallery wires both dock flags from `LayoutState`. 2 platform-gated headless tests (macOS pad shifts the pill bar's tab by the pad width; controls render only under the flag on their platforms); CI feature loop gained `window_controls` | | L5 | layout | Sidebar auto-open | Panels declare `needs_right_sidebar` on their descriptor; activating such a panel opens the right sidebar; closing it leaves visibility to the user. The gallery gains a workspace-style shell page — the bootstrap example apps copy. | Deferred until a real need: layout persistence/serialization, pane zoom (maximize one pane), a bottom dock, collapse-to-edge docks, floating windows, drag between windows. @@ -377,6 +377,6 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). 7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct), plus the `StyledExt` trait (`refine_style` — adapted from gpui-kit `base`, D15 — replacing the per-component `style().refine` pattern; 5 sites) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline landed as §6.3 M7 (2026-11-01). 8. Then — Phase G is the migration additions (§6.3): Badge ✅, Select ✅, Segmented control ✅, Context menu ✅, Empty state ✅, password input ✅, multiline textarea ✅, then Avatar image (M8) — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. `crates/editor` starts when file viewing (M15) is reached. -9. Then — the layout crate (§6.4): ResizeHandle ✅, Shell ✅, window frame ✅, Dock ✅, drag-to-split ✅, titlebar + window controls, and panel-declared sidebar auto-open — each through the review gate, with signed's `crates/dock` as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts. +9. Then — the layout crate (§6.4): ResizeHandle ✅, Shell ✅, window frame ✅, Dock ✅, drag-to-split ✅, titlebar + window controls ✅, and panel-declared sidebar auto-open — each through the review gate, with signed's `crates/dock` as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 9e73aab..868f5d7 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -245,11 +245,13 @@ impl Gallery { }), ) .child( - div() - .flex_1() - .min_h_0() - .overflow_hidden() - .child(Dock::new("gallery-dock", self.dock_state.clone())), + div().flex_1().min_h_0().overflow_hidden().child( + Dock::new("gallery-dock", self.dock_state.clone()) + // The pill bar is the titlebar strip whenever no + // right sidebar sits to its right. + .window_controls(!state.read(cx).is_right_sidebar_visible()) + .traffic_light_pad(!state.read(cx).is_left_sidebar_visible()), + ), ); Shell::new("gallery-shell", state)