gpui: Add tab group (#38531)

Ben Kunkle , Kate , Mikayla , Anthony , and Mikayla Maki created

Closes #ISSUE

Co-Authored-By: Mikayla <mikayla@zed.dev>
Co-Authored-By: Anthony <anthony@zed.dev>
Co-Authored-By: Kate <kate@zed.dev>

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Kate <kate@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

Cargo.lock                      |   1 
crates/gpui/Cargo.toml          |   3 
crates/gpui/src/elements/div.rs | 112 ++++--
crates/gpui/src/tab_stop.rs     | 611 ++++++++++++++++++++++++++++++----
crates/gpui/src/window.rs       |  35 +
5 files changed, 629 insertions(+), 133 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7087,6 +7087,7 @@ dependencies = [
  "parking_lot",
  "pathfinder_geometry",
  "postage",
+ "pretty_assertions",
  "profiling",
  "rand 0.9.1",
  "raw-window-handle",

crates/gpui/Cargo.toml 🔗

@@ -230,9 +230,10 @@ collections = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
 http_client = { workspace = true, features = ["test-support"] }
 lyon = { version = "1.0", features = ["extra"] }
+pretty_assertions.workspace = true
 rand.workspace = true
-unicode-segmentation.workspace = true
 reqwest_client = { workspace = true, features = ["test-support"] }
+unicode-segmentation.workspace = true
 util = { workspace = true, features = ["test-support"] }
 
 [target.'cfg(target_os = "windows")'.build-dependencies]

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

@@ -618,13 +618,32 @@ pub trait InteractiveElement: Sized {
         self
     }
 
-    /// Set index of the tab stop order.
+    /// Designate this element as a tab stop, equivalent to `tab_index(0)`.
+    /// This should be the primary mechanism for tab navigation within the application.
+    fn tab_stop(mut self) -> Self {
+        self.tab_index(0)
+    }
+
+    /// Set index of the tab stop order. This should only be used in conjunction with `tab_group`
+    /// in order to not interfere with the tab index of other elements.
     fn tab_index(mut self, index: isize) -> Self {
         self.interactivity().focusable = true;
         self.interactivity().tab_index = Some(index);
         self
     }
 
+    /// Designate this div as a "tab group". Tab groups have their own location in the tab-index order,
+    /// but for children of the tab group, the tab index is reset to 0. This can be useful for swapping
+    /// the order of tab stops within the group, without having to renumber all the tab stops in the whole
+    /// application.
+    fn tab_group(mut self) -> Self {
+        self.interactivity().tab_group = true;
+        if self.interactivity().tab_index.is_none() {
+            self.interactivity().tab_index = Some(0);
+        }
+        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
@@ -1481,6 +1500,7 @@ pub struct Interactivity {
     pub(crate) window_control: Option<WindowControlArea>,
     pub(crate) hitbox_behavior: HitboxBehavior,
     pub(crate) tab_index: Option<isize>,
+    pub(crate) tab_group: bool,
 
     #[cfg(any(feature = "inspector", debug_assertions))]
     pub(crate) source_location: Option<&'static core::panic::Location<'static>>,
@@ -1766,8 +1786,12 @@ impl Interactivity {
                     return ((), element_state);
                 }
 
+                let mut tab_group = None;
+                if self.tab_group {
+                    tab_group = self.tab_index;
+                }
                 if let Some(focus_handle) = &self.tracked_focus_handle {
-                    window.next_frame.tab_handles.insert(focus_handle);
+                    window.next_frame.tab_stops.insert(focus_handle);
                 }
 
                 window.with_element_opacity(style.opacity, |window| {
@@ -1776,55 +1800,59 @@ impl Interactivity {
                             window.with_content_mask(
                                 style.overflow_mask(bounds, window.rem_size()),
                                 |window| {
-                                    if let Some(hitbox) = hitbox {
-                                        #[cfg(debug_assertions)]
-                                        self.paint_debug_info(
-                                            global_id, hitbox, &style, window, cx,
-                                        );
-
-                                        if let Some(drag) = cx.active_drag.as_ref() {
-                                            if let Some(mouse_cursor) = drag.cursor_style {
-                                                window.set_window_cursor_style(mouse_cursor);
+                                    window.with_tab_group(tab_group, |window| {
+                                        if let Some(hitbox) = hitbox {
+                                            #[cfg(debug_assertions)]
+                                            self.paint_debug_info(
+                                                global_id, hitbox, &style, window, cx,
+                                            );
+
+                                            if let Some(drag) = cx.active_drag.as_ref() {
+                                                if let Some(mouse_cursor) = drag.cursor_style {
+                                                    window.set_window_cursor_style(mouse_cursor);
+                                                }
+                                            } else {
+                                                if let Some(mouse_cursor) = style.mouse_cursor {
+                                                    window.set_cursor_style(mouse_cursor, hitbox);
+                                                }
                                             }
-                                        } else {
-                                            if let Some(mouse_cursor) = style.mouse_cursor {
-                                                window.set_cursor_style(mouse_cursor, hitbox);
+
+                                            if let Some(group) = self.group.clone() {
+                                                GroupHitboxes::push(group, hitbox.id, cx);
                                             }
-                                        }
 
-                                        if let Some(group) = self.group.clone() {
-                                            GroupHitboxes::push(group, hitbox.id, cx);
-                                        }
+                                            if let Some(area) = self.window_control {
+                                                window.insert_window_control_hitbox(
+                                                    area,
+                                                    hitbox.clone(),
+                                                );
+                                            }
 
-                                        if let Some(area) = self.window_control {
-                                            window
-                                                .insert_window_control_hitbox(area, hitbox.clone());
+                                            self.paint_mouse_listeners(
+                                                hitbox,
+                                                element_state.as_mut(),
+                                                window,
+                                                cx,
+                                            );
+                                            self.paint_scroll_listener(hitbox, &style, window, cx);
                                         }
 
-                                        self.paint_mouse_listeners(
-                                            hitbox,
-                                            element_state.as_mut(),
-                                            window,
-                                            cx,
-                                        );
-                                        self.paint_scroll_listener(hitbox, &style, window, cx);
-                                    }
+                                        self.paint_keyboard_listeners(window, cx);
+                                        f(&style, window, cx);
 
-                                    self.paint_keyboard_listeners(window, cx);
-                                    f(&style, window, cx);
+                                        if let Some(_hitbox) = hitbox {
+                                            #[cfg(any(feature = "inspector", debug_assertions))]
+                                            window.insert_inspector_hitbox(
+                                                _hitbox.id,
+                                                _inspector_id,
+                                                cx,
+                                            );
 
-                                    if let Some(_hitbox) = hitbox {
-                                        #[cfg(any(feature = "inspector", debug_assertions))]
-                                        window.insert_inspector_hitbox(
-                                            _hitbox.id,
-                                            _inspector_id,
-                                            cx,
-                                        );
-
-                                        if let Some(group) = self.group.as_ref() {
-                                            GroupHitboxes::pop(group, cx);
+                                            if let Some(group) = self.group.as_ref() {
+                                                GroupHitboxes::pop(group, cx);
+                                            }
                                         }
-                                    }
+                                    })
                                 },
                             );
                         });

crates/gpui/src/tab_stop.rs 🔗

@@ -1,74 +1,320 @@
+use std::fmt::Debug;
+
+use ::sum_tree::SumTree;
+use collections::FxHashMap;
+use sum_tree::Bias;
+
 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 {
-    pub(crate) handles: Vec<FocusHandle>,
+/// Represents a collection of focus handles using the tab-index APIs.
+#[derive(Debug)]
+pub(crate) struct TabStopMap {
+    current_path: TabStopPath,
+    pub(crate) insertion_history: Vec<TabStopOperation>,
+    by_id: FxHashMap<FocusId, TabStopNode>,
+    order: SumTree<TabStopNode>,
 }
 
-impl TabHandles {
-    pub(crate) fn insert(&mut self, focus_handle: &FocusHandle) {
-        if !focus_handle.tab_stop {
-            return;
+#[derive(Debug, Clone)]
+pub enum TabStopOperation {
+    Insert(FocusHandle),
+    Group(TabIndex),
+    GroupEnd,
+}
+
+impl TabStopOperation {
+    fn focus_handle(&self) -> Option<&FocusHandle> {
+        match self {
+            TabStopOperation::Insert(focus_handle) => Some(focus_handle),
+            _ => None,
         }
+    }
+}
+
+type TabIndex = isize;
+
+#[derive(Debug, Default, PartialEq, Eq, Clone, Ord, PartialOrd)]
+struct TabStopPath(smallvec::SmallVec<[TabIndex; 6]>);
+
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+struct TabStopNode {
+    /// Path to access the node in the tree
+    /// The final node in the list is a leaf node corresponding to an actual focus handle,
+    /// all other nodes are group nodes
+    path: TabStopPath,
+    /// index into the backing array of nodes. Corresponds to insertion order
+    node_insertion_index: usize,
 
-        let focus_handle = focus_handle.clone();
+    /// Whether this node is a tab stop
+    tab_stop: bool,
+}
 
-        // Insert handle with same tab_index last
-        if let Some(ix) = self
-            .handles
-            .iter()
-            .position(|tab| tab.tab_index > focus_handle.tab_index)
+impl Ord for TabStopNode {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.path
+            .cmp(&other.path)
+            .then(self.node_insertion_index.cmp(&other.node_insertion_index))
+    }
+}
+
+impl PartialOrd for TabStopNode {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(&other))
+    }
+}
+
+impl Default for TabStopMap {
+    fn default() -> Self {
+        Self {
+            current_path: TabStopPath::default(),
+            insertion_history: Vec::new(),
+            by_id: FxHashMap::default(),
+            order: SumTree::new(()),
+        }
+    }
+}
+
+impl TabStopMap {
+    pub fn insert(&mut self, focus_handle: &FocusHandle) {
+        self.insertion_history
+            .push(TabStopOperation::Insert(focus_handle.clone()));
+        let mut path = self.current_path.clone();
+        path.0.push(focus_handle.tab_index);
+        let order = TabStopNode {
+            node_insertion_index: self.insertion_history.len() - 1,
+            tab_stop: focus_handle.tab_stop,
+            path,
+        };
+        self.by_id.insert(focus_handle.id, order.clone());
+        self.order.insert_or_replace(order, ());
+    }
+
+    pub fn begin_group(&mut self, tab_index: isize) {
+        self.insertion_history
+            .push(TabStopOperation::Group(tab_index));
+        self.current_path.0.push(tab_index);
+    }
+
+    pub fn end_group(&mut self) {
+        self.insertion_history.push(TabStopOperation::GroupEnd);
+        self.current_path.0.pop();
+    }
+
+    pub fn clear(&mut self) {
+        *self = Self::default();
+        self.current_path.0.clear();
+        self.insertion_history.clear();
+        self.by_id.clear();
+        self.order = SumTree::new(());
+    }
+
+    pub fn next(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
+        let Some(focused_id) = focused_id else {
+            let first = self.order.first()?;
+            if first.tab_stop {
+                return self.focus_handle_for_order(first);
+            } else {
+                return self
+                    .next_inner(first)
+                    .and_then(|order| self.focus_handle_for_order(order));
+            }
+        };
+
+        let node = self.tab_node_for_focus_id(focused_id)?;
+        let item = self.next_inner(node);
+
+        if let Some(item) = item {
+            self.focus_handle_for_order(&item)
+        } else {
+            self.next(None)
+        }
+    }
+
+    fn next_inner(&self, node: &TabStopNode) -> Option<&TabStopNode> {
+        let mut cursor = self.order.cursor::<TabStopNode>(());
+        cursor.seek(&node, Bias::Left);
+        cursor.next();
+        while let Some(item) = cursor.item()
+            && !item.tab_stop
         {
-            self.handles.insert(ix, focus_handle);
+            cursor.next();
+        }
+
+        cursor.item()
+    }
+
+    pub fn prev(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
+        let Some(focused_id) = focused_id else {
+            let last = self.order.last()?;
+            if last.tab_stop {
+                return self.focus_handle_for_order(last);
+            } else {
+                return self
+                    .prev_inner(last)
+                    .and_then(|order| self.focus_handle_for_order(order));
+            }
+        };
+
+        let node = self.tab_node_for_focus_id(focused_id)?;
+        let item = self.prev_inner(node);
+
+        if let Some(item) = item {
+            self.focus_handle_for_order(&item)
         } else {
-            self.handles.push(focus_handle);
+            self.prev(None)
         }
     }
 
-    pub(crate) fn clear(&mut self) {
-        self.handles.clear();
+    fn prev_inner(&self, node: &TabStopNode) -> Option<&TabStopNode> {
+        let mut cursor = self.order.cursor::<TabStopNode>(());
+        cursor.seek(&node, Bias::Left);
+        cursor.prev();
+        while let Some(item) = cursor.item()
+            && !item.tab_stop
+        {
+            cursor.prev();
+        }
+
+        cursor.item()
     }
 
-    fn current_index(&self, focused_id: Option<&FocusId>) -> Option<usize> {
-        self.handles.iter().position(|h| Some(&h.id) == focused_id)
+    pub fn replay(&mut self, nodes: &[TabStopOperation]) {
+        for node in nodes {
+            match node {
+                TabStopOperation::Insert(focus_handle) => self.insert(focus_handle),
+                TabStopOperation::Group(tab_index) => self.begin_group(*tab_index),
+                TabStopOperation::GroupEnd => self.end_group(),
+            }
+        }
     }
 
-    pub(crate) fn next(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
-        let next_ix = self
-            .current_index(focused_id)
-            .and_then(|ix| {
-                let next_ix = ix + 1;
-                (next_ix < self.handles.len()).then_some(next_ix)
-            })
-            .unwrap_or_default();
+    pub fn paint_index(&self) -> usize {
+        self.insertion_history.len()
+    }
 
-        self.handles.get(next_ix).cloned()
+    fn focus_handle_for_order(&self, order: &TabStopNode) -> Option<FocusHandle> {
+        let handle = self.insertion_history[order.node_insertion_index].focus_handle();
+        debug_assert!(
+            handle.is_some(),
+            "The order node did not correspond to an element, this is a GPUI bug"
+        );
+        handle.cloned()
     }
 
-    pub(crate) fn prev(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
-        let ix = self.current_index(focused_id).unwrap_or_default();
-        let prev_ix = if ix == 0 {
-            self.handles.len().saturating_sub(1)
-        } else {
-            ix.saturating_sub(1)
+    fn tab_node_for_focus_id(&self, focused_id: &FocusId) -> Option<&TabStopNode> {
+        let Some(order) = self.by_id.get(focused_id) else {
+            return None;
         };
+        Some(order)
+    }
+}
+
+mod sum_tree_impl {
+    use sum_tree::SeekTarget;
+
+    use crate::tab_stop::{TabStopNode, TabStopPath};
+
+    #[derive(Clone, Debug)]
+    pub struct TabStopOrderNodeSummary {
+        max_index: usize,
+        max_path: TabStopPath,
+        pub tab_stops: usize,
+    }
+
+    pub type TabStopCount = usize;
+
+    impl sum_tree::ContextLessSummary for TabStopOrderNodeSummary {
+        fn zero() -> Self {
+            TabStopOrderNodeSummary {
+                max_index: 0,
+                max_path: TabStopPath::default(),
+                tab_stops: 0,
+            }
+        }
+
+        fn add_summary(&mut self, summary: &Self) {
+            self.max_index = summary.max_index;
+            self.max_path = summary.max_path.clone();
+            self.tab_stops += summary.tab_stops;
+        }
+    }
+
+    impl sum_tree::KeyedItem for TabStopNode {
+        type Key = Self;
+
+        fn key(&self) -> Self::Key {
+            self.clone()
+        }
+    }
+
+    impl sum_tree::Item for TabStopNode {
+        type Summary = TabStopOrderNodeSummary;
+
+        fn summary(&self, _cx: <Self::Summary as sum_tree::Summary>::Context<'_>) -> Self::Summary {
+            TabStopOrderNodeSummary {
+                max_index: self.node_insertion_index,
+                max_path: self.path.clone(),
+                tab_stops: if self.tab_stop { 1 } else { 0 },
+            }
+        }
+    }
+
+    impl<'a> sum_tree::Dimension<'a, TabStopOrderNodeSummary> for TabStopCount {
+        fn zero(_: <TabStopOrderNodeSummary as sum_tree::Summary>::Context<'_>) -> Self {
+            0
+        }
+
+        fn add_summary(
+            &mut self,
+            summary: &'a TabStopOrderNodeSummary,
+            _: <TabStopOrderNodeSummary as sum_tree::Summary>::Context<'_>,
+        ) {
+            *self += summary.tab_stops;
+        }
+    }
+
+    impl<'a> sum_tree::Dimension<'a, TabStopOrderNodeSummary> for TabStopNode {
+        fn zero(_: <TabStopOrderNodeSummary as sum_tree::Summary>::Context<'_>) -> Self {
+            TabStopNode::default()
+        }
 
-        self.handles.get(prev_ix).cloned()
+        fn add_summary(
+            &mut self,
+            summary: &'a TabStopOrderNodeSummary,
+            _: <TabStopOrderNodeSummary as sum_tree::Summary>::Context<'_>,
+        ) {
+            self.node_insertion_index = summary.max_index;
+            self.path = summary.max_path.clone();
+        }
+    }
+
+    impl<'a, 'b> SeekTarget<'a, TabStopOrderNodeSummary, TabStopNode> for &'b TabStopNode {
+        fn cmp(
+            &self,
+            cursor_location: &TabStopNode,
+            _: <TabStopOrderNodeSummary as sum_tree::Summary>::Context<'_>,
+        ) -> std::cmp::Ordering {
+            Iterator::cmp(self.path.0.iter(), cursor_location.path.0.iter()).then(
+                <usize as Ord>::cmp(
+                    &self.node_insertion_index,
+                    &cursor_location.node_insertion_index,
+                ),
+            )
+        }
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::{FocusHandle, FocusMap, TabHandles};
+    use itertools::Itertools as _;
+
+    use crate::{FocusHandle, FocusId, FocusMap, TabStopMap};
     use std::sync::Arc;
 
     #[test]
     fn test_tab_handles() {
         let focus_map = Arc::new(FocusMap::default());
-        let mut tab = TabHandles::default();
+        let mut tab_index_map = TabStopMap::default();
 
         let focus_handles = vec![
             FocusHandle::new(&focus_map).tab_stop(true).tab_index(0),
@@ -81,72 +327,281 @@ mod tests {
         ];
 
         for handle in focus_handles.iter() {
-            tab.insert(handle);
+            tab_index_map.insert(handle);
         }
+        let expected = [
+            focus_handles[0].clone(),
+            focus_handles[5].clone(),
+            focus_handles[1].clone(),
+            focus_handles[2].clone(),
+            focus_handles[6].clone(),
+        ];
+
+        let mut prev = None;
+        let mut found = vec![];
+        for _ in 0..expected.len() {
+            let handle = tab_index_map.next(prev.as_ref()).unwrap();
+            prev = Some(handle.id);
+            found.push(handle.id);
+        }
+
         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,
-            ]
+            found,
+            expected.iter().map(|handle| handle.id).collect::<Vec<_>>()
         );
 
         // Select first tab index if no handle is currently focused.
-        assert_eq!(tab.next(None), Some(tab.handles[0].clone()));
+        assert_eq!(tab_index_map.next(None), Some(expected[0].clone()));
         // Select last tab index if no handle is currently focused.
-        assert_eq!(
-            tab.prev(None),
-            Some(tab.handles[tab.handles.len() - 1].clone())
-        );
+        assert_eq!(tab_index_map.prev(None), expected.last().cloned(),);
 
         assert_eq!(
-            tab.next(Some(&tab.handles[0].id)),
-            Some(tab.handles[1].clone())
+            tab_index_map.next(Some(&expected[0].id)),
+            Some(expected[1].clone())
         );
         assert_eq!(
-            tab.next(Some(&tab.handles[1].id)),
-            Some(tab.handles[2].clone())
+            tab_index_map.next(Some(&expected[1].id)),
+            Some(expected[2].clone())
         );
         assert_eq!(
-            tab.next(Some(&tab.handles[2].id)),
-            Some(tab.handles[3].clone())
+            tab_index_map.next(Some(&expected[2].id)),
+            Some(expected[3].clone())
         );
         assert_eq!(
-            tab.next(Some(&tab.handles[3].id)),
-            Some(tab.handles[4].clone())
+            tab_index_map.next(Some(&expected[3].id)),
+            Some(expected[4].clone())
         );
         assert_eq!(
-            tab.next(Some(&tab.handles[4].id)),
-            Some(tab.handles[0].clone())
+            tab_index_map.next(Some(&expected[4].id)),
+            Some(expected[0].clone())
         );
 
         // prev
-        assert_eq!(tab.prev(None), Some(tab.handles[4].clone()));
+        assert_eq!(tab_index_map.prev(None), Some(expected[4].clone()));
         assert_eq!(
-            tab.prev(Some(&tab.handles[0].id)),
-            Some(tab.handles[4].clone())
+            tab_index_map.prev(Some(&expected[0].id)),
+            Some(expected[4].clone())
         );
         assert_eq!(
-            tab.prev(Some(&tab.handles[1].id)),
-            Some(tab.handles[0].clone())
+            tab_index_map.prev(Some(&expected[1].id)),
+            Some(expected[0].clone())
         );
         assert_eq!(
-            tab.prev(Some(&tab.handles[2].id)),
-            Some(tab.handles[1].clone())
+            tab_index_map.prev(Some(&expected[2].id)),
+            Some(expected[1].clone())
         );
         assert_eq!(
-            tab.prev(Some(&tab.handles[3].id)),
-            Some(tab.handles[2].clone())
+            tab_index_map.prev(Some(&expected[3].id)),
+            Some(expected[2].clone())
         );
         assert_eq!(
-            tab.prev(Some(&tab.handles[4].id)),
-            Some(tab.handles[3].clone())
+            tab_index_map.prev(Some(&expected[4].id)),
+            Some(expected[3].clone())
         );
     }
+
+    #[test]
+    fn test_tab_non_stop_filtering() {
+        let focus_map = Arc::new(FocusMap::default());
+        let mut tab_index_map = TabStopMap::default();
+
+        // Check that we can query next from a non-stop tab
+        let tab_non_stop_1 = FocusHandle::new(&focus_map).tab_stop(false).tab_index(1);
+        let tab_stop_2 = FocusHandle::new(&focus_map).tab_stop(true).tab_index(2);
+        tab_index_map.insert(&tab_non_stop_1);
+        tab_index_map.insert(&tab_stop_2);
+        let result = tab_index_map.next(Some(&tab_non_stop_1.id)).unwrap();
+        assert_eq!(result.id, tab_stop_2.id);
+
+        // Check that we skip over non-stop tabs
+        let tab_stop_0 = FocusHandle::new(&focus_map).tab_stop(true).tab_index(0);
+        let tab_non_stop_0 = FocusHandle::new(&focus_map).tab_stop(false).tab_index(0);
+        tab_index_map.insert(&tab_stop_0);
+        tab_index_map.insert(&tab_non_stop_0);
+        let result = tab_index_map.next(Some(&tab_stop_0.id)).unwrap();
+        assert_eq!(result.id, tab_stop_2.id);
+    }
+
+    #[must_use]
+    struct TabStopMapTest {
+        tab_map: TabStopMap,
+        focus_map: Arc<FocusMap>,
+        expected: Vec<(usize, FocusId)>,
+    }
+
+    impl TabStopMapTest {
+        #[must_use]
+        fn new() -> Self {
+            Self {
+                tab_map: TabStopMap::default(),
+                focus_map: Arc::new(FocusMap::default()),
+                expected: Vec::default(),
+            }
+        }
+
+        #[must_use]
+        fn tab_non_stop(mut self, index: isize) -> Self {
+            let handle = FocusHandle::new(&self.focus_map)
+                .tab_stop(false)
+                .tab_index(index);
+            self.tab_map.insert(&handle);
+            self
+        }
+
+        #[must_use]
+        fn tab_stop(mut self, index: isize, expected: usize) -> Self {
+            let handle = FocusHandle::new(&self.focus_map)
+                .tab_stop(true)
+                .tab_index(index);
+            self.tab_map.insert(&handle);
+            self.expected.push((expected, handle.id));
+            self.expected.sort_by_key(|(expected, _)| *expected);
+            self
+        }
+
+        #[must_use]
+        fn tab_group(mut self, tab_index: isize, children: impl FnOnce(Self) -> Self) -> Self {
+            self.tab_map.begin_group(tab_index);
+            self = children(self);
+            self.tab_map.end_group();
+            self
+        }
+
+        fn traverse_tab_map(
+            &self,
+            traverse: impl Fn(&TabStopMap, Option<&FocusId>) -> Option<FocusHandle>,
+        ) -> Vec<FocusId> {
+            let mut last_focus_id = None;
+            let mut found = vec![];
+            for _ in 0..self.expected.len() {
+                let handle = traverse(&self.tab_map, last_focus_id.as_ref()).unwrap();
+                last_focus_id = Some(handle.id);
+                found.push(handle.id);
+            }
+            found
+        }
+
+        fn assert(self) {
+            let mut expected = self.expected.iter().map(|(_, id)| *id).collect_vec();
+
+            // Check next order
+            let forward_found = self.traverse_tab_map(|tab_map, prev| tab_map.next(prev));
+            assert_eq!(forward_found, expected);
+
+            // Test overflow. Last to first
+            assert_eq!(
+                self.tab_map
+                    .next(forward_found.last())
+                    .map(|handle| handle.id),
+                expected.first().cloned()
+            );
+
+            // Check previous order
+            let reversed_found = self.traverse_tab_map(|tab_map, prev| tab_map.prev(prev));
+            expected.reverse();
+            assert_eq!(reversed_found, expected);
+
+            // Test overflow. First to last
+            assert_eq!(
+                self.tab_map
+                    .prev(reversed_found.last())
+                    .map(|handle| handle.id),
+                expected.first().cloned(),
+            );
+        }
+    }
+
+    #[test]
+    fn test_with_disabled_tab_stop() {
+        TabStopMapTest::new()
+            .tab_stop(0, 0)
+            .tab_non_stop(1)
+            .tab_stop(2, 1)
+            .tab_stop(3, 2)
+            .assert();
+    }
+
+    #[test]
+    fn test_with_multiple_disabled_tab_stops() {
+        TabStopMapTest::new()
+            .tab_non_stop(0)
+            .tab_stop(1, 0)
+            .tab_non_stop(3)
+            .tab_stop(3, 1)
+            .tab_non_stop(4)
+            .assert();
+    }
+
+    #[test]
+    fn test_tab_group_functionality() {
+        TabStopMapTest::new()
+            .tab_stop(0, 0)
+            .tab_stop(0, 1)
+            .tab_group(2, |t| t.tab_stop(0, 2).tab_stop(1, 3))
+            .tab_stop(3, 4)
+            .tab_stop(4, 5)
+            .assert()
+    }
+
+    #[test]
+    fn test_sibling_groups() {
+        TabStopMapTest::new()
+            .tab_stop(0, 0)
+            .tab_stop(1, 1)
+            .tab_group(2, |test| test.tab_stop(0, 2).tab_stop(1, 3))
+            .tab_stop(3, 4)
+            .tab_stop(4, 5)
+            .tab_group(6, |test| test.tab_stop(0, 6).tab_stop(1, 7))
+            .tab_stop(7, 8)
+            .tab_stop(8, 9)
+            .assert();
+    }
+
+    #[test]
+    fn test_nested_group() {
+        TabStopMapTest::new()
+            .tab_stop(0, 0)
+            .tab_stop(1, 1)
+            .tab_group(2, |t| {
+                t.tab_group(0, |t| t.tab_stop(0, 2).tab_stop(1, 3))
+                    .tab_stop(1, 4)
+            })
+            .tab_stop(3, 5)
+            .tab_stop(4, 6)
+            .assert();
+    }
+
+    #[test]
+    fn test_sibling_nested_groups() {
+        TabStopMapTest::new()
+            .tab_stop(0, 0)
+            .tab_stop(1, 1)
+            .tab_group(2, |builder| {
+                builder
+                    .tab_stop(0, 2)
+                    .tab_stop(2, 5)
+                    .tab_group(1, |builder| builder.tab_stop(0, 3).tab_stop(1, 4))
+                    .tab_group(3, |builder| builder.tab_stop(0, 6).tab_stop(1, 7))
+            })
+            .tab_stop(3, 8)
+            .tab_stop(4, 9)
+            .assert();
+    }
+
+    #[test]
+    fn test_sibling_nested_groups_out_of_order() {
+        TabStopMapTest::new()
+            .tab_stop(9, 9)
+            .tab_stop(8, 8)
+            .tab_group(7, |builder| {
+                builder
+                    .tab_stop(0, 2)
+                    .tab_stop(2, 5)
+                    .tab_group(3, |builder| builder.tab_stop(1, 7).tab_stop(0, 6))
+                    .tab_group(1, |builder| builder.tab_stop(0, 3).tab_stop(1, 4))
+            })
+            .tab_stop(3, 0)
+            .tab_stop(4, 1)
+            .assert();
+    }
 }

crates/gpui/src/window.rs 🔗

@@ -13,7 +13,7 @@ use crate::{
     Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge,
     SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow,
     SharedString, Size, StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab,
-    SystemWindowTabController, TabHandles, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement,
+    SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement,
     TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance,
     WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem,
     point, prelude::*, px, rems, size, transparent_black,
@@ -684,7 +684,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,
+    pub(crate) tab_stops: TabStopMap,
 }
 
 #[derive(Clone, Default)]
@@ -733,7 +733,7 @@ impl Frame {
 
             #[cfg(any(feature = "inspector", debug_assertions))]
             inspector_hitboxes: FxHashMap::default(),
-            tab_handles: TabHandles::default(),
+            tab_stops: TabStopMap::default(),
         }
     }
 
@@ -749,7 +749,7 @@ impl Frame {
         self.hitboxes.clear();
         self.window_control_hitboxes.clear();
         self.deferred_draws.clear();
-        self.tab_handles.clear();
+        self.tab_stops.clear();
         self.focus = None;
 
         #[cfg(any(feature = "inspector", debug_assertions))]
@@ -1415,7 +1415,7 @@ impl Window {
             return;
         }
 
-        if let Some(handle) = self.rendered_frame.tab_handles.next(self.focus.as_ref()) {
+        if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) {
             self.focus(&handle)
         }
     }
@@ -1426,7 +1426,7 @@ impl Window {
             return;
         }
 
-        if let Some(handle) = self.rendered_frame.tab_handles.prev(self.focus.as_ref()) {
+        if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) {
             self.focus(&handle)
         }
     }
@@ -2285,7 +2285,7 @@ impl Window {
             input_handlers_index: self.next_frame.input_handlers.len(),
             cursor_styles_index: self.next_frame.cursor_styles.len(),
             accessed_element_states_index: self.next_frame.accessed_element_states.len(),
-            tab_handle_index: self.next_frame.tab_handles.handles.len(),
+            tab_handle_index: self.next_frame.tab_stops.paint_index(),
             line_layout_index: self.text_system.layout_index(),
         }
     }
@@ -2315,11 +2315,9 @@ impl Window {
                 .iter()
                 .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)),
         );
-        self.next_frame.tab_handles.handles.extend(
-            self.rendered_frame.tab_handles.handles
-                [range.start.tab_handle_index..range.end.tab_handle_index]
-                .iter()
-                .cloned(),
+        self.next_frame.tab_stops.replay(
+            &self.rendered_frame.tab_stops.insertion_history
+                [range.start.tab_handle_index..range.end.tab_handle_index],
         );
 
         self.text_system
@@ -2734,6 +2732,19 @@ impl Window {
         }
     }
 
+    /// Executes the given closure within the context of a tab group.
+    #[inline]
+    pub fn with_tab_group<R>(&mut self, index: Option<isize>, f: impl FnOnce(&mut Self) -> R) -> R {
+        if let Some(index) = index {
+            self.next_frame.tab_stops.begin_group(index);
+            let result = f(self);
+            self.next_frame.tab_stops.end_group();
+            result
+        } else {
+            f(self)
+        }
+    }
+
     /// Defers the drawing of the given element, scheduling it to be painted on top of the currently-drawn tree
     /// at a later time. The `priority` parameter determines the drawing order relative to other deferred elements,
     /// with higher values being drawn on top.