Kit · K02: Tab binds under FocusScope's ElyFocus context, so a deeper context's Tab wins

This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 12:39:20 -04:00
1 parent c61a5b22b0
commit a9b30c7eab
7 files changed
+58 -11

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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);
+4 -1
View File
@@ -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,
+3 -1
View File
@@ -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;
+44 -3
View File
@@ -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<Self>) -> 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);
}
+2 -1
View File
@@ -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.