settings ui: Improve rendering performance (#40001)

Anthony Eid , Mikayla Maki , and Ben Kunkle created

This PR improves the rendering performance of the Settings UI window by
using `gpui::list` to render only the visible contents of a settings
page, instead of rendering the full content of a page. This fixes a lag
that the editor page has in debug builds.

I also added a new field `measuring_behavior` to `ListState` that has
`Visible` and `Measured` variances. `Visible` only measures and caches
the bounds of visible items plus the overdraw pixel offset. `Measure`
will cache all items’ bounds on the first layout phase, which fixes
problems with the scrollbar size/position being miscalculated.

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/gpui/src/elements/list.rs      |  80 +++++++++++
crates/settings_ui/src/page_data.rs   |   2 
crates/settings_ui/src/settings_ui.rs | 199 +++++++++++++++++++++++++---
3 files changed, 258 insertions(+), 23 deletions(-)

Detailed changes

crates/gpui/src/elements/list.rs πŸ”—

@@ -70,6 +70,7 @@ struct StateInner {
     #[allow(clippy::type_complexity)]
     scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
     scrollbar_drag_start_height: Option<Pixels>,
+    measuring_behavior: ListMeasuringBehavior,
 }
 
 /// Whether the list is scrolling from top to bottom or bottom to top.
@@ -103,6 +104,26 @@ pub enum ListSizingBehavior {
     Auto,
 }
 
+/// The measuring behavior to apply during layout.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum ListMeasuringBehavior {
+    /// Measure all items in the list.
+    /// Note: This can be expensive for the first frame in a large list.
+    Measure(bool),
+    /// Only measure visible items
+    #[default]
+    Visible,
+}
+
+impl ListMeasuringBehavior {
+    fn reset(&mut self) {
+        match self {
+            ListMeasuringBehavior::Measure(has_measured) => *has_measured = false,
+            ListMeasuringBehavior::Visible => {}
+        }
+    }
+}
+
 /// The horizontal sizing behavior to apply during layout.
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum ListHorizontalSizingBehavior {
@@ -203,11 +224,20 @@ impl ListState {
             scroll_handler: None,
             reset: false,
             scrollbar_drag_start_height: None,
+            measuring_behavior: ListMeasuringBehavior::default(),
         })));
         this.splice(0..0, item_count);
         this
     }
 
+    /// Set the list to measure all items in the list in the first layout phase.
+    ///
+    /// This is useful for ensuring that the scrollbar size is correct instead of based on only rendered elements.
+    pub fn measure_all(self) -> Self {
+        self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false);
+        self
+    }
+
     /// Reset this instantiation of the list state.
     ///
     /// Note that this will cause scroll events to be dropped until the next paint.
@@ -215,6 +245,7 @@ impl ListState {
         let old_count = {
             let state = &mut *self.0.borrow_mut();
             state.reset = true;
+            state.measuring_behavior.reset();
             state.logical_scroll_top = None;
             state.scrollbar_drag_start_height = None;
             state.items.summary().count
@@ -524,6 +555,48 @@ impl StateInner {
         cursor.start().height + logical_scroll_top.offset_in_item
     }
 
+    fn layout_all_items(
+        &mut self,
+        available_width: Pixels,
+        render_item: &mut RenderItemFn,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        match &mut self.measuring_behavior {
+            ListMeasuringBehavior::Visible => {
+                return;
+            }
+            ListMeasuringBehavior::Measure(has_measured) => {
+                if *has_measured {
+                    return;
+                }
+                *has_measured = true;
+            }
+        }
+
+        let mut cursor = self.items.cursor::<Count>(());
+        let available_item_space = size(
+            AvailableSpace::Definite(available_width),
+            AvailableSpace::MinContent,
+        );
+
+        let mut measured_items = Vec::default();
+
+        for (ix, item) in cursor.enumerate() {
+            let size = item.size().unwrap_or_else(|| {
+                let mut element = render_item(ix, window, cx);
+                element.layout_as_root(available_item_space, window, cx)
+            });
+
+            measured_items.push(ListItem::Measured {
+                size,
+                focus_handle: item.focus_handle(),
+            });
+        }
+
+        self.items = SumTree::from_iter(measured_items, ());
+    }
+
     fn layout_items(
         &mut self,
         available_width: Option<Pixels>,
@@ -711,6 +784,13 @@ impl StateInner {
         cx: &mut App,
     ) -> Result<LayoutItemsResponse, ListOffset> {
         window.transact(|window| {
+            match self.measuring_behavior {
+                ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
+                    self.layout_all_items(bounds.size.width, render_item, window, cx);
+                }
+                _ => {}
+            }
+
             let mut layout_response = self.layout_items(
                 Some(bounds.size.width),
                 bounds.size.height,

crates/settings_ui/src/page_data.rs πŸ”—

@@ -1723,7 +1723,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                         title: language_name,
                         files: USER | LOCAL,
                         render: Arc::new(|this, window, cx| {
-                            this.render_page_items(
+                            this.render_sub_page_items(
                                 language_settings_data()
                                     .iter()
                                     .chain(non_editor_language_settings_data().iter())

crates/settings_ui/src/settings_ui.rs πŸ”—

@@ -6,10 +6,10 @@ use editor::{Editor, EditorEvent};
 use feature_flags::FeatureFlag;
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    Action, App, Div, Entity, FocusHandle, Focusable, FontWeight, Global, ReadGlobal as _,
-    ScrollHandle, Stateful, Subscription, Task, TitlebarOptions, UniformListScrollHandle, Window,
-    WindowBounds, WindowHandle, WindowOptions, actions, div, point, prelude::*, px, size,
-    uniform_list,
+    Action, App, Div, Entity, FocusHandle, Focusable, FontWeight, Global, ListState,
+    ReadGlobal as _, ScrollHandle, Stateful, Subscription, Task, TitlebarOptions,
+    UniformListScrollHandle, Window, WindowBounds, WindowHandle, WindowOptions, actions, div, list,
+    point, prelude::*, px, size, uniform_list,
 };
 use heck::ToTitleCase as _;
 use project::WorktreeId;
@@ -536,12 +536,14 @@ pub struct SettingsWindow {
     filter_table: Vec<Vec<bool>>,
     has_query: bool,
     content_handles: Vec<Vec<Entity<NonFocusableHandle>>>,
-    page_scroll_handle: ScrollHandle,
+    sub_page_scroll_handle: ScrollHandle,
     focus_handle: FocusHandle,
     navbar_focus_handle: Entity<NonFocusableHandle>,
     content_focus_handle: Entity<NonFocusableHandle>,
     files_focus_handle: FocusHandle,
     search_index: Option<Arc<SearchIndex>>,
+    visible_items: Vec<usize>,
+    list_state: ListState,
 }
 
 struct SearchIndex {
@@ -601,7 +603,7 @@ impl SettingsPageItem {
     fn render(
         &self,
         settings_window: &SettingsWindow,
-        section_header: &'static str,
+        item_index: usize,
         is_last: bool,
         window: &mut Window,
         cx: &mut Context<SettingsWindow>,
@@ -712,7 +714,23 @@ impl SettingsPageItem {
                     .on_click({
                         let sub_page_link = sub_page_link.clone();
                         cx.listener(move |this, _, _, cx| {
-                            this.push_sub_page(sub_page_link.clone(), section_header, cx)
+                            let mut section_index = item_index;
+                            let current_page = this.current_page();
+
+                            while !matches!(
+                                current_page.items[section_index],
+                                SettingsPageItem::SectionHeader(_)
+                            ) {
+                                section_index -= 1;
+                            }
+
+                            let SettingsPageItem::SectionHeader(header) =
+                                current_page.items[section_index]
+                            else {
+                                unreachable!("All items always have a section header above them")
+                            };
+
+                            this.push_sub_page(sub_page_link.clone(), header, cx)
                         })
                     }),
                 )
@@ -980,6 +998,10 @@ impl SettingsWindow {
             None
         };
 
+        // high overdraw value so the list scrollbar len doesn't change too much
+        let list_state = gpui::ListState::new(0, gpui::ListAlignment::Top, px(100.0)).measure_all();
+        list_state.set_scroll_handler(|_, _, _| {});
+
         let mut this = Self {
             title_bar,
             original_window,
@@ -996,7 +1018,7 @@ impl SettingsWindow {
             filter_table: vec![],
             has_query: false,
             content_handles: vec![],
-            page_scroll_handle: ScrollHandle::new(),
+            sub_page_scroll_handle: ScrollHandle::new(),
             focus_handle: cx.focus_handle(),
             navbar_focus_handle: NonFocusableHandle::new(
                 NAVBAR_CONTAINER_TAB_INDEX,
@@ -1016,6 +1038,8 @@ impl SettingsWindow {
                 .tab_index(HEADER_CONTAINER_TAB_INDEX)
                 .tab_stop(false),
             search_index: None,
+            visible_items: Vec::default(),
+            list_state,
         };
 
         this.fetch_files(window, cx);
@@ -1185,6 +1209,7 @@ impl SettingsWindow {
             }
             self.has_query = false;
             self.filter_matches_to_file();
+            self.reset_list_state();
             cx.notify();
             return;
         }
@@ -1214,6 +1239,7 @@ impl SettingsWindow {
             this.has_query = true;
             this.filter_matches_to_file();
             this.open_first_nav_page();
+            this.reset_list_state();
             cx.notify();
         }
 
@@ -1390,6 +1416,18 @@ impl SettingsWindow {
             .collect::<Vec<_>>();
     }
 
+    fn reset_list_state(&mut self) {
+        // plus one for the title
+        self.visible_items = self.visible_page_items().map(|(index, _)| index).collect();
+
+        if self.visible_items.is_empty() {
+            self.list_state.reset(0);
+        } else {
+            // show page title if page is non empty
+            self.list_state.reset(self.visible_items.len() + 1);
+        }
+    }
+
     fn build_ui(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
         if self.pages.is_empty() {
             self.pages = page_data::settings_data(cx);
@@ -1400,6 +1438,7 @@ impl SettingsWindow {
         sub_page_stack_mut().clear();
         // PERF: doesn't have to be rebuilt, can just be filled with true. pages is constant once it is built
         self.build_filter_table();
+        self.reset_list_state();
         self.update_matches(cx);
 
         cx.notify();
@@ -1463,7 +1502,17 @@ impl SettingsWindow {
         if !self.is_nav_entry_visible(navbar_entry) {
             self.open_first_nav_page();
         }
+
+        let is_new_page = self.navbar_entries[self.navbar_entry].page_index
+            != self.navbar_entries[navbar_entry].page_index;
         self.navbar_entry = navbar_entry;
+
+        // We only need to reset visible items when updating matches
+        // and selecting a new page
+        if is_new_page {
+            self.reset_list_state();
+        }
+
         sub_page_stack_mut().clear();
     }
 
@@ -1910,7 +1959,8 @@ impl SettingsWindow {
         if self.navbar_entries[navbar_entry_index].is_root
             || !self.is_nav_entry_visible(navbar_entry_index)
         {
-            self.page_scroll_handle.set_offset(point(px(0.), px(0.)));
+            self.sub_page_scroll_handle
+                .set_offset(point(px(0.), px(0.)));
             if focus_content {
                 let Some(first_item_index) =
                     self.visible_page_items().next().map(|(index, _)| index)
@@ -1931,9 +1981,11 @@ impl SettingsWindow {
             else {
                 return;
             };
-            self.page_scroll_handle
-                .scroll_to_top_of_item(selected_item_index + 1);
 
+            self.list_state.scroll_to(gpui::ListOffset {
+                item_ix: selected_item_index + 1,
+                offset_in_item: px(0.),
+            });
             if focus_content {
                 self.focus_content_element(entry_item_index, window, cx);
             } else {
@@ -2011,7 +2063,107 @@ impl SettingsWindow {
             .child(Label::new(last))
     }
 
-    fn render_page_items<'a, Items: Iterator<Item = (usize, &'a SettingsPageItem)>>(
+    fn render_page_items(
+        &mut self,
+        page_index: Option<usize>,
+        _window: &mut Window,
+        cx: &mut Context<SettingsWindow>,
+    ) -> impl IntoElement {
+        let mut page_content = v_flex().id("settings-ui-page").size_full();
+
+        let has_active_search = !self.search_bar.read(cx).is_empty(cx);
+        let has_no_results = self.visible_items.len() == 0 && has_active_search;
+
+        if has_no_results {
+            let search_query = self.search_bar.read(cx).text(cx);
+            page_content = page_content.child(
+                v_flex()
+                    .size_full()
+                    .items_center()
+                    .justify_center()
+                    .gap_1()
+                    .child(div().child("No Results"))
+                    .child(
+                        div()
+                            .text_sm()
+                            .text_color(cx.theme().colors().text_muted)
+                            .child(format!("No settings match \"{}\"", search_query)),
+                    ),
+            )
+        } else {
+            let items = &self.current_page().items;
+
+            let last_non_header_index = self
+                .visible_items
+                .iter()
+                .map(|index| &items[*index])
+                .enumerate()
+                .rev()
+                .find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
+                .map(|(index, _)| index);
+
+            let root_nav_label = self
+                .navbar_entries
+                .iter()
+                .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
+                .map(|entry| entry.title);
+
+            let list_content = list(
+                self.list_state.clone(),
+                cx.processor(move |this, index, window, cx| {
+                    if index == 0 {
+                        return div()
+                            .when(sub_page_stack().is_empty(), |this| {
+                                this.when_some(root_nav_label, |this, title| {
+                                    this.child(
+                                        Label::new(title).size(LabelSize::Large).mt_2().mb_3(),
+                                    )
+                                })
+                            })
+                            .into_any_element();
+                    }
+
+                    let index = index - 1;
+                    let actual_item_index = this.visible_items[index];
+                    let item: &SettingsPageItem = &this.current_page().items[actual_item_index];
+
+                    let no_bottom_border = this
+                        .visible_items
+                        .get(index + 1)
+                        .map(|item_index| {
+                            let item = &this.current_page().items[*item_index];
+                            matches!(item, SettingsPageItem::SectionHeader(_))
+                        })
+                        .unwrap_or(false);
+                    let is_last = Some(index) == last_non_header_index;
+
+                    v_flex()
+                        .id(("settings-page-item", actual_item_index))
+                        .w_full()
+                        .min_w_0()
+                        .when_some(page_index, |element, page_index| {
+                            element.track_focus(
+                                &this.content_handles[page_index][actual_item_index]
+                                    .focus_handle(cx),
+                            )
+                        })
+                        .child(item.render(
+                            this,
+                            actual_item_index,
+                            no_bottom_border || is_last,
+                            window,
+                            cx,
+                        ))
+                        .into_any_element()
+                }),
+            );
+
+            page_content = page_content.child(list_content.size_full())
+        }
+        page_content
+    }
+
+    fn render_sub_page_items<'a, Items: Iterator<Item = (usize, &'a SettingsPageItem)>>(
         &self,
         items: Items,
         page_index: Option<usize>,
@@ -2022,7 +2174,7 @@ impl SettingsWindow {
             .id("settings-ui-page")
             .size_full()
             .overflow_y_scroll()
-            .track_scroll(&self.page_scroll_handle);
+            .track_scroll(&self.sub_page_scroll_handle);
 
         let items: Vec<_> = items.collect();
         let items_len = items.len();
@@ -2092,7 +2244,7 @@ impl SettingsWindow {
                             })
                             .child(item.render(
                                 self,
-                                section_header.expect("All items rendered after a section header"),
+                                actual_item_index,
                                 no_bottom_border || is_last,
                                 window,
                                 cx,
@@ -2115,12 +2267,7 @@ impl SettingsWindow {
             page_header = self.render_files_header(window, cx).into_any_element();
 
             page_content = self
-                .render_page_items(
-                    self.visible_page_items(),
-                    Some(self.current_page_index()),
-                    window,
-                    cx,
-                )
+                .render_page_items(Some(self.current_page_index()), window, cx)
                 .into_any_element();
         } else {
             page_header = h_flex()
@@ -2143,13 +2290,19 @@ impl SettingsWindow {
         }
 
         return v_flex()
+            .id("Settings-ui-page")
             .flex_1()
             .pt_6()
             .pb_8()
             .px_8()
             .bg(cx.theme().colors().editor_background)
             .child(page_header)
-            .vertical_scrollbar_for(self.page_scroll_handle.clone(), window, cx)
+            .when(sub_page_stack().is_empty(), |this| {
+                this.vertical_scrollbar_for(self.list_state.clone(), window, cx)
+            })
+            .when(!sub_page_stack().is_empty(), |this| {
+                this.vertical_scrollbar_for(self.sub_page_scroll_handle.clone(), window, cx)
+            })
             .track_focus(&self.content_focus_handle.focus_handle(cx))
             .child(
                 div()
@@ -2791,7 +2944,7 @@ mod test {
             has_query: false,
             content_handles: vec![],
             search_task: None,
-            page_scroll_handle: ScrollHandle::new(),
+            sub_page_scroll_handle: ScrollHandle::new(),
             focus_handle: cx.focus_handle(),
             navbar_focus_handle: NonFocusableHandle::new(
                 NAVBAR_CONTAINER_TAB_INDEX,
@@ -2807,6 +2960,8 @@ mod test {
             ),
             files_focus_handle: cx.focus_handle(),
             search_index: None,
+            visible_items: Vec::default(),
+            list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
         };
 
         settings_window.build_filter_table();