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:
parent
137081f050
commit
caa4b529e4
6 changed files with 387 additions and 16 deletions
130
crates/gpui/examples/tab_stop.rs
Normal file
130
crates/gpui/examples/tab_stop.rs
Normal 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);
|
||||
});
|
||||
}
|
|
@ -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, _| {
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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
157
crates/gpui/src/tab_stop.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue