add dock layout component

This commit is contained in:
2026-11-02 16:24:20 +07:00
parent 9bce2820cc
commit 545d0986ed
11 changed files with 1914 additions and 67 deletions
+8 -1
View File
@@ -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)
Generated
+1
View File
@@ -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",
+11 -3
View File
@@ -40,6 +40,7 @@ pub struct Button {
variant: ButtonVariant,
size: Size,
disabled: bool,
tab_stop: bool,
a11y_label: Option<SharedString>,
tooltip: Option<TooltipBuilder>,
on_click: Option<ClickHandler>,
@@ -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)))
+2 -1
View File
@@ -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 = []
+236 -46
View File
@@ -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<dyn Fn(usize, &mut Window, &mut App)>;
/// The close reporter, fired with the tab's index by its close control.
type CloseHandler = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// Decorates the rendered tab, e.g. to attach a drag payload.
type TabWrap = Box<dyn FnOnce(Stateful<Div>) -> AnyElement>;
type TabsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<Tab>>;
/// 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<IconRole>,
lead: Option<AnyElement>,
busy: bool,
disabled: bool,
closeable: bool,
wrap: Option<TabWrap>,
}
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<E>(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<F>(mut self, wrap: F) -> Self
where
F: FnOnce(Stateful<Div>) -> 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<SharedString>,
on_change: Option<ChangeHandler>,
on_close: Option<CloseHandler>,
tabs: Option<TabsFn>,
}
@@ -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<F>(mut self, handler: F) -> Self
where
@@ -87,6 +149,15 @@ impl Tabs {
self
}
/// Reports a closed tab by index.
pub fn on_close<F>(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<F>(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<AnyElement> = 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<ChangeHandler>,
on_close: &Option<CloseHandler>,
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<CloseHandler>,
) -> 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()
}
+9 -1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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;
+422
View File
@@ -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<Self>) -> 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<DockState>,
}
impl Render for Harness {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
div()
.size_full()
.child(Dock::new("test-dock", self.dock.clone()))
}
}
fn dock_window(cx: &mut TestAppContext) -> (Entity<DockState>, 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<gpui::Bounds<Pixels>> {
visual.debug_bounds(Box::leak(selector.to_string().into_boxed_str()))
}
fn center_of(bounds: gpui::Bounds<Pixels>) -> gpui::Point<Pixels> {
point(bounds.center().x, bounds.center().y)
}
fn press(visual: &mut VisualTestContext, at: gpui::Point<Pixels>, 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<Pixels>, to: gpui::Point<Pixels>) {
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<DockState>, 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<DockState>, 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());
}
+6 -6
View File
@@ -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::<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 |
| 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.
+161 -9
View File
@@ -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<InputState>,
split_width: Pixels,
layout_state: Entity<LayoutState>,
dock_state: Entity<DockState>,
shell_selection: Option<usize>,
popover_open: bool,
menu_open: bool,
@@ -67,6 +69,8 @@ struct Gallery {
tree_nodes: Vec<GalleryNode>,
tree_selected: Option<usize>,
tab_selected: usize,
card_tabs: Vec<SharedString>,
card_tab_selected: usize,
sidebar_selected: Option<usize>,
}
@@ -202,19 +206,51 @@ impl Gallery {
.text_sm()
.text_color(colors.muted_foreground);
let open_panel = |dock: &Entity<DockState>, 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<Self>) -> 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<Self>) -> 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 {