layout: make strips the titlebar with window controls

This commit is contained in:
2026-11-02 19:04:13 +07:00
parent f295b25366
commit a6014b4ff3
13 changed files with 603 additions and 62 deletions
+4 -1
View File
@@ -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"]
+61 -8
View File
@@ -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<DockState>,
/// 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<DockState>,
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<DockState>,
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<DockState>,
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()
}
})
+3
View File
@@ -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;
+43 -29
View File
@@ -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()
}
+274
View File
@@ -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<I>(id: I) -> Self
where
I: Into<ElementId>,
{
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<F>(
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()
}
+145
View File
@@ -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<Self>) -> 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<DockState>,
window_controls: bool,
traffic_light_pad: bool,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> 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<DockState>, 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<gpui::Bounds<Pixels>> {
visual.debug_bounds(Box::leak(selector.to_string().into_boxed_str()))
}
fn open(visual: &mut VisualTestContext, dock: &Entity<DockState>, 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());
}