From c1f67f1da53e63bbf64591c389cc9f0c561dc0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 12:38:02 -0400 Subject: [PATCH] =?UTF-8?q?T48f=20Docs=20=C2=B7=20Focus=20and=20keyboard:?= =?UTF-8?q?=20what=20init=20binds,=20the=20root=20scope,=20making=20a=20Ta?= =?UTF-8?q?b=20stop,=20buttons,=20overlays;=20the=20focus=20example=20comp?= =?UTF-8?q?iles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 4 +++ examples/docs/focus.rs | 72 +++++++++++++++++++++++++++++++++++++ frontend/src/docs/focus.md | 43 ++++++++++++++++++++++ frontend/src/docs/guides.ts | 2 +- 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 examples/docs/focus.rs create mode 100644 frontend/src/docs/focus.md diff --git a/Cargo.toml b/Cargo.toml index d0f06ee..031a2ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,3 +99,7 @@ path = "examples/docs/start.rs" [[example]] name = "docs_theme" path = "examples/docs/theme.rs" + +[[example]] +name = "docs_focus" +path = "examples/docs/focus.rs" diff --git a/examples/docs/focus.rs b/examples/docs/focus.rs new file mode 100644 index 0000000..be9de1f --- /dev/null +++ b/examples/docs/focus.rs @@ -0,0 +1,72 @@ +use ely_gpui_component::{ + Assets, + buttons::Button, + primitives::{FocusRing, FocusScope}, + theme::{ActiveTheme, Radius}, +}; +use gpui::{ + App, AppContext, Context, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, + StatefulInteractiveElement, Styled, Window, WindowOptions, div, transparent_black, +}; + +struct Panel { + root: FocusHandle, + tile: FocusHandle, + on: bool, +} + +impl Render for Panel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let this = cx.entity(); + let tile = div() + .id("tile") + .track_focus(&self.tile) + .p_4() + .border_1() + .border_color(transparent_black()) + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.surface) + .focus_ring(cx) + .child(if self.on { "On" } else { "Off" }) + .on_click(move |_, _, cx| { + this.update(cx, |panel, cx| { + panel.on = !panel.on; + cx.notify(); + }) + }); + FocusScope::new(&self.root) + .root() + .size_full() + .flex() + .flex_col() + .gap_4() + .p_8() + .bg(theme.colors.bg) + .text_color(theme.colors.fg) + .child(Button::new("first", "First")) + .child(tile) + .child(Button::new("last", "Last")) + } +} + +fn main() { + gpui_platform::application() + .with_assets(Assets) + .run(|cx: &mut App| { + ely_gpui_component::init(cx); + cx.open_window(WindowOptions::default(), |window, cx| { + cx.new(|cx| { + let root = cx.focus_handle(); + window.focus(&root, cx); + Panel { + root, + tile: cx.focus_handle().tab_stop(true), + on: false, + } + }) + }) + .expect("window failed to open"); + cx.activate(true); + }); +} diff --git a/frontend/src/docs/focus.md b/frontend/src/docs/focus.md new file mode 100644 index 0000000..63d7b08 --- /dev/null +++ b/frontend/src/docs/focus.md @@ -0,0 +1,43 @@ +# Focus and keyboard + +Every Ely control that takes a press is a Tab stop. Focus moves with Tab and Shift-Tab, shows as a 1px ring, and comes back where it was when an overlay closes. + +## 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. + +## The root scope + +Wrap each window's content in `FocusScope::new(&handle).root()` and focus `handle` when the window opens. + +| Behavior | Detail | +| --- | --- | +| Tab and Shift-Tab | Move to the next or previous Tab stop in the window. | +| A focused element leaves the tree | The root takes focus, and logs `focus: its element left the tree; the root takes it`. Keys keep reaching the window. | +| `.trap()` | Keeps Tab inside the scope. A trap with no Tab stop inside logs a warning and leaves focus where it is. | + +## Make a control a Tab stop + +Four parts make a box a Tab stop that shows Ely's ring: + +| Part | Why | +| --- | --- | +| `cx.focus_handle().tab_stop(true)` | GPUI tabs only to handles built with `tab_stop(true)`. Keep the handle in the view. | +| `.track_focus(&handle)` | Registers the handle on the box each frame. | +| `.border_1()` with a transparent color | Room for the ring, so the box does not shift when it shows. | +| `.focus_ring(cx)` | From `FocusRing`: the border takes the theme's `focus` color while focused. | + +With an `on_click`, GPUI presses the focused box when Enter or Space is released. + +```rust example=focus +``` + +Tab from the window moves through First, the tile and Last. Enter or Space on the tile turns it on and off. + +## Buttons + +A `Button` stays out of focus when clicked: it stops GPUI's focus on mouse down. Tab still reaches it, unless it is disabled; a loading button stays a Tab stop, so focus holds through the wait. A button whose words and action change with a mode keeps one handle through `Button::focus_handle(&handle)`, so focus holds across the change. + +## Overlays + +Ely's dialogs, popovers, menus, palettes, sheets and lightbox take focus when they open and hand it back to what held it before, on every way they close. While open, they take focus back when the focused element leaves the tree. An app writes no focus code for them. diff --git a/frontend/src/docs/guides.ts b/frontend/src/docs/guides.ts index 64c953b..49108ef 100644 --- a/frontend/src/docs/guides.ts +++ b/frontend/src/docs/guides.ts @@ -6,7 +6,7 @@ import { Marked, type Tokens } from "marked"; const at = (relative: string) => fileURLToPath(new URL(relative, import.meta.url)); /** The guides in reading order, by file name. */ -const ORDER = ["introduction", "installation", "getting-started", "theme"]; +const ORDER = ["introduction", "installation", "getting-started", "theme", "focus"]; export interface Guide { /** The path segment; the introduction has none. */