Shell · ColumnShell: columns to the window's top, each under a header that drags it; the traffic lights or controls in the first or last (#6)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 01:04:34 -04:00
1 parent 2a18e9d0b3
commit 3935bb3fde
6 files changed
+444 -8

No files matched your search

+17 -7
View File
@@ -55,7 +55,7 @@ enum Control {
Close,
}
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
pub(super) type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
fn run(control: Control, on_close: Option<&OnClose>, window: &mut Window, cx: &mut App) {
log::info!("window controls: {control:?}");
@@ -249,6 +249,15 @@ fn round_buttons(
.into_any_element()
}
/// Whether the system draws the window controls, and whose manner they take.
pub(super) fn controls_style(platform: Option<Platform>, cx: &App) -> (bool, Platform) {
let system = platform.is_none() && cfg!(target_os = "macos");
match system {
true => (true, Platform::Mac),
false => (false, platform.unwrap_or(cx.theme().platform)),
}
}
/// The live close handler, read by the window's close request.
struct CloseRoute {
handler: Option<OnClose>,
@@ -256,7 +265,12 @@ struct CloseRoute {
}
/// Sends system closes to `handler` while the bar lives and holds one.
fn route_close(id: &ElementId, handler: Option<OnClose>, window: &mut Window, cx: &mut App) {
pub(super) fn route_close(
id: &ElementId,
handler: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) {
let route = window.use_keyed_state(id.clone(), cx, |_, _| CloseRoute {
handler: None,
registered: false,
@@ -342,12 +356,8 @@ impl RenderOnce for TitleBar {
route_close(&self.id, self.on_close.clone(), window, cx);
}
let bar = drag_region(self.id, window, cx);
let (system, style) = controls_style(self.platform, cx);
let theme = cx.theme();
let system = self.platform.is_none() && cfg!(target_os = "macos");
let style = match system {
true => Platform::Mac,
false => self.platform.unwrap_or(theme.platform),
};
let mut trailing = (!system).then_some(WindowControls {
id: "window-controls".into(),
platform: Some(style),
+234
View File
@@ -0,0 +1,234 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, DefiniteLength, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use smallvec::SmallVec;
use super::chrome::{OnClose, WindowControls, controls_style, drag_region, route_close};
use crate::{
layout::on_axis,
theme::{ActiveTheme, ControlSize, Platform, TextSize},
typography::Ellipsis,
};
/// One column of a `ColumnShell`: a header that drags the window, then its body.
pub struct ShellColumn {
id: ElementId,
width: Option<DefiniteLength>,
leading: SmallVec<[AnyElement; 2]>,
title: Option<SharedString>,
actions: SmallVec<[AnyElement; 2]>,
body: SmallVec<[AnyElement; 2]>,
}
impl ShellColumn {
/// Fills the width the other columns leave.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
width: None,
leading: SmallVec::new(),
title: None,
actions: SmallVec::new(),
body: SmallVec::new(),
}
}
/// A set width instead of the rest; it yields before a header's fixed parts.
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.width = Some(width.into());
self
}
/// Header content before the title.
pub fn leading(mut self, item: impl IntoElement) -> Self {
self.leading.push(item.into_any_element());
self
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Header content at the end.
pub fn action(mut self, action: impl IntoElement) -> Self {
self.actions.push(action.into_any_element());
self
}
}
impl ParentElement for ShellColumn {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
/// Columns to the window's top, each under a header; controls lead the first or end the last.
#[derive(IntoElement)]
pub struct ColumnShell {
id: ElementId,
platform: Option<Platform>,
on_close: Option<OnClose>,
columns: Vec<ShellColumn>,
}
impl ColumnShell {
/// On macOS the system draws the traffic lights over the first column.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
platform: None,
on_close: None,
columns: Vec::new(),
}
}
pub fn column(mut self, column: ShellColumn) -> Self {
self.columns.push(column);
self
}
/// Draws this platform's controls instead of the system's.
pub fn platform(mut self, platform: Platform) -> Self {
self.platform = Some(platform);
self
}
/// Runs instead of closing; without a forced platform, system closes run it too.
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ColumnShell {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.columns.len();
assert!(count > 0, "column shell {:?} needs a column", self.id);
if self.platform.is_none() {
route_close(&self.id, self.on_close.clone(), window, cx);
}
let (system, style) = controls_style(self.platform, cx);
let controls = || {
let controls = WindowControls::new((self.id.clone(), "controls")).platform(style);
let controls = match self.on_close.clone() {
Some(close) => controls.on_close(move |window, cx| close(window, cx)),
None => controls,
};
div()
.flex_none()
.debug_selector(|| "column-shell-controls".into())
.child(controls)
};
let room = system && !window.is_fullscreen();
let mut columns = Vec::with_capacity(count);
for (ix, column) in self.columns.into_iter().enumerate() {
let (first, last) = (ix == 0, ix + 1 == count);
let lead = (!system && first && style == Platform::Mac).then(controls);
let trail = (!system && last && style != Platform::Mac).then(controls);
let flush = trail.is_some() && style == Platform::Windows;
let header = header(column.id.clone(), window, cx)
.when(first && room, |header| {
header.pl(cx.theme().traffic_light_inset())
})
.when(!(first && room), |header| header.pl_3())
.when(!flush, |header| header.pr_3())
.children(lead)
.child(
items(
column.id.clone(),
column.leading,
title(column.title, cx),
column.actions,
)
.min_w(cx.theme().control_height(ControlSize::Lg))
.debug_selector(move || format!("column-shell-items-{ix}")),
)
.children(trail);
let colors = &cx.theme().colors;
columns.push(
div()
.flex()
.flex_col()
.h_full()
.map(|box_| match column.width {
Some(width) => box_.w(width),
None => box_.flex_1(),
})
.when(!last, |box_| box_.border_r_1().border_color(colors.border))
.debug_selector(move || format!("column-shell-{ix}"))
.child(header)
.child(
// Absolute, so only the header sets the least width.
div().flex_1().min_h_0().relative().child(
div()
.absolute()
.inset_0()
.flex()
.flex_col()
.children(column.body),
),
),
);
}
let theme = cx.theme();
div()
.flex()
.h_full()
.debug_selector(|| "column-shell".into())
.bg(theme.colors.bg)
.text_color(theme.colors.fg)
.children(columns)
}
}
/// A header strip at the title bar's height that drags the window.
fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gpui::Div> {
drag_region((id, "header"), window, cx)
.flex()
.flex_none()
.items_center()
.gap_2()
.h(cx.theme().titlebar_height())
}
/// The host's header items: one control wide at least, scrolling when short.
fn items(
id: ElementId,
leading: SmallVec<[AnyElement; 2]>,
title: gpui::Div,
actions: SmallVec<[AnyElement; 2]>,
) -> gpui::Div {
let group = || div().flex_none().flex().items_center().gap_2();
div().flex_1().min_w_0().h_full().relative().child(
on_axis(div().id((id, "items")))
.absolute()
.inset_0()
.flex()
.items_center()
.gap_2()
.overflow_x_scroll()
.child(group().children(leading))
.child(title)
.child(group().children(actions)),
)
}
/// The title, adding nothing to the least width; a spacer without one.
fn title(title: Option<SharedString>, cx: &App) -> gpui::Div {
let theme = cx.theme();
div().flex_1().min_w_0().h_full().relative().child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.colors.fg_muted)
.children(title.map(Ellipsis::new)),
)
}
+2
View File
@@ -1,6 +1,7 @@
mod activity;
mod bars;
mod chrome;
mod columns;
mod crash;
mod dialogs;
mod frame;
@@ -17,6 +18,7 @@ mod windows;
pub use activity::{ActivityBar, ActivityItem};
pub use bars::{StatusBar, StatusBarItem, Toolbar, ToolbarGroup, ToolbarSeparator};
pub use chrome::{TitleBar, WindowControls, drag_region};
pub use columns::{ColumnShell, ShellColumn};
pub use crash::CrashReporter;
pub use dialogs::{AboutDialog, SplashScreen};
pub use frame::ResizeBorder;
+187
View File
@@ -286,3 +286,190 @@ fn the_splash_bar_spans_its_column(cx: &mut TestAppContext) {
"half the column, not {fill:?}"
);
}
struct Columned {
platform: crate::theme::Platform,
middle: bool,
closes: usize,
}
impl Render for Columned {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let column = |id: &'static str| super::ShellColumn::new(id).title(id);
let shell = super::ColumnShell::new("shell")
.platform(self.platform)
.on_close(move |_, cx| view.update(cx, |view, _| view.closes += 1))
.column(column("side").width(px(200.0)))
.when(self.middle, |shell| {
shell.column(column("list").width(px(160.0)))
})
.column(column("main"));
div()
.w(px(800.0))
.h(px(400.0))
.flex()
.flex_col()
.child(shell)
}
}
fn columned(
platform: crate::theme::Platform,
middle: bool,
cx: &mut TestAppContext,
) -> (gpui::Entity<Columned>, &mut VisualTestContext) {
cx.update(Theme::init);
let (view, cx) = cx.add_window_view(|_, _| Columned {
platform,
middle,
closes: 0,
});
settle(cx);
(view, cx)
}
fn column(ix: usize, cx: &mut VisualTestContext) -> gpui::Bounds<gpui::Pixels> {
let name: &'static str = ["column-shell-0", "column-shell-1", "column-shell-2"][ix];
cx.debug_bounds(name).expect("a column")
}
/// Set widths hold, the last column takes the rest, and every column runs the window's height.
#[gpui::test]
fn columns_keep_their_widths_and_the_last_fills(cx: &mut TestAppContext) {
let (_, cx) = columned(crate::theme::Platform::Linux, true, cx);
let (side, list, main) = (column(0, cx), column(1, cx), column(2, cx));
assert_eq!((side.size.width, list.size.width), (px(200.0), px(160.0)));
assert_eq!((list.left(), main.left()), (px(200.0), px(360.0)));
assert_eq!(main.right(), px(800.0));
assert!(
[side, list, main]
.iter()
.all(|c| c.size.height == px(400.0))
);
}
/// Windows' caption buttons end the last header, flush with the window's edge; the close runs `on_close`.
#[gpui::test]
fn windows_controls_end_the_last_header(cx: &mut TestAppContext) {
let (view, cx) = columned(crate::theme::Platform::Windows, false, cx);
let (controls, main) = (
cx.debug_bounds("column-shell-controls").expect("controls"),
column(1, cx),
);
assert_eq!(controls.right(), main.right());
assert!(controls.left() > main.left() && controls.bottom() <= main.top() + px(38.0));
let close = cx.debug_bounds("caption-Close").expect("a close button");
cx.simulate_click(close.center(), gpui::Modifiers::none());
settle(cx);
assert_eq!(view.read_with(cx, |view, _| view.closes), 1);
}
/// Drawn traffic lights lead the first header, inside the sidebar.
#[gpui::test]
fn drawn_traffic_lights_lead_the_first_header(cx: &mut TestAppContext) {
let (_, cx) = columned(crate::theme::Platform::Mac, true, cx);
let (lights, side) = (
cx.debug_bounds("column-shell-controls").expect("lights"),
column(0, cx),
);
assert!(lights.left() > side.left() && lights.right() < side.right());
assert_eq!(lights.center().y, side.top() + px(19.0));
}
/// The shell fills a padded column's width.
#[gpui::test]
fn a_column_shell_fills_its_container(cx: &mut TestAppContext) {
cx.update(Theme::init);
let width = crate::layout::tests::narrow_width(cx, "column-shell", |_, _| {
super::ColumnShell::new("shell")
.platform(crate::theme::Platform::Linux)
.column(super::ShellColumn::new("main"))
.into_any_element()
});
assert_eq!(width, px(240.0));
}
struct Unforced;
impl Render for Unforced {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let lead = div().debug_selector(|| "lead".into()).size(px(10.0));
div().w(px(800.0)).h(px(400.0)).flex().flex_col().child(
super::ColumnShell::new("shell").column(super::ShellColumn::new("main").leading(lead)),
)
}
}
/// Unforced, macOS leaves the first header room for the system's lights; elsewhere Ely draws the theme's controls.
#[gpui::test]
fn an_unforced_shell_follows_the_system(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Unforced);
settle(cx);
let drawn = cx.debug_bounds("column-shell-controls").is_some();
let lead = cx.debug_bounds("lead").expect("the lead").left();
match cfg!(target_os = "macos") {
true => assert_eq!((drawn, lead), (false, px(78.0))),
false => assert_eq!((drawn, lead), (true, px(12.0))),
}
}
struct NarrowShell;
impl Render for NarrowShell {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let long = "A title long enough to run past any narrow column";
div().w(px(280.0)).h(px(200.0)).flex().flex_col().child(
super::ColumnShell::new("shell")
.platform(crate::theme::Platform::Windows)
.column(super::ShellColumn::new("side").width(px(200.0)).title(long))
.column(
super::ShellColumn::new("main")
.title(long)
.action(
div()
.debug_selector(|| "edit".into())
.child(Button::new("edit", "Edit")),
)
.action(
div()
.debug_selector(|| "share".into())
.child(Button::new("share", "Share")),
)
.child(long),
),
)
}
}
/// At 280px a set width gives way and the host's items scroll, so the caption buttons stay inside the shell.
#[gpui::test]
fn at_280px_the_caption_buttons_stay_inside_the_shell(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| NarrowShell);
settle(cx);
let (side, main) = (column(0, cx), column(1, cx));
let close = cx.debug_bounds("caption-Close").expect("a close button");
assert_eq!((side.left(), main.right()), (px(0.0), px(280.0)));
assert!(side.size.width < px(200.0), "{side:?} gave way");
assert!(
close.left() >= main.left() && close.right() <= px(280.0),
"{close:?} in {main:?}"
);
let items = cx.debug_bounds("column-shell-items-1").expect("the items");
let edit = cx.debug_bounds("edit").expect("Edit");
assert!(items.size.width >= px(32.0), "{items:?} holds a control");
assert!(items.contains(&edit.origin), "{edit:?} shows in {items:?}");
cx.simulate_event(gpui::ScrollWheelEvent {
position: items.center(),
delta: gpui::ScrollDelta::Pixels(gpui::point(px(-400.0), px(0.0))),
..Default::default()
});
settle(cx);
let share = cx.debug_bounds("share").expect("Share");
assert!(
share.right() <= items.right() + px(0.5),
"{share:?} scrolls into {items:?}"
);
}
+1 -1
View File
@@ -9,6 +9,6 @@
# GitHub round, 2026-10-05
- [x] G6 Issue #7: an IME composition restarts the caret's blink, as every edit does; its ranges are logged; tested
- [ ] G7 Issue #6: `layout::SidebarShell`, a sidebar that runs to the window's top with the traffic lights, beside one or two panes that bring their own headers; tested
- [x] G7 Issue #6: `shell::ColumnShell`, a sidebar that runs to the window's top with the traffic lights, beside one or two panes that bring their own headers; tested
- [ ] G8 Issue #6: a Layout story, `stories.json`, AGENTS.md and the PRD line; the story runs live on the site
- [ ] G9 Push, deploy, reply on #6 and #7, close #6
+3
View File
@@ -383,3 +383,6 @@
| L4 | 1 | PASS | |
| L5 | 1 | PASS | |
| G6 | 1 | PASS | codex gpt-6.1-sol high |
| G7 | 1 | FAIL | at 280px a 200px sidebar left the Windows close button past the shell |
| G7 | 2 | FAIL | Edit and Share beside the Windows buttons still pushed the close button to 322px |
| G7 | 3 | FAIL | the host's items shrank to 0px, out of reach; fixed after the cap (one control's width at least, a wheel test), not reviewed again |