From a9b30c7eab482f122238f472c288a9806416a607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 12:39:20 -0400 Subject: [PATCH] =?UTF-8?q?Kit=20=C2=B7=20K02:=20Tab=20binds=20under=20Foc?= =?UTF-8?q?usScope's=20ElyFocus=20context,=20so=20a=20deeper=20context's?= =?UTF-8?q?=20Tab=20wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- frontend/src/docs/focus.md | 2 +- src/lib.rs | 6 ++--- src/primitives/focus.rs | 5 +++- src/primitives/mod.rs | 4 +++- src/primitives/tests.rs | 47 +++++++++++++++++++++++++++++++++++--- tasks/kit.md | 3 ++- 7 files changed, 58 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1142613..8460707 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `svg()` paints only with its own `text_color`. `Icon` always sets one. - Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them. - The focus ring is a 1px focus-colored border (`FocusRing`). gpui paints shadows under the element, so a shadow ring floods transparent elements. Focusable elements keep a 1px border, transparent at rest. -- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` with `.root()` and focus its handle. A root scope takes focus when the focused element leaves the tree, as a toast's or an alert's button does. +- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev` under `ElyFocus`, the key context every `FocusScope` sets. gpui ranks a binding with no context at the deepest level, so a host's Tab in a deeper context, GPUI Kit's code editor among them, lost or won by which `init` ran last; under a context, the deeper one wins. Wrap the root in `FocusScope` with `.root()` and focus its handle. A root scope takes focus when the focused element leaves the tree, as a toast's or an alert's button does. - The gallery scripts mouse input by posting `NSEvent`s to its own queue, so a press takes AppKit's path and reaches a native view over the window (`misc::WebView`). `Window::dispatch_event` reaches only gpui's listeners. gpui's normal window takes mouse moves from a tracking area, which only a real cursor feeds, so the harness sets the window's `acceptsMouseMovedEvents` for posted moves. - gpui starts a drag on the first move past its threshold, and only the moves after it reach `on_drag_move`. gpui 0.2.2's mac window replayed a held drag every 16 ms; gpui_macos replays only while `pressedMouseButtons` shows a real button, which posted events never set. So `Step::DragTo` posts each move twice, as the replay did: the second post of the first step reaches `on_drag_move`, where a component that anchors on its first drag move sets its anchor (Scrollbar, Knob, Split). - Every shot of a gpui window, a page's or a script's, waits three of its frames through `on_next_frame`: gpui presents without waiting on the GPU, and a heavy page's draw outlasts a fixed pause, so a shot could show the frame before the last step. A page's pauses stay, for work off the main thread such as pictures decoding. A covered window draws no frames, so its capture fails after 5 s instead of shooting a stale frame. diff --git a/frontend/src/docs/focus.md b/frontend/src/docs/focus.md index 0dc5bc6..01a6ead 100644 --- a/frontend/src/docs/focus.md +++ b/frontend/src/docs/focus.md @@ -4,7 +4,7 @@ Every Ely control that takes a press is a Tab stop. Focus moves with Tab and Shi ## What `init` binds -`init` binds `tab` to the `FocusNext` action and `shift-tab` to `FocusPrev`, with no key context, so they reach every window. A `FocusScope` turns the two actions into focus moves. +`init` binds `tab` to the `FocusNext` action and `shift-tab` to `FocusPrev`, under `ElyFocus`, the key context every `FocusScope` sets. A `FocusScope` turns the two actions into focus moves. A context nested deeper keeps its own Tab, as a code editor that indents does, whichever library's `init` ran last. ## The root scope diff --git a/src/lib.rs b/src/lib.rs index 68d7ee4..63ecb13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,7 +50,7 @@ pub use assets::Assets; use anyhow::Context as _; use gpui::{App, KeyBinding}; -use crate::primitives::{FocusNext, FocusPrev, IconName}; +use crate::primitives::{FOCUS_CONTEXT, FocusNext, FocusPrev, IconName}; /// Loads fonts and the theme, binds Tab and text keys. Call once, first. pub fn init(cx: &mut App) -> anyhow::Result<()> { @@ -76,8 +76,8 @@ pub fn init_for_tests(cx: &mut App) { fn setup(cx: &mut App) { theme::Theme::init(cx); cx.bind_keys([ - KeyBinding::new("tab", FocusNext, None), - KeyBinding::new("shift-tab", FocusPrev, None), + KeyBinding::new("tab", FocusNext, Some(FOCUS_CONTEXT)), + KeyBinding::new("shift-tab", FocusPrev, Some(FOCUS_CONTEXT)), ]); forms::bind_keys(cx); editor::bind_keys(cx); diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs index 2d2b0d0..42227f3 100644 --- a/src/primitives/focus.rs +++ b/src/primitives/focus.rs @@ -8,6 +8,9 @@ use crate::theme::ActiveTheme; actions!(ely, [FocusNext, FocusPrev]); +/// The key context of every `FocusScope`; Tab and Shift-Tab bind under it. +pub(crate) const CONTEXT: &str = "ElyFocus"; + /// Focus-colored border while focused. Give the element a 1px border. pub trait FocusRing: InteractiveElement + Sized { fn focus_ring(self, cx: &App) -> Self { @@ -30,7 +33,7 @@ pub struct FocusScope { impl FocusScope { pub fn new(handle: &FocusHandle) -> Self { Self { - base: div().track_focus(handle), + base: div().key_context(CONTEXT).track_focus(handle), handle: handle.clone(), trap: false, root: false, diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index efba801..0e7ad65 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -20,8 +20,10 @@ pub use disclosure::Disclosure; pub use divider::Divider; pub use files::IconTheme; pub(crate) use files::{file_icon, icon_theme}; +pub(crate) use focus::{ + CONTEXT as FOCUS_CONTEXT, Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus, +}; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; -pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus}; pub use ghost::DragGhost; pub use icon::{Icon, IconName}; pub use image::Image; diff --git a/src/primitives/tests.rs b/src/primitives/tests.rs index d50b0b5..47d7566 100644 --- a/src/primitives/tests.rs +++ b/src/primitives/tests.rs @@ -1,11 +1,11 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - App, Context, Entity, FocusHandle, IntoElement, Modifiers, ParentElement, Render, RenderOnce, - Styled, TestAppContext, Window, anchored, div, point, prelude::*, px, + App, Context, Entity, FocusHandle, IntoElement, KeyBinding, Modifiers, ParentElement, Render, + RenderOnce, Styled, TestAppContext, Window, actions, anchored, div, point, prelude::*, px, }; -use super::raise; +use super::{FocusScope, raise}; use crate::theme::Theme; /// A raised square holding a raised button, under a cover drawn after it in the same layer. @@ -130,3 +130,44 @@ fn a_raise_keeps_its_state_while_another_comes_before_it(cx: &mut TestAppContext "the box kept focus" ); } + +actions!(host, [Indent]); + +/// A root scope around a field whose own context binds Tab. +struct Field { + root: FocusHandle, + field: FocusHandle, + indents: usize, +} + +impl Render for Field { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + FocusScope::new(&self.root).root().size_full().child( + div() + .key_context("HostField") + .track_focus(&self.field) + .size(px(100.0)) + .on_action(move |_: &Indent, _, cx| view.update(cx, |field, _| field.indents += 1)), + ) + } +} + +#[gpui::test] +fn a_deeper_context_keeps_its_own_tab_whatever_init_ran_last(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("tab", Indent, Some("HostField"))]); + crate::init_for_tests(cx); + }); + let (view, cx) = cx.add_window_view(|_, cx| Field { + root: cx.focus_handle(), + field: cx.focus_handle(), + indents: 0, + }); + cx.update(|window, cx| { + let field = view.read(cx).field.clone(); + window.focus(&field, cx); + }); + cx.simulate_keystrokes("tab"); + assert_eq!(view.read_with(cx, |field, _| field.indents), 1); +} diff --git a/tasks/kit.md b/tasks/kit.md index 2a160e0..f43cf3f 100644 --- a/tasks/kit.md +++ b/tasks/kit.md @@ -14,7 +14,7 @@ Rules: the work order follows dependencies, since the PRD has no chapter for it. ## Items - [x] K01 gpui from `gpui-pre =0.3.8`, Kit's pin: crate, gallery, web build, docs, CI -- [ ] K02 Tab and Shift-Tab bound under `FocusScope`'s own context, so a deeper context's Tab wins whatever the order of `init` +- [x] K02 Tab and Shift-Tab bound under `FocusScope`'s own context, so a deeper context's Tab wins whatever the order of `init` - [ ] K03 Assets: `init` checks every embedded file through the app's source; one source serves Ely's files and another's - [ ] K04 `compat/kit`: a crate on the newest Kit that runs both libraries in one window, in `scripts/check.sh` and CI - [ ] K05 `gpui-fast`: Ely builds and tests on Kit's other engine, or the item records why not @@ -25,3 +25,4 @@ Rules: the work order follows dependencies, since the PRD has no chapter for it. One line per item: what changed, what stayed and why, review rounds. - K01: gpui, gpui_platform and gpui_web come from `gpui-pre` `=0.3.8`; the lock holds no git source and one copy of each. `gpui-pre` split leak detection from `test-support`, so this crate's `test-support` turns on `gpui/leak-detection`, and a test that leaks on purpose proves it. The check, the wasm check, the web build (20.0 MB), a headless Chromium shot and the site build pass. README and the install guide name the snapshot. Review: 2 rounds. +- K02: `FocusScope` sets the key context `ElyFocus`, and `init` binds Tab and Shift-Tab under it. A host's Tab in a deeper context wins whatever the order of `init` (a test binds the host's first, the order that lost: 0 indents before, 1 after); Ely's terminal, editor, traps and the inspector's scope behave as before. Review: 1 round.