From bb5dcfa80c4ddbcb9f5957a7704ea4d6cfe188b2 Mon Sep 17 00:00:00 2001 From: luren Date: Sat, 16 May 2026 05:37:20 +0800 Subject: [PATCH] dialog: Focus first control on open --- crates/ui/src/dialog/dialog.rs | 8 +++ crates/ui/src/root.rs | 119 ++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/dialog/dialog.rs b/crates/ui/src/dialog/dialog.rs index f4d1d4766c..b5547eda2e 100644 --- a/crates/ui/src/dialog/dialog.rs +++ b/crates/ui/src/dialog/dialog.rs @@ -212,6 +212,7 @@ pub struct Dialog { /// This will be change when open the dialog, the focus handle is create when open the dialog. pub(crate) focus_handle: FocusHandle, + pub(crate) focus_first_descendant: bool, pub(crate) layer_ix: usize, } @@ -236,6 +237,7 @@ impl Dialog { content_builder: None, props: DialogProps::default(), children: Vec::new(), + focus_first_descendant: false, layer_ix: 0, button_props: DialogButtonProps::default(), } @@ -445,6 +447,12 @@ impl RenderOnce for Dialog { let on_close = self.button_props.on_close.clone(); let on_ok = self.button_props.on_ok.clone(); let on_cancel = self.button_props.on_cancel.clone(); + if self.focus_first_descendant { + let focus_handle = self.focus_handle.clone(); + window.defer(cx, move |window, cx| { + Root::focus_first_descendant(&focus_handle, window, cx); + }); + } let window_paddings = crate::window_border::window_paddings(window); let view_size = window.viewport_size() diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index ff3905be62..75fb95d7cb 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -13,7 +13,7 @@ use gpui::{ InteractiveElement, IntoElement, KeyBinding, ParentElement as _, Pixels, Render, StyleRefinement, Styled, WeakFocusHandle, Window, actions, div, prelude::FluentBuilder as _, }; -use std::{any::TypeId, rc::Rc}; +use std::{any::TypeId, cell::Cell, rc::Rc}; actions!(root, [Tab, TabPrev]); @@ -58,6 +58,7 @@ pub(crate) struct ActiveDialog { /// The previous focused handle before opening the Dialog. previous_focused_handle: Option, builder: Rc Dialog + 'static>, + focus_first_descendant: Rc>, } impl ActiveDialog { @@ -70,8 +71,13 @@ impl ActiveDialog { focus_handle, previous_focused_handle, builder: Rc::new(builder), + focus_first_descendant: Rc::new(Cell::new(true)), } } + + fn take_focus_first_descendant(&self) -> bool { + self.focus_first_descendant.replace(false) + } } impl Root { @@ -222,6 +228,7 @@ impl Root { // // So we keep the focus handle in the `active_dialog`, this is owned by the `Root`. dialog.focus_handle = active_dialog.focus_handle.clone(); + dialog.focus_first_descendant = active_dialog.take_focus_first_descendant(); dialog.layer_ix = i; // Find the dialog which one needs to show overlay. @@ -265,6 +272,31 @@ impl Root { cx.notify(); } + pub(crate) fn focus_first_descendant( + container_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut App, + ) { + if !container_focus_handle.is_focused(window) { + return; + } + + let previous_focus = window.focused(cx); + // The dialog container is in the focus tree but is not a tab stop, so advancing once + // lands on the first keyboard-focusable descendant when one exists. + window.focus_next(cx); + + if container_focus_handle.contains_focused(window, cx) + && !container_focus_handle.is_focused(window) + { + return; + } + + if let Some(previous_focus) = previous_focus { + window.focus(&previous_focus, cx); + } + } + fn close_dialog_internal(&mut self) -> Option { self.focused_input = None; self.active_dialogs @@ -509,3 +541,88 @@ impl Render for Root { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{WindowExt as _, input::Input, theme::Theme}; + use gpui::{Focusable as _, TestAppContext, VisualTestContext}; + + struct DialogFocusTest { + focus_handle: FocusHandle, + } + + impl Render for DialogFocusTest { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .size_full() + .children(Root::render_dialog_layer(window, cx)) + } + } + + fn window_with_root(cx: &mut TestAppContext) -> &mut VisualTestContext { + let (_, cx) = cx.add_window_view(|window, cx| { + cx.set_global(Theme::default()); + crate::init(cx); + + let view = cx.new(|cx| DialogFocusTest { + focus_handle: cx.focus_handle(), + }); + + Root::new(view, window, cx) + }); + + cx + } + + #[gpui::test] + fn open_dialog_focuses_first_input(cx: &mut TestAppContext) { + let cx = window_with_root(cx); + let input = cx.update(|window, cx| cx.new(|cx| InputState::new(window, cx))); + + cx.update(|window, cx| { + let input = input.clone(); + window.open_dialog(cx, move |dialog, _, _| { + dialog.title("Initial focus").child(Input::new(&input)) + }); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + assert!(input.read(cx).focus_handle(cx).is_focused(window)); + }); + + cx.simulate_input("abc"); + + cx.update(|_, cx| { + assert_eq!(input.read(cx).value().as_ref(), "abc"); + }); + } + + #[gpui::test] + fn open_dialog_does_not_override_explicit_focus(cx: &mut TestAppContext) { + let cx = window_with_root(cx); + let first_input = cx.update(|window, cx| cx.new(|cx| InputState::new(window, cx))); + let explicit_input = cx.update(|window, cx| cx.new(|cx| InputState::new(window, cx))); + + cx.update(|window, cx| { + let first_input = first_input.clone(); + let explicit_input = explicit_input.clone(); + window.open_dialog(cx, move |dialog, window, cx| { + explicit_input.update(cx, |input, cx| input.focus(window, cx)); + + dialog + .title("Initial focus") + .child(Input::new(&first_input)) + .child(Input::new(&explicit_input)) + }); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + assert!(!first_input.read(cx).focus_handle(cx).is_focused(window)); + assert!(explicit_input.read(cx).focus_handle(cx).is_focused(window)); + }); + } +}