Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/ui/src/dialog/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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(),
}
Expand Down Expand Up @@ -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| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't use defer for this, we was tired for this.

If user operations fast to close, this method will have unexpected behaviors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't use defer for this, we was tired for this.

If user operations fast to close, this method will have unexpected behaviors

Thank you for the review. I agree that using defer here is risky, especially when the dialog is opened and closed quickly.

I revisited the focus timing issue. The goal of this PR / issue is still to focus the first focusable control automatically when a dialog opens. The difficulty is that focus_next(cx) relies on tab stops from the already rendered frame, while focusable children in a newly opened dialog are only registered during the current render/paint phase. So calling focus_next(cx) synchronously from open_dialog or Dialog::render does not reliably discover the new dialog children; delaying it with defer makes discovery work, but introduces the lifecycle issue you pointed out.

I see two possible directions:

  1. Keep the original automatic behavior, but implement it without defer. This likely requires a synchronous initial-focus scope/registry during dialog rendering, where focusable descendants register their FocusHandle while rendering and the first eligible descendant is focused once. This preserves the original behavior, but the change is larger and may need to touch more focusable components.

  2. Use a smaller explicit API, for example:

dialog.initial_focus(&input.read(cx).focus_handle(cx))

Then Root::render_dialog_layer can perform a one-shot focus handoff only if the dialog container still owns focus. This avoids defer and avoids focus_next, but it changes the behavior from automatic discovery to caller-provided initial focus.

For this issue, would you prefer that we keep the original automatic “first focus handle” behavior even if the implementation is larger, or would the explicit initial_focus API be acceptable as a smaller and safer change?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not a good solution.

Actually, we have already considered for this before, but still not get a good choice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not a good solution.

Actually, we have already considered for this before, but still not get a good choice.

Thank you for your confirmation.

After trying the two solutions mentioned above, I explored a new direction: using a "one-shot" on_focus_in listener on the dialog container, hoping to transfer focus during the lifecycle of the focus in GPUI—specifically after the newly rendered frame has been officially replaced and displayed.

However, under the current GPUI public API, this approach still does not work. Context::on_focus_in is activated via the defer mechanism; this means that new subscribers are inactive before the activation actually occurs. Since the open_dialog method completes both the listener registration and the focus operation on the dialog container within the same update cycle, the subscription is not active when the first "focus-in" event is triggered, causing the listener to miss the initial focus lifecycle.

In summary, based on the current API, I have not been able to find an ideal component-level solution that satisfies all of the following constraints:

  • Retain the original automatic initial focus behavior;
  • Avoid introducing delayed focus handling tasks;
  • Avoid maintaining an additional focus registry at the component level;
  • Avoid turning this issue into a demand for an explicit initial_focus API.

Given this, I will temporarily close this PR (Pull Request) until GPUI provides more robust low-level focus handling primitives.

Root::focus_first_descendant(&focus_handle, window, cx);
});
}

let window_paddings = crate::window_border::window_paddings(window);
let view_size = window.viewport_size()
Expand Down
119 changes: 118 additions & 1 deletion crates/ui/src/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down Expand Up @@ -58,6 +58,7 @@ pub(crate) struct ActiveDialog {
/// The previous focused handle before opening the Dialog.
previous_focused_handle: Option<WeakFocusHandle>,
builder: Rc<dyn Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static>,
focus_first_descendant: Rc<Cell<bool>>,
}

impl ActiveDialog {
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<FocusHandle> {
self.focused_input = None;
self.active_dialogs
Expand Down Expand Up @@ -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<Self>) -> 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));
});
}
}
Loading