diff --git a/.clawpatch/config.json b/.clawpatch/config.json new file mode 100644 index 00000000..3da68a3b --- /dev/null +++ b/.clawpatch/config.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "stateDir": ".clawpatch", + "include": [ + "**/*" + ], + "exclude": [ + "node_modules/**", + "dist/**", + "build/**", + "target/**", + ".build/**", + ".git/**", + ".clawpatch/**" + ], + "provider": { + "name": "codex", + "model": null, + "reasoningEffort": null + }, + "commands": { + "typecheck": "cargo check --workspace --all-targets", + "lint": null, + "format": "cargo fmt --all --check", + "test": "cargo test --workspace" + }, + "review": { + "maxContextFiles": 24, + "maxOwnedFiles": 12, + "maxFindingsPerFeature": 10, + "minConfidenceToFix": "medium" + }, + "git": { + "requireCleanWorktreeForFix": true, + "commit": false, + "openPr": false + } +} diff --git a/.clawpatch/features/feat_cli-command_3b0436e283.json b/.clawpatch/features/feat_cli-command_3b0436e283.json new file mode 100644 index 00000000..88f78781 --- /dev/null +++ b/.clawpatch/features/feat_cli-command_3b0436e283.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "featureId": "feat_cli-command_3b0436e283", + "title": "Rust command perf", + "summary": "Rust executable command at tooling/perf/src/main.rs.", + "kind": "cli-command", + "source": "rust-command", + "confidence": "high", + "entrypoints": [ + { + "path": "tooling/perf/src/main.rs", + "symbol": "main", + "route": null, + "command": "perf" + } + ], + "ownedFiles": [ + { + "path": "tooling/perf/src/main.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "cli" + ], + "trustBoundaries": [ + "user-input", + "filesystem", + "process-exec", + "network" + ], + "status": "fixed", + "lock": null, + "findingIds": [ + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d" + ], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "1 finding(s); prompt=31227 bytes; approxTokens=7807; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:53.516Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:55:04.397Z" +} diff --git a/.clawpatch/features/feat_config_062b3c2b38.json b/.clawpatch/features/feat_config_062b3c2b38.json new file mode 100644 index 00000000..0b2d3e43 --- /dev/null +++ b/.clawpatch/features/feat_config_062b3c2b38.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_062b3c2b38", + "title": "Project config rust-toolchain.toml", + "summary": "Build, release, or quality configuration in rust-toolchain.toml.", + "kind": "config", + "source": "shared-infra-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": "rust-toolchain.toml", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "rust-toolchain.toml", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config" + ], + "trustBoundaries": [ + "process-exec", + "filesystem" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6459 bytes; approxTokens=1615; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:41.521Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:41.521Z" +} diff --git a/.clawpatch/features/feat_config_7afc533f22.json b/.clawpatch/features/feat_config_7afc533f22.json new file mode 100644 index 00000000..d53236f9 --- /dev/null +++ b/.clawpatch/features/feat_config_7afc533f22.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_7afc533f22", + "title": "Project config Cargo.toml", + "summary": "Build, release, or quality configuration in Cargo.toml.", + "kind": "config", + "source": "shared-infra-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": "Cargo.toml", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "Cargo.toml", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config" + ], + "trustBoundaries": [ + "process-exec", + "filesystem" + ], + "status": "fixed", + "lock": null, + "findingIds": [ + "fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b" + ], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "1 finding(s); prompt=16972 bytes; approxTokens=4243; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:53.565Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:55:43.285Z" +} diff --git a/.clawpatch/features/feat_config_902602e3e1.json b/.clawpatch/features/feat_config_902602e3e1.json new file mode 100644 index 00000000..939773ca --- /dev/null +++ b/.clawpatch/features/feat_config_902602e3e1.json @@ -0,0 +1,52 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_902602e3e1", + "title": "Shell/workflow config ci.yml", + "summary": "Shell or workflow automation in .github/workflows/ci.yml, including command substitutions and machine-readable command output.", + "kind": "config", + "source": "shell-workflow-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": ".github/workflows/ci.yml", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": ".github/workflows/ci.yml", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config", + "shell", + "workflow" + ], + "trustBoundaries": [ + "process-exec", + "filesystem", + "network" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=8103 bytes; approxTokens=2026; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:45.055Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:45.055Z" +} diff --git a/.clawpatch/features/feat_config_a5ac0cd10a.json b/.clawpatch/features/feat_config_a5ac0cd10a.json new file mode 100644 index 00000000..b4271000 --- /dev/null +++ b/.clawpatch/features/feat_config_a5ac0cd10a.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_config_a5ac0cd10a", + "title": "Project config Cargo.lock", + "summary": "Build, release, or quality configuration in Cargo.lock.", + "kind": "config", + "source": "shared-infra-heuristic", + "confidence": "medium", + "entrypoints": [ + { + "path": "Cargo.lock", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "Cargo.lock", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "config" + ], + "trustBoundaries": [ + "process-exec", + "filesystem" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=35993 bytes; approxTokens=8999; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:43.599Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:43.599Z" +} diff --git a/.clawpatch/features/feat_library_0aac2e5a47.json b/.clawpatch/features/feat_library_0aac2e5a47.json new file mode 100644 index 00000000..0ed8a7c4 --- /dev/null +++ b/.clawpatch/features/feat_library_0aac2e5a47.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_0aac2e5a47", + "title": "C/C++ source group crates", + "summary": "C/C++/CUDA source files under crates not owned by a build target.", + "kind": "library", + "source": "c-cpp-group", + "confidence": "low", + "entrypoints": [ + { + "path": "crates/media/src/bindings.h", + "symbol": "crates", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/media/src/bindings.h", + "reason": "source group member" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "c", + "source-group" + ], + "trustBoundaries": [ + "filesystem" + ], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6495 bytes; approxTokens=1624; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:41.965Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:41.965Z" +} diff --git a/.clawpatch/features/feat_library_12edbccd75.json b/.clawpatch/features/feat_library_12edbccd75.json new file mode 100644 index 00000000..35098c50 --- /dev/null +++ b/.clawpatch/features/feat_library_12edbccd75.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_12edbccd75", + "title": "Rust library perf", + "summary": "Rust library crate at tooling/perf/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "tooling/perf/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "tooling/perf/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6474 bytes; approxTokens=1619; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:40.801Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:40.801Z" +} diff --git a/.clawpatch/features/feat_library_4d4b6dfac8.json b/.clawpatch/features/feat_library_4d4b6dfac8.json new file mode 100644 index 00000000..29b22044 --- /dev/null +++ b/.clawpatch/features/feat_library_4d4b6dfac8.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_4d4b6dfac8", + "title": "Rust library gpui_util", + "summary": "Rust library crate at crates/gpui_util/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "crates/gpui_util/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_util/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "fixed", + "lock": null, + "findingIds": [ + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3" + ], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "1 finding(s); prompt=19308 bytes; approxTokens=4827; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:53.499Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:54:58.836Z" +} diff --git a/.clawpatch/features/feat_library_97901ecbe2.json b/.clawpatch/features/feat_library_97901ecbe2.json new file mode 100644 index 00000000..30e7d7e1 --- /dev/null +++ b/.clawpatch/features/feat_library_97901ecbe2.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_97901ecbe2", + "title": "Rust library ztracing_macro", + "summary": "Rust library crate at crates/ztracing_macro/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "crates/ztracing_macro/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/ztracing_macro/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=6440 bytes; approxTokens=1610; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:50:41.967Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:41.967Z" +} diff --git a/.clawpatch/features/feat_library_a4c19fb62c.json b/.clawpatch/features/feat_library_a4c19fb62c.json new file mode 100644 index 00000000..34cd30c2 --- /dev/null +++ b/.clawpatch/features/feat_library_a4c19fb62c.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "featureId": "feat_library_a4c19fb62c", + "title": "Rust library ztracing", + "summary": "Rust library crate at crates/ztracing/src/lib.rs.", + "kind": "library", + "source": "rust-library", + "confidence": "high", + "entrypoints": [ + { + "path": "crates/ztracing/src/lib.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/ztracing/src/lib.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "library" + ], + "trustBoundaries": [], + "status": "reviewed", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [ + { + "runId": "20260609T035033-f1797a", + "kind": "review", + "summary": "0 finding(s); prompt=9027 bytes; approxTokens=2257; includedFiles=1; omittedFiles=0", + "provider": "codex", + "model": null, + "reasoningEffort": "high", + "createdAt": "2026-06-09T03:51:12.464Z" + } + ], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:51:12.464Z" +} diff --git a/.clawpatch/features/feat_test-suite_20a0f803f2.json b/.clawpatch/features/feat_test-suite_20a0f803f2.json new file mode 100644 index 00000000..ba33de56 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_20a0f803f2.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_20a0f803f2", + "title": "Rust integration test gpui/action_macros", + "summary": "Rust integration test entrypoint at crates/gpui/tests/action_macros.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui/tests/action_macros.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui/tests/action_macros.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_5659fe0bd7.json b/.clawpatch/features/feat_test-suite_5659fe0bd7.json new file mode 100644 index 00000000..8c0d47b4 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_5659fe0bd7.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_5659fe0bd7", + "title": "Rust integration test gpui_macros/derive_context", + "summary": "Rust integration test entrypoint at crates/gpui_macros/tests/derive_context.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui_macros/tests/derive_context.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_macros/tests/derive_context.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_6be57175d6.json b/.clawpatch/features/feat_test-suite_6be57175d6.json new file mode 100644 index 00000000..e625c646 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_6be57175d6.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_6be57175d6", + "title": "Rust integration test gpui_macros/derive_inspector_reflection", + "summary": "Rust integration test entrypoint at crates/gpui_macros/tests/derive_inspector_reflection.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui_macros/tests/derive_inspector_reflection.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_macros/tests/derive_inspector_reflection.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_a20a893749.json b/.clawpatch/features/feat_test-suite_a20a893749.json new file mode 100644 index 00000000..dcc1a94e --- /dev/null +++ b/.clawpatch/features/feat_test-suite_a20a893749.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_a20a893749", + "title": "Rust integration test gpui_macros/render_test", + "summary": "Rust integration test entrypoint at crates/gpui_macros/tests/render_test.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui_macros/tests/render_test.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui_macros/tests/render_test.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/features/feat_test-suite_d062e8fe0e.json b/.clawpatch/features/feat_test-suite_d062e8fe0e.json new file mode 100644 index 00000000..09b46c16 --- /dev/null +++ b/.clawpatch/features/feat_test-suite_d062e8fe0e.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "featureId": "feat_test-suite_d062e8fe0e", + "title": "Rust integration test gpui/cursor_style", + "summary": "Rust integration test entrypoint at crates/gpui/tests/cursor_style.rs.", + "kind": "test-suite", + "source": "rust-integration-test", + "confidence": "medium", + "entrypoints": [ + { + "path": "crates/gpui/tests/cursor_style.rs", + "symbol": null, + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "crates/gpui/tests/cursor_style.rs", + "reason": "entrypoint" + } + ], + "contextFiles": [], + "tests": [], + "tags": [ + "rust", + "test" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-06-09T03:50:27.990Z", + "updatedAt": "2026-06-09T03:50:27.990Z" +} diff --git a/.clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json b/.clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json new file mode 100644 index 00000000..8097cd0c --- /dev/null +++ b/.clawpatch/findings/fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "findingId": "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d", + "featureId": "feat_cli-command_3b0436e283", + "title": "Dead tilde fallback in hyperfine lookup", + "category": "maintainability", + "severity": "low", + "confidence": "high", + "triage": "risk", + "evidence": [ + { + "path": "tooling/perf/src/main.rs", + "startLine": 416, + "endLine": 429, + "symbol": "hyp_binary", + "quote": null + } + ], + "reasoning": "`Command::new` does not run through a shell, so `~/.cargo/bin/hyperfine` is treated as a literal executable path. That fallback cannot find the normal Cargo bin install location, yet every lookup carries the extra branch and failed process attempt. Setup docs already require `hyperfine` in PATH, so this path is accidental complexity with runtime cost and misleading maintenance surface.", + "reproduction": null, + "recommendation": "Delete the `HYP_HOME` fallback and keep one PATH lookup, or replace it with a real absolute home-dir lookup only if non-PATH installs are a supported contract.", + "whyTestsDoNotAlreadyCoverThis": "No linked tests exercise hyperfine discovery, and deslopify review is limited to this owned file excerpt.", + "suggestedRegressionTest": null, + "minimumFixScope": "Simplify `hyp_binary` to one `Command::new(\"hyperfine\")` probe unless repo wants explicit absolute-path discovery.", + "status": "fixed", + "history": [ + { + "runId": "20260609T035322-e82086", + "kind": "revalidate", + "status": "fixed", + "note": null, + "reasoning": "Original evidence path still exists at tooling/perf/src/main.rs:416. Current hyp_binary is lines 417-423 and now probes only Command::new(\"hyperfine\") via PATH, then returns Command::new(\"hyperfine\"). HYP_HOME and \"~/.cargo/bin/hyperfine\" no longer appear in tooling/Cargo.toml/README.md search surface. Setup comment still says hyperfine must be in shell path, so current code matches stated contract. git diff shows dirty worktree change deleting original tilde fallback, so fixed in current repository but not necessarily committed. cargo fmt --all --check and git diff --check pass. cargo check/test could not run because read-only sandbox cannot open target/debug/.cargo-lock: \"Operation not permitted (os error 1)\". clawpatch revalidate CLI also could not write .clawpatch/runs tmp file due EPERM.", + "commands": [ + "test -f ~/.agents/AGENTS.local.md && sed -n '1,220p' ~/.agents/AGENTS.local.md || true", + "sed -n '1,220p' /Users/adityasharma/Projects/dotfiles/skills/clawpatch/SKILL.md", + "sed -n '1,220p' /Users/adityasharma/Projects/dotfiles/skills/verify-this/SKILL.md", + "git status --short", + "test -f AGENTS.md && sed -n '1,220p' AGENTS.md || true", + "test -f AGENTS.local.md && sed -n '1,220p' AGENTS.local.md || true", + "clawpatch --help", + "clawpatch revalidate --help", + "nl -ba tooling/perf/src/main.rs | sed -n '390,450p'", + "rg -n \"hyperfine|hyp_binary|HYP_HOME|cargo/bin\" tooling/perf src crates docs README.md Cargo.toml .clawpatch 2>/dev/null", + "git diff -- tooling/perf/src/main.rs", + "clawpatch show --finding fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d --json", + "sed -n '1,220p' tooling/perf/Cargo.toml", + "ast-grep --lang rust -p 'Command::new($ARG)' tooling/perf/src/main.rs", + "sed -n '1,32p' tooling/perf/src/main.rs", + "rg -n \"\\[\\[bin\\]\\]|name = \\\"perf\\\"|tooling/perf\" Cargo.toml tooling/perf/Cargo.toml .cargo/config.toml 2>/dev/null", + "cargo fmt --all --check", + "cargo test -p perf", + "cargo check -p perf --all-targets", + "clawpatch revalidate --finding fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d --include-dirty --reasoning-effort high --json", + "rg -n \"HYP_HOME|~/.cargo/bin/hyperfine\" tooling Cargo.toml README.md", + "rg -n \"Command::new\\(|hyp_binary|hyperfine\" tooling/perf/src/main.rs", + "git diff --check -- tooling/perf/src/main.rs" + ], + "createdAt": "2026-06-09T03:55:04.389Z" + } + ], + "signature": "sig_feat-cli-command-3b0436e283_f4f2e4dad9", + "linkedPatchAttemptIds": [], + "createdByRunId": "20260609T035033-f1797a", + "createdAt": "2026-06-09T03:50:53.515Z", + "updatedAt": "2026-06-09T03:55:04.389Z" +} diff --git a/.clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json b/.clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json new file mode 100644 index 00000000..95968ee3 --- /dev/null +++ b/.clawpatch/findings/fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "findingId": "fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b", + "featureId": "feat_config_7afc533f22", + "title": "Duplicate Windows feature entry", + "category": "maintainability", + "severity": "low", + "confidence": "high", + "triage": "risk", + "evidence": [ + { + "path": "Cargo.toml", + "startLine": 190, + "endLine": 240, + "symbol": null, + "quote": null + } + ], + "reasoning": "`System_Threading` appears twice in the same `windows` feature list. Cargo feature unification makes the second entry redundant, so it only adds noise to an already large manual feature registry and increases review cost when auditing Windows API surface.", + "reproduction": null, + "recommendation": "Delete the duplicate `System_Threading` feature entry and keep the list single-source within this table.", + "whyTestsDoNotAlreadyCoverThis": "No tests are linked for this config-only feature, and normal builds would not fail because Cargo de-duplicates repeated features.", + "suggestedRegressionTest": null, + "minimumFixScope": "Remove one duplicate line from `[workspace.dependencies.windows].features`.", + "status": "false-positive", + "history": [ + { + "runId": null, + "kind": "triage", + "status": "false-positive", + "note": "Verified against current Cargo.toml and the local windows 0.61.3 registry manifest. Cargo.toml contains exactly one \"System_Threading\" entry at line 194 and one distinct \"Win32_System_Threading\" entry at line 228. The windows 0.61.3 Cargo.toml defines both as separate features: System_Threading = [\"System\"] and Win32_System_Threading = [\"Win32_System\"]. No duplicate exact feature entry exists.", + "reasoning": null, + "commands": [], + "createdAt": "2026-06-09T03:55:43.277Z" + } + ], + "signature": "sig_feat-config-7afc533f22_9ad4120894", + "linkedPatchAttemptIds": [], + "createdByRunId": "20260609T035033-f1797a", + "createdAt": "2026-06-09T03:50:53.564Z", + "updatedAt": "2026-06-09T03:55:43.276Z" +} diff --git a/.clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json b/.clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json new file mode 100644 index 00000000..1287198d --- /dev/null +++ b/.clawpatch/findings/fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "findingId": "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3", + "featureId": "feat_library_4d4b6dfac8", + "title": "Backtrace future wrapper duplicates normal error logging future", + "category": "maintainability", + "severity": "low", + "confidence": "high", + "triage": "risk", + "evidence": [ + { + "path": "crates/gpui_util/src/lib.rs", + "startLine": 301, + "endLine": 326, + "symbol": "LogErrorFuture", + "quote": null + }, + { + "path": "crates/gpui_util/src/lib.rs", + "startLine": 328, + "endLine": 352, + "symbol": "LogErrorWithBacktraceFuture", + "quote": null + } + ], + "reasoning": "LogErrorFuture and LogErrorWithBacktraceFuture have the same state layout and identical poll control flow. Only the error formatting differs at the logging call. This creates duplicate unsafe pin-projection code, so any future pin-safety or polling behavior change must be made twice and kept in sync.", + "reproduction": null, + "recommendation": "Consolidate both wrappers into one local future implementation with a small formatting mode or shared poll helper, reusing DebugAsDisplay only at the error-formatting boundary.", + "whyTestsDoNotAlreadyCoverThis": "No linked tests are included for this feature, and behavior tests would not detect maintenance cost from duplicated unsafe poll code.", + "suggestedRegressionTest": null, + "minimumFixScope": "crates/gpui_util/src/lib.rs", + "status": "fixed", + "history": [ + { + "runId": "20260609T035322-d0d0ed", + "kind": "revalidate", + "status": "fixed", + "note": null, + "reasoning": "Current code no longer has duplicated poll control flow from original evidence. LogErrorFuture still starts at crates/gpui_util/src/lib.rs:301, LogErrorWithBacktraceFuture moved to :320, and both poll impls now delegate to shared poll_log_error_future at :337-358. Only formatting closures differ at :313-315 and :331-333. git diff confirms duplicate match/Poll blocks were removed and centralized. cargo fmt passes. cargo check/test could not run because read-only sandbox cannot open target/debug/.cargo-lock, but this maintainability finding is resolved by current code evidence.", + "commands": [ + "git status --short", + "rg -n \"LogErrorFuture|LogErrorWithBacktraceFuture|log_err|log_err_with_backtrace|DebugAsDisplay\" crates/gpui_util/src/lib.rs", + "nl -ba crates/gpui_util/src/lib.rs | sed -n '260,370p'", + "ast-grep --lang rust -p 'struct LogErrorFuture<$A, $B> { $$$ }' crates/gpui_util/src/lib.rs && ast-grep --lang rust -p 'struct LogErrorWithBacktraceFuture<$A, $B> { $$$ }' crates/gpui_util/src/lib.rs", + "ast-grep --lang rust -p 'impl<$A, $B> Future for $T { $$$ }' crates/gpui_util/src/lib.rs", + "cargo test -p gpui_util --lib", + "git diff -- crates/gpui_util/src/lib.rs", + "rg -n \"fn poll_log_error_future|Pin::new_unchecked|unsafe \\\\{ self.get_unchecked_mut\\\\(\\\\) \\\\}\" crates/gpui_util/src/lib.rs", + "clawpatch --help | sed -n '1,120p'", + "clawpatch revalidate --help | sed -n '1,160p'", + "rg -n \"fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3|LogErrorWithBacktraceFuture|poll_log_error_future\" .clawpatch crates/gpui_util/src/lib.rs", + "find .clawpatch -maxdepth 3 -type f | sed -n '1,120p'", + "clawpatch show --finding fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3 --json", + "cargo fmt --all --check", + "cargo check -p gpui_util" + ], + "createdAt": "2026-06-09T03:54:58.830Z" + } + ], + "signature": "sig_feat-library-4d4b6dfac8_a6be76b1de", + "linkedPatchAttemptIds": [], + "createdByRunId": "20260609T035033-f1797a", + "createdAt": "2026-06-09T03:50:53.497Z", + "updatedAt": "2026-06-09T03:54:58.830Z" +} diff --git a/.clawpatch/project.json b/.clawpatch/project.json new file mode 100644 index 00000000..72bc65e7 --- /dev/null +++ b/.clawpatch/project.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "projectId": "prj_https-github-com-bumpyclock-gpui_10c6d8e5b4", + "name": "gpui", + "rootPath": "/Users/adityasharma/Projects/gpui", + "git": { + "remoteUrl": "https://github.com/BumpyClock/gpui.git", + "defaultBranch": "main", + "currentBranch": "05-15-26-upstream-sync", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0" + }, + "detected": { + "languages": [ + "rust" + ], + "frameworks": [], + "packageManagers": [ + "cargo" + ], + "commands": { + "typecheck": "cargo check --workspace --all-targets", + "lint": null, + "format": "cargo fmt --all --check", + "test": "cargo test --workspace" + } + }, + "createdAt": "2026-06-09T03:50:20.073Z", + "updatedAt": "2026-06-09T03:50:20.073Z" +} diff --git a/.clawpatch/reports/20260609T035033-f1797a.md b/.clawpatch/reports/20260609T035033-f1797a.md new file mode 100644 index 00000000..40383f06 --- /dev/null +++ b/.clawpatch/reports/20260609T035033-f1797a.md @@ -0,0 +1,74 @@ +# clawpatch report + +findings: 3 + +## low: Backtrace future wrapper duplicates normal error logging future + +id: fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3 +category: maintainability +confidence: high +triage: risk +status: open +feature: Rust library gpui_util (feat_library_4d4b6dfac8) + +evidence: +- crates/gpui_util/src/lib.rs:301-326 (LogErrorFuture) +- crates/gpui_util/src/lib.rs:328-352 (LogErrorWithBacktraceFuture) + +LogErrorFuture and LogErrorWithBacktraceFuture have the same state layout and identical poll control flow. Only the error formatting differs at the logging call. This creates duplicate unsafe pin-projection code, so any future pin-safety or polling behavior change must be made twice and kept in sync. + +recommendation: +Consolidate both wrappers into one local future implementation with a small formatting mode or shared poll helper, reusing DebugAsDisplay only at the error-formatting boundary. + +test analysis: +No linked tests are included for this feature, and behavior tests would not detect maintenance cost from duplicated unsafe poll code. + +minimum fix scope: +crates/gpui_util/src/lib.rs + +## low: Dead tilde fallback in hyperfine lookup + +id: fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d +category: maintainability +confidence: high +triage: risk +status: open +feature: Rust command perf (feat_cli-command_3b0436e283) + +evidence: +- tooling/perf/src/main.rs:416-429 (hyp_binary) + +`Command::new` does not run through a shell, so `~/.cargo/bin/hyperfine` is treated as a literal executable path. That fallback cannot find the normal Cargo bin install location, yet every lookup carries the extra branch and failed process attempt. Setup docs already require `hyperfine` in PATH, so this path is accidental complexity with runtime cost and misleading maintenance surface. + +recommendation: +Delete the `HYP_HOME` fallback and keep one PATH lookup, or replace it with a real absolute home-dir lookup only if non-PATH installs are a supported contract. + +test analysis: +No linked tests exercise hyperfine discovery, and deslopify review is limited to this owned file excerpt. + +minimum fix scope: +Simplify `hyp_binary` to one `Command::new("hyperfine")` probe unless repo wants explicit absolute-path discovery. + +## low: Duplicate Windows feature entry + +id: fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b +category: maintainability +confidence: high +triage: risk +status: open +feature: Project config Cargo.toml (feat_config_7afc533f22) + +evidence: +- Cargo.toml:190-240 + +`System_Threading` appears twice in the same `windows` feature list. Cargo feature unification makes the second entry redundant, so it only adds noise to an already large manual feature registry and increases review cost when auditing Windows API surface. + +recommendation: +Delete the duplicate `System_Threading` feature entry and keep the list single-source within this table. + +test analysis: +No tests are linked for this config-only feature, and normal builds would not fail because Cargo de-duplicates repeated features. + +minimum fix scope: +Remove one duplicate line from `[workspace.dependencies.windows].features`. + diff --git a/.clawpatch/runs/20260609T035033-f1797a.json b/.clawpatch/runs/20260609T035033-f1797a.json new file mode 100644 index 00000000..fc85e992 --- /dev/null +++ b/.clawpatch/runs/20260609T035033-f1797a.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "runId": "20260609T035033-f1797a", + "command": "review", + "args": [ + "review", + "--mode", + "deslopify", + "--limit", + "10", + "--reasoning-effort", + "high", + "--jobs", + "10", + "--json" + ], + "rootPath": "/Users/adityasharma/Projects/gpui", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0", + "startedAt": "2026-06-09T03:50:33.118Z", + "finishedAt": "2026-06-09T03:51:12.465Z", + "status": "completed", + "claimedFeatureIds": [ + "feat_cli-command_3b0436e283", + "feat_config_062b3c2b38", + "feat_config_7afc533f22", + "feat_config_902602e3e1", + "feat_config_a5ac0cd10a", + "feat_library_0aac2e5a47", + "feat_library_12edbccd75", + "feat_library_4d4b6dfac8", + "feat_library_97901ecbe2", + "feat_library_a4c19fb62c" + ], + "findingIds": [ + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3", + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d", + "fnd_sig-feat-config-7afc533f22-9ad41_c0175a193b" + ], + "patchAttemptIds": [], + "errors": [] +} diff --git a/.clawpatch/runs/20260609T035322-d0d0ed.json b/.clawpatch/runs/20260609T035322-d0d0ed.json new file mode 100644 index 00000000..d1c0426a --- /dev/null +++ b/.clawpatch/runs/20260609T035322-d0d0ed.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "runId": "20260609T035322-d0d0ed", + "command": "revalidate", + "args": [ + "revalidate", + "--finding", + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3", + "--include-dirty", + "--reasoning-effort", + "high", + "--json" + ], + "rootPath": "/Users/adityasharma/Projects/gpui", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0", + "startedAt": "2026-06-09T03:53:22.701Z", + "finishedAt": "2026-06-09T03:54:58.836Z", + "status": "completed", + "claimedFeatureIds": [], + "findingIds": [ + "fnd_sig-feat-library-4d4b6dfac8-a6be_2065acefb3" + ], + "patchAttemptIds": [], + "errors": [] +} diff --git a/.clawpatch/runs/20260609T035322-e82086.json b/.clawpatch/runs/20260609T035322-e82086.json new file mode 100644 index 00000000..b878b1b8 --- /dev/null +++ b/.clawpatch/runs/20260609T035322-e82086.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "runId": "20260609T035322-e82086", + "command": "revalidate", + "args": [ + "revalidate", + "--finding", + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d", + "--include-dirty", + "--reasoning-effort", + "high", + "--json" + ], + "rootPath": "/Users/adityasharma/Projects/gpui", + "headSha": "1c2f0fb6c8e6eaeb186219cae33958ee3dddb8e0", + "startedAt": "2026-06-09T03:53:22.701Z", + "finishedAt": "2026-06-09T03:55:04.398Z", + "status": "completed", + "claimedFeatureIds": [], + "findingIds": [ + "fnd_sig-feat-cli-command-3b0436e283-_4f592efb4d" + ], + "patchAttemptIds": [], + "errors": [] +} diff --git a/.gitignore b/.gitignore index f0ffc9a5..b6939499 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ target/ .DS_Store **/*.rs.bk bk_gpui/ +.pi-lens/ diff --git a/Cargo.lock b/Cargo.lock index 7774a63a..ba395656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,95 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5c87e8d94f2ec10cce590aadff24c76f576dab5502d45d0aed9fc3065d4451" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite 2.6.1", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -75,6 +164,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -453,6 +548,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -644,13 +776,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -760,6 +901,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -859,6 +1006,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -887,6 +1061,31 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.56" @@ -1240,6 +1439,30 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.11.0", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.1", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1258,6 +1481,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1311,20 +1570,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.4.3" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "data-url" version = "0.3.2" @@ -1435,7 +1688,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1451,9 +1704,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -1491,21 +1744,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - [[package]] name = "dunce" version = "1.0.5" @@ -1653,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2252,7 +2490,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.17", - "windows 0.57.0", + "windows 0.62.2", ] [[package]] @@ -2279,6 +2517,7 @@ dependencies = [ name = "gpui" version = "0.2.2" dependencies = [ + "accesskit", "anyhow", "async-channel 2.5.0", "async-task", @@ -2323,8 +2562,8 @@ dependencies = [ "naga 25.0.1", "num_cpus", "objc", - "objc2", - "objc2-metal", + "objc2 0.6.3", + "objc2-metal 0.3.2", "parking", "parking_lot", "pathfinder_geometry", @@ -2353,6 +2592,7 @@ dependencies = [ "taffy", "thiserror 2.0.17", "ttf-parser", + "unicode-general-category", "unicode-segmentation", "usvg", "util", @@ -2370,6 +2610,8 @@ dependencies = [ name = "gpui_linux" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", @@ -2378,7 +2620,7 @@ dependencies = [ "calloop", "calloop-wayland-source", "collections", - "cosmic-text", + "cosmic-text 0.17.2", "filedescriptor", "futures", "gpui", @@ -2405,6 +2647,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", + "wgpu", "x11-clipboard", "x11rb", "xkbcommon", @@ -2417,6 +2660,8 @@ dependencies = [ name = "gpui_macos" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_macos", "anyhow", "async-task", "block", @@ -2534,7 +2779,8 @@ dependencies = [ "anyhow", "bytemuck", "collections", - "cosmic-text", + "cosmic-text 0.19.0", + "criterion", "etagere", "gpui", "gpui_util", @@ -2547,6 +2793,7 @@ dependencies = [ "raw-window-handle", "smallvec", "swash", + "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -2558,6 +2805,8 @@ dependencies = [ name = "gpui_windows" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_windows", "anyhow", "collections", "etagere", @@ -2875,7 +3124,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -3124,6 +3373,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -3140,6 +3400,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3370,6 +3639,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3689,8 +3970,8 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -3792,7 +4073,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3928,6 +4209,22 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.3" @@ -3937,6 +4234,34 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3945,7 +4270,19 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -3954,6 +4291,18 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -3961,10 +4310,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -3972,11 +4333,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -3986,10 +4360,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -4066,6 +4440,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.3.2" @@ -4193,6 +4573,49 @@ dependencies = [ "serde_json", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4248,6 +4671,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -4486,6 +4937,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4689,10 +5150,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -4986,7 +5447,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5893,7 +6354,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6024,6 +6485,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -6112,7 +6583,7 @@ dependencies = [ "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6144,7 +6615,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6156,7 +6627,7 @@ dependencies = [ "indexmap", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6165,7 +6636,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -6363,6 +6834,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -6883,8 +7360,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bitflags 2.11.0", @@ -6895,7 +7372,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga 29.0.0", + "naga 29.0.3", "parking_lot", "portable-atomic", "profiling", @@ -6912,8 +7389,8 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -6925,7 +7402,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "naga 29.0.0", + "naga 29.0.3", "once_cell", "parking_lot", "portable-atomic", @@ -6944,39 +7421,39 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", "bitflags 2.11.0", - "block2", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", @@ -6990,13 +7467,13 @@ dependencies = [ "libc", "libloading", "log", - "naga 29.0.0", + "naga 29.0.3", "ndk-sys", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -7016,21 +7493,22 @@ dependencies = [ "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ - "naga 29.0.0", + "naga 29.0.3", "wgpu-types", ] [[package]] name = "wgpu-types" -version = "29.0.0" -source = "git+https://github.com/zed-industries/wgpu.git?rev=a466bc382ea747f8e1ac810efdb6dcd49a514575#a466bc382ea747f8e1ac810efdb6dcd49a514575" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "bitflags 2.11.0", "bytemuck", @@ -7074,7 +7552,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7685,6 +8163,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.55.0" @@ -7910,12 +8397,36 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.12.0" @@ -7933,13 +8444,24 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "static_assertions", - "winnow", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml 0.39.4", + "serde", + "zbus_names", "zvariant", ] @@ -8227,24 +8749,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -8255,13 +8777,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 7b63fd8b..e2d38732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,10 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # +accesskit = "0.24.0" +accesskit_macos = "0.26.0" +accesskit_unix = "0.21.0" +accesskit_windows = "0.32.1" anyhow = "1.0.86" ashpd = { version = "0.12", default-features = false, features = ["async-std"] } async-channel = "2.5.0" @@ -92,7 +96,8 @@ cocoa-foundation = "=0.2.0" core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.4.3", features = ["metal"] } -ctor = "0.4.0" +criterion = { version = "0.5", features = ["html_reports"] } +ctor = "1.0.6" derive_more = "0.99.17" dirs = "4.0" env_logger = "0.11" @@ -165,13 +170,14 @@ thiserror = "2.0.12" tokio = { version = "1" } tracing = "0.1.40" unicase = "2.6" +unicode-general-category = "1.1.0" unicode-segmentation = "1.10" url = "2.2" uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } walkdir = "2.5" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "a466bc382ea747f8e1ac810efdb6dcd49a514575" } +wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } which = "6.0.0" windows-core = "0.61" windows-numerics = "0.2" diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 306f0227..5359eaa2 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -19,24 +19,18 @@ workspace = true [features] default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ - "leak-detection", - "collections/test-support", - "util/test-support", - "http_client/test-support", - "wayland", - "x11", + "leak-detection", + "collections/test-support", + "util/test-support", + "http_client/test-support", + "wayland", + "x11", ] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] -wayland = [ - "bitflags", -] -x11 = [ - "scap?/x11", -] -screen-capture = [ - "scap", -] +wayland = ["bitflags"] +x11 = ["scap?/x11"] +screen-capture = ["scap"] windows-manifest = [] [lib] @@ -44,6 +38,7 @@ path = "src/gpui.rs" doctest = false [dependencies] +accesskit.workspace = true anyhow.workspace = true async-task = "4.7" async-channel.workspace = true @@ -76,11 +71,12 @@ raw-window-handle = "0.6" refineable.workspace = true regex.workspace = true scheduler.workspace = true +unicode-general-category.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ - "text", - "system-fonts", - "memmap-fonts", - "raster-images", + "text", + "system-fonts", + "memmap-fonts", + "raster-images", ] } usvg = { version = "0.45.0", default-features = false } ttf-parser = "0.25" @@ -132,7 +128,6 @@ pathfinder_geometry = "0.5" scap = { workspace = true, optional = true } - [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = ["Win32_Foundation"] } @@ -141,7 +136,7 @@ windows = { version = "0.61", features = ["Win32_Foundation"] } backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true -gpui_platform.workspace = true +gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } http_client = { workspace = true, features = ["test-support"] } lyon = { version = "1.0", features = ["extra"] } pretty_assertions.workspace = true @@ -160,7 +155,6 @@ wasm-bindgen = { workspace = true } gpui_web.workspace = true - [target.'cfg(target_os = "windows")'.build-dependencies] embed-resource = "3.0" @@ -170,8 +164,6 @@ cbindgen = { version = "0.28.0", default-features = false } naga.workspace = true - - [[example]] name = "hello_world" path = "examples/hello_world.rs" diff --git a/crates/gpui/README.md b/crates/gpui/README.md index ad3fd37f..4d4ca769 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -12,6 +12,7 @@ gpui = { version = "*" } ``` - [Ownership and data flow](_ownership_and_data_flow) + - [Accessibility](_accessibility) Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs new file mode 100644 index 00000000..0ef6116c --- /dev/null +++ b/crates/gpui/examples/a11y.rs @@ -0,0 +1,208 @@ +//! Accessibility (AccessKit) demo app. +//! +//! Run with: `cargo run -p gpui --example a11y`. + +use gpui::{ + AccessibleAction, App, Context, FocusHandle, KeyBinding, Role, SharedString, Text, Toggled, + Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, +}; +use gpui_platform::application; + +actions!(a11y_example, [Tab, TabPrev]); + +struct A11yDemo { + focus_handle: FocusHandle, + count: i32, + enabled: bool, +} + +impl A11yDemo { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle, cx); + Self { + focus_handle, + count: 0, + enabled: false, + } + } +} + +impl Render for A11yDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tasks = ["Write code", "Run tests", "Ship it"]; + let task_count = tasks.len(); + + div() + .id("root") + .role(Role::Application) + .aria_label("Accessibility Demo") + .track_focus(&self.focus_handle) + .on_action(cx.listener(|_, _: &Tab, window, cx| window.focus_next(cx))) + .on_action(cx.listener(|_, _: &TabPrev, window, cx| window.focus_prev(cx))) + .size_full() + .flex() + .flex_col() + .gap_4() + .p_4() + .bg(rgb(0x1e1e2e)) + .text_color(rgb(0xcdd6f4)) + .child( + div() + .id("heading") + .role(Role::Heading) + .aria_level(1) + .aria_label("Accessibility Demo") + .text_xl() + .font_weight(gpui::FontWeight::BOLD) + .child(Text::new_inaccessible("Accessibility Demo".into())), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .id("counter") + .focusable() + .tab_stop(true) + .role(Role::SpinButton) + .aria_label("Counter") + .aria_numeric_value(self.count as f64) + .aria_min_numeric_value(0.0) + .on_a11y_action(AccessibleAction::Increment, { + let this = cx.entity().downgrade(); + move |_, _, cx| { + this.update(cx, |this, cx| { + this.count += 1; + cx.notify(); + }) + .ok(); + } + }) + .on_a11y_action(AccessibleAction::Decrement, { + let this = cx.entity().downgrade(); + move |_, _, cx| { + this.update(cx, |this, cx| { + this.count = (this.count - 1).max(0); + cx.notify(); + }) + .ok(); + } + }) + .on_click(cx.listener(|this, _, _, cx| { + this.count += 1; + cx.notify(); + })) + .px_3() + .py_1() + .rounded_md() + .bg(rgb(0x89b4fa)) + .text_color(rgb(0x1e1e2e)) + .cursor_pointer() + .child(text!(format!("Count: {}", self.count))), + ) + .child( + div() + .id("reset") + .focusable() + .tab_stop(true) + .role(Role::Button) + .aria_label("Reset counter") + .px_3() + .py_1() + .rounded_md() + .bg(rgb(0x585b70)) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| { + this.count = 0; + cx.notify(); + })) + .child(text!("Reset")), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .id("toggle") + .focusable() + .tab_stop(true) + .role(Role::Switch) + .aria_label("Enable feature") + .aria_toggled(if self.enabled { + Toggled::True + } else { + Toggled::False + }) + .w(px(44.)) + .h(px(24.)) + .rounded_full() + .cursor_pointer() + .when(self.enabled, |el| el.bg(rgb(0x89b4fa))) + .when(!self.enabled, |el| el.bg(rgb(0x585b70))) + .child( + div() + .size(px(20.)) + .rounded_full() + .bg(gpui::white()) + .mt(px(2.)) + .when(self.enabled, |el| el.ml(px(22.))) + .when(!self.enabled, |el| el.ml(px(2.))), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.enabled = !this.enabled; + cx.notify(); + })), + ) + .child(text!("Enable feature")), + ) + .child( + div() + .id("task-list") + .role(Role::List) + .aria_label("Tasks") + .flex() + .flex_col() + .gap_1() + .children(tasks.into_iter().enumerate().map(|(i, label)| { + div() + .id(("task", i)) + .role(Role::ListItem) + .aria_label(SharedString::from(label)) + .aria_position_in_set(i + 1) + .aria_size_of_set(task_count) + .py_1() + .px_2() + .child(text!(format!("{}. {}", i + 1, label))) + })), + ) + } +} + +fn main() { + application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + ]); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(640.), px(420.)), cx)), + titlebar: Some(gpui::TitlebarOptions { + title: Some("GPUI Accessibility Demo".into()), + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| A11yDemo::new(window, cx)), + ) + .unwrap(); + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 31094f49..a9faf387 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -117,6 +117,7 @@ impl Render for HelloWorld { blur_radius: px(1.0), spread_radius: px(5.0), offset: point(px(10.0), px(10.0)), + inset: false, }]) .child(img("image/app-icon.png").size_8()) .child("Opacity Panel (Click to test)") diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index 519053ae..f2afe7a1 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -107,6 +107,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -117,6 +118,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -127,6 +129,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -137,6 +140,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -147,6 +151,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -183,6 +188,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -192,6 +198,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(2.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -201,6 +208,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(4.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -210,6 +218,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -219,6 +228,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(16.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -235,6 +245,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -244,6 +255,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }]), ), example( @@ -253,6 +265,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(4.), + inset: false, }]), ), example( @@ -262,6 +275,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(8.), + inset: false, }]), ), example( @@ -271,6 +285,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(16.), + inset: false, }]), ), ]), @@ -287,6 +302,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -296,6 +312,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(8.), + inset: false, }]), ), example( @@ -305,6 +322,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(16.), + inset: false, }]), ), ]), @@ -321,6 +339,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -330,6 +349,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(8.), + inset: false, }]), ), example( @@ -339,6 +359,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(16.), + inset: false, }]), ), ]), @@ -355,6 +376,7 @@ impl Render for Shadow { offset: point(px(-8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -364,6 +386,7 @@ impl Render for Shadow { offset: point(px(8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -373,6 +396,7 @@ impl Render for Shadow { offset: point(px(0.), px(-8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -382,6 +406,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -398,6 +423,7 @@ impl Render for Shadow { offset: point(px(-8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -407,6 +433,7 @@ impl Render for Shadow { offset: point(px(8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -416,6 +443,7 @@ impl Render for Shadow { offset: point(px(0.), px(-8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -425,6 +453,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -441,6 +470,7 @@ impl Render for Shadow { offset: point(px(-8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -450,6 +480,7 @@ impl Render for Shadow { offset: point(px(8.), px(0.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -459,6 +490,7 @@ impl Render for Shadow { offset: point(px(0.), px(-8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), example( @@ -468,6 +500,7 @@ impl Render for Shadow { offset: point(px(0.), px(8.)), blur_radius: px(8.), spread_radius: px(0.), + inset: false, }]), ), ]), @@ -485,24 +518,28 @@ impl Render for Shadow { offset: point(px(0.), px(-12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow offset: point(px(12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green offset: point(px(0.), px(12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue offset: point(px(-12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, ]), ), @@ -514,24 +551,28 @@ impl Render for Shadow { offset: point(px(0.), px(-12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow offset: point(px(12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green offset: point(px(0.), px(12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue offset: point(px(-12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, ]), ), @@ -543,24 +584,113 @@ impl Render for Shadow { offset: point(px(0.), px(-12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow offset: point(px(12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green offset: point(px(0.), px(12.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, }, BoxShadow { color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue offset: point(px(-12.), px(0.)), blur_radius: px(8.), spread_radius: px(2.), + inset: false, + }, + ]), + ), + ]), + // Inset shadows (CSS `box-shadow: inset ...`). + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example( + "Inset basic", + Shadow::base().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(0.), px(0.)), + blur_radius: px(12.), + spread_radius: px(0.), + inset: true, + }]), + ), + example( + "Inset offset", + Shadow::base().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(6.), px(6.)), + blur_radius: px(8.), + spread_radius: px(0.), + inset: true, + }]), + ), + example( + "Inset spread", + Shadow::base().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(8.), + inset: true, + }]), + ), + example( + "Inset rounded", + Shadow::rounded_large().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.5), + offset: point(px(0.), px(4.)), + blur_radius: px(10.), + spread_radius: px(2.), + inset: true, + }]), + ), + example( + "Inset sharp", + Shadow::square().shadow(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.6), + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(6.), + inset: true, + }]), + ), + ]), + // Combined: drop + inset shadows on the same element. + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example( + "Drop + Inset", + Shadow::rounded_medium().shadow(vec![ + BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.25), + offset: point(px(0.), px(8.)), + blur_radius: px(12.), + spread_radius: px(0.), + inset: false, + }, + BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.4), + offset: point(px(0.), px(2.)), + blur_radius: px(4.), + spread_radius: px(0.), + inset: true, }, ]), ), diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index c8e37b67..3f3cdb37 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -114,6 +114,7 @@ impl Render for WindowShadow { }, blur_radius: shadow_size / 2., spread_radius: px(0.), + inset: false, offset: point(px(0.0), px(0.0)), }]) }), @@ -154,6 +155,7 @@ impl Render for WindowShadow { }, blur_radius: px(20.0), spread_radius: px(0.0), + inset: false, offset: point(px(0.0), px(0.0)), }]) .map(|div| match decorations { diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs new file mode 100644 index 00000000..3962bca2 --- /dev/null +++ b/crates/gpui/src/_accessibility.rs @@ -0,0 +1,80 @@ +//! # Accessibility in GPUI +//! +//! "Accessibility" refers to the ability of your application to be used by all +//! users, regardless of disability status. There are many aspects, all important, including: +//! - Ensuring sufficient text contrast. +//! - Providing a mechanism to disable animations. +//! - Providing a mechanism to increase text sizes. +//! - etc. +//! +//! This guide is focused on **programmatic accessibility**. This allows +//! assistive technology, such as screen readers or Braille displays, to inspect +//! and interact with your app. +//! +//! GPUI integrates with [AccessKit] to provide programmatic accessibility +//! features (referred to as simply "accessibility" for the rest of this guide). +//! +//! A minimal example can be found in `examples/a11y.rs`. +//! +//! ## Background +//! +//! Accessibility support is based on two key capabilities: +//! - Exposing information about the current UI state to assistive technology. +//! - Responding to actions requested by assistive technology. +//! +//! ### IDs in GPUI - [`ElementId`] and [`GlobalElementId`] +//! +//! In GPUI, each [`Element`] can have an [`id`][Element::id]. [`Element`]s with +//! IDs are also assigned a [`GlobalElementId`], formed by composing all +//! non-`None` IDs of its ancestors. These IDs should be unique per frame. +//! +//! ### IDs and accessibility +//! +//! When GPUI renders a frame, it walks your UI tree and informs assistive +//! technology about nodes with global IDs and non-`None` +//! [`role`][Element::a11y_role] values. Use +//! [`div().id(...).role()`][StatefulInteractiveElement::role] to set a role. +//! +//! Nodes with the same global ID across frames are considered to be the same +//! node. If a node's ID changes, assistive technology treats it as one node +//! being removed and another being added, which can be disorienting for users. +//! +//! #### IDs and text +//! +//! GPUI provides the [`text!`] macro, which wraps strings in the [`Text`] type +//! and automatically derives an ID from the source location of the macro +//! invocation. This means repeated calls through the same helper function can +//! produce duplicate text IDs. Set IDs explicitly with [`Text::with_id`] or wrap +//! each text element in a parent with a unique ID when rendering collections. +//! +//! Occasionally, you will need to create a [`Text`] element with no ID. You can +//! achieve this with [`Text::new_inaccessible`]. If you are creating a custom UI +//! component, this can avoid duplicating text that is already represented on a +//! parent node's accessible label. +//! +//! ### Handling actions +//! +//! Assistive technology can dispatch actions to a specific node. AccessKit +//! exposes [`accesskit::Action`], re-exported by GPUI as [`AccessibleAction`]. +//! Respond to actions with +//! [`div().on_a11y_action()`][StatefulInteractiveElement::on_a11y_action]. +//! +//! Some common actions are registered automatically. For example, +//! [`.on_click()`][StatefulInteractiveElement::on_click] adds an +//! [`AccessibleAction::Click`] handler that calls the click handler. +//! +//! ## Further reading +//! +//! - [AccessKit]: The cross-platform accessibility toolkit GPUI uses +//! internally. +//! - [MDN WAI-ARIA basics][mdn-aria]: Introduction to roles, properties, and +//! states. +//! - [ARIA Authoring Practices Guide][apg]: W3C patterns for accessible +//! widgets. +//! +//! [AccessKit]: https://accesskit.dev/ +//! [mdn-aria]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics +//! [apg]: https://www.w3.org/WAI/ARIA/apg/ + +#[cfg(doc)] +use crate::*; diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 5f9c9e2c..146d7d6a 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -141,6 +141,18 @@ impl Application { )) } + /// Builds an app with accessibility (AccessKit) integration forcibly + /// disabled. + /// + /// In this mode, accessibility APIs (for example + /// [`div().role()`][crate::StatefulInteractiveElement::role]) silently + /// no-op. + pub fn new_inaccessible(platform: Rc) -> Self { + let this = Self::with_platform(platform); + this.0.borrow_mut().accessibility_force_disabled = true; + this + } + /// Assigns the source of assets for the application. pub fn with_assets(self, asset_source: impl AssetSource) -> Self { let mut context_lock = self.0.borrow_mut(); @@ -247,6 +259,22 @@ pub enum QuitMode { Explicit, } +/// Controls when GPUI hides the mouse cursor in response to keyboard input. +/// +/// Restoration on mouse motion is handled by the platform layer; this enum +/// only describes the policy for *triggering* a hide. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum CursorHideMode { + /// Never hide the cursor automatically. + Never, + /// Hide on character-producing key presses (typing). + OnTyping, + /// Hide on character-producing key presses, *and* when a key binding + /// resolves to an action that consumes the keystroke. + #[default] + OnTypingAndAction, +} + #[doc(hidden)] #[derive(Clone, PartialEq, Eq)] pub struct SystemWindowTab { @@ -639,6 +667,9 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, + pub(crate) cursor_hide_mode: CursorHideMode, + /// Whether the app was created by [`Application::new_inaccessible`]. + pub(crate) accessibility_force_disabled: bool, flushing_effects: bool, pending_updates: usize, quit_mode: QuitMode, @@ -727,6 +758,8 @@ impl App { inspector_element_registry: InspectorElementRegistry::default(), quit_mode: QuitMode::default(), quitting: false, + cursor_hide_mode: CursorHideMode::default(), + accessibility_force_disabled: false, #[cfg(any(test, feature = "test-support", debug_assertions))] name: None, @@ -836,6 +869,27 @@ impl App { self.platform.quit(); } + /// Returns the current policy for hiding the cursor in response to + /// keyboard input. + pub fn cursor_hide_mode(&self) -> CursorHideMode { + self.cursor_hide_mode + } + + /// Sets the policy controlling when GPUI hides the cursor in response + /// to keyboard input. + pub fn set_cursor_hide_mode(&mut self, mode: CursorHideMode) { + self.cursor_hide_mode = mode; + } + + /// Returns whether the cursor is currently visible according to the + /// platform. This will report `false` after a keyboard input has hidden + /// the cursor and the user has not yet moved the mouse to restore it. + /// + /// See [`App::set_cursor_hide_mode`]. + pub fn is_cursor_visible(&self) -> bool { + self.platform.is_cursor_visible() + } + /// Schedules all windows in the application to be redrawn. This can be called /// multiple times in an update cycle and still result in a single redraw. pub fn refresh_windows(&mut self) { diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 36822ae1..051a2af6 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -362,23 +362,50 @@ impl AsyncWindowContext { } impl AppContext for AsyncWindowContext { + /// Create a new entity using the bound window's context. + /// + /// Panics if the bound window has been closed before the entity can be created. fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity where T: 'static, { - self.app.new(build_entity) + let mut build_entity = Some(build_entity); + match self.app.update_window(self.window, |_, _, cx| { + cx.new( + build_entity + .take() + .expect("build_entity is taken exactly once"), + ) + }) { + Ok(entity) => entity, + Err(error) => { + panic!("AsyncWindowContext::new failed: bound window could not be updated: {error}") + } + } } fn reserve_entity(&mut self) -> Reservation { self.app.reserve_entity() } + /// Insert a reserved entity using the bound window's context. + /// + /// Panics if the bound window has been closed before the entity can be inserted. fn insert_entity( &mut self, reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, ) -> Entity { - self.app.insert_entity(reservation, build_entity) + let mut args = Some((reservation, build_entity)); + match self.app.update_window(self.window, |_, _, cx| { + let (reservation, build_entity) = args.take().expect("args are taken exactly once"); + cx.insert_entity(reservation, build_entity) + }) { + Ok(entity) => entity, + Err(error) => panic!( + "AsyncWindowContext::insert_entity failed: bound window could not be updated: {error}" + ), + } } fn update_entity( @@ -492,3 +519,115 @@ impl VisualContext for AsyncWindowContext { }) } } + +#[cfg(test)] +mod tests { + use std::{ + any::Any, + cell::Cell, + panic::{AssertUnwindSafe, catch_unwind}, + rc::Rc, + }; + + use crate::{AppContext, Empty, TestAppContext, WindowHandle}; + + use super::AsyncWindowContext; + + fn open_async_window_context( + cx: &mut TestAppContext, + ) -> (AsyncWindowContext, WindowHandle) { + let window = cx.update(|cx| { + cx.open_window(Default::default(), |_, cx| cx.new(|_| Empty)) + .unwrap() + }); + let async_cx = AsyncWindowContext::new_context(cx.to_async(), window.into()); + (async_cx, window) + } + + fn close_window(cx: &mut TestAppContext, window: WindowHandle) { + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + fn panic_message(error: &(dyn Any + Send)) -> Option<&str> { + error + .downcast_ref::() + .map(String::as_str) + .or_else(|| error.downcast_ref::<&str>().copied()) + } + + #[gpui::test] + fn async_window_context_new_panics_when_bound_window_is_closed(cx: &mut TestAppContext) { + let (mut async_cx, window) = open_async_window_context(cx); + close_window(cx, window); + + let builder_invoked = Rc::new(Cell::new(false)); + let result = { + let builder_invoked = builder_invoked.clone(); + catch_unwind(AssertUnwindSafe(move || { + async_cx.new(|_| { + builder_invoked.set(true); + 1usize + }); + })) + }; + + let error = result.expect_err("AsyncWindowContext::new should panic"); + let message = panic_message(error.as_ref()).expect("panic should include a message"); + assert!( + message.contains("AsyncWindowContext::new failed: bound window could not be updated"), + "{message}" + ); + assert!(message.contains("window not found"), "{message}"); + assert!(!builder_invoked.get()); + } + + #[gpui::test] + fn async_window_context_insert_entity_panics_when_bound_window_is_closed( + cx: &mut TestAppContext, + ) { + let (mut async_cx, window) = open_async_window_context(cx); + let reservation = async_cx.reserve_entity::(); + close_window(cx, window); + + let builder_invoked = Rc::new(Cell::new(false)); + let result = { + let builder_invoked = builder_invoked.clone(); + catch_unwind(AssertUnwindSafe(move || { + async_cx.insert_entity(reservation, |_| { + builder_invoked.set(true); + 1usize + }); + })) + }; + + let error = result.expect_err("AsyncWindowContext::insert_entity should panic"); + let message = panic_message(error.as_ref()).expect("panic should include a message"); + assert!( + message.contains( + "AsyncWindowContext::insert_entity failed: bound window could not be updated" + ), + "{message}" + ); + assert!(message.contains("window not found"), "{message}"); + assert!(!builder_invoked.get()); + } + + #[gpui::test] + fn async_window_context_new_uses_live_window(cx: &mut TestAppContext) { + let (mut async_cx, _) = open_async_window_context(cx); + let builder_invoked = Rc::new(Cell::new(false)); + + let entity = { + let builder_invoked = builder_invoked.clone(); + async_cx.new(|_| { + builder_invoked.set(true); + 42usize + }) + }; + + assert!(builder_invoked.get()); + assert_eq!(async_cx.read_entity(&entity, |value, _| *value), 42); + } +} diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 7b78e9b0..4aa0307c 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -103,6 +103,17 @@ pub trait Element: 'static + IntoElement { cx: &mut App, ); + /// Returns the accessible role for this element, if any. + /// + /// Elements that return `None` are not included in the accessibility tree. + /// Inclusion also requires a non-`None` [`id`][Element::id]. + fn a11y_role(&self) -> Option { + None + } + + /// Write accessibility properties to the given node. + fn write_a11y_info(&self, _node: &mut accesskit::Node) {} + /// Convert this element into a dynamically-typed [`AnyElement`]. fn into_any(self) -> AnyElement { AnyElement::new(self) @@ -431,6 +442,28 @@ impl Drawable { } let bounds = window.layout_bounds(layout_id); + let mut pushed_a11y_node = false; + if window.a11y.is_active() { + if let Some(global_id) = global_id.as_ref() { + if let Some(role) = self.element.a11y_role() { + let node_id = window.a11y.node_id_for(global_id); + let mut node = accesskit::Node::new(role); + let scale = window.scale_factor(); + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale) as f64, + y0: (bounds.origin.y.0 * scale) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + }); + self.element.write_a11y_info(&mut node); + pushed_a11y_node = window.a11y.nodes.push(node_id, node); + if pushed_a11y_node { + window.a11y.node_bounds.insert(node_id, bounds); + } + } + } + } + let node_id = window.next_frame.dispatch_tree.push_node(); let prepaint = self.element.prepaint( global_id.as_ref(), @@ -442,6 +475,10 @@ impl Drawable { ); window.next_frame.dispatch_tree.pop_node(); + if pushed_a11y_node { + window.a11y.nodes.pop(); + } + if global_id.is_some() { window.element_id_stack.pop(); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 7080cc85..e3ef38b1 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1120,6 +1120,119 @@ pub trait InteractiveElement: Sized { /// A trait for elements that want to use the standard GPUI interactivity features /// that require state. pub trait StatefulInteractiveElement: InteractiveElement { + /// Set the accessible role for this element. + fn role(mut self, role: accesskit::Role) -> Self { + debug_assert!( + role != accesskit::Role::GenericContainer, + "GenericContainer is filtered out of the a11y tree and has no effect" + ); + self.interactivity().a11y_state_mut().override_role = Some(role); + self + } + + /// Set the accessible label for this element. + fn aria_label(mut self, label: impl Into) -> Self { + self.interactivity().a11y_state_mut().aria_label = Some(label.into()); + self + } + + /// Set the selected state for this element. + fn aria_selected(mut self, selected: bool) -> Self { + self.interactivity().a11y_state_mut().aria_selected = Some(selected); + self + } + + /// Set the expanded state for this element. + fn aria_expanded(mut self, expanded: bool) -> Self { + self.interactivity().a11y_state_mut().aria_expanded = Some(expanded); + self + } + + /// Set the toggled state for this element. + fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self { + self.interactivity().a11y_state_mut().aria_toggled = Some(toggled); + self + } + + /// Set the numeric value for this element. + fn aria_numeric_value(mut self, value: f64) -> Self { + self.interactivity().a11y_state_mut().aria_numeric_value = Some(value); + self + } + + /// Set the minimum numeric value for this element. + fn aria_min_numeric_value(mut self, value: f64) -> Self { + self.interactivity().a11y_state_mut().aria_min_numeric_value = Some(value); + self + } + + /// Set the maximum numeric value for this element. + fn aria_max_numeric_value(mut self, value: f64) -> Self { + self.interactivity().a11y_state_mut().aria_max_numeric_value = Some(value); + self + } + + /// Set the orientation of this element. + fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self { + self.interactivity().a11y_state_mut().aria_orientation = Some(orientation); + self + } + + /// Set the heading level of this element. + fn aria_level(mut self, level: usize) -> Self { + self.interactivity().a11y_state_mut().aria_level = Some(level); + self + } + + /// Set the position in set of this element. + fn aria_position_in_set(mut self, position: usize) -> Self { + self.interactivity().a11y_state_mut().aria_position_in_set = Some(position); + self + } + + /// Set the size of set for this element. + fn aria_size_of_set(mut self, size: usize) -> Self { + self.interactivity().a11y_state_mut().aria_size_of_set = Some(size); + self + } + + /// Set the row index for this element. + fn aria_row_index(mut self, index: usize) -> Self { + self.interactivity().a11y_state_mut().aria_row_index = Some(index); + self + } + + /// Set the column index for this element. + fn aria_column_index(mut self, index: usize) -> Self { + self.interactivity().a11y_state_mut().aria_column_index = Some(index); + self + } + + /// Set the row count for this element. + fn aria_row_count(mut self, count: usize) -> Self { + self.interactivity().a11y_state_mut().aria_row_count = Some(count); + self + } + + /// Set the column count for this element. + fn aria_column_count(mut self, count: usize) -> Self { + self.interactivity().a11y_state_mut().aria_column_count = Some(count); + self + } + + /// Register a handler for an accessibility action on this element. + fn on_a11y_action( + mut self, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity() + .a11y_state_mut() + .action_listeners + .push((action, Box::new(listener))); + self + } + /// Set this element to focusable. fn focusable(mut self) -> Self { self.interactivity().focusable = true; @@ -1425,6 +1538,18 @@ impl Element for Div { self.interactivity.source_location() } + fn a11y_role(&self) -> Option { + self.interactivity + .a11y_state + .as_deref() + .and_then(|state| state.override_role) + .filter(|role| *role != accesskit::Role::GenericContainer) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.interactivity.write_a11y_info(node); + } + #[stacksafe] fn request_layout( &mut self, @@ -1622,6 +1747,7 @@ pub struct Interactivity { pub(crate) tracked_scroll_handle: Option, pub(crate) scroll_anchor: Option, pub(crate) scroll_offset: Option>>>, + pub(crate) current_a11y_node: bool, pub(crate) group: Option, /// The base style of the element, before any modifications are applied /// by focus, active, etc. @@ -1659,6 +1785,7 @@ pub struct Interactivity { pub(crate) tab_index: Option, pub(crate) tab_group: bool, pub(crate) tab_stop: bool, + pub(crate) a11y_state: Option>, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1667,7 +1794,34 @@ pub struct Interactivity { pub(crate) debug_selector: Option, } +#[derive(Default)] +pub(crate) struct A11yState { + action_listeners: Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, + override_role: Option, + aria_label: Option, + aria_selected: Option, + aria_expanded: Option, + aria_toggled: Option, + aria_numeric_value: Option, + aria_min_numeric_value: Option, + aria_max_numeric_value: Option, + aria_orientation: Option, + aria_level: Option, + aria_position_in_set: Option, + aria_size_of_set: Option, + aria_row_index: Option, + aria_column_index: Option, + aria_row_count: Option, + aria_column_count: Option, +} + impl Interactivity { + fn a11y_state_mut(&mut self) -> &mut A11yState { + self.a11y_state + .get_or_insert_with(Box::::default) + .as_mut() + } + /// Layout this element according to this interactivity state's configured styles pub fn request_layout( &mut self, @@ -1778,9 +1932,20 @@ impl Interactivity { }, ); + let current_a11y_node_id = if window.a11y.is_active() { + global_id + .and_then(|global_id| window.a11y.node_id_for_existing(global_id)) + .filter(|node_id| window.a11y.nodes.has_current_node(*node_id)) + } else { + None + }; + let has_current_a11y_node = current_a11y_node_id.is_some(); + self.current_a11y_node = has_current_a11y_node; + if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { window.set_focus_handle(focus_handle, cx); } + window.with_optional_element_state::( global_id, |element_state, window| { @@ -1809,6 +1974,37 @@ impl Interactivity { } let translated_bounds = bounds + style.translate; + let suppress_a11y_descendants = window.a11y.is_active() + && (style.visibility == Visibility::Hidden || style.display == Display::None); + if window.a11y.is_active() { + if suppress_a11y_descendants { + if let Some(node_id) = current_a11y_node_id { + if window.a11y.nodes.suppress_current_node(node_id) { + self.current_a11y_node = false; + window.a11y.node_bounds.remove(&node_id); + window.a11y.focus_ids.remove(&node_id); + window.a11y.action_listeners.remove(&node_id); + } + } + } else if let Some(node_id) = current_a11y_node_id { + let scale_factor = window.scale_factor(); + let updated_bounds = window.a11y.nodes.update_current_node_bounds( + node_id, + translated_bounds, + scale_factor, + ); + if updated_bounds { + window.a11y.node_bounds.insert(node_id, translated_bounds); + } + if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { + window.a11y.focus_ids.insert(node_id, focus_handle.id); + if focus_handle.is_focused(window) { + window.a11y.nodes.set_focus(node_id); + } + } + } + } + window.with_text_style(style.text_style().cloned(), |window| { window.with_content_mask( style.overflow_mask(translated_bounds, window.rem_size()), @@ -1821,7 +2017,13 @@ impl Interactivity { let scroll_offset = self.clamp_scroll_position(bounds, &style, window, cx); + if suppress_a11y_descendants { + window.a11y.nodes.begin_suppressing_descendants(); + } let result = f(&style, scroll_offset, hitbox, window, cx); + if suppress_a11y_descendants { + window.a11y.nodes.end_suppressing_descendants(); + } (result, element_state) }, ) @@ -2013,6 +2215,32 @@ impl Interactivity { } self.paint_keyboard_listeners(window, cx); + + if window.a11y.is_active() { + let current_a11y_node_id = if self.current_a11y_node + { + global_id.and_then(|global_id| { + window.a11y.node_id_for_existing(global_id) + }) + } else { + None + }; + if let Some(node_id) = current_a11y_node_id { + if let Some(a11y_state) = + self.a11y_state.as_mut() + && !a11y_state.action_listeners.is_empty() + { + for (action, listener) in + a11y_state.action_listeners.drain(..) + { + window.on_a11y_action( + node_id, action, listener, + ); + } + } + } + } + f(&style, window, cx); if let Some(_hitbox) = hitbox { @@ -2806,6 +3034,71 @@ impl Interactivity { style } + + pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { + if let Some(a11y_state) = self.a11y_state.as_deref() { + a11y_state.write_a11y_info(node); + } + if !self.click_listeners.is_empty() { + node.add_action(accesskit::Action::Click); + } + if self.tracked_focus_handle.is_some() || self.focusable { + node.add_action(accesskit::Action::Focus); + } + } +} + +impl A11yState { + fn write_a11y_info(&self, node: &mut accesskit::Node) { + if let Some(label) = &self.aria_label { + node.set_label(label.to_string()); + } + if let Some(selected) = self.aria_selected { + node.set_selected(selected); + } + if let Some(expanded) = self.aria_expanded { + node.set_expanded(expanded); + } + if let Some(toggled) = self.aria_toggled { + node.set_toggled(toggled); + } + if let Some(value) = self.aria_numeric_value { + node.set_numeric_value(value); + } + if let Some(value) = self.aria_min_numeric_value { + node.set_min_numeric_value(value); + } + if let Some(value) = self.aria_max_numeric_value { + node.set_max_numeric_value(value); + } + if let Some(orientation) = self.aria_orientation { + node.set_orientation(orientation); + } + if let Some(level) = self.aria_level { + node.set_level(level); + } + if let Some(position) = self.aria_position_in_set { + node.set_position_in_set(position); + } + if let Some(size) = self.aria_size_of_set { + node.set_size_of_set(size); + } + if let Some(index) = self.aria_row_index { + node.set_row_index(index); + } + if let Some(index) = self.aria_column_index { + node.set_column_index(index); + } + if let Some(count) = self.aria_row_count { + node.set_row_count(count); + } + if let Some(count) = self.aria_column_count { + node.set_column_count(count); + } + for (action, _) in &self.action_listeners { + node.add_action(*action); + } + } } /// The per-frame state of an interactive element. Used for tracking stateful interactions like clicks @@ -3201,6 +3494,14 @@ where self.element.source_location() } + fn a11y_role(&self) -> Option { + self.element.a11y_role() + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.element.write_a11y_info(node); + } + fn request_layout( &mut self, id: Option<&GlobalElementId>, @@ -3517,6 +3818,238 @@ impl ScrollHandle { #[cfg(test)] mod tests { use super::*; + use crate::{ + AnyView, AppContext, Context, DrawPhase, Drawable, Render, StyleRefinement, TestAppContext, + deferred, text, + }; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::Arc; + + fn draw_accessible( + cx: &mut crate::VisualTestContext, + origin: Point, + space: impl Into>, + f: impl FnOnce(&mut Window, &mut App) -> E, + ) -> accesskit::TreeUpdate + where + E: Element, + { + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.a11y.begin_frame(); + + window.invalidator.set_phase(DrawPhase::Prepaint); + let mut element = Drawable::new(f(window, cx)); + element.layout_as_root(space.into(), window, cx); + window.with_absolute_element_offset(origin, |window| element.prepaint(window, cx)); + + window.invalidator.set_phase(DrawPhase::Paint); + element.paint(window, cx); + window.invalidator.set_phase(DrawPhase::None); + + let update = window.a11y.end_frame(); + window.a11y.set_active_for_test(false); + + window.next_frame.finish(&mut window.rendered_frame); + std::mem::swap(&mut window.rendered_frame, &mut window.next_frame); + window.next_frame.clear(); + + update + }) + } + + fn root_node(update: &accesskit::TreeUpdate) -> &accesskit::Node { + update + .nodes + .iter() + .find_map(|(node_id, node)| { + (*node_id == crate::window::a11y::ROOT_NODE_ID).then_some(node) + }) + .unwrap() + } + + fn node_with_role( + update: &accesskit::TreeUpdate, + role: accesskit::Role, + ) -> Option<(accesskit::NodeId, &accesskit::Node)> { + update + .nodes + .iter() + .find_map(|(node_id, node)| (node.role() == role).then_some((*node_id, node))) + } + + fn global_id(id: impl Into) -> GlobalElementId { + let ids: Arc<[ElementId]> = Arc::from([id.into()]); + GlobalElementId(ids) + } + + fn node_id_for_existing_element_id( + window: &Window, + id: impl Into, + ) -> Option { + window.a11y.node_id_for_existing(&global_id(id)) + } + + fn assert_tree_has_no_missing_children(update: &accesskit::TreeUpdate) { + let node_ids = update + .nodes + .iter() + .map(|(node_id, _)| *node_id) + .collect::>(); + + for (node_id, node) in &update.nodes { + for child_id in node.children() { + assert!( + node_ids.contains(child_id), + "node {node_id:?} references missing child {child_id:?}" + ); + } + } + } + + struct CachedDeferredRoot { + child: Entity, + } + + impl Render for CachedDeferredRoot { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + AnyView::from(self.child.clone()) + .cached(StyleRefinement::default().w(px(20.)).h(px(20.))) + } + } + + struct CachedDeferredChild { + action_count: Rc>, + } + + impl Render for CachedDeferredChild { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let action_count = self.action_count.clone(); + deferred( + div() + .id("cached-deferred-child") + .role(accesskit::Role::Button) + .on_a11y_action(accesskit::Action::Click, move |_, _, _| { + action_count.set(action_count.get() + 1); + }) + .w(px(10.)) + .h(px(10.)), + ) + } + } + + #[gpui::test] + fn a11y_cached_deferred_draw(cx: &mut TestAppContext) { + let action_count = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let action_count = action_count.clone(); + move |_, cx| { + let child = cx.new(|_| CachedDeferredChild { + action_count: action_count.clone(), + }); + CachedDeferredRoot { child } + } + }); + + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.refresh(); + let _ = window.draw(cx); + }); + + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + + let child_id = cx.update(|window, _| { + assert_eq!( + window.a11y.action_listeners.len(), + 1, + "deferred child should rebuild its a11y action listener" + ); + let child_id = window + .a11y + .action_listeners + .keys() + .next() + .copied() + .expect("deferred child should have an a11y action listener"); + assert!( + window.a11y.node_bounds.contains_key(&child_id), + "deferred child should be emitted into the active a11y frame" + ); + child_id + }); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: child_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(action_count.get(), 1); + } + + #[gpui::test] + fn a11y_listener_registered_during_dispatch_survives_for_same_target(cx: &mut TestAppContext) { + let original_count = Rc::new(Cell::new(0)); + let registered_count = Rc::new(Cell::new(0)); + let node_id = accesskit::NodeId(1); + let cx = cx.add_empty_window(); + + cx.update(|window, _| { + let original_count = original_count.clone(); + let registered_count = registered_count.clone(); + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, window, _| { + original_count.set(original_count.get() + 1); + let registered_count = registered_count.clone(); + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, _, _| { + registered_count.set(registered_count.get() + 1); + }); + }); + }); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: node_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(original_count.get(), 1); + assert_eq!(registered_count.get(), 0); + assert_eq!( + cx.update(|window, _| window.a11y.action_listeners[&node_id].len()), + 2 + ); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: node_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(original_count.get(), 2); + assert_eq!(registered_count.get(), 1); + } #[test] fn key_context_accepts_try_into_errors_without_display() { @@ -3573,4 +4106,340 @@ mod tests { assert_eq!(handle.offset().y, px(-25.)); } + + #[gpui::test] + fn a11y_hidden_role_div_is_absent_from_tree(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let update = draw_accessible( + cx, + point(px(0.), px(0.)), + size(px(100.), px(100.)), + |_, _| { + div() + .id("hidden-button") + .role(accesskit::Role::Button) + .invisible() + .w(px(20.)) + .h(px(20.)) + }, + ); + + assert!(node_with_role(&update, accesskit::Role::Button).is_none()); + assert_eq!(root_node(&update).children(), &[]); + assert_tree_has_no_missing_children(&update); + } + + #[gpui::test] + fn a11y_display_none_role_div_is_absent_from_tree(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let focus_handle = cx.update(|_, cx| cx.focus_handle()); + cx.update(|window, cx| focus_handle.focus(window, cx)); + + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let focus_handle = focus_handle.clone(); + move |_, _| { + div() + .id("display-none-button") + .role(accesskit::Role::Button) + .track_focus(&focus_handle) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}) + .hidden() + .child(text!(id = "display-none-label", "Hidden child")) + } + }); + + assert!(node_with_role(&update, accesskit::Role::Button).is_none()); + assert!(node_with_role(&update, accesskit::Role::Label).is_none()); + assert_eq!(root_node(&update).children(), &[]); + assert_eq!(update.focus, crate::window::a11y::ROOT_NODE_ID); + assert_tree_has_no_missing_children(&update); + + cx.update(|window, _| { + let node_id = node_id_for_existing_element_id(window, "display-none-button").unwrap(); + assert!(!window.a11y.node_bounds.contains_key(&node_id)); + assert!(!window.a11y.focus_ids.contains_key(&node_id)); + assert!(!window.a11y.action_listeners.contains_key(&node_id)); + }); + } + + #[gpui::test] + fn a11y_hidden_container_suppresses_descendants(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let focus_handle = cx.update(|_, cx| cx.focus_handle()); + + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let focus_handle = focus_handle.clone(); + move |_, _| { + div().invisible().child( + div() + .id("child-button") + .role(accesskit::Role::Button) + .track_focus(&focus_handle) + .child(text!(id = "child-label", "Hidden child")), + ) + } + }); + + assert!(node_with_role(&update, accesskit::Role::Button).is_none()); + assert!(node_with_role(&update, accesskit::Role::Label).is_none()); + assert_eq!(root_node(&update).children(), &[]); + assert_tree_has_no_missing_children(&update); + + cx.update(|window, _| { + let child_id = node_id_for_existing_element_id(window, "child-button").unwrap(); + assert!(!window.a11y.node_bounds.contains_key(&child_id)); + assert!(!window.a11y.focus_ids.contains_key(&child_id)); + }); + } + + #[gpui::test] + fn a11y_translated_clickable_div_updates_bounds_and_clicks_at_translated_center( + cx: &mut TestAppContext, + ) { + let clicked_at = Rc::new(Cell::new(None)); + let cx = cx.add_empty_window(); + + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let clicked_at = clicked_at.clone(); + move |_, _| { + div() + .id("translated-button") + .role(accesskit::Role::Button) + .on_click(move |event, _, _| clicked_at.set(Some(event.position()))) + .translate(px(10.), px(20.)) + .w(px(40.)) + .h(px(30.)) + } + }); + + let expected_bounds = Bounds::new(point(px(10.), px(20.)), size(px(40.), px(30.))); + let scale_factor = cx.update(|window, _| window.scale_factor()); + let (button_id, button) = node_with_role(&update, accesskit::Role::Button).unwrap(); + assert!(button.supports_action(accesskit::Action::Click)); + assert_eq!( + button.bounds(), + Some(accesskit::Rect { + x0: (expected_bounds.left().0 * scale_factor) as f64, + y0: (expected_bounds.top().0 * scale_factor) as f64, + x1: (expected_bounds.right().0 * scale_factor) as f64, + y1: (expected_bounds.bottom().0 * scale_factor) as f64, + }) + ); + + let node_bounds = cx + .update(|window, _| window.a11y.node_bounds.get(&button_id).copied()) + .unwrap(); + assert_eq!(node_bounds, expected_bounds); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: button_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(clicked_at.get(), Some(point(px(30.), px(35.)))); + } + + #[gpui::test] + fn a11y_window_transact_rolls_back_prepaint_state(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let accepted_global_id = global_id("accepted-node"); + let rejected_global_id = global_id("rejected-node"); + let accepted_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + let rejected_bounds = Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))); + + let (update, accepted_id, rejected_id, focus_ids, node_bounds) = cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.a11y.begin_frame(); + window.invalidator.set_phase(DrawPhase::Prepaint); + + let accepted_focus = cx.focus_handle(); + let rejected_focus = cx.focus_handle(); + let mut accepted_node = accesskit::Node::new(accesskit::Role::Button); + accepted_node.set_label("accepted"); + let accepted_id = window.a11y.node_id_for(&accepted_global_id); + assert!(window.a11y.nodes.push(accepted_id, accepted_node)); + window.a11y.node_bounds.insert(accepted_id, accepted_bounds); + window.a11y.focus_ids.insert(accepted_id, accepted_focus.id); + window.a11y.nodes.pop(); + + let mut rejected_id = None; + let result = window.transact(|window| { + let mut rejected_node = accesskit::Node::new(accesskit::Role::TextInput); + rejected_node.set_label("rejected"); + let node_id = window.a11y.node_id_for(&rejected_global_id); + rejected_id = Some(node_id); + assert!(window.a11y.nodes.push(node_id, rejected_node)); + window.a11y.node_bounds.insert(node_id, rejected_bounds); + window.a11y.focus_ids.insert(node_id, rejected_focus.id); + window.a11y.nodes.set_focus(node_id); + window.a11y.nodes.pop(); + Err::<(), ()>(()) + }); + assert!(result.is_err()); + + let focus_ids = window.a11y.focus_ids.clone(); + let node_bounds = window.a11y.node_bounds.clone(); + let update = window.a11y.end_frame(); + window.invalidator.set_phase(DrawPhase::None); + window.a11y.set_active_for_test(false); + ( + update, + accepted_id, + rejected_id.unwrap(), + focus_ids, + node_bounds, + ) + }); + + assert!(update.nodes.iter().any(|(id, _)| *id == accepted_id)); + assert!(!update.nodes.iter().any(|(id, _)| *id == rejected_id)); + assert_eq!(root_node(&update).children(), &[accepted_id]); + assert_eq!(update.focus, crate::window::a11y::ROOT_NODE_ID); + assert_eq!(node_bounds.get(&accepted_id), Some(&accepted_bounds)); + assert!(!node_bounds.contains_key(&rejected_id)); + assert!(focus_ids.contains_key(&accepted_id)); + assert!(!focus_ids.contains_key(&rejected_id)); + } + + #[cfg(not(debug_assertions))] + #[gpui::test] + fn a11y_duplicate_id_does_not_override_focus_fallback(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let first_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let second_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let first_focus_handle_id = first_focus_handle.id; + + draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let first_focus_handle = first_focus_handle; + let second_focus_handle = second_focus_handle; + move |_, _| { + div() + .child( + div() + .id("shared-focus") + .role(accesskit::Role::Button) + .track_focus(&first_focus_handle), + ) + .child( + div() + .id("shared-focus") + .role(accesskit::Role::TextInput) + .track_focus(&second_focus_handle), + ) + } + }); + + cx.update(|window, _| { + let duplicate_id = node_id_for_existing_element_id(window, "shared-focus").unwrap(); + assert_eq!( + window.a11y.focus_ids.get(&duplicate_id), + Some(&first_focus_handle_id) + ); + }); + } + + #[cfg(not(debug_assertions))] + #[gpui::test] + fn a11y_hidden_duplicate_id_does_not_remove_accepted_sibling_accessibility_state( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + let first_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let second_focus_handle = cx.update(|_, cx| cx.focus_handle()); + let first_focus_handle_id = first_focus_handle.id; + let expected_bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(10.))); + + draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let first_focus_handle = first_focus_handle; + let second_focus_handle = second_focus_handle; + move |_, _| { + div() + .child( + div() + .id("shared-hidden-node") + .role(accesskit::Role::Button) + .track_focus(&first_focus_handle) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}) + .w(px(20.)) + .h(px(10.)), + ) + .child( + div() + .id("shared-hidden-node") + .role(accesskit::Role::Button) + .track_focus(&second_focus_handle) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}) + .invisible(), + ) + } + }); + + cx.update(|window, _| { + let duplicate_id = + node_id_for_existing_element_id(window, "shared-hidden-node").unwrap(); + assert_eq!( + window.a11y.node_bounds.get(&duplicate_id), + Some(&expected_bounds) + ); + assert_eq!( + window.a11y.focus_ids.get(&duplicate_id), + Some(&first_focus_handle_id) + ); + assert_eq!( + window + .a11y + .action_listeners + .get(&duplicate_id) + .expect("duplicate node should retain first listener") + .len(), + 1 + ); + }); + } + + #[cfg(not(debug_assertions))] + #[gpui::test] + fn a11y_duplicate_id_does_not_register_a11y_action_listeners_for_rejected_node( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + move |_, _| { + div() + .child( + div() + .id("shared-listener") + .role(accesskit::Role::Button) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}), + ) + .child( + div() + .id("shared-listener") + .role(accesskit::Role::TextInput) + .on_a11y_action(accesskit::Action::Click, |_, _, _| {}), + ) + } + }); + + cx.update(|window, _| { + let duplicate_id = node_id_for_existing_element_id(window, "shared-listener").unwrap(); + assert_eq!( + window + .a11y + .action_listeners + .get(&duplicate_id) + .map(|listeners| listeners.len()), + Some(1) + ); + }); + } } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 91b4dd9e..586ad30b 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -61,6 +61,7 @@ impl std::fmt::Debug for ListState { struct StateInner { last_layout_bounds: Option>, + last_layout_scroll_top: Option, last_padding: Option>, items: SumTree, logical_scroll_top: Option, @@ -71,12 +72,27 @@ struct StateInner { scroll_handler: Option>, scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, - pending_scroll: Option, + pending_scroll: Option, follow_state: FollowState, } +/// Deferred scroll adjustment applied after the scroll-top item has been remeasured. +/// +/// An absolute pending scroll preserves the same pixel offset into the item, which keeps +/// visible text stable while content is appended to or removed from that item. A +/// proportional pending scroll preserves the same fractional position within the item, +/// which is useful when the whole list is being resized and each item scales similarly. +#[derive(Clone)] +enum PendingScroll { + /// Preserve the same pixel offset into the item after it is remeasured. + Absolute { item_ix: usize, offset: Pixels }, + /// Preserve the same fractional offset into the item after it is remeasured. + Proportional(PendingScrollFraction), +} + /// Keeps track of a fractional scroll position within an item for restoration /// after remeasurement. +#[derive(Clone)] struct PendingScrollFraction { /// The index of the item to scroll within. item_ix: usize, @@ -84,6 +100,15 @@ struct PendingScrollFraction { fraction: f32, } +/// Determines how remeasurement preserves the scroll position when the scroll-top item +/// changes height. +enum ScrollAnchor { + /// Preserve the same pixel offset into the scroll-top item. + Absolute, + /// Preserve the same fractional position within the scroll-top item. + Proportional, +} + /// Controls whether the list automatically follows new content at the end. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum FollowMode { @@ -220,6 +245,7 @@ pub struct ListPrepaintState { #[derive(Clone)] enum ListItem { Unmeasured { + size_hint: Option>, focus_handle: Option, }, Measured { @@ -237,9 +263,16 @@ impl ListItem { } } + fn size_hint(&self) -> Option> { + match self { + ListItem::Measured { size, .. } => Some(*size), + ListItem::Unmeasured { size_hint, .. } => *size_hint, + } + } + fn focus_handle(&self) -> Option { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle.clone() } } @@ -247,7 +280,7 @@ impl ListItem { fn contains_focused(&self, window: &Window, cx: &App) -> bool { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle .as_ref() .is_some_and(|handle| handle.contains_focused(window, cx)) @@ -263,6 +296,7 @@ struct ListItemSummary { unrendered_count: usize, height: Pixels, has_focus_handles: bool, + has_unknown_height: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] @@ -281,6 +315,7 @@ impl ListState { pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self { let this = Self(Rc::new(RefCell::new(StateInner { last_layout_bounds: None, + last_layout_scroll_top: None, last_padding: None, items: SumTree::default(), logical_scroll_top: None, @@ -314,6 +349,7 @@ impl ListState { state.reset = true; state.measuring_behavior.reset(); state.logical_scroll_top = None; + state.last_layout_scroll_top = None; state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -326,37 +362,87 @@ impl ListState { /// Use this when item heights may have changed (e.g., font size changes) /// but the number and identity of items remains the same. pub fn remeasure(&self) { - let state = &mut *self.0.borrow_mut(); - - let new_items = state.items.iter().map(|item| ListItem::Unmeasured { - focus_handle: item.focus_handle(), - }); + let count = self.item_count(); + self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional); + } - // If there's a `logical_scroll_top`, we need to keep track of it as a - // `PendingScrollFraction`, so we can later preserve that scroll - // position proportionally to the item, in case the item's height - // changes. - if let Some(scroll_top) = state.logical_scroll_top { - let mut cursor = state.items.cursor::(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + /// Mark items in `range` as needing remeasurement while preserving + /// the current scroll position. Unlike [`Self::splice`], this does + /// not change the number of items or blow away `logical_scroll_top`. + /// + /// Use this when an item's content has changed and its rendered + /// height may be different (e.g., streaming text, tool results + /// loading), but the item itself still exists at the same index. + pub fn remeasure_items(&self, range: Range) { + self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute); + } - if let Some(item) = cursor.item() { - if let Some(size) = item.size() { - let fraction = if size.height.0 > 0.0 { - (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) - } else { - 0.0 - }; + fn remeasure_items_with_scroll_anchor(&self, range: Range, scroll_anchor: ScrollAnchor) { + let state = &mut *self.0.borrow_mut(); - state.pending_scroll = Some(PendingScrollFraction { + if let Some(scroll_top) = state.logical_scroll_top { + if range.contains(&scroll_top.item_ix) { + state.pending_scroll = match scroll_anchor { + ScrollAnchor::Absolute => Some(PendingScroll::Absolute { item_ix: scroll_top.item_ix, - fraction, - }); - } + offset: scroll_top.offset_in_item, + }), + ScrollAnchor::Proportional => { + // If the scroll-top item falls within the remeasured range, + // store a fractional offset so the layout can restore the + // proportional scroll position after the item is re-rendered + // at its new height. + let mut cursor = state.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + + cursor + .item() + .and_then(|item| { + item.size_hint().map(|size| { + let fraction = if size.height.0 > 0.0 { + (scroll_top.offset_in_item.0 / size.height.0) + .clamp(0.0, 1.0) + } else { + 0.0 + }; + + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction, + }) + }) + }) + .or_else(|| match &state.pending_scroll { + Some(PendingScroll::Proportional(pending_scroll)) + if pending_scroll.item_ix == scroll_top.item_ix => + { + Some(PendingScroll::Proportional(pending_scroll.clone())) + } + _ => None, + }) + } + }; } } - state.items = SumTree::from_iter(new_items, ()); + // Rebuild the tree, replacing items in the range with + // Unmeasured copies that keep their focus handles and prior size hints. + let new_items = { + let mut cursor = state.items.cursor::(()); + let mut new_items = cursor.slice(&Count(range.start), Bias::Right); + let invalidated = cursor.slice(&Count(range.end), Bias::Right); + new_items.extend( + invalidated.iter().map(|item| ListItem::Unmeasured { + size_hint: item.size_hint(), + focus_handle: item.focus_handle(), + }), + (), + ); + new_items.append(cursor.suffix(), ()); + new_items + }; + state.items = new_items; + state.last_layout_scroll_top = None; state.measuring_behavior.reset(); } @@ -365,6 +451,25 @@ impl ListState { self.0.borrow().items.summary().count } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable or the total content height is not yet known. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let bounds = state.last_layout_bounds?; + let summary = state.items.summary(); + if summary.has_unknown_height { + return None; + } + let padding = state.last_padding.unwrap_or_default(); + let content_height = summary.height + padding.top + padding.bottom; + let scroll_max = (content_height - bounds.size.height).max(px(0.)); + if scroll_max <= px(0.) { + return None; + } + let scroll_top = state.scroll_top(&state.logical_scroll_top()); + Some(scroll_top >= scroll_max) + } + /// Inform the list state that the items in `old_range` have been replaced /// by `count` new items that must be recalculated. pub fn splice(&self, old_range: Range, count: usize) { @@ -390,13 +495,17 @@ impl ListState { new_items.extend( focus_handles.into_iter().map(|focus_handle| { spliced_count += 1; - ListItem::Unmeasured { focus_handle } + ListItem::Unmeasured { + size_hint: None, + focus_handle, + } }), (), ); new_items.append(old_items.suffix(), ()); drop(old_items); state.items = new_items; + state.last_layout_scroll_top = None; if let Some(ListOffset { item_ix, @@ -453,6 +562,7 @@ impl ListState { item_ix: cursor.start().count, offset_in_item: new_pixel_offset - cursor.start().height, }); + state.last_layout_scroll_top = None; } /// Scroll the list to the very end, past the last item. @@ -466,6 +576,7 @@ impl ListState { }), ListAlignment::Bottom => None, }; + state.last_layout_scroll_top = None; } /// Set whether the list should auto-follow the tail. @@ -484,6 +595,7 @@ impl ListState { }), ListAlignment::Bottom => None, }; + state.last_layout_scroll_top = None; } } } @@ -507,6 +619,7 @@ impl ListState { } state.logical_scroll_top = Some(scroll_top); + state.last_layout_scroll_top = None; } /// Scroll the list to the given item, such that the item is fully visible. @@ -540,36 +653,18 @@ impl ListState { } state.logical_scroll_top = Some(scroll_top); + state.last_layout_scroll_top = None; } /// Get the bounds for the given item in window coordinates, if it's /// been rendered. pub fn bounds_for_item(&self, ix: usize) -> Option> { let state = &*self.0.borrow(); + let scroll_top = state + .last_layout_scroll_top + .unwrap_or_else(|| state.logical_scroll_top()); - let bounds = state.last_layout_bounds.unwrap_or_default(); - let scroll_top = state.logical_scroll_top(); - if ix < scroll_top.item_ix { - return None; - } - - let mut cursor = state.items.cursor::>(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); - - let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item; - - cursor.seek_forward(&Count(ix), Bias::Right); - if let Some(&ListItem::Measured { size, .. }) = cursor.item() { - let &Dimensions(Count(count), Height(top), _) = cursor.start(); - if count == ix { - let top = bounds.top() + top - scroll_top; - return Some(Bounds::from_corners( - point(bounds.left(), top), - point(bounds.right(), top + size.height), - )); - } - } - None + state.bounds_for_item_at_scroll_top(ix, scroll_top) } /// Call this method when the user starts dragging the scrollbar. @@ -588,6 +683,16 @@ impl ListState { self.0.borrow_mut().scrollbar_drag_start_height.take(); } + /// Returns `true` if the scrollbar is currently being dragged. + /// + /// This is set between [`scrollbar_drag_started`](Self::scrollbar_drag_started) + /// and [`scrollbar_drag_ended`](Self::scrollbar_drag_ended) calls. Useful for + /// consumers that need to distinguish scrollbar drags from wheel/trackpad scrolls, + /// e.g. to suppress auto-scroll behavior during manual positioning. + pub fn is_scrollbar_dragging(&self) -> bool { + self.0.borrow().scrollbar_drag_start_height.is_some() + } + /// Set the offset from the scrollbar pub fn set_offset_from_scrollbar(&self, point: Point) { self.0.borrow_mut().set_offset_from_scrollbar(point); @@ -600,7 +705,10 @@ impl ListState { point(Pixels::ZERO, state.max_scroll_offset()) } - /// Returns the current scroll offset adjusted for the scrollbar + /// Returns the current scroll offset adjusted for the scrollbar. + /// + /// The returned offset has a negative `y` component representing + /// how far the content has scrolled. pub fn scroll_px_offset_for_scrollbar(&self) -> Point { let state = &self.0.borrow(); @@ -613,11 +721,7 @@ impl ListState { let mut cursor = state.items.cursor::(()); let summary: ListItemSummary = cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right); - let content_height = state.items.summary().height; - let drag_offset = - // if dragging the scrollbar, we want to offset the point if the height changed - content_height - state.scrollbar_drag_start_height.unwrap_or(content_height); - let offset = summary.height + logical_scroll_top.offset_in_item - drag_offset; + let offset = summary.height + logical_scroll_top.offset_in_item; Point::new(px(0.), -offset) } @@ -626,15 +730,85 @@ impl ListState { pub fn viewport_bounds(&self) -> Bounds { self.0.borrow().last_layout_bounds.unwrap_or_default() } + + /// Returns whether the item is entirely above the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_above_viewport(&self, ix: usize) -> Option { + let state = self.0.borrow(); + let viewport_bounds = state.last_layout_bounds.unwrap_or_default(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = state.last_layout_scroll_top?; + if scroll_top.item_ix < state.items.summary().count && ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(true); + } + + let item_bounds = state.bounds_for_item_at_scroll_top(ix, scroll_top)?; + Some(item_bounds.bottom() <= viewport_bounds.top()) + } + + /// Returns whether the item is entirely below the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_below_viewport(&self, ix: usize) -> Option { + let state = self.0.borrow(); + let viewport_bounds = state.last_layout_bounds.unwrap_or_default(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = state.last_layout_scroll_top?; + if scroll_top.item_ix < state.items.summary().count && ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(false); + } + + let item_bounds = state.bounds_for_item_at_scroll_top(ix, scroll_top)?; + Some(item_bounds.top() >= viewport_bounds.bottom()) + } } impl StateInner { + fn bounds_for_item_at_scroll_top( + &self, + ix: usize, + scroll_top: ListOffset, + ) -> Option> { + let bounds = self.last_layout_bounds.unwrap_or_default(); + if ix < scroll_top.item_ix { + return None; + } + + let mut cursor = self.items.cursor::>(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + + let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item; + + cursor.seek_forward(&Count(ix), Bias::Right); + if let Some(&ListItem::Measured { size, .. }) = cursor.item() { + let &Dimensions(Count(count), Height(top), _) = cursor.start(); + if count == ix { + let top = bounds.top() + top - scroll_top; + return Some(Bounds::from_corners( + point(bounds.left(), top), + point(bounds.right(), top + size.height), + )); + } + } + None + } + fn max_scroll_offset(&self) -> Pixels { let bounds = self.last_layout_bounds.unwrap_or_default(); let height = self .scrollbar_drag_start_height .unwrap_or_else(|| self.items.summary().height); - (height - bounds.size.height).max(px(0.)) + let padding = self.last_padding.unwrap_or_default(); + (height + padding.top + padding.bottom - bounds.size.height).max(px(0.)) } fn visible_range( @@ -684,6 +858,7 @@ impl StateInner { offset_in_item, }); } + self.last_layout_scroll_top = None; if delta.y > px(0.) { self.follow_state.stop_following(); @@ -829,10 +1004,23 @@ impl StateInner { // maintained after re-measuring. if ix == 0 { if let Some(pending_scroll) = self.pending_scroll.take() { - if pending_scroll.item_ix == scroll_top.item_ix { - scroll_top.offset_in_item = - Pixels(pending_scroll.fraction * element_size.height.0); - self.logical_scroll_top = Some(scroll_top); + match pending_scroll { + PendingScroll::Absolute { item_ix, offset } + if item_ix == scroll_top.item_ix => + { + scroll_top.offset_in_item = offset.min(element_size.height); + self.logical_scroll_top = Some(scroll_top); + } + PendingScroll::Proportional(pending_scroll) + if pending_scroll.item_ix == scroll_top.item_ix => + { + // Ensuring proportional scroll position is + // maintained after re-measuring. + scroll_top.offset_in_item = + Pixels(pending_scroll.fraction * element_size.height.0); + self.logical_scroll_top = Some(scroll_top); + } + _ => {} } } } @@ -1078,12 +1266,31 @@ impl StateInner { let height = bounds.size.height; let padding = self.last_padding.unwrap_or_default(); - let content_height = self.items.summary().height; + // Scrollbar drag positions are computed from the content height + // captured at drag start, so map them back using the same height. + let content_height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.)); - let drag_offset = - // if dragging the scrollbar, we want to offset the point if the height changed - content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); - let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max); + + // If content grew during the drag, the frozen bottom is below the + // live bottom. Treat dragging to the frozen end as resuming tail follow. + let dragged_to_end = + scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.)); + if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) { + self.follow_state = FollowState::Tail { is_following: true }; + if self.alignment == ListAlignment::Bottom { + self.logical_scroll_top = None; + } else { + self.logical_scroll_top = Some(ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }); + } + self.last_layout_scroll_top = None; + return; + } self.follow_state.stop_following(); @@ -1101,6 +1308,7 @@ impl StateInner { offset_in_item, }); } + self.last_layout_scroll_top = None; } } @@ -1233,6 +1441,7 @@ impl Element for List { { let new_items = SumTree::from_iter( state.items.iter().map(|item| ListItem::Unmeasured { + size_hint: None, focus_handle: item.focus_handle(), }), (), @@ -1257,6 +1466,7 @@ impl Element for List { }; state.last_layout_bounds = Some(bounds); + state.last_layout_scroll_top = Some(layout.scroll_top); state.last_padding = Some(padding); ListPrepaintState { hitbox, layout } } @@ -1319,12 +1529,20 @@ impl sum_tree::Item for ListItem { fn summary(&self, _: ()) -> Self::Summary { match self { - ListItem::Unmeasured { focus_handle } => ListItemSummary { + ListItem::Unmeasured { + size_hint, + focus_handle, + } => ListItemSummary { count: 1, rendered_count: 0, unrendered_count: 1, - height: px(0.), + height: if let Some(size) = size_hint { + size.height + } else { + px(0.) + }, has_focus_handles: focus_handle.is_some(), + has_unknown_height: size_hint.is_none(), }, ListItem::Measured { size, focus_handle, .. @@ -1334,6 +1552,7 @@ impl sum_tree::Item for ListItem { unrendered_count: 0, height: size.height, has_focus_handles: focus_handle.is_some(), + has_unknown_height: false, }, } } @@ -1350,6 +1569,7 @@ impl sum_tree::ContextLessSummary for ListItemSummary { self.unrendered_count += summary.unrendered_count; self.height += summary.height; self.has_focus_handles |= summary.has_focus_handles; + self.has_unknown_height |= summary.has_unknown_height; } } @@ -1393,8 +1613,8 @@ mod test { use std::rc::Rc; use crate::{ - self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled, - TestAppContext, Window, div, list, point, px, size, + self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render, + Styled, TestAppContext, Window, div, list, point, px, size, }; #[gpui::test] @@ -1485,6 +1705,169 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + struct TestListView(ListState); + impl Render for TestListView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(20.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + #[gpui::test] + fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) { + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); + } + + #[gpui::test] + fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + } + + #[gpui::test] + fn test_bounds_for_item_uses_last_layout_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(0.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.0.borrow_mut().logical_scroll_top = Some(gpui::ListOffset { + item_ix: 0, + offset_in_item: px(0.), + }); + + assert_eq!(state.bounds_for_item(2).unwrap().top(), px(0.)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(false)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(20.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(2), Some(true)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + } + + #[gpui::test] + fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.scroll_to_end(); + + assert_eq!(state.logical_scroll_top().item_ix, state.item_count()); + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); + } + + #[gpui::test] + fn test_item_viewport_queries_use_resolved_bottom_aligned_scroll_top(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_HEIGHT: f32 = 20.0; + + let state = ListState::new(ITEMS, crate::ListAlignment::Bottom, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_HEIGHT)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(40.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + assert_eq!(state.item_is_above_viewport(7), Some(true)); + assert_eq!(state.item_is_below_viewport(7), Some(false)); + assert_eq!(state.item_is_above_viewport(8), Some(false)); + assert_eq!(state.item_is_below_viewport(8), Some(false)); + assert_eq!(state.item_is_above_viewport(9), Some(false)); + assert_eq!(state.item_is_below_viewport(9), Some(false)); + } + #[gpui::test] fn test_measure_all_after_width_change(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -1515,6 +1898,34 @@ mod test { assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); } + #[gpui::test] + fn test_scrollbar_max_offset_includes_padding(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(1, crate::ListAlignment::Top, px(500.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(500.)).w_full().into_any() + }) + .py_5() + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + assert_eq!(state.max_offset_for_scrollbar().y, px(340.)); + + state.set_offset_from_scrollbar(point(px(0.), px(-340.))); + assert_eq!(state.scroll_px_offset_for_scrollbar().y, px(-340.)); + } + #[gpui::test] fn test_remeasure(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -1580,4 +1991,601 @@ mod test { assert_eq!(offset.item_ix, 2); assert_eq!(offset.offset_in_item, px(20.)); } + + #[gpui::test] + fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + item_height.set(200); + state.remeasure_items(5..6); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(40.)); + } + + #[gpui::test] + fn test_remeasure_uses_proportional_anchor_after_pending_item_remeasure( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.remeasure_items(5..6); + item_height.set(50); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(20.)); + } + + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Items grow. 10 × 80 = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Negative-offset scrollbar contract (upstream 1c61cc3fc2). + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let last_item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView { + state: ListState, + last_item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let last_item_height = self.last_item_height.clone(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 9 { + last_item_height.get() + } else { + 50 + }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state.clone(), + last_item_height: last_item_height.clone(), + }) + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.scrollbar_drag_started(); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar(); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + + // Content grows during the drag — frozen height must keep the scrollbar + // thumb position stable. + last_item_height.set(550); + state.remeasure_items(9..10); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + assert_eq!( + state.scroll_px_offset_for_scrollbar(), + scrollbar_offset_before_growth + ); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + + /// Bottom-aligned end-state contract: fork preserves + /// `logical_scroll_top = None` (per Batch C decision C1), and the public + /// `logical_scroll_top()` resolves that to `item_ix == item_count`. + /// Scrollbar offset math must still report the list as fully scrolled. + #[gpui::test] + fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_SIZE: f32 = 50.0; + + let state = ListState::new( + ITEMS, + crate::ListAlignment::Bottom, + px(ITEMS as f32 * ITEM_SIZE), + ); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_SIZE)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + + let max_offset = state.max_offset_for_scrollbar(); + let scroll_offset = state.scroll_px_offset_for_scrollbar(); + + assert_eq!( + -scroll_offset.y, max_offset.y, + "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom", + -scroll_offset.y, max_offset.y, + ); + } + + /// Scroll-wheel away from bottom suspends follow_tail; scrolling back + /// re-engages on the next paint. + #[gpui::test] + fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(50.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))), + ..Default::default() + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom" + ); + } + + /// Re-engagement uses fresh measurements: an unmeasured item temporarily + /// reducing summary.height must not fool the bottom-check. + #[gpui::test] + fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(20, crate::ListAlignment::Top, px(1000.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(200.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + state.remeasure_items(19..20); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + !state.is_following_tail(), + "follow_tail should not falsely re-engage due to an unmeasured item \ + reducing items.summary().height" + ); + } + + #[gpui::test] + fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + assert!(!state.is_following_tail()); + + state.set_offset_from_scrollbar(point(px(0.), px(-300.))); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom via the scrollbar" + ); + } + + /// When the user drags the scrollbar all the way to the bottom while + /// content grows mid-drag (so the frozen scroll height is less than the live + /// scroll height), `set_offset_from_scrollbar` re-engages follow_tail. + /// + /// Fork adaptation: bottom-aligned lists still store their end position as + /// `None`; top-aligned lists use the upstream `item_count` anchor. + #[gpui::test] + fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + state.scrollbar_drag_started(); + + state.splice(10..10, 10); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.set_offset_from_scrollbar(point(px(0.), px(-300.))); + state.scrollbar_drag_ended(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + assert!( + state.is_following_tail(), + "follow_tail should re-engage when the user drags the scrollbar to \ + the bottom of its track, even when content has grown during the drag \ + (so frozen_bottom < live_bottom)" + ); + } } diff --git a/crates/gpui/src/elements/retained_layer.rs b/crates/gpui/src/elements/retained_layer.rs index e02085e8..5ba892c7 100644 --- a/crates/gpui/src/elements/retained_layer.rs +++ b/crates/gpui/src/elements/retained_layer.rs @@ -178,6 +178,9 @@ impl Element for RetainedLayerElement { state.content_revision != content_revision || state.bounds != bounds || state.content_mask != content_mask + // Repaint while accessibility is active so descendants + // re-emit nodes, bounds, and action listeners each frame. + || window.a11y.is_active() } None => true, }; diff --git a/crates/gpui/src/elements/retained_layer_tests.rs b/crates/gpui/src/elements/retained_layer_tests.rs index 5b557d56..3316b707 100644 --- a/crates/gpui/src/elements/retained_layer_tests.rs +++ b/crates/gpui/src/elements/retained_layer_tests.rs @@ -1,8 +1,8 @@ -use std::{cell::Cell, rc::Rc}; +use std::{cell::Cell, rc::Rc, sync::Arc}; use crate::{ self as gpui, App, Bounds, Context, Element, ElementId, GlobalElementId, InspectorElementId, - IntoElement, LayoutId, Render, Style, TestAppContext, Window, fill, px, size, white, + IntoElement, LayoutId, Render, Style, TestAppContext, Window, fill, point, px, size, white, }; use super::*; @@ -77,6 +77,96 @@ impl Element for CountingElement { } } +struct A11yCountingElement { + paint_count: Rc>, + action_count: Rc>, +} + +impl IntoElement for A11yCountingElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for A11yCountingElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("retained-child".into()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + Some(accesskit::Role::Button) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.add_action(accesskit::Action::Click); + } + + fn request_layout( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ( + window.request_layout( + Style { + size: size(px(10.).into(), px(10.).into()), + ..Default::default() + }, + [], + cx, + ), + (), + ) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _state: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) { + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + self.paint_count.set(self.paint_count.get() + 1); + window.paint_quad(fill(bounds, white())); + + if window.a11y.is_active() { + let node_id = window + .a11y + .node_id_for_existing(global_id.expect("a11y child must have a global id")) + .expect("a11y child should have a node id"); + let action_count = self.action_count.clone(); + window.on_a11y_action(node_id, accesskit::Action::Click, move |_, _, _| { + action_count.set(action_count.get() + 1); + }); + } + } +} + #[gpui::test] fn retained_layer_replays_child_paint_on_compositor_only_update(cx: &mut TestAppContext) { let paint_count = Rc::new(Cell::new(0)); @@ -109,6 +199,55 @@ fn retained_layer_replays_child_paint_on_compositor_only_update(cx: &mut TestApp assert_eq!(third_layer.content_revision, 1.into()); } +#[gpui::test] +fn retained_layer_a11y(cx: &mut TestAppContext) { + let paint_count = Rc::new(Cell::new(0)); + let action_count = Rc::new(Cell::new(0)); + let cx = cx.add_empty_window(); + + draw_a11y_retained_layer(cx, paint_count.clone(), action_count.clone(), 0, 1.0); + finish_frame(cx); + + assert_eq!(paint_count.get(), 1); + + draw_a11y_retained_layer(cx, paint_count.clone(), action_count.clone(), 0, 0.25); + finish_frame(cx); + + assert_eq!(paint_count.get(), 2); + + let child_id = cx + .update(|window, _| { + window + .a11y + .node_id_for_existing(&retained_child_global_id()) + }) + .expect("retained child should have an a11y node id"); + cx.update(|window, _| { + assert!( + window.a11y.node_bounds.contains_key(&child_id), + "retained child should be emitted into the active a11y frame" + ); + assert!( + window.a11y.action_listeners.contains_key(&child_id), + "retained child should rebuild its a11y action listener" + ); + }); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: child_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(action_count.get(), 1); +} + fn draw_retained_layer( cx: &mut crate::VisualTestContext, paint_count: Rc>, @@ -133,6 +272,46 @@ fn draw_retained_layer( }); } +fn draw_a11y_retained_layer( + cx: &mut crate::VisualTestContext, + paint_count: Rc>, + action_count: Rc>, + revision: u64, + opacity: f32, +) { + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + window.a11y.begin_frame(); + + window.invalidator.set_phase(crate::DrawPhase::Prepaint); + let mut element = crate::Drawable::new( + A11yCountingElement { + paint_count, + action_count, + } + .with_retained_layer("layer", revision) + .opacity(opacity), + ); + element.layout_as_root(size(px(100.), px(100.)).into(), window, cx); + window.with_absolute_element_offset(point(px(0.), px(0.)), |window| { + element.prepaint(window, cx) + }); + + window.invalidator.set_phase(crate::DrawPhase::Paint); + element.paint(window, cx); + window.invalidator.set_phase(crate::DrawPhase::None); + + window.a11y.end_frame(); + }); +} + +fn retained_child_global_id() -> GlobalElementId { + GlobalElementId(Arc::from([ + ElementId::from("layer"), + ElementId::from("retained-child"), + ])) +} + fn finish_frame(cx: &mut crate::VisualTestContext) { cx.update(|window, _| { window.next_frame.finish(&mut window.rendered_frame); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index e575a186..59c87bb6 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -13,11 +13,182 @@ use std::{ borrow::Cow, cell::{Cell, RefCell}, mem, - ops::Range, + ops::{Deref, DerefMut, Range}, rc::Rc, sync::Arc, }; +/// An [`Element`] that renders accessible text. +#[derive(Debug, Clone)] +pub struct Text { + id: Option, + text: SharedString, +} + +impl Text { + /// Create a new [`Text`] element with a specific ID. + #[inline] + pub const fn new(id: ElementId, text: SharedString) -> Self { + Self { id: Some(id), text } + } + + /// Create a new [`Text`] element that is inaccessible to screen readers. + #[inline] + pub const fn new_inaccessible(text: SharedString) -> Self { + Self { id: None, text } + } + + /// The ID of this [`Text`] element. + #[inline] + pub const fn id(&self) -> Option<&ElementId> { + self.id.as_ref() + } + + /// Produce a new [`Text`] with the given `id`, replacing any default + /// macro-generated ID. + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// The text that this [`Text`] element will display. + #[inline] + pub const fn text(&self) -> &SharedString { + &self.text + } +} + +impl Deref for Text { + type Target = SharedString; + + fn deref(&self) -> &Self::Target { + &self.text + } +} + +impl DerefMut for Text { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.text + } +} + +/// Trivial hash function for the location information produced by the [`text`] +/// macro. Not covered by semver guarantees. +#[doc(hidden)] +pub const fn __hash_text_macro_location_unstable_do_not_use(s: &'static str) -> u64 { + const BASIS: u64 = 0xcbf29ce484222325; + const PRIME: u64 = 0x100000001b3; + + let bytes = s.as_bytes(); + let mut hash = BASIS; + let mut i = 0; + while i < bytes.len() { + hash ^= bytes[i] as u64; + hash = hash.wrapping_mul(PRIME); + i += 1; + } + hash +} + +/// Create a new accessible [`Text`] element. +/// +/// The `text!("...")` form assigns an ID derived from the macro call site +/// (`file!`, `line!`, and `column!`). This gives one call site a stable ID +/// across frames, but repeated calls through the same helper or loop body will +/// reuse that ID. When rendering repeated text elements, pass a unique ID with +/// `text!(id = ..., ...)` or [`Text::with_id`]. +#[macro_export] +macro_rules! text { + (id = $id:expr, $text:expr) => {{ $crate::Text::new($id.into(), $text.into()) }}; + ($text:expr) => {{ + const ID: &'static str = concat!(file!(), "/", line!(), ":", column!()); + const HASH: u64 = $crate::__hash_text_macro_location_unstable_do_not_use(ID); + $crate::Text::new($crate::ElementId::Integer(HASH), $text.into()) + }}; +} + +impl IntoElement for Text { + type Element = Self; + + #[inline] + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Text { + type RequestLayoutState = TextLayout; + type PrepaintState = (); + + fn id(&self) -> Option { + self.id.clone() + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + self.id.is_some().then_some(accesskit::Role::Label) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.set_value(self.text.to_string()); + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ::request_layout(&mut self.text, id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + ::prepaint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + window, + cx, + ) + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + ::paint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + } +} + impl Element for &'static str { type RequestLayoutState = TextLayout; type PrepaintState = (); @@ -395,13 +566,26 @@ impl TextLayout { let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); let (text, runs) = if let Some(truncate_width) = truncate_width { - line_wrapper.truncate_line( - text.clone(), - truncate_width, - &truncation_affix, - &runs, - truncate_from, - ) + if let Some(max_lines) = text_style.line_clamp + && let Some(wrap_width) = wrap_width + { + line_wrapper.truncate_wrapped_line( + text.clone(), + wrap_width, + max_lines, + &truncation_affix, + &runs, + truncate_from, + ) + } else { + line_wrapper.truncate_line( + text.clone(), + truncate_width, + &truncation_affix, + &runs, + truncate_from, + ) + } } else { (text.clone(), Cow::Borrowed(&*runs)) }; @@ -935,9 +1119,11 @@ impl IntoElement for InteractiveText { #[cfg(test)] mod tests { + use crate::{ElementId, SharedString, Text}; + #[test] fn test_into_element_for() { - use crate::{ParentElement as _, SharedString, div}; + use crate::{ParentElement as _, div}; use std::borrow::Cow; let _ = div().child("static str"); @@ -945,4 +1131,41 @@ mod tests { let _ = div().child(Cow::Borrowed("Cow")); let _ = div().child(SharedString::from("SharedString")); } + + #[test] + fn text_macro_default_id_is_source_location_based() { + fn text_from_same_call_site() -> ElementId { + crate::text!("item").id().cloned().unwrap() + } + + let first = text_from_same_call_site(); + let second = text_from_same_call_site(); + let separate_call_site = crate::text!("item").id().cloned().unwrap(); + + assert_eq!(first, second); + assert_ne!(first, separate_call_site); + } + + #[test] + fn text_macro_accepts_explicit_ids_for_repeated_text() { + let first = crate::text!(id = ("item", 0usize), "item"); + let second = crate::text!(id = ("item", 1usize), "item"); + let overridden = + Text::new_inaccessible(SharedString::from("item")).with_id(("item", 2usize)); + + assert_eq!( + first.id(), + Some(&ElementId::from((SharedString::new_static("item"), 0usize))) + ); + assert_eq!( + second.id(), + Some(&ElementId::from((SharedString::new_static("item"), 1usize))) + ); + assert_eq!( + overridden.id(), + Some(&ElementId::from((SharedString::new_static("item"), 2usize))) + ); + assert_ne!(first.id(), second.id()); + assert_ne!(second.id(), overridden.id()); + } } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index a7486f0c..0e423a07 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -8,7 +8,7 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, - StyleRefinement, Styled, Window, point, size, + StyleRefinement, Styled, Window, point, px, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize}; @@ -236,6 +236,22 @@ impl UniformListScrollHandle { } } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let max_offset = state.base_handle.max_offset(); + if max_offset.height <= px(0.) { + return None; + } + let offset = state.base_handle.offset(); + Some(if state.y_flipped { + offset.y == px(0.) + } else { + -offset.y >= max_offset.height + }) + } + /// Scroll to the bottom of the list. pub fn scroll_to_bottom(&self) { self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom); @@ -707,7 +723,82 @@ impl InteractiveElement for UniformList { #[cfg(test)] mod test { - use crate::TestAppContext; + use crate::{TestAppContext, UniformListScrollHandle}; + + fn draw_scroll_end_test_list( + cx: &mut TestAppContext, + handle: UniformListScrollHandle, + y_flipped: bool, + ) { + use crate::{ + Context, IntoElement, Render, Window, div, point, prelude::*, px, size, uniform_list, + }; + + struct TestView { + handle: UniformListScrollHandle, + y_flipped: bool, + } + + impl Render for TestView { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + let list = uniform_list("items", 10, |range, _, _| { + range + .map(|ix| div().id(ix).h(px(10.)).child(format!("Item {ix}"))) + .collect() + }) + .track_scroll(&self.handle) + .h(px(30.)); + + if self.y_flipped { + list.y_flipped(true) + } else { + list + } + } + } + + let cx = cx.add_empty_window(); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(30.)), |_, cx| { + cx.new(|_| TestView { handle, y_flipped }) + .into_any_element() + }); + } + + #[gpui::test] + fn test_is_scrolled_to_end(cx: &mut TestAppContext) { + use crate::{UniformListScrollHandle, point, px}; + + let handle = UniformListScrollHandle::new(); + draw_scroll_end_test_list(cx, handle.clone(), false); + + assert_eq!(handle.is_scrolled_to_end(), Some(false)); + + let base_handle = handle.0.borrow().base_handle.clone(); + base_handle.set_offset(point(px(0.), -base_handle.max_offset().height)); + + assert_eq!(handle.is_scrolled_to_end(), Some(true)); + } + + #[gpui::test] + fn test_is_scrolled_to_end_with_flipped_list(cx: &mut TestAppContext) { + use crate::{UniformListScrollHandle, point, px}; + + let handle = UniformListScrollHandle::new(); + draw_scroll_end_test_list(cx, handle.clone(), true); + + let base_handle = handle.0.borrow().base_handle.clone(); + base_handle.set_offset(point(px(0.), -base_handle.max_offset().height)); + + assert_eq!(handle.is_scrolled_to_end(), Some(false)); + + base_handle.set_offset(point(px(0.), px(0.))); + + assert_eq!(handle.is_scrolled_to_end(), Some(true)); + } #[gpui::test] fn test_scroll_strategy_nearest(cx: &mut TestAppContext) { diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 2f8a6855..bb5eb23a 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,7 +1,7 @@ use crate::{App, PlatformDispatcher, PlatformScheduler}; use futures::channel::mpsc; use futures::prelude::*; -use gpui_util::TryFutureExt; +use gpui_util::{TryFutureExt, TryFutureExtBacktrace}; use scheduler::Scheduler; use std::{ future::Future, @@ -13,7 +13,7 @@ use std::{ time::{Duration, Instant}, }; -pub use scheduler::{FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority}; +pub use scheduler::{FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task}; /// A pointer to the executor that is currently running, /// for spawning background tasks. @@ -27,90 +27,42 @@ pub struct BackgroundExecutor { /// for spawning tasks on the main thread. #[derive(Clone)] pub struct ForegroundExecutor { - inner: scheduler::ForegroundExecutor, + inner: scheduler::LocalExecutor, dispatcher: Arc, not_send: PhantomData>, } -/// Task is a primitive that allows work to happen in the background. +/// Extension trait for `Task>` that adds `detach_and_log_err` with an `&App` context. /// -/// It implements [`Future`] so you can `.await` on it. -/// -/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows -/// the task to continue running, but with no way to return a value. -#[must_use] -#[derive(Debug)] -pub struct Task(scheduler::Task); - -impl Task { - /// Creates a new task that will resolve with the value. - pub fn ready(val: T) -> Self { - Task(scheduler::Task::ready(val)) - } - - /// Returns true if the task has completed or was created with `Task::ready`. - pub fn is_ready(&self) -> bool { - self.0.is_ready() - } - - /// Detaching a task runs it to completion in the background. - pub fn detach(self) { - self.0.detach() - } - - /// Wraps a scheduler::Task. - pub fn from_scheduler(task: scheduler::Task) -> Self { - Task(task) - } - - /// Converts this task into a fallible task that returns `Option`. - /// - /// Unlike the standard `Task`, a [`FallibleTask`] will return `None` - /// if the task was cancelled. - /// - /// # Example - /// - /// ```ignore - /// // Background task that gracefully handles cancellation: - /// cx.background_spawn(async move { - /// let result = foreground_task.fallible().await; - /// if let Some(value) = result { - /// // Process the value - /// } - /// // If None, task was cancelled - just exit gracefully - /// }).detach(); - /// ``` - pub fn fallible(self) -> FallibleTask { - self.0.fallible() - } +/// This trait is implemented for `Task>` where `T: 'static` and +/// `E: 'static + std::fmt::Display + std::fmt::Debug`. +pub trait TaskExt { + /// Run the task to completion in the background and log any errors that occur. + fn detach_and_log_err(self, cx: &App); + /// Like [`Self::detach_and_log_err`], but uses `{:?}` formatting on failure so `anyhow::Error` + /// values emit their full backtrace. Prefer `detach_and_log_err` unless a backtrace is wanted. + fn detach_and_log_err_with_backtrace(self, cx: &App); } -impl Task> +impl TaskExt for Task> where T: 'static, - E: 'static + std::fmt::Display, + E: 'static + std::fmt::Display + std::fmt::Debug, { - /// Run the task to completion in the background and log any errors that occur. #[track_caller] - pub fn detach_and_log_err(self, cx: &App) { + fn detach_and_log_err(self, cx: &App) { let location = core::panic::Location::caller(); cx.foreground_executor() .spawn(self.log_tracked_err(*location)) .detach(); } -} - -impl std::future::Future for Task { - type Output = T; - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - // SAFETY: Task is a repr(transparent) wrapper around scheduler::Task, - // and we're just projecting the pin through to the inner task. - let inner = unsafe { self.map_unchecked_mut(|t| &mut t.0) }; - inner.poll(cx) + #[track_caller] + fn detach_and_log_err_with_backtrace(self, cx: &App) { + let location = *core::panic::Location::caller(); + cx.foreground_executor() + .spawn(self.log_tracked_err_with_backtrace(location)) + .detach(); } } @@ -163,66 +115,12 @@ impl BackgroundExecutor { R: Send + 'static, { if priority == Priority::RealtimeAudio { - Task::from_scheduler(self.inner.spawn_realtime(future)) + self.inner.spawn_realtime(future) } else { - Task::from_scheduler(self.inner.spawn_with_priority(priority, future)) + self.inner.spawn_with_priority(priority, future) } } - /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. - /// - /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to - /// completion before the current task is resumed, even if the current task is slated for cancellation. - pub async fn await_on_background(&self, future: impl Future + Send) -> R - where - R: Send, - { - use crate::RunnableMeta; - use parking_lot::{Condvar, Mutex}; - - struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for NotifyOnDrop<'_> { - fn drop(&mut self) { - *self.0.1.lock() = true; - self.0.0.notify_all(); - } - } - - struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for WaitOnDrop<'_> { - fn drop(&mut self) { - let mut done = self.0.1.lock(); - if !*done { - self.0.0.wait(&mut done); - } - } - } - - let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - - let pair = &(Condvar::new(), Mutex::new(false)); - let _wait_guard = WaitOnDrop(pair); - - let (runnable, task) = unsafe { - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn_unchecked( - move |_| async { - let _notify_guard = NotifyOnDrop(pair); - future.await - }, - move |runnable| { - dispatcher.dispatch(runnable, Priority::default()); - }, - ) - }; - runnable.schedule(); - task.await - } - /// Scoped lets you start a number of tasks and waits /// for all of them to complete before returning. pub async fn scoped<'scope, F>(&self, scheduler: F) @@ -388,18 +286,29 @@ impl ForegroundExecutor { ) } else { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + let inner = platform_scheduler.foreground_executor(); + return Self { + inner, + dispatcher, + not_send: PhantomData, + }; }; #[cfg(not(any(test, feature = "test-support")))] - let (scheduler, session_id): (Arc, _) = { + let inner = { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + platform_scheduler.foreground_executor() }; - let inner = scheduler::ForegroundExecutor::new(session_id, scheduler); + #[cfg(any(test, feature = "test-support"))] + let inner = { + let scheduler_for_dispatch = Arc::downgrade(&scheduler); + scheduler::LocalExecutor::new(session_id, scheduler, move |runnable| { + if let Some(scheduler) = scheduler_for_dispatch.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + }; Self { inner, @@ -414,7 +323,7 @@ impl ForegroundExecutor { where R: 'static, { - Task::from_scheduler(self.inner.spawn(future.boxed_local())) + self.inner.spawn(future.boxed_local()) } /// Enqueues the given Task to run on the main thread with the given priority. @@ -428,7 +337,7 @@ impl ForegroundExecutor { R: 'static, { // Priority is ignored for foreground tasks - they run in order on the main thread - Task::from_scheduler(self.inner.spawn(future)) + self.inner.spawn(future) } /// Used by the test harness to run an async test in a synchronous fashion. @@ -474,7 +383,7 @@ impl ForegroundExecutor { } #[doc(hidden)] - pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor { + pub fn scheduler_executor(&self) -> SchedulerLocalExecutor { self.inner.clone() } } @@ -594,4 +503,15 @@ mod test { "Task should run normally when app is alive" ); } + + #[test] + fn task_ext_remains_available_from_gpui_prelude() { + use crate::prelude::*; + + let (dispatcher, _background_executor, app) = create_test_app(); + let task: Task> = Task::ready(Ok(())); + + task.detach_and_log_err(&app.borrow()); + dispatcher.run_until_parked(); + } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 7a8866c7..b94fd71d 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -53,6 +53,8 @@ mod util; mod view; mod window; +#[cfg(doc)] +pub mod _accessibility; #[cfg(doc)] pub mod _ownership_and_data_flow; @@ -72,6 +74,9 @@ mod seal { pub trait Sealed {} } +pub use accesskit; +pub use accesskit::Action as AccessibleAction; +pub use accesskit::{Orientation, Role, Toggled}; pub use action::*; pub use anyhow::Result; pub use app::*; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 3502f956..a306fc1c 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -204,6 +204,16 @@ pub trait Platform: 'static { fn path_for_auxiliary_executable(&self, name: &str) -> Result; fn set_cursor_style(&self, style: CursorStyle); + + /// Hides the mouse cursor until the user moves the mouse over one of + /// this application's windows. + fn hide_cursor_until_mouse_moves(&self) {} + + /// Returns whether the mouse cursor is currently visible. + fn is_cursor_visible(&self) -> bool { + true + } + fn should_auto_hide_scrollbars(&self) -> bool; fn read_from_clipboard(&self) -> Option; @@ -310,27 +320,41 @@ pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); /// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] -pub struct DisplayId(pub(crate) u32); +pub struct DisplayId(pub(crate) u64); impl DisplayId { /// Create a new `DisplayId` from a raw platform display identifier. - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { + Self(id) + } +} + +impl From for DisplayId { + fn from(id: u64) -> Self { Self(id) } } impl From for DisplayId { fn from(id: u32) -> Self { - Self(id) + Self(u64::from(id)) } } -impl From for u32 { +impl From for u64 { fn from(id: DisplayId) -> Self { id.0 } } +impl TryFrom for u32 { + type Error = std::num::TryFromIntError; + + fn try_from(id: DisplayId) -> std::result::Result { + Self::try_from(id.0) + } +} + impl Debug for DisplayId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "DisplayId({})", self.0) @@ -436,6 +460,16 @@ impl Tiling { } } +/// Callbacks for the accessibility adapter. +pub struct A11yCallbacks { + /// Called when the adapter is activated. + pub activation: Box Option + Send + 'static>, + /// Called when an accessibility action is requested. + pub action: Box, + /// Called when the adapter is deactivated. + pub deactivation: Box, +} + #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] #[expect(missing_docs)] pub struct RequestFrameOptions { @@ -549,6 +583,15 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn play_system_bell(&self) {} + /// Initialize the accessibility adapter with callbacks. + fn a11y_init(&self, _callbacks: A11yCallbacks) {} + + /// Provide a TreeUpdate to the accessibility adapter. + fn a11y_tree_update(&self, _tree_update: accesskit::TreeUpdate) {} + + /// Inform the adapter of updated window bounds. + fn a11y_update_window_bounds(&self) {} + #[cfg(any(test, feature = "test-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None @@ -1754,6 +1797,7 @@ pub enum CursorStyle { ContextualMenu, /// Hide the cursor + /// corresponds to the CSS cursor value `none` None, } diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 8a8479fb..8bf77dc9 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -134,8 +134,7 @@ impl PlatformDispatcher for TestDispatcher { } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - self.scheduler - .schedule_foreground(self.session_id, runnable); + self.scheduler.schedule_local(self.session_id, runnable); } fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index a52c59c0..e3388d2e 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -404,6 +404,12 @@ impl Platform for TestPlatform { *self.active_cursor.lock() = style; } + fn hide_cursor_until_mouse_moves(&self) {} + + fn is_cursor_visible(&self) -> bool { + true + } + fn should_auto_hide_scrollbars(&self) -> bool { false } diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 8b9bec7e..3719a3ee 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -202,6 +202,14 @@ impl Platform for VisualTestPlatform { self.platform.set_cursor_style(style) } + fn hide_cursor_until_mouse_moves(&self) { + self.platform.hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.platform.is_cursor_visible() + } + fn should_auto_hide_scrollbars(&self) -> bool { self.platform.should_auto_hide_scrollbars() } diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index d056f470..1d5422d7 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -2,8 +2,12 @@ use crate::{PlatformDispatcher, RunnableMeta}; use async_task::Runnable; use chrono::{DateTime, Utc}; use futures::channel::oneshot; -use scheduler::{Clock, Priority, Scheduler, SessionId, TestScheduler, Timer}; +use scheduler::{ + Clock, LocalExecutor, Priority, Scheduler, SessionId, Task, TestScheduler, Timer, + spawn_dedicated_thread, +}; use std::{ + any::Any, future::Future, pin::Pin, sync::{ @@ -34,9 +38,21 @@ impl PlatformScheduler { } } - pub fn allocate_session_id(&self) -> SessionId { + fn next_session_id(&self) -> SessionId { SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst)) } + + pub fn foreground_executor(self: &Arc) -> LocalExecutor { + let session_id = self.next_session_id(); + // Detached local tasks can leave runnables or wakers behind; scheduling + // must not keep the platform scheduler alive after callers drop it. + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + } } impl Scheduler for PlatformScheduler { @@ -78,7 +94,7 @@ impl Scheduler for PlatformScheduler { } } - fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, _session_id: SessionId, runnable: Runnable) { self.dispatcher .dispatch_on_main_thread(runnable, Priority::default()); } @@ -102,7 +118,7 @@ impl Scheduler for PlatformScheduler { // Create a runnable that will send the completion signal let location = std::panic::Location::caller(); - let (runnable, _task) = async_task::Builder::new() + let (runnable, task) = async_task::Builder::new() .metadata(RunnableMeta { location }) .spawn( move |_| async move { @@ -112,6 +128,7 @@ impl Scheduler for PlatformScheduler { dispatcher.dispatch_after(duration, runnable); }, ); + task.detach(); runnable.schedule(); Timer::new(rx) @@ -121,6 +138,21 @@ impl Scheduler for PlatformScheduler { self.clock.clone() } + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.next_session_id(); + spawn_dedicated_thread(session_id, self, move |executor| f(executor)) + } + fn as_test(&self) -> Option<&TestScheduler> { None } diff --git a/crates/gpui/src/prelude.rs b/crates/gpui/src/prelude.rs index 191d0a0e..b5185a25 100644 --- a/crates/gpui/src/prelude.rs +++ b/crates/gpui/src/prelude.rs @@ -5,5 +5,5 @@ pub use crate::{ AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage, - VisualContext, util::FluentBuilder, + TaskExt as _, VisualContext, util::FluentBuilder, }; diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index e1104782..ed27b3c2 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -339,6 +339,7 @@ pub enum Primitive { impl Primitive { pub fn bounds(&self) -> &Bounds { match self { + Primitive::Shadow(shadow) if shadow.inset != 0 => &shadow.element_bounds, Primitive::Shadow(shadow) => &shadow.bounds, Primitive::BackdropBlur(blur) => &blur.bounds, Primitive::Quad(quad) => &quad.bounds, @@ -672,6 +673,11 @@ pub struct Shadow { pub corner_radii: Corners, pub content_mask: ContentMask, pub color: Hsla, + pub element_bounds: Bounds, + pub element_corner_radii: Corners, + /// 0 = drop shadow (rendered outside the element), 1 = inset shadow (rendered inside). + pub inset: u32, + pub pad: u32, // align to 8 bytes } impl From for Primitive { @@ -1183,4 +1189,27 @@ mod tests { assert_eq!(next_scene.paint_operations.len(), 2); assert_eq!(next_scene.retained_layers[0].paint_range, 1..2); } + + #[test] + fn shadow_bounds_treats_any_nonzero_inset_as_inset() { + let bounds = test_bounds(); + let element_bounds = Bounds::new( + point(ScaledPixels(1.0), ScaledPixels(1.0)), + size(ScaledPixels(2.0), ScaledPixels(2.0)), + ); + let shadow = Primitive::Shadow(Shadow { + order: 0, + blur_radius: ScaledPixels(0.0), + bounds, + corner_radii: Corners::all(ScaledPixels(0.0)), + content_mask: ContentMask { bounds }, + color: Hsla::default(), + element_bounds, + element_corner_radii: Corners::all(ScaledPixels(0.0)), + inset: 2, + pad: 0, + }); + + assert_eq!(shadow.bounds(), &element_bounds); + } } diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 7f8e66a9..c5bb61a9 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -357,6 +357,9 @@ pub struct BoxShadow { pub blur_radius: Pixels, /// How much should the shadow spread? pub spread_radius: Pixels, + /// Whether this is an inset shadow (drawn inside the element's bounds). + #[serde(default)] + pub inset: bool, } /// How to handle whitespace in text @@ -671,7 +674,7 @@ impl Style { .to_pixels(rem_size) .clamp_radii_for_quad_size(bounds.size); - window.paint_shadows(bounds, corner_radii, &self.box_shadow); + window.paint_drop_shadows(bounds, corner_radii, &self.box_shadow); if let Some(blur_radius) = self.backdrop_blur { window.paint_backdrop_blur(bounds, corner_radii, blur_radius); } @@ -703,6 +706,8 @@ impl Style { )); } + window.paint_inset_shadows(bounds, corner_radii, &self.box_shadow); + continuation(window, cx); if self.is_border_visible() { @@ -1492,4 +1497,33 @@ mod tests { style.text_style().unwrap().font_weight ); } + + #[test] + fn test_box_shadow_inset_deserialization_compatibility() { + let shadow = BoxShadow { + color: red(), + offset: Default::default(), + blur_radius: px(1.), + spread_radius: px(2.), + inset: true, + }; + + let legacy_json = { + let mut legacy = serde_json::to_value(&shadow).unwrap(); + legacy.as_object_mut().unwrap().remove("inset"); + legacy + }; + let legacy_shadow: BoxShadow = serde_json::from_value(legacy_json).unwrap(); + assert_eq!( + legacy_shadow, + BoxShadow { + inset: false, + ..shadow.clone() + } + ); + + let roundtripped_shadow: BoxShadow = + serde_json::from_str(&serde_json::to_string(&shadow).unwrap()).unwrap(); + assert_eq!(roundtripped_shadow, shadow); + } } diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index b62a0ad6..b6754ade 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -207,8 +207,10 @@ impl TextSystem { Ok(result * font_size) } - // Consider removing this? - /// Returns the shaped layout width of for the given character, in the given font and size. + /// Returns this text system's shaped layout width for the given character. + /// + /// This intentionally goes through the platform text system, while + /// [`WindowTextSystem::layout_width`] uses the window-local line layout cache. pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { let mut buffer = [0; 4]; let buffer = ch.encode_utf8(&mut buffer); @@ -238,8 +240,10 @@ impl TextSystem { Ok(self.advance(font_id, font_size, 'm')?.width) } - // Consider removing this? - /// Returns the shaped layout width of an `em`. + /// Returns this text system's shaped layout width of an `em`. + /// + /// This intentionally goes through the platform text system, while + /// [`WindowTextSystem::em_layout_width`] uses the window-local line layout cache. pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.layout_width(font_id, font_size, 'm') } @@ -698,6 +702,34 @@ impl WindowTextSystem { layout } + /// Returns the window-local cached shaped layout width for the given character. + /// + /// This intentionally uses the line layout cache, while + /// [`TextSystem::layout_width`] goes directly through the platform text system. + pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { + let mut buffer = [0; 4]; + let buffer: &_ = ch.encode_utf8(&mut buffer); + self.line_layout_cache + .layout_line( + buffer, + font_size, + &[FontRun { + len: buffer.len(), + font_id, + }], + None, + ) + .width + } + + /// Returns the window-local cached shaped layout width of an `em`. + /// + /// This intentionally uses the line layout cache, while + /// [`TextSystem::em_layout_width`] goes directly through the platform text system. + pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { + self.layout_width(font_id, font_size, 'm') + } + /// Probe the line layout cache using a caller-provided content hash, without allocating. /// /// Returns `Some(layout)` if the layout is already cached in either the current frame diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index fc3da3be..a0c9c2df 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -1,6 +1,7 @@ use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px}; use collections::HashMap; use std::{borrow::Cow, iter, sync::Arc}; +use unicode_general_category::{GeneralCategory, get_general_category}; /// Determines whether to truncate text from the start or end. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -196,15 +197,7 @@ impl LineWrapper { if let Some(truncate_ix) = self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { - let result = match truncate_from { - TruncateFrom::Start => SharedString::from(format!( - "{truncation_affix}{}", - &line[line.ceil_char_boundary(truncate_ix + 1)..] - )), - TruncateFrom::End => { - SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix])) - } - }; + let result = truncated_line_text(&line, truncate_ix, truncation_affix, truncate_from); let mut runs = runs.to_vec(); update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); (result, Cow::Owned(runs)) @@ -213,6 +206,343 @@ impl LineWrapper { } } + /// Truncate text to fit within a given number of wrapped lines. + pub fn truncate_wrapped_line<'a>( + &mut self, + text: SharedString, + wrap_width: Pixels, + max_lines: usize, + truncation_affix: &str, + runs: &'a [TextRun], + truncate_from: TruncateFrom, + ) -> (SharedString, Cow<'a, [TextRun]>) { + if max_lines == 0 { + return (SharedString::from(""), Cow::Owned(Vec::new())); + } + + if max_lines <= 1 { + return self.truncate_single_wrapped_line( + text, + wrap_width, + truncation_affix, + runs, + truncate_from, + ); + } + + if truncate_from == TruncateFrom::Start { + return self.truncate_wrapped_line_start( + text, + wrap_width, + max_lines, + truncation_affix, + runs, + ); + } + + let affix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width); + + let mut width = px(0.); + let mut line = 0; + let mut first_non_whitespace_ix = None; + let mut indent = None; + let mut last_candidate_ix = 0; + let mut last_candidate_width = px(0.); + let mut last_wrap_ix = 0; + let mut prev_c = '\0'; + let mut truncate_ix = 0; + + for (ix, c) in text.char_indices() { + if c == '\n' { + if line >= max_lines - 1 + && text + .get(ix + 1..) + .is_some_and(|suffix| !suffix.trim().is_empty()) + { + let result = SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&text[..truncate_ix], truncation_affix) + )); + let mut runs = runs.to_vec(); + update_runs_after_truncation( + &result, + truncation_affix, + &mut runs, + TruncateFrom::End, + ); + return (result, Cow::Owned(runs)); + } + + line += 1; + width = px(0.); + first_non_whitespace_ix = None; + indent = None; + last_candidate_ix = 0; + last_candidate_width = px(0.); + last_wrap_ix = ix + 1; + prev_c = '\0'; + truncate_ix = ix + 1; + continue; + } + + let char_width = self.width_for_char(c); + + if Self::is_word_char(c) { + if prev_c == ' ' && c != ' ' && first_non_whitespace_ix.is_some() { + last_candidate_ix = ix; + last_candidate_width = width; + } + } else if c != ' ' && first_non_whitespace_ix.is_some() { + last_candidate_ix = ix; + last_candidate_width = width; + } + + if c != ' ' && first_non_whitespace_ix.is_none() { + first_non_whitespace_ix = Some(ix); + } + + width += char_width; + + if line < max_lines - 1 { + if width > wrap_width && ix > last_wrap_ix { + if let (None, Some(first_non_whitespace_ix)) = (indent, first_non_whitespace_ix) + { + indent = Some( + Self::MAX_INDENT.min((first_non_whitespace_ix - last_wrap_ix) as u32), + ); + } + + if last_candidate_ix > 0 { + last_wrap_ix = last_candidate_ix; + width -= last_candidate_width; + last_candidate_ix = 0; + } else { + last_wrap_ix = ix; + width = char_width; + } + + if let Some(indent) = indent { + width += self.width_for_char(' ') * indent as f32; + } + + line += 1; + truncate_ix = last_wrap_ix; + } + } else { + if width + affix_width <= wrap_width { + truncate_ix = ix + c.len_utf8(); + } + + if width > wrap_width { + let result = SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&text[..truncate_ix], truncation_affix) + )); + let mut runs = runs.to_vec(); + update_runs_after_truncation( + &result, + truncation_affix, + &mut runs, + TruncateFrom::End, + ); + return (result, Cow::Owned(runs)); + } + } + + prev_c = c; + } + + (text, Cow::Borrowed(runs)) + } + + fn truncate_single_wrapped_line<'a>( + &mut self, + text: SharedString, + truncate_width: Pixels, + truncation_affix: &str, + runs: &'a [TextRun], + truncate_from: TruncateFrom, + ) -> (SharedString, Cow<'a, [TextRun]>) { + let Some(newline_ix) = text.find('\n') else { + return self.truncate_line(text, truncate_width, truncation_affix, runs, truncate_from); + }; + + let first_line = &text[..newline_ix]; + let (result, affix_inserted) = if !text[newline_ix + 1..].trim().is_empty() { + ( + self.truncate_line_with_forced_affix( + first_line, + truncate_width, + truncation_affix, + truncate_from, + ), + !truncation_affix.is_empty(), + ) + } else if let Some(truncate_ix) = + self.should_truncate_line(first_line, truncate_width, truncation_affix, truncate_from) + { + ( + truncated_line_text(first_line, truncate_ix, truncation_affix, truncate_from), + !truncation_affix.is_empty(), + ) + } else { + (SharedString::from(first_line), false) + }; + + let mut result_runs = runs_for_prefix(runs, newline_ix); + if affix_inserted || result.len() < first_line.len() { + let affix = if affix_inserted { truncation_affix } else { "" }; + update_runs_after_truncation(&result, affix, &mut result_runs, truncate_from); + result_runs.retain(|run| run.len > 0); + } + + if result_runs.is_empty() + && !result.is_empty() + && let Some(mut run) = runs.first().cloned() + { + run.len = result.len(); + result_runs.push(run); + } + + (result, Cow::Owned(result_runs)) + } + + fn truncate_line_with_forced_affix( + &mut self, + line: &str, + truncate_width: Pixels, + truncation_affix: &str, + truncate_from: TruncateFrom, + ) -> SharedString { + if truncation_affix.is_empty() { + if let Some(truncate_ix) = + self.should_truncate_line(line, truncate_width, truncation_affix, truncate_from) + { + truncated_line_text(line, truncate_ix, truncation_affix, truncate_from) + } else { + SharedString::from(line) + } + } else { + let affix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width); + + match truncate_from { + TruncateFrom::Start => { + let mut width = px(0.); + let mut truncate_ix = line.len(); + for (ix, c) in line.char_indices().rev() { + let char_width = self.width_for_char(c); + if width + char_width + affix_width <= truncate_width { + width += char_width; + truncate_ix = ix; + } else { + break; + } + } + SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix..])) + } + TruncateFrom::End => { + let mut width = px(0.); + let mut truncate_ix = 0; + for (ix, c) in line.char_indices() { + let char_width = self.width_for_char(c); + if width + char_width + affix_width <= truncate_width { + width += char_width; + truncate_ix = ix + c.len_utf8(); + } else { + break; + } + } + SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&line[..truncate_ix], truncation_affix) + )) + } + } + } + } + + fn truncate_wrapped_line_start<'a>( + &mut self, + text: SharedString, + wrap_width: Pixels, + max_lines: usize, + truncation_affix: &str, + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { + if self.wrapped_line_count(&text, wrap_width) <= max_lines { + return (text, Cow::Borrowed(runs)); + } + + let mut candidates = text.char_indices().map(|(ix, _)| ix).collect::>(); + candidates.push(text.len()); + + let mut low = 0; + let mut high = candidates.len() - 1; + while low < high { + let mid = (low + high) / 2; + let candidate = candidates[mid]; + + if self.wrapped_line_count_with_prefix(truncation_affix, &text[candidate..], wrap_width) + <= max_lines + { + high = mid; + } else { + low = mid + 1; + } + } + + let result = SharedString::from(format!("{truncation_affix}{}", &text[candidates[low]..])); + let mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_affix, &mut runs, TruncateFrom::Start); + runs.retain(|run| run.len > 0); + (result, Cow::Owned(runs)) + } + + fn wrapped_line_count(&mut self, text: &str, wrap_width: Pixels) -> usize { + text.split_terminator('\n') + .map(|line| { + self.wrap_line(&[LineFragment::text(line)], wrap_width) + .count() + + 1 + }) + .sum() + } + + fn wrapped_line_count_with_prefix( + &mut self, + prefix: &str, + text: &str, + wrap_width: Pixels, + ) -> usize { + let mut lines = text.split_terminator('\n'); + let Some(first_line) = lines.next() else { + return 0; + }; + + let first_line_count = self + .wrap_line( + &[LineFragment::text(prefix), LineFragment::text(first_line)], + wrap_width, + ) + .count() + + 1; + + first_line_count + + lines + .map(|line| { + self.wrap_line(&[LineFragment::text(line)], wrap_width) + .count() + + 1 + }) + .sum::() + } + /// Any character in this list should be treated as a word character, /// meaning it can be part of a word that should not be wrapped. pub(crate) fn is_word_char(c: char) -> bool { @@ -272,6 +602,68 @@ impl LineWrapper { } } +fn truncated_line_text( + line: &str, + truncate_ix: usize, + truncation_affix: &str, + truncate_from: TruncateFrom, +) -> SharedString { + match truncate_from { + TruncateFrom::Start => SharedString::from(format!( + "{truncation_affix}{}", + &line[line.ceil_char_boundary(truncate_ix + 1)..] + )), + TruncateFrom::End => SharedString::from(format!( + "{}{truncation_affix}", + trim_end_before_truncation_affix(&line[..truncate_ix], truncation_affix) + )), + } +} + +fn trim_end_before_truncation_affix<'a>(text: &'a str, truncation_affix: &str) -> &'a str { + if truncation_affix.is_empty() { + text + } else { + text.trim_end_matches(|c: char| c.is_whitespace() || is_truncation_boundary_punctuation(c)) + } +} + +fn is_truncation_boundary_punctuation(c: char) -> bool { + matches!( + get_general_category(c), + GeneralCategory::ConnectorPunctuation + | GeneralCategory::DashPunctuation + | GeneralCategory::OpenPunctuation + | GeneralCategory::ClosePunctuation + | GeneralCategory::InitialPunctuation + | GeneralCategory::FinalPunctuation + | GeneralCategory::OtherPunctuation + ) +} + +fn runs_for_prefix(runs: &[TextRun], prefix_len: usize) -> Vec { + let mut prefix_runs = Vec::new(); + let mut remaining_len = prefix_len; + + for run in runs { + if remaining_len == 0 { + break; + } + + let mut run = run.clone(); + if run.len > remaining_len { + run.len = remaining_len; + prefix_runs.push(run); + break; + } + + remaining_len -= run.len; + prefix_runs.push(run); + } + + prefix_runs +} + fn update_runs_after_truncation( result: &str, ellipsis: &str, @@ -281,28 +673,43 @@ fn update_runs_after_truncation( let mut truncate_at = result.len() - ellipsis.len(); match truncate_from { TruncateFrom::Start => { + let mut first_retained_run_ix = None; for (run_index, run) in runs.iter_mut().enumerate().rev() { if run.len <= truncate_at { truncate_at -= run.len; + first_retained_run_ix = Some(run_index); } else { run.len = truncate_at + ellipsis.len(); runs.splice(..run_index, std::iter::empty()); - break; + runs.retain(|run| run.len > 0); + return; } } + if let Some(run_index) = first_retained_run_ix { + runs[run_index].len += ellipsis.len(); + runs.splice(..run_index, std::iter::empty()); + } } TruncateFrom::End => { + let mut last_retained_run_ix = None; for (run_index, run) in runs.iter_mut().enumerate() { if run.len <= truncate_at { truncate_at -= run.len; + last_retained_run_ix = Some(run_index); } else { run.len = truncate_at + ellipsis.len(); runs.truncate(run_index + 1); - break; + runs.retain(|run| run.len > 0); + return; } } + if let Some(run_index) = last_retained_run_ix { + runs[run_index].len += ellipsis.len(); + runs.truncate(run_index + 1); + } } } + runs.retain(|run| run.len > 0); } /// A fragment of a line that can be wrapped. @@ -410,6 +817,17 @@ mod tests { .collect() } + fn wrapped_line_count(wrapper: &mut LineWrapper, text: &str, wrap_width: Pixels) -> usize { + text.split_terminator('\n') + .map(|line| { + wrapper + .wrap_line(&[LineFragment::text(line)], wrap_width) + .count() + + 1 + }) + .sum() + } + #[test] fn test_wrap_line() { let mut wrapper = build_wrapper(); @@ -557,12 +975,13 @@ mod tests { text: &'static str, expected: &'static str, ellipsis: &str, + line_width: Pixels, ) { let dummy_run_lens = vec![text.len()]; let dummy_runs = generate_test_runs(&dummy_run_lens); let (result, dummy_runs) = wrapper.truncate_line( text.into(), - px(220.), + line_width, ellipsis, &dummy_runs, TruncateFrom::End, @@ -576,24 +995,77 @@ mod tests { "aa bbb cccc ddddd eeee ffff gggg", "aa bbb cccc ddddd eeee", "", + px(220.), ); perform_test( &mut wrapper, "aa bbb cccc ddddd eeee ffff gggg", "aa bbb cccc ddddd eee…", "…", + px(220.), ); perform_test( &mut wrapper, "aa bbb cccc ddddd eeee ffff gggg", "aa bbb cccc dddd......", "......", + px(220.), ); perform_test( &mut wrapper, "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg", "aa bbb cccc 🦀🦀🦀🦀…", "…", + px(220.), + ); + perform_test(&mut wrapper, "hello world", "hello…", "…", px(70.)); + perform_test(&mut wrapper, "hello, world", "hello…", "…", px(70.)); + perform_test(&mut wrapper, "hello, world", "hello, ", "", px(70.)); + } + + #[test] + fn test_truncate_line_end_trims_unicode_punctuation_before_affix() { + let mut wrapper = build_wrapper(); + + for text in ["hello— world", "hello، world"] { + let dummy_runs = generate_test_runs(&[text.len()]); + let line_width = text + .chars() + .take(6) + .chain("…".chars()) + .map(|c| wrapper.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width) + + px(1.); + + let (result, result_runs) = + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End); + + assert_eq!(result, "hello…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + } + } + + #[test] + fn test_truncate_line_end_preserves_unicode_punctuation_without_affix() { + let mut wrapper = build_wrapper(); + let text = "hello— world"; + let dummy_runs = generate_test_runs(&[text.len()]); + let line_width = "hello—" + .chars() + .map(|c| wrapper.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width) + + px(1.); + + let (result, result_runs) = + wrapper.truncate_line(text.into(), line_width, "", &dummy_runs, TruncateFrom::End); + + assert_eq!(result, "hello—"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() ); } @@ -805,6 +1277,269 @@ mod tests { perform_test("abcdefgh…", &[4, 4, 4], &[4, 4, 3]); } + #[test] + fn test_update_runs_after_truncation_drops_empty_boundary_runs() { + let mut end_runs = generate_test_runs(&[4, 4, 4]); + update_runs_after_truncation("abcdefgh", "", &mut end_runs, TruncateFrom::End); + assert_eq!( + end_runs.iter().map(|run| run.len).collect::>(), + vec![4, 4] + ); + + let mut start_runs = generate_test_runs(&[4, 4, 4]); + update_runs_after_truncation("efghijkl", "", &mut start_runs, TruncateFrom::Start); + assert_eq!( + start_runs.iter().map(|run| run.len).collect::>(), + vec![4, 4] + ); + } + + #[test] + fn test_multiline_truncation_fits_within_wrapped_lines() { + let mut wrapper = build_wrapper(); + let text = "aa bbbbbb cccccc dddddd eeee ffff"; + let wrap_width = px(72.); + let max_lines = 2; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::End, + ); + + let wrapped_line_count = wrapper + .wrap_line(&[LineFragment::text(&truncated)], wrap_width) + .count() + + 1; + assert!( + wrapped_line_count <= max_lines, + "{truncated:?} wrapped into {wrapped_line_count} lines" + ); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_wrapped_truncation_zero_lines_returns_empty_text_and_runs() { + let mut wrapper = build_wrapper(); + let text = "aa bbbbbb cccccc"; + let runs = generate_test_runs(&[2, 1, text.len() - 3]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 0, "…", &runs, TruncateFrom::End); + + assert_eq!(result, ""); + assert!(result_runs.is_empty()); + } + + #[test] + fn test_multiline_truncation_no_truncation_needed() { + let mut wrapper = build_wrapper(); + let text = "aa bbb cccccc"; + let runs = generate_test_runs(&[text.len()]); + + let (result, _) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::End); + + assert_eq!(result, text); + } + + #[test] + fn test_multiline_truncation_three_lines() { + let mut wrapper = build_wrapper(); + let text = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj"; + let wrap_width = px(72.); + let max_lines = 3; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::End, + ); + + let wrapped_line_count = wrapper + .wrap_line(&[LineFragment::text(&truncated)], wrap_width) + .count() + + 1; + assert!( + wrapped_line_count <= max_lines, + "{truncated:?} wrapped into {wrapped_line_count} lines" + ); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_with_newlines() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld foo bar baz"; + let wrap_width = px(72.); + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + 2, + "…", + &runs, + TruncateFrom::End, + ); + + let mut lines = truncated.splitn(2, '\n'); + assert_eq!(lines.next(), Some("hello")); + let second_line = lines.next().expect("newline should be preserved"); + let second_line_width = second_line + .chars() + .map(|c| wrapper.width_for_char(c)) + .fold(px(0.), |width, char_width| width + char_width); + assert!(second_line_width <= wrap_width); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_newline_on_last_line() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld\nmore"; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::End); + + assert_eq!(truncated.split('\n').next(), Some("hello")); + assert!(truncated.ends_with('…')); + } + + #[test] + fn test_multiline_truncation_trailing_newline() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld\n"; + let runs = generate_test_runs(&[text.len()]); + + let (result, _) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::End); + + assert_eq!(result, text); + } + + #[test] + fn test_multiline_start_truncation_fits_within_wrapped_lines() { + let mut wrapper = build_wrapper(); + let text = "aa bbbbbb cccccc dddddd eeee ffff"; + let wrap_width = px(72.); + let max_lines = 2; + let runs = generate_test_runs(&[text.len()]); + + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::Start, + ); + + assert!(truncated.starts_with('…')); + let line_count = wrapped_line_count(&mut wrapper, &truncated, wrap_width); + assert!( + line_count <= max_lines, + "{truncated:?} wrapped into {line_count} lines" + ); + } + + #[test] + fn test_multiline_start_truncation_no_truncation_needed() { + let mut wrapper = build_wrapper(); + let text = "aa bbb cccccc"; + let runs = generate_test_runs(&[text.len()]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::Start); + + assert_eq!(result, text); + assert!(matches!(result_runs, Cow::Borrowed(_))); + } + + #[test] + fn test_multiline_start_truncation_trailing_newline() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld\n"; + let runs = generate_test_runs(&[text.len()]); + + let (result, result_runs) = wrapper.truncate_wrapped_line( + text.into(), + px(500.), + 2, + "…", + &runs, + TruncateFrom::Start, + ); + + assert_eq!(result, text); + assert!(matches!(result_runs, Cow::Borrowed(_))); + } + + #[test] + fn test_multiline_start_truncation_updates_runs() { + let mut wrapper = build_wrapper(); + let text = "abcdefghijklmnopqrst"; + let runs = generate_test_runs(&[5, 5, 5, 5]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(72.), 2, "…", &runs, TruncateFrom::Start); + + assert_eq!(result, "…hijklmnopqrst"); + let result_runs = result_runs.into_owned(); + assert_eq!( + result_runs.iter().map(|run| run.len).collect::>(), + vec![6, 5, 5] + ); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + + #[test] + fn test_one_line_wrapped_truncation_stops_at_hard_newline() { + let mut wrapper = build_wrapper(); + let text = "hello\nworld"; + let runs = generate_test_runs(&[5, 1, 5]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(500.), 1, "…", &runs, TruncateFrom::End); + + assert_eq!(result, "hello…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + + #[test] + fn test_one_line_wrapped_truncation_leading_newline_covers_affix_run() { + let mut wrapper = build_wrapper(); + let text = "\nworld"; + let runs = generate_test_runs(&[1, 5]); + + let (result, result_runs) = + wrapper.truncate_wrapped_line(text.into(), px(500.), 1, "…", &runs, TruncateFrom::End); + + assert_eq!(result, "…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + #[test] fn test_is_word_char() { #[track_caller] diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 39b87dbb..5c1d6609 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -158,6 +158,7 @@ impl Element for AnyView { && element_state.cache_key.text_style == text_style && !window.dirty_views.contains(&self.entity_id()) && !window.refreshing + && !window.a11y.is_active() { let prepaint_start = window.prepaint_index(); window.reuse_prepaint(element_state.prepaint_range.clone()); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f509ca32..32c8fdb9 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5,21 +5,22 @@ use crate::util::{ResultExt, measure}; use crate::{ Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset, AsyncWindowContext, AvailableSpace, BackdropBlur, Background, BorderStyle, Bounds, BoxShadow, - Capslock, Context, Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener, - DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, - FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, - KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, - LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, - MouseMoveEvent, MouseUpEvent, OverlayInputMode, Path, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, - PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, - RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, - SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, - Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, - TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, - ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, - WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, transparent_black, + Capslock, Context, Corners, CursorHideMode, CursorStyle, Decorations, DevicePixels, + DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, + EntityId, EventEmitter, FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, + Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, + KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, + MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, OverlayInputMode, Path, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PolychromeSprite, Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, + RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, + SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, + StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -48,14 +49,18 @@ use std::{ rc::Rc, sync::{ Arc, Weak, - atomic::{AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, }, time::{Duration, Instant}, }; use uuid::Uuid; +pub(crate) mod a11y; mod prompts; +use self::a11y::A11y; +#[cfg(not(target_family = "wasm"))] +use self::a11y::ROOT_NODE_ID; use crate::util::atomic_incr_if_not_zero; pub use prompts::*; @@ -1030,6 +1035,7 @@ pub struct Window { captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, + pub(crate) a11y: A11y, } #[derive(Clone, Debug, Default)] @@ -1250,6 +1256,81 @@ impl Window { WindowBounds::Windowed(_) => {} } + let accessibility_force_disabled = cx.accessibility_force_disabled; + let a11y_active_flag = Arc::new(AtomicBool::new(false)); + + #[cfg(not(target_family = "wasm"))] + if !accessibility_force_disabled { + let initial_tree = accesskit::TreeUpdate { + nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))], + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: ROOT_NODE_ID, + }; + let (activation_sender, activation_receiver) = async_channel::unbounded::<()>(); + let (deactivation_sender, deactivation_receiver) = async_channel::unbounded::<()>(); + let (action_sender, action_receiver) = + async_channel::unbounded::(); + + platform_window.a11y_init(crate::A11yCallbacks { + activation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility activated"); + active_flag.store(true, SeqCst); + activation_sender.send_blocking(()).log_err(); + Some(initial_tree.clone()) + }) + }, + action: Box::new(move |request| { + action_sender.send_blocking(request).log_err(); + }), + deactivation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility deactivated"); + active_flag.store(false, SeqCst); + deactivation_sender.send_blocking(()).log_err(); + }) + }, + }); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while activation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while deactivation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while let Ok(request) = action_receiver.recv().await { + handle + .update(&mut async_cx, |_, window, cx| { + window.handle_a11y_action(request, cx); + }) + .log_err(); + } + }) + .detach(); + } + platform_window.on_close(Box::new({ let window_id = handle.window_id(); let mut cx = cx.to_async(); @@ -1304,6 +1385,11 @@ impl Window { measure("frame duration", || { handle .update(&mut cx, |_, window, cx| { + if request_frame_options.force_render { + // Bypass cached view reuse so we don't replay stale + // atlas tile references after a GPU device recovery. + window.refresh(); + } let arena_clear_needed = window.draw(cx); window.present(); arena_clear_needed.clear(); @@ -1536,6 +1622,7 @@ impl Window { captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, + a11y: A11y::new(a11y_active_flag, accessibility_force_disabled), }) } @@ -2348,8 +2435,21 @@ impl Window { self.requested_autoscroll = None; // Restore the previously-used input handler. + // Place it back into a None slot (left by a previous .take()) so that + // cached paint_range indices in reuse_paint find the handler at the + // expected position. if let Some(input_handler) = self.platform_window.take_input_handler() { - self.rendered_frame.input_handlers.push(Some(input_handler)); + if let Some(slot) = self + .rendered_frame + .input_handlers + .iter_mut() + .rev() + .find(|h| h.is_none()) + { + *slot = Some(input_handler); + } else { + self.rendered_frame.input_handlers.push(Some(input_handler)); + } } if !cx.mode.skip_drawing() { self.draw_roots(cx); @@ -2358,9 +2458,18 @@ impl Window { self.next_frame.window_active = self.active.get(); // Register requested input handler with the platform window. - if let Some(input_handler) = self.next_frame.input_handlers.pop() { - self.platform_window - .set_input_handler(input_handler.unwrap()); + // Use .take() instead of .pop() to preserve Vec length, so that cached + // paint_range indices remain valid for reuse_paint on the next frame. + // Search backwards to find the last Some entry, since reuse_paint may + // have copied None slots from the previous frame. (Fixes #50456) + if let Some(input_handler) = self + .next_frame + .input_handlers + .iter_mut() + .rev() + .find_map(|h| h.take()) + { + self.platform_window.set_input_handler(input_handler); } self.layout_engine.as_mut().unwrap().clear(); @@ -2444,6 +2553,11 @@ impl Window { self.invalidator.set_phase(DrawPhase::Prepaint); self.tooltip_bounds.take(); + self.a11y.sync_active_flag(); + if self.a11y.is_active() { + self.a11y.begin_frame(); + } + let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size()); let root_size = { #[cfg(any(feature = "inspector", debug_assertions))] @@ -2510,6 +2624,22 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector_hitbox(cx); + + let a11y_active_start_of_frame = self.a11y.is_active(); + self.a11y.sync_active_flag(); + let a11y_active_end_of_frame = self.a11y.is_active(); + + if a11y_active_start_of_frame { + let tree_update = self.a11y.end_frame(); + + if a11y_active_end_of_frame { + log::debug!( + "Sending a11y tree update: {} nodes", + tree_update.nodes.len() + ); + self.platform_window.a11y_tree_update(tree_update); + } + } } fn prepaint_tooltip(&mut self, cx: &mut App) -> Option { @@ -2625,7 +2755,13 @@ impl Window { }); }) } else { - self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + debug_assert!( + !self.a11y.is_active(), + "cached deferred prepaint reuse reached while a11y is active" + ); + if !self.a11y.is_active() { + self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + } } let prepaint_end = self.prepaint_index(); deferred_draw.prepaint_range = prepaint_start..prepaint_end; @@ -2669,7 +2805,13 @@ impl Window { }) }) } else { - self.reuse_paint(deferred_draw.paint_range.clone()); + debug_assert!( + !self.a11y.is_active(), + "cached deferred paint reuse reached while a11y is active" + ); + if !self.a11y.is_active() { + self.reuse_paint(deferred_draw.paint_range.clone()); + } } let paint_end = self.paint_index(); deferred_draw.paint_range = paint_start..paint_end; @@ -2930,6 +3072,7 @@ impl Window { pub fn transact(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { self.invalidator.debug_assert_prepaint(); let index = self.prepaint_index(); + let a11y_snapshot = self.a11y.is_active().then(|| self.a11y.prepaint_snapshot()); let result = f(self); if result.is_err() { self.next_frame.hitboxes.truncate(index.hitboxes_index); @@ -2946,6 +3089,9 @@ impl Window { .accessed_element_states .truncate(index.accessed_element_states_index); self.text_system.truncate_layouts(index.line_layout_index); + if let Some(a11y_snapshot) = a11y_snapshot { + self.a11y.restore_prepaint_snapshot(a11y_snapshot); + } } result } @@ -3268,10 +3414,12 @@ impl Window { result } - /// Paint one or more drop shadows into the scene for the next frame at the current z-index. + /// Paint the drop (non-inset) shadows from `shadows` into the scene at the current + /// z-index. Inset shadows are skipped; paint those with [`Self::paint_inset_shadows`] + /// after the element's background so they layer on top of the fill. /// /// This method should only be called as part of the paint phase of element drawing. - pub fn paint_shadows( + pub fn paint_drop_shadows( &mut self, bounds: Bounds, corner_radii: Corners, @@ -3282,7 +3430,12 @@ impl Window { let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); let opacity = self.element_opacity(); + let element_bounds = bounds.scale(scale_factor); + let element_corner_radii = corner_radii.scale(scale_factor); for shadow in shadows { + if shadow.inset { + continue; + } let shadow_bounds = (bounds + shadow.offset).dilate(shadow.spread_radius); self.next_frame.scene.insert_primitive(Shadow { order: 0, @@ -3291,6 +3444,53 @@ impl Window { content_mask: content_mask.scale(scale_factor), corner_radii: corner_radii.scale(scale_factor), color: shadow.color.opacity(opacity), + element_bounds, + element_corner_radii, + inset: 0, + pad: 0, + }); + } + } + + /// Paint the inset shadows from `shadows` into the scene at the current z-index. Should + /// be called after the element's background so the shadow layers on top of the fill. + /// Drop shadows are skipped; paint those with [`Self::paint_drop_shadows`] before the background. + pub fn paint_inset_shadows( + &mut self, + bounds: Bounds, + corner_radii: Corners, + shadows: &[BoxShadow], + ) { + self.invalidator.debug_assert_paint(); + + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask(); + let opacity = self.element_opacity(); + let element_bounds = bounds.scale(scale_factor); + let element_corner_radii = corner_radii.scale(scale_factor); + for shadow in shadows { + if !shadow.inset { + continue; + } + let hole = (bounds + shadow.offset).dilate(-shadow.spread_radius); + let zero = Pixels::ZERO; + let hole_corner_radii = Corners { + top_left: (corner_radii.top_left - shadow.spread_radius).max(zero), + top_right: (corner_radii.top_right - shadow.spread_radius).max(zero), + bottom_right: (corner_radii.bottom_right - shadow.spread_radius).max(zero), + bottom_left: (corner_radii.bottom_left - shadow.spread_radius).max(zero), + }; + self.next_frame.scene.insert_primitive(Shadow { + order: 0, + blur_radius: shadow.blur_radius.scale(scale_factor), + bounds: hole.scale(scale_factor), + content_mask: content_mask.scale(scale_factor), + corner_radii: hole_corner_radii.scale(scale_factor), + color: shadow.color.opacity(opacity), + element_bounds, + element_corner_radii, + inset: 1, + pad: 0, }); } } @@ -4432,6 +4632,14 @@ impl Window { } else if let Some(key_down_event) = event.downcast_ref::() { self.pending_modifier.saw_keystroke = true; keystroke = Some(key_down_event.keystroke.clone()); + if key_down_event.keystroke.key_char.is_some() + && matches!( + cx.cursor_hide_mode, + CursorHideMode::OnTyping | CursorHideMode::OnTypingAndAction + ) + { + cx.platform.hide_cursor_until_mouse_moves(); + } } let Some(keystroke) = keystroke else { @@ -4697,6 +4905,22 @@ impl Window { node_id: DispatchNodeId, action: &dyn Action, cx: &mut App, + ) { + self.dispatch_action_on_node_inner(node_id, action, cx); + + if !cx.propagate_event + && cx.cursor_hide_mode == CursorHideMode::OnTypingAndAction + && self.last_input_was_keyboard() + { + cx.platform.hide_cursor_until_mouse_moves(); + } + } + + fn dispatch_action_on_node_inner( + &mut self, + node_id: DispatchNodeId, + action: &dyn Action, + cx: &mut App, ) { let dispatch_path = self.rendered_frame.dispatch_tree.dispatch_path(node_id); @@ -5151,6 +5375,85 @@ impl Window { self.platform_window.play_system_bell() } + /// Register a listener for an accessibility action on a specific node. + pub fn on_a11y_action( + &mut self, + node_id: accesskit::NodeId, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, + ) { + self.a11y + .action_listeners + .entry(node_id) + .or_default() + .push((action, Box::new(listener))); + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn handle_a11y_action(&mut self, request: accesskit::ActionRequest, cx: &mut App) { + if let Some(mut listeners) = self.a11y.action_listeners.remove(&request.target_node) { + let extra_data = request.data.as_ref(); + let mut matched = false; + for (action, listener) in &mut listeners { + if *action == request.action { + listener(extra_data, self, cx); + matched = true; + } + } + if let Some(mut current_listeners) = + self.a11y.action_listeners.remove(&request.target_node) + { + listeners.append(&mut current_listeners); + } + self.a11y + .action_listeners + .insert(request.target_node, listeners); + if matched { + return; + } + } + + match request.action { + accesskit::Action::Click => { + if let Some(bounds) = self.a11y.node_bounds.get(&request.target_node).copied() { + let center = bounds.center(); + let mouse_down = PlatformInput::MouseDown(crate::MouseDownEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + let mouse_up = PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + }); + self.dispatch_event(mouse_down, cx); + self.dispatch_event(mouse_up, cx); + } + } + accesskit::Action::Focus => { + if let Some(focus_id) = self.a11y.focus_ids.get(&request.target_node).copied() + && let Some(handle) = FocusHandle::for_id(focus_id, &cx.focus_handles) + { + self.focus(&handle, cx); + } + } + accesskit::Action::Blur => { + self.blur(); + } + _ => { + log::debug!( + "Unhandled a11y action: {:?} on {:?}", + request.action, + request.target_node + ); + } + } + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { @@ -5694,7 +5997,7 @@ impl From> for ElementId { impl From<&'static str> for ElementId { fn from(name: &'static str) -> Self { - ElementId::Name(name.into()) + ElementId::Name(SharedString::new_static(name)) } } @@ -5706,13 +6009,13 @@ impl<'a> From<&'a FocusHandle> for ElementId { impl From<(&'static str, EntityId)> for ElementId { fn from((name, id): (&'static str, EntityId)) -> Self { - ElementId::NamedInteger(name.into(), id.as_u64()) + ElementId::NamedInteger(SharedString::new_static(name), id.as_u64()) } } impl From<(&'static str, usize)> for ElementId { fn from((name, id): (&'static str, usize)) -> Self { - ElementId::NamedInteger(name.into(), id as u64) + ElementId::NamedInteger(SharedString::new_static(name), id as u64) } } @@ -5724,7 +6027,7 @@ impl From<(SharedString, usize)> for ElementId { impl From<(&'static str, u64)> for ElementId { fn from((name, id): (&'static str, u64)) -> Self { - ElementId::NamedInteger(name.into(), id) + ElementId::NamedInteger(SharedString::new_static(name), id) } } @@ -5736,7 +6039,7 @@ impl From for ElementId { impl From<(&'static str, u32)> for ElementId { fn from((name, id): (&'static str, u32)) -> Self { - ElementId::NamedInteger(name.into(), id.into()) + ElementId::NamedInteger(SharedString::new_static(name), u64::from(id)) } } diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs new file mode 100644 index 00000000..8107d615 --- /dev/null +++ b/crates/gpui/src/window/a11y.rs @@ -0,0 +1,310 @@ +//! Accessibility support, provided by [AccessKit][accesskit]. +//! +//! User-facing guide-level docs live in [`crate::_accessibility`]. + +use crate::{App, Bounds, FocusId, GlobalElementId, Pixels, Window}; +use accesskit::{Action, NodeId, TreeUpdate}; +use collections::{FxHashMap, FxHashSet}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +mod builder; + +pub(crate) use builder::{A11yNodeBuilder, A11yPrepaintSnapshot}; + +/// The fixed AccessKit node ID used for the root of every window's a11y tree. +pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0); + +/// A listener for an accessibility action on a specific node. +pub(crate) type A11yActionListener = + Box, &mut Window, &mut App) + 'static>; + +/// Per-window accessibility state. +pub(crate) struct A11y { + force_disabled: bool, + active_flag: Arc, + active_this_frame: bool, + node_ids: FxHashMap, + visited_global_ids: FxHashSet, + next_node_id: u64, + pub(crate) nodes: A11yNodeBuilder, + pub(crate) focus_ids: FxHashMap, + pub(crate) node_bounds: FxHashMap>, + pub(crate) action_listeners: FxHashMap>, +} + +impl A11y { + pub(crate) fn new(active_flag: Arc, force_disabled: bool) -> Self { + Self { + force_disabled, + active_flag, + active_this_frame: false, + node_ids: FxHashMap::default(), + visited_global_ids: FxHashSet::default(), + next_node_id: ROOT_NODE_ID.0 + 1, + nodes: A11yNodeBuilder::new(), + focus_ids: FxHashMap::default(), + node_bounds: FxHashMap::default(), + action_listeners: FxHashMap::default(), + } + } + + pub(crate) fn sync_active_flag(&mut self) { + self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst); + } + + pub(crate) fn is_active(&self) -> bool { + self.active_this_frame + } + + /// Force accessibility active/inactive for tests without going through a platform adapter. + #[cfg(test)] + pub(crate) fn set_active_for_test(&mut self, active: bool) { + self.active_flag.store(active, Ordering::SeqCst); + self.sync_active_flag(); + } + + pub(crate) fn begin_frame(&mut self) { + self.focus_ids.clear(); + self.node_bounds.clear(); + self.action_listeners.clear(); + self.visited_global_ids.clear(); + self.nodes.begin_frame(); + } + + pub(crate) fn node_id_for(&mut self, global_id: &GlobalElementId) -> NodeId { + self.visited_global_ids.insert(global_id.clone()); + + if let Some(node_id) = self.node_ids.get(global_id) { + return *node_id; + } + + let node_id = NodeId(self.next_node_id); + debug_assert_ne!(node_id, ROOT_NODE_ID); + self.next_node_id += 1; + self.node_ids.insert(global_id.clone(), node_id); + node_id + } + + pub(crate) fn node_id_for_existing(&self, global_id: &GlobalElementId) -> Option { + self.node_ids.get(global_id).copied() + } + + pub(crate) fn end_frame(&mut self) -> TreeUpdate { + let update = self.nodes.finalize(); + let live_node_ids = update + .nodes + .iter() + .map(|(node_id, _)| *node_id) + .collect::>(); + + self.node_ids + .retain(|global_id, _| self.visited_global_ids.contains(global_id)); + self.focus_ids + .retain(|node_id, _| live_node_ids.contains(node_id)); + self.node_bounds + .retain(|node_id, _| live_node_ids.contains(node_id)); + self.action_listeners + .retain(|node_id, _| live_node_ids.contains(node_id)); + + update + } + + pub(crate) fn prepaint_snapshot(&self) -> A11yPrepaintSnapshot { + A11yPrepaintSnapshot { + nodes: self.nodes.prepaint_snapshot(), + node_ids: self.node_ids.clone(), + visited_global_ids: self.visited_global_ids.clone(), + next_node_id: self.next_node_id, + focus_ids: self.focus_ids.clone(), + node_bounds: self.node_bounds.clone(), + } + } + + pub(crate) fn restore_prepaint_snapshot(&mut self, snapshot: A11yPrepaintSnapshot) { + self.nodes.restore_prepaint_snapshot(snapshot.nodes); + self.node_ids = snapshot.node_ids; + self.visited_global_ids = snapshot.visited_global_ids; + self.next_node_id = snapshot.next_node_id; + self.focus_ids = snapshot.focus_ids; + self.node_bounds = snapshot.node_bounds; + // `action_listeners` are populated during paint, while prepaint transactions only + // roll back prepaint side effects. + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ElementId, point, px, size}; + use slotmap::KeyData; + use std::sync::{Arc, atomic::AtomicBool}; + + fn global_id(id: impl Into) -> GlobalElementId { + GlobalElementId(Arc::from([id.into()])) + } + + fn has_update_node(update: &TreeUpdate, id: NodeId) -> bool { + update.nodes.iter().any(|(node_id, _)| *node_id == id) + } + + #[test] + fn restores_prepaint_snapshot_for_a11y_maps() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + a11y.begin_frame(); + + let focus_id = FocusId::from(KeyData::from_ffi(1)); + let original_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + a11y.focus_ids.insert(NodeId(1), focus_id); + a11y.node_bounds.insert(NodeId(1), original_bounds); + let snapshot = a11y.prepaint_snapshot(); + + a11y.focus_ids.remove(&NodeId(1)); + a11y.focus_ids + .insert(NodeId(2), FocusId::from(KeyData::from_ffi(2))); + a11y.node_bounds.insert( + NodeId(1), + Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))), + ); + a11y.node_bounds.insert( + NodeId(2), + Bounds::new(point(px(9.), px(10.)), size(px(11.), px(12.))), + ); + + a11y.restore_prepaint_snapshot(snapshot); + + assert_eq!(a11y.focus_ids.len(), 1); + assert_eq!(a11y.focus_ids.get(&NodeId(1)), Some(&focus_id)); + assert_eq!(a11y.node_bounds.len(), 1); + assert_eq!(a11y.node_bounds.get(&NodeId(1)), Some(&original_bounds)); + } + + #[test] + fn allocates_stable_unique_node_ids_for_global_element_ids() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let first_id = global_id("first"); + let second_id = global_id("second"); + + let first_node_id = a11y.node_id_for(&first_id); + let second_node_id = a11y.node_id_for(&second_id); + a11y.begin_frame(); + + assert_ne!(first_node_id, ROOT_NODE_ID); + assert_ne!(second_node_id, ROOT_NODE_ID); + assert_ne!(first_node_id, second_node_id); + assert_eq!(a11y.node_id_for(&first_id), first_node_id); + assert_eq!(a11y.node_id_for_existing(&second_id), Some(second_node_id)); + assert_eq!(a11y.node_id_for_existing(&global_id("missing")), None); + } + + #[test] + fn retains_visited_suppressed_node_id_and_sweeps_omitted_node_id() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let hidden_id = global_id("hidden"); + + a11y.begin_frame(); + let hidden_node_id = a11y.node_id_for(&hidden_id); + assert!(a11y.nodes.push( + hidden_node_id, + accesskit::Node::new(accesskit::Role::Button) + )); + assert!(a11y.nodes.suppress_current_node(hidden_node_id)); + a11y.nodes.pop(); + let update = a11y.end_frame(); + + assert!(!has_update_node(&update, hidden_node_id)); + assert_eq!(a11y.node_id_for_existing(&hidden_id), Some(hidden_node_id)); + + a11y.begin_frame(); + a11y.end_frame(); + + assert_eq!(a11y.node_id_for_existing(&hidden_id), None); + } + + #[test] + fn sweeps_per_node_maps_by_live_emitted_nodes_after_end_frame() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let live_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); + let stale_bounds = Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))); + let live_focus = FocusId::from(KeyData::from_ffi(1)); + let stale_focus = FocusId::from(KeyData::from_ffi(2)); + + a11y.begin_frame(); + assert!( + a11y.nodes + .push(NodeId(1), accesskit::Node::new(accesskit::Role::Button)) + ); + a11y.nodes.pop(); + a11y.focus_ids.insert(NodeId(1), live_focus); + a11y.focus_ids.insert(NodeId(2), stale_focus); + a11y.node_bounds.insert(NodeId(1), live_bounds); + a11y.node_bounds.insert(NodeId(2), stale_bounds); + a11y.action_listeners + .insert(NodeId(1), vec![(Action::Click, Box::new(|_, _, _| {}))]); + a11y.action_listeners + .insert(NodeId(2), vec![(Action::Focus, Box::new(|_, _, _| {}))]); + + let update = a11y.end_frame(); + + assert!(has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); + assert_eq!(a11y.focus_ids.len(), 1); + assert_eq!(a11y.focus_ids.get(&NodeId(1)), Some(&live_focus)); + assert_eq!(a11y.node_bounds.len(), 1); + assert_eq!(a11y.node_bounds.get(&NodeId(1)), Some(&live_bounds)); + assert_eq!(a11y.action_listeners.len(), 1); + assert!(a11y.action_listeners.contains_key(&NodeId(1))); + } + + #[test] + fn restores_prepaint_snapshot_for_node_id_allocator() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let accepted_id = global_id("accepted"); + let rejected_id = global_id("rejected"); + let next_id = global_id("next"); + + let accepted_node_id = a11y.node_id_for(&accepted_id); + let snapshot = a11y.prepaint_snapshot(); + let rejected_node_id = a11y.node_id_for(&rejected_id); + + a11y.restore_prepaint_snapshot(snapshot); + let next_node_id = a11y.node_id_for(&next_id); + + assert_eq!( + a11y.node_id_for_existing(&accepted_id), + Some(accepted_node_id) + ); + assert_eq!(a11y.node_id_for_existing(&rejected_id), None); + assert_eq!(next_node_id, rejected_node_id); + } + + #[test] + fn restores_prepaint_snapshot_for_visited_globals() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let accepted_id = global_id("accepted"); + let rejected_id = global_id("rejected"); + + a11y.begin_frame(); + let accepted_node_id = a11y.node_id_for(&accepted_id); + let snapshot = a11y.prepaint_snapshot(); + let rejected_node_id = a11y.node_id_for(&rejected_id); + assert!(a11y.nodes.push( + accepted_node_id, + accesskit::Node::new(accesskit::Role::Button) + )); + a11y.nodes.pop(); + + a11y.restore_prepaint_snapshot(snapshot); + let update = a11y.end_frame(); + + assert!(!has_update_node(&update, accepted_node_id)); + assert!(!has_update_node(&update, rejected_node_id)); + assert_eq!( + a11y.node_id_for_existing(&accepted_id), + Some(accepted_node_id) + ); + assert_eq!(a11y.node_id_for_existing(&rejected_id), None); + } +} diff --git a/crates/gpui/src/window/a11y/builder.rs b/crates/gpui/src/window/a11y/builder.rs new file mode 100644 index 00000000..a92038f6 --- /dev/null +++ b/crates/gpui/src/window/a11y/builder.rs @@ -0,0 +1,377 @@ +use super::ROOT_NODE_ID; +use crate::{Bounds, FocusId, GlobalElementId, Pixels}; +use accesskit::{NodeId, TreeUpdate}; +use collections::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; + +pub(crate) struct A11yNodeBuilder { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + suppression_stack: SmallVec<[bool; 16]>, + ambient_suppression_depth: usize, + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +pub(crate) struct A11yPrepaintSnapshot { + pub(super) nodes: A11yNodeBuilderPrepaintSnapshot, + pub(super) node_ids: FxHashMap, + pub(super) visited_global_ids: FxHashSet, + pub(super) next_node_id: u64, + pub(super) focus_ids: FxHashMap, + pub(super) node_bounds: FxHashMap>, +} + +pub(super) struct A11yNodeBuilderPrepaintSnapshot { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + suppression_stack: SmallVec<[bool; 16]>, + ambient_suppression_depth: usize, + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +impl A11yNodeBuilder { + pub(super) fn new() -> Self { + Self { + ids_stack: SmallVec::new(), + nodes_stack: SmallVec::new(), + suppression_stack: SmallVec::new(), + ambient_suppression_depth: 0, + all_nodes: Vec::new(), + seen_ids: FxHashSet::default(), + focus: ROOT_NODE_ID, + #[cfg(debug_assertions)] + has_set_focus: false, + } + } + + pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + debug_assert!(!self.ids_stack.is_empty(), "push called before begin_frame"); + + if self.is_suppressed() { + return false; + } + + if !self.seen_ids.insert(id) { + debug_assert!( + false, + "duplicate a11y node id: {id:?}; release builds discard this node" + ); + return false; + } + + self.ids_stack.push(id); + self.nodes_stack.push(node); + self.suppression_stack.push(false); + true + } + + pub(crate) fn pop(&mut self) { + debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); + + self.pop_any(); + } + + pub(super) fn begin_frame(&mut self) { + self.all_nodes.clear(); + self.ids_stack.clear(); + self.nodes_stack.clear(); + self.suppression_stack.clear(); + self.ambient_suppression_depth = 0; + self.seen_ids.clear(); + self.seen_ids.insert(ROOT_NODE_ID); + #[cfg(debug_assertions)] + { + self.has_set_focus = false; + } + + self.ids_stack.push(ROOT_NODE_ID); + self.nodes_stack + .push(accesskit::Node::new(accesskit::Role::Window)); + self.suppression_stack.push(false); + self.focus = ROOT_NODE_ID; + } + + #[cfg(test)] + fn has_node(&self, id: NodeId) -> bool { + id == ROOT_NODE_ID || self.seen_ids.contains(&id) + } + + pub(crate) fn has_current_node(&self, id: NodeId) -> bool { + self.ids_stack.last().copied() == Some(id) && !self.is_suppressed() + } + + pub(crate) fn set_focus(&mut self, id: NodeId) { + #[cfg(debug_assertions)] + { + debug_assert!( + !self.has_set_focus, + "set_focus called more than once in a single frame" + ); + self.has_set_focus = true; + } + self.focus = id; + } + + pub(super) fn prepaint_snapshot(&self) -> A11yNodeBuilderPrepaintSnapshot { + A11yNodeBuilderPrepaintSnapshot { + ids_stack: self.ids_stack.clone(), + nodes_stack: self.nodes_stack.clone(), + suppression_stack: self.suppression_stack.clone(), + ambient_suppression_depth: self.ambient_suppression_depth, + all_nodes: self.all_nodes.clone(), + seen_ids: self.seen_ids.clone(), + focus: self.focus, + #[cfg(debug_assertions)] + has_set_focus: self.has_set_focus, + } + } + + pub(super) fn restore_prepaint_snapshot(&mut self, snapshot: A11yNodeBuilderPrepaintSnapshot) { + self.ids_stack = snapshot.ids_stack; + self.nodes_stack = snapshot.nodes_stack; + self.suppression_stack = snapshot.suppression_stack; + self.ambient_suppression_depth = snapshot.ambient_suppression_depth; + self.all_nodes = snapshot.all_nodes; + self.seen_ids = snapshot.seen_ids; + self.focus = snapshot.focus; + #[cfg(debug_assertions)] + { + self.has_set_focus = snapshot.has_set_focus; + } + } + + pub(crate) fn update_current_node_bounds( + &mut self, + id: NodeId, + bounds: Bounds, + scale_factor: f32, + ) -> bool { + let Some(node) = self.current_node_mut(id) else { + return false; + }; + + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale_factor) as f64, + y0: (bounds.origin.y.0 * scale_factor) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale_factor) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale_factor) as f64, + }); + true + } + + pub(crate) fn suppress_current_node(&mut self, id: NodeId) -> bool { + if self.ids_stack.len() <= 1 { + debug_assert!(false, "cannot suppress the root a11y node"); + return false; + } + + if self.ids_stack.last().copied() != Some(id) { + return false; + } + + let Some(suppressed) = self.suppression_stack.last_mut() else { + return false; + }; + + if *suppressed { + return false; + } + + *suppressed = true; + self.prune_emitted_subtree(id); + true + } + + pub(crate) fn begin_suppressing_descendants(&mut self) { + self.ambient_suppression_depth += 1; + } + + pub(crate) fn end_suppressing_descendants(&mut self) { + debug_assert!( + self.ambient_suppression_depth > 0, + "end_suppressing_descendants called without matching begin" + ); + self.ambient_suppression_depth = self.ambient_suppression_depth.saturating_sub(1); + } + + pub(super) fn finalize(&mut self) -> TreeUpdate { + debug_assert_eq!(self.ids_stack.len(), 1); + debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); + debug_assert_eq!(self.ambient_suppression_depth, 0); + + if self.ids_stack.len() != 1 { + log::error!( + "a11y: stack imbalance at end of frame: expected 1 (root), got {}", + self.ids_stack.len() + ); + } + if self.ambient_suppression_depth != 0 { + log::error!( + "a11y: ambient suppression imbalance at end of frame: got {}", + self.ambient_suppression_depth + ); + self.ambient_suppression_depth = 0; + } + + while !self.ids_stack.is_empty() { + self.pop_any(); + } + + let update = TreeUpdate { + nodes: std::mem::take(&mut self.all_nodes), + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: self.focus, + }; + + Self::repair_tree_update(update) + } + + fn current_node_mut(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { + if self.ids_stack.len() <= 1 + || self.ids_stack.last().copied() != Some(id) + || self.suppression_stack.last().copied().unwrap_or(true) + { + None + } else { + self.nodes_stack.last_mut() + } + } + + fn is_suppressed(&self) -> bool { + self.ambient_suppression_depth > 0 + || self.suppression_stack.last().copied().unwrap_or_default() + } + + fn prune_emitted_subtree(&mut self, id: NodeId) { + let mut pruned_ids = FxHashSet::default(); + pruned_ids.insert(id); + + if let Some(current_node) = self.nodes_stack.last() { + let mut pending = current_node.children().to_vec(); + if !pending.is_empty() { + let emitted_nodes_by_id = self + .all_nodes + .iter() + .map(|(node_id, node)| (*node_id, node)) + .collect::>(); + while let Some(child_id) = pending.pop() { + if !pruned_ids.insert(child_id) { + continue; + } + + if let Some(child_node) = emitted_nodes_by_id.get(&child_id) { + pending.extend(child_node.children().iter().copied()); + } + } + } + } + + for node_id in &pruned_ids { + self.seen_ids.remove(node_id); + } + + if pruned_ids.contains(&self.focus) { + self.focus = ROOT_NODE_ID; + } + + self.all_nodes + .retain(|(node_id, _)| !pruned_ids.contains(node_id)); + + for (_, node) in &mut self.all_nodes { + Self::remove_child_refs(node, &pruned_ids); + } + for node in &mut self.nodes_stack { + Self::remove_child_refs(node, &pruned_ids); + } + } + + fn remove_child_refs(node: &mut accesskit::Node, removed_ids: &FxHashSet) { + if node + .children() + .iter() + .any(|child_id| removed_ids.contains(child_id)) + { + let children = node + .children() + .iter() + .copied() + .filter(|child_id| !removed_ids.contains(child_id)) + .collect::>(); + node.set_children(children); + } + } + + fn pop_any(&mut self) { + if let (Some(id), Some(node), Some(suppressed)) = ( + self.ids_stack.pop(), + self.nodes_stack.pop(), + self.suppression_stack.pop(), + ) { + if suppressed { + return; + } + + if let (Some(parent), Some(parent_suppressed)) = + (self.nodes_stack.last_mut(), self.suppression_stack.last()) + { + if !*parent_suppressed { + parent.push_child(id); + } + } + self.all_nodes.push((id, node)); + } + } + + fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate { + let node_ids: FxHashSet = update.nodes.iter().map(|(id, _)| *id).collect(); + + if !node_ids.contains(&update.focus) { + log::error!( + "a11y: focused node {:?} is not in the tree ({} nodes); falling back to root", + update.focus, + update.nodes.len() + ); + update.focus = ROOT_NODE_ID; + } + + for (id, node) in &mut update.nodes { + let has_invalid_child = node + .children() + .iter() + .any(|child_id| !node_ids.contains(child_id)); + if has_invalid_child { + let children = node.children(); + let invalid_count = children + .iter() + .filter(|child_id| !node_ids.contains(child_id)) + .count(); + log::error!( + "a11y: node {:?} references {} children not present in the tree; stripping invalid child references", + id, + invalid_count + ); + let valid = children + .iter() + .copied() + .filter(|child_id| node_ids.contains(child_id)) + .collect::>(); + node.set_children(valid); + } + } + + update + } +} + +#[cfg(test)] +#[path = "builder_tests.rs"] +mod tests; diff --git a/crates/gpui/src/window/a11y/builder_tests.rs b/crates/gpui/src/window/a11y/builder_tests.rs new file mode 100644 index 00000000..476b3e54 --- /dev/null +++ b/crates/gpui/src/window/a11y/builder_tests.rs @@ -0,0 +1,232 @@ +use super::*; +use crate::{point, px, size}; + +fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a accesskit::Node { + update + .nodes + .iter() + .find_map(|(node_id, node)| (*node_id == id).then_some(node)) + .unwrap() +} + +fn has_update_node(update: &TreeUpdate, id: NodeId) -> bool { + update.nodes.iter().any(|(node_id, _)| *node_id == id) +} + +#[test] +fn preserves_unsuppressed_tree_shape() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).children(), &[]); +} + +#[test] +fn updates_current_non_root_node_bounds() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.update_current_node_bounds( + NodeId(1), + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + 2., + )); + builder.pop(); + + let update = builder.finalize(); + assert_eq!( + node(&update, NodeId(1)).bounds(), + Some(accesskit::Rect { + x0: 4., + y0: 6., + x1: 12., + y1: 16., + }) + ); +} + +#[test] +fn suppresses_current_node_before_finalization() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.has_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); +} + +#[test] +fn skips_descendants_while_current_node_is_suppressed() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 2); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).children(), &[]); +} + +#[test] +fn ambient_descendant_suppression_under_root_skips_child_push_without_suppressing_root() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + builder.begin_suppressing_descendants(); + assert!(!builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.end_suppressing_descendants(); + + assert!(builder.has_node(ROOT_NODE_ID)); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 2); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); +} + +#[test] +fn id_specific_update_requires_current_node() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(!builder.update_current_node_bounds( + NodeId(2), + Bounds::new(point(px(2.), px(3.)), size(px(4.), px(5.))), + 2., + )); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, NodeId(1)).bounds(), None); +} + +#[test] +fn id_specific_suppress_requires_current_node() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(!builder.suppress_current_node(NodeId(2))); + assert!(builder.has_node(NodeId(1))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); +} + +#[test] +fn suppressing_focused_current_node_removes_focus_from_tree() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.set_focus(NodeId(1)); + assert!(builder.suppress_current_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.focus, ROOT_NODE_ID); + assert_eq!(update.nodes.len(), 1); +} + +#[test] +fn suppressing_current_node_prunes_already_emitted_descendants() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::TextInput))); + builder.set_focus(NodeId(3)); + builder.pop(); + builder.pop(); + + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.has_node(NodeId(1))); + assert!(!builder.has_node(NodeId(2))); + assert!(!builder.has_node(NodeId(3))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(update.focus, ROOT_NODE_ID); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); + assert!(!has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); + assert!(!has_update_node(&update, NodeId(3))); +} + +#[test] +fn parent_children_do_not_reference_suppressed_descendants() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.suppress_current_node(NodeId(2))); + builder.pop(); + + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(3)]); +} + +#[test] +fn restores_prepaint_snapshot_for_builder_state() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + let snapshot = builder.prepaint_snapshot(); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.begin_suppressing_descendants(); + assert!(!builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.set_focus(NodeId(1)); + + builder.restore_prepaint_snapshot(snapshot); + + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + builder.set_focus(NodeId(2)); + builder.pop(); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 3); + assert_eq!(update.focus, NodeId(2)); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); +} diff --git a/crates/gpui/tests/cursor_style.rs b/crates/gpui/tests/cursor_style.rs new file mode 100644 index 00000000..f7081ec8 --- /dev/null +++ b/crates/gpui/tests/cursor_style.rs @@ -0,0 +1,15 @@ +use gpui::{CursorStyle, Styled, div}; + +#[test] +fn cursor_none_helper_sets_none_cursor_style() { + let mut element = div().cursor_none(CursorStyle::Arrow); + + assert_eq!(element.style().mouse_cursor, Some(CursorStyle::None)); +} + +#[test] +fn cursor_style_accepts_none() { + let mut element = div().cursor(CursorStyle::None); + + assert_eq!(element.style().mouse_cursor, Some(CursorStyle::None)); +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index b687dafc..3dc0bf2f 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -28,6 +28,7 @@ wayland = [ "wayland-protocols-plasma", "wayland-protocols-wlr", "filedescriptor", + "wgpu", "xkbcommon", "open", "gpui/wayland", @@ -53,6 +54,8 @@ screen-capture = [ [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +accesskit.workspace = true +accesskit_unix.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true @@ -91,6 +94,7 @@ font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "11052312 bitflags = { workspace = true, optional = true } filedescriptor = { version = "0.8.2", optional = true } open = { version = "5.2.0", optional = true } +wgpu = { workspace = true, optional = true } xkbcommon = { version = "0.8.0", features = ["wayland", "x11"], optional = true } # Screen capture diff --git a/crates/gpui_linux/src/linux.rs b/crates/gpui_linux/src/linux.rs index bafdc2e5..a1123932 100644 --- a/crates/gpui_linux/src/linux.rs +++ b/crates/gpui_linux/src/linux.rs @@ -1,3 +1,5 @@ +#[cfg(any(feature = "wayland", feature = "x11"))] +mod accesskit_shims; mod dispatcher; mod headless; mod keyboard; diff --git a/crates/gpui_linux/src/linux/accesskit_shims.rs b/crates/gpui_linux/src/linux/accesskit_shims.rs new file mode 100644 index 00000000..375ba62f --- /dev/null +++ b/crates/gpui_linux/src/linux/accesskit_shims.rs @@ -0,0 +1,29 @@ +pub(crate) struct TrivialActivationHandler { + pub(crate) callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +pub(crate) struct TrivialActionHandler { + pub(crate) callback: Box, +} + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.callback)(request); + } +} + +pub(crate) struct TrivialDeactivationHandler { + pub(crate) callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } +} diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index 56cc9e8d..4c990540 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -99,6 +99,12 @@ impl LinuxClient for HeadlessClient { fn set_cursor_style(&self, _style: CursorStyle) {} + fn hide_cursor_until_mouse_moves(&self) {} + + fn is_cursor_visible(&self) -> bool { + false + } + fn open_uri(&self, _uri: &str) {} fn reveal_path(&self, _path: std::path::PathBuf) {} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 4614be6b..eda27f71 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -123,6 +123,8 @@ pub(crate) trait LinuxClient { options: WindowParams, ) -> anyhow::Result>; fn set_cursor_style(&self, style: CursorStyle); + fn hide_cursor_until_mouse_moves(&self); + fn is_cursor_visible(&self) -> bool; fn open_uri(&self, uri: &str); fn reveal_path(&self, path: PathBuf); fn write_to_primary(&self, item: ClipboardItem); @@ -570,6 +572,14 @@ impl Platform for LinuxPlatform

{ self.inner.set_cursor_style(style) } + fn hide_cursor_until_mouse_moves(&self) { + self.inner.hide_cursor_until_mouse_moves() + } + + fn is_cursor_visible(&self) -> bool { + self.inner.is_cursor_visible() + } + fn should_auto_hide_scrollbars(&self) -> bool { self.inner.with_common(|common| common.auto_hide_scrollbars) } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 3b855be1..aeba6110 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -248,6 +248,7 @@ pub(crate) struct WaylandClientState { keyboard_focused_window: Option, loop_handle: LoopHandle<'static, WaylandClientStatePtr>, cursor_style: Option, + cursor_hidden_window: Option, clipboard: Clipboard, data_offers: Vec>, primary_data_offer: Option>, @@ -402,6 +403,77 @@ impl WaylandClientStatePtr { { state.keyboard_focused_window = Some(window); } + if let Some(window) = state.cursor_hidden_window.take() + && !window.ptr_eq(&closed_window) + { + state.cursor_hidden_window = Some(window); + } + } +} + +impl WaylandClientState { + fn hide_cursor(&self) -> bool { + let Some(wl_pointer) = self.wl_pointer.clone() else { + // Seat lost its pointer capability; nothing to hide. + return false; + }; + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + wl_pointer.set_cursor(serial, None, 0, 0); + true + } + + fn hide_cursor_until_mouse_moves(&mut self) { + if self.cursor_hidden_window.is_some() { + return; + } + let Some(focused_window) = self.mouse_focused_window.clone() else { + // No surface to apply the hidden cursor to. + return; + }; + if self.hide_cursor() { + self.cursor_hidden_window = Some(focused_window); + } + } + + fn restore_cursor_after_hide(&mut self) { + if self.cursor_hidden_window.is_none() { + return; + } + let style = self.cursor_style.unwrap_or(CursorStyle::Arrow); + if style == CursorStyle::None { + self.cursor_hidden_window = None; + return; + } + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + if let Some(cursor_shape_device) = &self.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(style)); + self.cursor_hidden_window = None; + return; + } + let Some(focused_window) = self.mouse_focused_window.clone() else { + log::warn!( + "wayland: no focused surface to restore cursor style {:?} after hide; cursor may stay invisible", + style + ); + self.cursor_hidden_window = None; + return; + }; + let Some(wl_pointer) = self.wl_pointer.clone() else { + log::warn!( + "wayland: no wl_pointer to restore cursor style {:?} after hide; cursor may stay invisible", + style + ); + self.cursor_hidden_window = None; + return; + }; + let scale = focused_window.primary_output_scale(); + self.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); + self.cursor_hidden_window = None; } } @@ -642,6 +714,7 @@ impl WaylandClient { loop_handle: handle.clone(), enter_token: None, cursor_style: None, + cursor_hidden_window: None, clipboard: Clipboard::new(conn.clone(), handle.clone()), data_offers: Vec::new(), primary_data_offer: None, @@ -684,7 +757,7 @@ impl LinuxClient for WaylandClient { .outputs .iter() .find_map(|(object_id, output)| { - (object_id.protocol_id() == u32::from(id)).then(|| { + (object_id.protocol_id() as u64 == u64::from(id)).then(|| { Rc::new(WaylandDisplay { id: object_id.clone(), name: output.name.clone(), @@ -725,14 +798,22 @@ impl LinuxClient for WaylandClient { let parent = state.keyboard_focused_window.clone(); - let target_output = params.display_id.and_then(|display_id| { - let target_protocol_id: u32 = display_id.into(); - state - .wl_outputs - .iter() - .find(|(id, _)| id.protocol_id() == target_protocol_id) - .map(|(_, output)| output.clone()) - }); + let target_output = match params.display_id { + Some(display_id) => { + let target_protocol_id: u64 = display_id.into(); + Some( + state + .wl_outputs + .iter() + .find(|(id, _)| id.protocol_id() as u64 == target_protocol_id) + .map(|(_, output)| output.clone()) + .ok_or_else(|| { + anyhow::anyhow!("no Wayland output found for display {:?}", display_id) + })?, + ) + } + None => None, + }; let (window, surface_id) = WaylandWindow::new( handle, @@ -759,33 +840,48 @@ impl LinuxClient for WaylandClient { .as_ref() .is_some_and(|w| !w.is_blocked())); - if need_update { - let serial = state.serial_tracker.get(SerialKind::MouseEnter); - state.cursor_style = Some(style); - - if let CursorStyle::None = style { - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - wl_pointer.set_cursor(serial, None, 0, 0); - } else if let Some(cursor_shape_device) = &state.cursor_shape_device { - cursor_shape_device.set_shape(serial, to_shape(style)); - } else if let Some(focused_window) = &state.mouse_focused_window { - // cursor-shape-v1 isn't supported, set the cursor using a surface. - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - let scale = focused_window.primary_output_scale(); - state.cursor.set_icon( - &wl_pointer, - serial, - cursor_style_to_icon_names(style), - scale, - ); - } + if !need_update { + return; + } + + state.cursor_style = Some(style); + + if style == CursorStyle::None { + state.hide_cursor(); + return; + } + + // Don't clobber the invisible cursor; restore reads back from `cursor_style`. + if state.cursor_hidden_window.is_some() { + return; } + + let serial = state.serial_tracker.get(SerialKind::MouseEnter); + if let Some(cursor_shape_device) = &state.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(style)); + } else if let Some(focused_window) = &state.mouse_focused_window { + // cursor-shape-v1 isn't supported, set the cursor using a surface. + let wl_pointer = state + .wl_pointer + .clone() + .expect("window is focused by pointer"); + let scale = focused_window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); + } + } + + fn hide_cursor_until_mouse_moves(&self) { + self.0.borrow_mut().hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + let state = self.0.borrow(); + state.cursor_hidden_window.is_none() && state.cursor_style != Some(CursorStyle::None) } fn open_uri(&self, uri: &str) { @@ -855,7 +951,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set_primary(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state.serial_tracker.get_latest_input(); let data_source = primary_selection_manager.create_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -875,7 +971,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state.serial_tracker.get_latest_input(); let data_source = data_device_manager.create_data_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -1396,6 +1492,7 @@ impl Dispatch for WaylandClientStatePtr { state.enter_token.take(); // Prevent keyboard events from repeating after opening e.g. a file chooser and closing it quickly state.repeat.current_id += 1; + state.restore_cursor_after_hide(); if let Some(window) = keyboard_focused_window { if let Some(ref mut compose) = state.compose_state { @@ -1696,12 +1793,9 @@ impl Dispatch for WaylandClientStatePtr { if state.enter_token.is_some() { state.enter_token = None; } + state.restore_cursor_after_hide(); if let Some(style) = state.cursor_style { - if let CursorStyle::None = style { - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); + if style == CursorStyle::None { wl_pointer.set_cursor(serial, None, 0, 0); } else if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, to_shape(style)); @@ -1729,6 +1823,7 @@ impl Dispatch for WaylandClientStatePtr { state.mouse_focused_window = None; state.mouse_location = None; state.button_pressed = None; + state.cursor_hidden_window = None; drop(state); focused_window.handle_input(input); @@ -1744,6 +1839,7 @@ impl Dispatch for WaylandClientStatePtr { return; } state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.restore_cursor_after_hide(); if let Some(window) = state.mouse_focused_window.clone() { if window.is_blocked() { diff --git a/crates/gpui_linux/src/linux/wayland/display.rs b/crates/gpui_linux/src/linux/wayland/display.rs index 874cae87..8fa9122d 100644 --- a/crates/gpui_linux/src/linux/wayland/display.rs +++ b/crates/gpui_linux/src/linux/wayland/display.rs @@ -25,7 +25,7 @@ impl Hash for WaylandDisplay { impl PlatformDisplay for WaylandDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.id.protocol_id()) + DisplayId::new(self.id.protocol_id() as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/crates/gpui_linux/src/linux/wayland/serial.rs b/crates/gpui_linux/src/linux/wayland/serial.rs index eadc7a9c..eaf479a1 100644 --- a/crates/gpui_linux/src/linux/wayland/serial.rs +++ b/crates/gpui_linux/src/linux/wayland/serial.rs @@ -9,6 +9,12 @@ pub(crate) enum SerialKind { KeyPress, } +impl SerialKind { + fn is_input(&self) -> bool { + matches!(self, SerialKind::MousePress | SerialKind::KeyPress) + } +} + #[derive(Debug)] struct SerialData { serial: u32, @@ -46,4 +52,63 @@ impl SerialTracker { .map(|serial_data| serial_data.serial) .unwrap_or(0) } + + /// Returns the most recent input serial. + /// + /// Returns 0 only if no input serial has been received yet. + pub fn get_latest_input(&self) -> u32 { + latest_serial( + self.serials + .iter() + .filter_map(|(kind, serial_data)| kind.is_input().then_some(serial_data.serial)), + ) + } +} + +fn latest_serial(serials: impl Iterator) -> u32 { + serials + .reduce(|latest, serial| { + if serial_is_after(serial, latest) { + serial + } else { + latest + } + }) + .unwrap_or(0) +} + +fn serial_is_after(serial: u32, other: u32) -> bool { + const SERIAL_WRAP_THRESHOLD: u32 = 1 << 31; + + serial != other && serial.wrapping_sub(other) < SERIAL_WRAP_THRESHOLD +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latest_serial_uses_wrapping_order() { + assert_eq!(latest_serial([u32::MAX - 1, 3].into_iter()), 3); + } + + #[test] + fn latest_input_ignores_non_input_serials() { + let mut tracker = SerialTracker::new(); + tracker.update(SerialKind::KeyPress, 10); + tracker.update(SerialKind::MouseEnter, 40); + tracker.update(SerialKind::InputMethod, 50); + tracker.update(SerialKind::DataDevice, 60); + + assert_eq!(tracker.get_latest_input(), 10); + } + + #[test] + fn latest_input_uses_wrapping_order() { + let mut tracker = SerialTracker::new(); + tracker.update(SerialKind::KeyPress, u32::MAX - 1); + tracker.update(SerialKind::MousePress, 3); + + assert_eq!(tracker.get_latest_input(), 3); + } } diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 1173f3d4..5c45d3da 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -27,6 +27,9 @@ use wayland_protocols::{ use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; +use crate::linux::accesskit_shims::{ + TrivialActionHandler, TrivialActivationHandler, TrivialDeactivationHandler, +}; use crate::linux::wayland::{display::WaylandDisplay, serial::SerialKind}; use crate::linux::{Globals, Output, WaylandClientStatePtr, get_window}; use gpui::{ @@ -121,6 +124,7 @@ pub struct WaylandWindowState { in_progress_window_controls: Option, window_controls: WindowControls, client_inset: Option, + accesskit_adapter: Option, } pub enum WaylandSurfaceState { @@ -343,6 +347,7 @@ impl WaylandWindowState { height: DevicePixels(f32::from(options.bounds.size.height) as i32), }, transparent: true, + preferred_present_mode: Some(wgpu::PresentMode::Mailbox), }; WgpuRenderer::new(gpu_context, &raw_window, config)? }; @@ -393,6 +398,7 @@ impl WaylandWindowState { in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, + accesskit_adapter: None, }) } @@ -435,6 +441,30 @@ impl WaylandWindowState { WindowDecorations::Client => self.client_inset.unwrap_or(px(0.0)), } } + + fn update_accesskit_window_bounds(&mut self) { + let scale = self.scale; + let bounds = self.bounds.map_origin(|_| px(0.0)); + let inner_bounds = bounds.inset(self.inset()); + + let outer = accesskit::Rect { + x0: f64::from(f32::from(bounds.origin.x) * scale), + y0: f64::from(f32::from(bounds.origin.y) * scale), + x1: f64::from(f32::from(bounds.origin.x + bounds.size.width) * scale), + y1: f64::from(f32::from(bounds.origin.y + bounds.size.height) * scale), + }; + + let inner = accesskit::Rect { + x0: f64::from(f32::from(inner_bounds.origin.x) * scale), + y0: f64::from(f32::from(inner_bounds.origin.y) * scale), + x1: f64::from(f32::from(inner_bounds.origin.x + inner_bounds.size.width) * scale), + y1: f64::from(f32::from(inner_bounds.origin.y + inner_bounds.size.height) * scale), + }; + + if let Some(adapter) = self.accesskit_adapter.as_mut() { + adapter.set_root_window_bounds(outer, inner); + } + } } pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); @@ -952,6 +982,7 @@ impl WaylandWindowStatePtr { if let Some(scale) = scale { state.scale = scale; } + state.update_accesskit_window_bounds(); let device_bounds = state.bounds.to_device_pixels(state.scale); state.renderer.update_drawable_size(device_bounds.size); (state.bounds.size, state.scale) @@ -1027,6 +1058,9 @@ impl WaylandWindowStatePtr { if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { fun(focus); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1468,6 +1502,36 @@ impl PlatformWindow for WaylandWindow { bell.ring(surface); } } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler { + callback: callbacks.action, + }; + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let mut adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + adapter.update_window_focus_state(self.borrow().active); + + self.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + let mut state = self.borrow_mut(); + state.update_accesskit_window_bounds(); + } } fn update_window(mut state: RefMut) { diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 672dfafc..247b551b 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -210,6 +210,8 @@ pub struct X11ClientState { pub(crate) cursor_handle: cursor::Handle, pub(crate) cursor_styles: HashMap, pub(crate) cursor_cache: HashMap>, + pub(crate) invisible_cursor_cache: Option, + pub(crate) cursor_hidden_window: Option, pointer_device_states: BTreeMap, pub(crate) pinch_scale: f32, @@ -248,6 +250,9 @@ impl X11ClientStatePtr { if state.keyboard_focused_window == Some(x_window) { state.keyboard_focused_window = None; } + if state.cursor_hidden_window == Some(x_window) { + state.cursor_hidden_window = None; + } state.cursor_styles.remove(&x_window); } @@ -531,6 +536,8 @@ impl X11Client { cursor_handle, cursor_styles: HashMap::default(), cursor_cache: HashMap::default(), + cursor_hidden_window: None, + invisible_cursor_cache: None, pointer_device_states, pinch_scale: 1.0, @@ -953,6 +960,7 @@ impl X11Client { compose_state.reset(); } state.pre_edit_text.take(); + state.restore_cursor_after_hide(); drop(state); self.reset_ime(); window.handle_ime_delete(); @@ -1269,6 +1277,7 @@ impl X11Client { Event::XinputMotion(event) => { let window = self.get_window(event.event)?; let mut state = self.0.borrow_mut(); + state.restore_cursor_after_hide(); if window.is_blocked() { // We want to set the cursor to the default arrow // when the window is blocked @@ -1331,6 +1340,7 @@ impl X11Client { window.set_hovered(true); let mut state = self.0.borrow_mut(); state.mouse_focused_window = Some(event.event); + state.restore_cursor_after_hide(); } Event::XinputLeave(event) if event.mode == xinput::NotifyMode::NORMAL => { let mut state = self.0.borrow_mut(); @@ -1544,7 +1554,7 @@ impl LinuxClient for X11Client { X11Display::new( &state.xcb_connection, state.scale_factor, - u32::from(id) as usize, + u64::from(id) as usize, ) .ok()?, )) @@ -1646,11 +1656,22 @@ impl LinuxClient for X11Client { return; } - let Some(cursor) = state.get_cursor_icon(style) else { + state.cursor_styles.insert(focused_window, style); + + // Don't clobber the invisible cursor; restore reads back from `cursor_styles`. + if state.cursor_hidden_window == Some(focused_window) { + return; + } + + let cursor = if style == CursorStyle::None { + state.get_or_create_invisible_cursor() + } else { + state.get_cursor_icon(style) + }; + let Some(cursor) = cursor else { return; }; - state.cursor_styles.insert(focused_window, style); check_reply( || "Failed to set cursor style", state.xcb_connection.change_window_attributes( @@ -1665,6 +1686,22 @@ impl LinuxClient for X11Client { state.xcb_connection.flush().log_err(); } + fn hide_cursor_until_mouse_moves(&self) { + self.0.borrow_mut().hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + let state = self.0.borrow(); + let focused_cursor_style = state + .mouse_focused_window + .and_then(|window| state.cursor_styles.get(&window).copied()); + is_cursor_visible( + state.cursor_hidden_window, + state.mouse_focused_window, + focused_cursor_style, + ) + } + fn open_uri(&self, uri: &str) { #[cfg(any(feature = "wayland", feature = "x11"))] open_uri_internal( @@ -1965,41 +2002,33 @@ impl X11ClientState { return *cursor; } - let result; - match style { - CursorStyle::None => match create_invisible_cursor(&self.xcb_connection) { - Ok(loaded_cursor) => result = Ok(loaded_cursor), - Err(err) => result = Err(err.context("X11: error while creating invisible cursor")), - }, - _ => 'outer: { - let mut errors = String::new(); - let cursor_icon_names = cursor_style_to_icon_names(style); - for cursor_icon_name in cursor_icon_names { - match self - .cursor_handle - .load_cursor(&self.xcb_connection, cursor_icon_name) - { - Ok(loaded_cursor) => { - if loaded_cursor != x11rb::NONE { - result = Ok(loaded_cursor); - break 'outer; - } - } - Err(err) => { - errors.push_str(&err.to_string()); - errors.push('\n'); + let result = 'outer: { + let mut errors = String::new(); + let cursor_icon_names = cursor_style_to_icon_names(style); + for cursor_icon_name in cursor_icon_names { + match self + .cursor_handle + .load_cursor(&self.xcb_connection, cursor_icon_name) + { + Ok(loaded_cursor) => { + if loaded_cursor != x11rb::NONE { + break 'outer Ok(loaded_cursor); } } + Err(err) => { + errors.push_str(&err.to_string()); + errors.push('\n'); + } } - if errors.is_empty() { - result = Err(anyhow!( - "errors while loading cursor icons {:?}:\n{}", - cursor_icon_names, - errors - )); - } else { - result = Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)); - } + } + if errors.is_empty() { + Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)) + } else { + Err(anyhow!( + "errors while loading cursor icons {:?}:\n{}", + cursor_icon_names, + errors + )) } }; @@ -2031,6 +2060,95 @@ impl X11ClientState { self.cursor_cache.insert(style, cursor); cursor } + + fn get_or_create_invisible_cursor(&mut self) -> Option { + if let Some(cursor) = self.invisible_cursor_cache { + return Some(cursor); + } + let root = self.xcb_connection.setup().roots[self.x_root_index].root; + let cursor = create_invisible_cursor(&self.xcb_connection, root) + .context("X11: error while creating invisible cursor") + .log_err()?; + self.invisible_cursor_cache = Some(cursor); + Some(cursor) + } + + fn hide_cursor_until_mouse_moves(&mut self) { + if self.cursor_hidden_window.is_some() { + return; + } + let Some(focused_window) = self.mouse_focused_window else { + // No window to apply the per-window invisible cursor to. + return; + }; + let Some(invisible_cursor) = self.get_or_create_invisible_cursor() else { + return; + }; + let hide_result = check_reply( + || "Failed to hide cursor", + self.xcb_connection.change_window_attributes( + focused_window, + &ChangeWindowAttributesAux { + cursor: Some(invisible_cursor), + ..Default::default() + }, + ), + ) + .and_then(|()| { + self.xcb_connection + .flush() + .map(|_| ()) + .map_err(handle_connection_error) + .context("X11 flush failed") + }) + .log_err(); + if hide_result.is_some() { + self.cursor_hidden_window = Some(focused_window); + } + } + + fn restore_cursor_after_hide(&mut self) { + let Some(hidden_window) = self.cursor_hidden_window else { + return; + }; + let style = self + .cursor_styles + .get(&hidden_window) + .copied() + .unwrap_or(CursorStyle::Arrow); + if style == CursorStyle::None { + self.cursor_hidden_window = None; + return; + } + let Some(cursor) = self.get_cursor_icon(style) else { + log::warn!( + "X11: no cursor icon available to restore {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + let restore_result = check_reply( + || "Failed to restore cursor style after hide", + self.xcb_connection.change_window_attributes( + hidden_window, + &ChangeWindowAttributesAux { + cursor: Some(cursor), + ..Default::default() + }, + ), + ) + .and_then(|()| { + self.xcb_connection + .flush() + .map(|_| ()) + .map_err(handle_connection_error) + .context("X11 flush failed") + }) + .log_err(); + if restore_result.is_some() { + self.cursor_hidden_window = None; + } + } } // Adapted from: @@ -2396,11 +2514,26 @@ fn make_scroll_wheel_event( } } +fn is_cursor_visible( + cursor_hidden_window: Option, + mouse_focused_window: Option, + focused_cursor_style: Option, +) -> bool { + if focused_cursor_style == Some(CursorStyle::None) { + return false; + } + + !matches!( + (cursor_hidden_window, mouse_focused_window), + (Some(hidden_window), Some(focused_window)) if hidden_window == focused_window + ) +} + fn create_invisible_cursor( connection: &XCBConnection, + root: xproto::Window, ) -> anyhow::Result { let empty_pixmap = connection.generate_id()?; - let root = connection.setup().roots[0].root; connection.create_pixmap(1, empty_pixmap, root, 1, 1)?; let cursor = connection.generate_id()?; @@ -2695,3 +2828,18 @@ fn xkb_state_for_key_event(xkb: &xkbc::State, event_state: xproto::KeyButMask) - key_event_state } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_is_visible_unless_hidden_window_is_focused_window() { + assert!(is_cursor_visible(None, None, None)); + assert!(is_cursor_visible(Some(1), None, None)); + assert!(is_cursor_visible(None, Some(1), None)); + assert!(is_cursor_visible(Some(1), Some(2), None)); + assert!(!is_cursor_visible(Some(1), Some(1), None)); + assert!(!is_cursor_visible(None, Some(1), Some(CursorStyle::None))); + } +} diff --git a/crates/gpui_linux/src/linux/x11/display.rs b/crates/gpui_linux/src/linux/x11/display.rs index 900c55e7..582d76f7 100644 --- a/crates/gpui_linux/src/linux/x11/display.rs +++ b/crates/gpui_linux/src/linux/x11/display.rs @@ -38,7 +38,7 @@ impl X11Display { impl PlatformDisplay for X11Display { fn id(&self) -> DisplayId { - DisplayId::new(self.x_screen_index as u32) + DisplayId::new(self.x_screen_index as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 60423869..4018c406 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -2,6 +2,9 @@ use anyhow::{Context as _, anyhow}; use x11rb::connection::RequestConnection; use crate::linux::X11ClientStatePtr; +use crate::linux::accesskit_shims::{ + TrivialActionHandler, TrivialActivationHandler, TrivialDeactivationHandler, +}; use gpui::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, OverlayInputMode, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, @@ -282,6 +285,7 @@ pub struct X11WindowState { edge_constraints: Option, pub handle: AnyWindowHandle, last_insets: [u32; 4], + accesskit_adapter: Option, } impl X11WindowState { @@ -340,7 +344,12 @@ impl rwh::HasDisplayHandle for X11Window { }; let screen_id = { let state = self.0.state.borrow(); - u32::from(state.display.id()) as i32 + let display_id = u64::from(state.display.id()); + let Ok(screen_id) = i32::try_from(display_id) else { + log::error!("X11 display id {display_id} cannot fit into XCB screen id"); + return Err(rwh::HandleError::Unavailable); + }; + screen_id }; let handle = rwh::XcbDisplayHandle::new(Some(non_zero), screen_id); Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) @@ -423,9 +432,17 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { - let x_screen_index = params - .display_id - .map_or(x_main_screen_index, |did| u32::from(did) as usize); + let x_screen_index = match params.display_id { + Some(did) => { + let index = usize::try_from(u64::from(did)) + .context("X11 display id does not fit in usize")?; + xcb.setup().roots.get(index).with_context(|| { + format!("no X11 screen found for display id {}", u64::from(did)) + })?; + index + } + None => x_main_screen_index, + }; let visual_set = find_visuals(xcb, x_screen_index); @@ -727,6 +744,7 @@ impl X11WindowState { // If the window appearance changes, then the renderer will get updated // too transparent: false, + preferred_present_mode: None, }; WgpuRenderer::new(gpu_context, &raw_window, config)? }; @@ -780,6 +798,7 @@ impl X11WindowState { decorations: WindowDecorations::Server, last_insets: [0, 0, 0, 0], edge_constraints: None, + accesskit_adapter: None, counter_id: sync_request_counter, last_sync_counter: None, }) @@ -1049,15 +1068,15 @@ impl X11WindowStatePtr { .chunks_exact(4) .map(|chunk| u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); - state.active = false; state.fullscreen = false; state.maximized_vertical = false; state.maximized_horizontal = false; state.hidden = false; + let mut active = false; for atom in atoms { if atom == state.atoms._NET_WM_STATE_FOCUSED { - state.active = true; + active = true; } else if atom == state.atoms._NET_WM_STATE_FULLSCREEN { state.fullscreen = true; } else if atom == state.atoms._NET_WM_STATE_MAXIMIZED_VERT { @@ -1069,6 +1088,13 @@ impl X11WindowStatePtr { } } + let active_changed = state.active != active; + drop(state); + + if active_changed { + self.set_active(active); + } + Ok(()) } @@ -1250,9 +1276,13 @@ impl X11WindowStatePtr { } pub fn set_active(&self, focus: bool) { + self.state.borrow_mut().active = focus; if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { fun(focus); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1854,4 +1884,59 @@ impl PlatformWindow for X11Window { check_reply(|| "X11 Bell failed.", self.0.xcb.bell(0)).log_err(); xcb_flush(&self.0.xcb); } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler { + callback: callbacks.action, + }; + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let mut adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + adapter.update_window_focus_state(self.0.state.borrow().active); + + self.0.state.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.0.state.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + let mut state = self.0.state.borrow_mut(); + let scale = state.scale_factor; + let bounds = state.bounds; + let [left, right, top, bottom] = state.last_insets; + + let x = f32::from(bounds.origin.x); + let y = f32::from(bounds.origin.y); + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + + let outer = accesskit::Rect { + x0: (x * scale) as f64, + y0: (y * scale) as f64, + x1: ((x + width) * scale) as f64, + y1: ((y + height) * scale) as f64, + }; + + let inner = accesskit::Rect { + x0: (x * scale) as f64 + left as f64, + y0: (y * scale) as f64 + top as f64, + x1: ((x + width) * scale) as f64 - right as f64, + y1: ((y + height) * scale) as f64 - bottom as f64, + }; + + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.set_root_window_bounds(outer, inner); + } + } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 06e5d0e7..5e858791 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -22,6 +22,8 @@ screen-capture = ["gpui/screen-capture"] gpui.workspace = true [target.'cfg(target_os = "macos")'.dependencies] +accesskit.workspace = true +accesskit_macos.workspace = true anyhow.workspace = true async-task = "4.7" block = "0.1" diff --git a/crates/gpui_macos/src/display.rs b/crates/gpui_macos/src/display.rs index b9338bff..8e5db589 100644 --- a/crates/gpui_macos/src/display.rs +++ b/crates/gpui_macos/src/display.rs @@ -73,7 +73,7 @@ unsafe extern "C" { impl PlatformDisplay for MacDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.0) + DisplayId::new(self.0 as u64) } fn uuid(&self) -> Result { diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 0c3f9298..034e4f78 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -50,7 +50,10 @@ use std::{ ptr, rc::Rc, slice, str, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, }; use util::{ ResultExt, @@ -64,7 +67,7 @@ const MAC_PLATFORM_IVAR: &str = "platform"; static mut APP_CLASS: *const Class = ptr::null(); static mut APP_DELEGATE_CLASS: *const Class = ptr::null(); -#[ctor] +#[ctor(unsafe)] unsafe fn build_classes() { unsafe { APP_CLASS = { @@ -74,7 +77,7 @@ unsafe fn build_classes() { } }; unsafe { - APP_DELEGATE_CLASS = unsafe { + APP_DELEGATE_CLASS = { let mut decl = ClassDecl::new("GPUIApplicationDelegate", class!(NSResponder)).unwrap(); decl.add_ivar::<*mut c_void>(MAC_PLATFORM_IVAR); decl.add_method( @@ -177,6 +180,9 @@ pub(crate) struct MacPlatformState { dock_menu: Option, menus: Option>, keyboard_mapper: Rc, + /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. + cursor_visible: Arc, + cursor_hidden_by_style: bool, } impl MacPlatform { @@ -213,6 +219,8 @@ impl MacPlatform { on_thermal_state_change: None, menus: None, keyboard_mapper, + cursor_visible: Arc::new(AtomicBool::new(true)), + cursor_hidden_by_style: false, })) } @@ -616,10 +624,14 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { - let renderer_context = self.0.lock().renderer_context.clone(); + let (renderer_context, cursor_visible) = { + let state = self.0.lock(); + (state.renderer_context.clone(), state.cursor_visible.clone()) + }; Ok(Box::new(MacWindow::open( handle, options, + cursor_visible, self.foreground_executor(), self.background_executor(), renderer_context, @@ -975,12 +987,29 @@ impl Platform for MacPlatform { /// Match cursor style to one of the styles available /// in macOS's [NSCursor](https://developer.apple.com/documentation/appkit/nscursor). fn set_cursor_style(&self, style: CursorStyle) { + let cursor_was_hidden_by_style = { + let mut state = self.0.lock(); + if style == CursorStyle::None { + if state.cursor_hidden_by_style { + return; + } + state.cursor_hidden_by_style = true; + false + } else { + std::mem::replace(&mut state.cursor_hidden_by_style, false) + } + }; + unsafe { if style == CursorStyle::None { - let _: () = msg_send![class!(NSCursor), setHiddenUntilMouseMoves:YES]; + let _: () = msg_send![class!(NSCursor), hide]; return; } + if cursor_was_hidden_by_style { + let _: () = msg_send![class!(NSCursor), unhide]; + } + let new_cursor: id = match style { CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], @@ -1025,6 +1054,21 @@ impl Platform for MacPlatform { } } + fn hide_cursor_until_mouse_moves(&self) { + let cursor_visible = self.0.lock().cursor_visible.clone(); + if !cursor_visible.swap(false, Ordering::Relaxed) { + return; + } + unsafe { + let _: () = msg_send![class!(NSCursor), setHiddenUntilMouseMoves: YES]; + } + } + + fn is_cursor_visible(&self) -> bool { + let state = self.0.lock(); + !state.cursor_hidden_by_style && state.cursor_visible.load(Ordering::Relaxed) + } + fn should_auto_hide_scrollbars(&self) -> bool { #[allow(non_upper_case_globals)] const NSScrollerStyleOverlay: NSInteger = 1; diff --git a/crates/gpui_macos/src/screen_capture.rs b/crates/gpui_macos/src/screen_capture.rs index a7eaa3e1..5ece21a3 100644 --- a/crates/gpui_macos/src/screen_capture.rs +++ b/crates/gpui_macos/src/screen_capture.rs @@ -284,7 +284,7 @@ pub(crate) fn get_sources() -> oneshot::Receiver> = + RefCell::new(HashMap::new()); +} + #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = NSWindowStyleMask::from_bits_retain(1 << 7); @@ -121,7 +127,7 @@ unsafe extern "C" { ) -> i32; } -#[ctor] +#[ctor(unsafe)] unsafe fn build_classes() { unsafe { WINDOW_CLASS = build_window_class("GPUIWindow", class!(NSWindow)); @@ -129,165 +135,161 @@ unsafe fn build_classes() { VIEW_CLASS = { let mut decl = ClassDecl::new("GPUIView", class!(NSView)).unwrap(); decl.add_ivar::<*mut c_void>(WINDOW_STATE_IVAR); - unsafe { - decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); + decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); - decl.add_method( - sel!(performKeyEquivalent:), - handle_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, - ); - decl.add_method( - sel!(keyDown:), - handle_key_down as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(keyUp:), - handle_key_up as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(rightMouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(rightMouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(otherMouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(otherMouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseMoved:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(pressureChangeWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseExited:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseDragged:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(scrollWheel:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(swipeWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(flagsChanged:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); + decl.add_method( + sel!(performKeyEquivalent:), + handle_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + decl.add_method( + sel!(keyDown:), + handle_key_down as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(keyUp:), + handle_key_up as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseMoved:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(pressureChangeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseExited:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(scrollWheel:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(swipeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(flagsChanged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); - decl.add_method( - sel!(makeBackingLayer), - make_backing_layer as extern "C" fn(&Object, Sel) -> id, - ); + decl.add_method( + sel!(makeBackingLayer), + make_backing_layer as extern "C" fn(&Object, Sel) -> id, + ); - decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); - decl.add_method( - sel!(viewDidChangeBackingProperties), - view_did_change_backing_properties as extern "C" fn(&Object, Sel), - ); - decl.add_method( - sel!(setFrameSize:), - set_frame_size as extern "C" fn(&Object, Sel, NSSize), - ); - decl.add_method( - sel!(displayLayer:), - display_layer as extern "C" fn(&Object, Sel, id), - ); + decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); + decl.add_method( + sel!(viewDidChangeBackingProperties), + view_did_change_backing_properties as extern "C" fn(&Object, Sel), + ); + decl.add_method( + sel!(setFrameSize:), + set_frame_size as extern "C" fn(&Object, Sel, NSSize), + ); + decl.add_method( + sel!(displayLayer:), + display_layer as extern "C" fn(&Object, Sel, id), + ); - decl.add_protocol(Protocol::get("NSTextInputClient").unwrap()); - decl.add_method( - sel!(validAttributesForMarkedText), - valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, - ); - decl.add_method( - sel!(hasMarkedText), - has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, - ); - decl.add_method( - sel!(markedRange), - marked_range as extern "C" fn(&Object, Sel) -> NSRange, - ); - decl.add_method( - sel!(selectedRange), - selected_range as extern "C" fn(&Object, Sel) -> NSRange, - ); - decl.add_method( - sel!(firstRectForCharacterRange:actualRange:), - first_rect_for_character_range - as extern "C" fn(&Object, Sel, NSRange, id) -> NSRect, - ); - decl.add_method( - sel!(insertText:replacementRange:), - insert_text as extern "C" fn(&Object, Sel, id, NSRange), - ); - decl.add_method( - sel!(setMarkedText:selectedRange:replacementRange:), - set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), - ); - decl.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); - decl.add_method( - sel!(attributedSubstringForProposedRange:actualRange:), - attributed_substring_for_proposed_range - as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, - ); - decl.add_method( - sel!(viewDidChangeEffectiveAppearance), - view_did_change_effective_appearance as extern "C" fn(&Object, Sel), - ); + decl.add_protocol(Protocol::get("NSTextInputClient").unwrap()); + decl.add_method( + sel!(validAttributesForMarkedText), + valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, + ); + decl.add_method( + sel!(hasMarkedText), + has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, + ); + decl.add_method( + sel!(markedRange), + marked_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + decl.add_method( + sel!(selectedRange), + selected_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + decl.add_method( + sel!(firstRectForCharacterRange:actualRange:), + first_rect_for_character_range + as extern "C" fn(&Object, Sel, NSRange, id) -> NSRect, + ); + decl.add_method( + sel!(insertText:replacementRange:), + insert_text as extern "C" fn(&Object, Sel, id, NSRange), + ); + decl.add_method( + sel!(setMarkedText:selectedRange:replacementRange:), + set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), + ); + decl.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); + decl.add_method( + sel!(attributedSubstringForProposedRange:actualRange:), + attributed_substring_for_proposed_range + as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, + ); + decl.add_method( + sel!(viewDidChangeEffectiveAppearance), + view_did_change_effective_appearance as extern "C" fn(&Object, Sel), + ); - // Suppress beep on keystrokes with modifier keys. - decl.add_method( - sel!(doCommandBySelector:), - do_command_by_selector as extern "C" fn(&Object, Sel, Sel), - ); + // Suppress beep on keystrokes with modifier keys. + decl.add_method( + sel!(doCommandBySelector:), + do_command_by_selector as extern "C" fn(&Object, Sel, Sel), + ); - decl.add_method( - sel!(acceptsFirstMouse:), - accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, - ); + decl.add_method( + sel!(acceptsFirstMouse:), + accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, + ); - decl.add_method( - sel!(characterIndexForPoint:), - character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, - ); - } + decl.add_method( + sel!(characterIndexForPoint:), + character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, + ); decl.register() }; BLURRED_VIEW_CLASS = { let mut decl = ClassDecl::new("BlurredView", class!(NSVisualEffectView)).unwrap(); - unsafe { - decl.add_method( - sel!(initWithFrame:), - blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id, - ); - decl.add_method( - sel!(updateLayer), - blurred_view_update_layer as extern "C" fn(&Object, Sel), - ); - decl.register() - } + decl.add_method( + sel!(initWithFrame:), + blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id, + ); + decl.add_method( + sel!(updateLayer), + blurred_view_update_layer as extern "C" fn(&Object, Sel), + ); + decl.register() }; } } @@ -445,6 +447,7 @@ struct MacWindowState { toggle_tab_bar_callback: Option>, activated_least_once: bool, closed: Arc, + cursor_visible: Arc, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -596,6 +599,10 @@ impl MacWindowState { } } +// AppKit callbacks and GPUI foreground tasks access this state on the main +// thread. Background tasks only hold the mutex long enough to check counters and +// call sendable callbacks. Main-thread-only objects, such as AccessKit's macOS +// adapter, must stay out of this struct. unsafe impl Send for MacWindowState {} pub(crate) struct MacWindow(Arc>); @@ -616,6 +623,7 @@ impl MacWindow { window_min_size, tabbing_identifier, }: WindowParams, + cursor_visible: Arc, foreground_executor: ForegroundExecutor, background_executor: BackgroundExecutor, renderer_context: renderer::Context, @@ -770,6 +778,7 @@ impl MacWindow { toggle_tab_bar_callback: None, activated_least_once: false, closed: Arc::new(AtomicBool::new(false)), + cursor_visible, sheet_parent: None, }))); @@ -1008,6 +1017,7 @@ impl Drop for MacWindow { let window = this.native_window; let sheet_parent = this.sheet_parent.take(); this.display_link.take(); + remove_a11y_adapter(window); unsafe { this.native_window.setDelegate_(nil); } @@ -1737,6 +1747,37 @@ impl PlatformWindow for MacWindow { unsafe { NSBeep() } } + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let window = self.0.lock().native_window; + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + let action_handler = A11yActionHandler(callbacks.action); + + let adapter = unsafe { + accesskit_macos::SubclassingAdapter::for_window( + window as *mut c_void, + activation_handler, + action_handler, + ) + }; + + set_a11y_adapter(window, adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let window = self.0.lock().native_window; + let events = + with_a11y_adapter(window, |adapter| adapter.update_if_active(|| tree_update)).flatten(); + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // macOS handles window bounds tracking automatically via NSAccessibility. + } + #[cfg(feature = "test-support")] fn render_to_image(&self, scene: &gpui::Scene) -> Result { let mut this = self.0.lock(); @@ -1744,6 +1785,24 @@ impl PlatformWindow for MacWindow { } } +struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + impl rwh::HasWindowHandle for MacWindow { fn window_handle(&self) -> Result, rwh::HandleError> { // SAFETY: The AppKitWindowHandle is a wrapper around a pointer to an NSView @@ -1797,10 +1856,47 @@ unsafe fn get_window_state(object: &Object) -> Arc> { unsafe fn drop_window_state(object: &Object) { unsafe { let raw: *mut c_void = *object.get_ivar(WINDOW_STATE_IVAR); - Arc::from_raw(raw as *mut Mutex); + let window_state = Arc::from_raw(raw as *mut Mutex); + remove_a11y_adapter(window_state.lock().native_window); } } +fn a11y_adapter_key(window: id) -> usize { + window as usize +} + +fn set_a11y_adapter(window: id, adapter: accesskit_macos::SubclassingAdapter) { + debug_assert_main_thread(); + A11Y_ADAPTERS.with_borrow_mut(|adapters| { + adapters.insert(a11y_adapter_key(window), adapter); + }); +} + +fn remove_a11y_adapter(window: id) { + debug_assert_main_thread(); + A11Y_ADAPTERS.with_borrow_mut(|adapters| { + // This is intentionally idempotent: both MacWindow::drop and Objective-C + // dealloc paths can observe teardown, depending on who releases last. + adapters.remove(&a11y_adapter_key(window)); + }); +} + +fn with_a11y_adapter( + window: id, + f: impl FnOnce(&mut accesskit_macos::SubclassingAdapter) -> T, +) -> Option { + debug_assert_main_thread(); + A11Y_ADAPTERS.with_borrow_mut(|adapters| adapters.get_mut(&a11y_adapter_key(window)).map(f)) +} + +fn debug_assert_main_thread() { + let is_main_thread: BOOL = unsafe { msg_send![class!(NSThread), isMainThread] }; + debug_assert_eq!( + is_main_thread, YES, + "macOS accessibility adapters must stay on the main thread" + ); +} + extern "C" fn yes(_: &Object, _: Sel) -> BOOL { YES } @@ -1984,6 +2080,14 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { + // AppKit unhides the cursor on the next mouse movement; mirror that here. + if matches!( + event, + PlatformInput::MouseMove(_) | PlatformInput::MouseExited(_) + ) { + lock.cursor_visible.store(true, Ordering::Relaxed); + } + match &mut event { PlatformInput::MouseDown( event @ MouseDownEvent { @@ -2207,6 +2311,9 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; + // AppKit also unhides the cursor on activation changes, so mirror that here. + lock.cursor_visible.store(true, Ordering::Relaxed); + // When opening a pop-up while the application isn't active, Cocoa sends a spurious // `windowDidBecomeKey` message to the previous key window even though that window // isn't actually key. This causes a bug if the application is later activated while @@ -2228,6 +2335,15 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.foreground_executor.clone(); drop(lock); + let native_window = window_state.lock().native_window; + let a11y_events = with_a11y_adapter(native_window, |adapter| { + adapter.update_view_focus_state(is_active) + }) + .flatten(); + if let Some(events) = a11y_events { + events.raise(); + } + // When a window becomes active, trigger an immediate synchronous frame request to prevent // tab flicker when switching between windows in native tabs mode. // diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 133c9fde..8f4283de 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -329,7 +329,7 @@ pub fn cursor_style_methods(input: TokenStream) -> TokenStream { /// Sets cursor style when hovering over an element to `none`. /// [Docs](https://tailwindcss.com/docs/cursor) - #visibility fn cursor_none(mut self, cursor: CursorStyle) -> Self { + #visibility fn cursor_none(mut self, _cursor: CursorStyle) -> Self { self.style().mouse_cursor = Some(gpui::CursorStyle::None); self } @@ -416,6 +416,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]); self } @@ -431,6 +432,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(2.), spread_radius: px(0.), + inset: false, }]); self } @@ -447,12 +449,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(3.), spread_radius: px(0.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(1.)), blur_radius: px(2.), spread_radius: px(-1.), + inset: false, } ]); self @@ -470,12 +474,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(4.)), blur_radius: px(6.), spread_radius: px(-1.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(2.)), blur_radius: px(4.), spread_radius: px(-2.), + inset: false, } ]); self @@ -493,12 +499,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(10.)), blur_radius: px(15.), spread_radius: px(-3.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(4.)), blur_radius: px(6.), spread_radius: px(-4.), + inset: false, } ]); self @@ -516,12 +524,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(20.)), blur_radius: px(25.), spread_radius: px(-5.), + inset: false, }, BoxShadow { color: hsla(0., 0., 0., 0.1), offset: point(px(0.), px(8.)), blur_radius: px(10.), spread_radius: px(-6.), + inset: false, } ]); self @@ -538,6 +548,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(25.)), blur_radius: px(50.), spread_radius: px(-12.), + inset: false, }]); self } diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 5707d67f..25b1a1ee 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -309,19 +309,10 @@ where type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let level = self.1; - let location = self.2; - let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; - match inner.poll(cx) { - Poll::Ready(output) => Poll::Ready(match output { - Ok(output) => Some(output), - Err(error) => { - log_error_with_caller(location, error, level); - None - } - }), - Poll::Pending => Poll::Pending, - } + let this = unsafe { self.get_unchecked_mut() }; + poll_log_error_future(&mut this.0, cx, this.1, this.2, |location, error, level| { + log_error_with_caller(location, error, level); + }) } } @@ -336,19 +327,33 @@ where type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let level = self.1; - let location = self.2; - let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; - match inner.poll(cx) { - Poll::Ready(output) => Poll::Ready(match output { - Ok(output) => Some(output), - Err(error) => { - log_error_with_caller(location, DebugAsDisplay(&error), level); - None - } - }), - Poll::Pending => Poll::Pending, - } + let this = unsafe { self.get_unchecked_mut() }; + poll_log_error_future(&mut this.0, cx, this.1, this.2, |location, error, level| { + log_error_with_caller(location, DebugAsDisplay(&error), level); + }) + } +} + +fn poll_log_error_future( + future: &mut F, + cx: &mut Context, + level: log::Level, + location: core::panic::Location<'static>, + log_error: impl FnOnce(core::panic::Location<'static>, E, log::Level), +) -> Poll> +where + F: Future>, +{ + let inner = unsafe { Pin::new_unchecked(future) }; + match inner.poll(cx) { + Poll::Ready(output) => Poll::Ready(match output { + Ok(output) => Some(output), + Err(error) => { + log_error(location, error, level); + None + } + }), + Poll::Pending => Poll::Pending, } } diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index 9f5734e3..170bd9e3 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -1,5 +1,6 @@ use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; +use crate::events::WebEventListener; use crate::keyboard::WebKeyboardLayout; use crate::window::WebWindow; use anyhow::Result; @@ -13,11 +14,12 @@ use gpui::{ use gpui_wgpu::WgpuContext; use std::{ borrow::Cow, - cell::RefCell, + cell::{Cell, RefCell}, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; +use wasm_bindgen::JsCast; static BUNDLED_FONTS: &[&[u8]] = &[ include_bytes!("../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"), @@ -39,6 +41,10 @@ pub struct WebPlatform { active_display: Rc, callbacks: RefCell, wgpu_context: Rc>>, + cursor_visible: Rc>, + last_cursor_css: Rc>, + #[allow(dead_code)] + cursor_restore_listeners: Vec, } #[derive(Default)] @@ -75,6 +81,13 @@ impl WebPlatform { } let text_system: Arc = text_system; let active_display: Rc = Rc::new(WebDisplay::new()); + let cursor_visible = Rc::new(Cell::new(true)); + let last_cursor_css = Rc::new(Cell::new("default")); + let cursor_restore_listeners = Self::cursor_restore_listeners( + &browser_window, + cursor_visible.clone(), + last_cursor_css.clone(), + ); Self { browser_window, @@ -85,8 +98,69 @@ impl WebPlatform { active_display, callbacks: RefCell::new(WebPlatformCallbacks::default()), wgpu_context: Rc::new(RefCell::new(None)), + cursor_visible, + last_cursor_css, + cursor_restore_listeners, } } + + fn cursor_restore_listeners( + browser_window: &web_sys::Window, + cursor_visible: Rc>, + last_cursor_css: Rc>, + ) -> Vec { + let mut listeners = Vec::new(); + let window_target: web_sys::EventTarget = browser_window.clone().unchecked_into(); + for event_name in ["mousemove", "mouseenter", "blur"] { + listeners.push(Self::cursor_restore_listener( + window_target.clone(), + event_name, + browser_window.clone(), + cursor_visible.clone(), + last_cursor_css.clone(), + )); + } + + if let Some(document) = browser_window.document() { + listeners.push(Self::cursor_restore_listener( + document.unchecked_into(), + "visibilitychange", + browser_window.clone(), + cursor_visible, + last_cursor_css, + )); + } + + listeners + } + + fn cursor_restore_listener( + target: web_sys::EventTarget, + event_name: &'static str, + browser_window: web_sys::Window, + cursor_visible: Rc>, + last_cursor_css: Rc>, + ) -> WebEventListener { + WebEventListener::new(target, event_name, move |_| { + if !cursor_visible.replace(true) { + Self::apply_cursor_css_to_window(&browser_window, last_cursor_css.get()); + } + }) + } + + fn apply_cursor_css_to_window(browser_window: &web_sys::Window, css_cursor: &str) { + if let Some(document) = browser_window.document() { + if let Some(body) = document.body() { + if let Err(error) = body.style().set_property("cursor", css_cursor) { + log::warn!("Failed to set cursor style: {error:?}"); + } + } + } + } + + fn apply_cursor_css(&self, css_cursor: &str) { + Self::apply_cursor_css_to_window(&self.browser_window, css_cursor); + } } impl Platform for WebPlatform { @@ -302,15 +376,21 @@ impl Platform for WebPlatform { CursorStyle::None => "none", }; - if let Some(document) = self.browser_window.document() { - if let Some(body) = document.body() { - if let Err(error) = body.style().set_property("cursor", css_cursor) { - log::warn!("Failed to set cursor style: {error:?}"); - } - } + self.last_cursor_css.set(css_cursor); + if self.cursor_visible.get() { + self.apply_cursor_css(css_cursor); } } + fn hide_cursor_until_mouse_moves(&self) { + self.cursor_visible.set(false); + self.apply_cursor_css("none"); + } + + fn is_cursor_visible(&self) -> bool { + self.cursor_visible.get() && self.last_cursor_css.get() != "none" + } + fn should_auto_hide_scrollbars(&self) -> bool { true } diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index b8703c4c..4459f981 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -144,6 +144,7 @@ impl WebWindow { let renderer_config = WgpuSurfaceConfig { size: device_size, transparent: false, + preferred_present_mode: None, }; let renderer = WgpuRenderer::new_from_canvas(context, &canvas, renderer_config)?; diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 4d9ee02a..3520e8e6 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -20,7 +20,7 @@ gpui.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true -cosmic-text = "0.17.0" +cosmic-text = "0.19.0" etagere = "0.2" itertools.workspace = true log.workspace = true @@ -29,6 +29,7 @@ profiling.workspace = true raw-window-handle = "0.6" smallvec.workspace = true swash = "0.2.6" +unicode-segmentation.workspace = true gpui_util.workspace = true wgpu.workspace = true @@ -43,3 +44,10 @@ wasm-bindgen.workspace = true wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } js-sys = "0.3" + +[dev-dependencies] +criterion.workspace = true + +[[bench]] +name = "layout_line" +harness = false diff --git a/crates/gpui_wgpu/benches/layout_line.rs b/crates/gpui_wgpu/benches/layout_line.rs new file mode 100644 index 00000000..8f7f18c0 --- /dev/null +++ b/crates/gpui_wgpu/benches/layout_line.rs @@ -0,0 +1,100 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui::{FontFallbacks, FontRun, PlatformTextSystem, font, px}; +use gpui_wgpu::CosmicTextSystem; +use std::borrow::Cow; + +const LILEX: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../gpui_web/assets/fonts/lilex/Lilex-Regular.ttf" +)); +const IBM_PLEX: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../gpui_web/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" +)); + +// 991 chars of typical ASCII code text. +fn code_text() -> String { + concat!( + " fn compute_run_spans(\n", + " text: &str,\n", + " run_offset: usize,\n", + " run_len: usize,\n", + " primary: FontId,\n", + " fallback_chain: &[(FontId, SharedString)],\n", + " covers: &impl Fn(FontId, char) -> bool,\n", + " ) -> SmallVec<[RunSpan; 4]> {\n", + " let mut spans = SmallVec::new();\n", + " let run_end = run_offset + run_len;\n", + " if run_end <= run_offset { return spans; }\n", + " let run_text = &text[run_offset..run_end];\n", + " let mut span_start = run_offset;\n", + " let mut span_slot: Option = None;\n", + " for (ch_idx, ch) in run_text.char_indices() {\n", + " let abs = run_offset + ch_idx;\n", + " let next = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers);\n", + " if next == span_slot { continue; }\n", + " if abs > span_start {\n", + " spans.push(RunSpan { start: span_start, end: abs, slot: span_slot });\n", + " }\n", + " span_start = abs;\n", + " span_slot = next;\n", + " }\n", + " spans\n", + " }\n", + ) + .repeat(8) // 7,928 chars +} + +fn mixed_fallback_text() -> String { + code_text().replace("compute_run_spans", "compute_run_spans_\u{FB01}") +} + +fn bench_layout_line(c: &mut Criterion) { + let system = CosmicTextSystem::new_without_system_fonts("Lilex"); + system + .add_fonts(vec![Cow::Borrowed(LILEX), Cow::Borrowed(IBM_PLEX)]) + .unwrap(); + + let font_id_no_fallback = system.font_id(&font("Lilex")).unwrap(); + + let font_id_with_fallback = { + let mut f = font("Lilex"); + f.fallbacks = Some(FontFallbacks::from_fonts(vec!["IBM Plex Sans".to_string()])); + system.font_id(&f).unwrap() + }; + + let text = code_text(); + let mixed_text = mixed_fallback_text(); + + let runs_no_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_no_fallback, + }]; + let runs_with_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_with_fallback, + }]; + let runs_with_mixed_fallback = vec![FontRun { + len: mixed_text.len(), + font_id: font_id_with_fallback, + }]; + + let mut group = c.benchmark_group("layout_line"); + + group.bench_function("no_fallback", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_no_fallback)) + }); + + group.bench_function("fallback_chain_ascii_text", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_with_fallback)) + }); + + group.bench_function("fallback_chain_mixed_text", |b| { + b.iter(|| system.layout_line(&mixed_text, px(14.0), &runs_with_mixed_fallback)) + }); + + group.finish(); +} + +criterion_group!(benches, bench_layout_line); +criterion_main!(benches); diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index a9b299d4..94e8601b 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -1,13 +1,14 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ - Attrs, AttrsList, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures, - FontSystem, ShapeBuffer, ShapeLine, + Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, }; use gpui::{ - Bounds, DevicePixels, Font, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, LineLayout, - Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, - ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, size, + Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, + LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, + size, }; use itertools::Itertools; @@ -18,6 +19,7 @@ use swash::{ scale::{Render, ScaleContext, Source, StrikeWith}, zeno::{Format, Vector}, }; +use unicode_segmentation::UnicodeSegmentation; pub struct CosmicTextSystem(RwLock); @@ -25,11 +27,16 @@ pub struct CosmicTextSystem(RwLock); struct FontKey { family: SharedString, features: FontFeatures, + fallbacks: Option, } impl FontKey { - fn new(family: SharedString, features: FontFeatures) -> Self { - Self { family, features } + fn new(family: SharedString, features: FontFeatures, fallbacks: Option) -> Self { + Self { + family, + features, + fallbacks, + } } } @@ -49,6 +56,7 @@ struct LoadedFont { font: Arc, features: CosmicFontFeatures, is_known_emoji_font: bool, + user_fallback_chain: Arc<[(FontId, SharedString)]>, } impl CosmicTextSystem { @@ -103,11 +111,16 @@ impl PlatformTextSystem for CosmicTextSystem { fn font_id(&self, font: &Font) -> Result { let mut state = self.0.write(); - let key = FontKey::new(font.family.clone(), font.features.clone()); + let key = FontKey::new( + font.family.clone(), + font.features.clone(), + font.fallbacks.clone(), + ); let candidates = if let Some(font_ids) = state.font_ids_by_family_cache.get(&key) { font_ids.as_slice() } else { - let font_ids = state.load_family(&font.family, &font.features)?; + let font_ids = + state.load_family(&font.family, &font.features, font.fallbacks.as_ref())?; state.font_ids_by_family_cache.insert(key.clone(), font_ids); state.font_ids_by_family_cache[&key].as_ref() }; @@ -227,7 +240,39 @@ impl CosmicTextSystemState { &mut self, name: &str, features: &FontFeatures, + fallbacks: Option<&FontFallbacks>, ) -> Result> { + let user_fallback_chain: Arc<[(FontId, SharedString)]> = match fallbacks { + Some(fallbacks) if !fallbacks.fallback_list().is_empty() => { + let mut chain: Vec<(FontId, SharedString)> = Vec::new(); + for fallback_name in fallbacks.fallback_list() { + let fb_key = FontKey::new( + SharedString::from(fallback_name.clone()), + features.clone(), + None, + ); + let fb_ids = if let Some(cached) = self.font_ids_by_family_cache.get(&fb_key) { + cached.clone() + } else { + let loaded = self.load_family(fallback_name, features, None)?; + self.font_ids_by_family_cache + .insert(fb_key.clone(), loaded.clone()); + loaded + }; + for fb_id in fb_ids { + let db_id = self.loaded_fonts[fb_id.0].font.id(); + if let Some(face) = self.font_system.db().face(db_id) { + if let Some(family) = face.families.first() { + chain.push((fb_id, SharedString::from(family.0.clone()))); + } + } + } + } + Arc::from(chain) + } + _ => Arc::from(Vec::new()), + }; + let name = gpui::font_name_with_fallbacks(name, &self.system_font_fallback); let families = self @@ -238,6 +283,8 @@ impl CosmicTextSystemState { .map(|face| (face.id, face.post_script_name.clone())) .collect::>(); + let cosmic_features = cosmic_font_features(features)?; + let mut loaded_font_ids = SmallVec::new(); for (font_id, postscript_name) in families { let font = self @@ -262,8 +309,9 @@ impl CosmicTextSystemState { loaded_font_ids.push(font_id); self.loaded_fonts.push(LoadedFont { font, - features: cosmic_font_features(features)?, + features: cosmic_features.clone(), is_known_emoji_font: check_is_known_emoji_font(&postscript_name), + user_fallback_chain: Arc::clone(&user_fallback_chain), }); } @@ -404,6 +452,7 @@ impl CosmicTextSystemState { font, features: CosmicFontFeatures::new(), is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name), + user_fallback_chain: Arc::from(Vec::new()), }); Ok(font_id) @@ -415,13 +464,15 @@ impl CosmicTextSystemState { let mut attrs_list = AttrsList::new(&Attrs::new()); let mut offs = 0; for run in font_runs { + let run_end = offs + run.len; + let loaded_font = self.loaded_font(run.font_id); let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { log::warn!( "font face not found in database for font_id {:?}", run.font_id ); - offs += run.len; + offs = run_end; continue; }; let Some(first_family) = face.families.first() else { @@ -429,21 +480,60 @@ impl CosmicTextSystemState { "font face has no family names for font_id {:?}", run.font_id ); - offs += run.len; + offs = run_end; continue; }; - attrs_list.add_span( - offs..(offs + run.len), - &Attrs::new() - .metadata(run.font_id.0) - .family(Family::Name(&first_family.0)) - .stretch(face.stretch) - .style(face.style) - .weight(face.weight) - .font_features(loaded_font.features.clone()), - ); - offs += run.len; + let primary_family_name: SharedString = first_family.0.clone().into(); + let primary_stretch = face.stretch; + let primary_style = face.style; + let primary_weight = face.weight; + let primary_features = loaded_font.features.clone(); + let fallback_chain = Arc::clone(&loaded_font.user_fallback_chain); + + let primary_attrs = Attrs::new() + .metadata(run.font_id.0) + .family(Family::Name(&primary_family_name)) + .stretch(primary_stretch) + .style(primary_style) + .weight(primary_weight) + .font_features(primary_features.clone()); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = fallback_chain + .iter() + .map(|(fb_id, fb_name)| { + Attrs::new() + .metadata(fb_id.0) + .family(Family::Name(fb_name)) + .stretch(primary_stretch) + .style(primary_style) + .weight(primary_weight) + .font_features(primary_features.clone()) + }) + .collect(); + + let spans = if fallback_chain.is_empty() { + let mut spans = SmallVec::<[RunSpan; 4]>::new(); + spans.push(RunSpan { + start: offs, + end: run_end, + slot: None, + font_id: run.font_id, + }); + spans + } else { + let loaded_fonts = &self.loaded_fonts; + let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch); + compute_run_spans(text, offs, run.len, run.font_id, &fallback_chain, &covers) + }; + + for span in spans { + let attrs = match span.slot { + None => &primary_attrs, + Some(ix) => &fallback_attrs[ix], + }; + attrs_list.add_span(span.start..span.end, attrs); + } + offs = run_end; } let line = ShapeLine::new( @@ -459,6 +549,7 @@ impl CosmicTextSystemState { f32::from(font_size), None, // We do our own wrapping cosmic_text::Wrap::None, + Ellipsize::None, None, &mut layout_lines, None, @@ -606,6 +697,144 @@ fn find_best_match( Ok(best_index) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RunSpan { + start: usize, + end: usize, + slot: Option, + font_id: FontId, +} + +fn compute_run_spans( + text: &str, + run_offset: usize, + run_len: usize, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> SmallVec<[RunSpan; 4]> { + let mut spans = SmallVec::new(); + let run_end = run_offset + run_len; + if run_end <= run_offset { + return spans; + } + if fallback_chain.is_empty() { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + + let run_text = &text[run_offset..run_end]; + if run_text.chars().all(|ch| covers(primary, ch)) { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + + let fallback_covers_any_missing_primary_char = run_text.chars().any(|ch| { + !covers(primary, ch) && fallback_chain.iter().any(|(fb_id, _)| covers(*fb_id, ch)) + }); + if !fallback_covers_any_missing_primary_char { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + + let mut span_start = run_offset; + let mut span_slot: Option = None; + let mut span_font_id = primary; + for (grapheme_idx, grapheme) in run_text.grapheme_indices(true) { + let abs = run_offset + grapheme_idx; + let next_slot = pick_covering_slot(grapheme, primary, fallback_chain, covers); + if next_slot == span_slot { + continue; + } + if abs > span_start { + spans.push(RunSpan { + start: span_start, + end: abs, + slot: span_slot, + font_id: span_font_id, + }); + } + span_start = abs; + span_slot = next_slot; + span_font_id = slot_font_id(next_slot, primary, fallback_chain); + } + if span_start < run_end { + spans.push(RunSpan { + start: span_start, + end: run_end, + slot: span_slot, + font_id: span_font_id, + }); + } + spans +} + +fn slot_font_id( + slot: Option, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], +) -> FontId { + match slot { + None => primary, + Some(ix) => fallback_chain.get(ix).map_or_else( + || { + debug_assert!( + false, + "fallback slot {ix} must be present in the fallback chain" + ); + primary + }, + |(font_id, _)| *font_id, + ), + } +} + +fn pick_covering_slot( + grapheme: &str, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> Option { + if slot_covers_grapheme(primary, grapheme, covers) { + return None; + } + for (ix, (fb_id, _)) in fallback_chain.iter().enumerate() { + if slot_covers_grapheme(*fb_id, grapheme, covers) { + return Some(ix); + } + } + None +} + +fn slot_covers_grapheme( + font_id: FontId, + grapheme: &str, + covers: &impl Fn(FontId, char) -> bool, +) -> bool { + grapheme.chars().all(|ch| covers(font_id, ch)) +} + +fn charmap_covers(loaded_fonts: &[LoadedFont], id: FontId, ch: char) -> bool { + loaded_fonts + .get(id.0) + .is_some_and(|loaded| loaded.font.as_swash().charmap().map(ch) != 0) +} + fn cosmic_font_features(features: &FontFeatures) -> Result { let mut result = CosmicFontFeatures::new(); for feature in features.0.iter() { @@ -670,6 +899,271 @@ mod tests { use super::*; use gpui::font; + fn fid(i: usize) -> FontId { + FontId(i) + } + + fn chain(ids: &[usize]) -> SmallVec<[(FontId, SharedString); 4]> { + ids.iter() + .map(|&i| (fid(i), SharedString::from(format!("fb{i}")))) + .collect() + } + + fn span(start: usize, end: usize, slot: Option, font_id: FontId) -> RunSpan { + RunSpan { + start, + end, + slot, + font_id, + } + } + + #[test] + fn primary_preferred_over_fallback_when_both_cover() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |_: FontId, _: char| true; + + assert_eq!( + pick_covering_slot("a", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn primary_wins_over_fallback_when_primary_covers() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |id: FontId, _: char| id == fid(0) || id == fid(1); + + assert_eq!( + pick_covering_slot("a", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn fallback_chain_is_checked_in_order() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2, 3]); + let covers = |id: FontId, _: char| id == fid(2); + + assert_eq!( + pick_covering_slot("å­—", primary, &fallback_chain, &covers), + Some(1) + ); + } + + #[test] + fn fallback_must_cover_every_codepoint_in_grapheme() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |id: FontId, ch: char| match id.0 { + 0 | 1 => ch == 'e', + 2 => ch == 'e' || ch == '\u{0301}', + _ => false, + }; + + assert_eq!( + pick_covering_slot("e\u{0301}", primary, &fallback_chain, &covers), + Some(1) + ); + } + + #[test] + fn no_coverage_returns_primary_for_cosmic_text_fallback() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |_: FontId, _: char| false; + + assert_eq!( + pick_covering_slot("\u{1F600}", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn empty_chain_always_returns_primary() { + let primary = fid(0); + let fallback_chain: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + + assert_eq!( + pick_covering_slot("a", primary, &fallback_chain, &covers), + None + ); + } + + #[test] + fn slot_font_id_resolution() { + let primary = fid(7); + let fallback_chain = chain(&[10, 20]); + + assert_eq!(slot_font_id(None, primary, &fallback_chain), fid(7)); + assert_eq!(slot_font_id(Some(0), primary, &fallback_chain), fid(10)); + assert_eq!(slot_font_id(Some(1), primary, &fallback_chain), fid(20)); + } + + #[test] + fn run_spans_with_no_chain_emit_one_primary_span() { + let primary = fid(0); + let fallback_chain: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + let text = "hello"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_byte_offsets_for_multibyte_chars() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "aå­—b"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!( + spans.as_slice(), + &[ + span(0, 1, None, primary), + span(1, 4, Some(0), fid(1)), + span(4, 5, None, primary), + ] + ); + } + + #[test] + fn run_spans_respect_run_offset() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "xxå­—y"; + let run_offset = 2; + let run_len = text.len() - run_offset; + + let spans = compute_run_spans(text, run_offset, run_len, primary, &fallback_chain, &covers); + + assert_eq!( + spans.as_slice(), + &[span(2, 5, Some(0), fid(1)), span(5, 6, None, primary)] + ); + } + + #[test] + fn run_spans_do_not_use_fallback_for_partially_covered_grapheme() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + ch == '\u{0905}' + } + }; + let text = "\u{0905}\u{0902}"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_do_not_use_fallback_for_partially_covered_zwj_cluster() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| id == fid(1) && ch != '\u{200D}'; + let text = "\u{1F469}\u{200D}\u{1F467}"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_primary_span_when_primary_covers_all_chars() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, _: char| id == primary; + let text = "aå­—\u{0301}"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_primary_span_when_no_fallback_covers_missing_primary_chars() { + let primary = fid(0); + let fallback_chain = chain(&[1, 2]); + let covers = |id: FontId, ch: char| id == primary && ch.is_ascii(); + let text = "aå­—b"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_fallback_for_missing_ascii_graphemes() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { ch == 'a' } else { ch == 'b' } + }; + let text = "ab"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!( + spans.as_slice(), + &[span(0, 1, None, primary), span(1, 2, Some(0), fid(1))] + ); + } + + #[test] + fn run_spans_collapse_adjacent_same_slot() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "å­—å­—å­—"; + + let spans = compute_run_spans(text, 0, text.len(), primary, &fallback_chain, &covers); + + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_empty_run_returns_no_spans() { + let primary = fid(0); + let fallback_chain = chain(&[1]); + let covers = |_: FontId, _: char| true; + + let spans = compute_run_spans("anything", 3, 0, primary, &fallback_chain, &covers); + + assert!(spans.is_empty()); + } + #[test] fn typographic_bounds_use_glyph_outline_bounds() { let text_system = CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"); diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index eb2cbeb4..d3f63ab0 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -952,10 +952,18 @@ fn fmod(a: f32, b: f32) -> f32 { struct Shadow { order: u32, blur_radius: f32, + // The shadow rect for drop shadows; the "hole" rect for inset shadows. bounds: Bounds, corner_radii: Corners, content_mask: Bounds, color: Hsla, + // Only consulted when `inset == 1u`: the element's own bounds, used as a rounded-rect + // clip so the shadow never escapes the element. + element_bounds: Bounds, + element_corner_radii: Corners, + // 0 = drop shadow, 1 = inset shadow. + inset: u32, + pad: u32, // align to 8 bytes } @group(1) @binding(0) var b_shadows: array; @@ -972,17 +980,22 @@ fn vs_shadow(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) ins let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); var shadow = b_shadows[instance_id]; - let margin = 3.0 * shadow.blur_radius; - // Set the bounds of the shadow and adjust its size based on the shadow's - // spread radius to achieve the spreading effect - shadow.bounds.origin -= vec2(margin); - shadow.bounds.size += 2.0 * vec2(margin); + var geometry: Bounds; + if (shadow.inset != 0u) { + geometry = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + let margin = 3.0 * shadow.blur_radius; + geometry = shadow.bounds; + geometry.origin -= vec2(margin); + geometry.size += 2.0 * vec2(margin); + } var out = ShadowVarying(); - out.position = to_device_position(unit_vertex, shadow.bounds); + out.position = to_device_position(unit_vertex, geometry); out.color = hsla_to_rgba(shadow.color); out.shadow_id = instance_id; - out.clip_distances = distance_from_clip_rect(unit_vertex, shadow.bounds, shadow.content_mask); + out.clip_distances = distance_from_clip_rect(unit_vertex, geometry, shadow.content_mask); return out; } @@ -1000,21 +1013,36 @@ fn fs_shadow(input: ShadowVarying) -> @location(0) vec4 { let corner_radius = pick_corner_radius(center_to_point, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - let low = center_to_point.y - half_size.y; - let high = center_to_point.y + half_size.y; - let start = clamp(-3.0 * shadow.blur_radius, low, high); - let end = clamp(3.0 * shadow.blur_radius, low, high); - - // Accumulate samples (we can get away with surprisingly few samples) - let step = (end - start) / 4.0; - var y = start + step * 0.5; - var alpha = 0.0; - for (var i = 0; i < 4; i += 1) { - let blur = blur_along_x(center_to_point.x, center_to_point.y - y, - shadow.blur_radius, corner_radius, half_size); - alpha += blur * gaussian(y, shadow.blur_radius) * step; - y += step; + var alpha: f32; + if (shadow.blur_radius == 0.0) { + let distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + let low = center_to_point.y - half_size.y; + let high = center_to_point.y + half_size.y; + let start = clamp(-3.0 * shadow.blur_radius, low, high); + let end = clamp(3.0 * shadow.blur_radius, low, high); + + // Accumulate samples (we can get away with surprisingly few samples) + let step = (end - start) / 4.0; + var y = start + step * 0.5; + alpha = 0.0; + for (var i = 0; i < 4; i += 1) { + let blur = blur_along_x(center_to_point.x, center_to_point.y - y, + shadow.blur_radius, corner_radius, half_size); + alpha += blur * gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = saturate(1.0 - alpha); + let element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return blend_color(input.color, alpha); diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index fbc194ed..46e34381 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -21,6 +21,20 @@ const INITIAL_INSTANCE_BUFFER_SIZE: u64 = 2 * 1024 * 1024; const INSTANCE_BUFFER_SIZE_BUCKET: u64 = 1024 * 1024; const MAX_INSTANCE_BUFFER_SIZE: u64 = 256 * 1024 * 1024; +fn select_present_mode( + preferred: Option, + supported: &[wgpu::PresentMode], +) -> wgpu::PresentMode { + preferred + .filter(|mode| { + matches!( + mode, + wgpu::PresentMode::AutoVsync | wgpu::PresentMode::AutoNoVsync + ) || supported.contains(mode) + }) + .unwrap_or(wgpu::PresentMode::Fifo) +} + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct GlobalParams { @@ -106,6 +120,13 @@ struct BackdropScratchBounds { pub struct WgpuSurfaceConfig { pub size: Size, pub transparent: bool, + /// Preferred presentation mode. When `Some`, the renderer will use this + /// mode if supported by the surface, falling back to `Fifo`. + /// When `None`, defaults to `Fifo` (VSync). + /// + /// Mobile platforms may prefer `Mailbox` (triple-buffering) to avoid + /// blocking in `get_current_texture()` during lifecycle transitions. + pub preferred_present_mode: Option, } struct WgpuPipelines { @@ -304,12 +325,16 @@ impl WgpuRenderer { opaque_alpha_mode }; + let present_mode = + select_present_mode(config.preferred_present_mode, &surface_caps.present_modes); + Self::new_with_surface( context, surface, surface_format, surface_caps.usages, alpha_mode, + present_mode, transparent_alpha_mode, opaque_alpha_mode, config, @@ -322,6 +347,7 @@ impl WgpuRenderer { surface_format: wgpu::TextureFormat, surface_usages: wgpu::TextureUsages, alpha_mode: wgpu::CompositeAlphaMode, + present_mode: wgpu::PresentMode, transparent_alpha_mode: wgpu::CompositeAlphaMode, opaque_alpha_mode: wgpu::CompositeAlphaMode, config: WgpuSurfaceConfig, @@ -355,7 +381,7 @@ impl WgpuRenderer { format: surface_format, width: clamped_width.max(1), height: clamped_height.max(1), - present_mode: wgpu::PresentMode::Fifo, + present_mode, desired_maximum_frame_latency: 2, alpha_mode, view_formats: vec![], @@ -1522,6 +1548,7 @@ impl WgpuRenderer { } for shadow in &mut scene.shadows { shadow.bounds = shadow.bounds - origin; + shadow.element_bounds = shadow.element_bounds - origin; shadow.content_mask.bounds = shadow.content_mask.bounds - origin; } for blur in &mut scene.backdrop_blurs { @@ -2642,6 +2669,37 @@ impl WgpuRenderer { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_present_mode_accepts_auto_modes_without_surface_advertising_them() { + assert_eq!( + select_present_mode( + Some(wgpu::PresentMode::AutoVsync), + &[wgpu::PresentMode::Fifo] + ), + wgpu::PresentMode::AutoVsync + ); + assert_eq!( + select_present_mode( + Some(wgpu::PresentMode::AutoNoVsync), + &[wgpu::PresentMode::Fifo] + ), + wgpu::PresentMode::AutoNoVsync + ); + } + + #[test] + fn select_present_mode_falls_back_to_fifo_for_unsupported_explicit_mode() { + assert_eq!( + select_present_mode(Some(wgpu::PresentMode::Mailbox), &[wgpu::PresentMode::Fifo]), + wgpu::PresentMode::Fifo + ); + } +} + struct RenderingParameters { path_sample_count: u32, gamma_ratios: [f32; 4], diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 992a47dc..f5a93e7f 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -20,6 +20,8 @@ screen-capture = ["gpui/screen-capture", "scap"] gpui.workspace = true [target.'cfg(target_os = "windows")'.dependencies] +accesskit.workspace = true +accesskit_windows.workspace = true anyhow.workspace = true collections.workspace = true etagere = "0.2" diff --git a/crates/gpui_windows/src/display.rs b/crates/gpui_windows/src/display.rs index 1931a694..3b81dc63 100644 --- a/crates/gpui_windows/src/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -35,21 +35,19 @@ unsafe impl Sync for WindowsDisplay {} impl WindowsDisplay { pub(crate) fn new(display_id: DisplayId) -> Option { - let screen = available_monitors() - .into_iter() - .nth(u32::from(display_id) as _)?; - let info = get_monitor_info(screen).log_err()?; + let handle = HMONITOR(u64::from(display_id) as _); + let info = get_monitor_info(handle).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; + let scale_factor = get_scale_factor_for_monitor(handle).log_err()?; let physical_size = size( (monitor_size.right - monitor_size.left).into(), (monitor_size.bottom - monitor_size.top).into(), ); Some(WindowsDisplay { - handle: screen, + handle, display_id, scale_factor, bounds: Bounds { @@ -76,86 +74,8 @@ impl WindowsDisplay { }) } - pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { - let info = get_monitor_info(monitor)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let display_id = available_monitors() - .iter() - .position(|handle| handle.0 == monitor.0) - .unwrap(); - let scale_factor = get_scale_factor_for_monitor(monitor)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { - handle: monitor, - display_id: DisplayId::new(display_id as _), - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) - } - - fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { - let info = get_monitor_info(handle)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(handle)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { - handle, - display_id, - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) + pub(crate) fn display_id_for_monitor(monitor: HMONITOR) -> DisplayId { + DisplayId::new(monitor.0 as u64) } pub fn primary_monitor() -> Option { @@ -169,7 +89,7 @@ impl WindowsDisplay { ); return None; } - WindowsDisplay::new_with_handle(monitor).log_err() + WindowsDisplay::new(Self::display_id_for_monitor(monitor)) } /// Check if the center point of given bounds is inside this monitor @@ -183,7 +103,7 @@ impl WindowsDisplay { if monitor.is_invalid() { false } else { - let Ok(display) = WindowsDisplay::new_with_handle(monitor) else { + let Some(display) = WindowsDisplay::new(Self::display_id_for_monitor(monitor)) else { return false; }; display.uuid == self.uuid @@ -193,11 +113,11 @@ impl WindowsDisplay { pub fn displays() -> Vec> { available_monitors() .into_iter() - .enumerate() - .filter_map(|(id, handle)| { - Some(Rc::new( - WindowsDisplay::new_with_handle_and_id(handle, DisplayId::new(id as _)).ok()?, - ) as Rc) + .filter_map(|handle| { + Some( + Rc::new(WindowsDisplay::new(Self::display_id_for_monitor(handle))?) + as Rc, + ) }) .collect() } diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 2aa07d73..53a96dae 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{rc::Rc, sync::atomic::Ordering}; use ::util::ResultExt; use anyhow::Context as _; @@ -113,6 +113,7 @@ impl WindowsWindowInner { WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), + WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam), _ => None, }; if let Some(n) = handled { @@ -144,9 +145,9 @@ impl WindowsWindowInner { // monitor is invalid, we do nothing. if !monitor.is_invalid() && self.state.display.get().handle != monitor { // we will get the same monitor if we only have one - self.state - .display - .set(WindowsDisplay::new_with_handle(monitor).log_err()?); + self.state.display.set(WindowsDisplay::new( + WindowsDisplay::display_id_for_monitor(monitor), + )?); } } if let Some(mut callback) = self.state.callbacks.moved.take() { @@ -298,6 +299,7 @@ impl WindowsWindowInner { fn handle_mouse_move_msg(&self, handle: HWND, lparam: LPARAM, wparam: WPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE); + self.restore_cursor_after_hide(); let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); @@ -331,6 +333,9 @@ impl WindowsWindowInner { fn handle_mouse_leave_msg(&self) -> Option { self.state.hovered.set(false); + // The next window's `WM_SETCURSOR` picks its own cursor, so we just clear + // the flag for tight `is_cursor_visible()` semantics. + self.state.cursor_visible.store(true, Ordering::Release); if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(false); self.state @@ -725,7 +730,23 @@ impl WindowsWindowInner { fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { let activated = wparam.loword() > 0; + + let events = self + .state + .a11y + .try_borrow_mut() + .ok() + .and_then(|mut a11y| a11y.as_mut()?.adapter.update_window_focus_state(activated)); + if let Some(events) = events { + events.raise(); + } + let this = self.clone(); + + if !activated { + this.state.cursor_visible.store(true, Ordering::Release); + } + self.executor .spawn(async move { if let Some(mut func) = this.state.callbacks.active_status_change.take() { @@ -751,6 +772,20 @@ impl WindowsWindowInner { None } + fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let result = { + let mut a11y = self.state.a11y.try_borrow_mut().ok()?; + let a11y = a11y.as_mut()?; + a11y.adapter.handle_wm_getobject( + accesskit_windows::WPARAM(wparam.0), + accesskit_windows::LPARAM(lparam.0), + &mut a11y.activation_handler, + )? + }; + let lresult: accesskit_windows::LRESULT = result.into(); + Some(lresult.0) + } + fn handle_create_msg(&self, handle: HWND) -> Option { if self.hide_title_bar { notify_frame_changed(handle); @@ -840,7 +875,7 @@ impl WindowsWindowInner { log::error!("No monitor detected!"); return None; } - let new_display = WindowsDisplay::new_with_handle(new_monitor).log_err()?; + let new_display = WindowsDisplay::new(WindowsDisplay::display_id_for_monitor(new_monitor))?; self.state.display.set(new_display); Some(0) } @@ -910,6 +945,7 @@ impl WindowsWindowInner { fn handle_nc_mouse_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE | TME_NONCLIENT); + self.restore_cursor_after_hide(); let mut func = self.state.callbacks.input.take()?; let scale_factor = self.state.scale_factor.get(); @@ -1051,7 +1087,12 @@ impl WindowsWindowInner { }); if had_cursor != self.state.current_cursor.get().is_some() { - unsafe { SetCursor(self.state.current_cursor.get()) }; + let cursor = if self.state.cursor_visible.load(Ordering::Acquire) { + self.state.current_cursor.get() + } else { + None + }; + unsafe { SetCursor(cursor) }; } Some(0) @@ -1073,8 +1114,13 @@ impl WindowsWindowInner { { return None; } + let cursor = if self.state.cursor_visible.load(Ordering::Acquire) { + self.state.current_cursor.get() + } else { + None + }; unsafe { - SetCursor(self.state.current_cursor.get()); + SetCursor(cursor); }; Some(0) } @@ -1227,6 +1273,15 @@ impl WindowsWindowInner { } } + /// Clear the hidden flag and restore the cursor immediately + fn restore_cursor_after_hide(&self) { + if !self.state.cursor_visible.swap(true, Ordering::AcqRel) { + unsafe { + SetCursor(self.state.current_cursor.get()); + } + } + } + fn start_tracking_mouse(&self, handle: HWND, flags: TRACKMOUSEEVENT_FLAGS) { if !self.state.hovered.get() { self.state.hovered.set(true); diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 18210713..68b8424e 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -63,6 +63,8 @@ pub(crate) struct WindowsPlatformState { jump_list: RefCell, // NOTE: standard cursor handles don't need to close. pub(crate) current_cursor: Cell>, + /// Shared with each window so `WM_SETCURSOR` can read it directly. + pub(crate) cursor_visible: Arc, directx_devices: RefCell>, } @@ -87,6 +89,7 @@ impl WindowsPlatformState { callbacks, jump_list: RefCell::new(jump_list), current_cursor: Cell::new(current_cursor), + cursor_visible: Arc::new(AtomicBool::new(true)), directx_devices: RefCell::new(directx_devices), menus: RefCell::new(Vec::new()), } @@ -219,6 +222,7 @@ impl WindowsPlatform { icon: self.icon, executor: self.foreground_executor.clone(), current_cursor: self.inner.state.current_cursor.get(), + cursor_visible: self.inner.state.cursor_visible.clone(), drop_target_helper: self.drop_target_helper.clone().unwrap(), validation_number: self.inner.validation_number, main_receiver: self.inner.main_receiver.clone(), @@ -675,6 +679,31 @@ impl Platform for WindowsPlatform { should_auto_hide_scrollbars().log_err().unwrap_or(false) } + fn hide_cursor_until_mouse_moves(&self) { + if !self + .inner + .state + .cursor_visible + .swap(false, Ordering::AcqRel) + { + return; + } + + for handle in self.raw_window_handles.read().iter() { + let Some(window) = window_from_hwnd(handle.as_raw()) else { + continue; + }; + if window.state.hovered.get() { + unsafe { SetCursor(None) }; + break; + } + } + } + + fn is_cursor_visible(&self) -> bool { + self.inner.state.cursor_visible.load(Ordering::Acquire) + } + fn write_to_clipboard(&self, item: ClipboardItem) { write_to_clipboard(item); } @@ -1004,6 +1033,7 @@ pub(crate) struct WindowCreationInfo { pub(crate) icon: HICON, pub(crate) executor: ForegroundExecutor, pub(crate) current_cursor: Option, + pub(crate) cursor_visible: Arc, pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index 621c45d3..831333ab 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -995,6 +995,10 @@ struct Shadow { Corners corner_radii; Bounds content_mask; Hsla color; + Bounds element_bounds; + Corners element_corner_radii; + uint inset; + uint pad; // align to 8 bytes }; struct ShadowVertexOutput { @@ -1016,10 +1020,16 @@ ShadowVertexOutput shadow_vertex(uint vertex_id: SV_VertexID, uint shadow_id: SV float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); Shadow shadow = shadows[shadow_id]; - float margin = 3.0 * shadow.blur_radius; - Bounds bounds = shadow.bounds; - bounds.origin -= margin; - bounds.size += 2.0 * margin; + Bounds bounds; + if (shadow.inset != 0u) { + bounds = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + float margin = 3.0 * shadow.blur_radius; + bounds = shadow.bounds; + bounds.origin -= margin; + bounds.size += 2.0 * margin; + } float4 device_position = to_device_position(unit_vertex, bounds); float4 clip_distance = distance_from_clip_rect(unit_vertex, bounds, shadow.content_mask); @@ -1042,21 +1052,36 @@ float4 shadow_fragment(ShadowFragmentInput input): SV_TARGET { float2 point0 = input.position.xy - center; float corner_radius = pick_corner_radius(point0, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - float low = point0.y - half_size.y; - float high = point0.y + half_size.y; - float start = clamp(-3. * shadow.blur_radius, low, high); - float end = clamp(3. * shadow.blur_radius, low, high); - - // Accumulate samples (we can get away with surprisingly few samples) - float step = (end - start) / 4.; - float y = start + step * 0.5; - float alpha = 0.; - for (int i = 0; i < 4; i++) { - alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, - corner_radius, half_size) * - gaussian(y, shadow.blur_radius) * step; - y += step; + float alpha; + if (shadow.blur_radius == 0.) { + float distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + float low = point0.y - half_size.y; + float high = point0.y + half_size.y; + float start = clamp(-3. * shadow.blur_radius, low, high); + float end = clamp(3. * shadow.blur_radius, low, high); + + // Accumulate samples (we can get away with surprisingly few samples) + float step = (end - start) / 4.; + float y = start + step * 0.5; + alpha = 0.; + for (int i = 0; i < 4; i++) { + alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, + corner_radius, half_size) * + gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + float element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return input.color * float4(1., 1., 1., alpha); diff --git a/crates/gpui_windows/src/util.rs b/crates/gpui_windows/src/util.rs index fe5093de..dba38130 100644 --- a/crates/gpui_windows/src/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; -use ::util::ResultExt; use anyhow::Context; +use util::ResultExt; use windows::{ UI::{ Color, diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 54c12a59..40b4b879 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -69,6 +69,8 @@ pub struct WindowsWindowState { pub click_state: ClickState, pub current_cursor: Cell>, + /// Shared with [`WindowsPlatformState::cursor_visible`]. + pub cursor_visible: Arc, pub nc_button_pressed: Cell>, pub display: Cell, @@ -78,6 +80,7 @@ pub struct WindowsWindowState { fullscreen: Cell>, initial_placement: Cell>, hwnd: HWND, + pub(crate) a11y: RefCell>, } pub(crate) struct WindowsWindowInner { @@ -101,6 +104,7 @@ impl WindowsWindowState { directx_devices: &DirectXDevices, window_params: &CREATESTRUCTW, current_cursor: Option, + cursor_visible: Arc, display: WindowsDisplay, min_size: Option>, appearance: WindowAppearance, @@ -161,6 +165,7 @@ impl WindowsWindowState { renderer: RefCell::new(renderer), click_state, current_cursor: Cell::new(current_cursor), + cursor_visible, nc_button_pressed: Cell::new(nc_button_pressed), display: Cell::new(display), fullscreen: Cell::new(fullscreen), @@ -168,6 +173,7 @@ impl WindowsWindowState { hwnd, invalidate_devices, direct_manipulation, + a11y: RefCell::new(None), }) } @@ -237,6 +243,7 @@ impl WindowsWindowInner { &context.directx_devices, cs, context.current_cursor, + context.cursor_visible.clone(), context.display, context.min_size, context.appearance, @@ -376,6 +383,7 @@ struct WindowCreateContext { min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, + cursor_visible: Arc, drop_target_helper: IDropTargetHelper, validation_number: usize, main_receiver: PriorityQueueReceiver, @@ -397,6 +405,7 @@ impl WindowsWindow { icon, executor, current_cursor, + cursor_visible, drop_target_helper, validation_number, main_receiver, @@ -406,6 +415,12 @@ impl WindowsWindow { invalidate_devices, } = creation_info; register_window_class(icon); + let display = if let Some(display_id) = params.display_id { + WindowsDisplay::new(display_id) + .with_context(|| format!("requested display {display_id:?} is not available"))? + } else { + WindowsDisplay::primary_monitor().context("failed to find any monitor")? + }; let parent_hwnd = if params.kind == WindowKind::Dialog { let parent_window = unsafe { GetActiveWindow() }; if parent_window.is_invalid() { @@ -460,12 +475,6 @@ impl WindowsWindow { } let hinstance = get_module_handle(); - let display = if let Some(display_id) = params.display_id { - // if we obtain a display_id, then this ID must be valid. - WindowsDisplay::new(display_id).unwrap() - } else { - WindowsDisplay::primary_monitor().unwrap() - }; let appearance = system_appearance().unwrap_or_default(); let mut context = WindowCreateContext { inner: None, @@ -476,6 +485,7 @@ impl WindowsWindow { min_size: params.window_min_size, executor, current_cursor, + cursor_visible, drop_target_helper, validation_number, main_receiver, @@ -1101,6 +1111,69 @@ impl PlatformWindow for WindowsWindow { fn play_system_bell(&self) { let _ = unsafe { MessageBeep(MB_OK.0) }; } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + if self.state.a11y.borrow().is_some() { + log::warn!("Windows accessibility adapter already initialized for window"); + return; + } + + let action_handler = A11yActionHandler(callbacks.action); + let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd; + + let adapter = accesskit_windows::Adapter::new( + accesskit_windows::HWND(self.0.hwnd.0), + is_focused, + action_handler, + ); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + + *self.state.a11y.borrow_mut() = Some(A11yState { + adapter, + activation_handler, + }); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut a11y = self.state.a11y.borrow_mut(); + a11y.as_mut() + .and_then(|a11y| a11y.adapter.update_if_active(|| tree_update)) + }; + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // Windows UIA handles window bounds tracking automatically. + } +} + +pub(crate) struct A11yState { + pub(crate) adapter: accesskit_windows::Adapter, + pub(crate) activation_handler: A11yActivationHandler, +} + +pub(crate) struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } #[implement(IDropTarget)] diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 684ab950..74fec9ef 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -1,5 +1,7 @@ use crate::{Priority, RunnableMeta, Scheduler, SessionId, Timer}; +use async_task::Runnable; use std::{ + any::Any, future::Future, marker::PhantomData, mem::ManuallyDrop, @@ -12,18 +14,27 @@ use std::{ time::{Duration, Instant}, }; +/// A `!Send` executor pinned to a single session. Tasks spawned on it run in +/// order on whichever thread drains the dispatch destination supplied at +/// construction time. #[derive(Clone)] -pub struct ForegroundExecutor { +pub struct LocalExecutor { session_id: SessionId, scheduler: Arc, + dispatch: Arc) + Send + Sync>, not_send: PhantomData>, } -impl ForegroundExecutor { - pub fn new(session_id: SessionId, scheduler: Arc) -> Self { +impl LocalExecutor { + pub fn new( + session_id: SessionId, + scheduler: Arc, + dispatch: impl Fn(Runnable) + Send + Sync + 'static, + ) -> Self { Self { session_id, scheduler, + dispatch: Arc::new(dispatch), not_send: PhantomData, } } @@ -42,14 +53,11 @@ impl ForegroundExecutor { F: Future + 'static, F::Output: 'static, { - let session_id = self.session_id; - let scheduler = Arc::clone(&self.scheduler); + let dispatch = self.dispatch.clone(); let location = Location::caller(); let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| { - scheduler.schedule_foreground(session_id, runnable); - }, + move |runnable| dispatch(runnable), RunnableMeta { location }, ); runnable.schedule(); @@ -108,6 +116,37 @@ impl ForegroundExecutor { pub fn now(&self) -> Instant { self.scheduler.clock().now() } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } +} + +fn box_dedicated( + f: F, +) -> Box< + dyn FnOnce(LocalExecutor) -> Pin> + 'static>> + + Send + + 'static, +> +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + Box::new(move |executor| { + Box::pin(async move { Box::new(f(executor).await) as Box }) + }) } #[derive(Clone)] @@ -135,14 +174,19 @@ impl BackgroundExecutor { F: Future + Send + 'static, F::Output: Send + 'static, { - let scheduler = Arc::clone(&self.scheduler); + // Keep the schedule callback from retaining the scheduler through + // detached tasks, queued runnables, or pending wakers after callers + // intentionally drop the scheduler. + let scheduler = Arc::downgrade(&self.scheduler); let location = Location::caller(); let (runnable, task) = async_task::Builder::new() .metadata(RunnableMeta { location }) .spawn( move |_| future, move |runnable| { - scheduler.schedule_background_with_priority(runnable, priority); + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_background_with_priority(runnable, priority); + } }, ); runnable.schedule(); @@ -189,6 +233,20 @@ impl BackgroundExecutor { pub fn scheduler(&self) -> &Arc { &self.scheduler } + + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + #[track_caller] + pub fn spawn_dedicated(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } } /// Task is a primitive that allows work to happen in the background. @@ -198,16 +256,20 @@ impl BackgroundExecutor { /// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows /// the task to continue running, but with no way to return a value. #[must_use] -#[derive(Debug)] pub struct Task(TaskState); -#[derive(Debug)] enum TaskState { /// A task that is ready to return a value Ready(Option), /// A task that is currently running. Spawned(async_task::Task), + + /// A typed view of a [`Task>`]. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl Task { @@ -225,6 +287,7 @@ impl Task { match &self.0 { TaskState::Ready(_) => true, TaskState::Spawned(task) => task.is_finished(), + TaskState::Downcast { inner, .. } => inner.is_ready(), } } @@ -233,6 +296,7 @@ impl Task { match self { Task(TaskState::Ready(_)) => {} Task(TaskState::Spawned(task)) => task.detach(), + Task(TaskState::Downcast { inner, .. }) => inner.detach(), } } @@ -241,10 +305,36 @@ impl Task { FallibleTask(match self.0 { TaskState::Ready(val) => FallibleTaskState::Ready(val), TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()), + TaskState::Downcast { inner, .. } => FallibleTaskState::Downcast { + inner: Box::new(inner.fallible()), + marker: PhantomData, + }, }) } } +impl Task> { + /// Reinterprets the boxed output as a concrete `T` via downcast on completion. + pub fn downcast(self) -> Task { + Task(TaskState::Downcast { + inner: Box::new(self), + marker: PhantomData, + }) + } +} + +impl std::fmt::Debug for Task { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + TaskState::Ready(_) => f.debug_tuple("Task::Ready").finish(), + TaskState::Spawned(task) => f.debug_tuple("Task::Spawned").field(task).finish(), + TaskState::Downcast { inner, .. } => { + f.debug_tuple("Task::Downcast").field(inner).finish() + } + } + } +} + /// A task that returns `Option` instead of panicking when cancelled. #[must_use] pub struct FallibleTask(FallibleTaskState); @@ -255,6 +345,12 @@ enum FallibleTaskState { /// A task that is currently running (wraps async_task::FallibleTask). Spawned(async_task::FallibleTask), + + /// Mirror of [`TaskState::Downcast`] for fallible tasks. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl FallibleTask { @@ -268,17 +364,29 @@ impl FallibleTask { match self.0 { FallibleTaskState::Ready(_) => {} FallibleTaskState::Spawned(task) => task.detach(), + FallibleTaskState::Downcast { inner, .. } => inner.detach(), } } } -impl Future for FallibleTask { +impl Future for FallibleTask { type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()), FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx), + FallibleTask(FallibleTaskState::Downcast { inner, .. }) => { + match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(Some(boxed_any)) => Poll::Ready(Some( + *boxed_any + .downcast::() + .expect("FallibleTask::poll: downcast type mismatch"), + )), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } } } } @@ -290,17 +398,29 @@ impl std::fmt::Debug for FallibleTask { FallibleTaskState::Spawned(task) => { f.debug_tuple("FallibleTask::Spawned").field(task).finish() } + FallibleTaskState::Downcast { inner, .. } => f + .debug_tuple("FallibleTask::Downcast") + .field(inner) + .finish(), } } } -impl Future for Task { +impl Future for Task { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { match unsafe { self.get_unchecked_mut() } { Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()), Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx), + Task(TaskState::Downcast { inner, .. }) => match Pin::new(inner.as_mut()).poll(cx) { + Poll::Ready(boxed_any) => Poll::Ready( + *boxed_any + .downcast::() + .expect("Task::poll: downcast type mismatch"), + ), + Poll::Pending => Poll::Pending, + }, } } } @@ -342,9 +462,10 @@ where "local task dropped by a thread that didn't spawn it. Task spawned at {}", self.location ); - unsafe { - ManuallyDrop::drop(&mut self.inner); - } + // SAFETY: `inner` is wrapped in `ManuallyDrop`, so this is the only + // place it is dropped. The thread check above ensures local futures + // are dropped on the thread that created them. + unsafe { ManuallyDrop::drop(&mut self.inner) }; } } @@ -352,27 +473,34 @@ where type Output = F::Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // SAFETY: We don't move any fields out of `self`; this mutable + // reference is only used to check metadata and to project the pin to + // `inner` below. + let this = unsafe { self.get_unchecked_mut() }; assert!( - self.id == thread_id(), + this.id == thread_id(), "local task polled by a thread that didn't spawn it. Task spawned at {}", - self.location + this.location ); - unsafe { self.map_unchecked_mut(|c| &mut *c.inner).poll(cx) } + // SAFETY: `inner` is structurally pinned by `Checked`; after + // `Checked` is pinned, `inner` is never moved. The thread check + // above ensures the local future is only polled by its spawning + // thread. + unsafe { Pin::new_unchecked(&mut *this.inner).poll(cx) } } } let location = metadata.location; - unsafe { - async_task::Builder::new() - .metadata(metadata) - .spawn_unchecked( - move |_| Checked { - id: thread_id(), - inner: ManuallyDrop::new(future), - location, - }, - schedule, - ) - } + let future = move |_| Checked { + id: thread_id(), + inner: ManuallyDrop::new(future), + location, + }; + + let builder = async_task::Builder::new().metadata(metadata); + // SAFETY: `Checked` enforces the invariants required by `spawn_unchecked`: + // the non-`Send` future is only polled and dropped on the thread that + // spawned it. + unsafe { builder.spawn_unchecked(future, schedule) } } diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 05d285df..a81a359b 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -11,11 +11,13 @@ pub use test_scheduler::*; use async_task::Runnable; use futures::channel::oneshot; use std::{ + any::Any, future::Future, - panic::Location, + panic::{self, AssertUnwindSafe, Location}, pin::Pin, sync::Arc, task::{Context, Poll}, + thread, time::Duration, }; @@ -82,7 +84,8 @@ pub trait Scheduler: Send + Sync { timeout: Option, ) -> bool; - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); + /// Schedule a runnable on the local (session-pinned) queue for `session_id`. + fn schedule_local(&self, session_id: SessionId, runnable: Runnable); /// Schedule a background task with the given priority. fn schedule_background_with_priority( @@ -103,11 +106,78 @@ pub trait Scheduler: Send + Sync { fn timer(&self, timeout: Duration) -> Timer; fn clock(&self) -> Arc; + /// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`]. + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task>; + fn as_test(&self) -> Option<&TestScheduler> { None } } +/// Spawn work on a fresh OS thread that's exclusive to the returned task and +/// anything spawned on the executor it provides. +pub fn spawn_dedicated_thread( + session_id: SessionId, + scheduler: Arc, + f: F, +) -> Task +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + let (runnable_sender, runnable_receiver) = flume::unbounded::>(); + let (task_sender, task_receiver) = + flume::bounded::, Box>>(1); + + thread::Builder::new() + .name(format!("spawn_dedicated session {:?}", session_id)) + .spawn(move || { + let dispatch = move |runnable: Runnable| { + let _ = runnable_sender.send(runnable); + }; + let executor = LocalExecutor::new(session_id, scheduler, dispatch); + let root_task = + panic::catch_unwind(AssertUnwindSafe(|| executor.spawn(f(executor.clone())))); + let root_task = match root_task { + Ok(root_task) => root_task, + Err(error) => { + if task_sender.send(Err(error)).is_err() { + panic!("dedicated thread failed to send root task panic"); + } + return; + } + }; + + if task_sender.send(Ok(root_task)).is_err() { + panic!("dedicated thread failed to send root task"); + } + // Close the dispatch sender so the dedicated thread exits after draining queued work. + drop(executor); + + while let Ok(runnable) = runnable_receiver.recv() { + runnable.run(); + } + }) + .expect("failed to spawn dedicated thread"); + + match task_receiver.recv() { + Ok(Ok(root_task)) => root_task, + Ok(Err(error)) => panic::resume_unwind(error), + Err(error) => panic!("dedicated thread failed to produce root task: {}", error), + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct SessionId(u16); diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 70dc01e7..86922f2a 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -1,5 +1,5 @@ use crate::{ - BackgroundExecutor, Clock, ForegroundExecutor, Priority, RunnableMeta, Scheduler, SessionId, + BackgroundExecutor, Clock, LocalExecutor, Priority, RunnableMeta, Scheduler, SessionId, Task, TestClock, Timer, }; use async_task::Runnable; @@ -11,7 +11,7 @@ use rand::{ prelude::*, }; use std::{ - any::type_name_of_val, + any::{Any, type_name_of_val}, collections::{BTreeMap, HashSet, VecDeque}, env, fmt::Write, @@ -57,10 +57,14 @@ impl TestScheduler { .map(|seed| seed.parse().unwrap()) .unwrap_or(0); + let interactive = std::env::var("SCHEDULER_NONINTERACTIVE").is_err(); + (seed..seed + num_iterations as u64) .map(|seed| { let mut unwind_safe_f = AssertUnwindSafe(&mut f); - eprintln!("Running seed: {seed}"); + if interactive { + eprintln!("Running seed: {seed}"); + } match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { Ok(result) => result, Err(error) => { @@ -108,7 +112,12 @@ impl TestScheduler { pub fn end_test(&self) { let mut state = self.state.lock(); if let Some((message, backtrace)) = &state.non_determinism_error { - panic!("{}\n{:?}", message, backtrace) + if cfg!(miri) { + // miri cannot debug print backtraces with `miri-disable-isolation` enabled + panic!("{}", message) + } else { + panic!("{}\n{:?}", message, backtrace) + } } state.finished = true; } @@ -143,18 +152,23 @@ impl TestScheduler { self.state.lock().is_main_thread } - /// Allocate a new session ID for foreground task scheduling. - /// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions. pub fn allocate_session_id(&self) -> SessionId { let mut state = self.state.lock(); state.next_session_id.0 += 1; state.next_session_id } - /// Create a foreground executor for this scheduler - pub fn foreground(self: &Arc) -> ForegroundExecutor { + /// Create a local executor for this scheduler. + pub fn foreground(self: &Arc) -> LocalExecutor { let session_id = self.allocate_session_id(); - ForegroundExecutor::new(session_id, self.clone()) + // Detached local tasks can leave runnables or wakers behind; scheduling + // must not keep this test scheduler alive after callers drop it. + let scheduler = Arc::downgrade(self); + LocalExecutor::new(session_id, self.clone(), move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) } /// Create a background executor for this scheduler @@ -436,6 +450,9 @@ impl TestScheduler { } } else if deadline.is_some() { false + } else if cfg!(miri) { + // miri cannot debug print backtraces with `miri-disable-isolation` enabled + panic!("Parking forbidden."); } else if self.state.lock().capture_pending_traces { let mut pending_traces = String::new(); for (_, trace) in mem::take(&mut self.state.lock().pending_traces) { @@ -551,7 +568,7 @@ impl Scheduler for TestScheduler { completed } - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable) { + fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { assert_correct_thread(&self.thread, &self.state); let mut state = self.state.lock(); let ix = if state.randomize_order { @@ -626,6 +643,29 @@ impl Scheduler for TestScheduler { self.clock.clone() } + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.allocate_session_id(); + // Dedicated local tasks use the same weak scheduling contract as + // foreground tasks so detached children cannot retain the scheduler. + let scheduler = Arc::downgrade(&self); + let executor = LocalExecutor::new(session_id, self, move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }); + executor.spawn(f(executor.clone())) + } + fn as_test(&self) -> Option<&TestScheduler> { Some(self) } diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index dc24fed6..fa852ac1 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -8,7 +8,7 @@ use futures::{ stream::{FuturesUnordered, StreamExt}, }; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, collections::{BTreeSet, HashSet}, pin::Pin, rc::Rc, @@ -34,11 +34,125 @@ fn test_background_executor_spawn() { }); } +#[test] +fn test_scheduler_drops_with_stalled_detached_foreground_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + scheduler + .foreground() + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + scheduler.run(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + +#[test] +fn test_scheduler_drops_with_stalled_detached_background_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + scheduler + .background() + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + scheduler.run(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + +#[test] +fn test_scheduler_drops_with_queued_detached_foreground_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + + scheduler.foreground().spawn(async {}).detach(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); +} + +#[test] +fn test_scheduler_drops_with_queued_detached_background_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + + scheduler.background().spawn(async {}).detach(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); +} + +#[test] +fn test_detached_foreground_task_wakes_after_executor_handle_drop() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let completed = Rc::new(Cell::new(false)); + let (sender, receiver) = oneshot::channel::<()>(); + + let foreground = scheduler.foreground(); + foreground + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.set(true); + } + }) + .detach(); + drop(foreground); + + scheduler.run(); + assert!(!completed.get()); + + sender.send(()).unwrap(); + scheduler.run(); + assert!(completed.get()); +} + +#[test] +fn test_detached_background_task_wakes_after_executor_handle_drop() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let completed = Arc::new(AtomicBool::new(false)); + let (sender, receiver) = oneshot::channel::<()>(); + + let background = scheduler.background(); + background + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.store(true, Ordering::SeqCst); + } + }) + .detach(); + drop(background); + + scheduler.run(); + assert!(!completed.load(Ordering::SeqCst)); + + sender.send(()).unwrap(); + scheduler.run(); + assert!(completed.load(Ordering::SeqCst)); +} + #[test] fn test_foreground_ordering() { let mut traces = HashSet::new(); - TestScheduler::many(100, async |scheduler| { + TestScheduler::many(if cfg!(miri) { 5 } else { 100 }, async |scheduler| { #[derive(Hash, PartialEq, Eq)] struct TraceEntry { session: usize, @@ -128,6 +242,46 @@ fn test_timer_ordering() { }); } +#[test] +fn test_foreground_task_can_hold_mut_borrow_across_await() { + TestScheduler::once(async |scheduler| { + let foreground = scheduler.foreground(); + let (sender, mut receiver) = mpsc::unbounded::<()>(); + let (completion_sender, completion_receiver) = oneshot::channel::(); + let state = Rc::new(RefCell::new(1)); + + foreground + .spawn({ + let state = state.clone(); + async move { + let mut state = state.borrow_mut(); + *state += 1; + + receiver.next().await; + + *state += 1; + completion_sender.send(*state).ok(); + } + }) + .detach(); + + scheduler.run(); + assert!( + state.try_borrow_mut().is_err(), + "foreground task should hold the mutable borrow while suspended" + ); + + sender.unbounded_send(()).unwrap(); + scheduler.run(); + assert_eq!( + completion_receiver.now_or_never(), + Some(Ok(3)), + "foreground task should resume after receiving from the channel" + ); + assert_eq!(*state.borrow(), 3); + }); +} + #[test] fn test_send_from_bg_to_fg() { TestScheduler::once(async |scheduler| { @@ -238,7 +392,7 @@ fn test_block() { } #[test] -#[should_panic(expected = "Parking forbidden. Pending traces:")] +#[should_panic(expected = "Parking forbidden.")] fn test_parking_panics() { let config = TestSchedulerConfig { capture_pending_traces: true, @@ -315,7 +469,7 @@ fn test_block_with_timeout() { // Test case: future makes progress via timer but still times out let mut results = BTreeSet::new(); - TestScheduler::many(100, async |scheduler| { + TestScheduler::many(if cfg!(miri) { 5 } else { 100 }, async |scheduler| { // Keep the existing probabilistic behavior here (do not force 0 ticks), since this subtest // is explicitly checking that some seeds/timeouts can complete while others can time out. let task = scheduler.background().spawn(async move { @@ -329,7 +483,11 @@ fn test_block_with_timeout() { }); assert_eq!( results.into_iter().collect::>(), - vec![None, Some(42)] + if cfg!(miri) { + vec![Some(42)] + } else { + vec![None, Some(42)] + } ); // Regression test: @@ -375,7 +533,7 @@ fn test_block_with_timeout() { #[test] fn test_block_does_not_progress_same_session_foreground() { let mut task2_made_progress_once = false; - TestScheduler::many(1000, async |scheduler| { + TestScheduler::many(if cfg!(miri) { 5 } else { 1000 }, async |scheduler| { let foreground1 = scheduler.foreground(); let foreground2 = scheduler.foreground(); @@ -589,7 +747,7 @@ fn test_background_priority_scheduling() { // Run many iterations to get statistical significance let mut high_before_low_count = 0; - let iterations = 100; + let iterations = if cfg!(miri) { 5 } else { 100 }; for seed in 0..iterations { let config = TestSchedulerConfig::with_seed(seed); @@ -643,3 +801,312 @@ fn test_background_priority_scheduling() { iterations ); } + +#[test] +fn test_spawn_dedicated_basic_round_trip() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async { 42 }) + .await + }); + assert_eq!(result, 42); +} + +#[test] +fn test_spawn_dedicated_not_send_future() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async move { + let state = Rc::new(RefCell::new(0_i32)); + for _ in 0..5 { + *state.borrow_mut() += 1; + } + *state.borrow() + }) + .await + }); + assert_eq!(result, 5); +} + +#[test] +fn test_spawn_dedicated_output_only_needs_send() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|_executor| async { Cell::new(7_u32) }) + .await + }); + assert_eq!(result.get(), 7); +} + +#[test] +fn test_spawn_dedicated_send_closure_captures() { + use parking_lot::Mutex; + + let observed = TestScheduler::once(async |scheduler| { + let shared = Arc::new(Mutex::new(0_i32)); + let shared_for_closure = shared.clone(); + let returned = scheduler + .background() + .spawn_dedicated(move |_executor| { + let local = shared_for_closure; + async move { + *local.lock() = 7; + } + }) + .await; + let _: () = returned; + *shared.lock() + }); + assert_eq!(observed, 7); +} + +#[test] +fn test_spawn_dedicated_inner_spawn_local() { + let result = TestScheduler::once(async |scheduler| { + scheduler + .background() + .spawn_dedicated(|executor| async move { + let inner = Rc::new(RefCell::new(0_i32)); + let inner_for_child = inner.clone(); + let child = executor.spawn(async move { + *inner_for_child.borrow_mut() = 99; + *inner_for_child.borrow() + }); + child.await + }) + .await + }); + assert_eq!(result, 99); +} + +#[test] +fn test_spawn_dedicated_thread_closure_panic_reaches_caller() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = spawn_dedicated_thread( + SessionId::new(1), + scheduler, + |_executor| -> future::Ready<()> { + panic!("dedicated root task constructor exploded"); + }, + ); + })); + + assert!(result.is_err(), "expected spawn_dedicated_thread to panic"); + let panic_payload = result.unwrap_err(); + let panic_message = panic_payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic_payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + + assert!( + panic_message.contains("dedicated root task constructor exploded"), + "expected caller panic to include the dedicated closure panic, got: {}", + panic_message + ); +} + +#[test] +fn test_spawn_dedicated_determinism_under_many() { + use parking_lot::Mutex; + + let outcomes = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + let outcomes_replay = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| { + let trace = Arc::new(Mutex::new(Vec::::new())); + + let background = scheduler.background(); + let mut tasks = Vec::new(); + for id in 0..4_u32 { + let trace = trace.clone(); + let task = background.spawn_dedicated(move |executor| async move { + for step in 0..3 { + trace.lock().push(id * 100 + step); + executor.spawn(async {}).await; + } + id + }); + tasks.push(task); + } + + let mut outputs = Vec::new(); + for task in tasks { + outputs.push(task.await); + } + + (trace.lock().clone(), outputs) + }); + + assert_eq!( + outcomes, outcomes_replay, + "per-seed outcomes should be reproducible" + ); + + let any_interleaved = outcomes.iter().any(|(trace, _)| { + trace + .windows(2) + .any(|window| window[0] / 100 != window[1] / 100) + }); + assert!( + any_interleaved, + "expected at least one seed to interleave dedicated tasks" + ); +} + +#[test] +fn test_spawn_dedicated_dropping_task_cancels_future() { + use parking_lot::Mutex; + + let counter_after = TestScheduler::once(async |scheduler| { + let counter = Arc::new(Mutex::new(0_u32)); + let (resume_tx, resume_rx) = oneshot::channel::<()>(); + + let task = { + let counter = counter.clone(); + scheduler + .background() + .spawn_dedicated(move |_executor| async move { + *counter.lock() = 1; + let _ = resume_rx.await; + *counter.lock() = 2; + }) + }; + + scheduler.run(); + assert_eq!(*counter.lock(), 1); + + drop(task); + let _ = resume_tx.send(()); + scheduler.run(); + + *counter.lock() + }); + + assert_eq!( + counter_after, 1, + "dropping the dedicated task must cancel the root future before its second write" + ); +} + +#[test] +fn test_spawn_dedicated_detached_child_runs_after_root_completes() { + use parking_lot::Mutex; + + let child_ran = TestScheduler::once(async |scheduler| { + let child_ran = Arc::new(Mutex::new(false)); + + let task = { + let child_ran = child_ran.clone(); + scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + *child_ran.lock() = true; + }) + .detach(); + }) + }; + + task.await; + scheduler.run(); + + *child_ran.lock() + }); + + assert!( + child_ran, + "detached child must complete after the root, not be cancelled with it" + ); +} + +#[test] +fn test_scheduler_drops_with_stalled_spawn_dedicated_detached_child() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + let (sender, receiver) = oneshot::channel::<()>(); + + let task = scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + receiver.await.ok(); + }) + .detach(); + }); + + scheduler.run(); + assert!(task.is_ready()); + drop(task); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); + drop(sender); +} + +#[test] +fn test_spawn_dedicated_detached_child_wakes_after_root_task_completes() { + use parking_lot::Mutex; + + let child_ran = TestScheduler::once(async |scheduler| { + let child_ran = Arc::new(Mutex::new(false)); + let (resume_tx, resume_rx) = oneshot::channel::<()>(); + + let task = { + let child_ran = child_ran.clone(); + scheduler + .background() + .spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + let _ = resume_rx.await; + *child_ran.lock() = true; + }) + .detach(); + }) + }; + + task.await; + scheduler.run(); + assert!(!*child_ran.lock()); + + let _ = resume_tx.send(()); + scheduler.run(); + + *child_ran.lock() + }); + + assert!( + child_ran, + "detached dedicated child must wake after the root task has completed" + ); +} diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 0882992b..797b3082 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -1349,7 +1349,7 @@ mod tests { use rand::{distr::StandardUniform, prelude::*}; use std::cmp; - #[ctor::ctor] + #[ctor::ctor(unsafe)] fn init_logger() { zlog::init_test(); } diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index 347fc818..728b2067 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -13,6 +13,18 @@ use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Output}; use std::ptr; +use std::sync::{Mutex, MutexGuard}; + +// Coordinates this module's `pipe()` + FIOCLEX setup with its `posix_spawn` +// path. Darwin lacks `pipe2(O_CLOEXEC)`, so unrelated spawners that do not take +// this lock can still inherit pipe fds in the small pre-FIOCLEX window. +static FD_SPAWN_LOCK: Mutex<()> = Mutex::new(()); + +fn fd_spawn_lock() -> MutexGuard<'static, ()> { + FD_SPAWN_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()) +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Stdio { @@ -376,155 +388,283 @@ fn spawn_posix_spawn( .collect(); envp_ptrs.push(ptr::null_mut()); - let (stdin_read, stdin_write) = match stdin_cfg { - Stdio::Piped => { - let (r, w) = create_pipe()?; - (Some(r), Some(w)) - } - Stdio::Null => { - let fd = open_dev_null(libc::O_RDONLY)?; - (Some(fd), None) - } - Stdio::Inherit => (None, None), - }; + let (pid, stdin, stdout, stderr) = { + let _lock = fd_spawn_lock(); - let (stdout_read, stdout_write) = match stdout_cfg { - Stdio::Piped => { - let (r, w) = create_pipe()?; - (Some(r), Some(w)) - } - Stdio::Null => { - let fd = open_dev_null(libc::O_WRONLY)?; - (None, Some(fd)) - } - Stdio::Inherit => (None, None), - }; + let (stdin_read, stdin_write) = match stdin_cfg { + Stdio::Piped => { + let (r, w) = create_pipe_locked()?; + (Some(FdGuard::new(r)), Some(FdGuard::new(w))) + } + Stdio::Null => { + let fd = open_dev_null(libc::O_RDONLY)?; + (Some(FdGuard::new(fd)), None) + } + Stdio::Inherit => (None, None), + }; - let (stderr_read, stderr_write) = match stderr_cfg { - Stdio::Piped => { - let (r, w) = create_pipe()?; - (Some(r), Some(w)) - } - Stdio::Null => { - let fd = open_dev_null(libc::O_WRONLY)?; - (None, Some(fd)) - } - Stdio::Inherit => (None, None), - }; + let (stdout_read, stdout_write) = match stdout_cfg { + Stdio::Piped => { + let (r, w) = create_pipe_locked()?; + (Some(FdGuard::new(r)), Some(FdGuard::new(w))) + } + Stdio::Null => { + let fd = open_dev_null(libc::O_WRONLY)?; + (None, Some(FdGuard::new(fd))) + } + Stdio::Inherit => (None, None), + }; - let mut attr: libc::posix_spawnattr_t = ptr::null_mut(); - let mut file_actions: libc::posix_spawn_file_actions_t = ptr::null_mut(); + let (stderr_read, stderr_write) = match stderr_cfg { + Stdio::Piped => { + let (r, w) = create_pipe_locked()?; + (Some(FdGuard::new(r)), Some(FdGuard::new(w))) + } + Stdio::Null => { + let fd = open_dev_null(libc::O_WRONLY)?; + (None, Some(FdGuard::new(fd))) + } + Stdio::Inherit => (None, None), + }; - unsafe { - cvt_nz(libc::posix_spawnattr_init(&mut attr))?; - cvt_nz(libc::posix_spawn_file_actions_init(&mut file_actions))?; - - cvt_nz(libc::posix_spawnattr_setflags( - &mut attr, - libc::POSIX_SPAWN_CLOEXEC_DEFAULT as libc::c_short, - ))?; - - cvt_nz(posix_spawnattr_setexceptionports_np( - &mut attr, - EXC_MASK_ALL, - MACH_PORT_NULL, - EXCEPTION_DEFAULT as exception_behavior_t, - THREAD_STATE_NONE, - ))?; - - cvt_nz(posix_spawn_file_actions_addchdir_np( - &mut file_actions, - current_dir_cstr.as_ptr(), - ))?; - - if let Some(fd) = stdin_read { - cvt_nz(libc::posix_spawn_file_actions_adddup2( - &mut file_actions, - fd, - libc::STDIN_FILENO, - ))?; - cvt_nz(posix_spawn_file_actions_addinherit_np( - &mut file_actions, - libc::STDIN_FILENO, - ))?; - } + let mut attr = SpawnAttr::init()?; + let mut file_actions = SpawnFileActions::init()?; - if let Some(fd) = stdout_write { - cvt_nz(libc::posix_spawn_file_actions_adddup2( - &mut file_actions, - fd, - libc::STDOUT_FILENO, - ))?; - cvt_nz(posix_spawn_file_actions_addinherit_np( - &mut file_actions, - libc::STDOUT_FILENO, + unsafe { + cvt_nz(libc::posix_spawnattr_setflags( + attr.as_mut_ptr(), + libc::POSIX_SPAWN_CLOEXEC_DEFAULT as libc::c_short, ))?; - } - if let Some(fd) = stderr_write { - cvt_nz(libc::posix_spawn_file_actions_adddup2( - &mut file_actions, - fd, - libc::STDERR_FILENO, + cvt_nz(posix_spawnattr_setexceptionports_np( + attr.as_mut_ptr(), + EXC_MASK_ALL, + MACH_PORT_NULL, + EXCEPTION_DEFAULT as exception_behavior_t, + THREAD_STATE_NONE, ))?; - cvt_nz(posix_spawn_file_actions_addinherit_np( - &mut file_actions, - libc::STDERR_FILENO, + + cvt_nz(posix_spawn_file_actions_addchdir_np( + file_actions.as_mut_ptr(), + current_dir_cstr.as_ptr(), ))?; + + if let Some(fd) = &stdin_read { + cvt_nz(libc::posix_spawn_file_actions_adddup2( + file_actions.as_mut_ptr(), + fd.raw(), + libc::STDIN_FILENO, + ))?; + cvt_nz(posix_spawn_file_actions_addinherit_np( + file_actions.as_mut_ptr(), + libc::STDIN_FILENO, + ))?; + } + + if let Some(fd) = &stdout_write { + cvt_nz(libc::posix_spawn_file_actions_adddup2( + file_actions.as_mut_ptr(), + fd.raw(), + libc::STDOUT_FILENO, + ))?; + cvt_nz(posix_spawn_file_actions_addinherit_np( + file_actions.as_mut_ptr(), + libc::STDOUT_FILENO, + ))?; + } + + if let Some(fd) = &stderr_write { + cvt_nz(libc::posix_spawn_file_actions_adddup2( + file_actions.as_mut_ptr(), + fd.raw(), + libc::STDERR_FILENO, + ))?; + cvt_nz(posix_spawn_file_actions_addinherit_np( + file_actions.as_mut_ptr(), + libc::STDERR_FILENO, + ))?; + } } let mut pid: libc::pid_t = 0; - let spawn_result = libc::posix_spawnp( - &mut pid, - program_cstr.as_ptr(), - &file_actions, - &attr, - argv_ptrs.as_ptr(), - if envs.is_some() { - envp_ptrs.as_ptr() - } else { - environ - }, - ); + let spawn_result = unsafe { + libc::posix_spawnp( + &mut pid, + program_cstr.as_ptr(), + file_actions.as_ptr(), + attr.as_ptr(), + argv_ptrs.as_ptr(), + if envs.is_some() { + envp_ptrs.as_ptr() + } else { + environ + }, + ) + }; - libc::posix_spawnattr_destroy(&mut attr); - libc::posix_spawn_file_actions_destroy(&mut file_actions); + drop(file_actions); + drop(attr); + cvt_nz(spawn_result)?; - if let Some(fd) = stdin_read { - libc::close(fd); - } - if let Some(fd) = stdout_write { - libc::close(fd); - } - if let Some(fd) = stderr_write { - libc::close(fd); - } + drop(stdin_read); + drop(stdout_write); + drop(stderr_write); - cvt_nz(spawn_result)?; + ( + pid, + stdin_write.map(FdGuard::into_raw), + stdout_read.map(FdGuard::into_raw), + stderr_read.map(FdGuard::into_raw), + ) + }; + unsafe { Ok(Child { pid, - stdin: stdin_write.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), - stdout: stdout_read.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), - stderr: stderr_read.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), + stdin: stdin.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), + stdout: stdout.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), + stderr: stderr.map(|fd| Unblock::new(std::fs::File::from_raw_fd(fd))), kill_on_drop, status: None, }) } } +#[cfg_attr(not(test), allow(dead_code))] fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> { + let _lock = fd_spawn_lock(); + create_pipe_locked() +} + +fn create_pipe_locked() -> io::Result<(libc::c_int, libc::c_int)> { let mut fds: [libc::c_int; 2] = [0; 2]; - let result = unsafe { libc::pipe(fds.as_mut_ptr()) }; - if result == -1 { - return Err(io::Error::last_os_error()); + unsafe { + let result = libc::pipe(fds.as_mut_ptr()); + if result == -1 { + let error = io::Error::last_os_error(); + return Err(error); + } + + // Set close-on-exec on both ends of the pipe. + // + // Once set, this prevents unrelated spawns elsewhere in the process (e.g. + // `smol::process` or `async_process`, which on Apple platforms use + // `posix_spawn` *without* `POSIX_SPAWN_CLOEXEC_DEFAULT`) would inherit + // these file descriptors and keep the pipes open even after we drop our + // side. + for &fd in &fds { + let result = libc::ioctl(fd, libc::FIOCLEX); + if result == -1 { + let error = io::Error::last_os_error(); + libc::close(fds[0]); + libc::close(fds[1]); + return Err(error); + } + } + + Ok((fds[0], fds[1])) + } +} + +struct FdGuard { + fd: libc::c_int, +} + +impl FdGuard { + fn new(fd: libc::c_int) -> Self { + Self { fd } + } + + fn raw(&self) -> libc::c_int { + self.fd + } + + fn into_raw(mut self) -> libc::c_int { + let fd = self.fd; + self.fd = -1; + fd + } +} + +impl Drop for FdGuard { + fn drop(&mut self) { + if self.fd != -1 { + unsafe { + libc::close(self.fd); + } + } + } +} + +struct SpawnAttr { + raw: libc::posix_spawnattr_t, +} + +impl SpawnAttr { + fn init() -> io::Result { + let mut raw = ptr::null_mut(); + unsafe { + cvt_nz(libc::posix_spawnattr_init(&mut raw))?; + } + Ok(Self { raw }) + } + + fn as_mut_ptr(&mut self) -> *mut libc::posix_spawnattr_t { + &mut self.raw + } + + fn as_ptr(&self) -> *const libc::posix_spawnattr_t { + &self.raw + } +} + +impl Drop for SpawnAttr { + fn drop(&mut self) { + unsafe { + libc::posix_spawnattr_destroy(&mut self.raw); + } + } +} + +struct SpawnFileActions { + raw: libc::posix_spawn_file_actions_t, +} + +impl SpawnFileActions { + fn init() -> io::Result { + let mut raw = ptr::null_mut(); + unsafe { + cvt_nz(libc::posix_spawn_file_actions_init(&mut raw))?; + } + Ok(Self { raw }) + } + + fn as_mut_ptr(&mut self) -> *mut libc::posix_spawn_file_actions_t { + &mut self.raw + } + + fn as_ptr(&self) -> *const libc::posix_spawn_file_actions_t { + &self.raw + } +} + +impl Drop for SpawnFileActions { + fn drop(&mut self) { + unsafe { + libc::posix_spawn_file_actions_destroy(&mut self.raw); + } } - Ok((fds[0], fds[1])) } fn open_dev_null(flags: libc::c_int) -> io::Result { - let fd = unsafe { libc::open(c"/dev/null".as_ptr() as *const libc::c_char, flags) }; + // Set close-on-exec for this file descriptor, for the same reason as in `create_pipe`. + let fd = unsafe { + libc::open( + c"/dev/null".as_ptr() as *const libc::c_char, + flags | libc::O_CLOEXEC, + ) + }; if fd == -1 { return Err(io::Error::last_os_error()); } @@ -552,6 +692,123 @@ fn invalid_input_error() -> io::Error { mod tests { use super::*; use futures_lite::AsyncWriteExt; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + // Verifies that pipes returned by `create_pipe` aren't visible to unrelated + // child processes spawned via `std::process::Command`. On macOS, `std` + // uses `posix_spawn` without `POSIX_SPAWN_CLOEXEC_DEFAULT`, so any + // non-CLOEXEC fd in the parent leaks into the child. This verifies the + // post-`create_pipe` invariant; the module lock covers the earlier + // pipe-to-FIOCLEX window for participating spawns. + #[test] + fn test_create_pipe_not_inherited_by_unrelated_spawn() { + let (read_fd, write_fd) = create_pipe().expect("create_pipe failed"); + + // Probe with the exact fds returned by `create_pipe` (no dup), since + // duping with `F_DUPFD` would lose CLOEXEC and `F_DUPFD_CLOEXEC` would + // unconditionally set it, either of which would defeat the test. + #[allow(clippy::disallowed_methods)] + let output = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!( + "for fd in {read_fd} {write_fd}; do \ + if [ -e /dev/fd/$fd ]; then \ + echo $fd WAS INHERITED; \ + else \ + echo $fd WAS NOT INHERITED; \ + fi; \ + done; \ + echo DONE" + )) + .output() + .expect("failed to spawn sh"); + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + + unsafe { + libc::close(read_fd); + libc::close(write_fd); + } + + assert_eq!( + stdout, + format!("{read_fd} WAS NOT INHERITED\n{write_fd} WAS NOT INHERITED\nDONE\n") + ); + } + + #[test] + fn test_participating_spawn_waits_for_fd_lock() { + let guard = fd_spawn_lock(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + + let worker = thread::spawn(move || { + started_tx.send(()).expect("failed to signal worker start"); + let output = + smol::block_on(async { Command::new("/bin/echo").arg("ready").output().await }); + done_tx.send(output).expect("failed to send command output"); + }); + + started_rx + .recv_timeout(Duration::from_secs(5)) + .expect("worker did not start"); + assert!( + matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "participating spawn completed while fd lock was held" + ); + + drop(guard); + + let output = done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("worker did not complete after fd lock was released") + .expect("failed to run command"); + worker.join().expect("worker thread panicked"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"ready\n"); + } + + #[test] + fn test_concurrent_piped_commands_complete_without_fd_inheritance_deadlock() { + let mut threads = Vec::new(); + + for thread_index in 0..4 { + threads.push(thread::spawn(move || { + for iteration in 0..20 { + smol::block_on(async move { + let input = format!("thread {thread_index} iteration {iteration}\n"); + let mut child = Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn cat"); + + let mut stdin = child.stdin.take().expect("stdin should be piped"); + stdin + .write_all(input.as_bytes()) + .await + .expect("failed to write to stdin"); + stdin.close().await.expect("failed to close stdin"); + drop(stdin); + + let output = child.output().await.expect("failed to read output"); + assert!(output.status.success()); + assert_eq!(output.stdout, input.as_bytes()); + }); + } + })); + } + + for thread in threads { + thread.join().expect("worker thread panicked"); + } + } #[test] fn test_spawn_echo() { diff --git a/tooling/perf/src/main.rs b/tooling/perf/src/main.rs index 243658e5..728f61ae 100644 --- a/tooling/perf/src/main.rs +++ b/tooling/perf/src/main.rs @@ -416,16 +416,10 @@ fn triage_test( /// Try to find the hyperfine binary the user has installed. fn hyp_binary() -> Option { const HYP_PATH: &str = "hyperfine"; - const HYP_HOME: &str = "~/.cargo/bin/hyperfine"; - if Command::new(HYP_PATH).output().is_err() { - if Command::new(HYP_HOME).output().is_err() { - None - } else { - Some(Command::new(HYP_HOME)) - } - } else { - Some(Command::new(HYP_PATH)) - } + Command::new(HYP_PATH) + .output() + .is_ok() + .then(|| Command::new(HYP_PATH)) } /// Profiles a given test with hyperfine, returning the mean and standard deviation