feat: out-of-box experience #2

Merged
reya merged 64 commits from feat/ui into master 2026-08-25 13:23:08 +00:00
5 changed files with 299 additions and 25 deletions
Showing only changes of commit 1956cb96bb - Show all commits
Generated
+87 -4
View File
@@ -1363,6 +1363,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1714,7 +1723,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"convert_case 0.10.0",
"proc-macro2",
"quote",
"rustc_version",
@@ -3486,6 +3495,7 @@ dependencies = [
"anyhow",
"async-channel",
"async-task",
"backtrace",
"bindgen",
"bitflags 2.13.1",
"chrono",
@@ -3516,6 +3526,7 @@ dependencies = [
"pollster 0.4.0",
"postage",
"profiling",
"proptest",
"rand 0.9.5",
"raw-window-handle",
"refineable",
@@ -4498,7 +4509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
"quick-error 2.0.1",
]
[[package]]
@@ -6613,6 +6624,36 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "proptest"
version = "1.10.0"
source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.13.1",
"num-traits",
"proptest-macro",
"rand 0.9.5",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "proptest-macro"
version = "0.5.0"
source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b"
dependencies = [
"convert_case 0.11.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "psm"
version = "0.1.32"
@@ -6660,6 +6701,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -6838,6 +6885,15 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -6894,7 +6950,7 @@ dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"quick-error 2.0.1",
"rav1e",
"rayon",
"rgb",
@@ -7446,6 +7502,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -8592,7 +8660,7 @@ dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"quick-error 2.0.1",
"weezl",
"zune-jpeg 0.5.15",
]
@@ -9398,6 +9466,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
@@ -9701,6 +9775,15 @@ dependencies = [
"libc",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "waker-fn"
version = "1.2.0"
+3
View File
@@ -9,3 +9,6 @@ publish.workspace = true
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
+83 -20
View File
@@ -97,15 +97,6 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
.flatten()
}
/// The payload for dragging the tab at `ix` out of its group, or `None` when
/// this group must not be rearranged.
fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
group
.is_draggable()
.then(|| group.drag_panel(ix, cx))
.flatten()
}
/// The left-most, top-most tab group in a container — where a left dock's
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
@@ -183,6 +174,37 @@ impl SignedTabGroupSkin {
left == group.node() && group.panels().len() == 1
}
/// The bottom or right dock whose root tab group this group is, if any.
///
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that
/// — so the skin recognizes the group and routes around the bar.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
[DockPlacement::Bottom, DockPlacement::Right]
.into_iter()
.find(|placement| {
area.layout(*placement)
.is_some_and(|tree| tree.root().id() == group.node())
})
}
/// The drag payload for the tab at `ix`, or `None` when this group must
/// not be rearranged. A locked group is never draggable; a group that is
/// a bottom/right dock's only content still is, because the center is
/// always there to land in.
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
if group.is_locked() {
return None;
}
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
return None;
}
group.drag_panel(ix, cx)
}
/// Whether a dock's collapse affordance belongs in *this* group's tab
/// bar, and which way it points. `None` means this group draws none.
fn dock_toggle_button(
@@ -309,7 +331,12 @@ impl SignedTabGroupSkin {
let control = zoom_control(group, cx);
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
let menu_zoom = control.is_some_and(|control| control.menu_visible());
let closable = group.can_close();
// A bottom/right dock's only panel cannot be closed through the
// group (base keeps a dock's last group), but the skin handles that
// close by removing the whole dock, so the item is offered.
let closable = group.can_close()
|| (self.is_dock_root_group(group, cx).is_some()
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
let panel = handle.map(|handle| handle.panel());
@@ -395,7 +422,7 @@ impl SignedTabGroupSkin {
) -> Tab {
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let drag = tab_drag(group, ix, cx);
let drag = self.tab_drag(group, ix, cx);
let handle = PanelHandle::of(&panel);
Tab::new(ix)
@@ -546,6 +573,19 @@ impl SignedTabGroupSkin {
impl TabGroupRenderer for SignedTabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let control = zoom_control(group, cx);
// An emptied group — its last panel was dragged away — draws nothing,
// so an emptied dock does not leave a bare tab bar behind.
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Closing the only panel of a bottom/right dock would leave an empty
// dock, which base refuses through the group. The skin removes the
// whole dock instead — the vendored dock's close took its split
// group away just the same.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
let shared = self.shared.clone();
// `v_flex`, not `div`: gpui's default display is Block, and in block
// layout a child's `flex_grow` is ignored — the content region below
@@ -574,12 +614,23 @@ impl TabGroupRenderer for SignedTabGroupSkin {
})
.on_action({
let group = group.clone();
let shared = shared.clone();
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
if !panel.closable(cx) {
return;
}
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
match dock_to_remove {
Some(placement) => {
_ = shared.area().update(cx, |area, cx| {
area.remove_dock(placement, window, cx);
});
}
None => group.close(panel, window, cx),
}
}
})
})
@@ -599,6 +650,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// An emptied group draws no tab bar; the app prunes the emptied
// bottom/right dock a moment later.
if group.panels().is_empty() {
return Empty.into_any_element();
}
// The sidebar group draws no chrome at all, like the vendored dock's
// bare `DockItem::Panel`.
if self.is_plain_sidebar_group(group, cx) {
@@ -614,15 +671,21 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. The
// left dock (sidebar) normally clears them; when it is closed or
// absent, the center tab bar must reserve the space itself.
// macOS: the traffic lights overlay the window's top-left corner. Only
// the group whose tab bar actually sits under them must reserve the
// space: the left dock (sidebar) normally clears them, and when it is
// closed or absent it is the center's left-most, top-most tab group
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self
.shared
.area()
.upgrade()
.is_none_or(|area| !area.read(cx).is_dock_open(DockPlacement::Left));
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
!area.is_dock_open(DockPlacement::Left)
&& area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
});
// Bring a newly displayed tab into view. The group owns selection
// now, so the skin notices the change rather than being told about it.
+99
View File
@@ -0,0 +1,99 @@
//! Render-path smoke tests: the skin reads the dock area while rendering, and
//! GPUI panics if an entity is read while it is leased (being updated). These
//! pin that the first frame — docks, groups, tab bars — renders without
//! tripping the lease check.
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
use gpui::{
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
TestAppContext, Window,
};
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
struct Probe {
focus_handle: FocusHandle,
}
impl Probe {
fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
}
}
}
impl BasePanel for Probe {
fn panel_name(&self) -> &'static str {
"Probe"
}
}
impl Panel for Probe {
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
"Probe"
}
}
impl EventEmitter<PanelEvent> for Probe {}
impl Focusable for Probe {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[gpui::test]
fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
});
let (area, cx) = cx.add_window_view(|window, cx| {
let skin = SignedDockSkin::new(cx);
DockArea::new("test", None, window, cx).with_renderer(skin)
});
let bottom = cx.update(|_, cx| cx.new(Probe::new));
cx.update(|window, cx| {
let left = cx.new(Probe::new);
let center = cx.new(Probe::new);
area.update(cx, |area, cx| {
area.set_dock(
DockPlacement::Left,
DockLayout::tabs().panel_view(panel_handle(left), cx),
window,
cx,
);
area.set_center(
DockLayout::tabs().panel_view(panel_handle(center), cx),
window,
cx,
);
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel_view(panel_handle(bottom.clone()), cx),
window,
cx,
);
});
});
// The first frame walks every render hook — the dock frame, each group's
// tab bar, the toolbar — all of which read the dock area.
cx.update(|window, cx| window.draw(cx).clear(cx));
// Emptying a dock leaves an empty group behind; its render must also be
// safe (and draw nothing).
cx.update(|window, cx| {
area.update(cx, |area, cx| {
area.remove_panel(bottom, window, cx);
});
});
cx.update(|window, cx| window.draw(cx).clear(cx));
}
+27 -1
View File
@@ -1,4 +1,4 @@
use dock::{DockArea, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle};
use gpui::prelude::*;
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
use gpui_component::{Root, StyledExt, Theme};
@@ -40,6 +40,32 @@ impl Workspace {
let mut subscriptions = vec![];
// A bottom/right dock whose last panel was dragged away is removed
// entirely: base keeps the emptied region, which would otherwise
// linger as a bare strip. Deferred, because the event arrives while
// the area is mid-update.
let dock_for_pruning = dock.clone();
subscriptions.push(cx.subscribe_in(
&dock,
window,
move |_, _, event: &DockEvent, window, cx| {
if !matches!(event, DockEvent::LayoutChanged) {
return;
}
let dock = dock_for_pruning.clone();
cx.spawn_in(window, async move |_, window| {
_ = dock.update_in(window, |area, window, cx| {
for placement in [DockPlacement::Bottom, DockPlacement::Right] {
if area.is_empty(placement, cx) {
area.remove_dock(placement, window, cx);
}
}
});
})
.detach();
},
));
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
Theme::sync_system_appearance(Some(window), cx);
}));