From 545d0986eda3c76aa905dca048c0605daf019a26 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 2 Nov 2026 16:24:20 +0700 Subject: [PATCH] add dock layout component --- .github/workflows/ci.yml | 9 +- Cargo.lock | 1 + crates/core/src/button.rs | 14 +- crates/data/Cargo.toml | 3 +- crates/data/src/tabs.rs | 282 +++++++-- crates/layout/Cargo.toml | 10 +- crates/layout/src/dock.rs | 1055 ++++++++++++++++++++++++++++++++++ crates/layout/src/lib.rs | 3 + crates/layout/tests/dock.rs | 422 ++++++++++++++ docs/PLAN.md | 12 +- examples/gallery/src/main.rs | 170 +++++- 11 files changed, 1914 insertions(+), 67 deletions(-) create mode 100644 crates/layout/src/dock.rs create mode 100644 crates/layout/tests/dock.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7cbd1..f8ddac3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,11 +29,18 @@ jobs: for f in button checkbox switch radio toggle link slider progress avatar input popover tooltip menu dialog toast; do cargo check -p gpui-native-core --no-default-features --features "$f" done + - name: Feature checks (data) + run: | + cargo check -p gpui-native-data --no-default-features + cargo check -p gpui-native-data --all-features + for f in list sidebar tabs tree; do + cargo check -p gpui-native-data --no-default-features --features "$f" + done - name: Feature checks (layout) run: | cargo check -p gpui-native-layout --no-default-features cargo check -p gpui-native-layout --all-features - for f in resize_handle shell; do + for f in dock resize_handle shell; do cargo check -p gpui-native-layout --no-default-features --features "$f" done - name: Feature checks (facade) diff --git a/Cargo.lock b/Cargo.lock index 169a953..b9cbf8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1713,6 +1713,7 @@ name = "gpui-native-data" version = "0.1.0" dependencies = [ "gpui", + "gpui-native-core", "gpui-native-icons", "gpui-native-theme", "gpui-native-utils", diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index 33f42ba..2dad715 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -40,6 +40,7 @@ pub struct Button { variant: ButtonVariant, size: Size, disabled: bool, + tab_stop: bool, a11y_label: Option, tooltip: Option, on_click: Option, @@ -58,6 +59,7 @@ impl Button { variant: ButtonVariant::default(), size: Size::default(), disabled: false, + tab_stop: true, a11y_label: None, tooltip: None, on_click: None, @@ -88,13 +90,19 @@ impl Button { self } - /// Sets the visual treatment. + /// Sets the button's visual language. pub fn variant(mut self, variant: ButtonVariant) -> Self { self.variant = variant; self } - /// Renders the button inert, unfocusable, and dimmed; disabled always wins. + /// Whether the button is a keyboard tab stop, dense toolbars turn it off. + pub fn tab_stop(mut self, tab_stop: bool) -> Self { + self.tab_stop = tab_stop; + self + } + + /// Renders the button inert, unfocusable, and dimmed. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self @@ -193,7 +201,7 @@ impl RenderOnce for Button { button .cursor_pointer() .focusable() - .tab_stop(true) + .tab_stop(self.tab_stop) .hover(move |style| style.bg(hover)) .active(move |style| style.bg(pressed)) .focus_visible(|style| style.shadow(focus_ring(colors.ring))) diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index f54c293..944d3f8 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -6,6 +6,7 @@ description = "Data-heavy GPUI components: lists, trees, tabs, sidebars — one [dependencies] gpui = { workspace = true } +gpui-native-core = { workspace = true, optional = true } gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } gpui-native-utils = { workspace = true } @@ -18,5 +19,5 @@ default = ["full"] full = ["list", "sidebar", "tabs", "tree"] list = [] sidebar = [] -tabs = [] +tabs = ["dep:gpui-native-core", "gpui-native-core/button"] tree = [] diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs index f895ed2..f96fb29 100644 --- a/crates/data/src/tabs.rs +++ b/crates/data/src/tabs.rs @@ -2,23 +2,41 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, - ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, + ParentElement as _, RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, }; +use gpui_native_core::button::{Button, ButtonVariant}; +use gpui_native_core::common::Sizable as _; use gpui_native_icons::{IconRole, icon}; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_utils::adjacent_selectable; /// The change reporter, fired with the next selected tab by pointer and keys. type ChangeHandler = Rc; +/// The close reporter, fired with the tab's index by its close control. +type CloseHandler = Rc; +/// Decorates the rendered tab, e.g. to attach a drag payload. +type TabWrap = Box) -> AnyElement>; type TabsFn = Box Vec>; +/// The visual language of a [`Tabs`] strip. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TabsVariant { + #[default] + Underline, + Pill, +} + /// One tab of a [`Tabs`] strip. pub struct Tab { label: SharedString, icon: Option, + lead: Option, + busy: bool, disabled: bool, + closeable: bool, + wrap: Option, } impl Tab { @@ -30,7 +48,11 @@ impl Tab { Self { label: label.into(), icon: None, + lead: None, + busy: false, disabled: false, + closeable: false, + wrap: None, } } @@ -40,11 +62,41 @@ impl Tab { self } + /// Sets the leading element. + pub fn lead(mut self, lead: E) -> Self + where + E: IntoElement + 'static, + { + self.lead = Some(lead.into_any_element()); + self + } + + /// Shows the loading slot. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + /// Renders the tab dimmed; clicks and keyboard skip it. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } + + /// Shows a close control that reports the tab through [`Tabs::on_close`]. + pub fn closeable(mut self, closeable: bool) -> Self { + self.closeable = closeable; + self + } + + /// Hands the rendered tab through `wrap`, e.g. to attach a drag payload. + pub fn wrap(mut self, wrap: F) -> Self + where + F: FnOnce(Stateful
) -> AnyElement + 'static, + { + self.wrap = Some(Box::new(wrap)); + self + } } /// A tab strip. @@ -52,8 +104,10 @@ impl Tab { pub struct Tabs { id: ElementId, selected: usize, + variant: TabsVariant, a11y_label: Option, on_change: Option, + on_close: Option, tabs: Option, } @@ -66,8 +120,10 @@ impl Tabs { Self { id: id.into(), selected: 0, + variant: TabsVariant::default(), a11y_label: None, on_change: None, + on_close: None, tabs: None, } } @@ -78,6 +134,12 @@ impl Tabs { self } + /// Sets the strip's visual language. + pub fn variant(mut self, variant: TabsVariant) -> Self { + self.variant = variant; + self + } + /// Reports the next selected tab, from clicks and keys alike. pub fn on_change(mut self, handler: F) -> Self where @@ -87,6 +149,15 @@ impl Tabs { self } + /// Reports a closed tab by index. + pub fn on_close(mut self, handler: F) -> Self + where + F: Fn(usize, &mut Window, &mut App) + 'static, + { + self.on_close = Some(Rc::new(handler)); + self + } + /// Sets the tabs, built fresh each render. pub fn tabs(mut self, tabs: F) -> Self where @@ -129,27 +200,27 @@ impl RenderOnce for Tabs { .unwrap_or_else(|| SharedString::from(self.id.to_string())); let strip: Vec = tabs - .iter() + .into_iter() .enumerate() .map(|(index, tab)| { render_tab( + &self.id, cx, tab, index, self.selected == index, self.selected, + self.variant, &self.on_change, + &self.on_close, &focus, ) }) .collect(); - div() - .id(self.id) + let mut strip_root = div() + .id(self.id.clone()) .flex() - .gap_1() - .border_b_1() - .border_color(theme.colors.border) .text_size(theme.sizes.medium.text_size) .track_focus(&focus) .focusable() @@ -175,62 +246,121 @@ impl RenderOnce for Tabs { } cx.stop_propagation(); } - }) - .children(strip) + }); + + strip_root = match self.variant { + TabsVariant::Underline => strip_root + .gap_1() + .border_b_1() + .border_color(theme.colors.border), + TabsVariant::Pill => strip_root.items_center().gap_1(), + }; + + strip_root.children(strip) } } +#[allow(clippy::too_many_arguments)] fn render_tab( + tabs_id: &ElementId, cx: &App, - tab: &Tab, + tab: Tab, index: usize, selected: bool, selected_index: usize, + variant: TabsVariant, on_change: &Option, + on_close: &Option, focus: &gpui::FocusHandle, ) -> AnyElement { + let mut tab = tab; + let theme = cx.theme(); - let label_color = if tab.disabled { - theme.colors.muted_foreground - } else if selected { - theme.ramps.primary.base - } else { - theme.colors.foreground + let metrics = theme.sizes.resolve(Size::default()); + + let label_color = match variant { + TabsVariant::Underline => { + if tab.disabled { + theme.colors.muted_foreground + } else if selected { + theme.ramps.primary.base + } else { + theme.colors.foreground + } + } + TabsVariant::Pill => { + if tab.disabled { + theme.colors.muted_foreground + } else { + theme.colors.foreground + } + } }; let mut strip_tab = div() .id(("tab", index)) + .debug_selector({ + let id = format!("{tabs_id}-tab-{}", tab.label); + move || id + }) .flex() .items_center() .gap_1p5() - .px_2() - .py_1() - .rounded_tl(theme.radius.sm) - .rounded_tr(theme.radius.sm) .role(Role::Tab) .aria_selected(selected) .text_color(label_color) - .when(selected, |strip_tab| { - strip_tab - .border_b_2() - .border_color(theme.ramps.primary.base) - }) .when(!tab.disabled && !selected, |strip_tab| { strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted)) - }) - .child( + }); + + strip_tab = match variant { + TabsVariant::Underline => strip_tab + .px_2() + .py_1() + .rounded_tl(theme.radius.sm) + .rounded_tr(theme.radius.sm) + .when(selected, |strip_tab| { + strip_tab + .border_b_2() + .border_color(theme.ramps.primary.base) + }), + TabsVariant::Pill => strip_tab + .h(metrics.control_height) + .px(metrics.padding_x) + .rounded(theme.radius.control) + .when(selected, |strip_tab| { + strip_tab + .bg(theme.colors.muted) + .font_weight(gpui::FontWeight::MEDIUM) + }), + }; + + let lead = tab.lead.take(); + strip_tab = match lead { + Some(lead) => strip_tab.child(lead), + None => strip_tab.child( div() .flex_none() - .pb_0p5() - .when_some(tab.icon, |strip_tab, role| { - strip_tab.child( - icon(role) - .size(theme.sizes.medium.dense_icon_size) - .color(label_color), - ) + .when(variant == TabsVariant::Underline, |slot| slot.pb_0p5()) + .when_some(tab.icon, |slot, role| { + slot.child(icon(role).size(metrics.dense_icon_size).color(label_color)) }), - ) - .child(strip_tab_label(tab.label.clone(), selected, cx)); + ), + }; + + strip_tab = strip_tab.child(strip_tab_label(&tab, selected, variant, cx)); + + if variant == TabsVariant::Pill { + strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5)); + } + + if tab.busy && !tab.disabled { + strip_tab = strip_tab.child(busy_slot(cx)); + } + + if tab.closeable && on_close.is_some() && !tab.disabled { + strip_tab = strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close)); + } if !tab.disabled { strip_tab = strip_tab.cursor_pointer().on_click({ @@ -247,17 +377,77 @@ fn render_tab( }); } - strip_tab.into_any_element() + match tab.wrap { + Some(wrap) => wrap(strip_tab), + None => strip_tab.into_any_element(), + } } -fn strip_tab_label(label: SharedString, selected: bool, cx: &App) -> AnyElement { - let theme = cx.theme(); +fn render_tab_close( + tabs_id: &ElementId, + tab: &Tab, + index: usize, + on_close: &Option, +) -> AnyElement { + // The wrapper carries the test selector; the button paints itself. div() - .text_color(if selected { - theme.ramps.primary.base - } else { - theme.colors.foreground + .id(("tab-close", index)) + .debug_selector({ + let id = format!("{tabs_id}-close-{}", tab.label); + move || id }) - .child(label) + .flex_none() + .child( + Button::new(("tab-close-button", index)) + .icon(IconRole::Close) + .variant(ButtonVariant::Ghost) + .small() + .tab_stop(false) + .accessibility_label(SharedString::from(format!("Close {}", tab.label))) + .on_click({ + let on_close = on_close.clone(); + move |_, window, cx| { + cx.stop_propagation(); + if let Some(on_close) = &on_close { + on_close(index, window, cx); + } + } + }), + ) + .into_any_element() +} + +fn strip_tab_label(tab: &Tab, selected: bool, variant: TabsVariant, cx: &App) -> AnyElement { + let theme = cx.theme(); + let color = match variant { + TabsVariant::Underline => { + if selected { + theme.ramps.primary.base + } else { + theme.colors.foreground + } + } + TabsVariant::Pill => { + if tab.disabled { + theme.colors.muted_foreground + } else { + theme.colors.foreground + } + } + }; + div() + .text_color(color) + .child(tab.label.clone()) + .into_any_element() +} + +fn busy_slot(cx: &App) -> AnyElement { + let theme = cx.theme(); + div() + .flex_none() + .size(theme.sizes.medium.dense_icon_size) + .rounded(theme.radius.full) + .border_1() + .border_color(theme.colors.border) .into_any_element() } diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index 87c8cc1..68ad3a6 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -17,7 +17,15 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["resize_handle", "shell"] +full = ["dock", "resize_handle", "shell"] +dock = [ + "resize_handle", + "dep:gpui-native-core", + "dep:gpui-native-data", + "dep:gpui-native-icons", + "gpui-native-core/context_menu", + "gpui-native-data/tabs", +] resize_handle = [] shell = [ "resize_handle", diff --git a/crates/layout/src/dock.rs b/crates/layout/src/dock.rs new file mode 100644 index 0000000..a1ed83d --- /dev/null +++ b/crates/layout/src/dock.rs @@ -0,0 +1,1055 @@ +//! The dock: the center pane tree — tabbed panes the app fills from +//! registered panels, split two ways, collapsing as tabs close. + +use std::collections::HashMap; + +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, +}; +use gpui_native_core::common::{h_flex, v_flex}; +use gpui_native_core::context_menu::ContextMenu; +use gpui_native_core::menu::MenuItem; +use gpui_native_data::tabs::{Tab, Tabs, TabsVariant}; +use gpui_native_icons::IconRole; +use gpui_native_theme::{ActiveTheme as _, Size}; + +use crate::resize_handle::ResizeHandle; + +/// The narrowest either side of a split can get, as a fraction of the split. +const MIN_SPLIT_RATIO: f32 = 0.1; +/// The fraction a split opens at, and what a double-click on the seam restores. +const DEFAULT_SPLIT_RATIO: f32 = 0.5; + +/// Builds the view a panel opens as, once per open. +type PanelBuilder = Box AnyView>; + +/// A registered panel: its identity, chrome, and how its view is built. +pub struct PanelDescriptor { + id: SharedString, + title: SharedString, + icon: Option, + build: PanelBuilder, +} + +impl PanelDescriptor { + /// Creates a panel descriptor that opens through `build`. + pub fn new(id: I, title: T, build: F) -> Self + where + I: Into, + T: Into, + F: Fn(&mut Window, &mut App) -> AnyView + 'static, + { + Self { + id: id.into(), + title: title.into(), + icon: None, + build: Box::new(build), + } + } + + /// Shows an icon leading the tab label. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + self + } +} + +/// Which side of its pane a split places the new pane on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SplitDirection { + Left, + Right, + Top, + Bottom, +} + +impl SplitDirection { + fn axis(self) -> Axis { + match self { + Self::Left | Self::Right => Axis::Horizontal, + Self::Top | Self::Bottom => Axis::Vertical, + } + } + + /// Whether the new pane takes the split's leading slot. + fn leads(self) -> bool { + matches!(self, Self::Left | Self::Top) + } +} + +/// The payload a dock tab drags: the panel to move. +#[derive(Clone)] +pub struct PanelDrag { + pub panel: SharedString, +} + +/// The card that follows the cursor while a tab is dragged. +struct DragPreview { + title: SharedString, +} + +impl Render for DragPreview { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + div() + .px_2() + .py_1() + .rounded(theme.radius.sm) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .text_size(theme.sizes.medium.text_size) + .text_color(theme.colors.foreground) + .opacity(0.85) + .child(self.title.clone()) + } +} + +/// One open panel view. +struct PanelTab { + panel: SharedString, + title: SharedString, + icon: Option, + view: AnyView, +} + +/// A leaf: the tabs of one region and which is active. +struct Pane { + key: u64, + tabs: Vec, + active: Option, +} + +/// An internal node: two children along an axis. +struct Split { + key: u64, + axis: Axis, + ratio: f32, + a: Box, + b: Box, +} + +enum Node { + Pane(Pane), + Split(Split), +} + +/// The dock's pane tree, owned by the application. +pub struct DockState { + root: Node, + panels: HashMap, + /// The pane new panels open into, kept on the last activated pane. + active_pane: u64, + next_key: u64, +} + +impl DockState { + /// Creates a dock with one empty pane. + pub fn new(cx: &mut App) -> Entity { + cx.new(|_| Self { + root: Node::Pane(Pane { + key: 0, + tabs: Vec::new(), + active: None, + }), + panels: HashMap::new(), + active_pane: 0, + next_key: 1, + }) + } + + /// Registers a panel descriptor, replacing any earlier one with its id. + pub fn register_panel(&mut self, panel: PanelDescriptor, cx: &mut Context) { + if self.panels.insert(panel.id.clone(), panel).is_none() { + cx.notify(); + } + } + + /// Opens a panel into the active pane and activates its tab. + pub fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + if self.is_open(id) { + self.activate_panel(id, cx); + return; + } + + let Some(panel) = self.panels.get(id) else { + return; + }; + + let tab = PanelTab { + panel: SharedString::from(id), + title: panel.title.clone(), + icon: panel.icon, + view: (panel.build)(window, cx), + }; + + let pane_key = self.target_pane_key(); + + if let Some(pane) = self.pane_mut(pane_key) { + pane.tabs.push(tab); + pane.active = Some(SharedString::from(id)); + } + + self.active_pane = pane_key; + cx.notify(); + } + + /// Activates a panel's tab and makes its pane the active pane. + pub fn activate_panel(&mut self, id: &str, cx: &mut Context) { + let Some((pane_key, _)) = self.locate(id) else { + return; + }; + let changed = { + let Some(pane) = self.pane_mut(pane_key) else { + return; + }; + let changed = pane.active.as_deref() != Some(id); + if changed { + pane.active = Some(SharedString::from(id)); + } + changed + }; + self.active_pane = pane_key; + if changed { + cx.notify(); + } + } + + /// Closes a panel's tab. + pub fn close_panel(&mut self, id: &str, cx: &mut Context) { + let Some((pane_key, index)) = self.locate(id) else { + return; + }; + + let emptied = { + let Some(pane) = self.pane_mut(pane_key) else { + return; + }; + + let was_active = pane.active.as_deref() == Some(id); + pane.tabs.remove(index); + + if was_active { + pane.active = pane + .tabs + .get(index) + .or_else(|| { + index + .checked_sub(1) + .and_then(|previous| pane.tabs.get(previous)) + }) + .map(|tab| tab.panel.clone()); + } + + pane.tabs.is_empty() + }; + + if emptied { + self.detach_pane(pane_key); + if self.active_pane == pane_key { + self.active_pane = self.first_pane_key().unwrap_or(0); + } + } + + cx.notify(); + } + + /// Closes every other tab in the panel's pane, keeping this one active. + pub fn close_others(&mut self, id: &str, cx: &mut Context) { + let Some((pane_key, _)) = self.locate(id) else { + return; + }; + + let changed = { + let Some(pane) = self.pane_mut(pane_key) else { + return; + }; + + let before = pane.tabs.len(); + pane.tabs.retain(|tab| tab.panel.as_ref() == id); + pane.active = Some(SharedString::from(id)); + pane.tabs.len() != before + }; + + if changed { + cx.notify(); + } + } + + /// Closes every tab in the panel's pane; a non-root pane collapses. + pub fn close_pane(&mut self, id: &str, cx: &mut Context) { + let Some((pane_key, _)) = self.locate(id) else { + return; + }; + + let was_empty = self + .pane_mut(pane_key) + .is_none_or(|pane| pane.tabs.is_empty()); + + if was_empty { + return; + } + + if let Some(pane) = self.pane_mut(pane_key) { + pane.tabs.clear(); + pane.active = None; + } + + self.detach_pane(pane_key); + if self.active_pane == pane_key { + self.active_pane = self.first_pane_key().unwrap_or(0); + } + + cx.notify(); + } + + /// Moves a panel's tab into a new pane on `direction`'s side of its pane. + pub fn split_panel(&mut self, id: &str, direction: SplitDirection, cx: &mut Context) { + self.split_panel_at(id, id, direction, cx); + } + + /// Moves a panel's tab into a new pane on `direction`. + pub fn split_panel_at( + &mut self, + id: &str, + anchor: &str, + direction: SplitDirection, + cx: &mut Context, + ) { + let (Some((source_key, index)), Some((target_key, _))) = + (self.locate(id), self.locate(anchor)) + else { + return; + }; + + let tab = { + let Some(pane) = self.pane_mut(source_key) else { + return; + }; + + let tab = pane.tabs.remove(index); + if pane.active.as_deref() == Some(id) { + pane.active = pane + .tabs + .get(index) + .or_else(|| { + index + .checked_sub(1) + .and_then(|previous| pane.tabs.get(previous)) + }) + .map(|remaining| remaining.panel.clone()); + } + + tab + }; + + let emptied = self + .pane_mut(source_key) + .is_none_or(|pane| pane.tabs.is_empty()); + + let new_key = self.next_key; + let split_key = self.next_key + 1; + self.next_key += 2; + + let new_pane = Node::Pane(Pane { + key: new_key, + tabs: vec![tab], + active: Some(SharedString::from(id)), + }); + + Self::wrap_in(&mut self.root, target_key, new_pane, direction, split_key); + + if emptied { + self.detach_pane(source_key); + } + + self.active_pane = new_key; + cx.notify(); + } + + /// Moves a panel's tab into the pane holding `anchor`, appended last. + pub fn move_panel(&mut self, id: &str, anchor: &str, cx: &mut Context) { + let (Some((source_key, index)), Some((target_key, _))) = + (self.locate(id), self.locate(anchor)) + else { + return; + }; + + if source_key == target_key { + return; + } + + let tab = { + let Some(pane) = self.pane_mut(source_key) else { + return; + }; + let tab = pane.tabs.remove(index); + + if pane.active.as_deref() == Some(id) { + pane.active = pane + .tabs + .get(index) + .or_else(|| { + index + .checked_sub(1) + .and_then(|previous| pane.tabs.get(previous)) + }) + .map(|remaining| remaining.panel.clone()); + } + tab + }; + + if let Some(pane) = self.pane_mut(target_key) { + pane.tabs.push(tab); + pane.active = Some(SharedString::from(id)); + } + + let emptied = self + .pane_mut(source_key) + .is_none_or(|pane| pane.tabs.is_empty()); + + if emptied { + self.detach_pane(source_key); + if self.active_pane == source_key { + self.active_pane = self.first_pane_key().unwrap_or(0); + } + } + + self.active_pane = target_key; + cx.notify(); + } + + /// The active tab of the pane new panels open into. + pub fn active_panel(&self) -> Option { + let pane = pane_in(&self.root, self.target_pane_key())?; + pane.active.clone() + } + + /// Whether a panel's tab is open anywhere in the tree. + pub fn is_open(&self, id: &str) -> bool { + self.locate(id).is_some() + } + + fn locate(&self, id: &str) -> Option<(u64, usize)> { + Self::locate_in(&self.root, id) + } + + fn locate_in(node: &Node, id: &str) -> Option<(u64, usize)> { + match node { + Node::Pane(pane) => pane + .tabs + .iter() + .position(|tab| tab.panel.as_ref() == id) + .map(|index| (pane.key, index)), + Node::Split(split) => { + Self::locate_in(&split.a, id).or_else(|| Self::locate_in(&split.b, id)) + } + } + } + + fn target_pane_key(&self) -> u64 { + if pane_in(&self.root, self.active_pane).is_some() { + self.active_pane + } else { + self.first_pane_key().unwrap_or(0) + } + } + + fn first_pane_key(&self) -> Option { + Self::first_pane_in(&self.root) + } + + fn first_pane_in(node: &Node) -> Option { + match node { + Node::Pane(pane) => Some(pane.key), + Node::Split(split) => { + Self::first_pane_in(&split.a).or_else(|| Self::first_pane_in(&split.b)) + } + } + } + + fn pane_mut(&mut self, key: u64) -> Option<&mut Pane> { + Self::pane_mut_in(&mut self.root, key) + } + + fn pane_mut_in(node: &mut Node, key: u64) -> Option<&mut Pane> { + match node { + Node::Pane(pane) => (pane.key == key).then_some(pane), + Node::Split(split) => Self::pane_mut_in(&mut split.a, key) + .or_else(|| Self::pane_mut_in(&mut split.b, key)), + } + } + + fn split_mut(node: &mut Node, key: u64) -> Option<&mut Split> { + let Node::Split(split) = node else { + return None; + }; + if split.key == key { + return Some(split); + } + Self::split_mut(&mut split.a, key).or_else(|| Self::split_mut(&mut split.b, key)) + } + + /// Nudges a split's ratio by `fraction`, clamped at both ends. + fn resize_split(&mut self, key: u64, fraction: f32, cx: &mut Context) { + let Some(split) = Self::split_mut(&mut self.root, key) else { + return; + }; + + let ratio = (split.ratio + fraction).clamp(MIN_SPLIT_RATIO, 1.0 - MIN_SPLIT_RATIO); + + if (split.ratio - ratio).abs() > f32::EPSILON { + split.ratio = ratio; + cx.notify(); + } + } + + /// Restores a split to its default ratio. + fn reset_split(&mut self, key: u64, cx: &mut Context) { + let Some(split) = Self::split_mut(&mut self.root, key) else { + return; + }; + + if (split.ratio - DEFAULT_SPLIT_RATIO).abs() > f32::EPSILON { + split.ratio = DEFAULT_SPLIT_RATIO; + cx.notify(); + } + } + + /// Replaces a pane with a split holding it and `new_pane`. + fn wrap_in( + node: &mut Node, + key: u64, + new_pane: Node, + direction: SplitDirection, + split_key: u64, + ) { + match node { + Node::Pane(pane) if pane.key == key => { + let old_pane = take_node(node); + let split = if direction.leads() { + Split { + key: split_key, + axis: direction.axis(), + ratio: DEFAULT_SPLIT_RATIO, + a: Box::new(new_pane), + b: Box::new(old_pane), + } + } else { + Split { + key: split_key, + axis: direction.axis(), + ratio: DEFAULT_SPLIT_RATIO, + a: Box::new(old_pane), + b: Box::new(new_pane), + } + }; + *node = Node::Split(split); + } + Node::Pane(_) => {} + Node::Split(split) => { + if pane_in(&split.a, key).is_some() { + Self::wrap_in(&mut split.a, key, new_pane, direction, split_key); + } else if pane_in(&split.b, key).is_some() { + Self::wrap_in(&mut split.b, key, new_pane, direction, split_key); + } + } + } + } + + /// Removes a pane and replaces its parent split with the sibling subtree. + fn detach_pane(&mut self, key: u64) { + Self::detach_in(&mut self.root, key); + } + + fn detach_in(node: &mut Node, key: u64) { + let Node::Split(split) = node else { + return; + }; + + let a_direct = direct_pane_key(&split.a) == Some(key); + let b_direct = direct_pane_key(&split.b) == Some(key); + + if a_direct != b_direct { + let survivor = if a_direct { + take_node(&mut split.b) + } else { + take_node(&mut split.a) + }; + *node = survivor; + } else { + Self::detach_in(&mut split.a, key); + Self::detach_in(&mut split.b, key); + } + } + + /// The tree the dock renders from, one frame at a time. + fn snapshot(&self) -> TreeView { + fn snap(node: &Node) -> TreeView { + match node { + Node::Pane(pane) => TreeView::Pane(PaneView { + key: pane.key, + tabs: pane + .tabs + .iter() + .map(|tab| TabView { + panel: tab.panel.clone(), + title: tab.title.clone(), + icon: tab.icon, + view: tab.view.clone(), + }) + .collect(), + active: pane.active.clone(), + }), + Node::Split(split) => TreeView::Split(SplitView { + key: split.key, + axis: split.axis, + ratio: split.ratio, + a: Box::new(snap(&split.a)), + b: Box::new(snap(&split.b)), + }), + } + } + snap(&self.root) + } +} + +/// Finds a pane by key anywhere in the tree. +fn pane_in(node: &Node, key: u64) -> Option<&Pane> { + match node { + Node::Pane(pane) => (pane.key == key).then_some(pane), + Node::Split(split) => pane_in(&split.a, key).or_else(|| pane_in(&split.b, key)), + } +} + +/// The pane key of a node that is a pane, for seam-level tree edits. +fn direct_pane_key(node: &Node) -> Option { + match node { + Node::Pane(pane) => Some(pane.key), + Node::Split(_) => None, + } +} + +/// Takes a node out, leaving a placeholder that is never read again. +fn take_node(node: &mut Node) -> Node { + std::mem::replace( + node, + Node::Pane(Pane { + key: u64::MAX, + tabs: Vec::new(), + active: None, + }), + ) +} + +/// One open panel view, per frame. +#[derive(Clone)] +struct TabView { + panel: SharedString, + title: SharedString, + icon: Option, + view: AnyView, +} + +#[derive(Clone)] +struct PaneView { + key: u64, + tabs: Vec, + active: Option, +} + +struct SplitView { + key: u64, + axis: Axis, + ratio: f32, + a: Box, + b: Box, +} + +enum TreeView { + Pane(PaneView), + Split(SplitView), +} + +/// A dock: renders the pane tree of a [`DockState`]. +#[derive(IntoElement)] +pub struct Dock { + id: ElementId, + state: Entity, +} + +impl Dock { + /// Creates a dock bound to `state`. + pub fn new(id: I, state: Entity) -> Self + where + I: Into, + { + Self { + id: id.into(), + state, + } + } +} + +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); + + div() + .id(self.id.clone()) + .debug_selector({ + let id = self.id.clone(); + move || format!("dock-{id}") + }) + .size_full() + .flex() + .flex_col() + .overflow_hidden() + .child(root) + } +} + +fn render_node( + dock_id: &ElementId, + state: &Entity, + node: &TreeView, + 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), + } +} + +fn render_split( + dock_id: &ElementId, + state: &Entity, + split: &SplitView, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let span_state = window.with_element_namespace(dock_id.clone(), |window| { + window + .use_keyed_state(("split-span", split.key), cx, |_, _| size(px(0.), px(0.))) + .clone() + }); + + let seam = ResizeHandle::new(SharedString::from(format!("{dock_id}-seam-{}", split.key))) + .axis(split.axis) + .on_resize({ + let span_state = span_state.clone(); + let state = state.clone(); + let axis = split.axis; + let key = split.key; + move |delta, _, cx| { + let span = match axis { + Axis::Horizontal => span_state.read(cx).width, + Axis::Vertical => span_state.read(cx).height, + }; + if span > px(0.) { + let fraction = delta / span; + state.update(cx, |dock, cx| dock.resize_split(key, fraction, cx)); + } + } + }) + .on_reset({ + let state = state.clone(); + let key = split.key; + move |_, cx| state.update(cx, |dock, cx| dock.reset_split(key, cx)) + }); + + div() + .id(SharedString::from(format!("{dock_id}-split-{}", split.key))) + .size_full() + .min_w_0() + .min_h_0() + .overflow_hidden() + .flex() + .when(split.axis == Axis::Horizontal, |split_el| { + split_el.flex_row() + }) + .when(split.axis == Axis::Vertical, |split_el| split_el.flex_col()) + .child( + canvas(|bounds, _, _| bounds.size, { + let span_state = span_state.clone(); + move |_, span, _, cx| span_state.update(cx, |slot, _| *slot = span) + }) + .absolute() + .inset_0(), + ) + .child( + div() + .flex_grow(split.ratio) + .flex_basis(relative(0.)) + .min_w_0() + .min_h_0() + .overflow_hidden() + .child(render_node(dock_id, state, &split.a, window, cx)), + ) + .child(seam) + .child( + div() + .flex_grow(1.0 - split.ratio) + .flex_basis(relative(0.)) + .min_w_0() + .min_h_0() + .overflow_hidden() + .child(render_node(dock_id, state, &split.b, window, cx)), + ) + .into_any_element() +} + +fn render_pane( + dock_id: &ElementId, + state: &Entity, + pane: &PaneView, + cx: &App, +) -> AnyElement { + if pane.tabs.is_empty() { + return div().size_full().into_any_element(); + } + + let theme = cx.theme(); + let metrics = theme.sizes.resolve(Size::default()); + let pane_id = SharedString::from(format!("{dock_id}-pane-{}", pane.key)); + + let active_view = pane + .tabs + .iter() + .find(|tab| Some(&tab.panel) == pane.active.as_ref()) + .map(|tab| tab.view.clone()); + + // The cache keeps an un-notified panel from re-rendering on dock frames. + let content = div() + .id(SharedString::from(format!( + "{dock_id}-content-{}", + pane.key + ))) + .debug_selector({ + let id = SharedString::from(format!("{dock_id}-content-{}", pane.key)); + move || id.to_string() + }) + .flex_1() + .min_h_0() + .overflow_hidden() + .role(Role::TabPanel) + .when_some(active_view, |content, view| { + content.child(view.cached(StyleRefinement::default().absolute().size_full())) + }) + .when(cx.has_active_drag(), |content| { + content.child(render_drop_zones(state, pane)) + }); + + let menu = ContextMenu::new(SharedString::from(format!( + "{dock_id}-pane-{}-menu", + pane.key + ))) + .target({ + let dock_id = dock_id.clone(); + let state = state.clone(); + let pane = pane.clone(); + let strip_height = metrics.strip_height; + let padding_x = metrics.dense_padding_x; + move |_, _| { + let selected = pane + .active + .as_ref() + .and_then(|active| pane.tabs.iter().position(|tab| &tab.panel == active)) + .unwrap_or(0); + let tabs = Tabs::new(SharedString::from(format!( + "{dock_id}-pane-{}-tabs", + pane.key + ))) + .variant(TabsVariant::Pill) + .selected(selected) + .tabs({ + let tabs = pane.tabs.clone(); + move |_, _| { + tabs.iter() + .map(|tab| { + let mut entry = Tab::new(tab.title.clone()); + if let Some(role) = tab.icon { + entry = entry.icon(role); + } + entry.closeable(true).wrap({ + let panel = tab.panel.clone(); + let title = tab.title.clone(); + move |tab| { + tab.on_drag( + PanelDrag { + panel: panel.clone(), + }, + { + let title = title.clone(); + move |_, _, _, cx| { + cx.new(|_| DragPreview { + title: title.clone(), + }) + } + }, + ) + .into_any_element() + } + }) + }) + .collect() + } + }) + .on_change({ + let state = state.clone(); + let panels: Vec = + pane.tabs.iter().map(|tab| tab.panel.clone()).collect(); + move |index, _, cx| { + if let Some(panel) = panels.get(index) { + state.update(cx, |dock, cx| dock.activate_panel(panel, cx)); + } + } + }) + .on_close({ + let state = state.clone(); + let panels: Vec = + pane.tabs.iter().map(|tab| tab.panel.clone()).collect(); + move |index, _, cx| { + if let Some(panel) = panels.get(index) { + state.update(cx, |dock, cx| dock.close_panel(panel, cx)); + } + } + }); + + // Chrome-less like the future titlebar: the pills carry the look. + h_flex() + .flex_none() + .h(strip_height) + .w_full() + .items_center() + .pr(padding_x) + .child(tabs) + .child(div().flex_1()) + .into_any_element() + } + }) + .items({ + let state = state.clone(); + let active = pane.active.clone(); + let panels: Vec = pane.tabs.iter().map(|tab| tab.panel.clone()).collect(); + move |_, _| { + let mut items = Vec::new(); + if let Some(active) = &active { + items.push(MenuItem::new("Close").icon(IconRole::Close).handler({ + let state = state.clone(); + let active = active.clone(); + move |_, cx| state.update(cx, |dock, cx| dock.close_panel(&active, cx)) + })); + if panels.len() > 1 { + items.push(MenuItem::new("Close Others").handler({ + let state = state.clone(); + let active = active.clone(); + move |_, cx| state.update(cx, |dock, cx| dock.close_others(&active, cx)) + })); + } + } + if let Some(first) = panels.first() { + items.push(MenuItem::new("Close Pane").handler({ + let state = state.clone(); + let first = first.clone(); + move |_, cx| state.update(cx, |dock, cx| dock.close_pane(&first, cx)) + })); + } + items + } + }); + + v_flex() + .id(pane_id.clone()) + .debug_selector({ + let id = pane_id.clone(); + move || id.to_string() + }) + .size_full() + .overflow_hidden() + .child(menu) + .child(content) + .into_any_element() +} + +/// The five drop targets a live tab drag sees over a pane's content: +/// +/// - Four edge bands that split +/// - The center that moves the tab in. +fn render_drop_zones(state: &Entity, pane: &PaneView) -> AnyElement { + let Some(anchor) = pane + .active + .clone() + .or_else(|| pane.tabs.first().map(|tab| tab.panel.clone())) + else { + return div().into_any_element(); + }; + + div() + .absolute() + .inset_0() + .child( + drop_zone(state, &anchor, Some(SplitDirection::Left)) + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(relative(0.25)), + ) + .child( + drop_zone(state, &anchor, Some(SplitDirection::Right)) + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(relative(0.25)), + ) + .child( + drop_zone(state, &anchor, Some(SplitDirection::Top)) + .absolute() + .top_0() + .left(relative(0.25)) + .right(relative(0.25)) + .h(relative(0.25)), + ) + .child( + drop_zone(state, &anchor, Some(SplitDirection::Bottom)) + .absolute() + .bottom_0() + .left(relative(0.25)) + .right(relative(0.25)) + .h(relative(0.25)), + ) + .child( + drop_zone(state, &anchor, None) + .absolute() + .inset(relative(0.25)), + ) + .into_any_element() +} + +/// Renders a drop zone for panel dragging. +fn drop_zone( + state: &Entity, + anchor: &str, + direction: Option, +) -> gpui::Div { + let state = state.clone(); + let anchor = SharedString::from(anchor); + div() + .drag_over::(|zone, _, _, cx| zone.bg(cx.theme().colors.selection)) + .on_drop::(move |drag, _, cx| { + state.update(cx, |dock, cx| match direction { + Some(direction) => { + dock.split_panel_at(&drag.panel, &anchor, direction, cx); + } + None => dock.move_panel(&drag.panel, &anchor, cx), + }); + }) +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index acd53aa..34d5095 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1,5 +1,8 @@ //! Application layout: sidebars and a center dock bootstrapped for the app (plan §6.4). +#[cfg(feature = "dock")] +pub mod dock; + #[cfg(feature = "resize_handle")] pub mod resize_handle; diff --git a/crates/layout/tests/dock.rs b/crates/layout/tests/dock.rs new file mode 100644 index 0000000..39ea923 --- /dev/null +++ b/crates/layout/tests/dock.rs @@ -0,0 +1,422 @@ +use gpui::{ + AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, + TestAppContext, VisualTestContext, Window, div, point, px, +}; +use gpui_native_layout::dock::{Dock, DockState, PanelDescriptor, SplitDirection}; +use gpui_native_theme::Theme; + +struct Panel { + tag: &'static str, +} + +impl Render for Panel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .debug_selector(move || format!("panel-{}", self.tag)) + } +} + +fn descriptor(tag: &'static str) -> PanelDescriptor { + PanelDescriptor::new(tag, tag, move |_, cx| cx.new(|_| Panel { tag }).into()) +} + +struct Harness { + dock: Entity, +} + +impl Render for Harness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + div() + .size_full() + .child(Dock::new("test-dock", self.dock.clone())) + } +} + +fn dock_window(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, + }); + let mut visual = VisualTestContext::from_window(window.into(), cx); + redraw(&mut visual); + (dock, visual) +} + +fn redraw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +fn bounds(visual: &mut VisualTestContext, selector: &str) -> Option> { + visual.debug_bounds(Box::leak(selector.to_string().into_boxed_str())) +} + +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()); +} + +fn register(dock: &Entity, tags: &[&'static str], cx: &mut TestAppContext) { + cx.update(|cx| { + dock.update(cx, |dock, cx| { + for tag in tags { + dock.register_panel(descriptor(tag), cx); + } + }); + }); +} + +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)); + }); + redraw(visual); +} + +#[test] +fn opening_a_panel_adds_its_tab_and_activates_it() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + + open(&mut visual, &dock, "inbox"); + assert!( + bounds(&mut visual, "panel-inbox").is_some(), + "opened panel missing" + ); + assert!( + bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some(), + "tab missing" + ); + + open(&mut visual, &dock, "explore"); + assert!(bounds(&mut visual, "panel-explore").is_some()); + assert!( + bounds(&mut visual, "panel-inbox").is_none(), + "the inactive view must not render" + ); + assert!( + bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some(), + "the strip keeps both tabs" + ); + + assert!(dock.read_with(&cx, |dock, _| dock.is_open("inbox"))); + assert!(dock.read_with(&cx, |dock, _| dock.is_open("explore"))); +} + +#[test] +fn clicking_a_tab_activates_its_panel() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + + let tab = bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").expect("inbox tab missing"); + press(&mut visual, center_of(tab), 1); + redraw(&mut visual); + + assert!(bounds(&mut visual, "panel-inbox").is_some()); + assert!(bounds(&mut visual, "panel-explore").is_none()); +} + +#[test] +fn the_tab_close_control_closes_its_panel() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + + let close = bounds(&mut visual, "test-dock-pane-0-tabs-close-inbox").expect("close missing"); + press(&mut visual, center_of(close), 1); + redraw(&mut visual); + + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_none()); + assert!(bounds(&mut visual, "panel-explore").is_some()); +} + +#[test] +fn closing_the_active_tab_activates_the_neighbor() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + + cx.update(|cx| { + dock.update(cx, |dock, cx| dock.close_panel("explore", cx)); + }); + redraw(&mut visual); + + assert!( + bounds(&mut visual, "panel-inbox").is_some(), + "the neighbor tab should activate" + ); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").is_none()); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); +} + +#[test] +fn splitting_moves_the_tab_and_closing_collapses() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.split_panel("explore", SplitDirection::Right, cx) + }); + }); + redraw(&mut visual); + + assert!( + bounds(&mut visual, "resize-handle").is_some(), + "the split renders a seam" + ); + assert!(bounds(&mut visual, "test-dock-pane-0").is_some()); + assert!(bounds(&mut visual, "test-dock-pane-1").is_some()); + assert!( + bounds(&mut visual, "panel-explore").is_some(), + "the moved tab activates in its new pane" + ); + + // Closing the new pane's only tab collapses its split. + cx.update(|cx| { + dock.update(cx, |dock, cx| dock.close_panel("explore", cx)); + }); + redraw(&mut visual); + + assert!(bounds(&mut visual, "resize-handle").is_none()); + assert!(bounds(&mut visual, "test-dock-pane-1").is_none()); + assert!(bounds(&mut visual, "test-dock-pane-0").is_some()); + assert!(bounds(&mut visual, "panel-inbox").is_some()); +} + +#[test] +fn splitting_at_a_target_pane_places_the_new_pane_there() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.split_panel("explore", SplitDirection::Right, cx) + }); + }); + redraw(&mut visual); + + // Move inbox into a new pane left of explore's pane (pane 1), not beside + // inbox's own (pane 0, which empties and collapses). + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.split_panel_at("inbox", "explore", SplitDirection::Left, cx) + }); + }); + redraw(&mut visual); + + let moved = bounds(&mut visual, "test-dock-pane-3").expect("the new pane missing"); + let target = bounds(&mut visual, "test-dock-pane-1").expect("target pane missing"); + assert!( + moved.right() <= target.left(), + "the new pane must sit left of the anchored pane" + ); + assert!(bounds(&mut visual, "test-dock-pane-0").is_none()); +} + +#[test] +fn dragging_the_seam_resizes_and_double_click_resets() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.split_panel("explore", SplitDirection::Right, cx) + }); + }); + redraw(&mut visual); + + let before_0 = bounds(&mut visual, "test-dock-pane-0").expect("pane 0 missing"); + let before_1 = bounds(&mut visual, "test-dock-pane-1").expect("pane 1 missing"); + let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); + + drag( + &mut visual, + center_of(seam), + center_of(seam) + point(px(40.), px(0.)), + ); + let grown = bounds(&mut visual, "test-dock-pane-0").expect("pane 0 missing"); + let shrunk = bounds(&mut visual, "test-dock-pane-1").expect("pane 1 missing"); + assert!( + grown.size.width > before_0.size.width, + "the seam leads pane 0" + ); + assert!(shrunk.size.width < before_1.size.width, "pane 1 gives way"); + + // Double-clicking the seam restores the default ratio. + let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); + press(&mut visual, center_of(seam), 2); + redraw(&mut visual); + let reset_0 = bounds(&mut visual, "test-dock-pane-0").expect("pane 0 missing"); + let reset_1 = bounds(&mut visual, "test-dock-pane-1").expect("pane 1 missing"); + // The flex split may round an odd-pixel span by a hair. + assert!((reset_0.size.width - reset_1.size.width).abs() <= px(1.)); + + // A huge drag clamps instead of vanishing a side. + let seam = bounds(&mut visual, "resize-handle").expect("seam missing"); + drag( + &mut visual, + center_of(seam), + center_of(seam) + point(px(2000.), px(0.)), + ); + let clamped = bounds(&mut visual, "test-dock-pane-1").expect("clamped pane missing"); + assert!(clamped.size.width > px(0.)); +} + +#[test] +fn dragging_a_tab_to_a_pane_edge_splits_it() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + + // Drag explore's tab onto the right edge band of the pane's content. + let tab = bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").expect("tab missing"); + let content = bounds(&mut visual, "test-dock-content-0").expect("content missing"); + let drop_at = point( + content.right() - content.size.width * 0.125, + content.center().y, + ); + + visual.simulate_mouse_down(center_of(tab), MouseButton::Left, Modifiers::default()); + // Past the drag threshold, the drag starts and the drop zones mount. + visual.simulate_event(MouseMoveEvent { + position: center_of(tab) + point(px(8.), px(2.)), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + redraw(&mut visual); + visual.simulate_event(MouseMoveEvent { + position: drop_at, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_mouse_up(drop_at, MouseButton::Left, Modifiers::default()); + redraw(&mut visual); + + assert!( + bounds(&mut visual, "resize-handle").is_some(), + "the drop should have split the pane" + ); + assert!( + bounds(&mut visual, "test-dock-pane-1-tabs-tab-explore").is_some(), + "the dragged tab lives in the new pane" + ); + assert!(bounds(&mut visual, "panel-explore").is_some()); +} + +#[test] +fn moving_a_panel_merges_panes_and_collapses_the_source() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + cx.update(|cx| { + dock.update(cx, |dock, cx| { + dock.split_panel("explore", SplitDirection::Right, cx) + }); + }); + redraw(&mut visual); + + cx.update(|cx| { + dock.update(cx, |dock, cx| dock.move_panel("explore", "inbox", cx)); + }); + redraw(&mut visual); + + assert!( + bounds(&mut visual, "resize-handle").is_none(), + "the emptied source pane collapses" + ); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").is_some()); + assert!( + bounds(&mut visual, "panel-explore").is_some(), + "the moved tab activates in its new pane" + ); +} + +#[test] +fn close_others_keeps_one_and_close_pane_empties() { + let mut cx = TestAppContext::single(); + let (dock, mut visual) = dock_window(&mut cx); + register(&dock, &["inbox", "explore", "settings"], &mut cx); + open(&mut visual, &dock, "inbox"); + open(&mut visual, &dock, "explore"); + open(&mut visual, &dock, "settings"); + + cx.update(|cx| { + dock.update(cx, |dock, cx| dock.close_others("inbox", cx)); + }); + redraw(&mut visual); + + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-explore").is_none()); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-settings").is_none()); + assert!(bounds(&mut visual, "panel-inbox").is_some()); + + cx.update(|cx| { + dock.update(cx, |dock, cx| dock.close_pane("inbox", cx)); + }); + redraw(&mut visual); + + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_none()); + assert!( + bounds(&mut visual, "panel-inbox").is_none(), + "the emptied pane renders nothing" + ); + + // The dock is reusable: opening again fills the empty root pane. + open(&mut visual, &dock, "inbox"); + assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some()); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 6ae77b5..de43483 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -270,7 +270,7 @@ Wave M1 — each through the review gate: | M7 | input | Multiline textarea ✅ Landed (2026-11-01) | discussion composer, patch + commit messages | bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate. Shipped as a state mode — `InputState::set_multiline(bool)` — sharing the single-line machinery: shaping moved to gpui `shape_text`, which wraps to the field's inner width, and the paint layout gained a visual-row table (byte range + y per wrapped row) that the caret, selection, IME underline, hit-testing, triple-click and `bounds_for_range` all consume; `scroll_offset` became 2-D (x single-line, y multiline) with scroll-to-caret per axis. Height = the ambient line-height ratio × the element's `rows(usize)` (default 4); content taller than the field scrolls, and the field stops wheel propagation only while overflowing (min/max auto-grow deferred). Enter inserts a newline and modifier+Enter submits — `SubmitOn::ModifiedEnter` is the default, `SubmitOn::Enter` plus the `InsertNewline` shift-enter binding gives chat-style enter-submit — lifting §6.2's deferral. Up/down (+ shift variants) move between visual rows with a goal column; triple-click selects the clicked row; the sanitizer is mode-aware (multiline keeps newlines, drops `\r`; single-line unchanged), and the mask map gained per-grapheme display lengths so masked fields survive line breaks. No theme changes — all geometry derives from existing tokens. 7 headless tests join `tests/input.rs` | | M8 | core | Avatar image | profile pictures in rows and panel tabs | lifts the deferred image slot: gpui `img` + initials fallback (signed adds its pixel-art fallback app-side) | -Wave M2 — completes the surface: Field wrapper (M9: label/description/error around any control — the settings dialog; lifts another §6.2 deferral), Tag (M10: removable GRASP-server and relay entries), Status dot (M11: sync-drift beads on rows), Combobox (M12: type-to-filter pickers; gpuikit's `on_create`/`keep_unmatched` blur modes are worth copying), Command palette (M13: filtered actions — gpuikit keeps the matcher caller-owned, adopt that), VirtualList in `data` (M14: a thin `uniform_list` binding for commit histories and the inbox). +Wave M2 — completes the surface: Field wrapper (M9: label/description/error around any control — the settings dialog; lifts another §6.2 deferral), Tag (M10: removable GRASP-server and relay entries), Status dot (M11: sync-drift beads on rows), Combobox (M12: type-to-filter pickers; gpuikit's `on_create`/`keep_unmatched` blur modes are worth copying, adopt that), Command palette (M13: filtered actions — gpuikit keeps the matcher caller-owned, adopt that), VirtualList in `data` (M14: a thin `uniform_list` binding for commit histories and the inbox), and the Spinner (M18: the indeterminate/determinate loading ring — `Tab::busy`'s placeholder ring in the `Pill` tab waits on it, as do async buttons and refresh states; picked up next time a consumer lands, before signed wires repo loading into dock tabs). Wave M3 — content, where `crates/editor` starts (D12): code view with syntax highlighting (M15 — signed renders highlighted files with tree-sitter today), then the color-coded diff view (M16), then a Markdown view (M17) if a consumer confirms READMEs and issue bodies need one. @@ -278,7 +278,7 @@ Survey notes — worth adopting later, not now: bezel's hover card (profile prev ### 6.4 The layout crate (`crates/layout`) — application shell bootstrap -A new crate (D18: advanced crates are created on demand, not speculatively) that bootstraps the application layout the way zed's workspace and gpui-kit's dock do, with a deliberately smaller API (D26): a left sidebar (on by default), a right sidebar (off by default), and a center dock whose panes the user can drag and split. A panel can declare that it needs the right sidebar; activating it opens the sidebar automatically. [signed](https://git.reya.info/reya/signed)'s `crates/dock` — a gpui-component `DockArea` behind a custom skin — is the usage reference and the cautionary tale: its renderer seams, serialization placeholders, and zoom/collapse modes mark the complexity we skip. gpui-kit's dock and zed's editor dock are read as API-design references only (D15, D20). Facade feature `layout`; the crate depends on `core` and composes `data` (`Sidebar`) — at L3, decide whether `Tabs` gains optional close buttons + drag sources for pane headers or panes render their own header row. A `window_frame` module wraps a window root in the client-side frame the OS owes — border, `Radius::overlay` corners, dialog-elevation shadow, resize bands — under `Decorations::Client` and passes through under `Server` (adapted from bezel's `window::frame` and gpui-kit's `WindowBorder`, simplified; the window must open transparent). New `IconRole`s (panel-left/right toggles, split directions) land in the icons crate with the units that need them. Everything is theme-baked per §4.3 — no style parameters, no renderer/skin seam. +A new crate (D18: advanced crates are created on demand, not speculatively) that bootstraps the application layout the way zed's workspace and gpui-kit's dock do, with a deliberately smaller API (D26): a left sidebar (on by default), a right sidebar (off by default), and a center dock whose panes the user can drag and split. A panel can declare that it needs the right sidebar; activating it opens the sidebar automatically. [signed](https://git.reya.info/reya/signed)'s `crates/dock` — a gpui-component `DockArea` behind a custom skin — is the usage reference and the cautionary tale: its renderer seams, serialization placeholders, and zoom/collapse modes mark the complexity we skip. gpui-kit's dock and zed's editor dock are read as API-design references only (D15, D20). Facade feature `layout`; the crate depends on `core` and composes `data` — the L3 header decision, revised when L4 landed: pane headers compose the data crate's `Tabs` in its `Pill` variant (the signed dock tab — pills on a chrome-less bar: `[lead element][name][wide gap][togglable busy slot][close control]`; the bar carries no background or border because it doubles as the titlebar strip in L4.5), with `Tab::closeable` + `Tabs::on_close` for close controls, `Tab::lead` for avatar-or-custom leading elements, `Tab::busy` for the loading slot, and a `Tab::wrap` seam so the dock attaches its drag payload to the rendered tab; data's `Tabs` keeps the underline design as the default. A `window_frame` module wraps a window root in the client-side frame the OS owes — border, `Radius::overlay` corners, dialog-elevation shadow, resize bands — under `Decorations::Client` and passes through under `Server` (adapted from bezel's `window::frame` and gpui-kit's `WindowBorder`, simplified; the window must open transparent). New `IconRole`s (panel-left/right toggles, split directions) land in the icons crate with the units that need them. Everything is theme-baked per §4.3 — no style parameters, no renderer/skin seam. Units, each through the review gate: @@ -286,10 +286,10 @@ Units, each through the review gate: | --- | --- | --- | --- | | 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 | Shell ✅ 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; `shell` 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); `Sidebar` gained a `sidebar-{id}` debug selector for headless tests. 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. `shell` pulls only core's `button` + `data/sidebar`; the facade `layout` feature re-requests `full` (core/data workspace deps are now `default-features = false`). The shell always paints the window frame around itself (L2.5), so it must be the window's root. 5 headless tests in `crates/layout/tests/shell.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 | -| 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. | +| 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 becomes the dock's tab bar in L3 (in signed the tab bar doubles as the titlebar, 44 px, with 34 px-wide controls and a danger hover on close). Reference: signed's `crates/dock/src/window_controls.rs` | +| 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` | | 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. @@ -376,6 +376,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 ✅, titlebar + window controls, 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 ✅, 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 d2bdb6b..9e73aab 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -31,9 +31,10 @@ use gpui_native_core::toggle::Toggle; use gpui_native_core::tooltip::Tooltip; use gpui_native_data::list::{List, ListItem}; use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; -use gpui_native_data::tabs::{Tab, Tabs}; +use gpui_native_data::tabs::{Tab, Tabs, TabsVariant}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; +use gpui_native_layout::dock::{Dock, DockState, PanelDescriptor, SplitDirection}; use gpui_native_layout::resize_handle::ResizeHandle; use gpui_native_layout::shell::{LayoutState, Shell}; use gpui_native_theme::{ActiveTheme as _, Size, Theme}; @@ -51,6 +52,7 @@ struct Gallery { notes_state: Entity, split_width: Pixels, layout_state: Entity, + dock_state: Entity, shell_selection: Option, popover_open: bool, menu_open: bool, @@ -67,6 +69,8 @@ struct Gallery { tree_nodes: Vec, tree_selected: Option, tab_selected: usize, + card_tabs: Vec, + card_tab_selected: usize, sidebar_selected: Option, } @@ -202,19 +206,51 @@ impl Gallery { .text_sm() .text_color(colors.muted_foreground); + let open_panel = |dock: &Entity, id: &'static str, label: &'static str| { + let dock = dock.clone(); + Button::new(SharedString::from(format!("gallery-dock-open-{id}"))) + .label(label) + .variant(ButtonVariant::Secondary) + .small() + .on_click(move |_, window, cx| { + dock.update(cx, |dock, cx| dock.open_panel(id, window, cx)); + }) + }; + let center = v_flex() - .gap_2() - .items_center() - .justify_center() .size_full() + .gap_2() .child( h_flex() + .flex_none() + .flex_wrap() .gap_2() .child(toggle("left")) - .child(toggle("right")), + .child(toggle("right")) + .child(open_panel(&self.dock_state, "inbox", "Open inbox")) + .child(open_panel(&self.dock_state, "settings", "Open settings")) + .child({ + let dock = self.dock_state.clone(); + Button::new("gallery-dock-split") + .label("Split active right") + .variant(ButtonVariant::Secondary) + .small() + .on_click(move |_, _, cx| { + dock.update(cx, |dock, cx| { + if let Some(active) = dock.active_panel() { + dock.split_panel(&active, SplitDirection::Right, cx); + } + }); + }) + }), ) - .child("Center \u{2014} the dock lands here (L3)") - .text_color(colors.muted_foreground); + .child( + div() + .flex_1() + .min_h_0() + .overflow_hidden() + .child(Dock::new("gallery-dock", self.dock_state.clone())), + ); Shell::new("gallery-shell", state) .left_sidebar(left_sidebar) @@ -223,6 +259,30 @@ impl Gallery { } } +/// A stand-in panel view for the dock demo. +struct DemoPanel { + title: &'static str, + body: &'static str, +} + +impl Render for DemoPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + v_flex() + .size_full() + .gap_1() + .p_4() + .bg(colors.background) + .child(div().child(self.title)) + .child( + div() + .text_sm() + .text_color(colors.muted_foreground) + .child(self.body), + ) + } +} + impl Render for Gallery { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); @@ -548,7 +608,7 @@ impl Render for Gallery { .text_xs() .text_color(colors.muted_foreground) .child(format!( - "left: {} · right: {} \u{2014} buttons toggle, seams resize", + "left: {} · right: {} · dock: {} \u{2014} buttons toggle, seams resize, tabs close and split", if self.layout_state.read(cx).is_left_sidebar_visible() { "on" } else { @@ -559,6 +619,11 @@ impl Render for Gallery { } else { "off" }, + if self.dock_state.read(cx).active_panel().is_some() { + "open" + } else { + "empty" + }, )), ), ) @@ -957,7 +1022,53 @@ impl Render for Gallery { _ => div() .child("Settings: keyboard focus rides the strip; arrows switch tabs.") .into_any_element(), - }), + }) + .child({ + let gallery = gallery.clone(); + let gallery_for_close = gallery.clone(); + Tabs::new("gallery-pill-tabs") + .variant(TabsVariant::Pill) + .selected(self.card_tab_selected) + .on_change(move |selected, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.card_tab_selected = selected; + cx.notify(); + }); + }) + .on_close(move |index, _, cx| { + gallery_for_close.update(cx, |gallery, cx| { + if index < gallery.card_tabs.len() { + gallery.card_tabs.remove(index); + gallery.card_tab_selected = gallery + .card_tab_selected + .min(gallery.card_tabs.len().saturating_sub(1)); + cx.notify(); + } + }); + }) + .tabs({ + let labels = self.card_tabs.clone(); + move |_, _| { + labels + .iter() + .map(|label| { + Tab::new(label.clone()) + .closeable(true) + .busy(label == "Syncing") + }) + .collect() + } + }) + }) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!( + "pill variant (the signed dock tab) — {} tabs left; the ring on \"Syncing\" is the Spinner placeholder", + self.card_tabs.len() + )), + ), ) .child( v_flex() @@ -1653,6 +1764,39 @@ fn main() { let notes_state = InputState::new(cx); notes_state.update(cx, |state, cx| state.set_multiline(true, cx)); let layout_state = LayoutState::new(cx); + let dock_state = DockState::new(cx); + dock_state.update(cx, |dock, cx| { + dock.register_panel( + PanelDescriptor::new("overview", "Overview", |_, cx| { + cx.new(|_| DemoPanel { + title: "Overview", + body: "View state survives tab switches and pane moves.", + }) + .into() + }), + cx, + ); + dock.register_panel( + PanelDescriptor::new("inbox", "Inbox", |_, cx| { + cx.new(|_| DemoPanel { + title: "Inbox", + body: "Right-click the tab bar for Close, Close Others, Close Pane.", + }) + .into() + }), + cx, + ); + dock.register_panel( + PanelDescriptor::new("settings", "Settings", |_, cx| { + cx.new(|_| DemoPanel { + title: "Settings", + body: "Drag the seam between panes; double-click it to reset.", + }) + .into() + }), + cx, + ); + }); let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx); if let Err(err) = cx.open_window( WindowOptions { @@ -1674,6 +1818,7 @@ fn main() { cx.refresh_windows(); }) .detach(); + dock_state.update(cx, |dock, cx| dock.open_panel("overview", window, cx)); let toasts = cx.new(|_| Toasts::default()); let alerts = cx.new(|_| Alerts::default()); cx.new(|cx| { @@ -1693,6 +1838,7 @@ fn main() { notes_state, split_width: px(200.), layout_state, + dock_state, shell_selection: None, popover_open: false, menu_open: false, @@ -1708,6 +1854,12 @@ fn main() { list_selected: None, tree_selected: None, tab_selected: 0, + card_tabs: vec![ + SharedString::from("Explorer"), + SharedString::from("Syncing"), + SharedString::from("Blame"), + ], + card_tab_selected: 0, sidebar_selected: None, tree_nodes: vec![ GalleryNode {