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
This commit is contained in:
Jason Lee 2025-07-21 07:38:54 +08:00 committed by GitHub
parent 137081f050
commit caa4b529e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 387 additions and 16 deletions

View file

@ -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);
});
}

View file

@ -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, _| {

View file

@ -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| {

View file

@ -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::*;

157
crates/gpui/src/tab_stop.rs Normal file
View file

@ -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())
);
}
}

View file

@ -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