add app layout shell to the layout crate
This commit is contained in:
@@ -8,11 +8,20 @@ description = "Application layout bootstrap: sidebars and a center dock, one car
|
||||
gpui = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
gpui-native-utils = { workspace = true }
|
||||
gpui-native-core = { workspace = true, optional = true, features = ["button"] }
|
||||
gpui-native-data = { workspace = true, optional = true, features = ["sidebar"] }
|
||||
gpui-native-icons = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
full = ["resize_handle"]
|
||||
full = ["resize_handle", "app_layout"]
|
||||
resize_handle = []
|
||||
app_layout = [
|
||||
"resize_handle",
|
||||
"dep:gpui-native-core",
|
||||
"dep:gpui-native-data",
|
||||
"dep:gpui-native-icons",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The application shell: a left sidebar (on by default), a right sidebar
|
||||
//! (off by default), and the center region that hosts the dock.
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, ElementId, Entity, IntoElement, ParentElement as _,
|
||||
Pixels, RenderOnce, SharedString, Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_core::button::{Button, ButtonVariant};
|
||||
use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_data::sidebar::Sidebar;
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
|
||||
use crate::resize_handle::ResizeHandle;
|
||||
|
||||
/// The width a sidebar opens at.
|
||||
const DEFAULT_SIDEBAR_WIDTH: f32 = 240.0;
|
||||
/// The narrowest a sidebar can be dragged to.
|
||||
const MIN_SIDEBAR_WIDTH: f32 = 160.0;
|
||||
/// The widest a sidebar can be dragged to.
|
||||
const MAX_SIDEBAR_WIDTH: f32 = 480.0;
|
||||
|
||||
/// The layout the application owns.
|
||||
pub struct LayoutState {
|
||||
left_visible: bool,
|
||||
right_visible: bool,
|
||||
left_width: Pixels,
|
||||
right_width: Pixels,
|
||||
}
|
||||
|
||||
impl LayoutState {
|
||||
/// Creates the state: left sidebar visible, right sidebar hidden.
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {
|
||||
left_visible: true,
|
||||
right_visible: false,
|
||||
left_width: px(DEFAULT_SIDEBAR_WIDTH),
|
||||
right_width: px(DEFAULT_SIDEBAR_WIDTH),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows or hides the left sidebar.
|
||||
pub fn set_left_sidebar_visible(&mut self, visible: bool, cx: &mut Context<Self>) {
|
||||
if self.left_visible != visible {
|
||||
self.left_visible = visible;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows or hides the right sidebar.
|
||||
pub fn set_right_sidebar_visible(&mut self, visible: bool, cx: &mut Context<Self>) {
|
||||
if self.right_visible != visible {
|
||||
self.right_visible = visible;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the left sidebar is visible.
|
||||
pub fn is_left_sidebar_visible(&self) -> bool {
|
||||
self.left_visible
|
||||
}
|
||||
|
||||
/// Whether the right sidebar is visible.
|
||||
pub fn is_right_sidebar_visible(&self) -> bool {
|
||||
self.right_visible
|
||||
}
|
||||
|
||||
/// The left sidebar's current width.
|
||||
pub fn left_sidebar_width(&self) -> Pixels {
|
||||
self.left_width
|
||||
}
|
||||
|
||||
/// The right sidebar's current width.
|
||||
pub fn right_sidebar_width(&self) -> Pixels {
|
||||
self.right_width
|
||||
}
|
||||
|
||||
/// Applies a drag delta to the left sidebar's width, clamped.
|
||||
fn resize_left(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
||||
self.left_width =
|
||||
(self.left_width + delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Applies a drag delta to the right sidebar's width, clamped.
|
||||
fn resize_right(&mut self, delta: Pixels, cx: &mut Context<Self>) {
|
||||
self.right_width =
|
||||
(self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sidebar a region or toggle belongs to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Side::Left => "left",
|
||||
Side::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_icon(self) -> IconRole {
|
||||
match self {
|
||||
Side::Left => IconRole::PanelLeft,
|
||||
Side::Right => IconRole::PanelRight,
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_label(self) -> SharedString {
|
||||
match self {
|
||||
Side::Left => SharedString::from("Hide left sidebar"),
|
||||
Side::Right => SharedString::from("Hide right sidebar"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application shell.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AppLayout {
|
||||
id: ElementId,
|
||||
state: Entity<LayoutState>,
|
||||
left_sidebar: Option<Sidebar>,
|
||||
right_sidebar: Option<AnyElement>,
|
||||
center: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl AppLayout {
|
||||
/// Creates the shell bound to `state`.
|
||||
pub fn new<I>(id: I, state: Entity<LayoutState>) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
state,
|
||||
left_sidebar: None,
|
||||
right_sidebar: None,
|
||||
center: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the left sidebar's navigation list.
|
||||
pub fn left_sidebar(mut self, sidebar: Sidebar) -> Self {
|
||||
self.left_sidebar = Some(sidebar);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the right sidebar's content: a details or inspector pane.
|
||||
pub fn right_sidebar<E>(mut self, content: E) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
self.right_sidebar = Some(content.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the center region's content, which hosts the dock.
|
||||
pub fn center<E>(mut self, content: E) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
self.center = Some(content.into_any_element());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AppLayout {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let metrics = theme.sizes.resolve(Size::default());
|
||||
|
||||
let state = self.state;
|
||||
let id = self.id.clone();
|
||||
|
||||
let mut left = self.left_sidebar;
|
||||
let mut right = self.right_sidebar;
|
||||
let mut center = self.center;
|
||||
|
||||
let left_visible = state.read(cx).is_left_sidebar_visible();
|
||||
let right_visible = state.read(cx).is_right_sidebar_visible();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.w_full()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.when(left_visible && left.is_some(), |layout| {
|
||||
let width = state.read(cx).left_sidebar_width();
|
||||
layout
|
||||
.child(sidebar_region(
|
||||
width,
|
||||
left.take()
|
||||
.map(|sidebar| sidebar.into_any_element())
|
||||
.unwrap_or_else(|| div().into_any_element()),
|
||||
Side::Left,
|
||||
&state,
|
||||
metrics.strip_height,
|
||||
metrics.dense_padding_x,
|
||||
))
|
||||
.child(ResizeHandle::new((id.clone(), "left-resize")).on_resize({
|
||||
let state = state.clone();
|
||||
move |delta, _, cx| {
|
||||
state.update(cx, |state, cx| state.resize_left(delta, cx));
|
||||
}
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.child(center.take().unwrap_or_else(|| div().into_any_element())),
|
||||
)
|
||||
.when(right_visible && right.is_some(), |layout| {
|
||||
let width = state.read(cx).right_sidebar_width();
|
||||
layout
|
||||
.child(ResizeHandle::new((id.clone(), "right-resize")).on_resize({
|
||||
let state = state.clone();
|
||||
move |delta, _, cx| {
|
||||
state.update(cx, |state, cx| state.resize_right(delta, cx));
|
||||
}
|
||||
}))
|
||||
.child(sidebar_region(
|
||||
width,
|
||||
right.take().unwrap_or_else(|| div().into_any_element()),
|
||||
Side::Right,
|
||||
&state,
|
||||
metrics.strip_height,
|
||||
metrics.dense_padding_x,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One sidebar: a header strip with the hide toggle over the app's content.
|
||||
fn sidebar_region(
|
||||
width: Pixels,
|
||||
content: AnyElement,
|
||||
side: Side,
|
||||
state: &Entity<LayoutState>,
|
||||
strip_height: Pixels,
|
||||
padding_x: Pixels,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.w(width)
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.h(strip_height)
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.px(padding_x)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!(
|
||||
"sidebar-toggle-{}",
|
||||
side.name()
|
||||
)))
|
||||
.icon(side.toggle_icon())
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.small()
|
||||
.accessibility_label(side.toggle_label())
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |_, _, cx| match side {
|
||||
Side::Left => state
|
||||
.update(cx, |state, cx| state.set_left_sidebar_visible(false, cx)),
|
||||
Side::Right => state
|
||||
.update(cx, |state, cx| state.set_right_sidebar_visible(false, cx)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().overflow_hidden().child(content))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -2,3 +2,6 @@
|
||||
|
||||
#[cfg(feature = "resize_handle")]
|
||||
pub mod resize_handle;
|
||||
|
||||
#[cfg(feature = "app_layout")]
|
||||
pub mod app_layout;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
use gpui::{
|
||||
Entity, InteractiveElement as _, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement as _, Pixels, Render, Styled as _, TestAppContext,
|
||||
VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
use gpui_native_data::sidebar::Sidebar;
|
||||
use gpui_native_layout::app_layout::{AppLayout, LayoutState};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||
|
||||
struct Harness {
|
||||
state: Entity<LayoutState>,
|
||||
}
|
||||
|
||||
impl Render for Harness {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
div().size_full().child(
|
||||
AppLayout::new("test-layout", self.state.clone())
|
||||
.left_sidebar(Sidebar::new("test-left"))
|
||||
.center(
|
||||
div()
|
||||
.size_full()
|
||||
.debug_selector(|| "test-center".to_string()),
|
||||
)
|
||||
.right_sidebar(
|
||||
div()
|
||||
.size_full()
|
||||
.debug_selector(|| "test-right".to_string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_window(cx: &mut TestAppContext) -> (Entity<LayoutState>, VisualTestContext) {
|
||||
cx.update(Theme::init);
|
||||
let state = cx.update(LayoutState::new);
|
||||
let state_for_window = state.clone();
|
||||
let window = cx.add_window(move |_, _| Harness {
|
||||
state: state_for_window,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), cx);
|
||||
redraw(&mut visual);
|
||||
(state, visual)
|
||||
}
|
||||
|
||||
fn redraw(visual: &mut VisualTestContext) {
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn bounds(visual: &mut VisualTestContext, selector: &'static str) -> Option<gpui::Bounds<Pixels>> {
|
||||
visual.debug_bounds(selector)
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_sidebar_starts_visible_and_right_hidden() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (state, mut visual) = layout_window(&mut cx);
|
||||
|
||||
assert!(state.read_with(&cx, |state, _| state.is_left_sidebar_visible()));
|
||||
assert!(!state.read_with(&cx, |state, _| state.is_right_sidebar_visible()));
|
||||
|
||||
assert!(
|
||||
bounds(&mut visual, "sidebar-test-left").is_some(),
|
||||
"left region missing"
|
||||
);
|
||||
assert!(
|
||||
bounds(&mut visual, "test-center").is_some(),
|
||||
"center missing"
|
||||
);
|
||||
assert!(
|
||||
bounds(&mut visual, "test-right").is_none(),
|
||||
"right region must be hidden by default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_a_sidebar_shows_and_hides_its_region() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (state, mut visual) = layout_window(&mut cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| state.set_right_sidebar_visible(true, cx));
|
||||
});
|
||||
redraw(&mut visual);
|
||||
assert!(
|
||||
bounds(&mut visual, "test-right").is_some(),
|
||||
"right region missing"
|
||||
);
|
||||
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| state.set_left_sidebar_visible(false, cx));
|
||||
});
|
||||
redraw(&mut visual);
|
||||
assert!(
|
||||
bounds(&mut visual, "sidebar-test-left").is_none(),
|
||||
"left region lingering"
|
||||
);
|
||||
// The center keeps filling the shell.
|
||||
assert!(
|
||||
bounds(&mut visual, "test-center").is_some(),
|
||||
"center missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_toggle_hides_its_sidebar() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (state, mut visual) = layout_window(&mut cx);
|
||||
|
||||
// The toggle sits at the trailing end of the sidebar's header strip, an
|
||||
// icon-only small button: derive its center from the theme metrics.
|
||||
let metrics = cx.update(|cx| cx.theme().sizes.resolve(Size::default()));
|
||||
let small = cx.update(|cx| cx.theme().sizes.resolve(Size::Small));
|
||||
let region = bounds(&mut visual, "sidebar-test-left").expect("left sidebar missing");
|
||||
// The Sidebar fills the content area below the strip; the toggle sits at
|
||||
// the strip's trailing end, one strip height above the sidebar's top.
|
||||
let toggle = point(
|
||||
region.right() - metrics.dense_padding_x - small.control_height / 2.0,
|
||||
region.top() - metrics.strip_height / 2.0,
|
||||
);
|
||||
press(&mut visual, toggle, 1);
|
||||
redraw(&mut visual);
|
||||
|
||||
assert!(
|
||||
!state.read_with(&cx, |state, _| state.is_left_sidebar_visible()),
|
||||
"the toggle should hide the left sidebar"
|
||||
);
|
||||
assert!(bounds(&mut visual, "sidebar-test-left").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_seam_resizes_and_clamps() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (state, mut visual) = layout_window(&mut cx);
|
||||
|
||||
let before = state.read_with(&cx, |state, _| state.left_sidebar_width());
|
||||
let seam = bounds(&mut visual, "resize-handle").expect("left seam missing");
|
||||
|
||||
drag(
|
||||
&mut visual,
|
||||
center_of(seam),
|
||||
center_of(seam) + point(px(50.), px(0.)),
|
||||
);
|
||||
assert_eq!(
|
||||
state.read_with(&cx, |state, _| state.left_sidebar_width()),
|
||||
before + px(50.)
|
||||
);
|
||||
|
||||
// A huge drag clamps instead of collapsing the sidebar.
|
||||
let seam = bounds(&mut visual, "resize-handle").expect("seam missing");
|
||||
drag(
|
||||
&mut visual,
|
||||
center_of(seam),
|
||||
center_of(seam) + point(px(2000.), px(0.)),
|
||||
);
|
||||
assert_eq!(
|
||||
state.read_with(&cx, |state, _| state.left_sidebar_width()),
|
||||
px(480.)
|
||||
);
|
||||
|
||||
// Dragging back left shrinks it to the low clamp, no drift from the first.
|
||||
let seam = bounds(&mut visual, "resize-handle").expect("seam missing");
|
||||
drag(
|
||||
&mut visual,
|
||||
center_of(seam),
|
||||
center_of(seam) - point(px(2000.), px(0.)),
|
||||
);
|
||||
assert_eq!(
|
||||
state.read_with(&cx, |state, _| state.left_sidebar_width()),
|
||||
px(160.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_right_seam_grows_the_right_sidebar_leftwards() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (state, mut visual) = layout_window(&mut cx);
|
||||
|
||||
// Hide the left sidebar so the shell has exactly one seam.
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| state.set_left_sidebar_visible(false, cx));
|
||||
state.update(cx, |state, cx| state.set_right_sidebar_visible(true, cx));
|
||||
});
|
||||
redraw(&mut visual);
|
||||
|
||||
let before = state.read_with(&cx, |state, _| state.right_sidebar_width());
|
||||
let seam = bounds(&mut visual, "resize-handle").expect("right seam missing");
|
||||
|
||||
// Dragging left (negative) grows the right sidebar.
|
||||
drag(
|
||||
&mut visual,
|
||||
center_of(seam),
|
||||
center_of(seam) - point(px(30.), px(0.)),
|
||||
);
|
||||
let grown = state.read_with(&cx, |state, _| state.right_sidebar_width());
|
||||
assert_eq!(grown, before + px(30.));
|
||||
|
||||
// The right region's width follows the state.
|
||||
let region = bounds(&mut visual, "test-right").expect("right region missing");
|
||||
assert_eq!(region.size.width, grown);
|
||||
}
|
||||
Reference in New Issue
Block a user