diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index d82d7a67a12190a19acde5715378d295c2eb9bc8..0d2b9971d8074a3051b31f44faf91f9c734f3064 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -70,6 +70,7 @@ struct StateInner { #[allow(clippy::type_complexity)] scroll_handler: Option>, scrollbar_drag_start_height: Option, + 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::(()); + 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, @@ -711,6 +784,13 @@ impl StateInner { cx: &mut App, ) -> Result { 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, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index bc932b605d518d90be7fa8ab1454de09c13bbffc..fd2bd170233389f72c3ae4f6da9b77364089d76e 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1723,7 +1723,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { 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()) diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index d78067c032ff6b05e8138185aea1ab52d70c931e..98555f0347b078d757e5acc53b7ddf269de2d7a8 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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>, has_query: bool, content_handles: Vec>>, - page_scroll_handle: ScrollHandle, + sub_page_scroll_handle: ScrollHandle, focus_handle: FocusHandle, navbar_focus_handle: Entity, content_focus_handle: Entity, files_focus_handle: FocusHandle, search_index: Option>, + visible_items: Vec, + 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, @@ -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::>(); } + 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) { 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>( + fn render_page_items( + &mut self, + page_index: Option, + _window: &mut Window, + cx: &mut Context, + ) -> 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>( &self, items: Items, page_index: Option, @@ -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();