From 99615e1a0cc2fe0c599d05007311fe32fe5f69f7 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 13:23:22 -0400 Subject: [PATCH] =?UTF-8?q?Kit=20=C2=B7=20K04:=20compat/kit=20runs=20Ely?= =?UTF-8?q?=20beside=20the=20newest=20GPUI=20Kit=20in=20one=20window;=20ch?= =?UTF-8?q?eck.sh=20holds=20it=20to=20Kit's=20newest=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 4 +- compat/kit/Cargo.toml | 16 ++++++ compat/kit/src/assets.rs | 26 +++++++++ compat/kit/src/desk.rs | 112 ++++++++++++++++++++++++++++++++++++ compat/kit/src/dialogs.rs | 117 ++++++++++++++++++++++++++++++++++++++ compat/kit/src/focus.rs | 53 +++++++++++++++++ compat/kit/src/lib.rs | 10 ++++ scripts/check.sh | 9 +++ tasks/kit.md | 10 ++-- 10 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 compat/kit/Cargo.toml create mode 100644 compat/kit/src/assets.rs create mode 100644 compat/kit/src/desk.rs create mode 100644 compat/kit/src/dialogs.rs create mode 100644 compat/kit/src/focus.rs create mode 100644 compat/kit/src/lib.rs diff --git a/.gitignore b/.gitignore index 87939b3..4781840 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ /frontend/dist/ .DS_Store +/compat/kit/Cargo.lock diff --git a/AGENTS.md b/AGENTS.md index da0777c..bdc37af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - In `frontend/`: `ELY_GALLERY_ASSETS=/gallery/assets/ pnpm gallery` builds the gallery into `public/gallery`, `pnpm dev` serves the site, `pnpm build` checks types and writes `dist`, `pnpm deploy` runs `wrangler deploy`, `pnpm showcase` writes the showcase catalog into `src/showcase.json` and `public/showcase` (both ignored): it downloads the commit `showcase.lock` pins, installs that commit's packages and runs its own validator, so a bad catalog fails the build; `dev` and `build` run it first, and `ELY_SHOWCASE=` reads a local catalog instead. `pnpm test` runs the iso engine's, the mark's and the showcase list's tests, and `pnpm local` builds the gallery for localhost, builds the site and serves both on http://localhost:4173/ (wasm-bindgen and wasm-opt on PATH, as `scripts/web.sh` says). `node scripts/og.mjs`, with `pnpm preview` up, renders `public/og.png` from the hero and the header's mark. `node scripts/favicon.mjs` writes `public/favicon.svg`, which follows the system's mode, `public/apple-touch-icon.png` and the repo's `logo.png` (2048px, transparent) from the mark, in the colors `styles.css` sets. The site reads `examples/gallery/stories.json` for its chapters, stories and search. - `python3 scripts/stories.py` writes `examples/gallery/stories.json`, every page's sections in drawing order with their components, for the site: the names in a title, and in its `SHOWN` table what a story draws beyond them. It fails on a section opened inside another and on a ticked entry with a home of its own that no story names. `scripts/check.sh` refuses a stale one. - `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules and lints the gallery for wasm32. -- `rm -rf target/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk. +- `cargo test --manifest-path compat/kit/Cargo.toml --target-dir target/kit` runs Ely beside GPUI Kit in one window: Tab through both, each library's dialog over the other's fields, one asset source. `scripts/check.sh` first resolves it afresh and holds `gpui-kit` to the newest release `cargo search` names, so a Kit release that moves its gpui pin fails resolution there, naming `gpui-pre`; Ely's pin then follows. Its lockfile stays out of git. +- `rm -rf target/debug/incremental target/kit/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk. - `scripts/icons.sh ...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`. - `python3 scripts/countries.py ` writes the bundled world; `python3 scripts/tiles.py ` writes the gallery's map tiles. @@ -38,6 +39,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `src//`: one folder per chapter of `gpui-components.md`. - `src/theme`: palettes, tokens, `ActiveTheme`. `src/motion`: durations, easings, spring. - `examples/gallery/pages/.rs`: one page per chapter, in PRD order. +- `compat/kit`: a crate of its own, with its own workspace, that tests Ely beside GPUI Kit. - `TASKS.md` and `tasks/`: progress. Every PRD entry has a line and a tag; `tasks/reviews.md` keeps every review round. ## Rules diff --git a/compat/kit/Cargo.toml b/compat/kit/Cargo.toml new file mode 100644 index 0000000..f681a44 --- /dev/null +++ b/compat/kit/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ely-kit-compat" +version = "0.0.0" +edition = "2024" +rust-version = "1.95" +publish = false +description = "Ely and the newest GPUI Kit in one app and one window." + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +ely-gpui-component = { path = "../..", features = ["test-support"] } +gpui-kit = { version = ">=0.7.1", features = ["test-support"] } + +[workspace] diff --git a/compat/kit/src/assets.rs b/compat/kit/src/assets.rs new file mode 100644 index 0000000..b09e367 --- /dev/null +++ b/compat/kit/src/assets.rs @@ -0,0 +1,26 @@ +use ely_gpui_component::Assets; +use gpui_kit::AssetSource; + +#[test] +fn one_source_serves_both_libraries() { + let kit = gpui_kit::assets::Assets; + let both = Assets::before(gpui_kit::assets::Assets); + let ely = Assets.list("").unwrap(); + assert!(ely.len() > 300, "Ely bundles {} files", ely.len()); + for path in &ely { + let served = both.load(path).unwrap().expect("Ely's file loads"); + assert_eq!( + served, + Assets.load(path).unwrap().unwrap(), + "{path} is Ely's" + ); + } + let icons = kit.list("icons/").unwrap(); + assert!(!icons.is_empty(), "Kit lists its icons"); + for path in &icons { + let served = both.load(path).unwrap().expect("Kit's icon loads"); + if Assets.load(path).unwrap().is_none() { + assert_eq!(served, kit.load(path).unwrap().unwrap(), "{path} is Kit's"); + } + } +} diff --git a/compat/kit/src/desk.rs b/compat/kit/src/desk.rs new file mode 100644 index 0000000..b645b5c --- /dev/null +++ b/compat/kit/src/desk.rs @@ -0,0 +1,112 @@ +use ely_gpui_component::{buttons::Button, overlays::Dialog, primitives::FocusScope}; +use gpui_kit::{ + AppContext, Bounds, Context, Entity, FocusHandle, IntoElement, KeyUpEvent, Keystroke, + ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, WindowBounds, + WindowOptions, + component::{ + WindowExt, + input::{Editor, EditorState, Input, InputState}, + }, + div, point, + prelude::*, + px, size, +}; + +pub const CODE: &str = "fn main() {}"; + +/// Ely's root scope inside Kit's `Root`. +pub struct Desk { + pub root: FocusHandle, + pub open: FocusHandle, + pub line: Entity, + pub code: Entity, + pub ely_dialog: bool, +} + +impl Render for Desk { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + FocusScope::new(&self.root) + .root() + .size_full() + .p_4() + .flex() + .flex_col() + .gap_4() + .child( + div().debug_selector(|| "ely-open".into()).child( + Button::new("ely-open", "Open") + .focus_handle(&self.open) + .on_click(|_, window, cx| { + window.open_dialog(cx, |dialog, _, _| dialog.title("Kit dialog")); + }), + ), + ) + .child(Input::new(&self.line)) + .child(div().h(px(120.)).child(Editor::new(&self.code).size_full())) + .when(self.ely_dialog, |desk| { + desk.child( + Dialog::new("ely-dialog", "Ely dialog", move |_, cx| { + view.update(cx, |desk, cx| { + desk.ely_dialog = false; + cx.notify(); + }) + }) + .action(|_| Button::new("ely-first", "First")) + .action(|_| Button::new("ely-second", "Second")), + ) + }) + } +} + +/// Starts both libraries in order; Kit opens the window. +pub fn open(cx: &mut TestAppContext, ely_last: bool) -> (Entity, VisualTestContext) { + cx.update(|cx| { + if ely_last { + gpui_kit::init(cx); + ely_gpui_component::init_for_tests(cx); + } else { + ely_gpui_component::init_for_tests(cx); + gpui_kit::init(cx); + } + }); + let (window, desk) = cx.update(|cx| { + let options = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(800.), px(600.)), + })), + ..Default::default() + }; + gpui_kit::open_window(options, cx, |window, cx| { + cx.new(|cx| Desk { + root: cx.focus_handle(), + open: cx.focus_handle().tab_stop(true), + line: cx.new(|cx| InputState::new(window, cx)), + code: cx.new(|cx| { + EditorState::new(window, cx) + .language("rust") + .default_value(CODE) + }), + ely_dialog: false, + }) + }) + .expect("Kit opens the window") + }); + let cx = VisualTestContext::from_window(window, cx); + cx.run_until_parked(); + (desk, cx) +} + +/// Whether the handle holds focus. +pub fn focused(cx: &mut VisualTestContext, handle: &FocusHandle) -> bool { + cx.update(|window, _| handle.is_focused(window)) +} + +/// Key down and up; gpui presses on the release. +pub fn press(cx: &mut VisualTestContext, key: &str) { + cx.simulate_keystrokes(key); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); +} diff --git a/compat/kit/src/dialogs.rs b/compat/kit/src/dialogs.rs new file mode 100644 index 0000000..658632f --- /dev/null +++ b/compat/kit/src/dialogs.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use gpui_kit::{ + FocusHandle, Focusable, Modifiers, TestAppContext, VisualTestContext, test::TestWindowExt, +}; + +use crate::desk::{focused, open, press}; + +/// Draws until `done` holds; fails after a second. +fn settle(cx: &mut VisualTestContext, done: impl Fn(&mut VisualTestContext) -> bool) { + for _ in 0..50 { + cx.run_until_parked(); + cx.update(|window, cx| window.render_frame(cx)); + if done(cx) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("the window did not settle"); +} + +fn kit_dialog_open(cx: &mut VisualTestContext) -> bool { + cx.update(|window, _| window.try_find("dialog").is_some()) +} + +/// Opens Kit's dialog, tabs inside it, closes it. +fn kit_dialog_round_trip( + cx: &mut TestAppContext, + open_with: impl FnOnce(&mut VisualTestContext, &Handles), + back_to: impl Fn(&Handles) -> FocusHandle, +) { + let (desk, mut cx) = open(cx, true); + let handles = cx.update(|_, cx| { + let desk = desk.read(cx); + Handles { + button: desk.open.clone(), + line: desk.line.read(cx).focus_handle(cx), + code: desk.code.read(cx).focus_handle(cx), + } + }); + open_with(&mut cx, &handles); + settle(&mut cx, kit_dialog_open); + for _ in 0..3 { + cx.simulate_keystrokes("tab"); + for handle in [&handles.button, &handles.line, &handles.code] { + assert!(!focused(&mut cx, handle), "Tab stays in Kit's dialog"); + } + } + cx.simulate_keystrokes("escape"); + settle(&mut cx, |cx| !kit_dialog_open(cx)); + assert!( + focused(&mut cx, &back_to(&handles)), + "focus returns where it was" + ); +} + +struct Handles { + button: FocusHandle, + line: FocusHandle, + code: FocusHandle, +} + +#[gpui_kit::test] +fn enter_on_an_ely_button_opens_a_kit_dialog_that_hands_focus_back(cx: &mut TestAppContext) { + kit_dialog_round_trip( + cx, + |cx, handles| { + cx.update(|window, cx| window.focus(&handles.button, cx)); + press(cx, "enter"); + }, + |handles| handles.button.clone(), + ); +} + +#[gpui_kit::test] +fn a_press_on_an_ely_button_leaves_focus_for_the_kit_dialog_to_return(cx: &mut TestAppContext) { + kit_dialog_round_trip( + cx, + |cx, handles| { + cx.update(|window, cx| window.focus(&handles.line, cx)); + let bounds = cx.debug_bounds("ely-open").expect("Ely's button drew"); + cx.simulate_click(bounds.center(), Modifiers::none()); + }, + |handles| handles.line.clone(), + ); +} + +#[gpui_kit::test] +fn an_ely_dialog_over_kit_fields_traps_tab_and_hands_focus_back(cx: &mut TestAppContext) { + let (desk, mut cx) = open(cx, true); + let (button, line, code) = cx.update(|_, cx| { + let desk = desk.read(cx); + ( + desk.open.clone(), + desk.line.read(cx).focus_handle(cx), + desk.code.read(cx).focus_handle(cx), + ) + }); + cx.update(|window, cx| window.focus(&line, cx)); + desk.update(&mut cx, |desk, cx| { + desk.ely_dialog = true; + cx.notify(); + }); + settle(&mut cx, |cx| !focused(cx, &line)); + for _ in 0..3 { + cx.simulate_keystrokes("tab"); + for handle in [&button, &line, &code] { + assert!(!focused(&mut cx, handle), "Tab stays in Ely's dialog"); + } + } + cx.simulate_keystrokes("escape"); + settle(&mut cx, |cx| focused(cx, &line)); + assert!( + !desk.read_with(&cx, |desk, _| desk.ely_dialog), + "Escape closed it" + ); +} diff --git a/compat/kit/src/focus.rs b/compat/kit/src/focus.rs new file mode 100644 index 0000000..c49bb05 --- /dev/null +++ b/compat/kit/src/focus.rs @@ -0,0 +1,53 @@ +use gpui_kit::{Focusable, TestAppContext}; + +use crate::desk::{CODE, focused, open}; + +fn tab_runs_through_both(cx: &mut TestAppContext, ely_last: bool) { + let (desk, mut cx) = open(cx, ely_last); + let (open_button, line, code) = cx.update(|_, cx| { + let desk = desk.read(cx); + (desk.open.clone(), desk.line.clone(), desk.code.clone()) + }); + cx.update(|window, cx| window.focus(&open_button, cx)); + cx.simulate_keystrokes("tab"); + let line_focus = cx.update(|_, cx| line.read(cx).focus_handle(cx)); + assert!( + focused(&mut cx, &line_focus), + "Tab leaves Ely's button for Kit's field" + ); + cx.simulate_keystrokes("tab"); + let code_focus = cx.update(|_, cx| code.read(cx).focus_handle(cx)); + assert!( + focused(&mut cx, &code_focus), + "Tab reaches Kit's code editor" + ); + cx.simulate_keystrokes("tab"); + assert!( + focused(&mut cx, &code_focus), + "Tab stays in the code editor" + ); + let text = cx.update(|_, cx| code.read(cx).value().to_string()); + assert!( + text.len() > CODE.len() && text.trim() == CODE, + "Tab indents: {text:?}" + ); + cx.simulate_keystrokes("shift-tab"); + let text = cx.update(|_, cx| code.read(cx).value().to_string()); + assert_eq!(text, CODE, "Shift-Tab outdents"); + cx.update(|window, cx| window.focus(&line_focus, cx)); + cx.simulate_keystrokes("shift-tab"); + assert!( + focused(&mut cx, &open_button), + "Shift-Tab returns to Ely's button" + ); +} + +#[gpui_kit::test] +fn tab_runs_through_both_with_ely_started_last(cx: &mut TestAppContext) { + tab_runs_through_both(cx, true); +} + +#[gpui_kit::test] +fn tab_runs_through_both_with_kit_started_last(cx: &mut TestAppContext) { + tab_runs_through_both(cx, false); +} diff --git a/compat/kit/src/lib.rs b/compat/kit/src/lib.rs new file mode 100644 index 0000000..ffc7ddd --- /dev/null +++ b/compat/kit/src/lib.rs @@ -0,0 +1,10 @@ +//! Ely and GPUI Kit in one app and one window. + +#[cfg(test)] +mod assets; +#[cfg(test)] +mod desk; +#[cfg(test)] +mod dialogs; +#[cfg(test)] +mod focus; diff --git a/scripts/check.sh b/scripts/check.sh index 5677722..092386c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -4,6 +4,15 @@ set -eu cargo fmt --check cargo clippy --all-targets --features test-support -- -D warnings cargo test --lib --features test-support --quiet +# Ely beside the newest GPUI Kit: a release that moves Kit's gpui pin fails to resolve here. +kit="--manifest-path compat/kit/Cargo.toml" +newest=$(cargo search gpui-kit --limit 1 | sed -n 's/^gpui-kit = "\([^"]*\)".*/\1/p') +[ -n "$newest" ] || { echo "crates.io named no gpui-kit release" >&2; exit 1; } +cargo generate-lockfile $kit +cargo update $kit -p gpui-kit --precise "$newest" +cargo fmt $kit --check +cargo clippy $kit --all-targets --target-dir target/kit -- -D warnings +cargo test $kit --target-dir target/kit --quiet rustup target list --installed | grep -qx wasm32-unknown-unknown || { echo "the web lint needs wasm32: rustup target add wasm32-unknown-unknown" >&2 exit 1 diff --git a/tasks/kit.md b/tasks/kit.md index 481df73..7e57c2f 100644 --- a/tasks/kit.md +++ b/tasks/kit.md @@ -7,7 +7,7 @@ Started 2026-10-07. Ely and Longbridge's GPUI Kit (`gpui-kit` on crates.io) shar - Ely binds Tab with no context, which gpui ranks at the deepest level. In a Kit code editor inside an Ely scope, Tab ties with Kit's indent, and the later `init` wins. - An app has one asset source. Of Ely's 306 icons, Kit's source holds 290 paths, 289 with the same drawing; `credit-card` differs by one stroke (Lucide 1.48 against 1.43). Ely's `init` checked one icon, so Kit's source alone passed and Ely's other files went missing later. - Kit's `init` installs its inspector in debug builds; Ely's `install_inspector` replaces it when called after. -- Kit can run on `gpui-fast` instead; Ely could not follow. +- Kit can also run on `gpui-fast`; at the owner's word, Ely follows Kit's default gpui alone. Rules: the work order follows dependencies, since the PRD has no chapter for it. Each item passes `scripts/check.sh` and a codex review (`gpt-6.1-sol`, effort `high`) in a herdr split, five rounds at most, then commits and pushes. @@ -16,10 +16,9 @@ Rules: the work order follows dependencies, since the PRD has no chapter for it. - [x] K01 gpui from `gpui-pre =0.3.8`, Kit's pin: crate, gallery, web build, docs, CI - [x] K02 Tab and Shift-Tab bound under `FocusScope`'s own context, so a deeper context's Tab wins whatever the order of `init` - [x] 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 -- [ ] K06 Docs: README, the site's guide, AGENTS.md -- [ ] K07 Acceptance table +- [x] K04 `compat/kit`: a crate on the newest Kit that runs both libraries in one window, in `scripts/check.sh` and CI +- [ ] K05 Docs: README, the site's guide, AGENTS.md +- [ ] K06 Acceptance table ## Log @@ -27,3 +26,4 @@ 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. - K03: `init` loads every file Ely bundles (318, Plex among them) through the app's source and fails naming the first it cannot; an icons-only source like Kit's passed the old one-icon check and fails now. `Assets::before(other)` serves Ely's paths first and the rest from `other`, its errors passing through. Docs and AGENTS.md say so. Review: 1 round. +- K04: `compat/kit`, its own workspace, opens Ely's root scope inside Kit's `Root` and tests Tab through both libraries in each `init` order (red with K02 undone), Kit's dialog from an Ely button by key and by pointer, Ely's dialog over Kit's fields, and one asset source for both. `check.sh` resolves it afresh and holds `gpui-kit` to the newest release, so a Kit release on another gpui fails resolution naming `gpui-pre` (shown with Kit 0.7.0). Review: 2 rounds.