Make panels independently resizable

Nathan Sobo created

Change summary

assets/settings/default.json               |   3 
crates/gpui/src/elements.rs                |  20 +--
crates/gpui/src/elements/resizable.rs      | 122 +++++++++--------------
crates/project_panel/src/project_panel.rs  |  13 +-
crates/settings/src/settings.rs            |  31 ++++-
crates/terminal_view/src/terminal_panel.rs |   8 +
crates/workspace/src/dock.rs               |  50 +++++++--
crates/workspace/src/item.rs               |  40 -------
8 files changed, 134 insertions(+), 153 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -108,7 +108,8 @@
   "auto_update": true,
   // Git gutter behavior configuration.
   "project_panel": {
-      "dock": "left"
+      "dock": "left",
+      "default_width": 240
   },
   "git": {
     // Control whether the git gutter is shown. May take 2 values:

crates/gpui/src/elements.rs 🔗

@@ -187,25 +187,21 @@ pub trait Element<V: View>: 'static {
         Tooltip::new::<Tag, V>(id, text, action, style, self.into_any(), cx)
     }
 
-    fn with_resize_handle<Tag: 'static>(
+    fn resizable(
         self,
-        element_id: usize,
-        side: Side,
-        handle_size: f32,
-        initial_size: f32,
-        cx: &mut ViewContext<V>,
+        side: HandleSide,
+        size: f32,
+        on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>),
     ) -> Resizable<V>
     where
         Self: 'static + Sized,
     {
-        Resizable::new::<Tag, V>(
+        Resizable::new(
             self.into_any(),
-            element_id,
             side,
-            handle_size,
-            initial_size,
-            cx,
-        )
+            size,
+            on_resize
+       )
     }
 }
 

crates/gpui/src/elements/resizable.rs 🔗

@@ -1,4 +1,4 @@
-use std::{cell::Cell, rc::Rc};
+use std::{cell::RefCell, rc::Rc};
 
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
@@ -7,25 +7,23 @@ use crate::{
     geometry::rect::RectF,
     platform::{CursorStyle, MouseButton},
     scene::MouseDrag,
-    AnyElement, Axis, Element, ElementStateHandle, LayoutContext, MouseRegion, SceneBuilder, View,
-    ViewContext,
+    AnyElement, Axis, Element, LayoutContext, MouseRegion, SceneBuilder, View,
+    ViewContext, SizeConstraint,
 };
 
-use super::{ConstrainedBox, Hook};
-
 #[derive(Copy, Clone, Debug)]
-pub enum Side {
+pub enum HandleSide {
     Top,
     Bottom,
     Left,
     Right,
 }
 
-impl Side {
+impl HandleSide {
     fn axis(&self) -> Axis {
         match self {
-            Side::Left | Side::Right => Axis::Horizontal,
-            Side::Top | Side::Bottom => Axis::Vertical,
+            HandleSide::Left | HandleSide::Right => Axis::Horizontal,
+            HandleSide::Top | HandleSide::Bottom => Axis::Vertical,
         }
     }
 
@@ -33,8 +31,8 @@ impl Side {
     /// then top-to-bottom
     fn before_content(self) -> bool {
         match self {
-            Side::Left | Side::Top => true,
-            Side::Right | Side::Bottom => false,
+            HandleSide::Left | HandleSide::Top => true,
+            HandleSide::Right | HandleSide::Bottom => false,
         }
     }
 
@@ -55,14 +53,14 @@ impl Side {
 
     fn of_rect(&self, bounds: RectF, handle_size: f32) -> RectF {
         match self {
-            Side::Top => RectF::new(bounds.origin(), vec2f(bounds.width(), handle_size)),
-            Side::Left => RectF::new(bounds.origin(), vec2f(handle_size, bounds.height())),
-            Side::Bottom => {
+            HandleSide::Top => RectF::new(bounds.origin(), vec2f(bounds.width(), handle_size)),
+            HandleSide::Left => RectF::new(bounds.origin(), vec2f(handle_size, bounds.height())),
+            HandleSide::Bottom => {
                 let mut origin = bounds.lower_left();
                 origin.set_y(origin.y() - handle_size);
                 RectF::new(origin, vec2f(bounds.width(), handle_size))
             }
-            Side::Right => {
+            HandleSide::Right => {
                 let mut origin = bounds.upper_right();
                 origin.set_x(origin.x() - handle_size);
                 RectF::new(origin, vec2f(handle_size, bounds.height()))
@@ -71,69 +69,44 @@ impl Side {
     }
 }
 
-struct ResizeHandleState {
-    actual_dimension: Cell<f32>,
-    custom_dimension: Cell<f32>,
-}
-
 pub struct Resizable<V: View> {
-    side: Side,
-    handle_size: f32,
     child: AnyElement<V>,
-    state: Rc<ResizeHandleState>,
-    _state_handle: ElementStateHandle<Rc<ResizeHandleState>>,
+    handle_side: HandleSide,
+    handle_size: f32,
+    on_resize: Rc<RefCell<dyn FnMut(&mut V, f32, &mut ViewContext<V>)>>
 }
 
+const DEFAULT_HANDLE_SIZE: f32 = 4.0;
+
 impl<V: View> Resizable<V> {
-    pub fn new<Tag: 'static, T: View>(
+    pub fn new(
         child: AnyElement<V>,
-        element_id: usize,
-        side: Side,
-        handle_size: f32,
-        initial_size: f32,
-        cx: &mut ViewContext<V>,
+        handle_side: HandleSide,
+        size: f32,
+        on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>)
     ) -> Self {
-        let state_handle = cx.element_state::<Tag, Rc<ResizeHandleState>>(
-            element_id,
-            Rc::new(ResizeHandleState {
-                actual_dimension: Cell::new(initial_size),
-                custom_dimension: Cell::new(initial_size),
-            }),
-        );
-
-        let state = state_handle.read(cx).clone();
-
-        let child = Hook::new({
-            let constrained = ConstrainedBox::new(child);
-            match side.axis() {
-                Axis::Horizontal => constrained.with_max_width(state.custom_dimension.get()),
-                Axis::Vertical => constrained.with_max_height(state.custom_dimension.get()),
-            }
-        })
-        .on_after_layout({
-            let state = state.clone();
-            move |size, _| {
-                state.actual_dimension.set(side.relevant_component(size));
-            }
-        })
+        let child = match handle_side.axis() {
+            Axis::Horizontal => child.constrained().with_max_width(size),
+            Axis::Vertical => child.constrained().with_max_height(size),
+        }
         .into_any();
 
         Self {
-            side,
             child,
-            handle_size,
-            state,
-            _state_handle: state_handle,
+            handle_side,
+            handle_size: DEFAULT_HANDLE_SIZE,
+            on_resize: Rc::new(RefCell::new(on_resize)),
         }
     }
 
-    pub fn current_size(&self) -> f32 {
-        self.state.actual_dimension.get()
+    pub fn with_handle_size(mut self, handle_size: f32) -> Self {
+        self.handle_size = handle_size;
+        self
     }
 }
 
 impl<V: View> Element<V> for Resizable<V> {
-    type LayoutState = ();
+    type LayoutState = SizeConstraint;
     type PaintState = ();
 
     fn layout(
@@ -142,7 +115,7 @@ impl<V: View> Element<V> for Resizable<V> {
         view: &mut V,
         cx: &mut LayoutContext<V>,
     ) -> (Vector2F, Self::LayoutState) {
-        (self.child.layout(constraint, view, cx), ())
+        (self.child.layout(constraint, view, cx), constraint)
     }
 
     fn paint(
@@ -150,34 +123,37 @@ impl<V: View> Element<V> for Resizable<V> {
         scene: &mut SceneBuilder,
         bounds: pathfinder_geometry::rect::RectF,
         visible_bounds: pathfinder_geometry::rect::RectF,
-        _child_size: &mut Self::LayoutState,
+        constraint: &mut SizeConstraint,
         view: &mut V,
         cx: &mut ViewContext<V>,
     ) -> Self::PaintState {
         scene.push_stacking_context(None, None);
 
-        let handle_region = self.side.of_rect(bounds, self.handle_size);
+        let handle_region = self.handle_side.of_rect(bounds, self.handle_size);
 
         enum ResizeHandle {}
         scene.push_mouse_region(
-            MouseRegion::new::<ResizeHandle>(cx.view_id(), self.side as usize, handle_region)
+            MouseRegion::new::<ResizeHandle>(cx.view_id(), self.handle_side as usize, handle_region)
                 .on_down(MouseButton::Left, |_, _: &mut V, _| {}) // This prevents the mouse down event from being propagated elsewhere
                 .on_drag(MouseButton::Left, {
-                    let state = self.state.clone();
-                    let side = self.side;
-                    move |e, _: &mut V, cx| {
-                        let prev_width = state.actual_dimension.get();
-                        state
-                            .custom_dimension
-                            .set(0f32.max(prev_width + side.compute_delta(e)).round());
-                        cx.notify();
+                    let bounds = bounds.clone();
+                    let side = self.handle_side;
+                    let prev_size = side.relevant_component(bounds.size());
+                    let min_size = side.relevant_component(constraint.min);
+                    let max_size = side.relevant_component(constraint.max);
+                    let on_resize = self.on_resize.clone();
+                    move |event, view: &mut V, cx| {
+                        let new_size = min_size.max(prev_size + side.compute_delta(event)).min(max_size).round();
+                        if new_size != prev_size {
+                            on_resize.borrow_mut()(view, new_size, cx);
+                        }
                     }
                 }),
         );
 
         scene.push_cursor_region(crate::CursorRegion {
             bounds: handle_region,
-            style: match self.side.axis() {
+            style: match self.handle_side.axis() {
                 Axis::Horizontal => CursorStyle::ResizeLeftRight,
                 Axis::Vertical => CursorStyle::ResizeUpDown,
             },

crates/project_panel/src/project_panel.rs 🔗

@@ -1347,12 +1347,9 @@ impl Entity for ProjectPanel {
 impl workspace::dock::Panel for ProjectPanel {
     fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
         let settings = cx.global::<Settings>();
-        let dock = settings
-            .project_panel_overrides
-            .dock
-            .or(settings.project_panel_defaults.dock)
-            .unwrap();
-        match dock {
+        match settings
+            .project_panel
+            .dock {
             settings::ProjectPanelDockPosition::Left => DockPosition::Left,
             settings::ProjectPanelDockPosition::Right => DockPosition::Right,
         }
@@ -1374,6 +1371,10 @@ impl workspace::dock::Panel for ProjectPanel {
         })
     }
 
+    fn default_size(&self, cx: &gpui::WindowContext) -> f32 {
+        cx.global::<Settings>().project_panel.default_width
+    }
+
     fn icon_path(&self) -> &'static str {
         "icons/folder_tree_16.svg"
     }

crates/settings/src/settings.rs 🔗

@@ -44,8 +44,7 @@ pub struct Settings {
     pub show_call_status_icon: bool,
     pub vim_mode: bool,
     pub autosave: Autosave,
-    pub project_panel_defaults: ProjectPanelSettings,
-    pub project_panel_overrides: ProjectPanelSettings,
+    pub project_panel: ProjectPanelSettings,
     pub editor_defaults: EditorSettings,
     pub editor_overrides: EditorSettings,
     pub git: GitSettings,
@@ -158,9 +157,15 @@ pub enum GitGutter {
 
 pub struct GitGutterConfig {}
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 pub struct ProjectPanelSettings {
+    pub dock: ProjectPanelDockPosition,
+    pub default_width: f32,
+}
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct ProjectPanelSettingsContent {
     pub dock: Option<ProjectPanelDockPosition>,
+    pub default_width: Option<f32>,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
@@ -253,6 +258,8 @@ impl Default for HourFormat {
 
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct TerminalSettings {
+    pub default_width: Option<f32>,
+    pub default_height: Option<f32>,
     pub shell: Option<Shell>,
     pub working_directory: Option<WorkingDirectory>,
     pub font_size: Option<f32>,
@@ -387,7 +394,7 @@ pub struct SettingsFileContent {
     #[serde(flatten)]
     pub editor: EditorSettings,
     #[serde(default)]
-    pub project_panel: ProjectPanelSettings,
+    pub project_panel: ProjectPanelSettingsContent,
     #[serde(default)]
     pub journal: JournalSettings,
     #[serde(default)]
@@ -423,7 +430,6 @@ pub struct Features {
 }
 
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
 pub struct FeaturesContent {
     pub copilot: Option<bool>,
 }
@@ -482,8 +488,10 @@ impl Settings {
             show_call_status_icon: defaults.show_call_status_icon.unwrap(),
             vim_mode: defaults.vim_mode.unwrap(),
             autosave: defaults.autosave.unwrap(),
-            project_panel_defaults: defaults.project_panel,
-            project_panel_overrides: Default::default(),
+            project_panel: ProjectPanelSettings {
+                dock: defaults.project_panel.dock.unwrap(),
+                default_width: defaults.project_panel.default_width.unwrap(),
+            },
             editor_defaults: EditorSettings {
                 tab_size: required(defaults.editor.tab_size),
                 hard_tabs: required(defaults.editor.hard_tabs),
@@ -590,7 +598,8 @@ impl Settings {
             }
         }
         self.editor_overrides = data.editor;
-        self.project_panel_overrides = data.project_panel;
+        merge(&mut self.project_panel.dock, data.project_panel.dock);
+        merge(&mut self.project_panel.default_width, data.project_panel.default_width);
         self.git_overrides = data.git.unwrap_or_default();
         self.journal_overrides = data.journal;
         self.terminal_defaults.font_size = data.terminal.font_size;
@@ -778,8 +787,10 @@ impl Settings {
             show_call_status_icon: true,
             vim_mode: false,
             autosave: Autosave::Off,
-            project_panel_defaults: Default::default(),
-            project_panel_overrides: Default::default(),
+            project_panel: ProjectPanelSettings {
+                dock: ProjectPanelDockPosition::Left,
+                default_width: 240.,
+            },
             editor_defaults: EditorSettings {
                 tab_size: Some(4.try_into().unwrap()),
                 hard_tabs: Some(false),

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -179,6 +179,14 @@ impl Panel for TerminalPanel {
         });
     }
 
+    fn default_size(&self, cx: &gpui::WindowContext) -> f32 {
+        let settings = &cx.global::<Settings>().terminal_overrides;
+        match self.position(cx) {
+            DockPosition::Left | DockPosition::Right => settings.default_width.unwrap_or(640.),
+            DockPosition::Bottom => settings.default_height.unwrap_or(320.),
+        }
+    }
+
     fn icon_path(&self) -> &'static str {
         "icons/terminal_12.svg"
     }

crates/workspace/src/dock.rs 🔗

@@ -12,6 +12,7 @@ pub trait Panel: View {
     fn position(&self, cx: &WindowContext) -> DockPosition;
     fn position_is_valid(&self, position: DockPosition) -> bool;
     fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>);
+    fn default_size(&self, cx: &WindowContext) -> f32;
     fn icon_path(&self) -> &'static str;
     fn icon_tooltip(&self) -> String;
     fn icon_label(&self, _: &AppContext) -> Option<String> {
@@ -27,6 +28,7 @@ pub trait PanelHandle {
     fn position(&self, cx: &WindowContext) -> DockPosition;
     fn position_is_valid(&self, position: DockPosition, cx: &WindowContext) -> bool;
     fn set_position(&self, position: DockPosition, cx: &mut WindowContext);
+    fn default_size(&self, cx: &WindowContext) -> f32;
     fn icon_path(&self, cx: &WindowContext) -> &'static str;
     fn icon_tooltip(&self, cx: &WindowContext) -> String;
     fn icon_label(&self, cx: &WindowContext) -> Option<String>;
@@ -54,6 +56,10 @@ where
         self.update(cx, |this, cx| this.set_position(position, cx))
     }
 
+    fn default_size(&self, cx: &WindowContext) -> f32 {
+        self.read(cx).default_size(cx)
+    }
+
     fn icon_path(&self, cx: &WindowContext) -> &'static str {
         self.read(cx).icon_path()
     }
@@ -104,17 +110,18 @@ impl DockPosition {
         }
     }
 
-    fn to_resizable_side(self) -> Side {
+    fn to_resize_handle_side(self) -> HandleSide {
         match self {
-            Self::Left => Side::Right,
-            Self::Bottom => Side::Top,
-            Self::Right => Side::Left,
+            Self::Left => HandleSide::Right,
+            Self::Bottom => HandleSide::Top,
+            Self::Right => HandleSide::Left,
         }
     }
 }
 
 struct PanelEntry {
     panel: Rc<dyn PanelHandle>,
+    size: f32,
     context_menu: ViewHandle<ContextMenu>,
     _subscriptions: [Subscription; 2],
 }
@@ -181,8 +188,10 @@ impl Dock {
         ];
 
         let dock_view_id = cx.view_id();
+        let size = panel.default_size(cx);
         self.panel_entries.push(PanelEntry {
             panel: Rc::new(panel),
+            size,
             context_menu: cx.add_view(|cx| {
                 let mut menu = ContextMenu::new(dock_view_id, cx);
                 menu.set_position_mode(OverlayPositionMode::Local);
@@ -237,6 +246,23 @@ impl Dock {
             None
         }
     }
+
+    pub fn active_panel_size(&self) -> Option<f32> {
+        if self.is_open {
+            self.panel_entries
+                .get(self.active_panel_index)
+                .map(|entry| entry.size)
+        } else {
+            None
+        }
+    }
+
+    pub fn resize_active_panel(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+        if let Some(entry) = self.panel_entries.get_mut(self.active_panel_index) {
+            entry.size = size;
+            cx.notify();
+        }
+    }
 }
 
 impl Entity for Dock {
@@ -250,18 +276,14 @@ impl View for Dock {
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         if let Some(active_panel) = self.active_panel() {
-            enum ResizeHandleTag {}
+            let size = self.active_panel_size().unwrap();
             let style = &cx.global::<Settings>().theme.workspace.dock;
             ChildView::new(active_panel.as_any(), cx)
                 .contained()
                 .with_style(style.container)
-                .with_resize_handle::<ResizeHandleTag>(
-                    self.position as usize,
-                    self.position.to_resizable_side(),
-                    4.,
-                    style.initial_size,
-                    cx,
-                )
+                .resizable(self.position.to_resize_handle_side(), size, |dock: &mut Self, size, cx|  {
+                    dock.resize_active_panel(size, cx);
+                })
                 .into_any()
         } else {
             Empty::new().into_any()
@@ -464,6 +486,10 @@ pub(crate) mod test {
             cx.emit(TestPanelEvent::PositionChanged);
         }
 
+        fn default_size(&self, _: &WindowContext) -> f32 {
+            300.
+        }
+
         fn icon_path(&self) -> &'static str {
             "icons/test_panel.svg"
         }

crates/workspace/src/item.rs 🔗

@@ -766,7 +766,7 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
 #[cfg(test)]
 pub(crate) mod test {
     use super::{Item, ItemEvent};
-    use crate::{dock::Panel, ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
+    use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
     use gpui::{
         elements::Empty, AnyElement, AppContext, Element, Entity, ModelHandle, Task, View,
         ViewContext, ViewHandle, WeakViewHandle,
@@ -1059,42 +1059,4 @@ pub(crate) mod test {
             Task::Ready(Some(anyhow::Ok(view)))
         }
     }
-
-    impl Panel for TestItem {
-        fn position(&self, _cx: &gpui::WindowContext) -> crate::dock::DockPosition {
-            unimplemented!()
-        }
-
-        fn position_is_valid(&self, _position: crate::dock::DockPosition) -> bool {
-            unimplemented!()
-        }
-
-        fn set_position(
-            &mut self,
-            _position: crate::dock::DockPosition,
-            _cx: &mut ViewContext<Self>,
-        ) {
-            unimplemented!()
-        }
-
-        fn icon_path(&self) -> &'static str {
-            unimplemented!()
-        }
-
-        fn icon_tooltip(&self) -> String {
-            unimplemented!()
-        }
-
-        fn should_change_position_on_event(_: &Self::Event) -> bool {
-            unimplemented!()
-        }
-
-        fn should_activate_on_event(&self, _: &Self::Event, _: &AppContext) -> bool {
-            unimplemented!()
-        }
-
-        fn should_close_on_event(&self, _: &Self::Event, _: &AppContext) -> bool {
-            unimplemented!()
-        }
-    }
 }