diff --git a/Cargo.lock b/Cargo.lock index c27dbab2..82393cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5427,6 +5427,7 @@ dependencies = [ "taffy", "unicode-segmentation", "usvg", + "uuid", "windows", "wry", ] @@ -5577,9 +5578,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ "getrandom 0.2.15", "serde", diff --git a/README.md b/README.md index 332a3fca..93f09d09 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ This is an example of build app by using GPUI. - [x] Floating Popover - [x] Child window Popover - [x] Dockpanel -- [ ] Splitter +- [x] Resizable - [ ] Progress - [ ] ProgressBar - [ ] Loading diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index efabbe11..44061cbb 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -2,7 +2,8 @@ use gpui::*; use prelude::FluentBuilder as _; use story::{ ButtonStory, CheckboxStory, DropdownStory, ImageStory, InputStory, ListStory, PickerStory, - PopoverStory, ProgressStory, StoryContainer, SwitchStory, TableStory, TooltipStory, + PopoverStory, ProgressStory, ResizableStory, StoryContainer, SwitchStory, TableStory, + TooltipStory, }; use workspace::{dock::DockPosition, TitleBar, Workspace}; @@ -54,7 +55,7 @@ impl StoryWorkspace { InputStory::view(cx).into(), workspace.clone(), DockPosition::Right, - px(500.0), + px(350.0), cx, ); @@ -62,7 +63,7 @@ impl StoryWorkspace { CheckboxStory::view(cx).into(), workspace.clone(), DockPosition::Bottom, - px(300.), + px(200.), cx, ); @@ -115,7 +116,7 @@ impl StoryWorkspace { ListStory::view(cx).into(), workspace.clone(), DockPosition::Left, - px(360.), + px(300.), cx, ); @@ -146,6 +147,15 @@ impl StoryWorkspace { ) .detach(); + StoryContainer::add_pane( + "Resizable", + "Accessible resizable panel groups and layouts with keyboard support.", + ResizableStory::view(cx).into(), + workspace.clone(), + cx, + ) + .detach(); + Self { workspace } } diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 1a283625..fc9a74a6 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -7,6 +7,7 @@ mod list_story; mod picker_story; mod popover_story; mod progress_story; +mod resizable_story; mod switch_story; mod table_story; mod tooltip_story; @@ -20,6 +21,7 @@ pub use list_story::ListStory; pub use picker_story::PickerStory; pub use popover_story::PopoverStory; pub use progress_story::ProgressStory; +pub use resizable_story::ResizableStory; pub use switch_story::SwitchStory; pub use table_story::TableStory; pub use tooltip_story::TooltipStory; @@ -206,7 +208,9 @@ impl StoryContainer { impl Render for StoryContainer { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { v_flex() + .id("story-container") .size_full() + .overflow_scroll() .child( div() .flex() diff --git a/crates/story/src/picker_story.rs b/crates/story/src/picker_story.rs index 0b60286f..c9431bd1 100644 --- a/crates/story/src/picker_story.rs +++ b/crates/story/src/picker_story.rs @@ -36,7 +36,7 @@ impl ListDelegate for ListItemDeletegate { .items .iter() .filter(|item| item.to_lowercase().contains(&query.to_lowercase())) - .map(|s| s.clone()) + .cloned() .collect(); cx.notify(); } diff --git a/crates/story/src/resizable_story.rs b/crates/story/src/resizable_story.rs new file mode 100644 index 00000000..c622fb20 --- /dev/null +++ b/crates/story/src/resizable_story.rs @@ -0,0 +1,109 @@ +use gpui::{ + div, px, AnyElement, IntoElement, ParentElement as _, Render, SharedString, Styled, View, + ViewContext, VisualContext, WindowContext, +}; +use ui::theme::ActiveTheme; +use ui::{ + resizable::{h_resizable, resizable_panel, v_resizable, ResizablePanelGroup}, + v_flex, +}; + +pub struct ResizableStory { + group1: View, + group2: View, +} + +impl ResizableStory { + pub fn view(cx: &mut WindowContext) -> View { + cx.new_view(|cx| Self::new(cx)) + } + + fn new(cx: &mut WindowContext) -> Self { + fn panel_box(content: impl Into, cx: &WindowContext) -> AnyElement { + div() + .p_4() + .border_1() + .border_color(cx.theme().border) + .size_full() + .child(content.into()) + .into_any_element() + } + + let group1 = cx.new_view(|cx| { + v_resizable() + .group( + h_resizable() + .size(px(150.)) + .child( + resizable_panel() + .size(px(300.)) + .min_size(px(120.)) + .content(|cx| panel_box("Left 1 (Min 120px)", cx)), + cx, + ) + .child( + resizable_panel() + .size(px(400.)) + .min_size(px(100.)) + .content(|cx| panel_box("Center 1", cx)), + cx, + ) + .child( + resizable_panel() + .size(px(300.)) + .min_size(px(100.)) + .grow() + .content(|cx| panel_box("Right (Grow)", cx)), + cx, + ), + cx, + ) + .child( + resizable_panel() + .size(px(150.)) + .max_size(px(550.)) + .min_size(px(100.)) + .grow() + .content(|cx| panel_box("Center (Grow)", cx)), + cx, + ) + .child( + resizable_panel() + .size(px(210.)) + .min_size(px(100.)) + .content(|cx| panel_box("Bottom", cx)), + cx, + ) + }); + + let group2 = cx.new_view(|cx| { + h_resizable() + .child( + resizable_panel() + .size(px(300.)) + .min_size(px(100.)) + .content(|cx| panel_box("Left 2", cx)), + cx, + ) + .child( + resizable_panel() + .size(px(400.)) + .max_size(px(550.)) + .min_size(px(100.)) + .grow() + .content(|cx| panel_box("Right (Grow)", cx)), + cx, + ) + }); + Self { group1, group2 } + } +} + +impl Render for ResizableStory { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + v_flex() + .gap_6() + .child(self.group1.clone()) + .child(self.group2.clone()) + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 0ec2a811..04d0e31f 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -24,6 +24,7 @@ usvg = { version = "0.41.0", default-features = false } taffy = "0.4.3" paste = "1" once_cell = "1.19.0" +uuid = "1.10.0" [lints] workspace = true diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 4916e69b..56bff5d6 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -22,6 +22,7 @@ pub mod popover; pub mod popup_menu; pub mod prelude; pub mod progress; +pub mod resizable; pub mod switch; pub mod tab; pub mod table; diff --git a/crates/ui/src/resizable/mod.rs b/crates/ui/src/resizable/mod.rs new file mode 100644 index 00000000..aca31f3f --- /dev/null +++ b/crates/ui/src/resizable/mod.rs @@ -0,0 +1,16 @@ +use gpui::Axis; + +mod panel; +pub use panel::*; + +pub fn h_resizable() -> ResizablePanelGroup { + ResizablePanelGroup::new().axis(Axis::Horizontal) +} + +pub fn v_resizable() -> ResizablePanelGroup { + ResizablePanelGroup::new().axis(Axis::Vertical) +} + +pub fn resizable_panel() -> ResizablePanel { + ResizablePanel::new() +} diff --git a/crates/ui/src/resizable/panel.rs b/crates/ui/src/resizable/panel.rs new file mode 100644 index 00000000..856b76ce --- /dev/null +++ b/crates/ui/src/resizable/panel.rs @@ -0,0 +1,278 @@ +use std::rc::Rc; + +use gpui::{ + canvas, deferred, div, prelude::FluentBuilder as _, px, AnyElement, AnyView, Axis, Bounds, + DragMoveEvent, EntityId, InteractiveElement as _, IntoElement, ParentElement, Pixels, Render, + StatefulInteractiveElement, Styled, View, ViewContext, VisualContext as _, WindowContext, +}; + +use crate::{h_flex, theme::ActiveTheme, v_flex}; + +#[derive(Clone, Render)] +pub struct DragPanel(pub (EntityId, usize, Axis)); + +#[derive(Clone)] +pub struct ResizablePanelGroup { + panels: Vec>, + sizes: Vec, + axis: Axis, + handle_size: Pixels, + size: Pixels, +} + +impl ResizablePanelGroup { + pub(super) fn new() -> Self { + Self { + axis: Axis::Horizontal, + sizes: Vec::new(), + panels: Vec::new(), + handle_size: px(3.), + size: px(20.), + } + } + + /// Set the axis of the resizable panel group, default is horizontal. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Set the size of the resize handle, default is 3px. + /// + /// The handle size will inherit the parent group handle size, if you insert a group into another group. + pub fn handle_size(mut self, size: Pixels) -> Self { + self.handle_size = size; + self + } + + /// Add a resizable panel to the group. + pub fn child(mut self, panel: ResizablePanel, cx: &mut WindowContext) -> Self { + let mut panel = panel; + panel.axis = self.axis; + self.sizes.push(panel.size); + self.panels.push(cx.new_view(|_| panel)); + self + } + + /// Add a ResizablePanelGroup as a child to the group. + pub fn group(self, group: ResizablePanelGroup, cx: &mut WindowContext) -> Self { + let mut group: ResizablePanelGroup = group; + group.handle_size = self.handle_size; + let size = group.size; + let panel = ResizablePanel::new() + .content_view(cx.new_view(|_| group).into()) + .size(size); + self.child(panel, cx) + } + + /// Set size of the resizable panel group + /// + /// - When the axis is horizontal, the size is the height of the group. + /// - When the axis is vertical, the size is the width of the group. + pub fn size(mut self, size: Pixels) -> Self { + self.size = size; + self + } + + fn render_resize_handle(&self, ix: usize, cx: &mut ViewContext) -> impl IntoElement { + let axis = self.axis; + let handle_size = self.handle_size; + + deferred( + div() + .id(("resizable-handle", ix)) + .occlude() + .hover(|this| this.bg(cx.theme().drag_border)) + .on_drag_move(cx.listener(move |view, e: &DragMoveEvent, cx| { + match e.drag(cx) { + DragPanel((entity_id, ix, axis)) => { + let ix = *ix; + if cx.entity_id() != *entity_id { + return; + } + + let panel = view + .panels + .get(ix) + .expect("BUG: invalid panel index") + .read(cx); + + match axis { + Axis::Horizontal => { + let size = e.event.position.x - panel.bounds.left(); + view.resize_panels(ix, size, cx) + } + Axis::Vertical => { + let size = e.event.position.y - panel.bounds.top(); + view.resize_panels(ix, size, cx); + } + } + } + } + })) + .when(self.axis == Axis::Horizontal, |this| { + this.cursor_col_resize().top_0().w(handle_size).h_full() + }) + .when(self.axis == Axis::Vertical, |this| { + this.cursor_row_resize().left_0().w_full().h(handle_size) + }) + .on_drag(DragPanel((cx.entity_id(), ix, axis)), |drag_panel, cx| { + cx.stop_propagation(); + cx.new_view(|_| drag_panel.clone()) + }), + ) + } + + /// The `ix`` is the index of the panel to resize, + /// and the `size` is the new size for the panel. + fn resize_panels(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext) { + // Only resize the middle panels. + if ix == self.panels.len() - 1 { + return; + } + + let old_size = self.sizes[ix]; + let size = self.panels[ix].read(cx).limit_size(size); + let changed_size = size - old_size; + + // If change size is less than 1px, do nothing. + if changed_size > px(-1.0) && changed_size < px(1.0) { + return; + } + self.sizes[ix] = size; + + let next_size = self.sizes[ix + 1]; + self.sizes[ix + 1] = self.panels[ix + 1] + .read(cx) + .limit_size(next_size - changed_size); + + for (i, panel) in self.panels.iter_mut().enumerate() { + let size = self.sizes[i]; + panel.update(cx, |this, _| this.size = size); + } + cx.notify(); + } +} + +impl Render for ResizablePanelGroup { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let mut children: Vec = vec![]; + for (ix, panel) in self.panels.iter().enumerate() { + children.push(panel.clone().into_any_element()); + if ix < self.panels.len() - 1 { + children.push(self.render_resize_handle(ix, cx).into_any_element()); + } + } + + let container = if self.axis == Axis::Horizontal { + h_flex() + } else { + v_flex() + }; + + container.size_full().children(children) + } +} + +pub struct ResizablePanel { + size: Pixels, + max_size: Option, + min_size: Option, + axis: Axis, + content_builder: Option AnyElement>>, + content_view: Option, + /// The bounds of the resizable panel, when render the bounds will be updated. + bounds: Bounds, + + grow: bool, +} + +impl ResizablePanel { + pub(super) fn new() -> Self { + Self { + size: px(20.), + axis: Axis::Horizontal, + max_size: None, + min_size: None, + content_builder: None, + content_view: None, + bounds: Bounds::default(), + grow: false, + } + } + + pub fn content(mut self, content: F) -> Self + where + F: Fn(&mut WindowContext) -> AnyElement + 'static, + { + self.content_builder = Some(Rc::new(content)); + self + } + + pub fn content_view(mut self, content: AnyView) -> Self { + self.content_view = Some(content); + self + } + + pub fn size(mut self, size: Pixels) -> Self { + self.size = size; + self + } + + pub fn max_size(mut self, max_size: Pixels) -> Self { + self.max_size = Some(max_size); + self + } + + pub fn min_size(mut self, min_size: Pixels) -> Self { + self.min_size = Some(min_size); + self + } + + fn limit_size(&self, size: Pixels) -> Pixels { + if let Some(max_size) = self.max_size { + if size > max_size { + return max_size; + } + } + + if let Some(min_size) = self.min_size { + if size < min_size { + return min_size; + } + } + + size + } + + /// Set the panel to grow to fill the remaining space. + pub fn grow(mut self) -> Self { + self.grow = true; + self + } +} + +impl Render for ResizablePanel { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let size = self.limit_size(self.size); + + div() + .size_full() + .relative() + .when(self.grow, |this| this.flex_grow()) + .when(self.axis == Axis::Vertical, |this| this.h(size)) + .when(self.axis == Axis::Horizontal, |this| this.w(size)) + .overflow_hidden() + .child({ + let view = cx.view().clone(); + canvas( + move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _| {}, + ) + .absolute() + .size_full() + }) + .when_some(self.content_builder.clone(), |this, c| this.child(c(cx))) + .when_some(self.content_view.clone(), |this, c| this.child(c)) + } +} diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index c76f3d30..d39bd9c4 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -127,15 +127,8 @@ pub trait StyledExt: Styled + Sized { } /// Render a border with a width of 1px, color ring color - /// - /// Please ensure this after the shadow fn outline(self, cx: &WindowContext) -> Self { - self.shadow(smallvec![BoxShadow { - color: cx.theme().ring, - offset: point(px(0.), px(0.)), - blur_radius: px(0.1), - spread_radius: px(1.), - }]) + self.border_color(cx.theme().ring) } } diff --git a/crates/ui/src/tab/tab.rs b/crates/ui/src/tab/tab.rs index 6f8f6917..13817fa1 100644 --- a/crates/ui/src/tab/tab.rs +++ b/crates/ui/src/tab/tab.rs @@ -1,10 +1,9 @@ use crate::selectable::Selectable; use crate::theme::{ActiveTheme, Colorize}; use gpui::prelude::FluentBuilder as _; -use gpui::InteractiveElement; use gpui::{ - div, px, AnyElement, Div, ElementId, IntoElement, ParentElement as _, RenderOnce, Stateful, - StatefulInteractiveElement, Styled, WindowContext, + div, AnyElement, Div, ElementId, InteractiveElement, IntoElement, ParentElement as _, + RenderOnce, Stateful, StatefulInteractiveElement, Styled, WindowContext, }; #[derive(IntoElement)] @@ -20,7 +19,7 @@ pub struct Tab { impl Tab { pub fn new(id: impl Into, label: impl Into) -> Self { Self { - base: div().id(id.into()).gap_1().py_1().px_3(), + base: div().id(id.into()).gap_1().py_1p5().px_3().h_8(), label: label.into(), disabled: false, selected: false, @@ -66,20 +65,23 @@ impl Styled for Tab { impl RenderOnce for Tab { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let (text_color, bg_color) = match (self.selected, self.disabled) { - (true, _) => (cx.theme().foreground, cx.theme().background), - (false, true) => (cx.theme().foreground.opacity(0.5), cx.theme().muted), - (false, false) => (cx.theme().muted_foreground, cx.theme().muted), + (true, _) => (cx.theme().tab_active_foreground, cx.theme().tab_active), + (false, true) => (cx.theme().tab_foreground.opacity(0.5), cx.theme().tab), + (false, false) => (cx.theme().muted_foreground, cx.theme().tab), }; self.base .flex() .items_center() - .h_full() .flex_shrink_0() .cursor_pointer() .text_color(text_color) .bg(bg_color) - .when(self.selected, |this| this.rounded(px(6.))) + .border_x_1() + .border_color(bg_color) + .border_color(cx.theme().transparent) + .when(self.selected, |this| this.border_color(cx.theme().border)) + .text_sm() .when(self.disabled, |this| this) .when_some(self.prefix, |this, prefix| { this.child(prefix).text_color(text_color) diff --git a/crates/ui/src/tab/tab_bar.rs b/crates/ui/src/tab/tab_bar.rs index bd5e8dea..ac09267a 100644 --- a/crates/ui/src/tab/tab_bar.rs +++ b/crates/ui/src/tab/tab_bar.rs @@ -1,10 +1,10 @@ use crate::stack::h_flex; use crate::theme::ActiveTheme; -use gpui::InteractiveElement; use gpui::{ div, AnyElement, Div, IntoElement, ParentElement, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, WindowContext, }; +use gpui::{px, InteractiveElement}; use smallvec::SmallVec; #[derive(IntoElement)] @@ -18,7 +18,7 @@ pub struct TabBar { impl TabBar { pub fn new(id: impl Into) -> Self { Self { - base: div().h_10().p_1(), + base: div().h_8().px(px(-1.)), id: id.into(), children: SmallVec::new(), scroll_handle: ScrollHandle::new(), @@ -54,8 +54,10 @@ impl RenderOnce for TabBar { .flex() .flex_none() .items_center() - .bg(theme.muted) - .text_color(theme.muted_foreground) + .bg(theme.tab_bar) + .border_b_1() + .border_color(cx.theme().border) + .text_color(theme.tab_foreground) // The child will append to this level .child( h_flex() diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 1509cb56..a27eb8d9 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -1,4 +1,4 @@ -use gpui::{hsla, AppContext, Global, Hsla, WindowAppearance}; +use gpui::{hsla, point, AppContext, BoxShadow, Global, Hsla, Pixels, WindowAppearance}; pub trait ActiveTheme { fn theme(&self) -> &Theme; @@ -19,6 +19,27 @@ pub fn hsl(h: f32, s: f32, l: f32) -> Hsla { hsla(h / 360., s / 100.0, l / 100.0, 1.0) } +/// Make a BoxShadow like CSS +/// +/// e.g: +/// +/// If CSS is `box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);` +/// +/// Then the equivalent in Rust is `box_shadow(0., 0., 10., 0., hsla(0., 0., 0., 0.1))` +pub fn box_shadow( + x: impl Into, + y: impl Into, + blur: impl Into, + spread: impl Into, + color: Hsla, +) -> BoxShadow { + BoxShadow { + offset: point(x.into(), y.into()), + blur_radius: blur.into(), + spread_radius: spread.into(), + color, + } +} pub trait Colorize { fn opacity(&self, opacity: f32) -> Hsla; fn divide(&self, divisor: f32) -> Hsla; @@ -112,7 +133,7 @@ struct Colors { pub scrollbar: Hsla, pub scrollbar_thumb: Hsla, pub panel: Hsla, - pub drop_target: Hsla, + pub tab_bar: Hsla, } impl Colors { @@ -172,7 +193,7 @@ impl Colors { scrollbar: Hsla::transparent_black(), scrollbar_thumb: hsl(240.0, 5.9, 85.0).opacity(0.7), panel: hsl(0.0, 0.0, 100.0), - drop_target: hsl(240.0, 65., 44.0).opacity(0.15), + tab_bar: hsl(240.0, 4.8, 95.9), } } @@ -231,7 +252,7 @@ impl Colors { scrollbar: Hsla::transparent_black(), scrollbar_thumb: hsl(240.0, 3.7, 15.9).opacity(0.7), panel: hsl(299.0, 2., 9.), - drop_target: hsl(240.0, 89., 67.0).opacity(0.8), + tab_bar: hsl(299.0, 2., 9.), } } } @@ -272,8 +293,14 @@ pub struct Theme { pub scrollbar: Hsla, pub scrollbar_thumb: Hsla, pub panel: Hsla, + pub drag_border: Hsla, pub drop_target: Hsla, pub radius: f32, + pub tab_bar: Hsla, + pub tab: Hsla, + pub tab_active: Hsla, + pub tab_foreground: Hsla, + pub tab_active_foreground: Hsla, } impl Global for Theme {} @@ -321,7 +348,13 @@ impl From for Theme { scrollbar_thumb: colors.scrollbar_thumb, panel: colors.panel, selection: colors.selection, - drop_target: colors.drop_target, + drag_border: crate::blue_500(), + drop_target: hsl(240.0, 65., 44.0).opacity(0.15), + tab_bar: colors.tab_bar, + tab: gpui::transparent_black(), + tab_active: colors.background, + tab_foreground: colors.foreground, + tab_active_foreground: colors.foreground, } } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 3fce6618..f051a7b7 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -696,9 +696,10 @@ impl Pane { |tab, cx| cx.new_view(|_| tab.clone()), ) .drag_over::(|tab, _, cx| { - tab.border_l_3() - .rounded_l_none() - .border_color(cx.theme().drop_target) + tab.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) }) .drag_over::(|tab, _, cx| tab.bg(cx.theme().drop_target)) .when_some(self.can_drop_predicate.clone(), |this, p| {