add app layout shell to the layout crate
This commit is contained in:
@@ -33,7 +33,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo check -p gpui-native-layout --no-default-features
|
cargo check -p gpui-native-layout --no-default-features
|
||||||
cargo check -p gpui-native-layout --all-features
|
cargo check -p gpui-native-layout --all-features
|
||||||
cargo check -p gpui-native-layout --no-default-features --features resize_handle
|
for f in resize_handle app_layout; do
|
||||||
|
cargo check -p gpui-native-layout --no-default-features --features "$f"
|
||||||
|
done
|
||||||
- name: Feature checks (facade)
|
- name: Feature checks (facade)
|
||||||
run: |
|
run: |
|
||||||
cargo check -p gpui-native-kit --no-default-features
|
cargo check -p gpui-native-kit --no-default-features
|
||||||
|
|||||||
Generated
+3
@@ -1743,6 +1743,9 @@ name = "gpui-native-layout"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"gpui-native-core",
|
||||||
|
"gpui-native-data",
|
||||||
|
"gpui-native-icons",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
"gpui-native-utils",
|
"gpui-native-utils",
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-5
@@ -9,9 +9,9 @@ description = "GPUI components that feel native to the operating system"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["core", "data"]
|
default = ["core", "data"]
|
||||||
core = ["dep:gpui-native-core"]
|
core = ["dep:gpui-native-core", "gpui-native-core/full"]
|
||||||
data = ["dep:gpui-native-data"]
|
data = ["dep:gpui-native-data", "gpui-native-data/full"]
|
||||||
layout = ["dep:gpui-native-layout"]
|
layout = ["dep:gpui-native-layout", "gpui-native-layout/full"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
@@ -26,8 +26,8 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fdd
|
|||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531", features = [
|
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d67acd6fddd81392f44ed6ebcc6e7f531", features = [
|
||||||
"font-kit",
|
"font-kit",
|
||||||
] }
|
] }
|
||||||
gpui-native-core = { path = "crates/core" }
|
gpui-native-core = { path = "crates/core", default-features = false }
|
||||||
gpui-native-data = { path = "crates/data" }
|
gpui-native-data = { path = "crates/data", default-features = false }
|
||||||
gpui-native-layout = { path = "crates/layout" }
|
gpui-native-layout = { path = "crates/layout" }
|
||||||
gpui-native-icons = { path = "crates/icons" }
|
gpui-native-icons = { path = "crates/icons" }
|
||||||
gpui-native-theme = { path = "crates/theme" }
|
gpui-native-theme = { path = "crates/theme" }
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ impl RenderOnce for Sidebar {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||||
|
|
||||||
|
let id_text = self.id.to_string();
|
||||||
|
|
||||||
// Entry paint reads the theme through this borrow.
|
// Entry paint reads the theme through this borrow.
|
||||||
let children: Vec<AnyElement> = entries
|
let children: Vec<AnyElement> = entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -194,6 +196,7 @@ impl RenderOnce for Sidebar {
|
|||||||
|
|
||||||
div()
|
div()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
|
.debug_selector(move || format!("sidebar-{id_text}"))
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
|
||||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>
|
||||||
|
After Width: | Height: | Size: 257 B |
@@ -29,6 +29,8 @@ pub enum IconRole {
|
|||||||
Info,
|
Info,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
PanelLeft,
|
||||||
|
PanelRight,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IconRole {
|
impl IconRole {
|
||||||
@@ -48,6 +50,8 @@ impl IconRole {
|
|||||||
Self::Info,
|
Self::Info,
|
||||||
Self::Eye,
|
Self::Eye,
|
||||||
Self::EyeOff,
|
Self::EyeOff,
|
||||||
|
Self::PanelLeft,
|
||||||
|
Self::PanelRight,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +71,8 @@ impl IconRole {
|
|||||||
Self::Info => "icons/info.svg",
|
Self::Info => "icons/info.svg",
|
||||||
Self::Eye => "icons/eye.svg",
|
Self::Eye => "icons/eye.svg",
|
||||||
Self::EyeOff => "icons/eye-off.svg",
|
Self::EyeOff => "icons/eye-off.svg",
|
||||||
|
Self::PanelLeft => "icons/panel-left.svg",
|
||||||
|
Self::PanelRight => "icons/panel-right.svg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +92,8 @@ impl IconRole {
|
|||||||
Self::Info => "info",
|
Self::Info => "info",
|
||||||
Self::Eye => "eye",
|
Self::Eye => "eye",
|
||||||
Self::EyeOff => "eye-off",
|
Self::EyeOff => "eye-off",
|
||||||
|
Self::PanelLeft => "panel-left",
|
||||||
|
Self::PanelRight => "panel-right",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +152,19 @@ impl IconRole {
|
|||||||
"visibility-off",
|
"visibility-off",
|
||||||
"eye-off",
|
"eye-off",
|
||||||
],
|
],
|
||||||
|
Self::PanelLeft => &[
|
||||||
|
"sidebar-expand-left",
|
||||||
|
"sidebar-show-symbolic",
|
||||||
|
"panel-left",
|
||||||
|
"sidebar-left",
|
||||||
|
],
|
||||||
|
Self::PanelRight => &[
|
||||||
|
"sidebar-expand-right",
|
||||||
|
"sidebar-show-right-symbolic",
|
||||||
|
"sidebar-show-symbolic",
|
||||||
|
"panel-right",
|
||||||
|
"sidebar-right",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,6 +275,8 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> {
|
|||||||
"icons/info.svg" => Some(include_bytes!("../assets/info.svg")),
|
"icons/info.svg" => Some(include_bytes!("../assets/info.svg")),
|
||||||
"icons/eye.svg" => Some(include_bytes!("../assets/eye.svg")),
|
"icons/eye.svg" => Some(include_bytes!("../assets/eye.svg")),
|
||||||
"icons/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")),
|
"icons/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")),
|
||||||
|
"icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")),
|
||||||
|
"icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub(crate) fn glyph(role: IconRole) -> Option<char> {
|
|||||||
IconRole::Info => 0xE946, // Info
|
IconRole::Info => 0xE946, // Info
|
||||||
IconRole::Eye => 0xE7B3, // RedEye
|
IconRole::Eye => 0xE7B3, // RedEye
|
||||||
IconRole::EyeOff => 0xED1A, // Hide
|
IconRole::EyeOff => 0xED1A, // Hide
|
||||||
|
IconRole::PanelLeft => 0xE90C, // DockLeft
|
||||||
|
IconRole::PanelRight => 0xE90D, // DockRight
|
||||||
};
|
};
|
||||||
char::from_u32(codepoint)
|
char::from_u32(codepoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ description = "Application layout bootstrap: sidebars and a center dock, one car
|
|||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
gpui-native-utils = { 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]
|
[dev-dependencies]
|
||||||
gpui = { workspace = true, features = ["test-support"] }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["full"]
|
default = ["full"]
|
||||||
full = ["resize_handle"]
|
full = ["resize_handle", "app_layout"]
|
||||||
resize_handle = []
|
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")]
|
#[cfg(feature = "resize_handle")]
|
||||||
pub mod 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);
|
||||||
|
}
|
||||||
+2
-2
@@ -285,7 +285,7 @@ Units, each through the review gate:
|
|||||||
| Unit | Crate | Component | Notes |
|
| Unit | Crate | Component | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 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 |
|
| 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 | AppLayout | 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`. |
|
| L2 | layout | AppLayout ✅ 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; `app_layout` 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). 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. `app_layout` pulls only core's `button` + `data/sidebar`; the facade `layout` feature re-requests `full` (core/data workspace deps are now `default-features = false`). 5 headless tests in `crates/layout/tests/app_layout.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. **Follow-up (2026-11-01):** the left sidebar is now the data crate's `Sidebar` component itself (`.left_sidebar(sidebar)` — selection, keyboard nav, and a11y come free; `app_layout` pulls `data/sidebar`), the right sidebar stays a free element (a details pane is not a list), and `Sidebar` gained a `sidebar-{id}` debug selector for headless tests |
|
||||||
| 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`). |
|
| 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. |
|
| 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. |
|
||||||
| 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 | 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. |
|
||||||
@@ -374,6 +374,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 ✅, the AppLayout bootstrap, 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 ✅, AppLayout ✅, 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ description = "Live preview of every component against the resolved system theme
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui_platform = { workspace = true }
|
gpui_platform = { workspace = true }
|
||||||
gpui-native-core = { workspace = true }
|
gpui-native-core = { workspace = true, features = ["full"] }
|
||||||
gpui-native-data = { workspace = true }
|
gpui-native-data = { workspace = true, features = ["full"] }
|
||||||
gpui-native-layout = { workspace = true }
|
gpui-native-layout = { workspace = true }
|
||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry};
|
|||||||
use gpui_native_data::tabs::{Tab, Tabs};
|
use gpui_native_data::tabs::{Tab, Tabs};
|
||||||
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::app_layout::{AppLayout, LayoutState};
|
||||||
use gpui_native_layout::resize_handle::ResizeHandle;
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||||
use gpui_platform::application;
|
use gpui_platform::application;
|
||||||
@@ -49,6 +50,8 @@ struct Gallery {
|
|||||||
password_state: Entity<InputState>,
|
password_state: Entity<InputState>,
|
||||||
notes_state: Entity<InputState>,
|
notes_state: Entity<InputState>,
|
||||||
split_width: Pixels,
|
split_width: Pixels,
|
||||||
|
layout_state: Entity<LayoutState>,
|
||||||
|
shell_selection: Option<usize>,
|
||||||
popover_open: bool,
|
popover_open: bool,
|
||||||
menu_open: bool,
|
menu_open: bool,
|
||||||
strategy: Option<Strategy>,
|
strategy: Option<Strategy>,
|
||||||
@@ -144,6 +147,82 @@ fn tree_paths(nodes: &[GalleryNode]) -> Vec<Vec<usize>> {
|
|||||||
paths
|
paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Gallery {
|
||||||
|
/// The app shell demo: sidebars around a center region, toggles in the
|
||||||
|
/// center demonstrating the `LayoutState` API.
|
||||||
|
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_for_toggle = state.clone();
|
||||||
|
|
||||||
|
let toggle = |side: &'static str| {
|
||||||
|
let state = state_for_toggle.clone();
|
||||||
|
Button::new(SharedString::from(format!("gallery-shell-toggle-{side}")))
|
||||||
|
.label(format!("Toggle {}", side))
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.small()
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
state.update(cx, |state, cx| match side {
|
||||||
|
"left" => {
|
||||||
|
let visible = !state.is_left_sidebar_visible();
|
||||||
|
state.set_left_sidebar_visible(visible, cx);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let visible = !state.is_right_sidebar_visible();
|
||||||
|
state.set_right_sidebar_visible(visible, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let left_sidebar = Sidebar::new("gallery-shell-sidebar")
|
||||||
|
.selected(self.shell_selection)
|
||||||
|
.entries(|_, _| {
|
||||||
|
vec![
|
||||||
|
SidebarEntry::header("Repositories"),
|
||||||
|
SidebarEntry::row("gpui-native-kit").icon(IconRole::Folder),
|
||||||
|
SidebarEntry::row("signed").icon(IconRole::Folder),
|
||||||
|
SidebarEntry::header("Explore"),
|
||||||
|
SidebarEntry::row("Popular"),
|
||||||
|
SidebarEntry::row("Recent"),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.on_change({
|
||||||
|
let gallery = gallery.clone();
|
||||||
|
move |selection, _, cx| {
|
||||||
|
gallery.update(cx, |gallery, _| gallery.shell_selection = selection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let right_sidebar = v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.p_2()
|
||||||
|
.child("Details")
|
||||||
|
.child("Signed by the author")
|
||||||
|
.text_sm()
|
||||||
|
.text_color(colors.muted_foreground);
|
||||||
|
|
||||||
|
let center = v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.size_full()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(toggle("left"))
|
||||||
|
.child(toggle("right")),
|
||||||
|
)
|
||||||
|
.child("Center \u{2014} the dock lands here (L3)")
|
||||||
|
.text_color(colors.muted_foreground);
|
||||||
|
|
||||||
|
AppLayout::new("gallery-shell", state)
|
||||||
|
.left_sidebar(left_sidebar)
|
||||||
|
.center(center)
|
||||||
|
.right_sidebar(right_sidebar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -448,6 +527,41 @@ impl Render for Gallery {
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(section_label(
|
||||||
|
"layout · app shell \u{2014} NSWindow sidebar + inspector, WinUI SplitView shell",
|
||||||
|
colors,
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h(px(320.))
|
||||||
|
.rounded(radius.control)
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(self.render_app_shell(&gallery, cx)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(format!(
|
||||||
|
"left: {} · right: {} \u{2014} buttons toggle, seams resize",
|
||||||
|
if self.layout_state.read(cx).is_left_sidebar_visible() {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
},
|
||||||
|
if self.layout_state.read(cx).is_right_sidebar_visible() {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -1538,6 +1652,7 @@ fn main() {
|
|||||||
password_state.update(cx, |state, cx| state.set_password(true, cx));
|
password_state.update(cx, |state, cx| state.set_password(true, cx));
|
||||||
let notes_state = InputState::new(cx);
|
let notes_state = InputState::new(cx);
|
||||||
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 bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
||||||
if let Err(err) = cx.open_window(
|
if let Err(err) = cx.open_window(
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
@@ -1577,6 +1692,8 @@ fn main() {
|
|||||||
password_state,
|
password_state,
|
||||||
notes_state,
|
notes_state,
|
||||||
split_width: px(200.),
|
split_width: px(200.),
|
||||||
|
layout_state,
|
||||||
|
shell_selection: None,
|
||||||
popover_open: false,
|
popover_open: false,
|
||||||
menu_open: false,
|
menu_open: false,
|
||||||
strategy: Some(Strategy::Curated),
|
strategy: Some(Strategy::Curated),
|
||||||
|
|||||||
Reference in New Issue
Block a user