Add workspace::ActivatePaneInDirection (#2757)

Conrad Irwin created

This change adds support for choosing a pane based on direction; and
adds default keybindings (`cmd+k cmd+{left,right,up,down}`) and vim
keybindings.

Release Notes:

- Add support for navigating to the next pane in a given direction using
`cmd+k cmd-{up,down,left,right}`
([#476](https://github.com/zed-industries/community/issues/476),
[#478](https://github.com/zed-industries/community/issues/478))
- Vim: adds support for many window related shortcuts: `ctrl-w
{h,j,k,l,up,down,left,right,w,W,p}` for navigating around panes, `ctrl-w
{q,c}` for closing panes and `ctrl-w {v,s}` for splitting panes.

Change summary

assets/keymaps/default.json        | 18 ++++++
assets/keymaps/vim.json            | 70 ++++++++++++++++++++++++++++
crates/editor/src/editor.rs        |  2 
crates/editor/src/element.rs       | 14 +++++
crates/editor/src/items.rs         | 10 +++
crates/workspace/src/item.rs       |  9 +++
crates/workspace/src/pane.rs       |  6 ++
crates/workspace/src/pane_group.rs | 78 +++++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs  | 44 +++++++++++++++++
9 files changed, 243 insertions(+), 8 deletions(-)

Detailed changes

assets/keymaps/default.json 🔗

@@ -446,8 +446,22 @@
   },
   {
     "bindings": {
-      "cmd-k cmd-left": "workspace::ActivatePreviousPane",
-      "cmd-k cmd-right": "workspace::ActivateNextPane"
+      "cmd-k cmd-left": [
+        "workspace::ActivatePaneInDirection",
+        "Left"
+      ],
+      "cmd-k cmd-right": [
+        "workspace::ActivatePaneInDirection",
+        "Right"
+      ],
+      "cmd-k cmd-up": [
+        "workspace::ActivatePaneInDirection",
+        "Up"
+      ],
+      "cmd-k cmd-down": [
+        "workspace::ActivatePaneInDirection",
+        "Down"
+      ]
     }
   },
   // Bindings from Atom

assets/keymaps/vim.json 🔗

@@ -145,7 +145,75 @@
       "9": [
         "vim::Number",
         9
-      ]
+      ],
+      // window related commands (ctrl-w X)
+      "ctrl-w left": [
+        "workspace::ActivatePaneInDirection",
+        "Left"
+      ],
+      "ctrl-w right": [
+        "workspace::ActivatePaneInDirection",
+        "Right"
+      ],
+      "ctrl-w up": [
+        "workspace::ActivatePaneInDirection",
+        "Up"
+      ],
+      "ctrl-w down": [
+        "workspace::ActivatePaneInDirection",
+        "Down"
+      ],
+      "ctrl-w h": [
+        "workspace::ActivatePaneInDirection",
+        "Left"
+      ],
+      "ctrl-w l": [
+        "workspace::ActivatePaneInDirection",
+        "Right"
+      ],
+      "ctrl-w k": [
+        "workspace::ActivatePaneInDirection",
+        "Up"
+      ],
+      "ctrl-w j": [
+        "workspace::ActivatePaneInDirection",
+        "Down"
+      ],
+      "ctrl-w ctrl-h": [
+        "workspace::ActivatePaneInDirection",
+        "Left"
+      ],
+      "ctrl-w ctrl-l": [
+        "workspace::ActivatePaneInDirection",
+        "Right"
+      ],
+      "ctrl-w ctrl-k": [
+        "workspace::ActivatePaneInDirection",
+        "Up"
+      ],
+      "ctrl-w ctrl-j": [
+        "workspace::ActivatePaneInDirection",
+        "Down"
+      ],
+      "ctrl-w g t": "pane::ActivateNextItem",
+      "ctrl-w ctrl-g t": "pane::ActivateNextItem",
+      "ctrl-w g shift-t": "pane::ActivatePrevItem",
+      "ctrl-w ctrl-g shift-t": "pane::ActivatePrevItem",
+      "ctrl-w w": "workspace::ActivateNextPane",
+      "ctrl-w ctrl-w": "workspace::ActivateNextPane",
+      "ctrl-w p": "workspace::ActivatePreviousPane",
+      "ctrl-w ctrl-p": "workspace::ActivatePreviousPane",
+      "ctrl-w shift-w": "workspace::ActivatePreviousPane",
+      "ctrl-w ctrl-shift-w": "workspace::ActivatePreviousPane",
+      "ctrl-w v": "pane::SplitLeft",
+      "ctrl-w ctrl-v": "pane::SplitLeft",
+      "ctrl-w s": "pane::SplitUp",
+      "ctrl-w shift-s": "pane::SplitUp",
+      "ctrl-w ctrl-s": "pane::SplitUp",
+      "ctrl-w c": "pane::CloseAllItems",
+      "ctrl-w ctrl-c": "pane::CloseAllItems",
+      "ctrl-w q": "pane::CloseAllItems",
+      "ctrl-w ctrl-q": "pane::CloseAllItems"
     }
   },
   {

crates/editor/src/editor.rs 🔗

@@ -563,6 +563,7 @@ pub struct Editor {
     inlay_hint_cache: InlayHintCache,
     next_inlay_id: usize,
     _subscriptions: Vec<Subscription>,
+    pixel_position_of_newest_cursor: Option<Vector2F>,
 }
 
 pub struct EditorSnapshot {
@@ -1394,6 +1395,7 @@ impl Editor {
             copilot_state: Default::default(),
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
             gutter_hovered: false,
+            pixel_position_of_newest_cursor: None,
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
                 cx.subscribe(&buffer, Self::on_buffer_event),

crates/editor/src/element.rs 🔗

@@ -61,6 +61,7 @@ enum FoldMarkers {}
 struct SelectionLayout {
     head: DisplayPoint,
     cursor_shape: CursorShape,
+    is_newest: bool,
     range: Range<DisplayPoint>,
 }
 
@@ -70,6 +71,7 @@ impl SelectionLayout {
         line_mode: bool,
         cursor_shape: CursorShape,
         map: &DisplaySnapshot,
+        is_newest: bool,
     ) -> Self {
         if line_mode {
             let selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
@@ -77,6 +79,7 @@ impl SelectionLayout {
             Self {
                 head: selection.head().to_display_point(map),
                 cursor_shape,
+                is_newest,
                 range: point_range.start.to_display_point(map)
                     ..point_range.end.to_display_point(map),
             }
@@ -85,6 +88,7 @@ impl SelectionLayout {
             Self {
                 head: selection.head(),
                 cursor_shape,
+                is_newest,
                 range: selection.range(),
             }
         }
@@ -864,6 +868,12 @@ impl EditorElement {
                         let x = cursor_character_x - scroll_left;
                         let y = cursor_position.row() as f32 * layout.position_map.line_height
                             - scroll_top;
+                        if selection.is_newest {
+                            editor.pixel_position_of_newest_cursor = Some(vec2f(
+                                bounds.origin_x() + x + block_width / 2.,
+                                bounds.origin_y() + y + layout.position_map.line_height / 2.,
+                            ));
+                        }
                         cursors.push(Cursor {
                             color: selection_style.cursor,
                             block_width,
@@ -2109,6 +2119,7 @@ impl Element<Editor> for EditorElement {
                     line_mode,
                     cursor_shape,
                     &snapshot.display_snapshot,
+                    false,
                 ));
         }
         selections.extend(remote_selections);
@@ -2118,6 +2129,7 @@ impl Element<Editor> for EditorElement {
                 .selections
                 .disjoint_in_range(start_anchor..end_anchor, cx);
             local_selections.extend(editor.selections.pending(cx));
+            let newest = editor.selections.newest(cx);
             for selection in &local_selections {
                 let is_empty = selection.start == selection.end;
                 let selection_start = snapshot.prev_line_boundary(selection.start).1;
@@ -2140,11 +2152,13 @@ impl Element<Editor> for EditorElement {
                 local_selections
                     .into_iter()
                     .map(|selection| {
+                        let is_newest = selection == newest;
                         SelectionLayout::new(
                             selection,
                             editor.selections.line_mode,
                             editor.cursor_shape,
                             &snapshot.display_snapshot,
+                            is_newest,
                         )
                     })
                     .collect(),

crates/editor/src/items.rs 🔗

@@ -7,8 +7,10 @@ use anyhow::{Context, Result};
 use collections::HashSet;
 use futures::future::try_join_all;
 use gpui::{
-    elements::*, geometry::vector::vec2f, AppContext, AsyncAppContext, Entity, ModelHandle,
-    Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+    elements::*,
+    geometry::vector::{vec2f, Vector2F},
+    AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, ViewContext,
+    ViewHandle, WeakViewHandle,
 };
 use language::{
     proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, OffsetRangeExt, Point,
@@ -750,6 +752,10 @@ impl Item for Editor {
         Some(Box::new(handle.clone()))
     }
 
+    fn pixel_position_of_cursor(&self) -> Option<Vector2F> {
+        self.pixel_position_of_newest_cursor
+    }
+
     fn breadcrumb_location(&self) -> ToolbarItemLocation {
         ToolbarItemLocation::PrimaryLeft { flex: None }
     }

crates/workspace/src/item.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
 use crate::{AutosaveSetting, DelayedDebouncedEditAction, WorkspaceSettings};
 use anyhow::Result;
 use client::{proto, Client};
+use gpui::geometry::vector::Vector2F;
 use gpui::{
     fonts::HighlightStyle, AnyElement, AnyViewHandle, AppContext, ModelHandle, Task, View,
     ViewContext, ViewHandle, WeakViewHandle, WindowContext,
@@ -203,6 +204,9 @@ pub trait Item: View {
     fn show_toolbar(&self) -> bool {
         true
     }
+    fn pixel_position_of_cursor(&self) -> Option<Vector2F> {
+        None
+    }
 }
 
 pub trait ItemHandle: 'static + fmt::Debug {
@@ -271,6 +275,7 @@ pub trait ItemHandle: 'static + fmt::Debug {
     fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>>;
     fn serialized_item_kind(&self) -> Option<&'static str>;
     fn show_toolbar(&self, cx: &AppContext) -> bool;
+    fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F>;
 }
 
 pub trait WeakItemHandle {
@@ -615,6 +620,10 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
     fn show_toolbar(&self, cx: &AppContext) -> bool {
         self.read(cx).show_toolbar()
     }
+
+    fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F> {
+        self.read(cx).pixel_position_of_cursor()
+    }
 }
 
 impl From<Box<dyn ItemHandle>> for AnyViewHandle {

crates/workspace/src/pane.rs 🔗

@@ -542,6 +542,12 @@ impl Pane {
         self.items.get(self.active_item_index).cloned()
     }
 
+    pub fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F> {
+        self.items
+            .get(self.active_item_index)?
+            .pixel_position_of_cursor(cx)
+    }
+
     pub fn item_for_entry(
         &self,
         entry_id: ProjectEntryId,

crates/workspace/src/pane_group.rs 🔗

@@ -54,6 +54,20 @@ impl PaneGroup {
         }
     }
 
+    pub fn bounding_box_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<RectF> {
+        match &self.root {
+            Member::Pane(_) => None,
+            Member::Axis(axis) => axis.bounding_box_for_pane(pane),
+        }
+    }
+
+    pub fn pane_at_pixel_position(&self, coordinate: Vector2F) -> Option<&ViewHandle<Pane>> {
+        match &self.root {
+            Member::Pane(pane) => Some(pane),
+            Member::Axis(axis) => axis.pane_at_pixel_position(coordinate),
+        }
+    }
+
     /// Returns:
     /// - Ok(true) if it found and removed a pane
     /// - Ok(false) if it found but did not remove the pane
@@ -309,15 +323,18 @@ pub(crate) struct PaneAxis {
     pub axis: Axis,
     pub members: Vec<Member>,
     pub flexes: Rc<RefCell<Vec<f32>>>,
+    pub bounding_boxes: Rc<RefCell<Vec<Option<RectF>>>>,
 }
 
 impl PaneAxis {
     pub fn new(axis: Axis, members: Vec<Member>) -> Self {
         let flexes = Rc::new(RefCell::new(vec![1.; members.len()]));
+        let bounding_boxes = Rc::new(RefCell::new(vec![None; members.len()]));
         Self {
             axis,
             members,
             flexes,
+            bounding_boxes,
         }
     }
 
@@ -326,10 +343,12 @@ impl PaneAxis {
         debug_assert!(members.len() == flexes.len());
 
         let flexes = Rc::new(RefCell::new(flexes));
+        let bounding_boxes = Rc::new(RefCell::new(vec![None; members.len()]));
         Self {
             axis,
             members,
             flexes,
+            bounding_boxes,
         }
     }
 
@@ -409,6 +428,44 @@ impl PaneAxis {
         }
     }
 
+    fn bounding_box_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<RectF> {
+        debug_assert!(self.members.len() == self.bounding_boxes.borrow().len());
+
+        for (idx, member) in self.members.iter().enumerate() {
+            match member {
+                Member::Pane(found) => {
+                    if pane == found {
+                        return self.bounding_boxes.borrow()[idx];
+                    }
+                }
+                Member::Axis(axis) => {
+                    if let Some(rect) = axis.bounding_box_for_pane(pane) {
+                        return Some(rect);
+                    }
+                }
+            }
+        }
+        None
+    }
+
+    fn pane_at_pixel_position(&self, coordinate: Vector2F) -> Option<&ViewHandle<Pane>> {
+        debug_assert!(self.members.len() == self.bounding_boxes.borrow().len());
+
+        let bounding_boxes = self.bounding_boxes.borrow();
+
+        for (idx, member) in self.members.iter().enumerate() {
+            if let Some(coordinates) = bounding_boxes[idx] {
+                if coordinates.contains_point(coordinate) {
+                    return match member {
+                        Member::Pane(found) => Some(found),
+                        Member::Axis(axis) => axis.pane_at_pixel_position(coordinate),
+                    };
+                }
+            }
+        }
+        None
+    }
+
     fn render(
         &self,
         project: &ModelHandle<Project>,
@@ -423,7 +480,12 @@ impl PaneAxis {
     ) -> AnyElement<Workspace> {
         debug_assert!(self.members.len() == self.flexes.borrow().len());
 
-        let mut pane_axis = PaneAxisElement::new(self.axis, basis, self.flexes.clone());
+        let mut pane_axis = PaneAxisElement::new(
+            self.axis,
+            basis,
+            self.flexes.clone(),
+            self.bounding_boxes.clone(),
+        );
         let mut active_pane_ix = None;
 
         let mut members = self.members.iter().enumerate().peekable();
@@ -546,14 +608,21 @@ mod element {
         active_pane_ix: Option<usize>,
         flexes: Rc<RefCell<Vec<f32>>>,
         children: Vec<AnyElement<Workspace>>,
+        bounding_boxes: Rc<RefCell<Vec<Option<RectF>>>>,
     }
 
     impl PaneAxisElement {
-        pub fn new(axis: Axis, basis: usize, flexes: Rc<RefCell<Vec<f32>>>) -> Self {
+        pub fn new(
+            axis: Axis,
+            basis: usize,
+            flexes: Rc<RefCell<Vec<f32>>>,
+            bounding_boxes: Rc<RefCell<Vec<Option<RectF>>>>,
+        ) -> Self {
             Self {
                 axis,
                 basis,
                 flexes,
+                bounding_boxes,
                 active_pane_ix: None,
                 children: Default::default(),
             }
@@ -708,11 +777,16 @@ mod element {
 
             let mut child_origin = bounds.origin();
 
+            let mut bounding_boxes = self.bounding_boxes.borrow_mut();
+            bounding_boxes.clear();
+
             let mut children_iter = self.children.iter_mut().enumerate().peekable();
             while let Some((ix, child)) = children_iter.next() {
                 let child_start = child_origin.clone();
                 child.paint(scene, child_origin, visible_bounds, view, cx);
 
+                bounding_boxes.push(Some(RectF::new(child_origin, child.size())));
+
                 match self.axis {
                     Axis::Horizontal => child_origin += vec2f(child.size().x(), 0.0),
                     Axis::Vertical => child_origin += vec2f(0.0, child.size().y()),

crates/workspace/src/workspace.rs 🔗

@@ -152,6 +152,9 @@ pub struct OpenPaths {
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct ActivatePane(pub usize);
 
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct ActivatePaneInDirection(pub SplitDirection);
+
 #[derive(Deserialize)]
 pub struct Toast {
     id: usize,
@@ -197,7 +200,7 @@ impl Clone for Toast {
     }
 }
 
-impl_actions!(workspace, [ActivatePane, Toast]);
+impl_actions!(workspace, [ActivatePane, ActivatePaneInDirection, Toast]);
 
 pub type WorkspaceId = i64;
 
@@ -262,6 +265,13 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     cx.add_action(|workspace: &mut Workspace, _: &ActivateNextPane, cx| {
         workspace.activate_next_pane(cx)
     });
+
+    cx.add_action(
+        |workspace: &mut Workspace, action: &ActivatePaneInDirection, cx| {
+            workspace.activate_pane_in_direction(action.0, cx)
+        },
+    );
+
     cx.add_action(|workspace: &mut Workspace, _: &ToggleLeftDock, cx| {
         workspace.toggle_dock(DockPosition::Left, cx);
     });
@@ -2054,6 +2064,37 @@ impl Workspace {
         }
     }
 
+    pub fn activate_pane_in_direction(
+        &mut self,
+        direction: SplitDirection,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let bounding_box = match self.center.bounding_box_for_pane(&self.active_pane) {
+            Some(coordinates) => coordinates,
+            None => {
+                return;
+            }
+        };
+        let cursor = self.active_pane.read(cx).pixel_position_of_cursor(cx);
+        let center = match cursor {
+            Some(cursor) if bounding_box.contains_point(cursor) => cursor,
+            _ => bounding_box.center(),
+        };
+
+        let distance_to_next = theme::current(cx).workspace.pane_divider.width + 1.;
+
+        let target = match direction {
+            SplitDirection::Left => vec2f(bounding_box.origin_x() - distance_to_next, center.y()),
+            SplitDirection::Right => vec2f(bounding_box.max_x() + distance_to_next, center.y()),
+            SplitDirection::Up => vec2f(center.x(), bounding_box.origin_y() - distance_to_next),
+            SplitDirection::Down => vec2f(center.x(), bounding_box.max_y() + distance_to_next),
+        };
+
+        if let Some(pane) = self.center.pane_at_pixel_position(target) {
+            cx.focus(pane);
+        }
+    }
+
     fn handle_pane_focused(&mut self, pane: ViewHandle<Pane>, cx: &mut ViewContext<Self>) {
         if self.active_pane != pane {
             self.active_pane = pane.clone();
@@ -3030,6 +3071,7 @@ impl Workspace {
                     axis,
                     members,
                     flexes,
+                    bounding_boxes: _,
                 }) => SerializedPaneGroup::Group {
                     axis: *axis,
                     children: members