diff --git a/Cargo.lock b/Cargo.lock index 02074c8..591d2a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,6 @@ dependencies = [ "smol", "state", "theme", - "title_bar", "tracing-subscriber", "ui", ] @@ -1749,6 +1748,7 @@ dependencies = [ "anyhow", "common", "gpui", + "linicon", "log", "smallvec", "theme", @@ -6591,20 +6591,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "title_bar" -version = "0.3.0" -dependencies = [ - "anyhow", - "common", - "gpui", - "linicon", - "smallvec", - "theme", - "ui", - "windows 0.61.3", -] - [[package]] name = "tokio" version = "1.49.0" diff --git a/assets/icons/panel-left-open.svg b/assets/icons/panel-left-open.svg new file mode 100644 index 0000000..a0f79d3 --- /dev/null +++ b/assets/icons/panel-left-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg new file mode 100644 index 0000000..c18c700 --- /dev/null +++ b/assets/icons/panel-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index 2147bc7..59a81e3 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -30,7 +30,6 @@ icons = [ assets = { path = "../assets" } ui = { path = "../ui" } dock = { path = "../dock" } -title_bar = { path = "../title_bar" } theme = { path = "../theme" } common = { path = "../common" } state = { path = "../state" } diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index 2cf5ef8..f6de5d8 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -7,22 +7,25 @@ use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEA use dock::panel::{Panel, PanelEvent}; use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, relative, uniform_list, App, AppContext, Context, Entity, EventEmitter, + deferred, div, relative, rems, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task, Window, }; use gpui_tokio::Tokio; use list_item::RoomListItem; use nostr_sdk::prelude::*; +use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION}; -use theme::ActiveTheme; +use theme::{ActiveTheme, TITLEBAR_HEIGHT}; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::input::{InputEvent, InputState, TextInput}; use ui::popup_menu::PopupMenuExt; use ui::{h_flex, v_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension}; use crate::actions::{RelayStatus, Reload}; +use crate::views::compose::compose_button; mod list_item; @@ -589,6 +592,13 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const EMPTY_HELP: &str = "Start a conversation with someone to get started."; + const REQUEST_HELP: &str = + "New message requests from people you don't know will appear here."; + + let nostr = NostrRegistry::global(cx); + let identity = nostr.read(cx).identity(); + let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading(); @@ -619,36 +629,53 @@ impl Render for Sidebar { .size_full() .relative() .gap_3() + // Titlebar + .child( + h_flex().h(TITLEBAR_HEIGHT).w_full().items_center().child( + h_flex() + .h_6() + .w_full() + .gap_2() + .justify_between() + .when_some(identity.read(cx).public_key, |this, public_key| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&public_key, cx); + + this.child( + Button::new("user") + .small() + .reverse() + .transparent() + .icon(IconName::CaretDown) + .child(Avatar::new(profile.avatar()).size(rems(1.5))), + ) + }) + .child(div().pr_2p5().child(compose_button())), + ), + ) // Search Input .child( - div() - .relative() - .mt_3() - .px_2p5() - .w_full() - .h_7() - .flex_none() - .flex() - .child( - TextInput::new(&self.find_input) - .small() - .cleanable() - .appearance(true) - .text_xs() - .map(|this| { - if !self.find_input.read(cx).loading { - this.suffix( - Button::new("find") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - } else { - this - } - }), - ), + div().px_2p5().child( + TextInput::new(&self.find_input) + .small() + .cleanable() + .appearance(true) + .text_xs() + .flex_none() + .map(|this| { + if !self.find_input.read(cx).loading { + this.suffix( + Button::new("find") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + } else { + this + } + }), + ), ) // Chat Rooms .child( @@ -711,14 +738,8 @@ impl Render for Sidebar { .ghost() .rounded() .popup_menu(move |this, _window, _cx| { - this.menu( - "Reload", - Box::new(Reload), - ) - .menu( - "Relay Status", - Box::new(RelayStatus), - ) + this.menu("Reload", Box::new(Reload)) + .menu("Relay Status", Box::new(RelayStatus)) }), ), ), @@ -746,7 +767,7 @@ impl Render for Sidebar { .text_xs() .text_color(cx.theme().text_muted) .line_height(relative(1.25)) - .child(SharedString::from("Start a conversation with someone to get started.")), + .child(SharedString::from(EMPTY_HELP)), ), )) } else { @@ -770,7 +791,7 @@ impl Render for Sidebar { .text_xs() .text_color(cx.theme().text_muted) .line_height(relative(1.25)) - .child(SharedString::from("New message requests from people you don't know will appear here.")), + .child(SharedString::from(REQUEST_HELP)), ), )) } diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index b68d7a4..30860f4 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -1,35 +1,26 @@ use std::sync::Arc; -use auto_update::{AutoUpdateStatus, AutoUpdater}; use chat::{ChatEvent, ChatRegistry}; use chat_ui::{CopyPublicKey, OpenPublicKey}; use common::DEFAULT_SIDEBAR_WIDTH; use dock::dock::DockPlacement; use dock::{ClosePanel, DockArea, DockItem}; -use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, px, relative, rems, App, AppContext, Axis, ClipboardItem, Context, Entity, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Window, + div, px, relative, App, AppContext, Axis, ClipboardItem, Context, Entity, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, }; use nostr_connect::prelude::*; use person::PersonRegistry; -use relay_auth::RelayAuth; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; use theme::{ActiveTheme, Theme, ThemeMode, ThemeRegistry}; -use title_bar::TitleBar; -use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::modal::ModalButtonProps; -use ui::popup_menu::PopupMenuExt; -use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension}; +use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; use crate::actions::{ reset, DarkMode, KeyringPopup, Logout, Settings, Themes, ViewProfile, ViewRelays, }; use crate::user::viewer; -use crate::views::compose::compose_button; use crate::views::{preferences, setup_relay, welcome}; use crate::{sidebar, user}; @@ -39,9 +30,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { #[derive(Debug)] pub struct Workspace { - /// App's Title Bar - title_bar: Entity, - /// App's Dock Area dock: Entity, @@ -52,8 +40,8 @@ pub struct Workspace { impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); - let title_bar = cx.new(|_| TitleBar::new()); - let dock = cx.new(|cx| DockArea::new(window, cx)); + let dock = + cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar)); let mut subscriptions = smallvec![]; @@ -115,7 +103,6 @@ impl Workspace { Self { dock, - title_bar, _subscriptions: subscriptions, } } @@ -353,44 +340,7 @@ impl Workspace { Some(ids) } - fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { - let nostr = NostrRegistry::global(cx); - let identity = nostr.read(cx).identity(); - - h_flex() - .gap_2() - .h_6() - .w_full() - .when_some(identity.read(cx).public_key, |this, public_key| { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&public_key, cx); - - this.child( - Button::new("user") - .small() - .reverse() - .transparent() - .icon(IconName::CaretDown) - .child(Avatar::new(profile.avatar()).size(rems(1.5))) - .popup_menu(move |this, _window, _cx| { - this.label(profile.name()) - .menu_with_icon("Profile", IconName::Emoji, Box::new(ViewProfile)) - .menu_with_icon( - "Messaging Relays", - IconName::Relay, - Box::new(ViewRelays), - ) - .separator() - .menu_with_icon("Dark Mode", IconName::Sun, Box::new(DarkMode)) - .menu_with_icon("Themes", IconName::Moon, Box::new(Themes)) - .menu_with_icon("Settings", IconName::Settings, Box::new(Settings)) - .menu_with_icon("Sign Out", IconName::Door, Box::new(Logout)) - }), - ) - }) - .child(compose_button()) - } - + /* fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let chat = ChatRegistry::global(cx); let status = chat.read(cx).loading(); @@ -471,7 +421,7 @@ impl Workspace { )), )) }) - } + }*/ } impl Render for Workspace { diff --git a/crates/dock/Cargo.toml b/crates/dock/Cargo.toml index 36830b8..0697dfb 100644 --- a/crates/dock/Cargo.toml +++ b/crates/dock/Cargo.toml @@ -13,3 +13,6 @@ gpui.workspace = true smallvec.workspace = true anyhow.workspace = true log.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +linicon = "2.3.0" diff --git a/crates/dock/src/dock.rs b/crates/dock/src/dock.rs index 603510b..a535304 100644 --- a/crates/dock/src/dock.rs +++ b/crates/dock/src/dock.rs @@ -1,17 +1,17 @@ +use std::ops::Deref; use std::sync::Arc; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, App, AppContext, Axis, Context, Element, Entity, InteractiveElement as _, IntoElement, - MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, - StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window, + div, px, App, AppContext, Axis, Context, Element, Entity, IntoElement, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, WeakEntity, + Window, }; -use theme::ActiveTheme; -use ui::resizable::{HANDLE_PADDING, HANDLE_SIZE, PANEL_MIN_SIZE}; -use ui::{AxisExt as _, StyledExt}; +use ui::StyledExt; use super::{DockArea, DockItem}; use crate::panel::PanelView; +use crate::resizable::{resize_handle, PANEL_MIN_SIZE}; use crate::tab_panel::TabPanel; #[derive(Clone, Render)] @@ -67,7 +67,7 @@ pub struct Dock { pub(super) collapsible: bool, /// Whether the Dock is resizing - is_resizing: bool, + resizing: bool, } impl Dock { @@ -98,7 +98,7 @@ impl Dock { open: true, collapsible: true, size: px(200.0), - is_resizing: false, + resizing: false, } } @@ -231,54 +231,16 @@ impl Dock { cx: &mut Context, ) -> impl IntoElement { let axis = self.placement.axis(); - let neg_offset = -HANDLE_PADDING; let view = cx.entity().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) - .pt_12() - .pb_4() - }) - .when(self.placement.is_right(), |this| { - this.cursor_col_resize() - .top_0() - .left(px(-0.5)) - .h_full() - .w(HANDLE_SIZE) - .pt_12() - .pb_4() - }) - .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() - .rounded_full() - .hover(|this| this.bg(cx.theme().border_variant)) - .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) - .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), - ) + resize_handle("resize-handle", axis) + .placement(self.placement) .on_drag(ResizePanel {}, move |info, _, _, cx| { cx.stop_propagation(); - view.update(cx, |view, _| { - view.is_resizing = true; + view.update(cx, |view, _cx| { + view.resizing = true; }); - cx.new(|_| info.clone()) + cx.new(|_| info.deref().clone()) }) } @@ -288,7 +250,7 @@ impl Dock { _window: &mut Window, cx: &mut Context, ) { - if !self.is_resizing { + if !self.resizing { return; } @@ -349,7 +311,7 @@ impl Dock { } fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context) { - self.is_resizing = false; + self.resizing = false; } } @@ -440,7 +402,7 @@ impl Element for DockElement { ) { window.on_mouse_event({ let view = self.view.clone(); - let is_resizing = view.read(cx).is_resizing; + let is_resizing = view.read(cx).resizing; move |e: &MouseMoveEvent, phase, window, cx| { if !is_resizing { return; diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index 13265cf..5c78663 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use gpui::prelude::FluentBuilder; use gpui::{ - actions, canvas, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, - Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, - ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, + actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, + EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, + Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, }; +use ui::ElementExt; use crate::dock::{Dock, DockPlacement}; use crate::panel::{Panel, PanelEvent, PanelStyle, PanelView}; @@ -14,7 +15,10 @@ use crate::tab_panel::TabPanel; pub mod dock; pub mod panel; +mod platforms; +pub mod resizable; pub mod stack_panel; +pub mod tab; pub mod tab_panel; actions!(dock, [ToggleZoom, ClosePanel]); @@ -30,20 +34,31 @@ pub enum DockEvent { /// The main area of the dock. pub struct DockArea { pub(crate) bounds: Bounds, + /// The center view of the dockarea. pub items: DockItem, - /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, - toggle_button_panels: Edges>, + /// The left dock of the dock_area. left_dock: Option>, + /// The bottom dock of the dock_area. bottom_dock: Option>, + /// The right dock of the dock_area. right_dock: Option>, + + /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, + toggle_button_panels: Edges>, + + /// Whether to show the toggle button. + toggle_button_visible: bool, + /// The top zoom view of the dock_area, if any. zoom_view: Option, + /// Lock panels layout, but allow to resize. is_locked: bool, + /// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default). pub(crate) panel_style: PanelStyle, subscriptions: Vec, @@ -322,6 +337,7 @@ impl DockArea { items: dock_item, zoom_view: None, toggle_button_panels: Edges::default(), + toggle_button_visible: true, left_dock: None, right_dock: None, bottom_dock: None, @@ -738,14 +754,7 @@ impl Render for DockArea { .relative() .size_full() .overflow_hidden() - .child( - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) + .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)) .map(|this| { if let Some(zoom_view) = self.zoom_view.clone() { this.child(zoom_view) diff --git a/crates/title_bar/src/platforms/linux.rs b/crates/dock/src/platforms/linux.rs similarity index 91% rename from crates/title_bar/src/platforms/linux.rs rename to crates/dock/src/platforms/linux.rs index 530ed5a..2e675de 100644 --- a/crates/title_bar/src/platforms/linux.rs +++ b/crates/dock/src/platforms/linux.rs @@ -55,6 +55,7 @@ impl WindowControl { Self { kind, fallback } } + #[allow(dead_code)] pub fn is_gnome(&self) -> bool { matches!(detect_desktop_environment(), DesktopEnvironment::Gnome) } @@ -62,8 +63,6 @@ impl WindowControl { impl RenderOnce for WindowControl { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_gnome = self.is_gnome(); - h_flex() .id(self.kind.as_icon_name()) .group("") @@ -71,17 +70,6 @@ impl RenderOnce for WindowControl { .items_center() .rounded_full() .size_6() - .map(|this| { - if is_gnome { - this.bg(cx.theme().tab_inactive_background) - .hover(|this| this.bg(cx.theme().tab_hover_background)) - .active(|this| this.bg(cx.theme().tab_active_background)) - } else { - this.bg(cx.theme().ghost_element_background) - .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .active(|this| this.bg(cx.theme().ghost_element_active)) - } - }) .map(|this| { if let Some(Some(path)) = linux_controls().get(&self.kind).cloned() { this.child( diff --git a/crates/title_bar/src/platforms/mod.rs b/crates/dock/src/platforms/mod.rs similarity index 82% rename from crates/title_bar/src/platforms/mod.rs rename to crates/dock/src/platforms/mod.rs index e0ff781..f880e5d 100644 --- a/crates/title_bar/src/platforms/mod.rs +++ b/crates/dock/src/platforms/mod.rs @@ -1,4 +1,3 @@ #[cfg(target_os = "linux")] pub mod linux; -pub mod mac; pub mod windows; diff --git a/crates/title_bar/src/platforms/windows.rs b/crates/dock/src/platforms/windows.rs similarity index 100% rename from crates/title_bar/src/platforms/windows.rs rename to crates/dock/src/platforms/windows.rs diff --git a/crates/dock/src/resizable/mod.rs b/crates/dock/src/resizable/mod.rs new file mode 100644 index 0000000..ce66f14 --- /dev/null +++ b/crates/dock/src/resizable/mod.rs @@ -0,0 +1,294 @@ +use std::ops::Range; + +use gpui::{ + px, Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, +}; + +mod panel; +mod resize_handle; +pub use panel::*; +pub(crate) use resize_handle::*; + +pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.); + +/// Create a [`ResizablePanelGroup`] with horizontal resizing +pub fn h_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Horizontal) +} + +/// Create a [`ResizablePanelGroup`] with vertical resizing +pub fn v_resizable(id: impl Into) -> ResizablePanelGroup { + ResizablePanelGroup::new(id).axis(Axis::Vertical) +} + +/// Create a [`ResizablePanel`]. +pub fn resizable_panel() -> ResizablePanel { + ResizablePanel::new() +} + +/// State for a [`ResizablePanel`] +#[derive(Debug, Clone)] +pub struct ResizableState { + /// The `axis` will sync to actual axis of the ResizablePanelGroup in use. + axis: Axis, + panels: Vec, + sizes: Vec, + pub(crate) resizing_panel_ix: Option, + bounds: Bounds, +} + +impl Default for ResizableState { + fn default() -> Self { + Self { + axis: Axis::Horizontal, + panels: vec![], + sizes: vec![], + resizing_panel_ix: None, + bounds: Bounds::default(), + } + } +} + +impl ResizableState { + /// Get the size of the panels. + pub fn sizes(&self) -> &Vec { + &self.sizes + } + + pub(crate) fn insert_panel( + &mut self, + size: Option, + ix: Option, + cx: &mut Context, + ) { + let panel_state = ResizablePanelState { + size, + ..Default::default() + }; + + let size = size.unwrap_or(PANEL_MIN_SIZE); + + // We make sure that the size always sums up to the container size + // by reducing the size of all other panels first. + let container_size = self.container_size().max(px(1.)); + let total_leftover_size = (container_size - size).max(px(1.)); + + for (i, panel) in self.panels.iter_mut().enumerate() { + let ratio = self.sizes[i] / container_size; + self.sizes[i] = total_leftover_size * ratio; + panel.size = Some(self.sizes[i]); + } + + if let Some(ix) = ix { + self.panels.insert(ix, panel_state); + self.sizes.insert(ix, size); + } else { + self.panels.push(panel_state); + self.sizes.push(size); + }; + + cx.notify(); + } + + pub(crate) fn sync_panels_count( + &mut self, + axis: Axis, + panels_count: usize, + cx: &mut Context, + ) { + let mut changed = self.axis != axis; + self.axis = axis; + + if panels_count > self.panels.len() { + let diff = panels_count - self.panels.len(); + self.panels + .extend(vec![ResizablePanelState::default(); diff]); + self.sizes.extend(vec![PANEL_MIN_SIZE; diff]); + changed = true; + } + + if panels_count < self.panels.len() { + self.panels.truncate(panels_count); + self.sizes.truncate(panels_count); + changed = true; + } + + if changed { + // We need to make sure the total size is in line with the container size. + self.adjust_to_container_size(cx); + } + } + + pub(crate) fn update_panel_size( + &mut self, + panel_ix: usize, + bounds: Bounds, + size_range: Range, + cx: &mut Context, + ) { + let size = bounds.size.along(self.axis); + // This check is only necessary to stop the very first panel from resizing on its own + // it needs to be passed when the panel is freshly created so we get the initial size, + // but its also fine when it sometimes passes later. + if self.sizes[panel_ix].to_f64() == PANEL_MIN_SIZE.to_f64() { + self.sizes[panel_ix] = size; + self.panels[panel_ix].size = Some(size); + } + self.panels[panel_ix].bounds = bounds; + self.panels[panel_ix].size_range = size_range; + cx.notify(); + } + + pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context) { + self.panels.remove(panel_ix); + self.sizes.remove(panel_ix); + if let Some(resizing_panel_ix) = self.resizing_panel_ix { + if resizing_panel_ix > panel_ix { + self.resizing_panel_ix = Some(resizing_panel_ix - 1); + } + } + self.adjust_to_container_size(cx); + } + + pub(crate) fn replace_panel( + &mut self, + panel_ix: usize, + panel: ResizablePanelState, + cx: &mut Context, + ) { + let old_size = self.sizes[panel_ix]; + + self.panels[panel_ix] = panel; + self.sizes[panel_ix] = old_size; + self.adjust_to_container_size(cx); + } + + pub(crate) fn clear(&mut self) { + self.panels.clear(); + self.sizes.clear(); + } + + #[inline] + pub(crate) fn container_size(&self) -> Pixels { + self.bounds.size.along(self.axis) + } + + pub(crate) fn done_resizing(&mut self, cx: &mut Context) { + self.resizing_panel_ix = None; + cx.emit(ResizablePanelEvent::Resized); + } + + fn panel_size_range(&self, ix: usize) -> Range { + let Some(panel) = self.panels.get(ix) else { + return PANEL_MIN_SIZE..Pixels::MAX; + }; + + panel.size_range.clone() + } + + fn sync_real_panel_sizes(&mut self, _: &App) { + for (i, panel) in self.panels.iter().enumerate() { + self.sizes[i] = panel.bounds.size.along(self.axis); + } + } + + /// The `ix`` is the index of the panel to resize, + /// and the `size` is the new size for the panel. + fn resize_panel(&mut self, ix: usize, size: Pixels, _: &mut Window, cx: &mut Context) { + let old_sizes = self.sizes.clone(); + + let mut ix = ix; + // Only resize the left panels. + if ix >= old_sizes.len() - 1 { + return; + } + let container_size = self.container_size(); + self.sync_real_panel_sizes(cx); + + let move_changed = size - old_sizes[ix]; + if move_changed == px(0.) { + return; + } + + let size_range = self.panel_size_range(ix); + let new_size = size.clamp(size_range.start, size_range.end); + let is_expand = move_changed > px(0.); + + let main_ix = ix; + let mut new_sizes = old_sizes.clone(); + + if is_expand { + let mut changed = new_size - old_sizes[ix]; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix < old_sizes.len() - 1 { + ix += 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + new_sizes[ix] -= to_reduce; + changed -= to_reduce; + } + } else { + let mut changed = new_size - size; + new_sizes[ix] = new_size; + + while changed > px(0.) && ix > 0 { + ix -= 1; + let size_range = self.panel_size_range(ix); + let available_size = (new_sizes[ix] - size_range.start).max(px(0.)); + let to_reduce = changed.min(available_size); + changed -= to_reduce; + new_sizes[ix] -= to_reduce; + } + + new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed; + } + + let total_size: Pixels = new_sizes.iter().map(|s| s.to_f64()).sum::().into(); + + // If total size exceeds container size, adjust the main panel + if total_size > container_size { + let overflow = total_size - container_size; + new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start); + } + + for (i, _) in old_sizes.iter().enumerate() { + let size = new_sizes[i]; + self.panels[i].size = Some(size); + } + self.sizes = new_sizes; + cx.notify(); + } + + /// Adjust panel sizes according to the container size. + /// + /// When the container size changes, the panels should take up the same percentage as they did before. + fn adjust_to_container_size(&mut self, cx: &mut Context) { + if self.container_size().is_zero() { + return; + } + + let container_size = self.container_size(); + let total_size = px(self.sizes.iter().map(f32::from).sum::()); + + for i in 0..self.panels.len() { + let size = self.sizes[i]; + let ratio = size / total_size; + let new_size = container_size * ratio; + + self.sizes[i] = new_size; + self.panels[i].size = Some(new_size); + } + cx.notify(); + } +} + +impl EventEmitter for ResizableState {} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResizablePanelState { + pub size: Option, + pub size_range: Range, + bounds: Bounds, +} diff --git a/crates/dock/src/resizable/panel.rs b/crates/dock/src/resizable/panel.rs new file mode 100644 index 0000000..f72269d --- /dev/null +++ b/crates/dock/src/resizable/panel.rs @@ -0,0 +1,405 @@ +use std::ops::{Deref, Range}; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + div, Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, + Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, + MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, +}; +use ui::{h_flex, v_flex, AxisExt, ElementExt}; + +use super::{resizable_panel, resize_handle, ResizableState}; +use crate::resizable::PANEL_MIN_SIZE; + +pub enum ResizablePanelEvent { + Resized, +} + +#[derive(Clone)] +pub(crate) struct DragPanel; +impl Render for DragPanel { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + Empty + } +} + +/// A group of resizable panels. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +pub struct ResizablePanelGroup { + id: ElementId, + state: Option>, + axis: Axis, + size: Option, + children: Vec, + on_resize: Rc, &mut Window, &mut App)>, +} + +impl ResizablePanelGroup { + /// Create a new resizable panel group. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + axis: Axis::Horizontal, + children: vec![], + state: None, + size: None, + on_resize: Rc::new(|_, _, _| {}), + } + } + + /// Bind yourself to a resizable state entity. + /// + /// If not provided, it will handle its own state internally. + pub fn with_state(mut self, state: &Entity) -> Self { + self.state = Some(state.clone()); + self + } + + /// Set the axis of the resizable panel group, default is horizontal. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Add a panel to the group. + /// + /// - The `axis` will be set to the same axis as the group. + /// - The `initial_size` will be set to the average size of all panels if not provided. + /// - The `group` will be set to the group entity. + pub fn child(mut self, panel: impl Into) -> Self { + self.children.push(panel.into()); + self + } + + /// Add multiple panels to the group. + pub fn children(mut self, panels: impl IntoIterator) -> Self + where + I: Into, + { + self.children = panels.into_iter().map(|panel| panel.into()).collect(); + self + } + + /// 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 = Some(size); + self + } + + /// Set the callback to be called when the panels are resized. + /// + /// ## Callback arguments + /// + /// - Entity: The state of the ResizablePanelGroup. + pub fn on_resize( + mut self, + on_resize: impl Fn(&Entity, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resize = Rc::new(on_resize); + self + } +} + +impl From for ResizablePanel +where + T: Into, +{ + fn from(value: T) -> Self { + resizable_panel().child(value.into()) + } +} + +impl From for ResizablePanel { + fn from(value: ResizablePanelGroup) -> Self { + resizable_panel().child(value) + } +} + +impl EventEmitter for ResizablePanelGroup {} + +impl RenderOnce for ResizablePanelGroup { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.unwrap_or( + window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()), + ); + let container = if self.axis.is_horizontal() { + h_flex() + } else { + v_flex() + }; + + // Sync panels to the state + let panels_count = self.children.len(); + state.update(cx, |state, cx| { + state.sync_panels_count(self.axis, panels_count, cx); + }); + + container + .id(self.id) + .size_full() + .children( + self.children + .into_iter() + .enumerate() + .map(|(ix, mut panel)| { + panel.panel_ix = ix; + panel.axis = self.axis; + panel.state = Some(state.clone()); + panel + }), + ) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + let size_changed = + state.bounds.size.along(self.axis) != bounds.size.along(self.axis); + + state.bounds = bounds; + + if size_changed { + state.adjust_to_container_size(cx); + } + }) + } + }) + .child(ResizePanelGroupElement { + state: state.clone(), + axis: self.axis, + on_resize: self.on_resize.clone(), + }) + } +} + +/// A resizable panel inside a [`ResizablePanelGroup`]. +#[derive(IntoElement)] +pub struct ResizablePanel { + axis: Axis, + panel_ix: usize, + state: Option>, + /// Initial size is the size that the panel has when it is created. + initial_size: Option, + /// size range limit of this panel. + size_range: Range, + children: Vec, + visible: bool, +} + +impl ResizablePanel { + /// Create a new resizable panel. + pub(super) fn new() -> Self { + Self { + panel_ix: 0, + initial_size: None, + state: None, + size_range: (PANEL_MIN_SIZE..Pixels::MAX), + axis: Axis::Horizontal, + children: vec![], + visible: true, + } + } + + /// Set the visibility of the panel, default is true. + pub fn visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Set the initial size of the panel. + pub fn size(mut self, size: impl Into) -> Self { + self.initial_size = Some(size.into()); + self + } + + /// Set the size range to limit panel resize. + /// + /// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`]. + pub fn size_range(mut self, range: impl Into>) -> Self { + self.size_range = range.into(); + self + } +} + +impl ParentElement for ResizablePanel { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for ResizablePanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if !self.visible { + return div().id(("resizable-panel", self.panel_ix)); + } + + let state = self + .state + .expect("BUG: The `state` in ResizablePanel should be present."); + let panel_state = state + .read(cx) + .panels + .get(self.panel_ix) + .expect("BUG: The `index` of ResizablePanel should be one of in `state`."); + let size_range = self.size_range.clone(); + + div() + .id(("resizable-panel", self.panel_ix)) + .flex() + .flex_grow() + .size_full() + .relative() + .when(self.axis.is_vertical(), |this| { + this.min_h(size_range.start).max_h(size_range.end) + }) + .when(self.axis.is_horizontal(), |this| { + this.min_w(size_range.start).max_w(size_range.end) + }) + // 1. initial_size is None, to use auto size. + // 2. initial_size is Some and size is none, to use the initial size of the panel for first time render. + // 3. initial_size is Some and size is Some, use `size`. + .when(self.initial_size.is_none(), |this| this.flex_shrink()) + .when_some(self.initial_size, |this, initial_size| { + // The `self.size` is None, that mean the initial size for the panel, + // so we need set `flex_shrink_0` To let it keep the initial size. + this.when( + panel_state.size.is_none() && !initial_size.is_zero(), + |this| this.flex_none(), + ) + .flex_basis(initial_size) + }) + .map(|this| match panel_state.size { + Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)), + None => this, + }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, cx| { + state.update_panel_size(self.panel_ix, bounds, self.size_range, cx) + }) + } + }) + .children(self.children) + .when(self.panel_ix > 0, |this| { + let ix = self.panel_ix - 1; + this.child(resize_handle(("resizable-handle", ix), self.axis).on_drag( + DragPanel, + move |drag_panel, _, _, cx| { + cx.stop_propagation(); + // Set current resizing panel ix + state.update(cx, |state, _| { + state.resizing_panel_ix = Some(ix); + }); + cx.new(|_| drag_panel.deref().clone()) + }, + )) + }) + } +} + +#[allow(clippy::type_complexity)] +struct ResizePanelGroupElement { + state: Entity, + on_resize: Rc, &mut Window, &mut App)>, + axis: Axis, +} + +impl IntoElement for ResizePanelGroupElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizePanelGroupElement { + type PrepaintState = (); + type RequestLayoutState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let state = self.state.clone(); + let axis = self.axis; + let current_ix = state.read(cx).resizing_panel_ix; + move |e: &MouseMoveEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + let Some(ix) = current_ix else { return }; + + state.update(cx, |state, cx| { + let panel = state.panels.get(ix).expect("BUG: invalid panel index"); + + match axis { + Axis::Horizontal => { + state.resize_panel(ix, e.position.x - panel.bounds.left(), window, cx) + } + Axis::Vertical => { + state.resize_panel(ix, e.position.y - panel.bounds.top(), window, cx); + } + } + cx.notify(); + }) + } + }); + + // When any mouse up, stop dragging + window.on_mouse_event({ + let state = self.state.clone(); + let current_ix = state.read(cx).resizing_panel_ix; + let on_resize = self.on_resize.clone(); + move |_: &MouseUpEvent, phase, window, cx| { + if current_ix.is_none() { + return; + } + if phase.bubble() { + state.update(cx, |state, cx| state.done_resizing(cx)); + on_resize(&state, window, cx); + } + } + }) + } +} diff --git a/crates/dock/src/resizable/resize_handle.rs b/crates/dock/src/resizable/resize_handle.rs new file mode 100644 index 0000000..55cda5d --- /dev/null +++ b/crates/dock/src/resizable/resize_handle.rs @@ -0,0 +1,227 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + div, px, AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, + InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, + Point, Render, StatefulInteractiveElement, Styled as _, Window, +}; +use theme::ActiveTheme; +use ui::AxisExt; + +use crate::dock::DockPlacement; + +pub(crate) const HANDLE_PADDING: Pixels = px(4.); +pub(crate) const HANDLE_SIZE: Pixels = px(1.); + +/// Create a resize handle for a resizable panel. +pub(crate) fn resize_handle( + id: impl Into, + axis: Axis, +) -> ResizeHandle { + ResizeHandle::new(id, axis) +} + +#[allow(clippy::type_complexity)] +pub(crate) struct ResizeHandle { + id: ElementId, + axis: Axis, + drag_value: Option>, + placement: Option, + on_drag: Option, &mut Window, &mut App) -> Entity>>, +} + +impl ResizeHandle { + fn new(id: impl Into, axis: Axis) -> Self { + let id = id.into(); + Self { + id: id.clone(), + on_drag: None, + drag_value: None, + placement: None, + axis, + } + } + + pub(crate) fn on_drag( + mut self, + value: T, + f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, + ) -> Self { + let value = Rc::new(value); + self.drag_value = Some(value.clone()); + self.on_drag = Some(Rc::new(move |p, window, cx| { + f(value.clone(), p, window, cx) + })); + self + } + + #[allow(dead_code)] + pub(crate) fn placement(mut self, placement: DockPlacement) -> Self { + self.placement = Some(placement); + self + } +} + +#[derive(Default, Debug, Clone)] +struct ResizeHandleState { + active: Cell, +} + +impl ResizeHandleState { + fn set_active(&self, active: bool) { + self.active.set(active); + } + + fn is_active(&self) -> bool { + self.active.get() + } +} + +impl IntoElement for ResizeHandle { + type Element = ResizeHandle; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizeHandle { + type PrepaintState = (); + type RequestLayoutState = AnyElement; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let neg_offset = -HANDLE_PADDING; + let axis = self.axis; + + window.with_element_state(id.unwrap(), |state, window| { + let state = state.unwrap_or(ResizeHandleState::default()); + + let bg_color = if state.is_active() { + cx.theme().border_variant + } else { + cx.theme().border + }; + + let mut el = div() + .id(self.id.clone()) + .occlude() + .absolute() + .flex_shrink_0() + .group("handle") + .when_some(self.on_drag.clone(), |this, on_drag| { + this.on_drag( + self.drag_value.clone().unwrap(), + move |_, position, window, cx| on_drag(&position, window, cx), + ) + }) + .map(|this| match self.placement { + Some(DockPlacement::Left) => { + // Special for Left Dock + // 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) + } + _ => this + .when(axis.is_horizontal(), |this| { + this.cursor_col_resize() + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(axis.is_vertical(), |this| { + this.cursor_row_resize() + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }), + }) + .child( + div() + .bg(bg_color) + .group_hover("handle", |this| this.bg(bg_color)) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + .into_any_element(); + + let layout_id = el.request_layout(window, cx); + + ((layout_id, el), state) + }) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + request_layout.prepaint(window, cx); + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.paint(window, cx); + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + window.on_mouse_event({ + let state = state.clone(); + move |ev: &MouseDownEvent, phase, window, _| { + if bounds.contains(&ev.position) && phase.bubble() { + state.set_active(true); + window.refresh(); + } + } + }); + + window.on_mouse_event({ + let state = state.clone(); + move |_: &MouseUpEvent, _, window, _| { + if state.is_active() { + state.set_active(false); + window.refresh(); + } + } + }); + + ((), state) + }); + } +} diff --git a/crates/dock/src/stack_panel.rs b/crates/dock/src/stack_panel.rs index 6a7a18e..62736a5 100644 --- a/crates/dock/src/stack_panel.rs +++ b/crates/dock/src/stack_panel.rs @@ -1,20 +1,20 @@ use std::sync::Arc; -use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, }; use smallvec::SmallVec; -use ui::resizable::{ - h_resizable, resizable_panel, v_resizable, ResizablePanel, ResizablePanelEvent, - ResizablePanelGroup, -}; +use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; use ui::{h_flex, AxisExt as _, Placement}; use super::{DockArea, PanelEvent}; use crate::panel::{Panel, PanelView}; +use crate::resizable::{ + resizable_panel, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState, + PANEL_MIN_SIZE, +}; use crate::tab_panel::TabPanel; pub struct StackPanel { @@ -22,9 +22,8 @@ pub struct StackPanel { pub(super) axis: Axis, focus_handle: FocusHandle, pub(crate) panels: SmallVec<[Arc; 2]>, - panel_group: Entity, - #[allow(dead_code)] - subscriptions: Vec, + state: Entity, + _subscriptions: Vec, } impl Panel for StackPanel { @@ -39,28 +38,23 @@ impl Panel for StackPanel { impl StackPanel { pub fn new(axis: Axis, window: &mut Window, cx: &mut Context) -> Self { - let panel_group = cx.new(|cx| { - if axis == Axis::Horizontal { - h_resizable(window, cx) - } else { - v_resizable(window, cx) - } - }); + let state = cx.new(|_| ResizableState::default()); // Bubble up the resize event. - let subscriptions = vec![cx.subscribe_in( - &panel_group, - window, - |_, _, _: &ResizablePanelEvent, _, cx| cx.emit(PanelEvent::LayoutChanged), - )]; + let subscriptions = + vec![ + cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| { + cx.emit(PanelEvent::LayoutChanged) + }), + ]; Self { axis, parent: None, focus_handle: cx.focus_handle(), panels: SmallVec::new(), - panel_group, - subscriptions, + state, + _subscriptions: subscriptions, } } @@ -172,13 +166,6 @@ impl StackPanel { self.insert_panel(panel, ix + 1, size, dock_area, window, cx); } - fn new_resizable_panel(panel: Arc, size: Option) -> ResizablePanel { - resizable_panel() - .content_view(panel.view()) - .content_visible(move |cx| panel.visible(cx)) - .when_some(size, |this, size| this.size(size)) - } - fn insert_panel( &mut self, panel: Arc, @@ -225,14 +212,21 @@ impl StackPanel { ix }; + // Get avg size of all panels to insert new panel, if size is None. + let size = match size { + Some(size) => size, + None => { + let state = self.state.read(cx); + (state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE) + } + }; + + // Insert panel self.panels.insert(ix, panel.clone()); - self.panel_group.update(cx, |view, cx| { - view.insert_child( - Self::new_resizable_panel(panel.clone(), size), - ix, - window, - cx, - ) + + // Update resizable state + self.state.update(cx, |state, cx| { + state.insert_panel(Some(size), Some(ix), cx); }); cx.emit(PanelEvent::LayoutChanged); @@ -240,21 +234,25 @@ impl StackPanel { } /// Remove panel from the stack. + /// + /// If `ix` is not found, do nothing. pub fn remove_panel( &mut self, panel: Arc, window: &mut Window, cx: &mut Context, ) { - if let Some(ix) = self.index_of_panel(panel.clone()) { - self.panels.remove(ix); - self.panel_group.update(cx, |view, cx| { - view.remove_child(ix, window, cx); - }); + let Some(ix) = self.index_of_panel(panel.clone()) else { + return; + }; - cx.emit(PanelEvent::LayoutChanged); - self.remove_self_if_empty(window, cx); - } + self.panels.remove(ix); + self.state.update(cx, |state, cx| { + state.remove_panel(ix, cx); + }); + + cx.emit(PanelEvent::LayoutChanged); + self.remove_self_if_empty(window, cx); } /// Replace the old panel with the new panel at same index. @@ -262,18 +260,14 @@ impl StackPanel { &mut self, old_panel: Arc, new_panel: Entity, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { if let Some(ix) = self.index_of_panel(old_panel.clone()) { self.panels[ix] = Arc::new(new_panel.clone()); - self.panel_group.update(cx, |view, cx| { - view.replace_child( - Self::new_resizable_panel(Arc::new(new_panel.clone()), None), - ix, - window, - cx, - ); + let panel_state = ResizablePanelState::default(); + self.state.update(cx, |state, cx| { + state.replace_panel(ix, panel_state, cx); }); cx.emit(PanelEvent::LayoutChanged); } @@ -362,17 +356,17 @@ impl StackPanel { } /// Remove all panels from the stack. - pub(super) fn remove_all_panels(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { self.panels.clear(); - self.panel_group - .update(cx, |view, cx| view.remove_all_children(window, cx)); + self.state.update(cx, |state, cx| { + state.clear(); + cx.notify(); + }); } /// Change the axis of the stack panel. - pub(super) fn set_axis(&mut self, axis: Axis, window: &mut Window, cx: &mut Context) { + pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { self.axis = axis; - self.panel_group - .update(cx, |view, cx| view.set_axis(axis, window, cx)); cx.notify(); } } @@ -388,10 +382,21 @@ impl EventEmitter for StackPanel {} impl EventEmitter for StackPanel {} impl Render for StackPanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .size_full() .overflow_hidden() - .child(self.panel_group.clone()) + .rounded(CLIENT_SIDE_DECORATION_ROUNDING) + .bg(cx.theme().elevated_surface_background) + .child( + ResizablePanelGroup::new("stack-panel-group") + .with_state(&self.state) + .axis(self.axis) + .children(self.panels.clone().into_iter().map(|panel| { + resizable_panel() + .child(panel.view()) + .visible(panel.visible(cx)) + })), + ) } } diff --git a/crates/ui/src/tab/mod.rs b/crates/dock/src/tab/mod.rs similarity index 75% rename from crates/ui/src/tab/mod.rs rename to crates/dock/src/tab/mod.rs index 572b7d8..baa2d69 100644 --- a/crates/ui/src/tab/mod.rs +++ b/crates/dock/src/tab/mod.rs @@ -1,38 +1,45 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, - RenderOnce, Stateful, StatefulInteractiveElement, Styled, Window, + div, px, AnyElement, App, Div, InteractiveElement, IntoElement, ParentElement, RenderOnce, + StatefulInteractiveElement, Styled, Window, }; use theme::ActiveTheme; - -use crate::Selectable; +use ui::{Selectable, Sizable, Size}; pub mod tab_bar; #[derive(IntoElement)] pub struct Tab { - base: Stateful
, - label: AnyElement, + ix: usize, + base: Div, + label: Option, prefix: Option, suffix: Option, disabled: bool, selected: bool, + size: Size, } impl Tab { - pub fn new(id: impl Into, label: impl IntoElement) -> Self { - let id: ElementId = id.into(); - + pub fn new() -> Self { Self { - base: div().id(id), - label: label.into_any_element(), + ix: 0, + base: div(), + label: None, disabled: false, selected: false, prefix: None, suffix: None, + size: Size::default(), } } + /// Set label for the tab. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + /// Set the left side of the tab pub fn prefix(mut self, prefix: impl Into) -> Self { self.prefix = Some(prefix.into()); @@ -50,6 +57,18 @@ impl Tab { self.disabled = disabled; self } + + /// Set index to the tab. + pub fn ix(mut self, ix: usize) -> Self { + self.ix = ix; + self + } +} + +impl Default for Tab { + fn default() -> Self { + Self::new() + } } impl Selectable for Tab { @@ -77,6 +96,13 @@ impl Styled for Tab { } } +impl Sizable for Tab { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + impl RenderOnce for Tab { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let (text_color, bg_color, hover_bg_color) = match (self.selected, self.disabled) { @@ -103,6 +129,7 @@ impl RenderOnce for Tab { }; self.base + .id(self.ix) .h(px(30.)) .px_2() .relative() @@ -115,12 +142,11 @@ impl RenderOnce for Tab { .text_ellipsis() .text_color(text_color) .bg(bg_color) - .rounded(cx.theme().radius_lg) .hover(|this| this.bg(hover_bg_color)) .when_some(self.prefix, |this, prefix| { this.child(prefix).text_color(text_color) }) - .child(self.label) + .when_some(self.label, |this, label| this.child(label)) .when_some(self.suffix, |this, suffix| this.child(suffix)) } } diff --git a/crates/dock/src/tab/tab_bar.rs b/crates/dock/src/tab/tab_bar.rs new file mode 100644 index 0000000..8489f50 --- /dev/null +++ b/crates/dock/src/tab/tab_bar.rs @@ -0,0 +1,143 @@ +use gpui::prelude::FluentBuilder as _; +#[cfg(not(target_os = "windows"))] +use gpui::Pixels; +use gpui::{ + div, px, AnyElement, App, Div, InteractiveElement, IntoElement, ParentElement, RenderOnce, + ScrollHandle, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, +}; +use smallvec::SmallVec; +use theme::ActiveTheme; +use ui::{h_flex, Sizable, Size, StyledExt}; + +use crate::platforms::linux::LinuxWindowControls; +use crate::platforms::windows::WindowsWindowControls; + +#[derive(IntoElement)] +pub struct TabBar { + base: Div, + style: StyleRefinement, + scroll_handle: Option, + prefix: Option, + suffix: Option, + last_empty_space: AnyElement, + children: SmallVec<[AnyElement; 2]>, + size: Size, +} + +impl TabBar { + pub fn new() -> Self { + Self { + base: div().px(px(-1.)), + style: StyleRefinement::default(), + scroll_handle: None, + children: SmallVec::new(), + prefix: None, + suffix: None, + size: Size::default(), + last_empty_space: div().w_3().into_any_element(), + } + } + + /// Track the scroll of the TabBar. + pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self { + self.scroll_handle = Some(scroll_handle.clone()); + self + } + + /// Set the prefix element of the TabBar + pub fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } + + /// Set the suffix element of the TabBar + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + /// Set the last empty space element of the TabBar. + pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self { + self.last_empty_space = last_empty_space.into_any_element(); + self + } + + #[cfg(not(target_os = "windows"))] + pub fn height(window: &mut Window) -> Pixels { + (1.75 * window.rem_size()).max(px(36.)) + } +} + +impl Default for TabBar { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for TabBar { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Styled for TabBar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for TabBar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for TabBar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focused = window.is_window_active(); + + self.base + .group("tab-bar") + .refine_style(&self.style) + .relative() + .flex() + .items_center() + .bg(cx.theme().elevated_surface_background) + .when(!focused, |this| { + this.bg(cx.theme().elevated_surface_background.opacity(0.75)) + }) + .border_b_1() + .border_color(cx.theme().border) + .overflow_hidden() + .text_color(cx.theme().text) + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child( + h_flex() + .id("tabs") + .flex_grow() + .gap_1() + .overflow_x_scroll() + .when_some(self.scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .children(self.children) + .when(self.suffix.is_some(), |this| { + this.child(self.last_empty_space) + }), + ) + .when_some(self.suffix, |this, suffix| this.child(suffix)) + .when( + !cx.theme().platform.is_mac() && !window.is_fullscreen(), + |this| match cx.theme().platform { + theme::PlatformKind::Linux => { + this.child(div().px_2().child(LinuxWindowControls::new())) + } + theme::PlatformKind::Windows => { + this.child(WindowsWindowControls::new(Self::height(window))) + } + _ => this, + }, + ) + } +} diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index 0ef0efd..68c984c 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -5,17 +5,18 @@ use gpui::{ div, px, rems, App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString, - StatefulInteractiveElement, Styled, WeakEntity, Window, + StatefulInteractiveElement, Styled, WeakEntity, Window, WindowControlArea, }; -use theme::ActiveTheme; +use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING, TITLEBAR_HEIGHT}; use ui::button::{Button, ButtonVariants as _}; use ui::popup_menu::{PopupMenu, PopupMenuExt}; -use ui::tab::tab_bar::TabBar; -use ui::tab::Tab; use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt}; +use crate::dock::DockPlacement; use crate::panel::{Panel, PanelView}; use crate::stack_panel::StackPanel; +use crate::tab::tab_bar::TabBar; +use crate::tab::Tab; use crate::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom}; #[derive(Clone)] @@ -64,16 +65,32 @@ impl Render for DragPanel { pub struct TabPanel { focus_handle: FocusHandle, dock_area: WeakEntity, - /// The stock_panel can be None, if is None, that means the panels can't be split or move - stack_panel: Option>, + + /// List of panels in the tab panel pub(crate) panels: Vec>, + + /// Current active panel index pub(crate) active_ix: usize, + /// If this is true, the Panel closeable will follow the active panel's closeable, /// otherwise this TabPanel will not able to close pub(crate) closable: bool, + + /// The stock_panel can be None, if is None, that means the panels can't be split or move + stack_panel: Option>, + + /// Scroll handle for the tab bar tab_bar_scroll_handle: ScrollHandle, - is_zoomed: bool, - is_collapsed: bool, + + /// Whether the tab panel is zoomeds + zoomed: bool, + + /// Whether the tab panel is collapsed + collapsed: bool, + + /// Whether window is moving + window_move: bool, + /// When drag move, will get the placement of the panel to be split will_split_placement: Option, } @@ -141,8 +158,9 @@ impl TabPanel { active_ix: 0, tab_bar_scroll_handle: ScrollHandle::new(), will_split_placement: None, - is_zoomed: false, - is_collapsed: false, + zoomed: false, + collapsed: false, + window_move: false, closable: true, } } @@ -338,7 +356,7 @@ impl TabPanel { _window: &mut Window, cx: &mut Context, ) { - self.is_collapsed = collapsed; + self.collapsed = collapsed; cx.notify(); } @@ -351,7 +369,7 @@ impl TabPanel { return true; } - if self.is_zoomed { + if self.zoomed { return true; } @@ -407,7 +425,7 @@ impl TabPanel { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let is_zoomed = self.is_zoomed && state.zoomable; + let is_zoomed = self.zoomed && state.zoomable; let view = cx.entity().clone(); let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx); let toolbar = self.toolbar_buttons(window, cx); @@ -419,7 +437,7 @@ impl TabPanel { .occlude() .rounded_full() .children(toolbar.into_iter().map(|btn| btn.small().ghost().rounded())) - .when(self.is_zoomed, |this| { + .when(self.zoomed, |this| { this.child( Button::new("zoom") .icon(IconName::Zoom) @@ -432,8 +450,7 @@ impl TabPanel { ) }) .when(has_toolbar, |this| { - this.bg(cx.theme().surface_background) - .child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) + this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) }) .child( Button::new("menu") @@ -460,21 +477,115 @@ impl TabPanel { ) } + fn render_dock_toggle_button( + &self, + placement: DockPlacement, + _window: &mut Window, + cx: &mut Context, + ) -> Option