settings ui: Move selected nav bar entry on scroll (#39633)

Anthony Eid created

This PR makes selecting a sub-entry in the settings UI nav bar scroll to
that section in the settings page. It also updates the selected
sub-entry when scrolling through a settings page to match what a user is
viewing on the page.

I also added a new helper method to `ScrollHandle` type called
`scroll_to_top_of_item` that scrolls until an item is the top element
visible.

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/div.rs       | 91 +++++++++++++++++++++++++---
crates/settings_ui/src/settings_ui.rs | 58 +++++++++++++++++
2 files changed, 135 insertions(+), 14 deletions(-)

Detailed changes

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

@@ -3034,7 +3034,20 @@ struct ScrollHandleState {
     child_bounds: Vec<Bounds<Pixels>>,
     scroll_to_bottom: bool,
     overflow: Point<Overflow>,
-    active_item: Option<usize>,
+    active_item: Option<ScrollActiveItem>,
+}
+
+#[derive(Default, Debug, Clone, Copy)]
+struct ScrollActiveItem {
+    index: usize,
+    strategy: ScrollStrategy,
+}
+
+#[derive(Default, Debug, Clone, Copy)]
+enum ScrollStrategy {
+    #[default]
+    FirstVisible,
+    Top,
 }
 
 /// A handle to the scrollable aspects of an element.
@@ -3084,6 +3097,25 @@ impl ScrollHandle {
         }
     }
 
+    /// Get the bottom child that's scrolled into view.
+    pub fn bottom_item(&self) -> usize {
+        let state = self.0.borrow();
+        let bottom = state.bounds.bottom() - state.offset.borrow().y;
+
+        match state.child_bounds.binary_search_by(|bounds| {
+            if bottom < bounds.top() {
+                Ordering::Greater
+            } else if bottom > bounds.bottom() {
+                Ordering::Less
+            } else {
+                Ordering::Equal
+            }
+        }) {
+            Ok(ix) => ix,
+            Err(ix) => ix.min(state.child_bounds.len().saturating_sub(1)),
+        }
+    }
+
     /// Return the bounds into which this child is painted
     pub fn bounds(&self) -> Bounds<Pixels> {
         self.0.borrow().bounds
@@ -3097,26 +3129,48 @@ impl ScrollHandle {
     /// Update [ScrollHandleState]'s active item for scrolling to in prepaint
     pub fn scroll_to_item(&self, ix: usize) {
         let mut state = self.0.borrow_mut();
-        state.active_item = Some(ix);
+        state.active_item = Some(ScrollActiveItem {
+            index: ix,
+            strategy: ScrollStrategy::default(),
+        });
     }
 
-    /// Scrolls the minimal amount to ensure that the child is
-    /// fully visible
+    /// Update [ScrollHandleState]'s active item for scrolling to in prepaint
+    /// This scrolls the minimal amount to ensure that the child is the first visible element
+    pub fn scroll_to_top_of_item(&self, ix: usize) {
+        let mut state = self.0.borrow_mut();
+        state.active_item = Some(ScrollActiveItem {
+            index: ix,
+            strategy: ScrollStrategy::Top,
+        });
+    }
+
+    /// Scrolls the minimal amount to either ensure that the child is
+    /// fully visible or the top element of the view depends on the
+    /// scroll strategy
     fn scroll_to_active_item(&self) {
         let mut state = self.0.borrow_mut();
 
-        let Some(active_item_index) = state.active_item else {
+        let Some(active_item) = state.active_item else {
             return;
         };
-        let active_item = match state.child_bounds.get(active_item_index) {
+
+        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() {
+                match active_item.strategy {
+                    ScrollStrategy::FirstVisible => {
+                        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();
+                            }
+                        }
+                    }
+                    ScrollStrategy::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();
                     }
                 }
 
@@ -3129,7 +3183,7 @@ impl ScrollHandle {
                 }
                 None
             }
-            None => Some(active_item_index),
+            None => Some(active_item),
         };
         state.active_item = active_item;
     }
@@ -3163,6 +3217,21 @@ impl ScrollHandle {
         }
     }
 
+    /// Get the logical scroll bottom, based on a child index and a pixel offset.
+    pub fn logical_scroll_bottom(&self) -> (usize, Pixels) {
+        let ix = self.bottom_item();
+        let state = self.0.borrow();
+
+        if let Some(child_bounds) = state.child_bounds.get(ix) {
+            (
+                ix,
+                child_bounds.bottom() + state.offset.borrow().y - state.bounds.bottom(),
+            )
+        } else {
+            (ix, px(0.))
+        }
+    }
+
     /// Get the count of children for scrollable item.
     pub fn children_count(&self) -> usize {
         self.0.borrow().child_bounds.len()

crates/settings_ui/src/settings_ui.rs 🔗

@@ -986,6 +986,29 @@ impl SettingsWindow {
         cx.notify();
     }
 
+    fn calculate_navbar_entry_from_scroll_position(&mut self) {
+        let top = self.scroll_handle.top_item();
+        let bottom = self.scroll_handle.bottom_item();
+
+        let scroll_index = (top + bottom) / 2;
+        let scroll_index = scroll_index.clamp(top, bottom);
+        let mut page_index = self.navbar_entry;
+
+        while !self.navbar_entries[page_index].is_root {
+            page_index -= 1;
+        }
+
+        if self.navbar_entries[page_index].expanded {
+            let section_index = self
+                .page_items()
+                .take(scroll_index + 1)
+                .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
+                .count();
+
+            self.navbar_entry = section_index + page_index;
+        }
+    }
+
     fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
         let prev_files = self.files.clone();
         let settings_store = cx.global::<SettingsStore>();
@@ -1064,9 +1087,7 @@ impl SettingsWindow {
         window: &mut Window,
         cx: &mut Context<SettingsWindow>,
     ) -> impl IntoElement {
-        let visible_entries: Vec<_> = self.visible_navbar_entries().collect();
-        let visible_count = visible_entries.len();
-
+        let visible_count = self.visible_navbar_entries().count();
         let nav_background = cx.theme().colors().panel_background;
 
         v_flex()
@@ -1112,6 +1133,36 @@ impl SettingsWindow {
                                         .on_click(cx.listener(
                                             move |this, evt: &gpui::ClickEvent, window, cx| {
                                                 this.navbar_entry = ix;
+
+                                                if !this.navbar_entries[ix].is_root {
+                                                    let mut selected_page_ix = ix;
+
+                                                    while !this.navbar_entries[selected_page_ix]
+                                                        .is_root
+                                                    {
+                                                        selected_page_ix -= 1;
+                                                    }
+
+                                                    let section_header = ix - selected_page_ix;
+
+                                                    if let Some(section_index) = this
+                                                        .page_items()
+                                                        .enumerate()
+                                                        .filter(|item| {
+                                                            matches!(
+                                                                item.1,
+                                                                SettingsPageItem::SectionHeader(_)
+                                                            )
+                                                        })
+                                                        .take(section_header)
+                                                        .last()
+                                                        .map(|pair| pair.0)
+                                                    {
+                                                        this.scroll_handle
+                                                            .scroll_to_top_of_item(section_index);
+                                                    }
+                                                }
+
                                                 if evt.is_keyboard() {
                                                     // todo(settings_ui): Focus the actual item and scroll to it
                                                     this.focus_first_content_item(window, cx);
@@ -1380,6 +1431,7 @@ impl SettingsWindow {
 impl Render for SettingsWindow {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let ui_font = theme::setup_ui_font(window, cx);
+        self.calculate_navbar_entry_from_scroll_position();
 
         div()
             .id("settings-window")