Merge pull request #1342 from zed-industries/mouse-based-splitting

Antonio Scandurra created

Introduce mouse-based pane splitting

Change summary

Cargo.lock                              |   1 
assets/icons/split.svg                  |   3 
crates/context_menu/src/context_menu.rs |  45 +++++++--
crates/theme/src/theme.rs               |   2 
crates/workspace/Cargo.toml             |   1 
crates/workspace/src/pane.rs            | 123 +++++++++++++++++++++-----
styles/src/styleTree/contextMenu.ts     |   5 
styles/src/styleTree/workspace.ts       |  12 ++
8 files changed, 150 insertions(+), 42 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6934,6 +6934,7 @@ dependencies = [
  "client",
  "clock",
  "collections",
+ "context_menu",
  "futures",
  "gpui",
  "language",

assets/icons/split.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="12" height="10" viewBox="0 0 12 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.8 0.800476C11.4619 0.800476 12 1.33766 12 2.00048V8.00048C12 8.66235 11.4619 9.20048 10.8 9.20048H1.2C0.537188 9.20048 0 8.66235 0 8.00048V2.00048C0 1.33766 0.537188 0.800476 1.2 0.800476H10.8ZM3.6 2.00048H1.2V8.00048H3.6V2.00048ZM4.8 8.00048H7.2V2.00048H4.8V8.00048ZM10.8 2.00048H8.4V8.00048H10.8V2.00048Z" fill="#8B8792"/>
+</svg>

crates/context_menu/src/context_menu.rs 🔗

@@ -1,18 +1,23 @@
-use std::{any::TypeId, time::Duration};
-
 use gpui::{
-    elements::*, geometry::vector::Vector2F, keymap, platform::CursorStyle, Action, AppContext,
-    Axis, Entity, MutableAppContext, RenderContext, SizeConstraint, Subscription, View,
-    ViewContext,
+    elements::*, geometry::vector::Vector2F, impl_internal_actions, keymap, platform::CursorStyle,
+    Action, AppContext, Axis, Entity, MutableAppContext, RenderContext, SizeConstraint,
+    Subscription, View, ViewContext,
 };
 use menu::*;
 use settings::Settings;
+use std::{any::TypeId, time::Duration};
+
+#[derive(Copy, Clone, PartialEq)]
+struct Clicked;
+
+impl_internal_actions!(context_menu, [Clicked]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContextMenu::select_first);
     cx.add_action(ContextMenu::select_last);
     cx.add_action(ContextMenu::select_next);
     cx.add_action(ContextMenu::select_prev);
+    cx.add_action(ContextMenu::clicked);
     cx.add_action(ContextMenu::confirm);
     cx.add_action(ContextMenu::cancel);
 }
@@ -56,6 +61,7 @@ pub struct ContextMenu {
     selected_index: Option<usize>,
     visible: bool,
     previously_focused_view_id: Option<usize>,
+    clicked: bool,
     _actions_observation: Subscription,
 }
 
@@ -113,6 +119,7 @@ impl ContextMenu {
             selected_index: Default::default(),
             visible: Default::default(),
             previously_focused_view_id: Default::default(),
+            clicked: false,
             _actions_observation: cx.observe_actions(Self::action_dispatched),
         }
     }
@@ -123,22 +130,31 @@ impl ContextMenu {
             .iter()
             .position(|item| item.action_id() == Some(action_id))
         {
-            self.selected_index = Some(ix);
-            cx.notify();
-            cx.spawn(|this, mut cx| async move {
-                cx.background().timer(Duration::from_millis(100)).await;
-                this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx));
-            })
-            .detach();
+            if self.clicked {
+                self.cancel(&Default::default(), cx);
+            } else {
+                self.selected_index = Some(ix);
+                cx.notify();
+                cx.spawn(|this, mut cx| async move {
+                    cx.background().timer(Duration::from_millis(50)).await;
+                    this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx));
+                })
+                .detach();
+            }
         }
     }
 
+    fn clicked(&mut self, _: &Clicked, _: &mut ViewContext<Self>) {
+        self.clicked = true;
+    }
+
     fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self.selected_index {
             if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
                 let window_id = cx.window_id();
                 let view_id = cx.view_id();
                 cx.dispatch_action_at(window_id, view_id, action.as_ref());
+                self.reset(cx);
             }
         }
     }
@@ -158,6 +174,7 @@ impl ContextMenu {
         self.items.clear();
         self.visible = false;
         self.selected_index.take();
+        self.clicked = false;
         cx.notify();
     }
 
@@ -277,6 +294,8 @@ impl ContextMenu {
                                 .boxed(),
                         }
                     }))
+                    .contained()
+                    .with_margin_left(style.keystroke_margin)
                     .boxed(),
             )
             .contained()
@@ -315,8 +334,8 @@ impl ContextMenu {
                             })
                             .with_cursor_style(CursorStyle::PointingHand)
                             .on_click(move |_, _, cx| {
+                                cx.dispatch_action(Clicked);
                                 cx.dispatch_any_action(action.boxed_clone());
-                                cx.dispatch_action(Cancel);
                             })
                             .boxed()
                         }

crates/theme/src/theme.rs 🔗

@@ -40,6 +40,7 @@ pub struct Workspace {
     pub titlebar: Titlebar,
     pub tab: Tab,
     pub active_tab: Tab,
+    pub pane_button: Interactive<IconButton>,
     pub pane_divider: Border,
     pub leader_border_opacity: f32,
     pub leader_border_width: f32,
@@ -241,6 +242,7 @@ pub struct ContextMenu {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub item: Interactive<ContextMenuItem>,
+    pub keystroke_margin: f32,
     pub separator: ContainerStyle,
 }
 

crates/workspace/Cargo.toml 🔗

@@ -14,6 +14,7 @@ test-support = ["client/test-support", "project/test-support", "settings/test-su
 client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+context_menu = { path = "../context_menu" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 menu = { path = "../menu" }

crates/workspace/src/pane.rs 🔗

@@ -2,11 +2,15 @@ use super::{ItemHandle, SplitDirection};
 use crate::{toolbar::Toolbar, Item, WeakItemHandle, Workspace};
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
+use context_menu::{ContextMenu, ContextMenuItem};
 use futures::StreamExt;
 use gpui::{
     actions,
     elements::*,
-    geometry::{rect::RectF, vector::vec2f},
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
     impl_actions, impl_internal_actions,
     platform::{CursorStyle, NavigationDirection},
     AppContext, AsyncAppContext, Entity, ModelHandle, MutableAppContext, PromptLevel, Quad,
@@ -55,8 +59,13 @@ pub struct GoForward {
     pub pane: Option<WeakViewHandle<Pane>>,
 }
 
+#[derive(Clone, PartialEq)]
+pub struct DeploySplitMenu {
+    position: Vector2F,
+}
+
 impl_actions!(pane, [GoBack, GoForward, ActivateItem]);
-impl_internal_actions!(pane, [CloseItem]);
+impl_internal_actions!(pane, [CloseItem, DeploySplitMenu]);
 
 const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 
@@ -87,6 +96,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|pane: &mut Pane, _: &SplitUp, cx| pane.split(SplitDirection::Up, cx));
     cx.add_action(|pane: &mut Pane, _: &SplitRight, cx| pane.split(SplitDirection::Right, cx));
     cx.add_action(|pane: &mut Pane, _: &SplitDown, cx| pane.split(SplitDirection::Down, cx));
+    cx.add_action(Pane::deploy_split_menu);
     cx.add_action(|workspace: &mut Workspace, _: &ReopenClosedItem, cx| {
         Pane::reopen_closed_item(workspace, cx).detach();
     });
@@ -129,6 +139,7 @@ pub struct Pane {
     autoscroll: bool,
     nav_history: Rc<RefCell<NavHistory>>,
     toolbar: ViewHandle<Toolbar>,
+    split_menu: ViewHandle<ContextMenu>,
 }
 
 pub struct ItemNavHistory {
@@ -169,6 +180,7 @@ pub struct NavigationEntry {
 impl Pane {
     pub fn new(cx: &mut ViewContext<Self>) -> Self {
         let handle = cx.weak_handle();
+        let split_menu = cx.add_view(|cx| ContextMenu::new(cx));
         Self {
             items: Vec::new(),
             active_item_index: 0,
@@ -182,6 +194,7 @@ impl Pane {
                 pane: handle.clone(),
             })),
             toolbar: cx.add_view(|_| Toolbar::new(handle)),
+            split_menu,
         }
     }
 
@@ -786,6 +799,21 @@ impl Pane {
         cx.emit(Event::Split(direction));
     }
 
+    fn deploy_split_menu(&mut self, action: &DeploySplitMenu, cx: &mut ViewContext<Self>) {
+        self.split_menu.update(cx, |menu, cx| {
+            menu.show(
+                action.position,
+                vec![
+                    ContextMenuItem::item("Split Right", SplitRight),
+                    ContextMenuItem::item("Split Left", SplitLeft),
+                    ContextMenuItem::item("Split Up", SplitUp),
+                    ContextMenuItem::item("Split Down", SplitDown),
+                ],
+                cx,
+            );
+        });
+    }
+
     pub fn toolbar(&self) -> &ViewHandle<Toolbar> {
         &self.toolbar
     }
@@ -800,13 +828,13 @@ impl Pane {
         });
     }
 
-    fn render_tabs(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+    fn render_tabs(&mut self, cx: &mut RenderContext<Self>) -> impl Element {
         let theme = cx.global::<Settings>().theme.clone();
 
         enum Tabs {}
         enum Tab {}
         let pane = cx.handle();
-        let tabs = MouseEventHandler::new::<Tabs, _, _>(0, cx, |mouse_state, cx| {
+        MouseEventHandler::new::<Tabs, _, _>(0, cx, |mouse_state, cx| {
             let autoscroll = if mem::take(&mut self.autoscroll) {
                 Some(self.active_item_index)
             } else {
@@ -941,11 +969,7 @@ impl Pane {
             );
 
             row.boxed()
-        });
-
-        ConstrainedBox::new(tabs.boxed())
-            .with_height(theme.workspace.tab.height)
-            .named("tabs")
+        })
     }
 }
 
@@ -959,27 +983,72 @@ impl View for Pane {
     }
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum SplitIcon {}
+
         let this = cx.handle();
 
-        EventHandler::new(if let Some(active_item) = self.active_item() {
-            Flex::column()
-                .with_child(self.render_tabs(cx))
-                .with_child(ChildView::new(&self.toolbar).boxed())
-                .with_child(ChildView::new(active_item).flex(1., true).boxed())
-                .boxed()
-        } else {
-            Empty::new().boxed()
-        })
-        .on_navigate_mouse_down(move |direction, cx| {
-            let this = this.clone();
-            match direction {
-                NavigationDirection::Back => cx.dispatch_action(GoBack { pane: Some(this) }),
-                NavigationDirection::Forward => cx.dispatch_action(GoForward { pane: Some(this) }),
-            }
+        Stack::new()
+            .with_child(
+                EventHandler::new(if let Some(active_item) = self.active_item() {
+                    Flex::column()
+                        .with_child(
+                            Flex::row()
+                                .with_child(self.render_tabs(cx).flex(1., true).named("tabs"))
+                                .with_child(
+                                    MouseEventHandler::new::<SplitIcon, _, _>(
+                                        0,
+                                        cx,
+                                        |mouse_state, cx| {
+                                            let theme = &cx.global::<Settings>().theme.workspace;
+                                            let style =
+                                                theme.pane_button.style_for(mouse_state, false);
+                                            Svg::new("icons/split.svg")
+                                                .with_color(style.color)
+                                                .constrained()
+                                                .with_width(style.icon_width)
+                                                .aligned()
+                                                .contained()
+                                                .with_style(style.container)
+                                                .constrained()
+                                                .with_width(style.button_width)
+                                                .with_height(style.button_width)
+                                                .aligned()
+                                                .boxed()
+                                        },
+                                    )
+                                    .with_cursor_style(CursorStyle::PointingHand)
+                                    .on_mouse_down(|position, cx| {
+                                        cx.dispatch_action(DeploySplitMenu { position });
+                                    })
+                                    .boxed(),
+                                )
+                                .constrained()
+                                .with_height(cx.global::<Settings>().theme.workspace.tab.height)
+                                .boxed(),
+                        )
+                        .with_child(ChildView::new(&self.toolbar).boxed())
+                        .with_child(ChildView::new(active_item).flex(1., true).boxed())
+                        .boxed()
+                } else {
+                    Empty::new().boxed()
+                })
+                .on_navigate_mouse_down(move |direction, cx| {
+                    let this = this.clone();
+                    match direction {
+                        NavigationDirection::Back => {
+                            cx.dispatch_action(GoBack { pane: Some(this) })
+                        }
+                        NavigationDirection::Forward => {
+                            cx.dispatch_action(GoForward { pane: Some(this) })
+                        }
+                    }
 
-            true
-        })
-        .named("pane")
+                    true
+                })
+                .boxed(),
+            )
+            .with_child(ChildView::new(&self.split_menu).boxed())
+            .named("pane")
     }
 
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {

styles/src/styleTree/contextMenu.ts 🔗

@@ -8,13 +8,14 @@ export default function contextMenu(theme: Theme) {
     padding: 6,
     shadow: popoverShadow(theme),
     border: border(theme, "primary"),
+    keystrokeMargin: 30,
     item: {
       padding: { left: 4, right: 4, top: 2, bottom: 2 },
       cornerRadius: 6,
       label: text(theme, "sans", "secondary", { size: "sm" }),
       keystroke: {
-        margin: { left: 60 },
-        ...text(theme, "sans", "muted", { size: "sm", weight: "bold" })
+        ...text(theme, "sans", "muted", { size: "sm", weight: "bold" }),
+        padding: { left: 3, right: 3 }
       },
       hover: {
         background: backgroundColor(theme, 300, "hovered"),

styles/src/styleTree/workspace.ts 🔗

@@ -55,6 +55,18 @@ export default function workspace(theme: Theme) {
     leaderBorderWidth: 2.0,
     tab,
     activeTab,
+    paneButton: {
+      color: iconColor(theme, "secondary"),
+      border: {
+        ...tab.border
+      },
+      iconWidth: 14,
+      buttonWidth: tab.height,
+      hover: {
+        color: iconColor(theme, "active"),
+        background: backgroundColor(theme, 300),
+      }
+    },
     modal: {
       margin: {
         bottom: 52,