diff --git a/AGENTS.md b/AGENTS.md index 2370f6c..3511792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,3 +260,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 2026-09-29: the web gallery runs on one thread, since a static site sends no cross-origin isolation, and draws through WebGL2 where WebGPU is missing. Its pictures are served beside the wasm, not embedded: the wasm stays under Cloudflare's 25 MiB, and a story fetches only its own. `wasm-opt -Oz` takes the wasm from 23.0 to 20.0 MB; gzip and brotli stay near 7.9 and 5.5 MB. - 2026-10-04: the Showcase follows gpui-kit.com/apps: apps live in their own public repo and arrive by pull request. The site pins a commit of it in `frontend/showcase.lock` and moves only when someone bumps the pin, so stars refresh on the site with each bump. READMEs take Markdown and PNG pictures alone, no HTML or video, so the site needs no sanitizer. The catalog started empty. - 2026-10-04: chapter 44, Rendering, holds `RenderSurface` alone. gpui at 1a28cff paints `RenderImage`s everywhere, `CVPixelBuffer`s (NV12) on macOS's Metal renderer alone, and shares no device or shader hook, so shader, camera and GPU views are the host's, feeding the surface, and zero-copy or Metal, OpenGL, Vulkan and Direct3D surfaces are blocked (`tasks/ch44.md`). CI tests the crate and builds the gallery on Linux, Windows and macOS, arm64 and x86_64. +- 2026-10-05: an overlay its owner removes without calling `Close` still hands focus back: `take_focus` records each holder and what it took focus from, `give_back` forgets it, and when the focused element leaves the tree the root scope returns focus to the newest holder that is gone, else takes it. Before, a dialog whose action set its owner's state to closed left focus on the root, and keys reached nothing until a click. diff --git a/src/overlays/tests/mod.rs b/src/overlays/tests/mod.rs index 8f60f71..b24665e 100644 --- a/src/overlays/tests/mod.rs +++ b/src/overlays/tests/mod.rs @@ -136,6 +136,7 @@ impl Render for Stage { .into_any_element(), }); FocusScope::new(&self.root) + .root() .size_full() .child(popover) .child(div().h(px(52.0))) @@ -260,6 +261,19 @@ fn escape_closes_a_dialog_and_hands_focus_back(cx: &mut TestAppContext) { assert!(cx.update(|window, _| before.is_focused(window))); } +#[gpui::test] +fn a_dialog_its_owner_drops_hands_focus_back(cx: &mut TestAppContext) { + let (view, cx) = stage(cx); + show(&view, Open::Plain, cx); + assert!(!before_focused(&view, cx), "the dialog holds focus"); + view.update(cx, |stage, cx| { + stage.open = None; + cx.notify(); + }); + settle(cx); + assert!(before_focused(&view, cx), "focus went back"); +} + #[gpui::test] fn the_scrim_closes_a_dialog_but_not_an_alert(cx: &mut TestAppContext) { let (view, cx) = stage(cx); diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs index e4afd0e..86c09af 100644 --- a/src/primitives/focus.rs +++ b/src/primitives/focus.rs @@ -1,6 +1,7 @@ use gpui::{ - AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, - ParentElement, RenderOnce, StyleRefinement, Styled, Subscription, Window, actions, div, + AnyElement, AnyWindowHandle, App, Div, ElementId, Entity, FocusHandle, Global, + InteractiveElement, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, + Subscription, WeakEntity, Window, actions, div, }; use crate::theme::ActiveTheme; @@ -71,8 +72,16 @@ impl RenderOnce for FocusScope { let handle = self.handle.clone(); window.use_keyed_state("focus-root", cx, |window, cx| Root { _lost: cx.on_focus_lost(window, move |_: &mut Root, window, cx| { - log::info!("focus: its element left the tree; the root takes it"); - window.focus(&handle, cx); + match dropped_overlay(window, cx) { + Some(previous) => { + log::info!("focus: an overlay left unclosed; focus goes back"); + window.focus(&previous, cx); + } + None => { + log::info!("focus: its element left the tree; the root takes it"); + window.focus(&handle, cx); + } + } }), }); } @@ -117,6 +126,26 @@ fn step(scope: &FocusHandle, trap: bool, forward: bool, window: &mut Window, cx: } } +/// Open overlays holding focus, oldest first, with what they took it from. +#[derive(Default)] +struct Holders(Vec<(WeakEntity, AnyWindowHandle, Option)>); + +impl Global for Holders {} + +/// Forgets overlays gone without closing; the newest one's prior focus. +fn dropped_overlay(window: &Window, cx: &mut App) -> Option { + let here = window.window_handle(); + let mut back = None; + cx.default_global::().0.retain(|(holder, at, previous)| { + let gone = holder.upgrade().is_none(); + if gone && *at == here { + back = previous.clone(); + } + !gone + }); + back +} + /// Focus an overlay holds while open, and what it held before. pub(crate) struct Takeover { pub focus: FocusHandle, @@ -147,6 +176,8 @@ pub(crate) fn take_focus( if !state.read(cx).taken { let previous = window.focused(cx); window.focus(&state.read(cx).focus.clone(), cx); + let holder = (state.downgrade(), window.window_handle(), previous.clone()); + cx.default_global::().0.push(holder); state.update(cx, |takeover, _| { takeover.previous = previous; takeover.taken = true; @@ -157,6 +188,8 @@ pub(crate) fn take_focus( /// Returns focus to whatever held it before the overlay opened. pub(crate) fn give_back(state: &Entity, window: &mut Window, cx: &mut App) { + let closed = state.downgrade(); + cx.default_global::().0.retain(|(holder, ..)| *holder != closed); let previous = state.update(cx, |takeover, _| { takeover.returned = true; takeover.previous.clone()