Pane tabs: Scroll entire new tab into view (#36827)

hrou0003 created

The state of the child bounds is not up-to-date when `scroll_to_item`
gets triggered, causing the new tab to not scroll completely into view.

Closes #36317 

Release Notes:

- Fix an issue where a new tab is only partially visible on creation.

Change summary

crates/collab/src/tests/integration_tests.rs |  6 --
crates/gpui/src/elements/div.rs              | 60 +++++++++++++-------
crates/workspace/src/pane.rs                 | 64 +++++++++++++++++++--
3 files changed, 95 insertions(+), 35 deletions(-)

Detailed changes

crates/collab/src/tests/integration_tests.rs 🔗

@@ -6514,14 +6514,8 @@ async fn test_right_click_menu_behind_collab_panel(cx: &mut TestAppContext) {
     cx.simulate_keystrokes("cmd-n cmd-n cmd-n");
     cx.update(|window, _cx| window.refresh());
 
-    let tab_bounds = cx.debug_bounds("TAB-2").unwrap();
     let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap();
 
-    assert!(
-        tab_bounds.intersects(&new_tab_button_bounds),
-        "Tab should overlap with the new tab button, if this is failing check if there's been a redesign!"
-    );
-
     cx.simulate_event(MouseDownEvent {
         button: MouseButton::Right,
         position: new_tab_button_bounds.center(),

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

@@ -1384,6 +1384,10 @@ impl Element for Div {
             (child_max - child_min).into()
         };
 
+        if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() {
+            scroll_handle.scroll_to_active_item();
+        }
+
         self.interactivity.prepaint(
             global_id,
             inspector_id,
@@ -2986,8 +2990,7 @@ where
 }
 
 /// Represents an element that can be scrolled *to* in its parent element.
-///
-/// Contrary to [ScrollHandle::scroll_to_item], an anchored element does not have to be an immediate child of the parent.
+/// Contrary to [ScrollHandle::scroll_to_active_item], an anchored element does not have to be an immediate child of the parent.
 #[derive(Clone)]
 pub struct ScrollAnchor {
     handle: ScrollHandle,
@@ -3022,6 +3025,7 @@ struct ScrollHandleState {
     child_bounds: Vec<Bounds<Pixels>>,
     scroll_to_bottom: bool,
     overflow: Point<Overflow>,
+    active_item: Option<usize>,
 }
 
 /// A handle to the scrollable aspects of an element.
@@ -3081,32 +3085,44 @@ impl ScrollHandle {
         self.0.borrow().child_bounds.get(ix).cloned()
     }
 
-    /// scroll_to_item scrolls the minimal amount to ensure that the child is
-    /// fully visible
+    /// Update [ScrollHandleState]'s active item for scrolling to in prepaint
     pub fn scroll_to_item(&self, ix: usize) {
-        let state = self.0.borrow();
+        let mut state = self.0.borrow_mut();
+        state.active_item = Some(ix);
+    }
+
+    /// Scrolls the minimal amount to ensure that the child is
+    /// fully visible
+    fn scroll_to_active_item(&self) {
+        let mut state = self.0.borrow_mut();
 
-        let Some(bounds) = state.child_bounds.get(ix) else {
+        let Some(active_item_index) = state.active_item else {
             return;
         };
+        let active_item = match state.child_bounds.get(active_item_index) {
+            Some(bounds) => {
+                let mut scroll_offset = state.offset.borrow_mut();
+
+                if state.overflow.y == Overflow::Scroll {
+                    if bounds.top() + scroll_offset.y < state.bounds.top() {
+                        scroll_offset.y = state.bounds.top() - bounds.top();
+                    } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() {
+                        scroll_offset.y = state.bounds.bottom() - bounds.bottom();
+                    }
+                }
 
-        let mut scroll_offset = state.offset.borrow_mut();
-
-        if state.overflow.y == Overflow::Scroll {
-            if bounds.top() + scroll_offset.y < state.bounds.top() {
-                scroll_offset.y = state.bounds.top() - bounds.top();
-            } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() {
-                scroll_offset.y = state.bounds.bottom() - bounds.bottom();
-            }
-        }
-
-        if state.overflow.x == Overflow::Scroll {
-            if bounds.left() + scroll_offset.x < state.bounds.left() {
-                scroll_offset.x = state.bounds.left() - bounds.left();
-            } else if bounds.right() + scroll_offset.x > state.bounds.right() {
-                scroll_offset.x = state.bounds.right() - bounds.right();
+                if state.overflow.x == Overflow::Scroll {
+                    if bounds.left() + scroll_offset.x < state.bounds.left() {
+                        scroll_offset.x = state.bounds.left() - bounds.left();
+                    } else if bounds.right() + scroll_offset.x > state.bounds.right() {
+                        scroll_offset.x = state.bounds.right() - bounds.right();
+                    }
+                }
+                None
             }
-        }
+            None => Some(active_item_index),
+        };
+        state.active_item = active_item;
     }
 
     /// Scrolls to the bottom.

crates/workspace/src/pane.rs 🔗

@@ -367,6 +367,9 @@ pub struct Pane {
     max_tabs: Option<NonZeroUsize>,
     _subscriptions: Vec<Subscription>,
     tab_bar_scroll_handle: ScrollHandle,
+    /// This is set to true if a user scroll has occurred more recently than a system scroll
+    /// We want to suppress certain system scrolls when the user has intentionally scrolled
+    suppress_scroll: bool,
     /// Is None if navigation buttons are permanently turned off (and should not react to setting changes).
     /// Otherwise, when `display_nav_history_buttons` is Some, it determines whether nav buttons should be displayed.
     display_nav_history_buttons: Option<bool>,
@@ -497,6 +500,7 @@ impl Pane {
             }))),
             toolbar: cx.new(|_| Toolbar::new()),
             tab_bar_scroll_handle: ScrollHandle::new(),
+            suppress_scroll: false,
             drag_split_direction: None,
             workspace,
             project: project.downgrade(),
@@ -573,6 +577,9 @@ impl Pane {
         if !self.was_focused {
             self.was_focused = true;
             self.update_history(self.active_item_index);
+            if !self.suppress_scroll && self.items.get(self.active_item_index).is_some() {
+                self.update_active_tab(self.active_item_index);
+            }
             cx.emit(Event::Focus);
             cx.notify();
         }
@@ -618,6 +625,7 @@ impl Pane {
         self.toolbar.update(cx, |toolbar, cx| {
             toolbar.focus_changed(false, window, cx);
         });
+
         cx.notify();
     }
 
@@ -1124,6 +1132,7 @@ impl Pane {
             }
         } else {
             self.items.insert(insertion_index, item.clone());
+            cx.notify();
 
             if activate {
                 if insertion_index <= self.active_item_index
@@ -1134,7 +1143,6 @@ impl Pane {
 
                 self.activate_item(insertion_index, activate_pane, focus_item, window, cx);
             }
-            cx.notify();
         }
 
         cx.emit(Event::AddItem { item });
@@ -1272,15 +1280,18 @@ impl Pane {
                 focus_changed: focus_item,
             });
 
-            if !self.is_tab_pinned(index) {
-                self.tab_bar_scroll_handle
-                    .scroll_to_item(index - self.pinned_tab_count);
-            }
-
+            self.update_active_tab(index);
             cx.notify();
         }
     }
 
+    fn update_active_tab(&mut self, index: usize) {
+        if !self.is_tab_pinned(index) {
+            self.suppress_scroll = false;
+            self.tab_bar_scroll_handle.scroll_to_item(index);
+        }
+    }
+
     fn update_history(&mut self, index: usize) {
         if let Some(newly_active_item) = self.items.get(index) {
             self.activation_history
@@ -3028,6 +3039,9 @@ impl Pane {
                     .overflow_x_scroll()
                     .w_full()
                     .track_scroll(&self.tab_bar_scroll_handle)
+                    .on_scroll_wheel(cx.listener(|this, _, _, _| {
+                        this.suppress_scroll = true;
+                    }))
                     .children(unpinned_tabs)
                     .child(
                         div()
@@ -4095,7 +4109,7 @@ mod tests {
 
     use super::*;
     use crate::item::test::{TestItem, TestProjectItem};
-    use gpui::{TestAppContext, VisualTestContext};
+    use gpui::{TestAppContext, VisualTestContext, size};
     use project::FakeFs;
     use settings::SettingsStore;
     use theme::LoadThemes;
@@ -6310,6 +6324,42 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_new_tab_scrolls_into_view_completely(cx: &mut TestAppContext) {
+        // Arrange
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        let project = Project::test(fs, None, cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
+
+        cx.simulate_resize(size(px(300.), px(300.)));
+
+        add_labeled_item(&pane, "untitled", false, cx);
+        add_labeled_item(&pane, "untitled", false, cx);
+        add_labeled_item(&pane, "untitled", false, cx);
+        add_labeled_item(&pane, "untitled", false, cx);
+        // Act: this should trigger a scroll
+        add_labeled_item(&pane, "untitled", false, cx);
+        // Assert
+        let tab_bar_scroll_handle =
+            pane.update_in(cx, |pane, _window, _cx| pane.tab_bar_scroll_handle.clone());
+        assert_eq!(tab_bar_scroll_handle.children_count(), 6);
+        let tab_bounds = cx.debug_bounds("TAB-3").unwrap();
+        let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap();
+        let scroll_bounds = tab_bar_scroll_handle.bounds();
+        let scroll_offset = tab_bar_scroll_handle.offset();
+        assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x);
+        // -39.75 is the magic number for this setup
+        assert_eq!(scroll_offset.x, px(-39.75));
+        assert!(
+            !tab_bounds.intersects(&new_tab_button_bounds),
+            "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!"
+        );
+    }
+
     #[gpui::test]
     async fn test_close_all_items_including_pinned(cx: &mut TestAppContext) {
         init_test(cx);