Skip to content

Commit caa4b52

Browse files
authored
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
1 parent 137081f commit caa4b52

File tree

6 files changed

+387
-16
lines changed

6 files changed

+387
-16
lines changed

crates/gpui/examples/tab_stop.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use gpui::{
2+
App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString,
3+
Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size,
4+
};
5+
6+
actions!(example, [Tab, TabPrev]);
7+
8+
struct Example {
9+
items: Vec<FocusHandle>,
10+
message: SharedString,
11+
}
12+
13+
impl Example {
14+
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
15+
let items = vec![
16+
cx.focus_handle().tab_index(1).tab_stop(true),
17+
cx.focus_handle().tab_index(2).tab_stop(true),
18+
cx.focus_handle().tab_index(3).tab_stop(true),
19+
cx.focus_handle(),
20+
cx.focus_handle().tab_index(2).tab_stop(true),
21+
];
22+
23+
window.focus(items.first().unwrap());
24+
Self {
25+
items,
26+
message: SharedString::from("Press `Tab`, `Shift-Tab` to switch focus."),
27+
}
28+
}
29+
30+
fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context<Self>) {
31+
window.focus_next();
32+
self.message = SharedString::from("You have pressed `Tab`.");
33+
}
34+
35+
fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context<Self>) {
36+
window.focus_prev();
37+
self.message = SharedString::from("You have pressed `Shift-Tab`.");
38+
}
39+
}
40+
41+
impl Render for Example {
42+
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
43+
fn button(id: impl Into<ElementId>) -> Stateful<Div> {
44+
div()
45+
.id(id)
46+
.h_10()
47+
.flex_1()
48+
.flex()
49+
.justify_center()
50+
.items_center()
51+
.border_1()
52+
.border_color(gpui::black())
53+
.bg(gpui::black())
54+
.text_color(gpui::white())
55+
.focus(|this| this.border_color(gpui::blue()))
56+
.shadow_sm()
57+
}
58+
59+
div()
60+
.id("app")
61+
.on_action(cx.listener(Self::on_tab))
62+
.on_action(cx.listener(Self::on_tab_prev))
63+
.size_full()
64+
.flex()
65+
.flex_col()
66+
.p_4()
67+
.gap_3()
68+
.bg(gpui::white())
69+
.text_color(gpui::black())
70+
.child(self.message.clone())
71+
.children(
72+
self.items
73+
.clone()
74+
.into_iter()
75+
.enumerate()
76+
.map(|(ix, item_handle)| {
77+
div()
78+
.id(("item", ix))
79+
.track_focus(&item_handle)
80+
.h_10()
81+
.w_full()
82+
.flex()
83+
.justify_center()
84+
.items_center()
85+
.border_1()
86+
.border_color(gpui::black())
87+
.when(
88+
item_handle.tab_stop && item_handle.is_focused(window),
89+
|this| this.border_color(gpui::blue()),
90+
)
91+
.map(|this| match item_handle.tab_stop {
92+
true => this
93+
.hover(|this| this.bg(gpui::black().opacity(0.1)))
94+
.child(format!("tab_index: {}", item_handle.tab_index)),
95+
false => this.opacity(0.4).child("tab_stop: false"),
96+
})
97+
}),
98+
)
99+
.child(
100+
div()
101+
.flex()
102+
.flex_row()
103+
.gap_3()
104+
.items_center()
105+
.child(button("el1").tab_index(4).child("Button 1"))
106+
.child(button("el2").tab_index(5).child("Button 2")),
107+
)
108+
}
109+
}
110+
111+
fn main() {
112+
Application::new().run(|cx: &mut App| {
113+
cx.bind_keys([
114+
KeyBinding::new("tab", Tab, None),
115+
KeyBinding::new("shift-tab", TabPrev, None),
116+
]);
117+
118+
let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx);
119+
cx.open_window(
120+
WindowOptions {
121+
window_bounds: Some(WindowBounds::Windowed(bounds)),
122+
..Default::default()
123+
},
124+
|window, cx| cx.new(|cx| Example::new(window, cx)),
125+
)
126+
.unwrap();
127+
128+
cx.activate(true);
129+
});
130+
}

crates/gpui/src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -954,8 +954,8 @@ impl App {
954954
self.focus_handles
955955
.clone()
956956
.write()
957-
.retain(|handle_id, count| {
958-
if count.load(SeqCst) == 0 {
957+
.retain(|handle_id, focus| {
958+
if focus.ref_count.load(SeqCst) == 0 {
959959
for window_handle in self.windows() {
960960
window_handle
961961
.update(self, |_, window, _| {

crates/gpui/src/elements/div.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,13 @@ pub trait InteractiveElement: Sized {
619619
self
620620
}
621621

622+
/// Set index of the tab stop order.
623+
fn tab_index(mut self, index: isize) -> Self {
624+
self.interactivity().focusable = true;
625+
self.interactivity().tab_index = Some(index);
626+
self
627+
}
628+
622629
/// Set the keymap context for this element. This will be used to determine
623630
/// which action to dispatch from the keymap.
624631
fn key_context<C, E>(mut self, key_context: C) -> Self
@@ -1462,6 +1469,7 @@ pub struct Interactivity {
14621469
pub(crate) tooltip_builder: Option<TooltipBuilder>,
14631470
pub(crate) window_control: Option<WindowControlArea>,
14641471
pub(crate) hitbox_behavior: HitboxBehavior,
1472+
pub(crate) tab_index: Option<isize>,
14651473

14661474
#[cfg(any(feature = "inspector", debug_assertions))]
14671475
pub(crate) source_location: Option<&'static core::panic::Location<'static>>,
@@ -1521,12 +1529,17 @@ impl Interactivity {
15211529
// as frames contain an element with this id.
15221530
if self.focusable && self.tracked_focus_handle.is_none() {
15231531
if let Some(element_state) = element_state.as_mut() {
1524-
self.tracked_focus_handle = Some(
1525-
element_state
1526-
.focus_handle
1527-
.get_or_insert_with(|| cx.focus_handle())
1528-
.clone(),
1529-
);
1532+
let mut handle = element_state
1533+
.focus_handle
1534+
.get_or_insert_with(|| cx.focus_handle())
1535+
.clone()
1536+
.tab_stop(false);
1537+
1538+
if let Some(index) = self.tab_index {
1539+
handle = handle.tab_index(index).tab_stop(true);
1540+
}
1541+
1542+
self.tracked_focus_handle = Some(handle);
15301543
}
15311544
}
15321545

@@ -1729,6 +1742,10 @@ impl Interactivity {
17291742
return ((), element_state);
17301743
}
17311744

1745+
if let Some(focus_handle) = &self.tracked_focus_handle {
1746+
window.next_frame.tab_handles.insert(focus_handle);
1747+
}
1748+
17321749
window.with_element_opacity(style.opacity, |window| {
17331750
style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| {
17341751
window.with_text_style(style.text_style().cloned(), |window| {

crates/gpui/src/gpui.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ mod style;
9595
mod styled;
9696
mod subscription;
9797
mod svg_renderer;
98+
mod tab_stop;
9899
mod taffy;
99100
#[cfg(any(test, feature = "test-support"))]
100101
pub mod test;
@@ -151,6 +152,7 @@ pub use style::*;
151152
pub use styled::*;
152153
pub use subscription::*;
153154
use svg_renderer::*;
155+
pub(crate) use tab_stop::*;
154156
pub use taffy::{AvailableSpace, LayoutId};
155157
#[cfg(any(test, feature = "test-support"))]
156158
pub use test::*;

crates/gpui/src/tab_stop.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use crate::{FocusHandle, FocusId};
2+
3+
/// Represents a collection of tab handles.
4+
///
5+
/// Used to manage the `Tab` event to switch between focus handles.
6+
#[derive(Default)]
7+
pub(crate) struct TabHandles {
8+
handles: Vec<FocusHandle>,
9+
}
10+
11+
impl TabHandles {
12+
pub(crate) fn insert(&mut self, focus_handle: &FocusHandle) {
13+
if !focus_handle.tab_stop {
14+
return;
15+
}
16+
17+
let focus_handle = focus_handle.clone();
18+
19+
// Insert handle with same tab_index last
20+
if let Some(ix) = self
21+
.handles
22+
.iter()
23+
.position(|tab| tab.tab_index > focus_handle.tab_index)
24+
{
25+
self.handles.insert(ix, focus_handle);
26+
} else {
27+
self.handles.push(focus_handle);
28+
}
29+
}
30+
31+
pub(crate) fn clear(&mut self) {
32+
self.handles.clear();
33+
}
34+
35+
fn current_index(&self, focused_id: Option<&FocusId>) -> usize {
36+
self.handles
37+
.iter()
38+
.position(|h| Some(&h.id) == focused_id)
39+
.unwrap_or_default()
40+
}
41+
42+
pub(crate) fn next(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
43+
let ix = self.current_index(focused_id);
44+
45+
let mut next_ix = ix + 1;
46+
if next_ix + 1 > self.handles.len() {
47+
next_ix = 0;
48+
}
49+
50+
if let Some(next_handle) = self.handles.get(next_ix) {
51+
Some(next_handle.clone())
52+
} else {
53+
None
54+
}
55+
}
56+
57+
pub(crate) fn prev(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> {
58+
let ix = self.current_index(focused_id);
59+
let prev_ix;
60+
if ix == 0 {
61+
prev_ix = self.handles.len().saturating_sub(1);
62+
} else {
63+
prev_ix = ix.saturating_sub(1);
64+
}
65+
66+
if let Some(prev_handle) = self.handles.get(prev_ix) {
67+
Some(prev_handle.clone())
68+
} else {
69+
None
70+
}
71+
}
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use crate::{FocusHandle, FocusMap, TabHandles};
77+
use std::sync::Arc;
78+
79+
#[test]
80+
fn test_tab_handles() {
81+
let focus_map = Arc::new(FocusMap::default());
82+
let mut tab = TabHandles::default();
83+
84+
let focus_handles = vec![
85+
FocusHandle::new(&focus_map).tab_stop(true).tab_index(0),
86+
FocusHandle::new(&focus_map).tab_stop(true).tab_index(1),
87+
FocusHandle::new(&focus_map).tab_stop(true).tab_index(1),
88+
FocusHandle::new(&focus_map),
89+
FocusHandle::new(&focus_map).tab_index(2),
90+
FocusHandle::new(&focus_map).tab_stop(true).tab_index(0),
91+
FocusHandle::new(&focus_map).tab_stop(true).tab_index(2),
92+
];
93+
94+
for handle in focus_handles.iter() {
95+
tab.insert(&handle);
96+
}
97+
assert_eq!(
98+
tab.handles
99+
.iter()
100+
.map(|handle| handle.id)
101+
.collect::<Vec<_>>(),
102+
vec![
103+
focus_handles[0].id,
104+
focus_handles[5].id,
105+
focus_handles[1].id,
106+
focus_handles[2].id,
107+
focus_handles[6].id,
108+
]
109+
);
110+
111+
// next
112+
assert_eq!(tab.next(None), Some(tab.handles[1].clone()));
113+
assert_eq!(
114+
tab.next(Some(&tab.handles[0].id)),
115+
Some(tab.handles[1].clone())
116+
);
117+
assert_eq!(
118+
tab.next(Some(&tab.handles[1].id)),
119+
Some(tab.handles[2].clone())
120+
);
121+
assert_eq!(
122+
tab.next(Some(&tab.handles[2].id)),
123+
Some(tab.handles[3].clone())
124+
);
125+
assert_eq!(
126+
tab.next(Some(&tab.handles[3].id)),
127+
Some(tab.handles[4].clone())
128+
);
129+
assert_eq!(
130+
tab.next(Some(&tab.handles[4].id)),
131+
Some(tab.handles[0].clone())
132+
);
133+
134+
// prev
135+
assert_eq!(tab.prev(None), Some(tab.handles[4].clone()));
136+
assert_eq!(
137+
tab.prev(Some(&tab.handles[0].id)),
138+
Some(tab.handles[4].clone())
139+
);
140+
assert_eq!(
141+
tab.prev(Some(&tab.handles[1].id)),
142+
Some(tab.handles[0].clone())
143+
);
144+
assert_eq!(
145+
tab.prev(Some(&tab.handles[2].id)),
146+
Some(tab.handles[1].clone())
147+
);
148+
assert_eq!(
149+
tab.prev(Some(&tab.handles[3].id)),
150+
Some(tab.handles[2].clone())
151+
);
152+
assert_eq!(
153+
tab.prev(Some(&tab.handles[4].id)),
154+
Some(tab.handles[3].clone())
155+
);
156+
}
157+
}

0 commit comments

Comments
 (0)