From fea012127a470b16aa37993bc023a0be0be02882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 01:04:34 -0400 Subject: [PATCH] =?UTF-8?q?Shell=20=C2=B7=20ColumnShell:=20columns=20to=20?= =?UTF-8?q?the=20window's=20top,=20each=20under=20a=20header=20that=20drag?= =?UTF-8?q?s=20it;=20the=20traffic=20lights=20or=20controls=20in=20the=20f?= =?UTF-8?q?irst=20or=20last=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/shell/chrome.rs | 24 +++-- src/shell/columns.rs | 234 +++++++++++++++++++++++++++++++++++++++++++ src/shell/mod.rs | 2 + src/shell/tests.rs | 187 ++++++++++++++++++++++++++++++++++ tasks/github.md | 2 +- tasks/reviews.md | 3 + 6 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 src/shell/columns.rs diff --git a/src/shell/chrome.rs b/src/shell/chrome.rs index 7bba17f..3f940ef 100644 --- a/src/shell/chrome.rs +++ b/src/shell/chrome.rs @@ -55,7 +55,7 @@ enum Control { Close, } -type OnClose = Rc; +pub(super) type OnClose = Rc; 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, 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, @@ -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, window: &mut Window, cx: &mut App) { +pub(super) fn route_close( + id: &ElementId, + handler: Option, + 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), diff --git a/src/shell/columns.rs b/src/shell/columns.rs new file mode 100644 index 0000000..236ba69 --- /dev/null +++ b/src/shell/columns.rs @@ -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, + leading: SmallVec<[AnyElement; 2]>, + title: Option, + actions: SmallVec<[AnyElement; 2]>, + body: SmallVec<[AnyElement; 2]>, +} + +impl ShellColumn { + /// Fills the width the other columns leave. + pub fn new(id: impl Into) -> 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) -> 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) -> 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) { + 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, + on_close: Option, + columns: Vec, +} + +impl ColumnShell { + /// On macOS the system draws the traffic lights over the first column. + pub fn new(id: impl Into) -> 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 { + 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, 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)), + ) +} diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 3c30209..02c3aa5 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -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; diff --git a/src/shell/tests.rs b/src/shell/tests.rs index 1346f2a..38879ea 100644 --- a/src/shell/tests.rs +++ b/src/shell/tests.rs @@ -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) -> 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, &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 { + 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) -> 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) -> 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:?}" + ); +} diff --git a/tasks/github.md b/tasks/github.md index 62373c9..4191af1 100644 --- a/tasks/github.md +++ b/tasks/github.md @@ -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 diff --git a/tasks/reviews.md b/tasks/reviews.md index d62e1de..178eea8 100644 --- a/tasks/reviews.md +++ b/tasks/reviews.md @@ -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 |