From caa4b529e4b130006990a31b4908e4252aa96fe4 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 21 Jul 2025 07:38:54 +0800 Subject: [PATCH] gpui: Add tab focus support (#33008) 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 --- 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(-) create mode 100644 crates/gpui/examples/tab_stop.rs create mode 100644 crates/gpui/src/tab_stop.rs diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c58b52a5e93b154237f8822e6abc86a237c2d02 --- /dev/null +++ b/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, + message: SharedString, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> 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) { + window.focus_next(); + self.message = SharedString::from("You have pressed `Tab`."); + } + + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { + 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) -> impl IntoElement { + fn button(id: impl Into) -> Stateful
{ + 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); + }); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index de7ba782b2039c00c729343daa92d094b59ad248..2771de9aac2bc721126091826aa672d194589e61 100644 --- a/crates/gpui/src/app.rs +++ b/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, _| { diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index cb53276bc2a879168e718e210b03b7af2061ad52..ed1666c53060dfdf3ed4c10a85a730d69f87986d 100644 --- a/crates/gpui/src/elements/div.rs +++ b/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(mut self, key_context: C) -> Self @@ -1462,6 +1469,7 @@ pub struct Interactivity { pub(crate) tooltip_builder: Option, pub(crate) window_control: Option, pub(crate) hitbox_behavior: HitboxBehavior, + pub(crate) tab_index: Option, #[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| { diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 4eb6fa8dabeb1476c779d36cbd61257faf431413..09799eb910f0eeece17fd9975c3c13f6accd2df6 100644 --- a/crates/gpui/src/gpui.rs +++ b/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::*; diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ec3f560e8be80d486f0920b4d41d1964c7645da --- /dev/null +++ b/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, +} + +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 { + 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 { + 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![ + 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()) + ); + } +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index b6601829c74e6e267c48fe1c5aa9f9ca681d2855..963d2bb45c437e98d7a56587dedd7ff56827a56f 100644 --- a/crates/gpui/src/window.rs +++ b/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>; +pub(crate) type FocusMap = RwLock>; +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, + /// 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) -> 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) -> Option { 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, usize>, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) inspector_hitboxes: FxHashMap, + 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 { &self.text_system