Introduce autoscroll support for elements (#10889)

This pull request introduces the new
`ElementContext::request_autoscroll(bounds)` and
`ElementContext::take_autoscroll()` methods in GPUI. These new APIs
enable container elements such as `List` to change their scroll position
if one of their children requested an autoscroll. We plan to use this in
the revamped assistant.

As a drive-by, we also:

- Renamed `Element::before_layout` to `Element::request_layout`
- Renamed `Element::after_layout` to `Element::prepaint`
- Introduced a new `List::splice_focusable` method to splice focusable
elements into the list, which enables rendering offscreen elements that
are focused.

Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>
This commit is contained in:
Antonio Scandurra 2024-04-23 15:14:22 +02:00 committed by GitHub
parent efcd31c254
commit bcbf2f2fd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 780 additions and 513 deletions

View file

@ -8,8 +8,8 @@
use crate::{
point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges,
Element, ElementContext, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style,
StyleRefinement, Styled, WindowContext,
Element, ElementContext, FocusHandle, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent,
Size, Style, StyleRefinement, Styled, WindowContext,
};
use collections::VecDeque;
use refineable::Refineable as _;
@ -92,20 +92,58 @@ pub enum ListSizingBehavior {
struct LayoutItemsResponse {
max_item_width: Pixels,
scroll_top: ListOffset,
available_item_space: Size<AvailableSpace>,
item_elements: VecDeque<AnyElement>,
item_layouts: VecDeque<ItemLayout>,
}
struct ItemLayout {
index: usize,
element: AnyElement,
size: Size<Pixels>,
}
/// Frame state used by the [List] element after layout.
pub struct ListAfterLayoutState {
pub struct ListPrepaintState {
hitbox: Hitbox,
layout: LayoutItemsResponse,
}
#[derive(Clone)]
enum ListItem {
Unrendered,
Rendered { size: Size<Pixels> },
Unmeasured {
focus_handle: Option<FocusHandle>,
},
Measured {
size: Size<Pixels>,
focus_handle: Option<FocusHandle>,
},
}
impl ListItem {
fn size(&self) -> Option<Size<Pixels>> {
if let ListItem::Measured { size, .. } = self {
Some(*size)
} else {
None
}
}
fn focus_handle(&self) -> Option<FocusHandle> {
match self {
ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => {
focus_handle.clone()
}
}
}
fn contains_focused(&self, cx: &WindowContext) -> bool {
match self {
ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => {
focus_handle
.as_ref()
.is_some_and(|handle| handle.contains_focused(cx))
}
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
@ -114,6 +152,7 @@ struct ListItemSummary {
rendered_count: usize,
unrendered_count: usize,
height: Pixels,
has_focus_handles: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
@ -131,45 +170,45 @@ struct Height(Pixels);
impl ListState {
/// Construct a new list state, for storage on a view.
///
/// the overdraw parameter controls how much extra space is rendered
/// above and below the visible area. This can help ensure that the list
/// doesn't flicker or pop in when scrolling.
pub fn new<F>(
element_count: usize,
orientation: ListAlignment,
/// The overdraw parameter controls how much extra space is rendered
/// above and below the visible area. Elements within this area will
/// be measured even though they are not visible. This can help ensure
/// that the list doesn't flicker or pop in when scrolling.
pub fn new<R>(
item_count: usize,
alignment: ListAlignment,
overdraw: Pixels,
render_item: F,
render_item: R,
) -> Self
where
F: 'static + FnMut(usize, &mut WindowContext) -> AnyElement,
R: 'static + FnMut(usize, &mut WindowContext) -> AnyElement,
{
let mut items = SumTree::new();
items.extend((0..element_count).map(|_| ListItem::Unrendered), &());
Self(Rc::new(RefCell::new(StateInner {
let this = Self(Rc::new(RefCell::new(StateInner {
last_layout_bounds: None,
last_padding: None,
render_item: Box::new(render_item),
items,
items: SumTree::new(),
logical_scroll_top: None,
alignment: orientation,
alignment,
overdraw,
scroll_handler: None,
reset: false,
})))
})));
this.splice(0..0, item_count);
this
}
/// Reset this instantiation of the list state.
///
/// Note that this will cause scroll events to be dropped until the next paint.
pub fn reset(&self, element_count: usize) {
let state = &mut *self.0.borrow_mut();
state.reset = true;
{
let state = &mut *self.0.borrow_mut();
state.reset = true;
state.logical_scroll_top = None;
}
state.logical_scroll_top = None;
state.items = SumTree::new();
state
.items
.extend((0..element_count).map(|_| ListItem::Unrendered), &());
self.splice(0..element_count, element_count);
}
/// The number of items in this list.
@ -177,11 +216,39 @@ impl ListState {
self.0.borrow().items.summary().count
}
/// Register with the list state that the items in `old_range` have been replaced
/// Inform the list state that the items in `old_range` have been replaced
/// by `count` new items that must be recalculated.
pub fn splice(&self, old_range: Range<usize>, count: usize) {
self.splice_focusable(old_range, (0..count).map(|_| None))
}
/// Register with the list state that the items in `old_range` have been replaced
/// by new items. As opposed to [`splice`], this method allows an iterator of optional focus handles
/// to be supplied to properly integrate with items in the list that can be focused. If a focused item
/// is scrolled out of view, the list will continue to render it to allow keyboard interaction.
pub fn splice_focusable(
&self,
old_range: Range<usize>,
focus_handles: impl IntoIterator<Item = Option<FocusHandle>>,
) {
let state = &mut *self.0.borrow_mut();
let mut old_items = state.items.cursor::<Count>();
let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right, &());
old_items.seek_forward(&Count(old_range.end), Bias::Right, &());
let mut spliced_count = 0;
new_items.extend(
focus_handles.into_iter().map(|focus_handle| {
spliced_count += 1;
ListItem::Unmeasured { focus_handle }
}),
&(),
);
new_items.append(old_items.suffix(&()), &());
drop(old_items);
state.items = new_items;
if let Some(ListOffset {
item_ix,
offset_in_item,
@ -191,18 +258,9 @@ impl ListState {
*item_ix = old_range.start;
*offset_in_item = px(0.);
} else if old_range.end <= *item_ix {
*item_ix = *item_ix - (old_range.end - old_range.start) + count;
*item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count;
}
}
let mut old_heights = state.items.cursor::<Count>();
let mut new_heights = old_heights.slice(&Count(old_range.start), Bias::Right, &());
old_heights.seek_forward(&Count(old_range.end), Bias::Right, &());
new_heights.extend((0..count).map(|_| ListItem::Unrendered), &());
new_heights.append(old_heights.suffix(&()), &());
drop(old_heights);
state.items = new_heights;
}
/// Set a handler that will be called when the list is scrolled.
@ -279,7 +337,7 @@ impl ListState {
let scroll_top = cursor.start().1 .0 + scroll_top.offset_in_item;
cursor.seek_forward(&Count(ix), Bias::Right, &());
if let Some(&ListItem::Rendered { size }) = cursor.item() {
if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
let &(Count(count), Height(top)) = cursor.start();
if count == ix {
let top = bounds.top() + top - scroll_top;
@ -379,10 +437,11 @@ impl StateInner {
) -> LayoutItemsResponse {
let old_items = self.items.clone();
let mut measured_items = VecDeque::new();
let mut item_elements = VecDeque::new();
let mut item_layouts = VecDeque::new();
let mut rendered_height = padding.top;
let mut max_item_width = px(0.);
let mut scroll_top = self.logical_scroll_top();
let mut rendered_focused_item = false;
let available_item_space = size(
available_width.map_or(AvailableSpace::MinContent, |width| {
@ -401,27 +460,34 @@ impl StateInner {
break;
}
// Use the previously cached height if available
let mut size = if let ListItem::Rendered { size } = item {
Some(*size)
} else {
None
};
// Use the previously cached height and focus handle if available
let mut size = item.size();
// If we're within the visible area or the height wasn't cached, render and measure the item's element
if visible_height < available_height || size.is_none() {
let mut element = (self.render_item)(scroll_top.item_ix + ix, cx);
let element_size = element.measure(available_item_space, cx);
let item_index = scroll_top.item_ix + ix;
let mut element = (self.render_item)(item_index, cx);
let element_size = element.layout_as_root(available_item_space, cx);
size = Some(element_size);
if visible_height < available_height {
item_elements.push_back(element);
item_layouts.push_back(ItemLayout {
index: item_index,
element,
size: element_size,
});
if item.contains_focused(cx) {
rendered_focused_item = true;
}
}
}
let size = size.unwrap();
rendered_height += size.height;
max_item_width = max_item_width.max(size.width);
measured_items.push_back(ListItem::Rendered { size });
measured_items.push_back(ListItem::Measured {
size,
focus_handle: item.focus_handle(),
});
}
rendered_height += padding.bottom;
@ -433,13 +499,24 @@ impl StateInner {
if rendered_height - scroll_top.offset_in_item < available_height {
while rendered_height < available_height {
cursor.prev(&());
if cursor.item().is_some() {
let mut element = (self.render_item)(cursor.start().0, cx);
let element_size = element.measure(available_item_space, cx);
if let Some(item) = cursor.item() {
let item_index = cursor.start().0;
let mut element = (self.render_item)(item_index, cx);
let element_size = element.layout_as_root(available_item_space, cx);
let focus_handle = item.focus_handle();
rendered_height += element_size.height;
measured_items.push_front(ListItem::Rendered { size: element_size });
item_elements.push_front(element)
measured_items.push_front(ListItem::Measured {
size: element_size,
focus_handle,
});
item_layouts.push_front(ItemLayout {
index: item_index,
element,
size: element_size,
});
if item.contains_focused(cx) {
rendered_focused_item = true;
}
} else {
break;
}
@ -470,15 +547,18 @@ impl StateInner {
while leading_overdraw < self.overdraw {
cursor.prev(&());
if let Some(item) = cursor.item() {
let size = if let ListItem::Rendered { size } = item {
let size = if let ListItem::Measured { size, .. } = item {
*size
} else {
let mut element = (self.render_item)(cursor.start().0, cx);
element.measure(available_item_space, cx)
element.layout_as_root(available_item_space, cx)
};
leading_overdraw += size.height;
measured_items.push_front(ListItem::Rendered { size });
measured_items.push_front(ListItem::Measured {
size,
focus_handle: item.focus_handle(),
});
} else {
break;
}
@ -490,23 +570,83 @@ impl StateInner {
new_items.extend(measured_items, &());
cursor.seek(&Count(measured_range.end), Bias::Right, &());
new_items.append(cursor.suffix(&()), &());
self.items = new_items;
// If none of the visible items are focused, check if an off-screen item is focused
// and include it to be rendered after the visible items so keyboard interaction continues
// to work for it.
if !rendered_focused_item {
let mut cursor = self
.items
.filter::<_, Count>(|summary| summary.has_focus_handles);
cursor.next(&());
while let Some(item) = cursor.item() {
if item.contains_focused(cx) {
let item_index = cursor.start().0;
let mut element = (self.render_item)(cursor.start().0, cx);
let size = element.layout_as_root(available_item_space, cx);
item_layouts.push_back(ItemLayout {
index: item_index,
element,
size,
});
break;
}
cursor.next(&());
}
}
LayoutItemsResponse {
max_item_width,
scroll_top,
available_item_space,
item_elements,
item_layouts,
}
}
fn prepaint_items(
&mut self,
bounds: Bounds<Pixels>,
padding: Edges<Pixels>,
cx: &mut ElementContext,
) -> Result<LayoutItemsResponse, ListOffset> {
cx.transact(|cx| {
let mut layout_response =
self.layout_items(Some(bounds.size.width), bounds.size.height, &padding, cx);
// Only paint the visible items, if there is actually any space for them (taking padding into account)
if bounds.size.height > padding.top + padding.bottom {
let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
item_origin.y -= layout_response.scroll_top.offset_in_item;
for item in &mut layout_response.item_layouts {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
item.element.prepaint_at(item_origin, cx);
});
if let Some(autoscroll_bounds) = cx.take_autoscroll() {
if bounds.intersect(&autoscroll_bounds) != autoscroll_bounds {
return Err(ListOffset {
item_ix: item.index,
offset_in_item: autoscroll_bounds.origin.y - item_origin.y,
});
}
}
item_origin.y += item.size.height;
}
} else {
layout_response.item_layouts.clear();
}
Ok(layout_response)
})
}
}
impl std::fmt::Debug for ListItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unrendered => write!(f, "Unrendered"),
Self::Rendered { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
Self::Unmeasured { .. } => write!(f, "Unrendered"),
Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
}
}
}
@ -522,13 +662,13 @@ pub struct ListOffset {
}
impl Element for List {
type BeforeLayout = ();
type AfterLayout = ListAfterLayoutState;
type RequestLayoutState = ();
type PrepaintState = ListPrepaintState;
fn before_layout(
fn request_layout(
&mut self,
cx: &mut crate::ElementContext,
) -> (crate::LayoutId, Self::BeforeLayout) {
) -> (crate::LayoutId, Self::RequestLayoutState) {
let layout_id = match self.sizing_behavior {
ListSizingBehavior::Infer => {
let mut style = Style::default();
@ -589,12 +729,12 @@ impl Element for List {
(layout_id, ())
}
fn after_layout(
fn prepaint(
&mut self,
bounds: Bounds<Pixels>,
_: &mut Self::BeforeLayout,
_: &mut Self::RequestLayoutState,
cx: &mut ElementContext,
) -> ListAfterLayoutState {
) -> ListPrepaintState {
let state = &mut *self.state.0.borrow_mut();
state.reset = false;
@ -607,55 +747,47 @@ impl Element for List {
if state.last_layout_bounds.map_or(true, |last_bounds| {
last_bounds.size.width != bounds.size.width
}) {
state.items = SumTree::from_iter(
(0..state.items.summary().count).map(|_| ListItem::Unrendered),
let new_items = SumTree::from_iter(
state.items.iter().map(|item| ListItem::Unmeasured {
focus_handle: item.focus_handle(),
}),
&(),
)
);
state.items = new_items;
}
let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
let mut layout_response =
state.layout_items(Some(bounds.size.width), bounds.size.height, &padding, cx);
// Only paint the visible items, if there is actually any space for them (taking padding into account)
if bounds.size.height > padding.top + padding.bottom {
// Paint the visible items
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
item_origin.y -= layout_response.scroll_top.offset_in_item;
for mut item_element in &mut layout_response.item_elements {
let item_size = item_element.measure(layout_response.available_item_space, cx);
item_element.layout(item_origin, layout_response.available_item_space, cx);
item_origin.y += item_size.height;
}
});
}
let layout = match state.prepaint_items(bounds, padding, cx) {
Ok(layout) => layout,
Err(autoscroll_request) => {
state.logical_scroll_top = Some(autoscroll_request);
state.prepaint_items(bounds, padding, cx).unwrap()
}
};
state.last_layout_bounds = Some(bounds);
state.last_padding = Some(padding);
ListAfterLayoutState {
hitbox,
layout: layout_response,
}
ListPrepaintState { hitbox, layout }
}
fn paint(
&mut self,
bounds: Bounds<crate::Pixels>,
_: &mut Self::BeforeLayout,
after_layout: &mut Self::AfterLayout,
_: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
cx: &mut crate::ElementContext,
) {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
for item in &mut after_layout.layout.item_elements {
item.paint(cx);
for item in &mut prepaint.layout.item_layouts {
item.element.paint(cx);
}
});
let list_state = self.state.clone();
let height = bounds.size.height;
let scroll_top = after_layout.layout.scroll_top;
let hitbox_id = after_layout.hitbox.id;
let scroll_top = prepaint.layout.scroll_top;
let hitbox_id = prepaint.hitbox.id;
cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
if phase == DispatchPhase::Bubble && hitbox_id.is_hovered(cx) {
list_state.0.borrow_mut().scroll(
@ -688,17 +820,21 @@ impl sum_tree::Item for ListItem {
fn summary(&self) -> Self::Summary {
match self {
ListItem::Unrendered => ListItemSummary {
ListItem::Unmeasured { focus_handle } => ListItemSummary {
count: 1,
rendered_count: 0,
unrendered_count: 1,
height: px(0.),
has_focus_handles: focus_handle.is_some(),
},
ListItem::Rendered { size } => ListItemSummary {
ListItem::Measured {
size, focus_handle, ..
} => ListItemSummary {
count: 1,
rendered_count: 1,
unrendered_count: 0,
height: size.height,
has_focus_handles: focus_handle.is_some(),
},
}
}
@ -712,6 +848,7 @@ impl sum_tree::Summary for ListItemSummary {
self.rendered_count += summary.rendered_count;
self.unrendered_count += summary.unrendered_count;
self.height += summary.height;
self.has_focus_handles |= summary.has_focus_handles;
}
}