From aa40003a37d9152826d0cb448987ff29772dd957 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 2 Nov 2026 08:53:18 +0700 Subject: [PATCH] add app layout shell to the layout crate --- .github/workflows/ci.yml | 4 +- Cargo.lock | 3 + Cargo.toml | 10 +- crates/data/src/sidebar.rs | 3 + crates/icons/assets/panel-left.svg | 1 + crates/icons/assets/panel-right.svg | 1 + crates/icons/src/lib.rs | 23 +++ crates/icons/src/segoe.rs | 2 + crates/layout/Cargo.toml | 11 +- crates/layout/src/app_layout.rs | 286 ++++++++++++++++++++++++++++ crates/layout/src/lib.rs | 3 + crates/layout/tests/app_layout.rs | 236 +++++++++++++++++++++++ docs/PLAN.md | 4 +- examples/gallery/Cargo.toml | 4 +- examples/gallery/src/main.rs | 117 ++++++++++++ 15 files changed, 697 insertions(+), 11 deletions(-) create mode 100644 crates/icons/assets/panel-left.svg create mode 100644 crates/icons/assets/panel-right.svg create mode 100644 crates/layout/src/app_layout.rs create mode 100644 crates/layout/tests/app_layout.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c87df07..ab6f6a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,9 @@ jobs: run: | cargo check -p gpui-native-layout --no-default-features cargo check -p gpui-native-layout --all-features - cargo check -p gpui-native-layout --no-default-features --features resize_handle + for f in resize_handle app_layout; do + cargo check -p gpui-native-layout --no-default-features --features "$f" + done - name: Feature checks (facade) run: | cargo check -p gpui-native-kit --no-default-features diff --git a/Cargo.lock b/Cargo.lock index 66a15ad..169a953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1743,6 +1743,9 @@ name = "gpui-native-layout" version = "0.1.0" dependencies = [ "gpui", + "gpui-native-core", + "gpui-native-data", + "gpui-native-icons", "gpui-native-theme", "gpui-native-utils", ] diff --git a/Cargo.toml b/Cargo.toml index 15c0925..d06c09d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,9 @@ description = "GPUI components that feel native to the operating system" [features] default = ["core", "data"] -core = ["dep:gpui-native-core"] -data = ["dep:gpui-native-data"] -layout = ["dep:gpui-native-layout"] +core = ["dep:gpui-native-core", "gpui-native-core/full"] +data = ["dep:gpui-native-data", "gpui-native-data/full"] +layout = ["dep:gpui-native-layout", "gpui-native-layout/full"] [dependencies] gpui = { workspace = true } @@ -26,8 +26,8 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fdd gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531", features = [ "font-kit", ] } -gpui-native-core = { path = "crates/core" } -gpui-native-data = { path = "crates/data" } +gpui-native-core = { path = "crates/core", default-features = false } +gpui-native-data = { path = "crates/data", default-features = false } gpui-native-layout = { path = "crates/layout" } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } diff --git a/crates/data/src/sidebar.rs b/crates/data/src/sidebar.rs index 69c055d..9b7016d 100644 --- a/crates/data/src/sidebar.rs +++ b/crates/data/src/sidebar.rs @@ -175,6 +175,8 @@ impl RenderOnce for Sidebar { .clone() .unwrap_or_else(|| SharedString::from(self.id.to_string())); + let id_text = self.id.to_string(); + // Entry paint reads the theme through this borrow. let children: Vec = entries .iter() @@ -194,6 +196,7 @@ impl RenderOnce for Sidebar { div() .id(self.id) + .debug_selector(move || format!("sidebar-{id_text}")) .flex() .flex_col() .gap_0p5() diff --git a/crates/icons/assets/panel-left.svg b/crates/icons/assets/panel-left.svg new file mode 100644 index 0000000..95798a8 --- /dev/null +++ b/crates/icons/assets/panel-left.svg @@ -0,0 +1 @@ + diff --git a/crates/icons/assets/panel-right.svg b/crates/icons/assets/panel-right.svg new file mode 100644 index 0000000..2961644 --- /dev/null +++ b/crates/icons/assets/panel-right.svg @@ -0,0 +1 @@ + diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index 4415bce..e581748 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -29,6 +29,8 @@ pub enum IconRole { Info, Eye, EyeOff, + PanelLeft, + PanelRight, } impl IconRole { @@ -48,6 +50,8 @@ impl IconRole { Self::Info, Self::Eye, Self::EyeOff, + Self::PanelLeft, + Self::PanelRight, ] } @@ -67,6 +71,8 @@ impl IconRole { Self::Info => "icons/info.svg", Self::Eye => "icons/eye.svg", Self::EyeOff => "icons/eye-off.svg", + Self::PanelLeft => "icons/panel-left.svg", + Self::PanelRight => "icons/panel-right.svg", } } @@ -86,6 +92,8 @@ impl IconRole { Self::Info => "info", Self::Eye => "eye", Self::EyeOff => "eye-off", + Self::PanelLeft => "panel-left", + Self::PanelRight => "panel-right", } } @@ -144,6 +152,19 @@ impl IconRole { "visibility-off", "eye-off", ], + Self::PanelLeft => &[ + "sidebar-expand-left", + "sidebar-show-symbolic", + "panel-left", + "sidebar-left", + ], + Self::PanelRight => &[ + "sidebar-expand-right", + "sidebar-show-right-symbolic", + "sidebar-show-symbolic", + "panel-right", + "sidebar-right", + ], } } } @@ -254,6 +275,8 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> { "icons/info.svg" => Some(include_bytes!("../assets/info.svg")), "icons/eye.svg" => Some(include_bytes!("../assets/eye.svg")), "icons/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")), + "icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")), + "icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")), _ => None, } } diff --git a/crates/icons/src/segoe.rs b/crates/icons/src/segoe.rs index f257145..37ca0ee 100644 --- a/crates/icons/src/segoe.rs +++ b/crates/icons/src/segoe.rs @@ -20,6 +20,8 @@ pub(crate) fn glyph(role: IconRole) -> Option { IconRole::Info => 0xE946, // Info IconRole::Eye => 0xE7B3, // RedEye IconRole::EyeOff => 0xED1A, // Hide + IconRole::PanelLeft => 0xE90C, // DockLeft + IconRole::PanelRight => 0xE90D, // DockRight }; char::from_u32(codepoint) } diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index a64e400..3238329 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -8,11 +8,20 @@ description = "Application layout bootstrap: sidebars and a center dock, one car gpui = { workspace = true } gpui-native-theme = { workspace = true } gpui-native-utils = { workspace = true } +gpui-native-core = { workspace = true, optional = true, features = ["button"] } +gpui-native-data = { workspace = true, optional = true, features = ["sidebar"] } +gpui-native-icons = { workspace = true, optional = true } [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["resize_handle"] +full = ["resize_handle", "app_layout"] resize_handle = [] +app_layout = [ + "resize_handle", + "dep:gpui-native-core", + "dep:gpui-native-data", + "dep:gpui-native-icons", +] diff --git a/crates/layout/src/app_layout.rs b/crates/layout/src/app_layout.rs new file mode 100644 index 0000000..4064568 --- /dev/null +++ b/crates/layout/src/app_layout.rs @@ -0,0 +1,286 @@ +//! The application shell: a left sidebar (on by default), a right sidebar +//! (off by default), and the center region that hosts the dock. + +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, +}; +use gpui_native_core::button::{Button, ButtonVariant}; +use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_data::sidebar::Sidebar; +use gpui_native_icons::IconRole; +use gpui_native_theme::{ActiveTheme as _, Size}; + +use crate::resize_handle::ResizeHandle; + +/// The width a sidebar opens at. +const DEFAULT_SIDEBAR_WIDTH: f32 = 240.0; +/// The narrowest a sidebar can be dragged to. +const MIN_SIDEBAR_WIDTH: f32 = 160.0; +/// The widest a sidebar can be dragged to. +const MAX_SIDEBAR_WIDTH: f32 = 480.0; + +/// The layout the application owns. +pub struct LayoutState { + left_visible: bool, + right_visible: bool, + left_width: Pixels, + right_width: Pixels, +} + +impl LayoutState { + /// Creates the state: left sidebar visible, right sidebar hidden. + pub fn new(cx: &mut App) -> Entity { + cx.new(|_| Self { + left_visible: true, + right_visible: false, + left_width: px(DEFAULT_SIDEBAR_WIDTH), + right_width: px(DEFAULT_SIDEBAR_WIDTH), + }) + } + + /// Shows or hides the left sidebar. + pub fn set_left_sidebar_visible(&mut self, visible: bool, cx: &mut Context) { + if self.left_visible != visible { + self.left_visible = visible; + cx.notify(); + } + } + + /// Shows or hides the right sidebar. + pub fn set_right_sidebar_visible(&mut self, visible: bool, cx: &mut Context) { + if self.right_visible != visible { + self.right_visible = visible; + cx.notify(); + } + } + + /// Whether the left sidebar is visible. + pub fn is_left_sidebar_visible(&self) -> bool { + self.left_visible + } + + /// Whether the right sidebar is visible. + pub fn is_right_sidebar_visible(&self) -> bool { + self.right_visible + } + + /// The left sidebar's current width. + pub fn left_sidebar_width(&self) -> Pixels { + self.left_width + } + + /// The right sidebar's current width. + pub fn right_sidebar_width(&self) -> Pixels { + self.right_width + } + + /// Applies a drag delta to the left sidebar's width, clamped. + fn resize_left(&mut self, delta: Pixels, cx: &mut Context) { + self.left_width = + (self.left_width + delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH)); + cx.notify(); + } + + /// Applies a drag delta to the right sidebar's width, clamped. + fn resize_right(&mut self, delta: Pixels, cx: &mut Context) { + self.right_width = + (self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH)); + cx.notify(); + } +} + +/// Which sidebar a region or toggle belongs to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Side { + Left, + Right, +} + +impl Side { + fn name(self) -> &'static str { + match self { + Side::Left => "left", + Side::Right => "right", + } + } + + fn toggle_icon(self) -> IconRole { + match self { + Side::Left => IconRole::PanelLeft, + Side::Right => IconRole::PanelRight, + } + } + + fn toggle_label(self) -> SharedString { + match self { + Side::Left => SharedString::from("Hide left sidebar"), + Side::Right => SharedString::from("Hide right sidebar"), + } + } +} + +/// The application shell. +#[derive(IntoElement)] +pub struct AppLayout { + id: ElementId, + state: Entity, + left_sidebar: Option, + right_sidebar: Option, + center: Option, +} + +impl AppLayout { + /// Creates the shell bound to `state`. + pub fn new(id: I, state: Entity) -> Self + where + I: Into, + { + Self { + id: id.into(), + state, + left_sidebar: None, + right_sidebar: None, + center: None, + } + } + + /// Sets the left sidebar's navigation list. + pub fn left_sidebar(mut self, sidebar: Sidebar) -> Self { + self.left_sidebar = Some(sidebar); + self + } + + /// Sets the right sidebar's content: a details or inspector pane. + pub fn right_sidebar(mut self, content: E) -> Self + where + E: IntoElement, + { + self.right_sidebar = Some(content.into_any_element()); + self + } + + /// Sets the center region's content, which hosts the dock. + pub fn center(mut self, content: E) -> Self + where + E: IntoElement, + { + self.center = Some(content.into_any_element()); + self + } +} + +impl RenderOnce for AppLayout { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let metrics = theme.sizes.resolve(Size::default()); + + let state = self.state; + let id = self.id.clone(); + + let mut left = self.left_sidebar; + let mut right = self.right_sidebar; + let mut center = self.center; + + let left_visible = state.read(cx).is_left_sidebar_visible(); + let right_visible = state.read(cx).is_right_sidebar_visible(); + + div() + .flex() + .flex_row() + .w_full() + .h_full() + .overflow_hidden() + .when(left_visible && left.is_some(), |layout| { + let width = state.read(cx).left_sidebar_width(); + layout + .child(sidebar_region( + width, + left.take() + .map(|sidebar| sidebar.into_any_element()) + .unwrap_or_else(|| div().into_any_element()), + Side::Left, + &state, + metrics.strip_height, + metrics.dense_padding_x, + )) + .child(ResizeHandle::new((id.clone(), "left-resize")).on_resize({ + let state = state.clone(); + move |delta, _, cx| { + state.update(cx, |state, cx| state.resize_left(delta, cx)); + } + })) + }) + .child( + div() + .flex_1() + .h_full() + .min_w_0() + .overflow_hidden() + .child(center.take().unwrap_or_else(|| div().into_any_element())), + ) + .when(right_visible && right.is_some(), |layout| { + let width = state.read(cx).right_sidebar_width(); + layout + .child(ResizeHandle::new((id.clone(), "right-resize")).on_resize({ + let state = state.clone(); + move |delta, _, cx| { + state.update(cx, |state, cx| state.resize_right(delta, cx)); + } + })) + .child(sidebar_region( + width, + right.take().unwrap_or_else(|| div().into_any_element()), + Side::Right, + &state, + metrics.strip_height, + metrics.dense_padding_x, + )) + }) + } +} + +/// One sidebar: a header strip with the hide toggle over the app's content. +fn sidebar_region( + width: Pixels, + content: AnyElement, + side: Side, + state: &Entity, + strip_height: Pixels, + padding_x: Pixels, +) -> AnyElement { + 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(div().flex_1().min_h_0().overflow_hidden().child(content)) + .into_any_element() +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 6227dbe..e66f28e 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2,3 +2,6 @@ #[cfg(feature = "resize_handle")] pub mod resize_handle; + +#[cfg(feature = "app_layout")] +pub mod app_layout; diff --git a/crates/layout/tests/app_layout.rs b/crates/layout/tests/app_layout.rs new file mode 100644 index 0000000..95e8c90 --- /dev/null +++ b/crates/layout/tests/app_layout.rs @@ -0,0 +1,236 @@ +use gpui::{ + Entity, InteractiveElement as _, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, point, px, +}; +use gpui_native_data::sidebar::Sidebar; +use gpui_native_layout::app_layout::{AppLayout, LayoutState}; +use gpui_native_theme::{ActiveTheme as _, Size, Theme}; + +struct Harness { + state: Entity, +} + +impl Render for Harness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + div().size_full().child( + AppLayout::new("test-layout", self.state.clone()) + .left_sidebar(Sidebar::new("test-left")) + .center( + div() + .size_full() + .debug_selector(|| "test-center".to_string()), + ) + .right_sidebar( + div() + .size_full() + .debug_selector(|| "test-right".to_string()), + ), + ) + } +} + +fn layout_window(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + cx.update(Theme::init); + let state = cx.update(LayoutState::new); + let state_for_window = state.clone(); + let window = cx.add_window(move |_, _| Harness { + state: state_for_window, + }); + let mut visual = VisualTestContext::from_window(window.into(), cx); + redraw(&mut visual); + (state, visual) +} + +fn redraw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +fn bounds(visual: &mut VisualTestContext, selector: &'static str) -> Option> { + visual.debug_bounds(selector) +} + +fn center_of(bounds: gpui::Bounds) -> gpui::Point { + point(bounds.center().x, bounds.center().y) +} + +fn press(visual: &mut VisualTestContext, at: gpui::Point, click_count: usize) { + visual.simulate_event(MouseDownEvent { + position: at, + click_count, + modifiers: Modifiers::default(), + button: MouseButton::Left, + first_mouse: false, + }); + visual.simulate_event(MouseUpEvent { + position: at, + click_count, + modifiers: Modifiers::default(), + button: MouseButton::Left, + }); +} + +fn drag(visual: &mut VisualTestContext, from: gpui::Point, to: gpui::Point) { + visual.simulate_mouse_down(from, MouseButton::Left, Modifiers::default()); + visual.simulate_event(MouseMoveEvent { + position: to, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_mouse_up(to, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn left_sidebar_starts_visible_and_right_hidden() { + let mut cx = TestAppContext::single(); + let (state, mut visual) = layout_window(&mut cx); + + assert!(state.read_with(&cx, |state, _| state.is_left_sidebar_visible())); + assert!(!state.read_with(&cx, |state, _| state.is_right_sidebar_visible())); + + assert!( + bounds(&mut visual, "sidebar-test-left").is_some(), + "left region missing" + ); + assert!( + bounds(&mut visual, "test-center").is_some(), + "center missing" + ); + assert!( + bounds(&mut visual, "test-right").is_none(), + "right region must be hidden by default" + ); +} + +#[test] +fn toggling_a_sidebar_shows_and_hides_its_region() { + let mut cx = TestAppContext::single(); + let (state, mut visual) = layout_window(&mut cx); + + cx.update(|cx| { + state.update(cx, |state, cx| state.set_right_sidebar_visible(true, cx)); + }); + redraw(&mut visual); + assert!( + bounds(&mut visual, "test-right").is_some(), + "right region missing" + ); + + cx.update(|cx| { + state.update(cx, |state, cx| state.set_left_sidebar_visible(false, cx)); + }); + redraw(&mut visual); + assert!( + bounds(&mut visual, "sidebar-test-left").is_none(), + "left region lingering" + ); + // The center keeps filling the shell. + assert!( + bounds(&mut visual, "test-center").is_some(), + "center missing" + ); +} + +#[test] +fn the_header_toggle_hides_its_sidebar() { + let mut cx = TestAppContext::single(); + let (state, mut visual) = layout_window(&mut cx); + + // The toggle sits at the trailing end of the sidebar's header strip, an + // icon-only small button: derive its center from the theme metrics. + let metrics = cx.update(|cx| cx.theme().sizes.resolve(Size::default())); + let small = cx.update(|cx| cx.theme().sizes.resolve(Size::Small)); + let region = bounds(&mut visual, "sidebar-test-left").expect("left sidebar missing"); + // The Sidebar fills the content area below the strip; the toggle sits at + // the strip's trailing end, one strip height above the sidebar's top. + let toggle = point( + region.right() - metrics.dense_padding_x - small.control_height / 2.0, + region.top() - metrics.strip_height / 2.0, + ); + press(&mut visual, toggle, 1); + redraw(&mut visual); + + assert!( + !state.read_with(&cx, |state, _| state.is_left_sidebar_visible()), + "the toggle should hide the left sidebar" + ); + assert!(bounds(&mut visual, "sidebar-test-left").is_none()); +} + +#[test] +fn dragging_the_seam_resizes_and_clamps() { + let mut cx = TestAppContext::single(); + let (state, mut visual) = layout_window(&mut cx); + + let before = state.read_with(&cx, |state, _| state.left_sidebar_width()); + let seam = bounds(&mut visual, "resize-handle").expect("left seam missing"); + + drag( + &mut visual, + center_of(seam), + center_of(seam) + point(px(50.), px(0.)), + ); + assert_eq!( + state.read_with(&cx, |state, _| state.left_sidebar_width()), + before + px(50.) + ); + + // A huge drag clamps instead of collapsing the sidebar. + let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); + drag( + &mut visual, + center_of(seam), + center_of(seam) + point(px(2000.), px(0.)), + ); + assert_eq!( + state.read_with(&cx, |state, _| state.left_sidebar_width()), + px(480.) + ); + + // Dragging back left shrinks it to the low clamp, no drift from the first. + let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); + drag( + &mut visual, + center_of(seam), + center_of(seam) - point(px(2000.), px(0.)), + ); + assert_eq!( + state.read_with(&cx, |state, _| state.left_sidebar_width()), + px(160.) + ); +} + +#[test] +fn the_right_seam_grows_the_right_sidebar_leftwards() { + let mut cx = TestAppContext::single(); + let (state, mut visual) = layout_window(&mut cx); + + // Hide the left sidebar so the shell has exactly one seam. + cx.update(|cx| { + state.update(cx, |state, cx| state.set_left_sidebar_visible(false, cx)); + state.update(cx, |state, cx| state.set_right_sidebar_visible(true, cx)); + }); + redraw(&mut visual); + + let before = state.read_with(&cx, |state, _| state.right_sidebar_width()); + let seam = bounds(&mut visual, "resize-handle").expect("right seam missing"); + + // Dragging left (negative) grows the right sidebar. + drag( + &mut visual, + center_of(seam), + center_of(seam) - point(px(30.), px(0.)), + ); + let grown = state.read_with(&cx, |state, _| state.right_sidebar_width()); + assert_eq!(grown, before + px(30.)); + + // The right region's width follows the state. + let region = bounds(&mut visual, "test-right").expect("right region missing"); + assert_eq!(region.size.width, grown); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 39c4097..3b71637 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -285,7 +285,7 @@ Units, each through the review gate: | Unit | Crate | Component | Notes | | --- | --- | --- | --- | | L1 | layout | ResizeHandle ✅ Landed (2026-11-01) | The drag divider primitive: a thin themed handle that resizes the adjacent region (sidebar edge, split seam). Pointer drag, double-click resets to the default fraction, splitter a11y role. Lives in `layout` — promotes to `core` if a second consumer appears (D16). Shipped as a delta reporter: `ResizeHandle::new(id).axis(Axis).on_resize(delta)` hands the owner the signed seam movement per event — the owner stores a pixel size and clamps, the `InputState::scroll_by` pattern — with `.on_reset()` for double-click; no geometry parameters, so it works at any edge or seam. The hairline is `border`, highlights to `ring` on hover/drag; hit slop = `dense_padding_x` (component-own decoration, D25), hairline 1 px like the input's paint hairlines; `Role::Splitter` + focus ring, arrow keys move the seam (`KEYBOARD_STEP` 32 px, input-style const). One gpui dispatch lesson locked in tests: bubble runs user mouse-down listeners before the div's own focus transfer, so a listener that stops propagation must focus explicitly first — the keyboard test caught it. Feature `resize_handle`; 6 headless tests in `crates/layout/tests/resize_handle.rs`; gallery section shows a draggable split with live width | -| L2 | layout | AppLayout | The bootstrap shell: left sidebar region (on by default) + center + right sidebar region (off by default). Sidebar toggle in each sidebar's header, drag-resize widths through `ResizeHandle`; visibility and widths live on an application-owned `LayoutState` (the `InputState` pattern: `set_left_sidebar_visible` / `set_right_sidebar_visible` / `is_*` readers). The center region hosts the `Dock`. | +| L2 | layout | AppLayout ✅ Landed (2026-11-01) | The bootstrap shell: left sidebar region (on by default) + center + right sidebar region (off by default). Sidebar toggle in each sidebar's header, drag-resize widths through `ResizeHandle`; visibility and widths live on an application-owned `LayoutState` (the `InputState` pattern: `set_left_sidebar_visible` / `set_right_sidebar_visible` / `is_*` readers). The center region hosts the `Dock`. Shipped so: the left sidebar is the data crate's `Sidebar` component (`.left_sidebar(sidebar)` — selection, keyboard nav, and a11y come for free; `app_layout` pulls `data/sidebar`, per the "may compose data" note), the right sidebar and center are app elements (the right side is a details pane, not a list). Each visible sidebar renders a `strip_height` header strip with an end-aligned ghost icon toggle (`IconRole::PanelLeft`/`PanelRight` — new roles this unit: Segoe `DockLeft`/`DockRight` E90C/E90D per the MDL2 table, Lucide `panel-left`/`panel-right` bundled, freedesktop `sidebar-expand-*` → `sidebar-show-symbolic` cascade); `LayoutState` holds visibility + pixel widths with 160/480 px drag clamps (menu-cap px precedent); the right handle inverts the delta so dragging left grows it. `app_layout` pulls only core's `button` + `data/sidebar`; the facade `layout` feature re-requests `full` (core/data workspace deps are now `default-features = false`). 5 headless tests in `crates/layout/tests/app_layout.rs`: defaults, toggling via the state API, the header toggle button (clicked at metric-derived coordinates — gpui dispatch lesson from L1 applies to `on_click` too), seam resize + both clamps with no drift, and the inverted right seam. **Follow-up (2026-11-01):** the left sidebar is now the data crate's `Sidebar` component itself (`.left_sidebar(sidebar)` — selection, keyboard nav, and a11y come free; `app_layout` pulls `data/sidebar`), the right sidebar stays a free element (a details pane is not a list), and `Sidebar` gained a `sidebar-{id}` debug selector for headless tests | | L3 | layout | Dock | The center pane tree, controlled: `DockState` = `Pane { tabs, active }` or `Split { axis, ratio, a, b }`; panels are registered descriptors (`id` + view builder); add / close / activate are plain state ops; a pane emptied by closing its last tab collapses its split. Pane headers show titles, the active tab's close button, and a context menu (via core `ContextMenu`). | | L4 | layout | Dock drag-to-split | Dragging a tab: drop on a pane's edge splits a new pane there; drop on a pane's center moves the tab into that pane; a pane emptied by the move collapses. Edge drop zones paint from theme tokens while a drag is live. Split/merge go through the same `DockState` ops L3 exposed — the drag is interaction, not a second API. | | 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. | @@ -374,6 +374,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 ✅, the AppLayout bootstrap, the center Dock with drag-to-split, 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 ✅, AppLayout ✅, the center Dock with drag-to-split, 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/Cargo.toml b/examples/gallery/Cargo.toml index 4f41f88..13f78fc 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -7,8 +7,8 @@ description = "Live preview of every component against the resolved system theme [dependencies] gpui = { workspace = true } gpui_platform = { workspace = true } -gpui-native-core = { workspace = true } -gpui-native-data = { workspace = true } +gpui-native-core = { workspace = true, features = ["full"] } +gpui-native-data = { workspace = true, features = ["full"] } gpui-native-layout = { workspace = true } gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 2fbd9ff..88ee0a7 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -34,6 +34,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; use gpui_native_data::tabs::{Tab, Tabs}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; +use gpui_native_layout::app_layout::{AppLayout, LayoutState}; use gpui_native_layout::resize_handle::ResizeHandle; use gpui_native_theme::{ActiveTheme as _, Size, Theme}; use gpui_platform::application; @@ -49,6 +50,8 @@ struct Gallery { password_state: Entity, notes_state: Entity, split_width: Pixels, + layout_state: Entity, + shell_selection: Option, popover_open: bool, menu_open: bool, strategy: Option, @@ -144,6 +147,82 @@ fn tree_paths(nodes: &[GalleryNode]) -> Vec> { paths } +impl Gallery { + /// The app shell demo: sidebars around a center region, toggles in the + /// center demonstrating the `LayoutState` API. + fn render_app_shell(&self, gallery: &Entity, cx: &App) -> impl IntoElement + use<> { + let colors = cx.theme().colors; + let state = self.layout_state.clone(); + let state_for_toggle = state.clone(); + + let toggle = |side: &'static str| { + let state = state_for_toggle.clone(); + Button::new(SharedString::from(format!("gallery-shell-toggle-{side}"))) + .label(format!("Toggle {}", side)) + .variant(ButtonVariant::Secondary) + .small() + .on_click(move |_, _, cx| { + state.update(cx, |state, cx| match side { + "left" => { + let visible = !state.is_left_sidebar_visible(); + state.set_left_sidebar_visible(visible, cx); + } + _ => { + let visible = !state.is_right_sidebar_visible(); + state.set_right_sidebar_visible(visible, cx); + } + }); + }) + }; + + let left_sidebar = Sidebar::new("gallery-shell-sidebar") + .selected(self.shell_selection) + .entries(|_, _| { + vec![ + SidebarEntry::header("Repositories"), + SidebarEntry::row("gpui-native-kit").icon(IconRole::Folder), + SidebarEntry::row("signed").icon(IconRole::Folder), + SidebarEntry::header("Explore"), + SidebarEntry::row("Popular"), + SidebarEntry::row("Recent"), + ] + }) + .on_change({ + let gallery = gallery.clone(); + move |selection, _, cx| { + gallery.update(cx, |gallery, _| gallery.shell_selection = selection); + } + }); + + let right_sidebar = v_flex() + .gap_1() + .p_2() + .child("Details") + .child("Signed by the author") + .text_sm() + .text_color(colors.muted_foreground); + + let center = v_flex() + .gap_2() + .items_center() + .justify_center() + .size_full() + .child( + h_flex() + .gap_2() + .child(toggle("left")) + .child(toggle("right")), + ) + .child("Center \u{2014} the dock lands here (L3)") + .text_color(colors.muted_foreground); + + AppLayout::new("gallery-shell", state) + .left_sidebar(left_sidebar) + .center(center) + .right_sidebar(right_sidebar) + } +} + impl Render for Gallery { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); @@ -448,6 +527,41 @@ impl Render for Gallery { )), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "layout · app shell \u{2014} NSWindow sidebar + inspector, WinUI SplitView shell", + colors, + )) + .child( + div() + .h(px(320.)) + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child(self.render_app_shell(&gallery, cx)), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!( + "left: {} · right: {} \u{2014} buttons toggle, seams resize", + if self.layout_state.read(cx).is_left_sidebar_visible() { + "on" + } else { + "off" + }, + if self.layout_state.read(cx).is_right_sidebar_visible() { + "on" + } else { + "off" + }, + )), + ), + ) .child( v_flex() .gap_2() @@ -1538,6 +1652,7 @@ fn main() { password_state.update(cx, |state, cx| state.set_password(true, cx)); let notes_state = InputState::new(cx); notes_state.update(cx, |state, cx| state.set_multiline(true, cx)); + let layout_state = LayoutState::new(cx); let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx); if let Err(err) = cx.open_window( WindowOptions { @@ -1577,6 +1692,8 @@ fn main() { password_state, notes_state, split_width: px(200.), + layout_state, + shell_selection: None, popover_open: false, menu_open: false, strategy: Some(Strategy::Curated),