diff --git a/assets/icons/panel-bottom-open.svg b/assets/icons/panel-bottom-open.svg new file mode 100644 index 00000000..df77e5b2 --- /dev/null +++ b/assets/icons/panel-bottom-open.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-bottom.svg b/assets/icons/panel-bottom.svg new file mode 100644 index 00000000..ebe599c7 --- /dev/null +++ b/assets/icons/panel-bottom.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-left-open.svg b/assets/icons/panel-left-open.svg new file mode 100644 index 00000000..579e4583 --- /dev/null +++ b/assets/icons/panel-left-open.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg new file mode 100644 index 00000000..2eed2667 --- /dev/null +++ b/assets/icons/panel-left.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-right-open.svg b/assets/icons/panel-right-open.svg new file mode 100644 index 00000000..3b5ff0b4 --- /dev/null +++ b/assets/icons/panel-right-open.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg new file mode 100644 index 00000000..d29a4a51 --- /dev/null +++ b/assets/icons/panel-right.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index 8c83bcec..0e00c91b 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -11,7 +11,7 @@ use story::{ use ui::{ button::{Button, ButtonStyled as _}, color_picker::{ColorPicker, ColorPickerEvent}, - dock::{DockArea, DockEvent, DockItem, DockItemState}, + dock::{DockArea, DockEvent, DockItem, DockItemState, PanelView}, h_flex, popup_menu::PopupMenuExt, theme::{ActiveTheme, Colorize as _, Theme}, @@ -61,7 +61,23 @@ impl StoryWorkspace { } }; - dock_area.update(cx, |view, cx| view.set_root(dock_item, cx)); + let left_panels: Vec> = + vec![Arc::new(StoryContainer::panel::(cx))]; + + let bottom_panels: Vec> = vec![ + Arc::new(StoryContainer::panel::(cx)), + Arc::new(StoryContainer::panel::(cx)), + ]; + + let right_panels: Vec> = + vec![Arc::new(StoryContainer::panel::(cx))]; + + dock_area.update(cx, |view, cx| { + view.set_root(dock_item, cx); + view.set_left_dock(left_panels, Some(px(350.)), cx); + view.set_bottom_dock(bottom_panels, Some(px(200.)), cx); + view.set_right_dock(right_panels, Some(px(320.)), cx); + }); cx.subscribe(&dock_area, |this, dock_area, ev: &DockEvent, cx| match ev { DockEvent::LayoutChanged => this.save_layout(dock_area, cx), @@ -173,7 +189,6 @@ impl StoryWorkspace { Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), - Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 594f3841..edf99d77 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -38,8 +38,7 @@ pub use webview_story::WebViewStory; use gpui::{ actions, div, prelude::FluentBuilder as _, px, AnyElement, AnyView, AppContext, Div, EventEmitter, FocusableView, Hsla, InteractiveElement, IntoElement, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled as _, View, ViewContext, VisualContext, - WindowContext, + SharedString, Styled as _, View, ViewContext, VisualContext, WindowContext, }; use ui::{ @@ -313,7 +312,6 @@ impl Render for StoryContainer { v_flex() .id("story-container") .size_full() - .overflow_scroll() .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_action_panel_info)) .when(self.description.len() > 0, |this| { @@ -328,14 +326,7 @@ impl Render for StoryContainer { ) }) .when_some(self.story.clone(), |this, story| { - this.child( - v_flex() - .id("story-children") - .overflow_scroll() - .size_full() - .p_4() - .child(story), - ) + this.child(v_flex().id("story-children").size_full().p_4().child(story)) }) } } diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml index c72ac401..0a6d7ff7 100644 --- a/crates/ui/locales/ui.yml +++ b/crates/ui/locales/ui.yml @@ -103,3 +103,11 @@ Dock: en: Zoom Out zh-CN: 缩小 zh-HK: 縮小 + Collapse: + en: Collapse + zh-CN: 隐藏 + zh-HK: 隱藏 + Expand: + en: Expand + zh-CN: 展开 + zh-HK: 展開 diff --git a/crates/ui/src/dock/dock.rs b/crates/ui/src/dock/dock.rs new file mode 100644 index 00000000..23bdf9d1 --- /dev/null +++ b/crates/ui/src/dock/dock.rs @@ -0,0 +1,310 @@ +//! Dock is a fixed container that places at left, bottom, right of the Windows. + +use std::sync::Arc; + +use gpui::{ + div, prelude::FluentBuilder as _, px, Axis, Element, InteractiveElement as _, IntoElement, + MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, + StatefulInteractiveElement, Style, Styled as _, View, ViewContext, VisualContext as _, + WeakView, +}; + +use crate::{ + resizable::{HANDLE_PADDING, HANDLE_SIZE, PANEL_MIN_SIZE}, + theme::ActiveTheme as _, + AxisExt as _, StyledExt, +}; + +use super::{DockArea, PanelView, TabPanel}; + +#[derive(Clone, Render)] +struct ResizePanel; + +#[derive(Debug, Clone, Copy)] +pub enum DockPlacement { + Left, + Bottom, + Right, +} + +impl DockPlacement { + fn axis(&self) -> Axis { + match self { + Self::Left | Self::Right => Axis::Horizontal, + Self::Bottom => Axis::Vertical, + } + } + + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + pub fn is_bottom(&self) -> bool { + matches!(self, Self::Bottom) + } + + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// The Dock is a fixed container that places at left, bottom, right of the Windows. +/// +/// This is unlike Panel, it can't be move or add any other panel. +pub struct Dock { + placement: DockPlacement, + dock_area: WeakView, + pub(crate) panel: View, + /// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height. + size: Pixels, + open: bool, + resizeable: bool, + is_resizing: bool, +} + +impl Dock { + fn new( + dock_area: WeakView, + placement: DockPlacement, + cx: &mut ViewContext, + ) -> Self { + let panel = cx.new_view(|cx| { + let mut tab = TabPanel::new(None, dock_area.clone(), cx); + tab.closeable = false; + tab.zoomable = false; + tab + }); + + Self { + placement, + dock_area, + panel, + open: true, + resizeable: true, + size: px(200.0), + is_resizing: false, + } + } + + pub fn left(dock_area: WeakView, cx: &mut ViewContext) -> Self { + Self::new(dock_area, DockPlacement::Left, cx) + } + + pub fn bottom(dock_area: WeakView, cx: &mut ViewContext) -> Self { + Self::new(dock_area, DockPlacement::Bottom, cx) + } + + pub fn right(dock_area: WeakView, cx: &mut ViewContext) -> Self { + Self::new(dock_area, DockPlacement::Right, cx) + } + + pub fn set_panels(&mut self, panels: Vec>, cx: &mut ViewContext) { + self.panel.update(cx, |tab_panel, _| { + tab_panel.panels = panels; + tab_panel.active_ix = 0; + }); + cx.notify(); + } + + /// Set the Dock to be resizeable, default: true + pub fn resizeable(mut self, resizeable: bool) -> Self { + self.resizeable = resizeable; + self + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn toggle_open(&mut self, cx: &mut ViewContext) { + self.open = !self.open; + cx.notify(); + } + + /// Returns the size of the Dock, the size is means the width or height of + /// the Dock, if the placement is left or right, the size is width, + /// otherwise the size is height. + pub fn size(&self) -> Pixels { + self.size + } + + /// Set the size of the Dock. + pub fn set_size(&mut self, size: Pixels, cx: &mut ViewContext) { + self.size = size.max(PANEL_MIN_SIZE); + cx.notify(); + } + + /// Set the open state of the Dock. + pub fn set_open(&mut self, open: bool, cx: &mut ViewContext) { + self.open = open; + cx.notify(); + } + + fn render_resize_handle(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let axis = self.placement.axis(); + let neg_offset = -HANDLE_PADDING; + let view = cx.view().clone(); + + div() + .id("resize-handle") + .occlude() + .absolute() + .flex_shrink_0() + .when(self.placement.is_left(), |this| { + // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING) + this.cursor_col_resize() + .top_0() + .right(px(1.)) + .h_full() + .w(HANDLE_SIZE) + .pl(HANDLE_PADDING) + }) + .when(self.placement.is_right(), |this| { + this.cursor_col_resize() + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(self.placement.is_bottom(), |this| { + this.cursor_row_resize() + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }) + .child( + div() + .bg(cx.theme().border) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + .on_drag(ResizePanel {}, move |info, cx| { + cx.stop_propagation(); + view.update(cx, |view, _| { + view.is_resizing = true; + }); + cx.new_view(|_| info.clone()) + }) + } + + fn resize(&mut self, mouse_position: Point, cx: &mut ViewContext) { + if !self.is_resizing { + return; + } + + let area_bounds = self + .dock_area + .upgrade() + .expect("DockArea is missing") + .read(cx) + .bounds; + + let size = match self.placement { + DockPlacement::Left => mouse_position.x - area_bounds.left(), + DockPlacement::Right => area_bounds.right() - mouse_position.x, + DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y, + }; + + self.size = size.max(PANEL_MIN_SIZE); + cx.notify(); + } + + fn done_resizing(&mut self, _: &mut ViewContext) { + self.is_resizing = false; + } +} + +impl Render for Dock { + fn render(&mut self, cx: &mut ViewContext) -> impl gpui::IntoElement { + if !self.open && !self.placement.is_bottom() { + return div(); + } + + div() + .relative() + .overflow_hidden() + .map(|this| match self.placement { + DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size), + DockPlacement::Bottom => this.w_full().h(self.size), + }) + // Bottom Dock should keep the title bar, then user can click the Toggle button + .when(!self.open && self.placement.is_bottom(), |this| { + this.h(px(30.)) + }) + .child(self.panel.clone()) + .child(self.render_resize_handle(cx)) + .child(DockElement { + view: cx.view().clone(), + }) + } +} + +struct DockElement { + view: View, +} + +impl IntoElement for DockElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for DockElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + cx: &mut gpui::WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (cx.request_layout(Style::default(), None), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut gpui::WindowContext, + ) -> Self::PrepaintState { + () + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut gpui::WindowContext, + ) { + cx.on_mouse_event({ + let view = self.view.clone(); + move |e: &MouseMoveEvent, phase, cx| { + if phase.bubble() { + view.update(cx, |view, cx| view.resize(e.position, cx)) + } + } + }); + + // When any mouse up, stop dragging + cx.on_mouse_event({ + let view = self.view.clone(); + move |_: &MouseUpEvent, phase, cx| { + if phase.bubble() { + view.update(cx, |view, cx| view.done_resizing(cx)); + } + } + }) + } +} diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index bc581d5f..933fc9b3 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -1,3 +1,4 @@ +mod dock; mod invalid_panel; mod panel; mod stack_panel; @@ -5,10 +6,11 @@ mod tab_panel; use std::sync::Arc; +pub use dock::*; use gpui::{ - actions, div, prelude::FluentBuilder, AnyElement, AnyView, AppContext, Axis, EventEmitter, - InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, SharedString, Styled, - View, ViewContext, VisualContext, WeakView, WindowContext, + actions, canvas, div, prelude::FluentBuilder, AnyElement, AnyView, AppContext, Axis, Bounds, + EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, + SharedString, Styled, View, ViewContext, VisualContext, WeakView, WindowContext, }; pub use panel::*; pub use stack_panel::*; @@ -31,7 +33,18 @@ pub enum DockEvent { /// The main area of the dock. pub struct DockArea { id: SharedString, + pub(crate) bounds: Bounds, + + /// The center view of the dockarea. items: DockItem, + + /// The left dock of the dockarea. + left_dock: Option>, + /// The bottom dock of the dockarea. + bottom_dock: Option>, + /// The right dock of the dockarea. + right_dock: Option>, + /// The top zoom view of the dockarea, if any. zoom_view: Option, } @@ -166,7 +179,7 @@ impl DockItem { } impl DockArea { - pub fn new(id: impl Into, cx: &mut WindowContext) -> Self { + pub fn new(id: impl Into, cx: &mut ViewContext) -> Self { let stack_panel = cx.new_view(|cx| StackPanel::new(Axis::Horizontal, cx)); let dock_item = DockItem::Split { axis: Axis::Horizontal, @@ -177,13 +190,18 @@ impl DockArea { Self { id: id.into(), + bounds: Bounds::default(), items: dock_item, zoom_view: None, + left_dock: None, + right_dock: None, + bottom_dock: None, } } /// The the DockItem as the root of the dock area. - #[must_use] + /// + /// This is used to render at the Center of the DockArea. pub fn set_root(&mut self, item: DockItem, cx: &mut ViewContext) { self.subscribe_item(&item, cx); self.items = item; @@ -191,6 +209,90 @@ impl DockArea { cx.notify(); } + pub fn set_left_dock( + &mut self, + panels: Vec>, + size: Option, + cx: &mut ViewContext, + ) { + let weak_self = cx.view().downgrade(); + self.left_dock = Some(cx.new_view(|cx| { + let mut dock = Dock::left(weak_self.clone(), cx); + if let Some(size) = size { + dock.set_size(size, cx); + } + dock.set_panels(panels, cx); + dock + })) + } + + pub fn set_bottom_dock( + &mut self, + panels: Vec>, + size: Option, + cx: &mut ViewContext, + ) { + let weak_self = cx.view().downgrade(); + self.bottom_dock = Some(cx.new_view(|cx| { + let mut dock = Dock::bottom(weak_self.clone(), cx); + if let Some(size) = size { + dock.set_size(size, cx); + } + dock.set_panels(panels, cx); + dock + })) + } + + pub fn set_right_dock( + &mut self, + panels: Vec>, + size: Option, + cx: &mut ViewContext, + ) { + let weak_self = cx.view().downgrade(); + self.right_dock = Some(cx.new_view(|cx| { + let mut dock = Dock::right(weak_self.clone(), cx); + if let Some(size) = size { + dock.set_size(size, cx); + } + dock.set_panels(panels, cx); + dock + })) + } + + pub fn is_dock_open(&self, placement: DockPlacement, cx: &AppContext) -> bool { + match placement { + DockPlacement::Left => self + .left_dock + .as_ref() + .and_then(|dock| Some(dock.read(cx).is_open())) + .unwrap_or(false), + DockPlacement::Bottom => self + .bottom_dock + .as_ref() + .and_then(|dock| Some(dock.read(cx).is_open())) + .unwrap_or(false), + DockPlacement::Right => self + .right_dock + .as_ref() + .and_then(|dock| Some(dock.read(cx).is_open())) + .unwrap_or(false), + } + } + + pub fn toggle_dock(&self, placement: DockPlacement, cx: &mut ViewContext) { + let dock = match placement { + DockPlacement::Left => &self.left_dock, + DockPlacement::Bottom => &self.bottom_dock, + DockPlacement::Right => &self.right_dock, + }; + if let Some(dock) = dock { + dock.update(cx, |view, cx| { + view.toggle_open(cx); + }) + } + } + /// Dump the dock panels layout to DockItemState. /// /// See also `DockItemState::to_item` for the load DockItem from DockItemState. @@ -275,16 +377,58 @@ impl DockArea { impl EventEmitter for DockArea {} impl Render for DockArea { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - // println!("Rendering dock area"); + let view = cx.view().clone(); + div() .id("dock-area") + .relative() .size_full() .overflow_hidden() + .child( + canvas( + move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _| {}, + ) + .absolute() + .size_full(), + ) .map(|this| { if let Some(zoom_view) = self.zoom_view.clone() { this.child(zoom_view) } else { - this.child(self.render_items(cx)) + this.child( + div() + .flex() + .flex_row() + .h_full() + // Left dock + .when_some(self.left_dock.clone(), |this, dock| { + this.child(div().flex().flex_none().child(dock)) + }) + // Center + .child( + div() + .flex() + .flex_1() + .flex_col() + .overflow_hidden() + // Top center + .child( + div() + .flex_1() + .overflow_hidden() + .child(self.render_items(cx)), + ) + // Bottom Dock + .when_some(self.bottom_dock.clone(), |this, dock| { + this.child(dock) + }), + ) + // Right Dock + .when_some(self.right_dock.clone(), |this, dock| { + this.child(div().flex().flex_none().child(dock)) + }), + ) } }) } diff --git a/crates/ui/src/dock/stack_panel.rs b/crates/ui/src/dock/stack_panel.rs index 7fb74575..1b520211 100644 --- a/crates/ui/src/dock/stack_panel.rs +++ b/crates/ui/src/dock/stack_panel.rs @@ -8,12 +8,12 @@ use crate::{ ResizablePanelGroup, }, theme::ActiveTheme, - Placement, + AxisExt, Placement, }; use super::{DockArea, DockItemState, Panel, PanelEvent, PanelView, TabPanel}; use gpui::{ - prelude::FluentBuilder as _, AppContext, Axis, DismissEvent, EventEmitter, FocusHandle, + prelude::FluentBuilder as _, AppContext, Axis, DismissEvent, Entity, EventEmitter, FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, View, ViewContext, VisualContext, WeakView, }; @@ -277,6 +277,39 @@ impl StackPanel { .update(cx, |view, cx| view.set_axis(axis, cx)); cx.notify(); } + + /// Check if the given panel is at the first top left in the stack. + pub(super) fn is_top_left_panel(&self, panel: View, cx: &AppContext) -> bool { + let first_panel = self.panels.first(); + + if let Some(view) = first_panel { + if let Ok(view) = view.view().downcast::() { + return view.entity_id() == panel.entity_id(); + } else if let Ok(view) = view.view().downcast::() { + return view.read(cx).is_top_left_panel(panel, cx); + } + } + false + } + + /// Check if the given panel is at the first top right in the stack. + pub(super) fn is_top_right_panel(&self, panel: View, cx: &AppContext) -> bool { + let first_panel = if self.axis.is_vertical() { + self.panels.first() + } else { + self.panels.last() + }; + + if let Some(view) = first_panel { + if let Ok(view) = view.view().downcast::() { + return view.entity_id() == panel.entity_id(); + } else if let Ok(view) = view.view().downcast::() { + return view.read(cx).is_top_right_panel(panel, cx); + } + } + + false + } } impl FocusableView for StackPanel { diff --git a/crates/ui/src/dock/tab_panel.rs b/crates/ui/src/dock/tab_panel.rs index 1b346010..ba9ad0ca 100644 --- a/crates/ui/src/dock/tab_panel.rs +++ b/crates/ui/src/dock/tab_panel.rs @@ -1,10 +1,11 @@ use std::sync::Arc; use gpui::{ - div, prelude::FluentBuilder, rems, AnchorCorner, AppContext, DefiniteLength, DismissEvent, - DragMoveEvent, Empty, EventEmitter, FocusHandle, FocusableView, InteractiveElement as _, - IntoElement, ParentElement, Pixels, Render, ScrollHandle, StatefulInteractiveElement, Styled, - View, ViewContext, VisualContext as _, WeakView, WindowContext, + div, prelude::FluentBuilder, px, rems, AnchorCorner, AppContext, DefiniteLength, DismissEvent, + DragMoveEvent, Empty, Entity, EventEmitter, FocusHandle, FocusableView, + InteractiveElement as _, IntoElement, ParentElement, Pixels, Render, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, View, ViewContext, VisualContext as _, + WeakView, WindowContext, }; use rust_i18n::t; @@ -15,11 +16,12 @@ use crate::{ popup_menu::{PopupMenu, PopupMenuExt}, tab::{Tab, TabBar}, theme::ActiveTheme, - v_flex, AxisExt, IconName, Placement, Selectable, Sizable, + v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt, }; use super::{ - ClosePanel, DockArea, DockItemState, Panel, PanelEvent, PanelView, StackPanel, ToggleZoom, + ClosePanel, DockArea, DockItemState, DockPlacement, Panel, PanelEvent, PanelView, StackPanel, + ToggleZoom, }; #[derive(Clone)] @@ -57,12 +59,18 @@ impl Render for DragPanel { pub struct TabPanel { focus_handle: FocusHandle, dock_area: WeakView, + /// The stock_panel can be None, if is None, that means the panels can't be split or move stack_panel: Option>, pub(crate) panels: Vec>, pub(crate) active_ix: usize, tab_bar_scroll_handle: ScrollHandle, - is_zoomed: bool, + /// If this is true, the Panel closeable will follow the active panel's closeable, + /// otherwise this TabPanel will not able to close + pub(crate) closeable: bool, + /// If this is true, the Panel zoomable will follow the active panel's zoomable, + /// otherwise this TabPanel will not able to zoom + pub(crate) zoomable: bool, /// When drag move, will get the placement of the panel to be split will_split_placement: Option, @@ -80,12 +88,20 @@ impl Panel for TabPanel { } fn closeable(&self, cx: &WindowContext) -> bool { + if !self.closeable { + return false; + } + self.active_panel() .map(|panel| panel.closeable(cx)) .unwrap_or(false) } fn zoomable(&self, cx: &WindowContext) -> bool { + if !self.zoomable { + return false; + } + self.active_panel() .map(|panel| panel.zoomable(cx)) .unwrap_or(false) @@ -130,6 +146,8 @@ impl TabPanel { tab_bar_scroll_handle: ScrollHandle::new(), will_split_placement: None, is_zoomed: false, + closeable: true, + zoomable: true, } } @@ -245,6 +263,11 @@ impl TabPanel { } } + /// Return true if the panel can be split or move + fn can_split(&self) -> bool { + self.stack_panel.is_some() + } + fn render_menu_button(&self, cx: &mut ViewContext) -> impl IntoElement { let closeable = self.closeable(cx); let zoomable = self.zoomable(cx); @@ -253,6 +276,8 @@ impl TabPanel { let view = cx.view().clone(); let build_popup_menu = move |this, cx: &WindowContext| view.read(cx).popup_menu(this, cx); + // TODO: Do not show MenuButton if there is no menu items + h_flex() .gap_2() .occlude() @@ -293,9 +318,128 @@ impl TabPanel { ) } + fn render_dock_toggle_button( + &self, + placement: DockPlacement, + cx: &mut ViewContext, + ) -> Option { + let dock_area = self.dock_area.upgrade().expect("BUG: DockArea is missing"); + + if self.is_zoomed { + return None; + } + + let mut self_is_left_dock = false; + let mut self_is_right_dock = false; + let mut self_is_bottom_dock = false; + if let Some(left_view) = &dock_area.read(cx).left_dock { + if left_view.read(cx).panel.entity_id() == cx.view().entity_id() { + self_is_left_dock = true; + } + } + if let Some(right_view) = &dock_area.read(cx).right_dock { + if right_view.read(cx).panel.entity_id() == cx.view().entity_id() { + self_is_right_dock = true; + } + } + if let Some(bottom_view) = &dock_area.read(cx).bottom_dock { + if bottom_view.read(cx).panel.entity_id() == cx.view().entity_id() { + self_is_bottom_dock = true; + } + } + + // Check the dock origin vs self.bounds.origin, if they are in the same line, then render the ToggleButton + match placement { + DockPlacement::Left => { + if self_is_left_dock || self_is_right_dock || self_is_bottom_dock { + return None; + } + + if let Some(parent) = self.stack_panel.as_ref() { + if !parent.read(cx).is_top_left_panel(cx.view().clone(), cx) { + return None; + } + } + } + DockPlacement::Right => { + if self_is_left_dock || self_is_right_dock || self_is_bottom_dock { + return None; + } + + if let Some(parent) = self.stack_panel.as_ref() { + if !parent.read(cx).is_top_right_panel(cx.view().clone(), cx) { + return None; + } + } + } + DockPlacement::Bottom => { + if !self_is_bottom_dock { + return None; + } + } + } + + let is_left_dock_open = dock_area + .read(cx) + .is_dock_open(super::DockPlacement::Left, cx); + let is_right_dock_open = dock_area + .read(cx) + .is_dock_open(super::DockPlacement::Right, cx); + let is_bottom_dock_open = dock_area + .read(cx) + .is_dock_open(super::DockPlacement::Bottom, cx); + + let (icon, is_open) = match placement { + DockPlacement::Left => { + if is_left_dock_open { + (IconName::PanelLeft, true) + } else { + (IconName::PanelLeftOpen, false) + } + } + DockPlacement::Right => { + if is_right_dock_open { + (IconName::PanelRight, true) + } else { + (IconName::PanelRightOpen, false) + } + } + DockPlacement::Bottom => { + if is_bottom_dock_open { + (IconName::PanelBottom, true) + } else { + (IconName::PanelBottomOpen, false) + } + } + }; + + Some( + Button::new(SharedString::from(format!("toggle-dock:{:?}", placement))) + .icon(icon) + .xsmall() + .ghost() + .tooltip(match is_open { + true => t!("Dock.Collapse"), + false => t!("Dock.Expand"), + }) + .on_click(cx.listener({ + let dock_area = dock_area.clone(); + move |_, _, cx| { + dock_area.update(cx, |dock_area, cx| { + dock_area.toggle_dock(placement, cx); + }); + } + })), + ) + } + fn render_tabs(&self, cx: &mut ViewContext) -> impl IntoElement { let view = cx.view().clone(); + let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, cx); + let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, cx); + let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, cx); + if self.panels.len() == 1 { let panel = self.panels.get(0).unwrap(); let title_style = panel.title_style(cx); @@ -304,31 +448,59 @@ impl TabPanel { .justify_between() .items_center() .line_height(rems(1.0)) - .pr_3() + .h(px(30.)) + .py_2() + .px_3() + .when(left_dock_button.is_some(), |this| this.pl_2()) + .when(right_dock_button.is_some(), |this| this.pr_2()) .when_some(title_style, |this, theme| { this.bg(theme.background).text_color(theme.foreground) }) + .when( + left_dock_button.is_some() || bottom_dock_button.is_some(), + |this| { + this.child( + h_flex() + .flex_shrink_0() + .mr_1() + .gap_1() + .children(left_dock_button) + .children(bottom_dock_button), + ) + }, + ) .child( div() .id("tab") - .py_2() - .px_3() + .flex_1() + .debug_red() .min_w_16() .overflow_hidden() .text_ellipsis() + .whitespace_nowrap() + .debug_green() .child(panel.title(cx)) - .on_drag( - DragPanel { - panel: panel.clone(), - tab_panel: view, - }, - |drag, cx| { - cx.stop_propagation(); - cx.new_view(|_| drag.clone()) - }, - ), + .when(self.can_split(), |this| { + this.on_drag( + DragPanel { + panel: panel.clone(), + tab_panel: view, + }, + |drag, cx| { + cx.stop_propagation(); + cx.new_view(|_| drag.clone()) + }, + ) + }), + ) + .child( + h_flex() + .flex_shrink_0() + .ml_1() + .gap_1() + .child(self.render_menu_button(cx)) + .children(right_dock_button), ) - .child(self.render_menu_button(cx)) .into_any_element(); } @@ -336,6 +508,25 @@ impl TabPanel { TabBar::new("tab-bar") .track_scroll(self.tab_bar_scroll_handle.clone()) + .when( + left_dock_button.is_some() || bottom_dock_button.is_some(), + |this| { + this.prefix( + h_flex() + .items_center() + .top_0() + .right_0() + .border_r_1() + .border_b_1() + .h_full() + .border_color(cx.theme().border) + .bg(cx.theme().tab_bar) + .px_2() + .children(left_dock_button) + .children(bottom_dock_button), + ) + }, + ) .children(self.panels.iter().enumerate().map(|(ix, panel)| { let active = ix == self.active_ix; Tab::new(("tab", ix), panel.title(cx)) @@ -344,20 +535,24 @@ impl TabPanel { .on_click(cx.listener(move |view, _, cx| { view.set_active_ix(ix, cx); })) - .on_drag(DragPanel::new(panel.clone(), view.clone()), |drag, cx| { - cx.stop_propagation(); - cx.new_view(|_| drag.clone()) + .when(self.can_split(), |this| { + this.on_drag(DragPanel::new(panel.clone(), view.clone()), |drag, cx| { + cx.stop_propagation(); + cx.new_view(|_| drag.clone()) + }) + .drag_over::(|this, _, cx| { + this.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) + }) + .on_drop(cx.listener( + move |this, drag: &DragPanel, cx| { + this.will_split_placement = None; + this.on_drop(drag, Some(ix), cx) + }, + )) }) - .drag_over::(|this, _, cx| { - this.rounded_l_none() - .border_l_2() - .border_r_0() - .border_color(cx.theme().drag_border) - }) - .on_drop(cx.listener(move |this, drag: &DragPanel, cx| { - this.will_split_placement = None; - this.on_drop(drag, Some(ix), cx) - })) })) .child( // empty space to allow move to last tab right @@ -366,18 +561,20 @@ impl TabPanel { .h_full() .flex_grow() .min_w_16() - .drag_over::(|this, _, cx| this.bg(cx.theme().drop_target)) - .on_drop(cx.listener(move |this, drag: &DragPanel, cx| { - this.will_split_placement = None; + .when(self.can_split(), |this| { + this.drag_over::(|this, _, cx| this.bg(cx.theme().drop_target)) + .on_drop(cx.listener(move |this, drag: &DragPanel, cx| { + this.will_split_placement = None; - let ix = if drag.tab_panel == view { - Some(tabs_count - 1) - } else { - None - }; + let ix = if drag.tab_panel == view { + Some(tabs_count - 1) + } else { + None + }; - this.on_drop(drag, ix, cx) - })), + this.on_drop(drag, ix, cx) + })) + }), ) .suffix( h_flex() @@ -389,8 +586,10 @@ impl TabPanel { .h_full() .border_color(cx.theme().border) .bg(cx.theme().tab_bar) - .px_3() - .child(self.render_menu_button(cx)), + .px_2() + .gap_1() + .child(self.render_menu_button(cx)) + .when_some(right_dock_button, |this, btn| this.child(btn)), ) .into_any_element() } @@ -405,33 +604,39 @@ impl TabPanel { .overflow_x_hidden() .flex_1() .child(panel.view()) - .on_drag_move(cx.listener(Self::on_panel_drag_move)) - .child( - div() - .invisible() - .absolute() - .bg(cx.theme().drop_target) - .map(|this| match self.will_split_placement { - Some(placement) => { - let size = DefiniteLength::Fraction(0.35); - match placement { - Placement::Left => this.left_0().top_0().bottom_0().w(size), - Placement::Right => { - this.right_0().top_0().bottom_0().w(size) + .when(self.can_split(), |this| { + this.on_drag_move(cx.listener(Self::on_panel_drag_move)) + .child( + div() + .invisible() + .absolute() + .bg(cx.theme().drop_target) + .map(|this| match self.will_split_placement { + Some(placement) => { + let size = DefiniteLength::Fraction(0.35); + match placement { + Placement::Left => { + this.left_0().top_0().bottom_0().w(size) + } + Placement::Right => { + this.right_0().top_0().bottom_0().w(size) + } + Placement::Top => { + this.top_0().left_0().right_0().h(size) + } + Placement::Bottom => { + this.bottom_0().left_0().right_0().h(size) + } + } } - Placement::Top => this.top_0().left_0().right_0().h(size), - Placement::Bottom => { - this.bottom_0().left_0().right_0().h(size) - } - } - } - None => this.top_0().left_0().size_full(), - }) - .group_drag_over::("", |this| this.visible()) - .on_drop(cx.listener(|this, drag: &DragPanel, cx| { - this.on_drop(drag, None, cx) - })), - ) + None => this.top_0().left_0().size_full(), + }) + .group_drag_over::("", |this| this.visible()) + .on_drop(cx.listener(|this, drag: &DragPanel, cx| { + this.on_drop(drag, None, cx) + })), + ) + }) .into_any_element() }) .unwrap_or(Empty {}.into_any_element()) @@ -634,6 +839,7 @@ impl EventEmitter for TabPanel {} impl Render for TabPanel { fn render(&mut self, cx: &mut ViewContext) -> impl gpui::IntoElement { let focus_handle = self.focus_handle(cx); + v_flex() .id("tab-panel") .track_focus(&focus_handle) diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index c9dd751a..752dfd61 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -43,6 +43,12 @@ pub enum IconName { Minus, Moon, Palette, + PanelBottom, + PanelBottomOpen, + PanelLeft, + PanelLeftOpen, + PanelRight, + PanelRightOpen, Plus, Search, SortAscending, @@ -95,6 +101,12 @@ impl IconName { IconName::Minus => "icons/minus.svg", IconName::Moon => "icons/moon.svg", IconName::Palette => "icons/palette.svg", + IconName::PanelBottom => "icons/panel-bottom.svg", + IconName::PanelBottomOpen => "icons/panel-bottom-open.svg", + IconName::PanelLeft => "icons/panel-left.svg", + IconName::PanelLeftOpen => "icons/panel-left-open.svg", + IconName::PanelRight => "icons/panel-right.svg", + IconName::PanelRightOpen => "icons/panel-right-open.svg", IconName::Plus => "icons/plus.svg", IconName::Search => "icons/search.svg", IconName::SortAscending => "icons/sort-ascending.svg", diff --git a/crates/ui/src/resizable/mod.rs b/crates/ui/src/resizable/mod.rs index 287f6e67..4c63fe96 100644 --- a/crates/ui/src/resizable/mod.rs +++ b/crates/ui/src/resizable/mod.rs @@ -1,7 +1,9 @@ use gpui::{Axis, ViewContext}; mod panel; +mod resize_handle; pub use panel::*; +pub(crate) use resize_handle::*; pub fn h_resizable(cx: &mut ViewContext) -> ResizablePanelGroup { ResizablePanelGroup::new(cx).axis(Axis::Horizontal) diff --git a/crates/ui/src/resizable/panel.rs b/crates/ui/src/resizable/panel.rs index 16b14d5d..6f96fe8c 100644 --- a/crates/ui/src/resizable/panel.rs +++ b/crates/ui/src/resizable/panel.rs @@ -2,15 +2,16 @@ use std::rc::Rc; use gpui::{ canvas, div, prelude::FluentBuilder, px, Along, AnyElement, AnyView, Axis, Bounds, Element, - Entity, EntityId, EventEmitter, InteractiveElement as _, IntoElement, MouseMoveEvent, - MouseUpEvent, ParentElement, Pixels, Render, StatefulInteractiveElement, Style, Styled, View, - ViewContext, VisualContext as _, WindowContext, + Entity, EntityId, EventEmitter, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement, + Pixels, Render, StatefulInteractiveElement as _, Style, Styled, View, ViewContext, + VisualContext as _, WindowContext, }; -use crate::{h_flex, theme::ActiveTheme, v_flex, AxisExt}; +use crate::{h_flex, v_flex, AxisExt}; -const PANEL_MIN_SIZE: Pixels = px(100.); -const HANDLE_PADDING: Pixels = px(4.); +use super::resize_handle; + +pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.); pub enum ResizablePanelEvent { Resized, @@ -24,7 +25,6 @@ pub struct ResizablePanelGroup { panels: Vec>, sizes: Vec, axis: Axis, - handle_size: Pixels, size: Option, bounds: Bounds, resizing_panel_ix: Option, @@ -36,7 +36,6 @@ impl ResizablePanelGroup { axis: Axis::Horizontal, sizes: Vec::new(), panels: Vec::new(), - handle_size: px(1.), size: None, bounds: Bounds::default(), resizing_panel_ix: None, @@ -59,14 +58,6 @@ impl ResizablePanelGroup { cx.notify(); } - /// 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 ViewContext) -> Self { self.add_child(panel, cx); @@ -75,8 +66,7 @@ impl ResizablePanelGroup { /// Add a ResizablePanelGroup as a child to the group. pub fn group(self, group: ResizablePanelGroup, cx: &mut ViewContext) -> Self { - let mut group: ResizablePanelGroup = group; - group.handle_size = self.handle_size; + let group: ResizablePanelGroup = group; let size = group.size; let panel = ResizablePanel::new() .content_view(cx.new_view(|_| group).into()) @@ -150,52 +140,18 @@ impl ResizablePanelGroup { } fn render_resize_handle(&self, ix: usize, cx: &mut ViewContext) -> impl IntoElement { - let axis = self.axis; - let neg_offset = -HANDLE_PADDING; let view = cx.view().clone(); - - div() - .id(("resizable-handle", ix)) - .occlude() - .absolute() - .flex_shrink_0() - .when(self.axis.is_horizontal(), |this| { - this.cursor_col_resize() - .top_0() - .left(neg_offset) - .h_full() - .w(px(1.)) - .px(HANDLE_PADDING) - }) - .when(self.axis.is_vertical(), |this| { - this.cursor_row_resize() - .top(neg_offset) - .left_0() - .w_full() - .h(px(1.)) - .py(HANDLE_PADDING) - }) - .child( - div() - .bg(cx.theme().border) - .when(self.axis.is_horizontal(), |this| { - this.h_full().w(self.handle_size) - }) - .when(self.axis.is_vertical(), |this| { - this.w_full().h(self.handle_size) - }), - ) - .on_drag( - DragPanel((cx.entity_id(), ix, axis)), - move |drag_panel, cx| { - cx.stop_propagation(); - // Set current resizing panel ix - view.update(cx, |view, _| { - view.resizing_panel_ix = Some(ix); - }); - cx.new_view(|_| drag_panel.clone()) - }, - ) + resize_handle(("resizable-handle", ix), self.axis).on_drag( + DragPanel((cx.entity_id(), ix, self.axis)), + move |drag_panel, cx| { + cx.stop_propagation(); + // Set current resizing panel ix + view.update(cx, |view, _| { + view.resizing_panel_ix = Some(ix); + }); + cx.new_view(|_| drag_panel.clone()) + }, + ) } fn done_resizing(&mut self, cx: &mut ViewContext) { diff --git a/crates/ui/src/resizable/resize_handle.rs b/crates/ui/src/resizable/resize_handle.rs new file mode 100644 index 00000000..324c90f9 --- /dev/null +++ b/crates/ui/src/resizable/resize_handle.rs @@ -0,0 +1,72 @@ +use gpui::{ + div, prelude::FluentBuilder as _, px, Axis, Div, ElementId, InteractiveElement, IntoElement, + ParentElement as _, Pixels, RenderOnce, Stateful, StatefulInteractiveElement, Styled as _, + WindowContext, +}; + +use crate::{theme::ActiveTheme as _, AxisExt as _}; + +pub(crate) const HANDLE_PADDING: Pixels = px(4.); +pub(crate) const HANDLE_SIZE: Pixels = px(1.); + +#[derive(IntoElement)] +pub(crate) struct ResizeHandle { + base: Stateful
, + axis: Axis, +} + +impl ResizeHandle { + fn new(id: impl Into, axis: Axis) -> Self { + Self { + base: div().id(id.into()), + axis, + } + } +} + +/// Create a resize handle for a resizable panel. +pub(crate) fn resize_handle(id: impl Into, axis: Axis) -> ResizeHandle { + ResizeHandle::new(id, axis) +} + +impl InteractiveElement for ResizeHandle { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.base.interactivity() + } +} +impl StatefulInteractiveElement for ResizeHandle {} + +impl RenderOnce for ResizeHandle { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let neg_offset = -HANDLE_PADDING; + + self.base + .occlude() + .absolute() + .flex_shrink_0() + .when(self.axis.is_horizontal(), |this| { + this.cursor_col_resize() + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(self.axis.is_vertical(), |this| { + this.cursor_row_resize() + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }) + .child( + div() + .bg(cx.theme().border) + .when(self.axis.is_horizontal(), |this| { + this.h_full().w(HANDLE_SIZE) + }) + .when(self.axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + } +}