Add support for activating a pane by direction

Conrad Irwin created

Contributes: zed-industries/community#476
Contributes: zed-industries/community#478

Change summary

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 | 74 +++++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs  | 47 +++++++++++++++++++
7 files changed, 157 insertions(+), 5 deletions(-)

Detailed changes

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,
@@ -2108,6 +2118,7 @@ impl Element<Editor> for EditorElement {
                     line_mode,
                     cursor_shape,
                     &snapshot.display_snapshot,
+                    false,
                 ));
         }
         selections.extend(remote_selections);
@@ -2117,6 +2128,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;
@@ -2139,11 +2151,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,40 @@ impl PaneAxis {
         }
     }
 
+    fn bounding_box_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<RectF> {
+        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>> {
+        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 +476,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 +604,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 +773,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,40 @@ 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) => cursor,
+            None => bounding_box.center(),
+        };
+
+        // currently there's a small gap between panes, so we can't just look "1px to the left"
+        // instead of trying to calcuate this exactly, we assume it'll always be smaller than
+        // "pane_gap" pixels (and that no-one uses panes smaller in any dimension than pane_gap).
+        let pane_gap = 20.;
+
+        let target = match direction {
+            SplitDirection::Left => vec2f(bounding_box.origin_x() - pane_gap, center.y()),
+            SplitDirection::Right => vec2f(bounding_box.max_x() + pane_gap, center.y()),
+            SplitDirection::Up => vec2f(center.x(), bounding_box.origin_y() - pane_gap),
+            SplitDirection::Down => vec2f(center.x(), bounding_box.max_y() + pane_gap),
+        };
+
+        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 +3074,7 @@ impl Workspace {
                     axis,
                     members,
                     flexes,
+                    bounding_boxes: _,
                 }) => SerializedPaneGroup::Group {
                     axis: *axis,
                     children: members