add right sidebar auto-open to dock panels
This commit is contained in:
+111
-3
@@ -2,6 +2,7 @@
|
|||||||
//! registered panels, split two ways, collapsing as tabs close.
|
//! registered panels, split two ways, collapsing as tabs close.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -27,6 +28,8 @@ const DEFAULT_SPLIT_RATIO: f32 = 0.5;
|
|||||||
|
|
||||||
/// Builds the view a panel opens as, once per open.
|
/// Builds the view a panel opens as, once per open.
|
||||||
type PanelBuilder = Box<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
type PanelBuilder = Box<dyn Fn(&mut Window, &mut App) -> AnyView>;
|
||||||
|
/// Runs when a panel with a right sidebar activates, so the app can open it.
|
||||||
|
type OpenRightSidebarHandler = Rc<dyn Fn(&mut App)>;
|
||||||
|
|
||||||
/// A registered panel: its identity, chrome, and how its view is built.
|
/// A registered panel: its identity, chrome, and how its view is built.
|
||||||
pub struct PanelDescriptor {
|
pub struct PanelDescriptor {
|
||||||
@@ -34,6 +37,7 @@ pub struct PanelDescriptor {
|
|||||||
title: SharedString,
|
title: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<IconRole>,
|
||||||
build: PanelBuilder,
|
build: PanelBuilder,
|
||||||
|
right_sidebar: Option<PanelBuilder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PanelDescriptor {
|
impl PanelDescriptor {
|
||||||
@@ -49,6 +53,7 @@ impl PanelDescriptor {
|
|||||||
title: title.into(),
|
title: title.into(),
|
||||||
icon: None,
|
icon: None,
|
||||||
build: Box::new(build),
|
build: Box::new(build),
|
||||||
|
right_sidebar: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +62,17 @@ impl PanelDescriptor {
|
|||||||
self.icon = Some(role);
|
self.icon = Some(role);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the view the right sidebar shows while this panel is active.
|
||||||
|
///
|
||||||
|
/// Activating the panel opens the right sidebar.
|
||||||
|
pub fn right_sidebar<F>(mut self, build: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||||
|
{
|
||||||
|
self.right_sidebar = Some(Box::new(build));
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which side of its pane a split places the new pane on.
|
/// Which side of its pane a split places the new pane on.
|
||||||
@@ -116,6 +132,7 @@ struct PanelTab {
|
|||||||
title: SharedString,
|
title: SharedString,
|
||||||
icon: Option<IconRole>,
|
icon: Option<IconRole>,
|
||||||
view: AnyView,
|
view: AnyView,
|
||||||
|
right_view: Option<AnyView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A leaf: the tabs of one region and which is active.
|
/// A leaf: the tabs of one region and which is active.
|
||||||
@@ -146,6 +163,10 @@ pub struct DockState {
|
|||||||
/// The pane new panels open into, kept on the last activated pane.
|
/// The pane new panels open into, kept on the last activated pane.
|
||||||
active_pane: u64,
|
active_pane: u64,
|
||||||
next_key: u64,
|
next_key: u64,
|
||||||
|
/// The right sidebar view on display, retained from its panel.
|
||||||
|
right_view: Option<AnyView>,
|
||||||
|
/// Runs when a panel with a right sidebar activates, so the app can open it.
|
||||||
|
on_open_right_sidebar: Option<OpenRightSidebarHandler>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DockState {
|
impl DockState {
|
||||||
@@ -160,9 +181,16 @@ impl DockState {
|
|||||||
panels: HashMap::new(),
|
panels: HashMap::new(),
|
||||||
active_pane: 0,
|
active_pane: 0,
|
||||||
next_key: 1,
|
next_key: 1,
|
||||||
|
right_view: None,
|
||||||
|
on_open_right_sidebar: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs when a panel with a right sidebar activates.
|
||||||
|
pub fn on_open_right_sidebar(&mut self, handler: impl Fn(&mut App) + 'static) {
|
||||||
|
self.on_open_right_sidebar = Some(Rc::new(handler));
|
||||||
|
}
|
||||||
|
|
||||||
/// Registers a panel descriptor, replacing any earlier one with its id.
|
/// Registers a panel descriptor, replacing any earlier one with its id.
|
||||||
pub fn register_panel(&mut self, panel: PanelDescriptor, cx: &mut Context<Self>) {
|
pub fn register_panel(&mut self, panel: PanelDescriptor, cx: &mut Context<Self>) {
|
||||||
if self.panels.insert(panel.id.clone(), panel).is_none() {
|
if self.panels.insert(panel.id.clone(), panel).is_none() {
|
||||||
@@ -181,11 +209,17 @@ impl DockState {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let right_view = panel
|
||||||
|
.right_sidebar
|
||||||
|
.as_ref()
|
||||||
|
.map(|build| (build)(window, cx));
|
||||||
|
|
||||||
let tab = PanelTab {
|
let tab = PanelTab {
|
||||||
panel: SharedString::from(id),
|
panel: SharedString::from(id),
|
||||||
title: panel.title.clone(),
|
title: panel.title.clone(),
|
||||||
icon: panel.icon,
|
icon: panel.icon,
|
||||||
view: (panel.build)(window, cx),
|
view: (panel.build)(window, cx),
|
||||||
|
right_view,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pane_key = self.target_pane_key();
|
let pane_key = self.target_pane_key();
|
||||||
@@ -196,9 +230,22 @@ impl DockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.active_pane = pane_key;
|
self.active_pane = pane_key;
|
||||||
|
self.show_right_view_of(id, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points the right sidebar at the panel's own view and asks the app to open it.
|
||||||
|
fn show_right_view_of(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||||
|
let right_view = tab_in(&self.root, id).and_then(|tab| tab.right_view.clone());
|
||||||
|
let Some(right_view) = right_view else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.right_view = Some(right_view);
|
||||||
|
if let Some(handler) = self.on_open_right_sidebar.clone() {
|
||||||
|
(handler)(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Activates a panel's tab and makes its pane the active pane.
|
/// Activates a panel's tab and makes its pane the active pane.
|
||||||
pub fn activate_panel(&mut self, id: &str, cx: &mut Context<Self>) {
|
pub fn activate_panel(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||||
let Some((pane_key, _)) = self.locate(id) else {
|
let Some((pane_key, _)) = self.locate(id) else {
|
||||||
@@ -216,6 +263,7 @@ impl DockState {
|
|||||||
};
|
};
|
||||||
self.active_pane = pane_key;
|
self.active_pane = pane_key;
|
||||||
if changed {
|
if changed {
|
||||||
|
self.show_right_view_of(id, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,13 +274,13 @@ impl DockState {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let emptied = {
|
let (closed_right_view, emptied) = {
|
||||||
let Some(pane) = self.pane_mut(pane_key) else {
|
let Some(pane) = self.pane_mut(pane_key) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let was_active = pane.active.as_deref() == Some(id);
|
let was_active = pane.active.as_deref() == Some(id);
|
||||||
pane.tabs.remove(index);
|
let closed = pane.tabs.remove(index);
|
||||||
|
|
||||||
if was_active {
|
if was_active {
|
||||||
pane.active = pane
|
pane.active = pane
|
||||||
@@ -246,9 +294,14 @@ impl DockState {
|
|||||||
.map(|tab| tab.panel.clone());
|
.map(|tab| tab.panel.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
pane.tabs.is_empty()
|
(closed.right_view, pane.tabs.is_empty())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The sidebar showed the closed panel's view.
|
||||||
|
if self.right_view.as_ref() == closed_right_view.as_ref() {
|
||||||
|
self.right_view = first_right_view(&self.root);
|
||||||
|
}
|
||||||
|
|
||||||
if emptied {
|
if emptied {
|
||||||
self.detach_pane(pane_key);
|
self.detach_pane(pane_key);
|
||||||
if self.active_pane == pane_key {
|
if self.active_pane == pane_key {
|
||||||
@@ -627,6 +680,22 @@ fn pane_in(node: &Node, key: u64) -> Option<&Pane> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds an open tab by panel id, anywhere in the tree.
|
||||||
|
fn tab_in<'a>(node: &'a Node, id: &str) -> Option<&'a PanelTab> {
|
||||||
|
match node {
|
||||||
|
Node::Pane(pane) => pane.tabs.iter().find(|tab| tab.panel.as_ref() == id),
|
||||||
|
Node::Split(split) => tab_in(&split.a, id).or_else(|| tab_in(&split.b, id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first open tab's right sidebar view, in display order.
|
||||||
|
fn first_right_view(node: &Node) -> Option<AnyView> {
|
||||||
|
match node {
|
||||||
|
Node::Pane(pane) => pane.tabs.iter().find_map(|tab| tab.right_view.clone()),
|
||||||
|
Node::Split(split) => first_right_view(&split.a).or_else(|| first_right_view(&split.b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The pane key of a node that is a pane, for seam-level tree edits.
|
/// The pane key of a node that is a pane, for seam-level tree edits.
|
||||||
fn direct_pane_key(node: &Node) -> Option<u64> {
|
fn direct_pane_key(node: &Node) -> Option<u64> {
|
||||||
match node {
|
match node {
|
||||||
@@ -744,6 +813,45 @@ struct Titlebar {
|
|||||||
traffic_light_pad: bool,
|
traffic_light_pad: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The right sidebar content of the dock's active panel.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct DockSidebar {
|
||||||
|
id: ElementId,
|
||||||
|
state: Entity<DockState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DockSidebar {
|
||||||
|
/// Creates the sidebar content bound to `state`.
|
||||||
|
pub fn new<I>(id: I, state: Entity<DockState>) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for DockSidebar {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
// The cache keeps an un-notified sidebar from re-rendering on frames.
|
||||||
|
let right_view = self.state.read(cx).right_view.clone();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.debug_selector({
|
||||||
|
let id = self.id.clone();
|
||||||
|
move || format!("{id}-view")
|
||||||
|
})
|
||||||
|
.size_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.when_some(right_view, |sidebar, view| {
|
||||||
|
sidebar.child(view.cached(StyleRefinement::default().absolute().size_full()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_node(
|
fn render_node(
|
||||||
dock_id: &ElementId,
|
dock_id: &ElementId,
|
||||||
state: &Entity<DockState>,
|
state: &Entity<DockState>,
|
||||||
|
|||||||
+119
-1
@@ -3,8 +3,10 @@ use gpui::{
|
|||||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _,
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _,
|
||||||
TestAppContext, VisualTestContext, Window, div, point, px,
|
TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_layout::dock::{Dock, DockState, PanelDescriptor, SplitDirection};
|
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
struct Panel {
|
struct Panel {
|
||||||
tag: &'static str,
|
tag: &'static str,
|
||||||
@@ -18,10 +20,28 @@ impl Render for Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The right sidebar view a panel declares.
|
||||||
|
struct SidePanel {
|
||||||
|
tag: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for SidePanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.debug_selector(move || format!("side-{}", self.tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn descriptor(tag: &'static str) -> PanelDescriptor {
|
fn descriptor(tag: &'static str) -> PanelDescriptor {
|
||||||
PanelDescriptor::new(tag, tag, move |_, cx| cx.new(|_| Panel { tag }).into())
|
PanelDescriptor::new(tag, tag, move |_, cx| cx.new(|_| Panel { tag }).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn descriptor_with_right_sidebar(tag: &'static str) -> PanelDescriptor {
|
||||||
|
PanelDescriptor::new(tag, tag, move |_, cx| cx.new(|_| Panel { tag }).into())
|
||||||
|
.right_sidebar(move |_, cx| cx.new(|_| SidePanel { tag }).into())
|
||||||
|
}
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
dock: Entity<DockState>,
|
dock: Entity<DockState>,
|
||||||
}
|
}
|
||||||
@@ -420,3 +440,101 @@ fn close_others_keeps_one_and_close_pane_empties() {
|
|||||||
open(&mut visual, &dock, "inbox");
|
open(&mut visual, &dock, "inbox");
|
||||||
assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some());
|
assert!(bounds(&mut visual, "test-dock-pane-0-tabs-tab-inbox").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SidebarHarness {
|
||||||
|
dock: Entity<DockState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for SidebarHarness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.child(Dock::new("test-dock", self.dock.clone())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(160.))
|
||||||
|
.child(DockSidebar::new("test-dock-sidebar", self.dock.clone())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidebar_window(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) -> (Entity<DockState>, Rc<Cell<bool>>, VisualTestContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let dock = cx.update(DockState::new);
|
||||||
|
let opened = Rc::new(Cell::new(false));
|
||||||
|
let opened_for_dock = opened.clone();
|
||||||
|
let dock_for_window = dock.clone();
|
||||||
|
let window = cx.add_window(move |_, _| SidebarHarness {
|
||||||
|
dock: dock_for_window,
|
||||||
|
});
|
||||||
|
dock.update(cx, |dock, _| {
|
||||||
|
dock.on_open_right_sidebar(move |cx| {
|
||||||
|
opened_for_dock.set(true);
|
||||||
|
let _ = cx;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), cx);
|
||||||
|
visual.update(|window, cx| window.draw(cx).clear(cx));
|
||||||
|
(dock, opened, visual)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activating_a_panel_with_a_right_view_opens_the_sidebar() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
let (dock, opened, mut visual) = sidebar_window(&mut cx);
|
||||||
|
cx.update(|cx| {
|
||||||
|
dock.update(cx, |dock, cx| {
|
||||||
|
dock.register_panel(descriptor_with_right_sidebar("overview"), cx);
|
||||||
|
dock.register_panel(descriptor("inbox"), cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
open(&mut visual, &dock, "overview");
|
||||||
|
assert!(
|
||||||
|
opened.get(),
|
||||||
|
"activation must ask the app to open the sidebar"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bounds(&mut visual, "side-overview").is_some(),
|
||||||
|
"the sidebar shows the panel's own view"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A panel without a right view leaves the sidebar showing the last one.
|
||||||
|
open(&mut visual, &dock, "inbox");
|
||||||
|
assert!(opened.get());
|
||||||
|
assert!(bounds(&mut visual, "side-overview").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn closing_the_displayed_tab_falls_back_to_another_right_view() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
let (dock, opened, mut visual) = sidebar_window(&mut cx);
|
||||||
|
cx.update(|cx| {
|
||||||
|
dock.update(cx, |dock, cx| {
|
||||||
|
dock.register_panel(descriptor_with_right_sidebar("overview"), cx);
|
||||||
|
dock.register_panel(descriptor_with_right_sidebar("explore"), cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
open(&mut visual, &dock, "overview");
|
||||||
|
open(&mut visual, &dock, "explore");
|
||||||
|
assert!(bounds(&mut visual, "side-explore").is_some());
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
dock.update(cx, |dock, cx| dock.close_panel("explore", cx));
|
||||||
|
});
|
||||||
|
redraw(&mut visual);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bounds(&mut visual, "side-overview").is_some(),
|
||||||
|
"the sidebar falls back to a remaining panel's view"
|
||||||
|
);
|
||||||
|
let _ = opened;
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -291,7 +291,7 @@ Units, each through the review gate:
|
|||||||
| 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::<PanelDrag>` 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 |
|
| 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::<PanelDrag>` 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 |
|
| 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 ✅ Landed (2026-11-02) | The strips are the window's titlebar: every strip (shell sidebars, dock pill bar) marks itself `WindowControlArea::Drag` and double-click-maximizes (`titlebar_double_click` on macOS, `zoom_window` on Linux; Windows does it natively through the caption hit test). New `window_controls` module (feature `window_controls`, pulled by `shell` and `dock`): a `WindowControls` element rendered at the trailing end of the right-hand strip — the right sidebar's strip when visible, else the dock pill bar via `Dock::window_controls(bool)`. **Windows**: caption buttons (36 px wide, Segoe codepoints through the icons crate — new `WindowMinimize`/`WindowMaximize`/`WindowRestore` roles, E921/E922/E923 verified against zed's own table) marked `WindowControlArea::Min/Max/Close` so the OS owns the clicks and snap layouts; maximize flips to restore via `is_maximized`; capability-gated by `Window::window_controls()` + `is_minimizable`/`is_resizable`; close hovers the platform red (a platform convention const, like the Segoe codepoints). **Linux**: Adwaita circular buttons (the platform dress, hand-rolled like zed's) under `Decorations::Client` only, clicking `minimize_window`/`zoom_window`/`remove_window`. **macOS** draws nothing — system traffic lights; the shell reserves `TRAFFIC_LIGHT_PAD` (78 px, Tahoe SDK) in the left sidebar strip's leading edge, and `Dock::traffic_light_pad(bool)` pads the pill bar when no left sidebar sits over the lights. The gpui dispatch fact that makes this safe: Windows forwards `WM_NCLBUTTONDOWN` into gpui's normal event stream, so buttons inside a Drag-marked strip still get their clicks, and only unconsumed presses fall through to native caption behavior. Adapted from zed's `platform_title_bar` (D15/D20); the gallery wires both dock flags from `LayoutState`. 2 platform-gated headless tests (macOS pad shifts the pill bar's tab by the pad width; controls render only under the flag on their platforms); CI feature loop gained `window_controls` |
|
| L4.5 | layout | Titlebar + window controls ✅ Landed (2026-11-02) | The strips are the window's titlebar: every strip (shell sidebars, dock pill bar) marks itself `WindowControlArea::Drag` and double-click-maximizes (`titlebar_double_click` on macOS, `zoom_window` on Linux; Windows does it natively through the caption hit test). New `window_controls` module (feature `window_controls`, pulled by `shell` and `dock`): a `WindowControls` element rendered at the trailing end of the right-hand strip — the right sidebar's strip when visible, else the dock pill bar via `Dock::window_controls(bool)`. **Windows**: caption buttons (36 px wide, Segoe codepoints through the icons crate — new `WindowMinimize`/`WindowMaximize`/`WindowRestore` roles, E921/E922/E923 verified against zed's own table) marked `WindowControlArea::Min/Max/Close` so the OS owns the clicks and snap layouts; maximize flips to restore via `is_maximized`; capability-gated by `Window::window_controls()` + `is_minimizable`/`is_resizable`; close hovers the platform red (a platform convention const, like the Segoe codepoints). **Linux**: Adwaita circular buttons (the platform dress, hand-rolled like zed's) under `Decorations::Client` only, clicking `minimize_window`/`zoom_window`/`remove_window`. **macOS** draws nothing — system traffic lights; the shell reserves `TRAFFIC_LIGHT_PAD` (78 px, Tahoe SDK) in the left sidebar strip's leading edge, and `Dock::traffic_light_pad(bool)` pads the pill bar when no left sidebar sits over the lights. The gpui dispatch fact that makes this safe: Windows forwards `WM_NCLBUTTONDOWN` into gpui's normal event stream, so buttons inside a Drag-marked strip still get their clicks, and only unconsumed presses fall through to native caption behavior. Adapted from zed's `platform_title_bar` (D15/D20); the gallery wires both dock flags from `LayoutState`. 2 platform-gated headless tests (macOS pad shifts the pill bar's tab by the pad width; controls render only under the flag on their platforms); CI feature loop gained `window_controls` |
|
||||||
| 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. |
|
| L5 | layout | Sidebar auto-open ✅ Landed (2026-11-02) | Panels declare a right-sidebar **view**, not a flag: `PanelDescriptor::right_sidebar(builder)` — the view builds once when the panel opens (retained alongside the panel view, so its state survives too) and lives on the open tab. Activating a panel that has one points the sidebar at that view and runs the app-registered `DockState::on_open_right_sidebar(handler)` so the app opens it — visibility stays on the shell's `LayoutState`, the dock only asks. Panels without a view leave the sidebar showing the last view (sticky — no yanking on tab switches); closing the displayed panel's tab falls back to the first remaining tab's view, else blank. The sidebar renders through a new `DockSidebar` element (the cached-view pattern), which the app places in `Shell::right_sidebar`. Gallery: Overview and Inbox declare details views, Settings doesn't — switching to Settings keeps the sidebar, closing a details tab falls back. 2 headless tests (activation fires the callback and renders the panel's view; sticky-keep on view-less panels + close-fallback) |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -377,6 +377,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).
|
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).
|
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.
|
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 ✅, 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.
|
9. Then — the layout crate (§6.4): ResizeHandle ✅, Shell ✅, window frame ✅, Dock ✅, drag-to-split ✅, titlebar + window controls ✅, sidebar auto-open ✅ — §6.4 is complete; further layout work (persistence, pane zoom, bottom dock) waits for a real consumer. Order against M9+ follows signed's needs.
|
||||||
|
|
||||||
Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.
|
Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry};
|
|||||||
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
use gpui_native_data::tabs::{Tab, Tabs, TabsVariant};
|
||||||
use gpui_native_data::tree::{Tree, TreeItem};
|
use gpui_native_data::tree::{Tree, TreeItem};
|
||||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||||
use gpui_native_layout::dock::{Dock, DockState, PanelDescriptor, SplitDirection};
|
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||||
use gpui_native_layout::resize_handle::ResizeHandle;
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
use gpui_native_layout::shell::{LayoutState, Shell};
|
use gpui_native_layout::shell::{LayoutState, Shell};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||||
@@ -155,7 +155,6 @@ impl Gallery {
|
|||||||
/// The app shell demo: sidebars around a center region, toggles in the
|
/// The app shell demo: sidebars around a center region, toggles in the
|
||||||
/// center demonstrating the `LayoutState` API.
|
/// center demonstrating the `LayoutState` API.
|
||||||
fn render_app_shell(&self, gallery: &Entity<Self>, cx: &App) -> impl IntoElement + use<> {
|
fn render_app_shell(&self, gallery: &Entity<Self>, cx: &App) -> impl IntoElement + use<> {
|
||||||
let colors = cx.theme().colors;
|
|
||||||
let state = self.layout_state.clone();
|
let state = self.layout_state.clone();
|
||||||
let state_for_toggle = state.clone();
|
let state_for_toggle = state.clone();
|
||||||
|
|
||||||
@@ -198,13 +197,8 @@ impl Gallery {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let right_sidebar = v_flex()
|
let right_sidebar =
|
||||||
.gap_1()
|
DockSidebar::new("gallery-dock-sidebar", self.dock_state.clone()).into_any_element();
|
||||||
.p_2()
|
|
||||||
.child("Details")
|
|
||||||
.child("Signed by the author")
|
|
||||||
.text_sm()
|
|
||||||
.text_color(colors.muted_foreground);
|
|
||||||
|
|
||||||
let open_panel = |dock: &Entity<DockState>, id: &'static str, label: &'static str| {
|
let open_panel = |dock: &Entity<DockState>, id: &'static str, label: &'static str| {
|
||||||
let dock = dock.clone();
|
let dock = dock.clone();
|
||||||
@@ -285,6 +279,34 @@ impl Render for DemoPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The right sidebar view a demo panel declares.
|
||||||
|
struct RightDetails {
|
||||||
|
title: &'static str,
|
||||||
|
body: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for RightDetails {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let colors = cx.theme().colors;
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.gap_1()
|
||||||
|
.p_2()
|
||||||
|
.bg(colors.background)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_weight(gpui::FontWeight::MEDIUM)
|
||||||
|
.child(self.title),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(self.body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Render for Gallery {
|
impl Render for Gallery {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
@@ -1767,6 +1789,16 @@ fn main() {
|
|||||||
notes_state.update(cx, |state, cx| state.set_multiline(true, cx));
|
notes_state.update(cx, |state, cx| state.set_multiline(true, cx));
|
||||||
let layout_state = LayoutState::new(cx);
|
let layout_state = LayoutState::new(cx);
|
||||||
let dock_state = DockState::new(cx);
|
let dock_state = DockState::new(cx);
|
||||||
|
// A panel that declares a right sidebar view asks for it to open on
|
||||||
|
// activation; visibility itself stays on the shell's LayoutState.
|
||||||
|
let layout_for_dock = layout_state.clone();
|
||||||
|
dock_state.update(cx, |dock, _| {
|
||||||
|
dock.on_open_right_sidebar(move |cx| {
|
||||||
|
layout_for_dock.update(cx, |state, cx| {
|
||||||
|
state.set_right_sidebar_visible(true, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
dock_state.update(cx, |dock, cx| {
|
dock_state.update(cx, |dock, cx| {
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("overview", "Overview", |_, cx| {
|
PanelDescriptor::new("overview", "Overview", |_, cx| {
|
||||||
@@ -1775,6 +1807,13 @@ fn main() {
|
|||||||
body: "View state survives tab switches and pane moves.",
|
body: "View state survives tab switches and pane moves.",
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
|
})
|
||||||
|
.right_sidebar(|_, cx| {
|
||||||
|
cx.new(|_| RightDetails {
|
||||||
|
title: "Overview",
|
||||||
|
body: "Details follow the active panel.",
|
||||||
|
})
|
||||||
|
.into()
|
||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -1785,6 +1824,13 @@ fn main() {
|
|||||||
body: "Right-click the tab bar for Close, Close Others, Close Pane.",
|
body: "Right-click the tab bar for Close, Close Others, Close Pane.",
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
|
})
|
||||||
|
.right_sidebar(|_, cx| {
|
||||||
|
cx.new(|_| RightDetails {
|
||||||
|
title: "Inbox",
|
||||||
|
body: "Sync status of the selected account.",
|
||||||
|
})
|
||||||
|
.into()
|
||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user