dock: Add left, bottom, right fixed dock and support toggle. (#291)

- Improve TabPanel to support control closable.

## Demo 


https://github.com/user-attachments/assets/dcf8cde3-61ee-42f6-be93-76f10fc8cf34
This commit is contained in:
Jason Lee 2024-10-02 17:42:52 +08:00 committed by GitHub
parent 20b9f9c0f2
commit 6f1be83b6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 913 additions and 158 deletions

View file

@ -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" class="lucide lucide-panel-bottom-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/><path d="m9 10 3-3 3 3"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -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" class="lucide lucide-panel-bottom"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/></svg>

After

Width:  |  Height:  |  Size: 292 B

View file

@ -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" class="lucide lucide-panel-left-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg>

After

Width:  |  Height:  |  Size: 319 B

View file

@ -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" class="lucide lucide-panel-left"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -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" class="lucide lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -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" class="lucide lucide-panel-right"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>

After

Width:  |  Height:  |  Size: 291 B

View file

@ -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<Arc<dyn PanelView>> =
vec![Arc::new(StoryContainer::panel::<ListStory>(cx))];
let bottom_panels: Vec<Arc<dyn PanelView>> = vec![
Arc::new(StoryContainer::panel::<TextStory>(cx)),
Arc::new(StoryContainer::panel::<IconStory>(cx)),
];
let right_panels: Vec<Arc<dyn PanelView>> =
vec![Arc::new(StoryContainer::panel::<ImageStory>(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::<DropdownStory>(cx)),
Arc::new(StoryContainer::panel::<ModalStory>(cx)),
Arc::new(StoryContainer::panel::<PopupStory>(cx)),
Arc::new(StoryContainer::panel::<ListStory>(cx)),
Arc::new(StoryContainer::panel::<SwitchStory>(cx)),
Arc::new(StoryContainer::panel::<ProgressStory>(cx)),
Arc::new(StoryContainer::panel::<TableStory>(cx)),

View file

@ -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))
})
}
}

View file

@ -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: 展開

310
crates/ui/src/dock/dock.rs Normal file
View file

@ -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<DockArea>,
pub(crate) panel: View<TabPanel>,
/// 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<DockArea>,
placement: DockPlacement,
cx: &mut ViewContext<Self>,
) -> 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<DockArea>, cx: &mut ViewContext<Self>) -> Self {
Self::new(dock_area, DockPlacement::Left, cx)
}
pub fn bottom(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
Self::new(dock_area, DockPlacement::Bottom, cx)
}
pub fn right(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
Self::new(dock_area, DockPlacement::Right, cx)
}
pub fn set_panels(&mut self, panels: Vec<Arc<dyn PanelView>>, cx: &mut ViewContext<Self>) {
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>) {
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>) {
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>) {
self.open = open;
cx.notify();
}
fn render_resize_handle(&mut self, cx: &mut ViewContext<Self>) -> 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<Pixels>, cx: &mut ViewContext<Self>) {
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>) {
self.is_resizing = false;
}
}
impl Render for Dock {
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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<Dock>,
}
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<gpui::ElementId> {
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<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut gpui::WindowContext,
) -> Self::PrepaintState {
()
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<Pixels>,
_: &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));
}
}
})
}
}

View file

@ -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<Pixels>,
/// The center view of the dockarea.
items: DockItem,
/// The left dock of the dockarea.
left_dock: Option<View<Dock>>,
/// The bottom dock of the dockarea.
bottom_dock: Option<View<Dock>>,
/// The right dock of the dockarea.
right_dock: Option<View<Dock>>,
/// The top zoom view of the dockarea, if any.
zoom_view: Option<AnyView>,
}
@ -166,7 +179,7 @@ impl DockItem {
}
impl DockArea {
pub fn new(id: impl Into<SharedString>, cx: &mut WindowContext) -> Self {
pub fn new(id: impl Into<SharedString>, cx: &mut ViewContext<Self>) -> 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>) {
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<Arc<dyn PanelView>>,
size: Option<Pixels>,
cx: &mut ViewContext<Self>,
) {
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<Arc<dyn PanelView>>,
size: Option<Pixels>,
cx: &mut ViewContext<Self>,
) {
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<Arc<dyn PanelView>>,
size: Option<Pixels>,
cx: &mut ViewContext<Self>,
) {
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<Self>) {
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<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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))
}),
)
}
})
}

View file

@ -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<TabPanel>, cx: &AppContext) -> bool {
let first_panel = self.panels.first();
if let Some(view) = first_panel {
if let Ok(view) = view.view().downcast::<TabPanel>() {
return view.entity_id() == panel.entity_id();
} else if let Ok(view) = view.view().downcast::<Self>() {
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<TabPanel>, 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::<TabPanel>() {
return view.entity_id() == panel.entity_id();
} else if let Ok(view) = view.view().downcast::<Self>() {
return view.read(cx).is_top_right_panel(panel, cx);
}
}
false
}
}
impl FocusableView for StackPanel {

View file

@ -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<DockArea>,
/// The stock_panel can be None, if is None, that means the panels can't be split or move
stack_panel: Option<View<StackPanel>>,
pub(crate) panels: Vec<Arc<dyn PanelView>>,
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<Placement>,
@ -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<Self>) -> 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<Self>,
) -> Option<impl IntoElement> {
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<Self>) -> 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::<DragPanel>(|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::<DragPanel>(|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::<DragPanel>(|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::<DragPanel>(|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::<DragPanel>("", |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::<DragPanel>("", |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<PanelEvent> for TabPanel {}
impl Render for TabPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
let focus_handle = self.focus_handle(cx);
v_flex()
.id("tab-panel")
.track_focus(&focus_handle)

View file

@ -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",

View file

@ -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 {
ResizablePanelGroup::new(cx).axis(Axis::Horizontal)

View file

@ -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<View<ResizablePanel>>,
sizes: Vec<Pixels>,
axis: Axis,
handle_size: Pixels,
size: Option<Pixels>,
bounds: Bounds<Pixels>,
resizing_panel_ix: Option<usize>,
@ -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 {
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>) -> 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<Self>) -> 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<Self>) {

View file

@ -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<Div>,
axis: Axis,
}
impl ResizeHandle {
fn new(id: impl Into<ElementId>, 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<ElementId>, 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)),
)
}
}