From 4fd4cbbfb7655fe39356f972eaffbaf98185b8ef Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 22 Oct 2025 16:36:18 -0700 Subject: [PATCH] gpui: Add focus-visible selector support (#40940) Release Notes: - N/A --------- Co-authored-by: Claude --- crates/gpui/examples/focus_visible.rs | 214 ++++++++++++++++++++++++++ crates/gpui/src/elements/div.rs | 20 +++ crates/gpui/src/window.rs | 17 ++ 3 files changed, 251 insertions(+) create mode 100644 crates/gpui/examples/focus_visible.rs diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs new file mode 100644 index 0000000000000000000000000000000000000000..737317cabadb7d3358c9c0497b52d4c2ff2e1028 --- /dev/null +++ b/crates/gpui/examples/focus_visible.rs @@ -0,0 +1,214 @@ +use gpui::{ + App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, + Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, +}; + +actions!(example, [Tab, TabPrev, Quit]); + +struct Example { + focus_handle: FocusHandle, + items: Vec<(FocusHandle, &'static str)>, + 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), + "Button with .focus() - always shows border when focused", + ), + ( + cx.focus_handle().tab_index(2).tab_stop(true), + "Button with .focus_visible() - only shows border with keyboard", + ), + ( + cx.focus_handle().tab_index(3).tab_stop(true), + "Button with both .focus() and .focus_visible()", + ), + ]; + + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); + + Self { + focus_handle, + items, + message: SharedString::from( + "Try clicking vs tabbing! Click shows no border, Tab shows border.", + ), + } + } + + fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { + window.focus_next(); + self.message = SharedString::from("Pressed Tab - focus-visible border should appear!"); + } + + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { + window.focus_prev(); + self.message = + SharedString::from("Pressed Shift-Tab - focus-visible border should appear!"); + } + + fn on_quit(&mut self, _: &Quit, _window: &mut Window, cx: &mut Context) { + cx.quit(); + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn button_base(id: impl Into, label: &'static str) -> Stateful
{ + div() + .id(id) + .h_16() + .w_full() + .flex() + .justify_center() + .items_center() + .bg(gpui::rgb(0x2563eb)) + .text_color(gpui::white()) + .rounded_md() + .cursor_pointer() + .hover(|style| style.bg(gpui::rgb(0x1d4ed8))) + .child(label) + } + + div() + .id("app") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) + .on_action(cx.listener(Self::on_quit)) + .size_full() + .flex() + .flex_col() + .p_8() + .gap_6() + .bg(gpui::rgb(0xf3f4f6)) + .child( + div() + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x111827)) + .child("CSS focus-visible Demo"), + ) + .child( + div() + .p_4() + .rounded_md() + .bg(gpui::rgb(0xdbeafe)) + .text_color(gpui::rgb(0x1e3a8a)) + .child(self.message.clone()), + ) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child("1. Regular .focus() - always visible:"), + ) + .child( + button_base("button1", self.items[0].1) + .track_focus(&self.items[0].0) + .focus(|style| { + style.border_4().border_color(gpui::rgb(0xfbbf24)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 1 - focus border is visible!".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child("2. New .focus_visible() - only keyboard:"), + ) + .child( + button_base("button2", self.items[1].1) + .track_focus(&self.items[1].0) + .focus_visible(|style| { + style.border_4().border_color(gpui::rgb(0x10b981)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 2 - no border! Try Tab instead.".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child( + "3. Both .focus() (yellow) and .focus_visible() (green):", + ), + ) + .child( + button_base("button3", self.items[2].1) + .track_focus(&self.items[2].0) + .focus(|style| { + style.border_4().border_color(gpui::rgb(0xfbbf24)) + }) + .focus_visible(|style| { + style.border_4().border_color(gpui::rgb(0x10b981)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 3 - yellow border. Tab shows green!" + .into(); + cx.notify(); + })), + ), + ), + ) + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + KeyBinding::new("cmd-q", Quit, 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/elements/div.rs b/crates/gpui/src/elements/div.rs index 4d4e176919784f7d7fba68f68cc00b8e7ff92922..efc931f05ffbed2a0b20f23967f20f9e0704b454 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1034,6 +1034,18 @@ pub trait InteractiveElement: Sized { self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default()))); self } + + /// Set the given styles to be applied when this element is focused via keyboard navigation. + /// This is similar to CSS's `:focus-visible` pseudo-class - it only applies when the element + /// is focused AND the user is navigating via keyboard (not mouse clicks). + /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`]. + fn focus_visible(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self + where + Self: Sized, + { + self.interactivity().focus_visible_style = Some(Box::new(f(StyleRefinement::default()))); + self + } } /// A trait for elements that want to use the standard GPUI interactivity features @@ -1497,6 +1509,7 @@ pub struct Interactivity { pub base_style: Box, pub(crate) focus_style: Option>, pub(crate) in_focus_style: Option>, + pub(crate) focus_visible_style: Option>, pub(crate) hover_style: Option>, pub(crate) group_hover_style: Option, pub(crate) active_style: Option>, @@ -2492,6 +2505,13 @@ impl Interactivity { { style.refine(focus_style); } + + if let Some(focus_visible_style) = self.focus_visible_style.as_ref() + && focus_handle.is_focused(window) + && window.last_input_was_keyboard() + { + style.refine(focus_visible_style); + } } if let Some(hitbox) = hitbox { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 6d74a0e11f7a7ecde003f48b084f4720bd03230e..88076f3af13b5d95e820e29a59913156eae07065 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -863,6 +863,7 @@ pub struct Window { hovered: Rc>, pub(crate) needs_present: Rc>, pub(crate) last_input_timestamp: Rc>, + last_input_was_keyboard: bool, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1246,6 +1247,7 @@ impl Window { hovered, needs_present, last_input_timestamp, + last_input_was_keyboard: false, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -1899,6 +1901,12 @@ impl Window { self.modifiers } + /// Returns true if the last input event was keyboard-based (key press, tab navigation, etc.) + /// This is used for focus-visible styling to show focus indicators only for keyboard navigation. + pub fn last_input_was_keyboard(&self) -> bool { + self.last_input_was_keyboard + } + /// The current state of the keyboard's capslock pub fn capslock(&self) -> Capslock { self.capslock @@ -3580,6 +3588,15 @@ impl Window { #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { self.last_input_timestamp.set(Instant::now()); + + // Track whether this input was keyboard-based for focus-visible styling + self.last_input_was_keyboard = matches!( + event, + PlatformInput::KeyDown(_) + | PlatformInput::KeyUp(_) + | PlatformInput::ModifiersChanged(_) + ); + // Handlers may set this to false by calling `stop_propagation`. cx.propagate_event = true; // Handlers may set this to true by calling `prevent_default`.