gpui: Add tab focus support (#33008)

Jason Lee created

Release Notes:

- N/A

With a `tab_index` and `tab_stop` option to `FocusHandle` to us can
switch focus by `Tab`, `Shift-Tab`.

The `tab_index` is from
[WinUI](https://learn.microsoft.com/en-us/uwp/api/windows.ui.xaml.controls.control.tabindex?view=winrt-26100)
and [HTML
tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex),
only the `tab_stop` is enabled that can be added into the `tab_handles`
list.

- Added `window.focus_next()` and `window.focus_previous()` method to
switch focus.
- Added `tab_index` to `InteractiveElement`.

```bash
cargo run -p gpui --example tab_stop
```


https://github.com/user-attachments/assets/ac4e3e49-8359-436c-9a6e-badba2225211

Change summary

crates/gpui/examples/tab_stop.rs | 130 ++++++++++++++++++++++++++++
crates/gpui/src/app.rs           |   4 
crates/gpui/src/elements/div.rs  |  29 ++++-
crates/gpui/src/gpui.rs          |   2 
crates/gpui/src/tab_stop.rs      | 157 ++++++++++++++++++++++++++++++++++
crates/gpui/src/window.rs        |  81 +++++++++++++++-
6 files changed, 387 insertions(+), 16 deletions(-)

Detailed changes

crates/gpui/examples/tab_stop.rs 🔗

@@ -0,0 +1,130 @@
+use gpui::{
+    App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString,
+    Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size,
+};
+
+actions!(example, [Tab, TabPrev]);
+
+struct Example {
+    items: Vec<FocusHandle>,
+    message: SharedString,
+}
+
+impl Example {
+    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let items = vec![
+            cx.focus_handle().tab_index(1).tab_stop(true),
+            cx.focus_handle().tab_index(2).tab_stop(true),
+            cx.focus_handle().tab_index(3).tab_stop(true),
+            cx.focus_handle(),
+            cx.focus_handle().tab_index(2).tab_stop(true),
+        ];
+
+        window.focus(items.first().unwrap());
+        Self {
+            items,
+            message: SharedString::from("Press `Tab`, `Shift-Tab` to switch focus."),
+        }
+    }
+
+    fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
+        window.focus_next();
+        self.message = SharedString::from("You have pressed `Tab`.");
+    }
+
+    fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
+        window.focus_prev();
+        self.message = SharedString::from("You have pressed `Shift-Tab`.");
+    }
+}
+
+impl Render for Example {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        fn button(id: impl Into<ElementId>) -> Stateful<Div> {
+            div()
+                .id(id)
+                .h_10()
+                .flex_1()
+                .flex()
+                .justify_center()
+                .items_center()
+                .border_1()
+                .border_color(gpui::black())
+                .bg(gpui::black())
+                .text_color(gpui::white())
+                .focus(|this| this.border_color(gpui::blue()))
+                .shadow_sm()
+        }
+
+        div()
+            .id("app")
+            .on_action(cx.listener(Self::on_tab))
+            .on_action(cx.listener(Self::on_tab_prev))
+            .size_full()
+            .flex()
+            .flex_col()
+            .p_4()
+            .gap_3()
+            .bg(gpui::white())
+            .text_color(gpui::black())
+            .child(self.message.clone())
+            .children(
+                self.items
+                    .clone()
+                    .into_iter()
+                    .enumerate()
+                    .map(|(ix, item_handle)| {
+                        div()
+                            .id(("item", ix))
+                            .track_focus(&item_handle)
+                            .h_10()
+                            .w_full()
+                            .flex()
+                            .justify_center()
+                            .items_center()
+                            .border_1()
+                            .border_color(gpui::black())
+                            .when(
+                                item_handle.tab_stop && item_handle.is_focused(window),
+                                |this| this.border_color(gpui::blue()),
+                            )
+                            .map(|this| match item_handle.tab_stop {
+                                true => this
+                                    .hover(|this| this.bg(gpui::black().opacity(0.1)))
+                                    .child(format!("tab_index: {}", item_handle.tab_index)),
+                                false => this.opacity(0.4).child("tab_stop: false"),
+                            })
+                    }),
+            )
+            .child(
+                div()
+                    .flex()
+                    .flex_row()
+                    .gap_3()
+                    .items_center()
+                    .child(button("el1").tab_index(4).child("Button 1"))
+                    .child(button("el2").tab_index(5).child("Button 2")),
+            )
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        cx.bind_keys([
+            KeyBinding::new("tab", Tab, None),
+            KeyBinding::new("shift-tab", TabPrev, None),
+        ]);
+
+        let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx);
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |window, cx| cx.new(|cx| Example::new(window, cx)),
+        )
+        .unwrap();
+
+        cx.activate(true);
+    });
+}

crates/gpui/src/app.rs 🔗

@@ -954,8 +954,8 @@ impl App {
         self.focus_handles
             .clone()
             .write()
-            .retain(|handle_id, count| {
-                if count.load(SeqCst) == 0 {
+            .retain(|handle_id, focus| {
+                if focus.ref_count.load(SeqCst) == 0 {
                     for window_handle in self.windows() {
                         window_handle
                             .update(self, |_, window, _| {

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

@@ -619,6 +619,13 @@ pub trait InteractiveElement: Sized {
         self
     }
 
+    /// Set index of the tab stop order.
+    fn tab_index(mut self, index: isize) -> Self {
+        self.interactivity().focusable = true;
+        self.interactivity().tab_index = Some(index);
+        self
+    }
+
     /// Set the keymap context for this element. This will be used to determine
     /// which action to dispatch from the keymap.
     fn key_context<C, E>(mut self, key_context: C) -> Self
@@ -1462,6 +1469,7 @@ pub struct Interactivity {
     pub(crate) tooltip_builder: Option<TooltipBuilder>,
     pub(crate) window_control: Option<WindowControlArea>,
     pub(crate) hitbox_behavior: HitboxBehavior,
+    pub(crate) tab_index: Option<isize>,
 
     #[cfg(any(feature = "inspector", debug_assertions))]
     pub(crate) source_location: Option<&'static core::panic::Location<'static>>,
@@ -1521,12 +1529,17 @@ impl Interactivity {
                 // as frames contain an element with this id.
                 if self.focusable && self.tracked_focus_handle.is_none() {
                     if let Some(element_state) = element_state.as_mut() {
-                        self.tracked_focus_handle = Some(
-                            element_state
-                                .focus_handle
-                                .get_or_insert_with(|| cx.focus_handle())
-                                .clone(),
-                        );
+                        let mut handle = element_state
+                            .focus_handle
+                            .get_or_insert_with(|| cx.focus_handle())
+                            .clone()
+                            .tab_stop(false);
+
+                        if let Some(index) = self.tab_index {
+                            handle = handle.tab_index(index).tab_stop(true);
+                        }
+
+                        self.tracked_focus_handle = Some(handle);
                     }
                 }
 
@@ -1729,6 +1742,10 @@ impl Interactivity {
                     return ((), element_state);
                 }
 
+                if let Some(focus_handle) = &self.tracked_focus_handle {
+                    window.next_frame.tab_handles.insert(focus_handle);
+                }
+
                 window.with_element_opacity(style.opacity, |window| {
                     style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| {
                         window.with_text_style(style.text_style().cloned(), |window| {

crates/gpui/src/gpui.rs 🔗

@@ -95,6 +95,7 @@ mod style;
 mod styled;
 mod subscription;
 mod svg_renderer;
+mod tab_stop;
 mod taffy;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
@@ -151,6 +152,7 @@ pub use style::*;
 pub use styled::*;
 pub use subscription::*;
 use svg_renderer::*;
+pub(crate) use tab_stop::*;
 pub use taffy::{AvailableSpace, LayoutId};
 #[cfg(any(test, feature = "test-support"))]
 pub use test::*;

crates/gpui/src/tab_stop.rs 🔗

@@ -0,0 +1,157 @@
+use crate::{FocusHandle, FocusId};
+
+/// Represents a collection of tab handles.
+///
+/// Used to manage the `Tab` event to switch between focus handles.
+#[derive(Default)]
+pub(crate) struct TabHandles {
+    handles: Vec<FocusHandle>,
+}
+
+impl TabHandles {
+    pub(crate) fn insert(&mut self, focus_handle: &FocusHandle) {
+        if !focus_handle.tab_stop {
+            return;
+        }
+
+        let focus_handle = focus_handle.clone();
+
+        // Insert handle with same tab_index last
+        if let Some(ix) = self
+            .handles
+            .iter()
+            .position(|tab| tab.tab_index > focus_handle.tab_index)
+        {
+            self.handles.insert(ix, focus_handle);
+        } else {
+            self.handles.push(focus_handle);
+        }
+    }
+
+    pub(crate) fn clear(&mut self) {
+        self.handles.clear();
+    }
+
+    fn current_index(&self, focused_id: Option<&FocusId>) -> usize {
+        self.handles
+            .iter()
+            .position(|h| Some(&h.id) == focused_id)
+            .unwrap_or_default()
+    }
+
+    pub(crate) fn next(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
+        let ix = self.current_index(focused_id);
+
+        let mut next_ix = ix + 1;
+        if next_ix + 1 > self.handles.len() {
+            next_ix = 0;
+        }
+
+        if let Some(next_handle) = self.handles.get(next_ix) {
+            Some(next_handle.clone())
+        } else {
+            None
+        }
+    }
+
+    pub(crate) fn prev(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
+        let ix = self.current_index(focused_id);
+        let prev_ix;
+        if ix == 0 {
+            prev_ix = self.handles.len().saturating_sub(1);
+        } else {
+            prev_ix = ix.saturating_sub(1);
+        }
+
+        if let Some(prev_handle) = self.handles.get(prev_ix) {
+            Some(prev_handle.clone())
+        } else {
+            None
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{FocusHandle, FocusMap, TabHandles};
+    use std::sync::Arc;
+
+    #[test]
+    fn test_tab_handles() {
+        let focus_map = Arc::new(FocusMap::default());
+        let mut tab = TabHandles::default();
+
+        let focus_handles = vec![
+            FocusHandle::new(&focus_map).tab_stop(true).tab_index(0),
+            FocusHandle::new(&focus_map).tab_stop(true).tab_index(1),
+            FocusHandle::new(&focus_map).tab_stop(true).tab_index(1),
+            FocusHandle::new(&focus_map),
+            FocusHandle::new(&focus_map).tab_index(2),
+            FocusHandle::new(&focus_map).tab_stop(true).tab_index(0),
+            FocusHandle::new(&focus_map).tab_stop(true).tab_index(2),
+        ];
+
+        for handle in focus_handles.iter() {
+            tab.insert(&handle);
+        }
+        assert_eq!(
+            tab.handles
+                .iter()
+                .map(|handle| handle.id)
+                .collect::<Vec<_>>(),
+            vec![
+                focus_handles[0].id,
+                focus_handles[5].id,
+                focus_handles[1].id,
+                focus_handles[2].id,
+                focus_handles[6].id,
+            ]
+        );
+
+        // next
+        assert_eq!(tab.next(None), Some(tab.handles[1].clone()));
+        assert_eq!(
+            tab.next(Some(&tab.handles[0].id)),
+            Some(tab.handles[1].clone())
+        );
+        assert_eq!(
+            tab.next(Some(&tab.handles[1].id)),
+            Some(tab.handles[2].clone())
+        );
+        assert_eq!(
+            tab.next(Some(&tab.handles[2].id)),
+            Some(tab.handles[3].clone())
+        );
+        assert_eq!(
+            tab.next(Some(&tab.handles[3].id)),
+            Some(tab.handles[4].clone())
+        );
+        assert_eq!(
+            tab.next(Some(&tab.handles[4].id)),
+            Some(tab.handles[0].clone())
+        );
+
+        // prev
+        assert_eq!(tab.prev(None), Some(tab.handles[4].clone()));
+        assert_eq!(
+            tab.prev(Some(&tab.handles[0].id)),
+            Some(tab.handles[4].clone())
+        );
+        assert_eq!(
+            tab.prev(Some(&tab.handles[1].id)),
+            Some(tab.handles[0].clone())
+        );
+        assert_eq!(
+            tab.prev(Some(&tab.handles[2].id)),
+            Some(tab.handles[1].clone())
+        );
+        assert_eq!(
+            tab.prev(Some(&tab.handles[3].id)),
+            Some(tab.handles[2].clone())
+        );
+        assert_eq!(
+            tab.prev(Some(&tab.handles[4].id)),
+            Some(tab.handles[3].clone())
+        );
+    }
+}

crates/gpui/src/window.rs 🔗

@@ -12,10 +12,11 @@ use crate::{
     PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad,
     Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge,
     SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size,
-    StrikethroughStyle, Style, SubscriberSet, Subscription, TaffyLayoutEngine, Task, TextStyle,
-    TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance,
-    WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions,
-    WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, transparent_black,
+    StrikethroughStyle, Style, SubscriberSet, Subscription, TabHandles, TaffyLayoutEngine, Task,
+    TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle,
+    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations,
+    WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size,
+    transparent_black,
 };
 use anyhow::{Context as _, Result, anyhow};
 use collections::{FxHashMap, FxHashSet};
@@ -222,7 +223,12 @@ impl ArenaClearNeeded {
     }
 }
 
-pub(crate) type FocusMap = RwLock<SlotMap<FocusId, AtomicUsize>>;
+pub(crate) type FocusMap = RwLock<SlotMap<FocusId, FocusRef>>;
+pub(crate) struct FocusRef {
+    pub(crate) ref_count: AtomicUsize,
+    pub(crate) tab_index: isize,
+    pub(crate) tab_stop: bool,
+}
 
 impl FocusId {
     /// Obtains whether the element associated with this handle is currently focused.
@@ -258,6 +264,10 @@ impl FocusId {
 pub struct FocusHandle {
     pub(crate) id: FocusId,
     handles: Arc<FocusMap>,
+    /// The index of this element in the tab order.
+    pub tab_index: isize,
+    /// Whether this element can be focused by tab navigation.
+    pub tab_stop: bool,
 }
 
 impl std::fmt::Debug for FocusHandle {
@@ -268,25 +278,54 @@ impl std::fmt::Debug for FocusHandle {
 
 impl FocusHandle {
     pub(crate) fn new(handles: &Arc<FocusMap>) -> Self {
-        let id = handles.write().insert(AtomicUsize::new(1));
+        let id = handles.write().insert(FocusRef {
+            ref_count: AtomicUsize::new(1),
+            tab_index: 0,
+            tab_stop: false,
+        });
+
         Self {
             id,
+            tab_index: 0,
+            tab_stop: false,
             handles: handles.clone(),
         }
     }
 
     pub(crate) fn for_id(id: FocusId, handles: &Arc<FocusMap>) -> Option<Self> {
         let lock = handles.read();
-        let ref_count = lock.get(id)?;
-        if atomic_incr_if_not_zero(ref_count) == 0 {
+        let focus = lock.get(id)?;
+        if atomic_incr_if_not_zero(&focus.ref_count) == 0 {
             return None;
         }
         Some(Self {
             id,
+            tab_index: focus.tab_index,
+            tab_stop: focus.tab_stop,
             handles: handles.clone(),
         })
     }
 
+    /// Sets the tab index of the element associated with this handle.
+    pub fn tab_index(mut self, index: isize) -> Self {
+        self.tab_index = index;
+        if let Some(focus) = self.handles.write().get_mut(self.id) {
+            focus.tab_index = index;
+        }
+        self
+    }
+
+    /// Sets whether the element associated with this handle is a tab stop.
+    ///
+    /// When `false`, the element will not be included in the tab order.
+    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
+        self.tab_stop = tab_stop;
+        if let Some(focus) = self.handles.write().get_mut(self.id) {
+            focus.tab_stop = tab_stop;
+        }
+        self
+    }
+
     /// Converts this focus handle into a weak variant, which does not prevent it from being released.
     pub fn downgrade(&self) -> WeakFocusHandle {
         WeakFocusHandle {
@@ -354,6 +393,7 @@ impl Drop for FocusHandle {
             .read()
             .get(self.id)
             .unwrap()
+            .ref_count
             .fetch_sub(1, SeqCst);
     }
 }
@@ -642,6 +682,7 @@ pub(crate) struct Frame {
     pub(crate) next_inspector_instance_ids: FxHashMap<Rc<crate::InspectorElementPath>, usize>,
     #[cfg(any(feature = "inspector", debug_assertions))]
     pub(crate) inspector_hitboxes: FxHashMap<HitboxId, crate::InspectorElementId>,
+    pub(crate) tab_handles: TabHandles,
 }
 
 #[derive(Clone, Default)]
@@ -689,6 +730,7 @@ impl Frame {
 
             #[cfg(any(feature = "inspector", debug_assertions))]
             inspector_hitboxes: FxHashMap::default(),
+            tab_handles: TabHandles::default(),
         }
     }
 
@@ -704,6 +746,7 @@ impl Frame {
         self.hitboxes.clear();
         self.window_control_hitboxes.clear();
         self.deferred_draws.clear();
+        self.tab_handles.clear();
         self.focus = None;
 
         #[cfg(any(feature = "inspector", debug_assertions))]
@@ -1289,6 +1332,28 @@ impl Window {
         self.focus_enabled = false;
     }
 
+    /// Move focus to next tab stop.
+    pub fn focus_next(&mut self) {
+        if !self.focus_enabled {
+            return;
+        }
+
+        if let Some(handle) = self.rendered_frame.tab_handles.next(self.focus.as_ref()) {
+            self.focus(&handle)
+        }
+    }
+
+    /// Move focus to previous tab stop.
+    pub fn focus_prev(&mut self) {
+        if !self.focus_enabled {
+            return;
+        }
+
+        if let Some(handle) = self.rendered_frame.tab_handles.prev(self.focus.as_ref()) {
+            self.focus(&handle)
+        }
+    }
+
     /// Accessor for the text system.
     pub fn text_system(&self) -> &Arc<WindowTextSystem> {
         &self.text_system