gpui: Add scroll anchors (#19894)

## Problem statement
I want to add keyboard navigation support to SSH modal. Doing so is
possible in current landscape, but not particularly ergonomic;
`gpui::ScrollHandle` has `scroll_to_item` API that takes an index of the
item you want to scroll to. The problem is, however, that it only works
with it's immediate children - thus in order to support scrolling via
keyboard you have to bend your UI to have a particular layout. Even when
your list of items is perfectly flat, having decorations inbetween items
is problematic as they are also children of the list, which means that
you either have to maintain the mapping to devise a correct index of an
item that you want to scroll to, or you have to make the decoration a
part of the list item itself, which might render the scrolling imprecise
(you might e.g. not want to scroll to a header, but to a button beneath
it).

## The solution
This PR adds `ScrollAnchor`, a new kind of handle to the gpui. It has a
similar role to that of a ScrollHandle, but instead of tracking how far
along an item has been scrolled, it tracks position of an element
relative to the parent to which a given scroll handle belongs. In short,
it allows us to persist the position of an element in a list of items
and scroll to it even if it's not an immediate children of a container
whose scroll position is tracked via an associated scroll handle.
Additionally this PR adds a new kind of the container to the UI crate
that serves as a convenience wrapper for using ScrollAnchors. This
container provides handlers for `menu::SelectNext` and
`menu::SelectPrev` and figures out which item should be focused next.

Release Notes:

- Improve keyboard navigation in ssh modal
This commit is contained in:
Piotr Osiewicz 2024-11-01 14:47:46 +01:00 committed by GitHub
parent 183e3664cc
commit 95842c7987
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 631 additions and 412 deletions

View file

@ -915,6 +915,12 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Track the scroll state of this element with the given handle.
fn anchor_scroll(mut self, scroll_anchor: Option<ScrollAnchor>) -> Self {
self.interactivity().scroll_anchor = scroll_anchor;
self
}
/// Set the given styles to be applied when this element is active.
fn active(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self
where
@ -1156,6 +1162,9 @@ impl Element for Div {
) -> Option<Hitbox> {
let mut child_min = point(Pixels::MAX, Pixels::MAX);
let mut child_max = Point::default();
if let Some(handle) = self.interactivity.scroll_anchor.as_ref() {
*handle.last_origin.borrow_mut() = bounds.origin - cx.element_offset();
}
let content_size = if request_layout.child_layout_ids.is_empty() {
bounds.size
} else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() {
@ -1245,6 +1254,7 @@ pub struct Interactivity {
pub(crate) focusable: bool,
pub(crate) tracked_focus_handle: Option<FocusHandle>,
pub(crate) tracked_scroll_handle: Option<ScrollHandle>,
pub(crate) scroll_anchor: Option<ScrollAnchor>,
pub(crate) scroll_offset: Option<Rc<RefCell<Point<Pixels>>>>,
pub(crate) group: Option<SharedString>,
/// The base style of the element, before any modifications are applied
@ -2091,7 +2101,6 @@ impl Interactivity {
}
scroll_offset.y += delta_y;
scroll_offset.x += delta_x;
cx.stop_propagation();
if *scroll_offset != old_scroll_offset {
cx.refresh();
@ -2454,6 +2463,34 @@ 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.
#[derive(Clone)]
pub struct ScrollAnchor {
handle: ScrollHandle,
last_origin: Rc<RefCell<Point<Pixels>>>,
}
impl ScrollAnchor {
/// Creates a [ScrollAnchor] associated with a given [ScrollHandle].
pub fn for_handle(handle: ScrollHandle) -> Self {
Self {
handle,
last_origin: Default::default(),
}
}
/// Request scroll to this item on the next frame.
pub fn scroll_to(&self, cx: &mut WindowContext<'_>) {
let this = self.clone();
cx.on_next_frame(move |_| {
let viewport_bounds = this.handle.bounds();
let self_bounds = *this.last_origin.borrow();
this.handle.set_offset(viewport_bounds.origin - self_bounds);
});
}
}
#[derive(Default, Debug)]
struct ScrollHandleState {
offset: Rc<RefCell<Point<Pixels>>>,

File diff suppressed because it is too large Load diff

View file

@ -63,7 +63,7 @@ impl SshSettings {
}
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
pub struct SshConnection {
pub host: SharedString,
#[serde(skip_serializing_if = "Option::is_none")]
@ -100,7 +100,7 @@ impl From<SshConnection> for SshConnectionOptions {
}
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
#[derive(Clone, Default, Serialize, PartialEq, Deserialize, JsonSchema)]
pub struct SshProject {
pub paths: Vec<String>,
}

View file

@ -14,6 +14,7 @@ mod keybinding;
mod label;
mod list;
mod modal;
mod navigable;
mod numeric_stepper;
mod popover;
mod popover_menu;
@ -47,6 +48,7 @@ pub use keybinding::*;
pub use label::*;
pub use list::*;
pub use modal::*;
pub use navigable::*;
pub use numeric_stepper::*;
pub use popover::*;
pub use popover_menu::*;

View file

@ -0,0 +1,98 @@
use crate::prelude::*;
use gpui::{AnyElement, FocusHandle, ScrollAnchor, ScrollHandle};
/// An element that can be navigated through via keyboard. Intended for use with scrollable views that want to use
pub struct Navigable {
child: AnyElement,
selectable_children: Vec<NavigableEntry>,
}
/// An entry of [Navigable] that can be navigated to.
#[derive(Clone)]
pub struct NavigableEntry {
#[allow(missing_docs)]
pub focus_handle: FocusHandle,
#[allow(missing_docs)]
pub scroll_anchor: Option<ScrollAnchor>,
}
impl NavigableEntry {
/// Creates a new [NavigableEntry] for a given scroll handle.
pub fn new(scroll_handle: &ScrollHandle, cx: &WindowContext<'_>) -> Self {
Self {
focus_handle: cx.focus_handle(),
scroll_anchor: Some(ScrollAnchor::for_handle(scroll_handle.clone())),
}
}
/// Create a new [NavigableEntry] that cannot be scrolled to.
pub fn focusable(cx: &WindowContext<'_>) -> Self {
Self {
focus_handle: cx.focus_handle(),
scroll_anchor: None,
}
}
}
impl Navigable {
/// Creates new empty [Navigable] wrapper.
pub fn new(child: AnyElement) -> Self {
Self {
child,
selectable_children: vec![],
}
}
/// Add a new entry that can be navigated to via keyboard.
/// The order of calls to [Navigable::entry] determines the order of traversal of elements via successive
/// uses of [menu:::SelectNext]/[menu::SelectPrev]
pub fn entry(mut self, child: NavigableEntry) -> Self {
self.selectable_children.push(child);
self
}
fn find_focused(
selectable_children: &[NavigableEntry],
cx: &mut WindowContext<'_>,
) -> Option<usize> {
selectable_children
.iter()
.position(|entry| entry.focus_handle.contains_focused(cx))
}
}
impl RenderOnce for Navigable {
fn render(self, _: &mut WindowContext<'_>) -> impl crate::IntoElement {
div()
.on_action({
let children = self.selectable_children.clone();
move |_: &menu::SelectNext, cx| {
let target = Self::find_focused(&children, cx)
.and_then(|index| {
index.checked_add(1).filter(|index| *index < children.len())
})
.unwrap_or(0);
if let Some(entry) = children.get(target) {
entry.focus_handle.focus(cx);
if let Some(anchor) = &entry.scroll_anchor {
anchor.scroll_to(cx);
}
}
}
})
.on_action({
let children = self.selectable_children;
move |_: &menu::SelectPrev, cx| {
let target = Self::find_focused(&children, cx)
.and_then(|index| index.checked_sub(1))
.or(children.len().checked_sub(1));
if let Some(entry) = target.and_then(|target| children.get(target)) {
entry.focus_handle.focus(cx);
if let Some(anchor) = &entry.scroll_anchor {
anchor.scroll_to(cx);
}
}
}
})
.size_full()
.child(self.child)
}
}