diff --git a/.cargo/ci-config.toml b/.cargo/ci-config.toml index b31b79a59b..6a5feece64 100644 --- a/.cargo/ci-config.toml +++ b/.cargo/ci-config.toml @@ -15,14 +15,4 @@ rustflags = ["-D", "warnings"] [profile.dev] debug = "limited" -# Use Mold on Linux, because it's faster than GNU ld and LLD. -# -# We no longer set this in the default `config.toml` so that developers can opt in to Wild, which -# is faster than Mold, in their own ~/.cargo/config.toml. -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] -[target.aarch64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.cargo/config.toml b/.cargo/config.toml index 9b2e6f51c9..a9bf1f9cc9 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -16,5 +16,9 @@ rustflags = [ "target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows ] +# We need lld to link libwebrtc.a successfully on aarch64-linux +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=lld"] + [env] MACOSX_DEPLOYMENT_TARGET = "10.15.7" diff --git a/.config/nextest.toml b/.config/nextest.toml deleted file mode 100644 index e8fa9bdf9d..0000000000 --- a/.config/nextest.toml +++ /dev/null @@ -1,5 +0,0 @@ -# Nextest configuration for GPUI -# https://nexte.st/book/configuration.html - -[profile.default] -# Default test settings \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 9973cfb4db..f092686f6c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Prevent GitHub from displaying comments within JSON files as errors. *.json linguist-language=JSON-with-Comments + diff --git a/.gitignore b/.gitignore index d3157ef5a5..44027a54dc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ xcuserdata/ # Misc **/*.db .build + diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index 4f7a5a6245..0000000000 --- a/.zed/settings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "languages": { - "Markdown": { - "tab_size": 2, - }, - "TOML": { - "format_on_save": "off", - }, - "YAML": { - "tab_size": 2, - }, - "JSON": { - "tab_size": 2, - "preferred_line_length": 120, - }, - "JSONC": { - "tab_size": 2, - "preferred_line_length": 120, - }, - }, - "hard_tabs": false, - "formatter": "auto", - "remove_trailing_whitespace_on_save": true, - "ensure_final_newline_on_save": true, - "file_scan_exclusions": [ - "**/.git", - "**/.svn", - "**/.hg", - "**/.jj", - "**/CVS", - "**/.DS_Store", - "**/Thumbs.db", - "**/target", - ], -} diff --git a/.zed/tasks.json b/.zed/tasks.json deleted file mode 100644 index 57f6b28e12..0000000000 --- a/.zed/tasks.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "label": "clippy", - "command": "./script/clippy", - "args": [], - "allow_concurrent_runs": true, - "use_new_terminal": false, - }, - { - "label": "cargo check gpui", - "command": "cargo", - "args": ["check", "--package", "gpui"], - "allow_concurrent_runs": true, - "use_new_terminal": false, - }, - { - "label": "cargo test gpui", - "command": "cargo", - "args": ["test", "--package", "gpui"], - "allow_concurrent_runs": false, - "use_new_terminal": false, - }, -] diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 82d15eb9e8..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,143 +0,0 @@ -# Rust coding guidelines - -* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified. -* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious. -* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files. -* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors. -* Be careful with operations like indexing which may panic if the indexes are out of bounds. -* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately: - - Propagate errors with `?` when the calling function should handle them - - Use `.log_err()` or similar when you need to ignore errors but want visibility - - Use explicit error handling with `match` or `if let Err(...)` when you need custom logic - - Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead -* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback. -* Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`. -* When creating new crates, prefer specifying the library root path in `Cargo.toml` using `[lib] path = "...rs"` instead of the default `lib.rs`, to maintain consistent and descriptive naming (e.g., `gpui.rs` or `main.rs`). -* Avoid creative additions unless explicitly requested -* Use full words for variable names (no abbreviations like "q" for "queue") -* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references. - Example: - ```rust - executor.spawn({ - let task_ran = task_ran.clone(); - async move { - *task_ran.borrow_mut() = true; - } - }); - ``` - -# GPUI - -GPUI is a UI framework which also provides primitives for state and concurrency management. - -## Context - -Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter. - -* `App` is the root context type, providing access to global state and read and update of entities. -* `Context` is provided when updating an `Entity`. This context dereferences into `App`, so functions which take `&App` can also take `&Context`. -* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points. - -## `Window` - -`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc. - -## Entities - -An `Entity` is a handle to state of type `T`. With `thing: Entity`: - -* `thing.entity_id()` returns `EntityId` -* `thing.downgrade()` returns `WeakEntity` -* `thing.read(cx: &App)` returns `&T`. -* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value. -* `thing.update(cx, |thing: &mut T, cx: &mut Context| ...)` allows the closure to mutate the state, and provides a `Context` for interacting with the entity. It returns the closure's return value. -* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`. - -Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows. - -Trying to update an entity while it's already being updated must be avoided as this will cause a panic. - -When `read_with`, `update`, or `update_in` are used with an async context, the closure's return value is wrapped in an `anyhow::Result`. - -`WeakEntity` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped. - -## Concurrency - -All use of entities and UI rendering occurs on a single foreground thread. - -`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is an async context like `AsyncApp` or `AsyncWindowContext`. - -When the outer cx is a `Context`, the use of `spawn` instead looks like `cx.spawn(async move |handle, cx| ...)`, where `handle: WeakEntity`. - -To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state. - -Both `cx.spawn` and `cx.background_spawn` return a `Task`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done: - -* Awaiting the task in some other async context. -* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely. -* Storing the task in a field, if the work should be halted when the struct is dropped. - -A task which doesn't do anything but provide a value can be created with `Task::ready(value)`. - -## Elements - -The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity` where `T` implements `Render` is sometimes called a "view". - -Example: - -``` -struct TextWithBorder(SharedString); - -impl Render for TextWithBorder { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().border_1().child(self.0.clone()) - } -} -``` - -Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc`. - -UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children. - -The style methods on elements are similar to those used by Tailwind CSS. - -If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value. - -## Input events - -Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`. - -Often event handlers will want to update the entity that's in the current `Context`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context| ...)`. - -## Actions - -Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`. - -Actions with no data defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user. - -Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`. - -## Notify - -When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called. - -## Entity events - -While updating an entity (`cx: Context`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmittor for EntityType {}`. - -Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec` field. - -## Recent API changes - -GPUI has had some changes to its APIs. Always write code using the new APIs: - -* `spawn` methods now take async closures (`AsyncFn`), and so should be called like `cx.spawn(async move |cx| ...)`. -* Use `Entity`. This replaces `Model` and `View` which no longer exist and should NEVER be used. -* Use `App` references. This replaces `AppContext` which no longer exists and should NEVER be used. -* Use `Context` references. This replaces `ModelContext` which no longer exists and should NEVER be used. -* `Window` is now passed around explicitly. The new interface adds a `Window` reference parameter to some methods, and adds some new "*_in" methods for plumbing `Window`. The old types `WindowContext` and `ViewContext` should NEVER be used. - - -## General guidelines - -- Use `./script/clippy` instead of `cargo clippy` diff --git a/Cargo.lock b/Cargo.lock index c0818ec137..c18d1438f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,22 +44,13 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - [[package]] name = "aligned-vec" version = "0.6.4" @@ -69,6 +60,15 @@ dependencies = [ "equator", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -101,22 +101,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.11" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -127,11 +127,11 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "ar_archive_writer" -version = "0.2.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object 0.32.2", + "object", ] [[package]] @@ -148,7 +148,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -169,15 +169,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -187,22 +178,11 @@ dependencies = [ "libloading", ] -[[package]] -name = "ash-window" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" -dependencies = [ - "ash", - "raw-window-handle", - "raw-window-metal", -] - [[package]] name = "ashpd" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ "async-fs", "async-net", @@ -215,25 +195,20 @@ dependencies = [ "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "zbus", ] [[package]] name = "ashpd" -version = "0.12.0" +version = "0.13.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0986d5b4f0802160191ad75f8d33ada000558757db3defb70299ca95d9fcbd" +checksum = "13bdf0fd848239dcd5e64eeeee35dbc00378ebcc6f3aa4ead0a305eec83d0cfb" dependencies = [ - "async-fs", - "async-net", "enumflags2", - "futures-channel", "futures-util", - "rand 0.9.2", + "getrandom 0.4.2", "serde", - "serde_repr", - "url", "zbus", ] @@ -274,9 +249,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" dependencies = [ "compression-codecs", "compression-core", @@ -338,7 +313,7 @@ dependencies = [ "futures-lite 2.6.1", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.2", "slab", "windows-sys 0.61.2", ] @@ -380,7 +355,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite 2.6.1", - "rustix 1.1.3", + "rustix 1.1.2", ] [[package]] @@ -391,7 +366,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -406,7 +381,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.2", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -453,7 +428,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -487,36 +462,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.17", - "v_frame", - "y4m", -] - [[package]] name = "av1-grain" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" dependencies = [ "anyhow", "arrayvec", "log", - "nom 8.0.0", + "nom", "num-rational", "v_frame", ] @@ -540,7 +495,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object 0.37.3", + "object", "rustc-demangle", "windows-link 0.2.1", ] @@ -551,33 +506,22 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.111", -] - [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -586,6 +530,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bit_field" version = "0.10.3" @@ -603,73 +553,15 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" -version = "4.9.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "blade-graphics" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4deb8f595ce7f00dee3543ebf6fd9a20ea86fc421ab79600dac30876250bdae" -dependencies = [ - "ash", - "ash-window", - "bitflags 2.10.0", - "bytemuck", - "codespan-reporting", - "glow", - "gpu-alloc", - "gpu-alloc-ash", - "hidden-trait", - "js-sys", - "khronos-egl", - "libloading", - "log", - "mint", - "naga", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", - "objc2-ui-kit", - "once_cell", - "raw-window-handle", - "slab", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "blade-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "blade-util" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c" -dependencies = [ - "blade-graphics", - "bytemuck", - "log", - "profiling", -] +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" [[package]] name = "block" @@ -719,9 +611,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", "serde", @@ -729,15 +621,15 @@ dependencies = [ [[package]] name = "built" -version = "0.8.0" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" @@ -756,7 +648,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -773,18 +665,19 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "calloop" -version = "0.14.3" -source = "git+https://github.com/zed-industries/calloop#eb6b4fd17b9af5ecc226546bdd04185391b3e265" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.10.0", "polling", - "rustix 1.1.3", + "rustix 1.1.2", "slab", "tracing", ] @@ -796,7 +689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop", - "rustix 1.1.3", + "rustix 1.1.2", "wayland-backend", "wayland-client", ] @@ -823,16 +716,16 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.111", + "syn", "tempfile", "toml 0.8.23", ] [[package]] name = "cc" -version = "1.2.51" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -841,12 +734,13 @@ dependencies = [ ] [[package]] -name = "cexpr" -version = "0.6.0" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "nom 7.1.3", + "smallvec", + "target-lexicon", ] [[package]] @@ -870,6 +764,19 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -887,33 +794,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "cocoa" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation 0.1.2", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "foreign-types", - "libc", - "objc", -] - [[package]] name = "cocoa" version = "0.26.0" @@ -922,28 +802,14 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ "bitflags 2.10.0", "block", - "cocoa-foundation 0.2.0", + "cocoa-foundation", "core-foundation 0.10.0", - "core-graphics 0.24.0", + "core-graphics", "foreign-types", "libc", "objc", ] -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - [[package]] name = "cocoa-foundation" version = "0.2.0" @@ -960,13 +826,21 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ - "serde", "termcolor", - "unicode-width", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width 0.2.2", ] [[package]] @@ -993,9 +867,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" dependencies = [ "compression-core", "deflate64", @@ -1005,9 +879,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" [[package]] name = "concurrent-queue" @@ -1070,19 +944,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - [[package]] name = "core-graphics" version = "0.24.0" @@ -1096,19 +957,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-graphics-helmer-fork" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - [[package]] name = "core-graphics-types" version = "0.1.3" @@ -1151,7 +999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.0", - "core-graphics 0.24.0", + "core-graphics", "foreign-types", "libc", ] @@ -1167,16 +1015,7 @@ dependencies = [ "core-graphics2", "io-surface", "libc", - "metal", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", + "metal 0.29.0", ] [[package]] @@ -1190,21 +1029,22 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.14.2" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" +checksum = "5d8c4e3a1d02f5269ed15c2d70b4647167856f66f228dcdf99050ab77bbb5a56" dependencies = [ "bitflags 2.10.0", - "fontdb 0.16.2", + "fontdb", + "harfrust", + "linebender_resource_handle", "log", "rangemap", - "rustc-hash 1.1.0", - "rustybuzz 0.14.1", + "rustc-hash 2.1.1", "self_cell", + "skrifa 0.40.0", "smol_str", "swash", "sys-locale", - "ttf-parser 0.21.1", "unicode-bidi", "unicode-linebreak", "unicode-script", @@ -1271,12 +1111,11 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1318,7 +1157,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.111", + "syn", ] [[package]] @@ -1379,19 +1218,15 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", ] @@ -1403,7 +1238,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1415,6 +1250,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1475,7 +1319,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.10+spec-1.1.0", + "toml 0.9.8", "vswhom", "winreg", ] @@ -1491,9 +1335,9 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" [[package]] name = "enumflags2" @@ -1513,7 +1357,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1556,7 +1400,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1634,9 +1478,9 @@ dependencies = [ [[package]] name = "exr" -version = "1.74.0" +version = "1.73.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" dependencies = [ "bit_field", "half", @@ -1679,7 +1523,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1716,15 +1560,21 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -1773,10 +1623,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "font-types" -version = "0.10.1" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "511e2c18a516c666d27867d2f9821f76e7d591f762e9fc41dd6cc5c90fe54b0b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2" dependencies = [ "bytemuck", ] @@ -1790,20 +1655,6 @@ dependencies = [ "roxmltree", ] -[[package]] -name = "fontdb" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser 0.20.0", -] - [[package]] name = "fontdb" version = "0.23.0" @@ -1815,7 +1666,7 @@ dependencies = [ "memmap2", "slotmap", "tinyvec", - "ttf-parser 0.25.1", + "ttf-parser", ] [[package]] @@ -1836,7 +1687,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -1900,6 +1751,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite 2.6.1", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -1959,7 +1823,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2008,7 +1872,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.2", "windows-link 0.2.1", ] @@ -2034,16 +1898,29 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] -name = "gif" -version = "0.14.1" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", "weezl", @@ -2057,9 +1934,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "git2" -version = "0.20.3" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags 2.10.0", "libc", @@ -2069,16 +1946,21 @@ dependencies = [ ] [[package]] -name = "glob" -version = "0.3.3" +name = "gl_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] [[package]] name = "globset" -version = "0.4.18" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" dependencies = [ "aho-corasick", "bstr", @@ -2111,6 +1993,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -2121,17 +2012,6 @@ dependencies = [ "gpu-alloc-types", ] -[[package]] -name = "gpu-alloc-ash" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbda7a18a29bc98c2e0de0435c347df935bf59489935d0cbd0b73f1679b6f79a" -dependencies = [ - "ash", - "gpu-alloc-types", - "tinyvec", -] - [[package]] name = "gpu-alloc-types" version = "0.3.0" @@ -2141,36 +2021,67 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "gpui-ce" version = "0.3.3" dependencies = [ "anyhow", "as-raw-xcb-connection", - "ashpd 0.11.0", + "ashpd 0.11.1", + "async-channel 2.5.0", "async-task", "backtrace", - "bindgen", "bitflags 2.10.0", - "blade-graphics", - "blade-macros", - "blade-util", "block", "bytemuck", "calloop", "calloop-wayland-source", "cbindgen", + "chrono", "circular-buffer", - "cocoa 0.26.0", - "cocoa-foundation 0.2.0", + "cocoa", + "cocoa-foundation", "core-foundation 0.10.0", "core-foundation-sys", - "core-graphics 0.24.0", + "core-graphics", "core-text", "core-video", "cosmic-text", "ctor", "derive_more", + "dispatch2", "embed-resource", "env_logger", "etagere", @@ -2178,10 +2089,10 @@ dependencies = [ "flume", "foreign-types", "futures", - "gpui-macros", + "futures-concurrency", + "gpui-ce-macros", "gpui_collections", "gpui_http_client", - "gpui_media", "gpui_refineable", "gpui_sum_tree", "gpui_util", @@ -2193,8 +2104,8 @@ dependencies = [ "log", "lyon", "mach2", - "metal", - "naga", + "metal 0.29.0", + "naga 29.0.0", "num_cpus", "objc", "objc2", @@ -2205,9 +2116,11 @@ dependencies = [ "parking_lot", "pathfinder_geometry", "pin-project", + "pollster 0.4.0", "postage", "pretty_assertions", "profiling", + "proptest", "rand 0.9.2", "raw-window-handle", "resvg", @@ -2222,18 +2135,22 @@ dependencies = [ "spin 0.10.0", "stacksafe", "strum 0.27.2", + "swash", "taffy", "thiserror 2.0.17", "unicode-segmentation", + "url", "usvg", "uuid", "waker-fn", "wayland-backend", "wayland-client", "wayland-cursor", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", + "web-time", + "wgpu", "windows 0.61.3", "windows-core 0.61.2", "windows-numerics", @@ -2242,20 +2159,17 @@ dependencies = [ "x11rb", "xkbcommon", "zed-font-kit", - "zed-scap", "zed-xim", ] [[package]] -name = "gpui-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcb02dd63a2859714ac7b6b476937617c3c744157af1b49f7c904023a79039be" +name = "gpui-ce-macros" +version = "0.1.0" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2276,7 +2190,7 @@ checksum = "644de174341a87b3478bd65b66bca38af868bcf2b2e865700523734f83cfc664" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2305,22 +2219,6 @@ dependencies = [ "zed-reqwest", ] -[[package]] -name = "gpui_media" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" -dependencies = [ - "anyhow", - "bindgen", - "core-foundation 0.10.0", - "core-video", - "ctor", - "foreign-types", - "metal", - "objc", -] - [[package]] name = "gpui_perf" version = "0.2.2" @@ -2399,7 +2297,7 @@ checksum = "2c28f65ef47fb97e21e82fd4dd75ccc2506eda010c846dc8054015ea234f1a22" dependencies = [ "gpui_perf", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2439,6 +2337,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2451,7 +2362,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2459,6 +2370,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2490,17 +2404,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "hidden-trait" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -2521,20 +2424,21 @@ dependencies = [ [[package]] name = "home" -version = "0.5.12" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "http" -version = "1.4.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", + "fnv", "itoa", ] @@ -2569,9 +2473,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ "atomic-waker", "bytes", @@ -2608,9 +2512,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "bytes", "futures-channel", @@ -2628,10 +2532,34 @@ dependencies = [ ] [[package]] -name = "icu_collections" -version = "2.1.1" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", "potential_utf", @@ -2642,9 +2570,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -2655,10 +2583,11 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2669,38 +2598,42 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", + "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", "icu_locale_core", + "stable_deref_trait", + "tinystr", "writeable", "yoke", "zerofrom", @@ -2708,6 +2641,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -2731,9 +2670,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", @@ -2749,8 +2688,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.0", - "zune-jpeg 0.5.8", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2760,7 +2699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -2777,9 +2716,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -2814,7 +2753,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -2865,15 +2804,15 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.2" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.13.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] @@ -2889,34 +2828,40 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.17" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde_core", + "serde", ] [[package]] name = "jiff-static" -version = "0.2.17" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -2929,9 +2874,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" dependencies = [ "once_cell", "wasm-bindgen", @@ -2945,8 +2890,15 @@ checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", "libloading", + "pkg-config", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kurbo" version = "0.11.3" @@ -2972,9 +2924,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.8", -] [[package]] name = "leak" @@ -2991,6 +2940,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -2999,9 +2954,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libfuzzer-sys" @@ -3043,20 +2998,20 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall 0.6.0", + "redox_syscall 0.5.18", ] [[package]] name = "libz-sys" -version = "1.1.23" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" dependencies = [ "cc", "libc", @@ -3064,6 +3019,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3078,9 +3039,15 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -3149,9 +3116,9 @@ dependencies = [ [[package]] name = "lyon_geom" -version = "1.0.18" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e260b6de923e6e47adfedf6243013a7a874684165a6a277594ee3906021b2343" +checksum = "4e16770d760c7848b0c1c2d209101e408207a65168109509f8483837a36cf2e7" dependencies = [ "arrayvec", "euclid", @@ -3231,9 +3198,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" dependencies = [ "libc", ] @@ -3262,6 +3229,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -3294,17 +3276,11 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mint" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" - [[package]] name = "mio" -version = "1.1.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "wasi", @@ -3313,9 +3289,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" dependencies = [ "num-traits", "pxfm", @@ -3323,25 +3299,47 @@ dependencies = [ [[package]] name = "naga" -version = "25.0.1" +version = "24.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.8.0", "bitflags 2.10.0", "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.15.5", + "codespan-reporting 0.11.1", "hexf-parse", "indexmap", "log", + "rustc-hash 1.1.0", + "spirv", + "strum 0.26.3", + "termcolor", + "thiserror 2.0.17", + "unicode-xid", +] + +[[package]] +name = "naga" +version = "29.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b4372fed0bd362d646d01b6926df0e837859ccc522fed720c395e0460f29c8" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting 0.13.1", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", "num-traits", "once_cell", "rustc-hash 1.1.0", - "spirv", - "strum 0.26.3", "thiserror 2.0.17", "unicode-ident", ] @@ -3355,6 +3353,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3383,7 +3390,6 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -3396,30 +3402,12 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "noop_proc_macro" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" -[[package]] -name = "ntapi" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" -dependencies = [ - "winapi", -] - [[package]] name = "num" version = "0.4.3" @@ -3446,16 +3434,16 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.6" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" dependencies = [ - "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "once_cell", + "rand 0.9.2", "serde", "smallvec", "zeroize", @@ -3478,7 +3466,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3539,18 +3527,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", - "objc_exception", -] - -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", ] [[package]] @@ -3562,19 +3538,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3600,7 +3563,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", "objc2", - "objc2-core-foundation", ] [[package]] @@ -3617,59 +3579,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", -] - -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - [[package]] name = "object" version = "0.37.3" @@ -3687,18 +3596,18 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.2" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "oo7" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes", - "ashpd 0.12.0", + "ashpd 0.13.9", "async-fs", "async-io", "async-lock", @@ -3709,15 +3618,15 @@ dependencies = [ "endi", "futures-lite 2.6.1", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.2", "hkdf", "hmac", "md-5", "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.2", "serde", + "serde_bytes", "sha2", "subtle", "zbus", @@ -3728,9 +3637,9 @@ dependencies = [ [[package]] name = "open" -version = "5.3.3" +version = "5.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" dependencies = [ "is-wsl", "libc", @@ -3749,6 +3658,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3794,12 +3712,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3864,7 +3776,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -3932,7 +3844,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -3943,10 +3855,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" [[package]] -name = "portable-atomic" -version = "1.12.0" +name = "pollster" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" @@ -3969,16 +3887,16 @@ dependencies = [ "log", "parking_lot", "pin-project", - "pollster", + "pollster 0.2.5", "static_assertions", "thiserror 1.0.69", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -3992,6 +3910,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -4009,7 +3933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn", ] [[package]] @@ -4018,7 +3942,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.23.7", ] [[package]] @@ -4040,14 +3964,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -4068,14 +3992,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.111", + "syn", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.10.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", ] [[package]] name = "psm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" dependencies = [ "ar_archive_writer", "cc", @@ -4083,9 +4026,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.27" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" dependencies = [ "num-traits", ] @@ -4099,21 +4042,18 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -4180,9 +4120,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -4193,6 +4133,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -4252,6 +4198,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "rangemap" version = "1.7.1" @@ -4260,21 +4221,19 @@ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "rav1e" -version = "0.8.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" dependencies = [ - "aligned-vec", "arbitrary", "arg_enum_proc_macro", "arrayvec", - "av-scenechange", "av1-grain", "bitstream-io", "built", "cfg-if", "interpolate_name", - "itertools 0.14.0", + "itertools 0.12.1", "libc", "libfuzzer-sys", "log", @@ -4283,26 +4242,28 @@ dependencies = [ "noop_proc_macro", "num-derive", "num-traits", + "once_cell", "paste", "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", + "rand 0.8.5", + "rand_chacha 0.3.1", "simd_helpers", - "thiserror 2.0.17", + "system-deps", + "thiserror 1.0.69", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" -version = "0.12.0" +version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -4314,18 +4275,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "raw-window-metal" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" -dependencies = [ - "cocoa 0.25.0", - "core-graphics 0.23.2", - "objc", - "raw-window-handle", -] - [[package]] name = "rayon" version = "1.11.0" @@ -4353,7 +4302,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", - "font-types", + "font-types 0.10.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.1", ] [[package]] @@ -4374,15 +4334,6 @@ dependencies = [ "bitflags 2.10.0", ] -[[package]] -name = "redox_syscall" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -4411,7 +4362,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -4443,6 +4394,12 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "resvg" version = "0.45.1" @@ -4488,9 +4445,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rust-embed" -version = "8.9.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -4499,22 +4456,22 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.9.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.111", + "syn", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.9.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" dependencies = [ "globset", "sha2", @@ -4563,9 +4520,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags 2.10.0", "errno", @@ -4576,9 +4533,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" dependencies = [ "once_cell", "ring", @@ -4611,9 +4568,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", "zeroize", @@ -4621,9 +4578,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ "ring", "rustls-pki-types", @@ -4637,20 +4594,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "rustybuzz" -version = "0.14.1" +name = "rusty-fork" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "libm", - "smallvec", - "ttf-parser 0.21.1", - "unicode-bidi-mirroring 0.2.0", - "unicode-ccc 0.2.0", - "unicode-properties", - "unicode-script", + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", ] [[package]] @@ -4664,18 +4616,18 @@ dependencies = [ "core_maths", "log", "smallvec", - "ttf-parser 0.25.1", - "unicode-bidi-mirroring 0.4.0", - "unicode-ccc 0.4.0", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", "unicode-properties", "unicode-script", ] [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "same-file" @@ -4697,9 +4649,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", "indexmap", @@ -4711,14 +4663,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.111", + "syn", ] [[package]] @@ -4733,29 +4685,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "screencapturekit" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" -dependencies = [ - "screencapturekit-sys", -] - -[[package]] -name = "screencapturekit-sys" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" -dependencies = [ - "block", - "dispatch", - "objc", - "objc-foundation", - "objc_id", - "once_cell", -] - [[package]] name = "seahash" version = "4.1.0" @@ -4787,9 +4716,9 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" @@ -4811,6 +4740,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4828,7 +4767,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -4839,30 +4778,30 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "serde_fmt" -version = "1.1.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4" dependencies = [ - "serde_core", + "serde", ] [[package]] name = "serde_json" -version = "1.0.147" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "indexmap", "itoa", "memchr", + "ryu", "serde", "serde_core", - "zmij", ] [[package]] @@ -4886,7 +4825,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -4900,9 +4839,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ "serde_core", ] @@ -4944,19 +4883,18 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.8" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ - "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "simd_helpers" @@ -4989,7 +4927,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", ] [[package]] @@ -5000,9 +4948,9 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slotmap" -version = "1.1.1" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" dependencies = [ "version_check", ] @@ -5032,9 +4980,9 @@ dependencies = [ [[package]] name = "smol_str" -version = "0.2.2" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" [[package]] name = "socket2" @@ -5081,9 +5029,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" dependencies = [ "cc", "cfg-if", @@ -5110,7 +5058,7 @@ checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" dependencies = [ "proc-macro-error2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -5156,7 +5104,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn", ] [[package]] @@ -5168,7 +5116,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -5179,15 +5127,15 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sval" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502b8906c4736190684646827fbab1e954357dfe541013bbd7994d033d53a1ca" +checksum = "d94c4464e595f0284970fd9c7e9013804d035d4a61ab74b113242c874c05814d" [[package]] name = "sval_buffer" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4b854348b15b6c441bdd27ce9053569b016a0723eab2d015b1fd8e6abe4f708" +checksum = "a0f46e34b20a39e6a2bf02b926983149b3af6609fd1ee8a6e63f6f340f3e2164" dependencies = [ "sval", "sval_ref", @@ -5195,18 +5143,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bd9e8b74410ddad37c6962587c5f9801a2caadba9e11f3f916ee3f31ae4a1f" +checksum = "03d0970e53c92ab5381d3b2db1828da8af945954d4234225f6dd9c3afbcef3f5" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe17b8deb33a9441280b4266c2d257e166bafbaea6e66b4b34ca139c91766d9" +checksum = "43e5e6e1613e1e7fc2e1a9fdd709622e54c122ceb067a60d170d75efd491a839" dependencies = [ "itoa", "ryu", @@ -5215,9 +5163,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854addb048a5bafb1f496c98e0ab5b9b581c3843f03ca07c034ae110d3b7c623" +checksum = "aec382f7bfa6e367b23c9611f129b94eb7daaf3d8fae45a8d0a0211eb4d4c8e6" dependencies = [ "itoa", "ryu", @@ -5226,9 +5174,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96cf068f482108ff44ae8013477cb047a1665d5f1a635ad7cf79582c1845dce9" +checksum = "3049d0f99ce6297f8f7d9953b35a0103b7584d8f638de40e64edb7105fa578ae" dependencies = [ "sval", "sval_buffer", @@ -5237,18 +5185,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed02126365ffe5ab8faa0abd9be54fbe68d03d607cd623725b0a71541f8aaa6f" +checksum = "f88913e77506085c0a8bf6912bb6558591a960faf5317df6c1d9b227224ca6e1" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.16.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a263383c6aa2076c4ef6011d3bae1b356edf6ea2613e3d8e8ebaa7b57dd707d5" +checksum = "f579fd7254f4be6cd7b450034f856b78523404655848789c451bacc6aa8b387d" dependencies = [ "serde_core", "sval", @@ -5277,27 +5225,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" dependencies = [ - "skrifa", + "skrifa 0.37.0", "yazi", "zeno", ] [[package]] name = "syn" -version = "1.0.109" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -5321,7 +5258,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -5333,20 +5270,6 @@ dependencies = [ "libc", ] -[[package]] -name = "sysinfo" -version = "0.31.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "rayon", - "windows 0.57.0", -] - [[package]] name = "system-configuration" version = "0.6.1" @@ -5368,6 +5291,19 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + [[package]] name = "taffy" version = "0.9.0" @@ -5387,27 +5323,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" [[package]] -name = "tao-core-video-sys" -version = "0.2.0" +name = "target-lexicon" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "objc", -] +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand 2.3.0", "getrandom 0.3.4", "once_cell", - "rustix 1.1.3", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -5457,7 +5387,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -5468,7 +5398,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -5480,9 +5410,9 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -5522,9 +5452,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -5561,9 +5491,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ "rustls", "tokio", @@ -5583,9 +5513,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -5608,14 +5538,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", "toml_parser", "toml_writer", "winnow", @@ -5632,9 +5562,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] @@ -5655,21 +5585,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 0.7.3", "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] @@ -5682,9 +5612,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tower" @@ -5715,9 +5645,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.44" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -5733,14 +5663,14 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "tracing-core" -version = "0.1.36" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", ] @@ -5751,18 +5681,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" - -[[package]] -name = "ttf-parser" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" - [[package]] name = "ttf-parser" version = "0.25.1" @@ -5795,6 +5713,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.8.1" @@ -5807,24 +5731,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" - [[package]] name = "unicode-bidi-mirroring" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" -[[package]] -name = "unicode-ccc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" - [[package]] name = "unicode-ccc" version = "0.4.0" @@ -5833,9 +5745,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -5845,9 +5757,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-properties" -version = "0.1.4" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-script" @@ -5867,12 +5779,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -5900,13 +5824,13 @@ dependencies = [ "base64", "data-url", "flate2", - "fontdb 0.23.0", + "fontdb", "imagesize", "kurbo", "log", "pico-args", "roxmltree", - "rustybuzz 0.20.1", + "rustybuzz", "simplecss", "siphasher", "strict-num", @@ -5938,13 +5862,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde_core", + "serde", "sha1_smol", "wasm-bindgen", ] @@ -6002,6 +5926,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[package]] name = "version_check" version = "0.9.5" @@ -6028,6 +5958,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -6065,14 +6004,23 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" dependencies = [ "cfg-if", "once_cell", @@ -6083,11 +6031,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -6096,9 +6045,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6106,26 +6055,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -6139,6 +6110,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.11" @@ -6147,7 +6130,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.2", "scoped-tls", "smallvec", "wayland-sys", @@ -6160,7 +6143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.10.0", - "rustix 1.1.3", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] @@ -6171,23 +6154,11 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.2", "wayland-client", "xcursor", ] -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - [[package]] name = "wayland-protocols" version = "0.32.9" @@ -6202,14 +6173,14 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.2.0" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.10.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-scanner", ] @@ -6222,7 +6193,7 @@ dependencies = [ "bitflags 2.10.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "wayland-scanner", ] @@ -6233,7 +6204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", - "quick-xml 0.37.5", + "quick-xml", "quote", ] @@ -6251,9 +6222,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" dependencies = [ "js-sys", "wasm-bindgen", @@ -6271,9 +6242,118 @@ dependencies = [ [[package]] name = "weezl" -version = "0.1.12" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.10.0", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga 24.0.0", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec 0.8.0", + "bitflags 2.10.0", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga 24.0.0", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.17", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.8.0", + "bitflags 2.10.0", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types 0.1.3", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal 0.31.0", + "naga 24.0.0", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "log", + "web-sys", +] [[package]] name = "which" @@ -6320,11 +6400,11 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.57.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" dependencies = [ - "windows-core 0.57.0", + "windows-core 0.58.0", "windows-targets 0.52.6", ] @@ -6341,19 +6421,6 @@ dependencies = [ "windows-numerics", ] -[[package]] -name = "windows-capture" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" -dependencies = [ - "parking_lot", - "rayon", - "thiserror 2.0.17", - "windows 0.61.3", - "windows-future", -] - [[package]] name = "windows-collections" version = "0.2.0" @@ -6365,13 +6432,14 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.57.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", "windows-targets 0.52.6", ] @@ -6401,13 +6469,13 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.57.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -6418,18 +6486,18 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "windows-interface" -version = "0.57.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -6440,7 +6508,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -6489,9 +6557,9 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ "windows-targets 0.52.6", ] @@ -6505,6 +6573,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.3.1" @@ -6765,9 +6843,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -6804,21 +6882,99 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] -name = "writeable" -version = "0.6.2" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "x11" -version = "2.21.0" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "libc", - "pkg-config", + "anyhow", + "heck 0.5.0", + "wit-parser", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "x11-clipboard" version = "0.9.3" @@ -6838,7 +6994,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "rustix 1.1.3", + "rustix 1.1.2", "x11rb-protocol", "xcursor", ] @@ -6858,18 +7014,6 @@ dependencies = [ "libc", ] -[[package]] -name = "xcb" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f07c123b796139bfe0603e654eaf08e132e52387ba95b252c78bad3640ba37ea" -dependencies = [ - "bitflags 1.3.2", - "libc", - "quick-xml 0.30.0", - "x11", -] - [[package]] name = "xcursor" version = "0.3.10" @@ -6910,18 +7054,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xmlwriter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yansi" version = "1.0.1" @@ -6947,10 +7091,11 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ + "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -6958,21 +7103,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] [[package]] name = "zbus" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -6988,8 +7133,9 @@ dependencies = [ "futures-core", "futures-lite 2.6.1", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix 1.1.2", "serde", "serde_repr", "tracing", @@ -7004,14 +7150,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn", "zbus_names", "zvariant", "zvariant_utils", @@ -7019,12 +7165,11 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", "winnow", "zvariant", ] @@ -7051,7 +7196,7 @@ dependencies = [ "bitflags 2.10.0", "byteorder", "core-foundation 0.10.0", - "core-graphics 0.24.0", + "core-graphics", "core-text", "dirs 5.0.1", "dwrote", @@ -7117,27 +7262,6 @@ dependencies = [ "windows-registry 0.4.0", ] -[[package]] -name = "zed-scap" -version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" -dependencies = [ - "anyhow", - "cocoa 0.25.0", - "core-graphics-helmer-fork", - "log", - "objc", - "rand 0.8.5", - "screencapturekit", - "screencapturekit-sys", - "sysinfo", - "tao-core-video-sys", - "windows 0.61.3", - "windows-capture", - "x11", - "xcb", -] - [[package]] name = "zed-xim" version = "0.4.0-zed" @@ -7159,22 +7283,22 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] @@ -7194,7 +7318,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", "synstructure", ] @@ -7215,14 +7339,14 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" dependencies = [ "displaydoc", "yoke", @@ -7231,9 +7355,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -7242,33 +7366,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn", ] -[[package]] -name = "zmij" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0095ecd462946aa3927d9297b63ef82fb9a5316d7a37d134eeb36e58228615a" - [[package]] name = "zune-core" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" -[[package]] -name = "zune-core" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" - [[package]] name = "zune-inflate" version = "0.2.54" @@ -7284,27 +7396,19 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" -dependencies = [ - "zune-core 0.5.0", + "zune-core", ] [[package]] name = "zvariant" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", + "serde_bytes", "url", "winnow", "zvariant_derive", @@ -7313,26 +7417,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.111", + "syn", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 3599a2d902..a204aa6488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,13 @@ +[workspace] +members = [ + ".", + "tooling/macros", +] +default-members = [ + ".", +] +resolver = "3" + [package] name = "gpui-ce" version = "0.3.3" @@ -17,31 +27,19 @@ autoexamples = false default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", + "backtrace", "collections/test-support", "util/test-support", "http_client/test-support", "wayland", "x11", ] -inspector = ["gpui_macros/inspector"] +inspector = ["gpui-ce-macros/inspector"] leak-detection = ["backtrace"] runtime_shaders = [] -macos-blade = [ - "blade-graphics", - "blade-macros", - "blade-util", - "bytemuck", - "objc2", - "objc2-metal", -] wayland = [ "bitflags", - "blade-graphics", - "blade-macros", - "blade-util", - "bytemuck", "ashpd/wayland", - "cosmic-text", "font-kit", "calloop-wayland-source", "wayland-backend", @@ -55,12 +53,7 @@ wayland = [ "open", ] x11 = [ - "blade-graphics", - "blade-macros", - "blade-util", - "bytemuck", "ashpd", - "cosmic-text", "font-kit", "as-raw-xcb-connection", "x11rb", @@ -69,10 +62,6 @@ x11 = [ "x11-clipboard", "filedescriptor", "open", - "scap?/x11", -] -screen-capture = [ - "scap", ] windows-manifest = [] @@ -85,18 +74,14 @@ doctest = false anyhow = "1.0.86" async-task = "4.7" backtrace = { version = "0.3", optional = true } -bitflags = { version = "2.6.0", optional = true } -blade-graphics = { version = "0.7.0", optional = true } -blade-macros = { version = "0.3.0", optional = true } -blade-util = { version = "0.3.0", optional = true } -bytemuck = { version = "1", optional = true } circular-buffer = "1.0" collections = { package = "gpui_collections", version = "0.2.2" } ctor = "0.4.0" derive_more = "0.99.17" etagere = "0.2" futures = "0.3" -gpui_macros = { package = "gpui-macros", version = "0.2.2" } +futures-concurrency = "7" +gpui-ce-macros = { path = "./tooling/macros", features = ["inspector"] } http_client = { package = "gpui_http_client", version = "0.2.2" } image = "0.25.1" inventory = "0.3.19" @@ -110,6 +95,7 @@ parking_lot = "0.12.1" pin-project = "1.1.10" postage = { version = "0.5", features = ["futures-traits"] } profiling = "1" +proptest = "1" rand = "0.9" raw-window-handle = "0.6" refineable = { package = "gpui_refineable", version = "0.2.2" } @@ -135,8 +121,13 @@ thiserror = "2.0.12" util = { package = "gpui_util", version = "0.2.2" } util_macros = { package = "gpui_util_macros", version = "0.2.2" } usvg = { version = "0.45.0", default-features = false } +url = "2" uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } waker-fn = "1.2.0" +web-time = "1" +async-channel = "2" +pollster = "0.4" +chrono = "0.4" [target.'cfg(target_os = "macos")'.dependencies] block = "0.1" @@ -147,11 +138,11 @@ core-foundation-sys = "0.8.6" core-graphics = "0.24" core-text = "21" core-video = { version = "0.4.3", features = ["metal"] } +dispatch2 = "0.3.1" flume = "0.11" font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", optional = true } foreign-types = "0.5" mach2 = "0.5" -media = { package = "gpui_media", version = "0.2.2" } metal = "0.29" objc = "0.2" objc2 = { version = "0.6", optional = true } @@ -160,22 +151,18 @@ objc2-metal = { version = "0.3", optional = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies] pathfinder_geometry = "0.5" -[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies] -scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed", optional = true } - [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] flume = "0.11" -oo7 = { version = "0.5.0", default-features = false, features = [ +oo7 = { version = "0.6", default-features = false, features = [ "async-std", "native_crypto", ] } +wgpu = "24" +cosmic-text = "0.17.0" +swash = "0.2.6" +bytemuck = "1" ashpd = { version = "0.11", default-features = false, features = ["async-std"], optional = true } -blade-graphics = { version = "0.7.0", optional = true } -blade-macros = { version = "0.3.0", optional = true } -blade-util = { version = "0.3.0", optional = true } -bytemuck = { version = "1", optional = true } -cosmic-text = { version = "0.14.0", optional = true } font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", features = [ "source-fontconfig-dlopen", ], optional = true } @@ -189,14 +176,14 @@ wayland-backend = { version = "0.3.3", features = [ "client_system", "dlopen", ], optional = true } -wayland-client = { version = "0.31.2", optional = true } -wayland-cursor = { version = "0.31.1", optional = true } -wayland-protocols = { version = "0.31.2", features = [ +wayland-client = { version = "0.31.11", optional = true } +wayland-cursor = { version = "0.31.11", optional = true } +wayland-protocols = { version = "0.32.9", features = [ "client", "staging", "unstable", ], optional = true } -wayland-protocols-plasma = { version = "0.2.0", features = [ +wayland-protocols-plasma = { version = "0.3.9", features = [ "client", ], optional = true } wayland-protocols-wlr = { version = "0.3.9", features = [ @@ -212,6 +199,7 @@ x11rb = { version = "0.13.1", features = [ "cursor", "resource_manager", "sync", + "dri3", ], optional = true } xkbcommon = { version = "0.8.0", features = [ "wayland", @@ -222,6 +210,7 @@ xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881 "x11rb-client", ], package = "zed-xim", version = "0.4.0-zed", optional = true } x11-clipboard = { version = "0.9.3", optional = true } +bitflags = { version = "2.6.0", optional = true } [target.'cfg(target_os = "windows")'.dependencies] flume = "0.11" @@ -285,6 +274,7 @@ env_logger = "0.11" http_client = { package = "gpui_http_client", version = "0.2.2", features = ["test-support"] } lyon = { version = "1.0", features = ["extra"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } +proptest = "1" rand = "0.9" unicode-segmentation = "1.10" util = { package = "gpui_util", version = "0.2.2", features = ["test-support"] } @@ -294,15 +284,10 @@ embed-resource = "3.0" windows-registry = "0.5" [target.'cfg(target_os = "macos")'.build-dependencies] -bindgen = "0.71" cbindgen = { version = "0.28.0", default-features = false } -naga = { version = "25.0", features = ["wgsl-in"] } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.build-dependencies] -naga = { version = "25.0", features = ["wgsl-in"] } - -[patch.crates-io] -calloop = { git = "https://github.com/zed-industries/calloop" } +naga = { version = "29.0", features = ["wgsl-in"] } # ============================================================================ # Learn Examples - Educational examples for learning GPUI @@ -474,3 +459,4 @@ single_range_in_vec_init = "allow" too_many_arguments = "allow" large_enum_variant = "allow" nonminimal_bool = "allow" + diff --git a/LICENSE-APACHE b/LICENSE-APACHE deleted file mode 100644 index 461a0fe5ba..0000000000 --- a/LICENSE-APACHE +++ /dev/null @@ -1,222 +0,0 @@ -Copyright 2022 - 2025 Zed Industries, Inc. - - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - - http://www.apache.org/licenses/LICENSE-2.0 - - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - - - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - - 1. Definitions. - - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - - END OF TERMS AND CONDITIONS diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000000..3e2f4b3866 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,170 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ diff --git a/README.md b/README.md index 35527a5bb3..ab0e448753 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,4 @@ In addition to the systems above, GPUI provides a range of smaller services that - The `[gpui::test]` macro provides a convenient way to write tests for your GPUI applications. Tests also have their own kind of context, a `TestAppContext` which provides ways of simulating common platform input. See `app::test_context` and `test` modules for more details. Currently, the best way to learn about these APIs is to read the Zed source code or drop a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). + diff --git a/build.rs b/build.rs index c7ae7ac9f2..fb31fc5f9b 100644 --- a/build.rs +++ b/build.rs @@ -1,59 +1,25 @@ #![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] -#![cfg_attr(any(not(target_os = "macos"), feature = "macos-blade"), allow(unused))] - -//TODO: consider generating shader code for WGSL -//TODO: deprecate "runtime-shaders" and "macos-blade" use std::env; fn main() { - let target = env::var("CARGO_CFG_TARGET_OS"); println!("cargo::rustc-check-cfg=cfg(gles)"); - #[cfg(any( - not(any(target_os = "macos", target_os = "windows")), - all(target_os = "macos", feature = "macos-blade") - ))] - check_wgsl_shaders(); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - match target.as_deref() { - Ok("macos") => { + match target_os.as_str() { + "macos" => { #[cfg(target_os = "macos")] macos::build(); } - Ok("windows") => { + "windows" => { #[cfg(target_os = "windows")] windows::build(); } - _ => (), - }; -} - -#[cfg(any( - not(any(target_os = "macos", target_os = "windows")), - all(target_os = "macos", feature = "macos-blade") -))] -fn check_wgsl_shaders() { - use std::path::PathBuf; - use std::process; - use std::str::FromStr; - - let shader_source_path = "./src/platform/blade/shaders.wgsl"; - let shader_path = PathBuf::from_str(shader_source_path).unwrap(); - println!("cargo:rerun-if-changed={}", &shader_path.display()); - - let shader_source = std::fs::read_to_string(&shader_path).unwrap(); - - match naga::front::wgsl::parse_str(&shader_source) { - Ok(_) => { - // All clear - } - Err(e) => { - println!("cargo::error=WGSL shader compilation failed:\n{}", e); - process::exit(1); - } + _ => {} } } + #[cfg(target_os = "macos")] mod macos { use std::{ @@ -63,55 +29,19 @@ mod macos { use cbindgen::Config; - pub(super) fn build() { - generate_dispatch_bindings(); - #[cfg(not(feature = "macos-blade"))] - { - let header_path = generate_shader_bindings(); + pub fn build() { + let header_path = generate_shader_bindings(); - #[cfg(feature = "runtime_shaders")] - emit_stitched_shaders(&header_path); - #[cfg(not(feature = "runtime_shaders"))] - compile_metal_shaders(&header_path); - } - } - - fn generate_dispatch_bindings() { - println!("cargo:rustc-link-lib=framework=System"); - - let bindings = bindgen::Builder::default() - .header("src/platform/mac/dispatch.h") - .allowlist_var("_dispatch_main_q") - .allowlist_var("_dispatch_source_type_data_add") - .allowlist_var("DISPATCH_QUEUE_PRIORITY_HIGH") - .allowlist_var("DISPATCH_QUEUE_PRIORITY_DEFAULT") - .allowlist_var("DISPATCH_QUEUE_PRIORITY_LOW") - .allowlist_var("DISPATCH_TIME_NOW") - .allowlist_function("dispatch_get_global_queue") - .allowlist_function("dispatch_async_f") - .allowlist_function("dispatch_after_f") - .allowlist_function("dispatch_time") - .allowlist_function("dispatch_source_merge_data") - .allowlist_function("dispatch_source_create") - .allowlist_function("dispatch_source_set_event_handler_f") - .allowlist_function("dispatch_resume") - .allowlist_function("dispatch_suspend") - .allowlist_function("dispatch_source_cancel") - .allowlist_function("dispatch_set_context") - .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) - .layout_tests(false) - .generate() - .expect("unable to generate bindings"); - - let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); - bindings - .write_to_file(out_path.join("dispatch_sys.rs")) - .expect("couldn't write dispatch bindings"); + #[cfg(feature = "runtime_shaders")] + emit_stitched_shaders(&header_path); + #[cfg(not(feature = "runtime_shaders"))] + compile_metal_shaders(&header_path); } fn generate_shader_bindings() -> PathBuf { let output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("scene.h"); let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let mut config = Config { include_guard: Some("SCENE_H".into()), language: cbindgen::Language::C, @@ -152,6 +82,7 @@ mod macos { let mut builder = cbindgen::Builder::new(); + // Source files that define types used in shaders let src_paths = [ crate_dir.join("src/scene.rs"), crate_dir.join("src/geometry.rs"), @@ -160,7 +91,8 @@ mod macos { crate_dir.join("src/platform.rs"), crate_dir.join("src/platform/mac/metal_renderer.rs"), ]; - for src_path in src_paths { + + for src_path in &src_paths { println!("cargo:rerun-if-changed={}", src_path.display()); builder = builder.with_src(src_path); } @@ -178,7 +110,6 @@ mod macos { /// so that it is self-contained. #[cfg(feature = "runtime_shaders")] fn emit_stitched_shaders(header_path: &Path) { - use std::str::FromStr; fn stitch_header(header: &Path, shader_path: &Path) -> std::io::Result { let header_contents = std::fs::read_to_string(header)?; let shader_contents = std::fs::read_to_string(shader_path)?; @@ -189,7 +120,7 @@ mod macos { Ok(out_path) } let shader_source_path = "./src/platform/mac/shaders.metal"; - let shader_path = PathBuf::from_str(shader_source_path).unwrap(); + let shader_path = PathBuf::from(shader_source_path); stitch_header(header_path, &shader_path).unwrap(); println!("cargo:rerun-if-changed={}", &shader_source_path); } @@ -249,20 +180,10 @@ mod macos { #[cfg(target_os = "windows")] mod windows { - use std::{ - ffi::OsString, - fs, - io::Write, - path::{Path, PathBuf}, - process::{self, Command}, - }; - - pub(super) fn build() { - // Compile HLSL shaders + pub fn build() { #[cfg(not(debug_assertions))] - compile_shaders(); + shader_compilation::compile_shaders(); - // Embed the Windows manifest and resource file #[cfg(feature = "windows-manifest")] embed_resource(); } @@ -278,222 +199,223 @@ mod windows { .unwrap(); } - /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler. - fn compile_shaders() { - let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) - .join("src/platform/windows/shaders.hlsl"); - let out_dir = std::env::var("OUT_DIR").unwrap(); + #[cfg(not(debug_assertions))] + mod shader_compilation { + use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::{self, Command}, + }; - println!("cargo:rerun-if-changed={}", shader_path.display()); - - // Check if fxc.exe is available - let fxc_path = find_fxc_compiler(); - - // Define all modules - let modules = [ - "quad", - "shadow", - "path_rasterization", - "path_sprite", - "underline", - "monochrome_sprite", - "polychrome_sprite", - ]; - - let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); - if Path::new(&rust_binding_path).exists() { - fs::remove_file(&rust_binding_path) - .expect("Failed to remove existing Rust binding file"); - } - for module in modules { - compile_shader_for_module( - module, - &out_dir, - &fxc_path, - shader_path.to_str().unwrap(), - &rust_binding_path, - ); - } - - { + pub fn compile_shaders() { let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) - .join("src/platform/windows/color_text_raster.hlsl"); - compile_shader_for_module( - "emoji_rasterization", - &out_dir, - &fxc_path, - shader_path.to_str().unwrap(), - &rust_binding_path, - ); - } - } + .join("src/platform/windows/shaders.hlsl"); + let out_dir = std::env::var("OUT_DIR").unwrap(); - /// Locate `binary` in the newest installed Windows SDK. - pub fn find_latest_windows_sdk_binary( - binary: &str, - ) -> Result, Box> { - let key = windows_registry::LOCAL_MACHINE - .open("SOFTWARE\\WOW6432Node\\Microsoft\\Microsoft SDKs\\Windows\\v10.0")?; + println!("cargo:rerun-if-changed={}", shader_path.display()); - let install_folder: String = key.get_string("InstallationFolder")?; // "C:\Program Files (x86)\Windows Kits\10\" - let install_folder_bin = Path::new(&install_folder).join("bin"); + let fxc_path = find_fxc_compiler(); - let mut versions: Vec<_> = std::fs::read_dir(&install_folder_bin)? - .flatten() - .filter(|entry| entry.path().is_dir()) - .filter_map(|entry| entry.file_name().into_string().ok()) - .collect(); + let modules = [ + "quad", + "shadow", + "path_rasterization", + "path_sprite", + "underline", + "monochrome_sprite", + "subpixel_sprite", + "polychrome_sprite", + ]; - versions.sort_by_key(|s| { - s.split('.') - .filter_map(|p| p.parse().ok()) - .collect::>() - }); - - let arch = match std::env::consts::ARCH { - "x86_64" => "x64", - "aarch64" => "arm64", - _ => Err(format!( - "Unsupported architecture: {}", - std::env::consts::ARCH - ))?, - }; - - if let Some(highest_version) = versions.last() { - return Ok(Some( - install_folder_bin - .join(highest_version) - .join(arch) - .join(binary), - )); - } - - Ok(None) - } - - /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler. - fn find_fxc_compiler() -> String { - // Check environment variable - if let Ok(path) = std::env::var("GPUI_FXC_PATH") - && Path::new(&path).exists() - { - return path; - } - - // Try to find in PATH - // NOTE: This has to be `where.exe` on Windows, not `where`, it must be ended with `.exe` - if let Ok(output) = std::process::Command::new("where.exe") - .arg("fxc.exe") - .output() - && output.status.success() - { - let path = String::from_utf8_lossy(&output.stdout); - return path.trim().to_string(); - } - - if let Ok(Some(path)) = find_latest_windows_sdk_binary("fxc.exe") { - return path.to_string_lossy().into_owned(); - } - - panic!("Failed to find fxc.exe"); - } - - fn compile_shader_for_module( - module: &str, - out_dir: &str, - fxc_path: &str, - shader_path: &str, - rust_binding_path: &str, - ) { - // Compile vertex shader - let output_file = format!("{}/{}_vs.h", out_dir, module); - let const_name = format!("{}_VERTEX_BYTES", module.to_uppercase()); - compile_shader_impl( - fxc_path, - &format!("{module}_vertex"), - &output_file, - &const_name, - shader_path, - "vs_4_1", - ); - generate_rust_binding(&const_name, &output_file, rust_binding_path); - - // Compile fragment shader - let output_file = format!("{}/{}_ps.h", out_dir, module); - let const_name = format!("{}_FRAGMENT_BYTES", module.to_uppercase()); - compile_shader_impl( - fxc_path, - &format!("{module}_fragment"), - &output_file, - &const_name, - shader_path, - "ps_4_1", - ); - generate_rust_binding(&const_name, &output_file, rust_binding_path); - } - - fn compile_shader_impl( - fxc_path: &str, - entry_point: &str, - output_path: &str, - var_name: &str, - shader_path: &str, - target: &str, - ) { - let output = Command::new(fxc_path) - .args([ - "/T", - target, - "/E", - entry_point, - "/Fh", - output_path, - "/Vn", - var_name, - "/O3", - shader_path, - ]) - .output(); - - match output { - Ok(result) => { - if result.status.success() { - return; - } - println!( - "cargo::error=Shader compilation failed for {}:\n{}", - entry_point, - String::from_utf8_lossy(&result.stderr) + let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); + if Path::new(&rust_binding_path).exists() { + fs::remove_file(&rust_binding_path) + .expect("Failed to remove existing Rust binding file"); + } + for module in modules { + compile_shader_for_module( + module, + &out_dir, + &fxc_path, + shader_path.to_str().unwrap(), + &rust_binding_path, ); - process::exit(1); } - Err(e) => { - println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e); - process::exit(1); + + { + let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("src/platform/windows/color_text_raster.hlsl"); + compile_shader_for_module( + "emoji_rasterization", + &out_dir, + &fxc_path, + shader_path.to_str().unwrap(), + &rust_binding_path, + ); } } - } - fn generate_rust_binding(const_name: &str, head_file: &str, output_path: &str) { - let header_content = fs::read_to_string(head_file).expect("Failed to read header file"); - let const_definition = { - let global_var_start = header_content.find("const BYTE").unwrap(); - let global_var = &header_content[global_var_start..]; - let equal = global_var.find('=').unwrap(); - global_var[equal + 1..].trim() - }; - let rust_binding = format!( - "const {}: &[u8] = &{}\n", - const_name, - const_definition.replace('{', "[").replace('}', "]") - ); - let mut options = fs::OpenOptions::new() - .create(true) - .append(true) - .open(output_path) - .expect("Failed to open Rust binding file"); - options - .write_all(rust_binding.as_bytes()) - .expect("Failed to write Rust binding file"); + fn find_latest_windows_sdk_binary( + binary: &str, + ) -> Result, Box> { + let key = windows_registry::LOCAL_MACHINE + .open("SOFTWARE\\WOW6432Node\\Microsoft\\Microsoft SDKs\\Windows\\v10.0")?; + + let install_folder: String = key.get_string("InstallationFolder")?; + let install_folder_bin = Path::new(&install_folder).join("bin"); + + let mut versions: Vec<_> = std::fs::read_dir(&install_folder_bin)? + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect(); + + versions.sort_by_key(|s| { + s.split('.') + .filter_map(|p| p.parse().ok()) + .collect::>() + }); + + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => Err(format!( + "Unsupported architecture: {}", + std::env::consts::ARCH + ))?, + }; + + if let Some(highest_version) = versions.last() { + return Ok(Some( + install_folder_bin + .join(highest_version) + .join(arch) + .join(binary), + )); + } + + Ok(None) + } + + fn find_fxc_compiler() -> String { + if let Ok(path) = std::env::var("GPUI_FXC_PATH") + && Path::new(&path).exists() + { + return path; + } + + if let Ok(output) = std::process::Command::new("where.exe") + .arg("fxc.exe") + .output() + && output.status.success() + { + let path = String::from_utf8_lossy(&output.stdout); + return path.trim().to_string(); + } + + if let Ok(Some(path)) = find_latest_windows_sdk_binary("fxc.exe") { + return path.to_string_lossy().into_owned(); + } + + panic!("Failed to find fxc.exe"); + } + + fn compile_shader_for_module( + module: &str, + out_dir: &str, + fxc_path: &str, + shader_path: &str, + rust_binding_path: &str, + ) { + let output_file = format!("{}/{}_vs.h", out_dir, module); + let const_name = format!("{}_VERTEX_BYTES", module.to_uppercase()); + compile_shader_impl( + fxc_path, + &format!("{module}_vertex"), + &output_file, + &const_name, + shader_path, + "vs_4_1", + ); + generate_rust_binding(&const_name, &output_file, rust_binding_path); + + let output_file = format!("{}/{}_ps.h", out_dir, module); + let const_name = format!("{}_FRAGMENT_BYTES", module.to_uppercase()); + compile_shader_impl( + fxc_path, + &format!("{module}_fragment"), + &output_file, + &const_name, + shader_path, + "ps_4_1", + ); + generate_rust_binding(&const_name, &output_file, rust_binding_path); + } + + fn compile_shader_impl( + fxc_path: &str, + entry_point: &str, + output_path: &str, + var_name: &str, + shader_path: &str, + target: &str, + ) { + let output = Command::new(fxc_path) + .args([ + "/T", + target, + "/E", + entry_point, + "/Fh", + output_path, + "/Vn", + var_name, + "/O3", + shader_path, + ]) + .output(); + + match output { + Ok(result) => { + if result.status.success() { + return; + } + println!( + "cargo::error=Shader compilation failed for {}:\n{}", + entry_point, + String::from_utf8_lossy(&result.stderr) + ); + process::exit(1); + } + Err(e) => { + println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e); + process::exit(1); + } + } + } + + fn generate_rust_binding(const_name: &str, head_file: &str, output_path: &str) { + let header_content = fs::read_to_string(head_file).expect("Failed to read header file"); + let const_definition = { + let global_var_start = header_content.find("const BYTE").unwrap(); + let global_var = &header_content[global_var_start..]; + let equal = global_var.find('=').unwrap(); + global_var[equal + 1..].trim() + }; + let rust_binding = format!( + "const {}: &[u8] = &{}\n", + const_name, + const_definition.replace('{', "[").replace('}', "]") + ); + let mut options = fs::OpenOptions::new() + .create(true) + .append(true) + .open(output_path) + .expect("Failed to open Rust binding file"); + options + .write_all(rust_binding.as_bytes()) + .expect("Failed to write Rust binding file"); + } } } diff --git a/clippy.toml b/clippy.toml index 0ce7a6cd68..7977140657 100644 --- a/clippy.toml +++ b/clippy.toml @@ -21,3 +21,4 @@ disallowed-types = [ # { path = "indexmap::IndexSet", replacement = "collections::IndexSet" }, # { path = "indexmap::IndexMap", replacement = "collections::IndexMap" }, ] + diff --git a/examples/bench/shadow.rs b/examples/bench/shadow.rs index 352e29c042..a66e9f7889 100644 --- a/examples/bench/shadow.rs +++ b/examples/bench/shadow.rs @@ -1,6 +1,6 @@ use gpui::{ App, Application, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, - WindowOptions, div, hsla, point, prelude::*, px, relative, rgb, size, + WindowOptions, current_platform, div, hsla, point, prelude::*, px, relative, rgb, size, }; struct Shadow {} @@ -569,7 +569,7 @@ impl Render for Shadow { } fn main() { - Application::new().run(|cx: &mut App| { + Application::with_platform(current_platform(false)).run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(1000.0), px(800.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/animation.rs b/examples/learn/animation.rs index 6d1e5a17b2..8b89315bc2 100644 --- a/examples/learn/animation.rs +++ b/examples/learn/animation.rs @@ -13,10 +13,11 @@ mod example_prelude; use std::time::Duration; use anyhow::Result; +use gpui::colors::Colors; use gpui::{ - Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Colors, Context, Hsla, + Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Context, Hsla, SharedString, Transformation, Window, WindowBounds, WindowOptions, bounce, div, ease_in_out, - linear, percentage, prelude::*, px, size as gpui_size, svg, + linear, percentage, prelude::*, px, rgb, size as gpui_size, svg, }; struct Assets {} @@ -78,7 +79,7 @@ impl Render for AnimationExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Animations, easing, and transformations in GPUI"), ), ) @@ -99,8 +100,8 @@ impl Render for AnimationExample { } fn rotation_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; - let accent = colors.accent; + let text_muted = colors.disabled; + let accent = colors.selected; div() .flex() @@ -133,8 +134,8 @@ fn rotation_example(colors: &Colors) -> impl IntoElement { } fn bounce_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; - let success = colors.success; + let text_muted = colors.disabled; + let success = rgb(0x388e3c); div() .flex() @@ -167,8 +168,8 @@ fn bounce_example(colors: &Colors) -> impl IntoElement { } fn scale_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; - let warning = colors.warning; + let text_muted = colors.disabled; + let warning = rgb(0xf9a825); div() .flex() @@ -202,8 +203,8 @@ fn scale_example(colors: &Colors) -> impl IntoElement { } fn combined_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; - let error = colors.error; + let text_muted = colors.disabled; + let error = rgb(0xd32f2f); div() .flex() @@ -240,7 +241,7 @@ fn combined_example(colors: &Colors) -> impl IntoElement { } fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> impl IntoElement { - let surface: Hsla = colors.surface.into(); + let surface: Hsla = colors.container.into(); div() .flex() diff --git a/examples/learn/async_tasks.rs b/examples/learn/async_tasks.rs index 928b8c93c7..00900b0b3f 100644 --- a/examples/learn/async_tasks.rs +++ b/examples/learn/async_tasks.rs @@ -12,9 +12,10 @@ mod example_prelude; use std::time::Duration; +use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Colors, Context, Entity, Render, Task, Window, WindowBounds, - WindowOptions, div, prelude::*, px, size, + App, Application, Bounds, Context, Entity, Render, Task, Window, WindowBounds, WindowOptions, + div, prelude::*, px, rgb, size, }; // Example 1: Simple Foreground Task @@ -272,7 +273,7 @@ impl Render for AsyncTasksExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Spawning, background work, and task management"), ), ) @@ -348,9 +349,9 @@ impl Render for AsyncTasksExample { .child({ let is_running = cancellable.is_running(); let (bg, bg_hover) = if is_running { - (colors.error, colors.error_hover) + (rgb(0xd32f2f), rgb(0xe04545)) } else { - (colors.success, colors.success_hover) + (rgb(0x388e3c), rgb(0x43a047)) }; div() .id("cancel-btn") @@ -381,7 +382,7 @@ impl Render for AsyncTasksExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child(format!("Numbers: {:?}", return_demo.numbers)), ) .child( @@ -446,7 +447,7 @@ fn demo_section( .gap_3() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .border_1() .border_color(colors.border) .child( @@ -464,7 +465,7 @@ fn demo_section( .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child(description), ), ) @@ -477,10 +478,10 @@ fn button( label: &'static str, disabled: bool, ) -> gpui::Stateful { - let disabled_bg = colors.surface_hover; - let bg = colors.accent; - let bg_hover = colors.accent_hover; - let bg_active = colors.accent_active; + let disabled_bg = colors.selected; + let bg = colors.selected; + let bg_hover = colors.selected; + let bg_active = colors.selected; let text = colors.selected_text; div() @@ -507,7 +508,7 @@ fn secondary_button( id: impl Into, label: &'static str, ) -> gpui::Stateful { - let bg = colors.surface_hover; + let bg = colors.selected; let bg_hover = colors.border; let text = colors.text; @@ -526,8 +527,8 @@ fn secondary_button( fn progress_bar(colors: &Colors, progress: u32) -> impl IntoElement { let clamped = progress.min(100); - let bar_bg = colors.surface_hover; - let bar_fill = colors.success; + let bar_bg = colors.selected; + let bar_fill = rgb(0x388e3c); div() .h_2() diff --git a/examples/learn/creating_components.rs b/examples/learn/creating_components.rs index a8204869dc..6de5d31b2a 100644 --- a/examples/learn/creating_components.rs +++ b/examples/learn/creating_components.rs @@ -11,9 +11,10 @@ mod example_prelude; use example_prelude::init_example; +use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Colors, Context, Entity, IntoElement, Render, RenderOnce, Window, - WindowBounds, WindowOptions, div, prelude::*, px, size, + App, Application, Bounds, Context, Entity, IntoElement, Render, RenderOnce, Window, + WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, }; // ============================================================================ @@ -43,10 +44,10 @@ fn use_state_counter(colors: &Colors, window: &mut Window, cx: &mut App) -> impl let count = state.read(cx).count; - let error = colors.error; - let error_hover = colors.error_hover; - let success = colors.success; - let success_hover = colors.success_hover; + let error = rgb(0xd32f2f); + let error_hover = rgb(0xe04545); + let success = rgb(0x388e3c); + let success_hover = rgb(0x43a047); div() .id("use-state-counter") @@ -55,11 +56,11 @@ fn use_state_counter(colors: &Colors, window: &mut Window, cx: &mut App) -> impl .gap_2() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("use_state Counter"), ) .child( @@ -164,10 +165,10 @@ impl RenderOnceCounter { impl RenderOnce for RenderOnceCounter { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let colors = self.colors; - let error = colors.error; - let error_hover = colors.error_hover; - let success = colors.success; - let success_hover = colors.success_hover; + let error = rgb(0xd32f2f); + let error_hover = rgb(0xe04545); + let success = rgb(0x388e3c); + let success_hover = rgb(0x43a047); div() .id("render-once-counter") @@ -176,11 +177,11 @@ impl RenderOnce for RenderOnceCounter { .gap_2() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("RenderOnce Counter"), ) .child( @@ -269,10 +270,10 @@ impl RenderCounter { impl Render for RenderCounter { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = Colors::for_appearance(window); - let error = colors.error; - let error_hover = colors.error_hover; - let success = colors.success; - let success_hover = colors.success_hover; + let error = rgb(0xd32f2f); + let error_hover = rgb(0xe04545); + let success = rgb(0x388e3c); + let success_hover = rgb(0x43a047); div() .id("render-counter") @@ -281,11 +282,11 @@ impl Render for RenderCounter { .gap_2() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Render Counter"), ) .child( @@ -380,7 +381,7 @@ impl Render for CreatingComponentsExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Three approaches to stateful components in GPUI"), ), ) diff --git a/examples/learn/custom_drawing.rs b/examples/learn/custom_drawing.rs index ed7db83d55..84f5dc41d2 100644 --- a/examples/learn/custom_drawing.rs +++ b/examples/learn/custom_drawing.rs @@ -7,8 +7,9 @@ //! 3. `window.paint_*` methods - Drawing quads, paths, and more //! 4. Interactive drawing - Responding to mouse events +use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Colors, Context, Hsla, MouseButton, MouseDownEvent, MouseMoveEvent, + App, Application, Bounds, Context, Hsla, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Path, PathBuilder, Pixels, Point, Render, Rgba, Window, WindowBounds, WindowOptions, canvas, div, fill, point, prelude::*, px, rgb, size, }; @@ -23,9 +24,9 @@ mod example_prelude; // - paint: Called during paint to actually draw fn basic_shapes_canvas(colors: &Colors) -> impl IntoElement { - let error = colors.error; - let success = colors.success; - let accent = colors.accent; + let error = rgb(0xd32f2f); + let success = rgb(0x388e3c); + let accent = colors.selected; canvas( move |_bounds, _window, _cx| {}, @@ -106,8 +107,8 @@ fn create_triangle(p1: Point, p2: Point, p3: Point) -> P } fn custom_paths_canvas(colors: &Colors) -> impl IntoElement { - let warning = colors.warning; - let accent = colors.accent; + let warning = rgb(0xf9a825); + let accent = colors.selected; canvas( move |_bounds, _window, _cx| {}, @@ -169,10 +170,10 @@ impl DrawingCanvas { fn get_colors(colors: &Colors) -> Vec { vec![ - colors.error, - colors.success, - colors.accent, - colors.warning, + rgb(0xd32f2f), + rgb(0x388e3c), + colors.selected, + rgb(0xf9a825), rgb(0x8b5cf6), // Purple rgb(0x06b6d4), // Cyan ] @@ -277,12 +278,12 @@ impl Render for DrawingCanvas { let current_color = self.current_color(&colors); let palette = Self::get_colors(&colors); - let surface = colors.surface; + let surface = colors.container; let border = colors.border; - let error = colors.error; - let error_hover = colors.error_hover; + let error = rgb(0xd32f2f); + let error_hover = rgb(0xe04545); let text = colors.selected_text; - let text_muted = colors.text_muted; + let text_muted = colors.disabled; div() .flex() @@ -393,7 +394,7 @@ impl Render for CustomDrawingExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Canvas element, paths, and interactive painting"), ), ) @@ -429,7 +430,7 @@ fn section( content: impl IntoElement, height: Pixels, ) -> impl IntoElement { - let surface: Hsla = colors.surface.into(); + let surface: Hsla = colors.container.into(); div() .flex() @@ -455,7 +456,7 @@ fn section( .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child(description), ), ) diff --git a/examples/learn/interactive_elements.rs b/examples/learn/interactive_elements.rs index 22aadbcf61..6ddbfc2fc8 100644 --- a/examples/learn/interactive_elements.rs +++ b/examples/learn/interactive_elements.rs @@ -11,10 +11,11 @@ mod example_prelude; use example_prelude::init_example; +use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, ClickEvent, Colors, Context, Entity, Half, Hsla, IntoElement, - MouseButton, MouseMoveEvent, Pixels, Point, Render, Window, WindowBounds, WindowOptions, div, - prelude::*, px, size, + App, Application, Bounds, ClickEvent, Context, Entity, Half, Hsla, IntoElement, MouseButton, + MouseMoveEvent, Pixels, Point, Render, Window, WindowBounds, WindowOptions, div, prelude::*, + px, rgb, size, }; // ============================================================================ @@ -50,7 +51,7 @@ impl Render for ClickDemo { .gap_3() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() @@ -61,7 +62,7 @@ impl Render for ClickDemo { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Single click, double click, or triple click the button"), ) .child( @@ -70,12 +71,12 @@ impl Render for ClickDemo { .px_4() .py_2() .rounded_md() - .bg(colors.accent) + .bg(colors.selected) .text_color(colors.selected_text) .text_sm() .cursor_pointer() - .hover(|style| style.bg(colors.accent_hover)) - .active(|style| style.bg(colors.accent_active)) + .hover(|style| style.bg(colors.selected)) + .active(|style| style.bg(colors.selected)) .child("Click Me!") // on_click receives a ClickEvent with click_count() method .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| { @@ -98,13 +99,13 @@ impl Render for ClickDemo { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child(format!("Total clicks: {}", self.click_count)), ) .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child(format!("Last: {}", self.last_click_type)), ), ) @@ -144,7 +145,7 @@ impl Render for HoverDemo { .gap_3() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() @@ -155,7 +156,7 @@ impl Render for HoverDemo { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Move your mouse in and out of the target"), ) .child( @@ -166,14 +167,14 @@ impl Render for HoverDemo { .rounded_md() .border_2() .border_color(if is_hovered { - colors.accent + colors.selected } else { colors.border }) .bg(if is_hovered { - colors.accent_hover + colors.selected } else { - colors.surface_hover + colors.selected }) .text_color(if is_hovered { colors.selected_text @@ -199,7 +200,7 @@ impl Render for HoverDemo { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .mt_2() .child(format!("Times hovered: {}", self.hover_count)), ) @@ -253,7 +254,7 @@ impl Render for MouseEventsDemo { .gap_3() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() @@ -264,7 +265,7 @@ impl Render for MouseEventsDemo { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Move and click within the target area"), ) .child( @@ -274,14 +275,14 @@ impl Render for MouseEventsDemo { .rounded_md() .border_2() .border_color(if is_pressed { - colors.accent + colors.selected } else { colors.border }) .bg(if is_pressed { - colors.accent_hover + colors.selected } else { - colors.surface_hover + colors.selected }) .flex() .items_center() @@ -317,7 +318,7 @@ impl Render for MouseEventsDemo { .gap_0p5() .mt_2() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .children( self.event_log .iter() @@ -396,7 +397,7 @@ impl DragDropDemo { impl Render for DragDropDemo { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = Colors::for_appearance(window); - let item_colors = [colors.error, colors.success, colors.warning]; + let item_colors = [rgb(0xd32f2f), rgb(0x388e3c), rgb(0xf9a825)]; div() .flex() @@ -404,7 +405,7 @@ impl Render for DragDropDemo { .gap_3() .p_4() .rounded_lg() - .bg(colors.surface) + .bg(colors.container) .child( div() .text_sm() @@ -415,7 +416,7 @@ impl Render for DragDropDemo { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Drag items to the drop zone below"), ) .child( @@ -464,7 +465,7 @@ impl Render for DragDropDemo { .items_center() .justify_center() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) // on_drop receives the drag data when an item is dropped .on_drop(cx.listener(|this, data: &DragData, _window, cx| { this.dropped_item = Some(*data); @@ -532,7 +533,7 @@ impl Render for InteractiveElementsExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Click, hover, mouse events, and drag-and-drop in GPUI"), ), ) diff --git a/examples/learn/layout.rs b/examples/learn/layout.rs index 909a0c8d91..c13f2e81fb 100644 --- a/examples/learn/layout.rs +++ b/examples/learn/layout.rs @@ -10,8 +10,9 @@ mod example_prelude; use example_prelude::init_example; +use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Colors, Context, Div, Hsla, Render, Rgba, Window, WindowBounds, + App, Application, Bounds, Context, Div, Hsla, Render, Rgba, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, }; @@ -34,7 +35,7 @@ fn block(label: &'static str, color: Hsla, text_color: Rgba) -> Div { // Flexbox Examples fn flexbox_row_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; div() @@ -59,7 +60,7 @@ fn flexbox_row_example(colors: &Colors) -> impl IntoElement { } fn flexbox_column_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; div() @@ -85,9 +86,9 @@ fn flexbox_column_example(colors: &Colors) -> impl IntoElement { } fn flexbox_justify_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; - let surface = colors.surface; + let surface = colors.container; div() .flex() @@ -136,7 +137,7 @@ fn flexbox_justify_example(colors: &Colors) -> impl IntoElement { } fn flexbox_grow_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; div() @@ -162,7 +163,7 @@ fn flexbox_grow_example(colors: &Colors) -> impl IntoElement { // Grid Examples fn grid_basic_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; div() @@ -190,7 +191,7 @@ fn grid_basic_example(colors: &Colors) -> impl IntoElement { } fn grid_span_example(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; div() @@ -232,10 +233,10 @@ fn grid_span_example(colors: &Colors) -> impl IntoElement { // Common Layout Patterns fn app_shell_pattern(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.text; - let surface = colors.surface; - let surface_hover = colors.surface_hover; + let surface = colors.container; + let surface_hover = colors.selected; let background = colors.background; let border = colors.border; @@ -300,10 +301,10 @@ fn app_shell_pattern(colors: &Colors) -> impl IntoElement { } fn centered_pattern(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; let text = colors.selected_text; - let surface = colors.surface; - let accent = colors.accent; + let surface = colors.container; + let accent = colors.selected; div() .flex() @@ -337,8 +338,8 @@ fn centered_pattern(colors: &Colors) -> impl IntoElement { } fn stack_pattern(colors: &Colors) -> impl IntoElement { - let text_muted = colors.text_muted; - let surface = colors.surface; + let text_muted = colors.disabled; + let surface = colors.container; div() .flex() @@ -421,7 +422,7 @@ impl Render for LayoutExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Flexbox, Grid, and common layout patterns in GPUI"), ), ) @@ -463,7 +464,7 @@ impl Render for LayoutExample { } fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> impl IntoElement { - let surface: Hsla = colors.surface.into(); + let surface: Hsla = colors.container.into(); div() .flex() diff --git a/examples/learn/styling.rs b/examples/learn/styling.rs index f3475d6561..6bff1d197c 100644 --- a/examples/learn/styling.rs +++ b/examples/learn/styling.rs @@ -6,9 +6,10 @@ //! 2. Conditional styling - when, when_some, map //! 3. Theming patterns - using Colors for consistent styling +use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Colors, Context, FocusHandle, Hsla, KeyBinding, Menu, MenuItem, - Render, Rgba, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, + App, Application, Bounds, Context, FocusHandle, Hsla, KeyBinding, Menu, MenuItem, Render, Rgba, + Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, }; actions!(styling_example, [Quit, Tab, TabPrev]); @@ -20,9 +21,9 @@ fn interactive_button( label: &'static str, colors: &Colors, ) -> impl IntoElement { - let accent = colors.accent; - let accent_hover = colors.accent_hover; - let accent_active = colors.accent_active; + let accent = colors.selected; + let accent_hover = colors.selected; + let accent_active = colors.selected; let text = colors.selected_text; div() @@ -45,10 +46,10 @@ fn focus_button( focus_handle: &FocusHandle, colors: &Colors, ) -> impl IntoElement { - let surface = colors.surface; - let surface_hover = colors.surface_hover; + let surface = colors.container; + let surface_hover = colors.selected; let text = colors.text; - let accent = colors.accent; + let accent = colors.selected; let focus_ring: Rgba = rgb(0x60a5fa); div() @@ -77,7 +78,7 @@ fn interactive_states_section(colors: &Colors) -> impl IntoElement { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("hover() / active() - Mouse interaction states"), ) .child( @@ -93,10 +94,10 @@ fn interactive_states_section(colors: &Colors) -> impl IntoElement { fn status_badge(status: &'static str, variant: StatusVariant, colors: &Colors) -> impl IntoElement { let (bg, text): (Rgba, Rgba) = match variant { - StatusVariant::Success => (colors.success, colors.selected_text), - StatusVariant::Warning => (colors.warning, rgb(0x000000)), - StatusVariant::Error => (colors.error, colors.selected_text), - StatusVariant::Neutral => (colors.surface, colors.text), + StatusVariant::Success => (rgb(0x388e3c), colors.selected_text), + StatusVariant::Warning => (rgb(0xf9a825), rgb(0x000000)), + StatusVariant::Error => (rgb(0xd32f2f), colors.selected_text), + StatusVariant::Neutral => (colors.container, colors.text), }; div() @@ -124,11 +125,11 @@ fn list_item( is_disabled: bool, colors: &Colors, ) -> impl IntoElement { - let surface = colors.surface; - let surface_hover = colors.surface_hover; + let surface = colors.container; + let surface_hover = colors.selected; let text = colors.text; - let text_muted = colors.text_muted; - let accent = colors.accent; + let text_muted = colors.disabled; + let accent = colors.selected; div() .id(id) @@ -167,7 +168,7 @@ fn conditional_section(colors: &Colors) -> impl IntoElement { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("when() - Apply styles conditionally"), ) .child( @@ -182,7 +183,7 @@ fn conditional_section(colors: &Colors) -> impl IntoElement { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .mt_2() .child("Status badges with variant-based styling"), ) @@ -205,11 +206,11 @@ fn card_with_group_hover( description: &'static str, colors: &Colors, ) -> impl IntoElement { - let surface = colors.surface; + let surface = colors.container; let border = colors.border; - let accent = colors.accent; + let accent = colors.selected; let text = colors.text; - let text_muted = colors.text_muted; + let text_muted = colors.disabled; div() .id(id) @@ -259,7 +260,7 @@ fn group_hover_section(colors: &Colors) -> impl IntoElement { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("group() / group_hover() - Parent hover affects children"), ) .child( @@ -292,7 +293,7 @@ struct StylingExample { impl StylingExample { fn new(window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); let buttons = vec![ cx.focus_handle().tab_index(1).tab_stop(true), @@ -306,12 +307,12 @@ impl StylingExample { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); } } @@ -349,7 +350,7 @@ impl Render for StylingExample { .child( div() .text_sm() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Interactive states, conditional styling, and theming"), ), ) @@ -368,7 +369,7 @@ impl Render for StylingExample { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("focus() / focus_visible() - Keyboard navigation"), ) .child( @@ -415,7 +416,7 @@ impl Render for StylingExample { .child( div() .text_xs() - .text_color(colors.text_muted) + .text_color(colors.disabled) .child("Using Colors::for_appearance() for consistent theming"), ) .child( @@ -424,11 +425,11 @@ impl Render for StylingExample { .flex_wrap() .gap_2() .child(color_swatch(&colors, "background", colors.background)) - .child(color_swatch(&colors, "surface", colors.surface)) - .child(color_swatch(&colors, "accent", colors.accent)) - .child(color_swatch(&colors, "success", colors.success)) - .child(color_swatch(&colors, "warning", colors.warning)) - .child(color_swatch(&colors, "error", colors.error)) + .child(color_swatch(&colors, "container", colors.container)) + .child(color_swatch(&colors, "selected", colors.selected)) + .child(color_swatch(&colors, "success", rgb(0x388e3c))) + .child(color_swatch(&colors, "warning", rgb(0xf9a825))) + .child(color_swatch(&colors, "error", rgb(0xd32f2f))) .child(color_swatch(&colors, "border", colors.border)), ), )), @@ -437,7 +438,7 @@ impl Render for StylingExample { } fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> impl IntoElement { - let surface: Hsla = colors.surface.into(); + let surface: Hsla = colors.container.into(); let border: Hsla = colors.border.into(); div() @@ -460,7 +461,7 @@ fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> i } fn color_swatch(colors: &Colors, name: &'static str, color: Rgba) -> impl IntoElement { - let text_muted = colors.text_muted; + let text_muted = colors.disabled; div() .flex() @@ -490,6 +491,7 @@ fn main() { cx.set_menus(vec![Menu { name: "Styling".into(), items: vec![MenuItem::action("Quit", Quit)], + disabled: false, }]); cx.on_window_closed(|cx| { if cx.windows().is_empty() { diff --git a/examples/learn/text.rs b/examples/learn/text.rs index 5f938dc5ea..99ce86860d 100644 --- a/examples/learn/text.rs +++ b/examples/learn/text.rs @@ -14,15 +14,16 @@ mod example_prelude; use example_prelude::init_example; use gpui::{ - App, Application, Bounds, Colors, Context, FontStyle, FontWeight, Hsla, Render, StyledText, - TextOverflow, Window, WindowBounds, WindowOptions, div, prelude::*, px, relative, size, + App, Application, Bounds, Context, FontStyle, FontWeight, Hsla, Render, StyledText, + TextOverflow, Window, WindowBounds, WindowOptions, colors::Colors, current_platform, div, + prelude::*, px, relative, rgb, size, }; // Text Styling Examples fn text_sizes_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; + let text_muted = colors.disabled; div() .flex() @@ -50,7 +51,7 @@ fn text_sizes_example(colors: &Colors) -> impl IntoElement { fn text_weights_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; + let text_muted = colors.disabled; div() .flex() @@ -116,8 +117,8 @@ fn text_weights_example(colors: &Colors) -> impl IntoElement { fn text_alignment_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; - let surface = colors.surface; + let text_muted = colors.disabled; + let surface = colors.container; div() .flex() @@ -167,9 +168,9 @@ fn text_alignment_example(colors: &Colors) -> impl IntoElement { fn text_decoration_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; - let accent = colors.accent; - let error = colors.error; + let text_muted = colors.disabled; + let accent = colors.selected; + let error = rgb(0xd32f2f); div() .flex() @@ -208,8 +209,8 @@ fn text_decoration_example(colors: &Colors) -> impl IntoElement { fn text_overflow_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; - let surface = colors.surface; + let text_muted = colors.disabled; + let surface = colors.container; let border = colors.border; let long_text = "The quick brown fox jumps over the lazy dog. This is a long sentence that will overflow its container."; @@ -333,7 +334,7 @@ fn text_overflow_example(colors: &Colors) -> impl IntoElement { fn styled_text_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; + let text_muted = colors.disabled; div() .flex() @@ -358,8 +359,8 @@ fn styled_text_example(colors: &Colors) -> impl IntoElement { fn character_grid_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; - let surface = colors.surface; + let text_muted = colors.disabled; + let surface = colors.container; let border = colors.border; let characters = [ @@ -414,8 +415,8 @@ fn character_grid_example(colors: &Colors) -> impl IntoElement { fn line_height_example(colors: &Colors) -> impl IntoElement { let text = colors.text; - let text_muted = colors.text_muted; - let surface = colors.surface; + let text_muted = colors.disabled; + let surface = colors.container; div() .flex() @@ -500,7 +501,7 @@ impl Render for TextExample { .child("Text & Typography"), ) .child( - div().text_sm().text_color(colors.text_muted).child( + div().text_sm().text_color(colors.disabled).child( "Font styling, alignment, overflow, and unicode support", ), ), @@ -546,7 +547,7 @@ impl Render for TextExample { } fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> impl IntoElement { - let surface: Hsla = colors.surface.into(); + let surface: Hsla = colors.container.into(); div() .flex() @@ -566,7 +567,7 @@ fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> i } fn main() { - Application::new().run(|cx: &mut App| { + Application::with_platform(current_platform(false)).run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(650.), px(900.)), cx); cx.open_window( WindowOptions { diff --git a/examples/legacy/focus_visible.rs b/examples/legacy/focus_visible.rs index 737317caba..d7c15396f0 100644 --- a/examples/legacy/focus_visible.rs +++ b/examples/legacy/focus_visible.rs @@ -29,7 +29,7 @@ impl Example { ]; let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { focus_handle, @@ -40,13 +40,13 @@ impl Example { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); self.message = SharedString::from("Pressed Tab - focus-visible border should appear!"); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); self.message = SharedString::from("Pressed Shift-Tab - focus-visible border should appear!"); } diff --git a/examples/legacy/input.rs b/examples/legacy/input.rs index 37115feaa5..aac56bdf1d 100644 --- a/examples/legacy/input.rs +++ b/examples/legacy/input.rs @@ -546,8 +546,15 @@ impl Element for TextElement { window.paint_quad(selection) } let line = prepaint.line.take().unwrap(); - line.paint(bounds.origin, window.line_height(), window, cx) - .unwrap(); + line.paint( + bounds.origin, + window.line_height(), + gpui::TextAlign::Left, + None, + window, + cx, + ) + .unwrap(); if focus_handle.is_focused(window) && let Some(cursor) = prepaint.cursor.take() @@ -736,7 +743,7 @@ fn main() { window .update(cx, |view, window, cx| { - window.focus(&view.text_input.focus_handle(cx)); + window.focus(&view.text_input.focus_handle(cx), cx); cx.activate(true); }) .unwrap(); diff --git a/examples/legacy/on_window_close_quit.rs b/examples/legacy/on_window_close_quit.rs index 8fe2400144..9a2b2f2fee 100644 --- a/examples/legacy/on_window_close_quit.rs +++ b/examples/legacy/on_window_close_quit.rs @@ -55,7 +55,7 @@ fn main() { cx.activate(false); cx.new(|cx| { let focus_handle = cx.focus_handle(); - focus_handle.focus(window); + focus_handle.focus(window, cx); ExampleWindow { focus_handle } }) }, @@ -72,7 +72,7 @@ fn main() { |window, cx| { cx.new(|cx| { let focus_handle = cx.focus_handle(); - focus_handle.focus(window); + focus_handle.focus(window, cx); ExampleWindow { focus_handle } }) }, diff --git a/examples/legacy/svg/svg.rs b/examples/legacy/svg/svg.rs index 5d938998e8..81792af999 100644 --- a/examples/legacy/svg/svg.rs +++ b/examples/legacy/svg/svg.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use anyhow::Result; use gpui::{ App, Application, AssetSource, Bounds, Context, SharedString, Window, WindowBounds, - WindowOptions, div, prelude::*, px, rgb, size, svg, + WindowOptions, current_platform, div, prelude::*, px, rgb, size, svg, }; struct Assets { @@ -68,7 +68,7 @@ impl Render for SvgExample { } fn main() { - Application::new() + Application::with_platform(current_platform(false)) .with_assets(Assets { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) diff --git a/examples/legacy/tab_stop.rs b/examples/legacy/tab_stop.rs index 8dbcbeccb7..4d99da1a07 100644 --- a/examples/legacy/tab_stop.rs +++ b/examples/legacy/tab_stop.rs @@ -22,7 +22,7 @@ impl Example { ]; let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { focus_handle, @@ -31,13 +31,13 @@ impl Example { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); self.message = SharedString::from("You have pressed `Tab`."); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); self.message = SharedString::from("You have pressed `Shift-Tab`."); } } diff --git a/examples/legacy/window.rs b/examples/legacy/window.rs index 06003c4663..4c275d4cb7 100644 --- a/examples/legacy/window.rs +++ b/examples/legacy/window.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Timer, Window, - WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, + App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Window, WindowBounds, + WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, }; struct SubWindow { @@ -188,7 +188,7 @@ impl Render for WindowDemo { // Restore the application after 3 seconds window .spawn(cx, async move |cx| { - Timer::after(std::time::Duration::from_secs(3)).await; + smol::Timer::after(std::time::Duration::from_secs(3)).await; cx.update(|_, cx| { cx.activate(false); }) diff --git a/examples/prelude.rs b/examples/prelude.rs index 7d9899166f..c16408b5ca 100644 --- a/examples/prelude.rs +++ b/examples/prelude.rs @@ -29,6 +29,7 @@ pub fn init_example(cx: &mut App, name: impl Into) { cx.set_menus(vec![Menu { name: name.into(), items: vec![MenuItem::action("Quit", Quit)], + disabled: false, }]); // Quit the app when all windows are closed diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..d8bbb50ac0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,116 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1773857772, + "narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=", + "owner": "ipetkov", + "repo": "crane", + "rev": "b556d7bbae5ff86e378451511873dfd07e4504cd", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1774076307, + "narHash": "sha256-v8axK9HGgVERw9oG3SKdsuE+ri0GPUZDyRBN4GLqQ1c=", + "owner": "nix-community", + "repo": "fenix", + "rev": "556198cc6c69c0a13228a15e33b2360f333b0092", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1773840656, + "narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1774036669, + "narHash": "sha256-EWhsBSh/h1VcyLKXuTEyH8lNVB2ktuKVkqx8dkQ6hxk=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "0cf3e8a07f0e29825f5db78840e646a4bb519742", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..0632bdfc4e --- /dev/null +++ b/flake.nix @@ -0,0 +1,121 @@ +{ + description = "Standalone GPUI build"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + crane.url = "github:ipetkov/crane"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + fenix, + crane, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + inherit (pkgs) lib; + + toolchain = fenix.packages.${system}.latest.withComponents [ + "cargo" + "rustc" + "rust-src" + "rustfmt" + "clippy" + ]; + + craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; + + src = lib.cleanSourceWith { + src = craneLib.path ./.; + filter = + path: type: + (craneLib.filterCargoSources path type) + || (lib.hasSuffix ".metal" path) + || (lib.hasSuffix ".wgsl" path) + || (lib.hasSuffix ".hlsl" path) + || (lib.hasSuffix ".glsl" path); + }; + + linuxLibs = with pkgs; [ + alsa-lib + libdrm + mesa # provides libgbm + libxkbcommon + libva + vulkan-loader + wayland + xorg.libX11 + xorg.libxcb + ]; + + commonArgs = { + pname = "gpui-ce"; + version = "0.3.3"; + + inherit src; + strictDeps = true; + + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + rustPlatform.bindgenHook + ]; + + buildInputs = + with pkgs; + [ + fontconfig + freetype + openssl + zlib + ] + ++ lib.optionals stdenv.isLinux linuxLibs + ++ lib.optionals stdenv.isDarwin [ + apple-sdk_15 + (darwinMinVersionHook "11.0") + ]; + + env = lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = lib.makeLibraryPath linuxLibs; + }; + + cargoExtraArgs = "--features runtime_shaders"; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + gpui = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + } + ); + in + { + packages.default = gpui; + + devShells.default = pkgs.mkShell { + inputsFrom = [ gpui ]; + packages = [ toolchain ]; + + shellHook = '' + export RUST_BACKTRACE=1 + export RUST_SRC_PATH="${toolchain}/lib/rustlib/src/rust/library" + ${lib.optionalString pkgs.stdenv.isLinux '' + export LD_LIBRARY_PATH="${lib.makeLibraryPath linuxLibs}:$LD_LIBRARY_PATH" + ''} + ''; + }; + } + ); +} diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf similarity index 100% rename from assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf rename to resources/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf similarity index 100% rename from assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf rename to resources/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf similarity index 100% rename from assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf rename to resources/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf similarity index 100% rename from assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf rename to resources/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf diff --git a/assets/fonts/ibm-plex-sans/license.txt b/resources/fonts/ibm-plex-sans/license.txt similarity index 100% rename from assets/fonts/ibm-plex-sans/license.txt rename to resources/fonts/ibm-plex-sans/license.txt diff --git a/assets/fonts/lilex/Lilex-Bold.ttf b/resources/fonts/lilex/Lilex-Bold.ttf similarity index 100% rename from assets/fonts/lilex/Lilex-Bold.ttf rename to resources/fonts/lilex/Lilex-Bold.ttf diff --git a/assets/fonts/lilex/Lilex-BoldItalic.ttf b/resources/fonts/lilex/Lilex-BoldItalic.ttf similarity index 100% rename from assets/fonts/lilex/Lilex-BoldItalic.ttf rename to resources/fonts/lilex/Lilex-BoldItalic.ttf diff --git a/assets/fonts/lilex/Lilex-Italic.ttf b/resources/fonts/lilex/Lilex-Italic.ttf similarity index 100% rename from assets/fonts/lilex/Lilex-Italic.ttf rename to resources/fonts/lilex/Lilex-Italic.ttf diff --git a/assets/fonts/lilex/Lilex-Regular.ttf b/resources/fonts/lilex/Lilex-Regular.ttf similarity index 100% rename from assets/fonts/lilex/Lilex-Regular.ttf rename to resources/fonts/lilex/Lilex-Regular.ttf diff --git a/assets/fonts/lilex/OFL.txt b/resources/fonts/lilex/OFL.txt similarity index 100% rename from assets/fonts/lilex/OFL.txt rename to resources/fonts/lilex/OFL.txt diff --git a/script/clippy b/script/clippy deleted file mode 100755 index 494ccf81dd..0000000000 --- a/script/clippy +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -set -x -"${CARGO:-cargo}" clippy "$@" --release --all-targets --all-features -- --deny warnings - -# If local, run other checks if we have the tools installed. -if [[ -z "${GITHUB_ACTIONS+x}" ]]; then - which cargo-machete >/dev/null 2>&1 || exit 0 - cargo machete - - which typos >/dev/null 2>&1 || exit 0 - typos --config typos.toml -fi diff --git a/script/clippy.ps1 b/script/clippy.ps1 deleted file mode 100644 index afb1611d83..0000000000 --- a/script/clippy.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -$ErrorActionPreference = "Stop" - -Write-Host "Your PATH entries:" -$env:Path -split ";" | ForEach-Object { Write-Host " $_" } - -$needAddWorkspace = $false -if ($args -notcontains "-p" -and $args -notcontains "--package") -{ - $needAddWorkspace = $true -} - -# https://stackoverflow.com/questions/41324882/how-to-run-a-powershell-script-with-verbose-output/70020655#70020655 -# Set-PSDebug -Trace 2 - -if ($env:CARGO) -{ - $Cargo = $env:CARGO -} elseif (Get-Command "cargo" -ErrorAction SilentlyContinue) -{ - $Cargo = "cargo" -} else -{ - Write-Error "Could not find cargo in path." -ErrorAction Stop -} - -if ($needAddWorkspace) -{ - & $Cargo clippy @args --workspace --release --all-targets --all-features -- --deny warnings -} else -{ - & $Cargo clippy @args --release --all-targets --all-features -- --deny warnings -} diff --git a/script/linux b/script/linux deleted file mode 100755 index c5c4ea9ab3..0000000000 --- a/script/linux +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env bash - -set -xeuo pipefail - -# if root or if sudo/unavailable, define an empty variable -if [ "$(id -u)" -eq 0 ] -then maysudo='' -else maysudo="$(command -v sudo || command -v doas || true)" -fi - -function finalize { - # after packages install (curl, etc), get the rust toolchain - which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - cat < = cx.new(|_cx| Counter { count: 0 }); +//! // ... +//! }); +//! ``` +//! +//! The call to `new_entity` returns an _entity handle_, which carries a type parameter based on the type of object it references. By itself, this `Entity` handle doesn't provide access to the entity's state. It's merely an inert identifier plus a compile-time type tag, and it maintains a reference counted pointer to the underlying `Counter` object that is owned by the app. +//! +//! Much like an `Rc` from the Rust standard library, this reference count is incremented when the handle is cloned and decremented when it is dropped to enable shared ownership over the underlying model, but unlike an `Rc` it only provides access to the model's state when a reference to an `App` is available. The handle doesn't truly _own_ the state, but it can be used to access the state from its true owner, the `App`. Stripping away some of the setup code for brevity: +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Context, Entity}; +//! # struct Counter { +//! # count: usize, +//! # } +//! gpui_platform::application().run(|cx: &mut App| { +//! let counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! // Call `update` to access the model's state. +//! counter.update(cx, |counter: &mut Counter, _cx: &mut Context| { +//! counter.count += 1; +//! }); +//! }); +//! ``` +//! +//! To update the counter, we call `update` on the handle, passing the context reference and a callback. The callback is yielded a mutable reference to the counter, which can be used to manipulate state. +//! +//! The callback is also provided a second `Context` reference. This reference is similar to the `App` reference provided to the `run` callback. A `Context` is actually a wrapper around the `App`, including some additional data to indicate which particular entity it is tied to; in this case the counter. +//! +//! In addition to the application-level services provided by `App`, a `Context` provides access to entity-level services. For example, it can be used it to inform observers of this entity that its state has changed. Let's add that to our example, by calling `cx.notify()`. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Entity}; +//! # struct Counter { +//! # count: usize, +//! # } +//! gpui_platform::application().run(|cx: &mut App| { +//! let counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! counter.update(cx, |counter, cx| { +//! counter.count += 1; +//! cx.notify(); // Notify observers +//! }); +//! }); +//! ``` +//! +//! Next, these notifications need to be observed and reacted to. Before updating the counter, we'll construct a second counter that observes it. Whenever the first counter changes, twice its count is assigned to the second counter. Note how `observe` is called on the `Context` belonging to our second counter to arrange for it to be notified whenever the first counter notifies. The call to `observe` returns a `Subscription`, which is `detach`ed to preserve this behavior for as long as both counters exist. We could also store this subscription and drop it at a time of our choosing to cancel this behavior. +//! +//! The `observe` callback is passed a mutable reference to the observer and a _handle_ to the observed counter, whose state we access with the `read` method. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Entity, prelude::*}; +//! # struct Counter { +//! # count: usize, +//! # } +//! gpui_platform::application().run(|cx: &mut App| { +//! let first_counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! +//! let second_counter = cx.new(|cx: &mut Context| { +//! // Note we can set up the callback before the Counter is even created! +//! cx.observe( +//! &first_counter, +//! |second: &mut Counter, first: Entity, cx| { +//! second.count = first.read(cx).count * 2; +//! }, +//! ) +//! .detach(); +//! +//! Counter { count: 0 } +//! }); +//! +//! first_counter.update(cx, |counter, cx| { +//! counter.count += 1; +//! cx.notify(); +//! }); +//! +//! assert_eq!(second_counter.read(cx).count, 2); +//! }); +//! ``` +//! +//! After updating the first counter, it can be noted that the observing counter's state is maintained according to our subscription. +//! +//! In addition to `observe` and `notify`, which indicate that an entity's state has changed, GPUI also offers `subscribe` and `emit`, which enables entities to emit typed events. To opt into this system, the emitting object must implement the `EventEmitter` trait. +//! +//! Let's introduce a new event type called `CounterChangeEvent`, then indicate that `Counter` can emit this type of event: +//! +//! ```no_run +//! use gpui::EventEmitter; +//! # struct Counter { +//! # count: usize, +//! # } +//! struct CounterChangeEvent { +//! increment: usize, +//! } +//! +//! impl EventEmitter for Counter {} +//! ``` +//! +//! Next, the example should be updated, replacing the observation with a subscription. Whenever the counter is incremented, a `Change` event is emitted to indicate the magnitude of the increase. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Context, Entity, EventEmitter}; +//! # struct Counter { +//! # count: usize, +//! # } +//! # struct CounterChangeEvent { +//! # increment: usize, +//! # } +//! # impl EventEmitter for Counter {} +//! gpui_platform::application().run(|cx: &mut App| { +//! let first_counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! +//! let second_counter = cx.new(|cx: &mut Context| { +//! // Note we can set up the callback before the Counter is even created! +//! cx.subscribe(&first_counter, |second: &mut Counter, _first: Entity, event, _cx| { +//! second.count += event.increment * 2; +//! }) +//! .detach(); +//! +//! Counter { +//! count: first_counter.read(cx).count * 2, +//! } +//! }); +//! +//! first_counter.update(cx, |first, cx| { +//! first.count += 2; +//! cx.emit(CounterChangeEvent { increment: 2 }); +//! cx.notify(); +//! }); +//! +//! assert_eq!(second_counter.read(cx).count, 4); +//! }); +//! ``` diff --git a/src/action.rs b/src/action.rs index 38e94aa356..a47ebe69f0 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,7 +1,7 @@ use anyhow::{Context as _, Result}; use collections::HashMap; pub use gpui_macros::Action; -pub use no_action::{NoAction, is_no_action}; +pub use no_action::{NoAction, Unbind, is_no_action, is_unbind}; use serde_json::json; use std::{ any::{Any, TypeId}, @@ -290,19 +290,6 @@ impl ActionRegistry { } } - #[cfg(test)] - pub(crate) fn load_action(&mut self) { - self.insert_action(MacroActionData { - name: A::name_for_type(), - type_id: TypeId::of::(), - build: A::build, - json_schema: A::action_json_schema, - deprecated_aliases: A::deprecated_aliases(), - deprecation_message: A::deprecation_message(), - documentation: A::documentation(), - }); - } - fn insert_action(&mut self, action: MacroActionData) { let name = action.name; if self.by_name.contains_key(name) { @@ -397,6 +384,16 @@ impl ActionRegistry { .collect::>() } + pub fn action_schema_by_name( + &self, + name: &str, + generator: &mut schemars::SchemaGenerator, + ) -> Option> { + self.by_name + .get(name) + .map(|action_data| (action_data.json_schema)(generator)) + } + pub fn deprecated_aliases(&self) -> &HashMap<&'static str, &'static str> { &self.deprecated_aliases } @@ -422,7 +419,8 @@ pub fn generate_list_of_all_registered_actions() -> impl Iterator bool { - action.as_any().type_id() == (NoAction {}).type_id() + action.as_any().is::() + } + + /// Returns whether or not this action represents an unbind marker. + pub fn is_unbind(action: &dyn gpui::Action) -> bool { + action.as_any().is::() } } diff --git a/src/app.rs b/src/app.rs index 1faae21cbc..5b5d78b3ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,14 @@ +use crate::scheduler::Instant; use std::{ any::{TypeId, type_name}, - cell::{BorrowMutError, Ref, RefCell, RefMut}, + cell::{BorrowMutError, Cell, Ref, RefCell, RefMut}, marker::PhantomData, mem, ops::{Deref, DerefMut}, path::{Path, PathBuf}, rc::{Rc, Weak}, sync::{Arc, atomic::Ordering::SeqCst}, - time::{Duration, Instant}, + time::Duration, }; use anyhow::{Context as _, Result, anyhow}; @@ -25,25 +26,31 @@ pub use async_context::*; use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use context::*; pub use entity_map::*; +#[cfg(any(test, feature = "test-support"))] +pub use headless_app_context::*; use http_client::{HttpClient, Url}; use smallvec::SmallVec; #[cfg(any(test, feature = "test-support"))] +pub use test_app::*; +#[cfg(any(test, feature = "test-support"))] pub use test_context::*; use util::{ResultExt, debug_panic}; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +pub use visual_test_context::*; #[cfg(any(feature = "inspector", debug_assertions))] use crate::InspectorElementRegistry; use crate::{ - Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Asset, - AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, - EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, - Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, - PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, - RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, - Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, WindowHandle, WindowId, - WindowInvalidator, current_platform, - default_colors::{Colors, GlobalColors}, + Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Arena, + ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, + DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, + KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, + PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, + PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, + PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, + SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem, + ThermalState, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -51,7 +58,13 @@ mod async_context; mod context; mod entity_map; #[cfg(any(test, feature = "test-support"))] +mod headless_app_context; +#[cfg(any(test, feature = "test-support"))] +mod test_app; +#[cfg(any(test, feature = "test-support"))] mod test_context; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +mod visual_test_context; /// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits. pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100); @@ -128,31 +141,21 @@ pub struct Application(Rc); /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl Application { - /// Builds an app with the given asset source. - #[allow(clippy::new_without_default)] + /// Builds an app with the default platform for the current OS. pub fn new() -> Self { - #[cfg(any(test, feature = "test-support"))] - log::info!("GPUI was compiled in test mode"); + Self::with_platform(crate::current_platform(false)) + } + /// Builds an app with a caller-provided platform implementation. + pub fn with_platform(platform: Rc) -> Self { Self(App::new_app( - current_platform(false), + platform, Arc::new(()), Arc::new(NullHttpClient), )) } - /// Build an app in headless mode. This prevents opening windows, - /// but makes it possible to run an application in an context like - /// SSH, where GUI applications are not allowed. - pub fn headless() -> Self { - Self(App::new_app( - current_platform(true), - Arc::new(()), - Arc::new(NullHttpClient), - )) - } - - /// Assign + /// 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(); let asset_source = Arc::new(asset_source); @@ -316,6 +319,7 @@ impl SystemWindowTabController { .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); let current_group = current_group?; + // TODO: `.keys()` returns arbitrary order, what does "next" mean? let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); let idx = group_ids.iter().position(|g| *g == current_group)?; let next_idx = (idx + 1) % group_ids.len(); @@ -340,6 +344,7 @@ impl SystemWindowTabController { .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); let current_group = current_group?; + // TODO: `.keys()` returns arbitrary order, what does "previous" mean? let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); let idx = group_ids.iter().position(|g| *g == current_group)?; let prev_idx = if idx == 0 { @@ -361,12 +366,9 @@ impl SystemWindowTabController { /// Get all tabs in the same window. pub fn tabs(&self, id: WindowId) -> Option<&Vec> { - let tab_group = self - .tab_groups - .iter() - .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?; - - self.tab_groups.get(&tab_group) + self.tab_groups + .values() + .find(|tabs| tabs.iter().any(|tab| tab.id == id)) } /// Initialize the visibility of the system window tab controller. @@ -441,7 +443,7 @@ impl SystemWindowTabController { /// Insert a tab into a tab group. pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec) { let mut controller = cx.global_mut::(); - let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else { + let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else { return; }; @@ -504,16 +506,14 @@ impl SystemWindowTabController { return; }; + let initial_tabs_len = initial_tabs.len(); let mut all_tabs = initial_tabs.clone(); - for tabs in controller.tab_groups.values() { - all_tabs.extend( - tabs.iter() - .filter(|tab| !initial_tabs.contains(tab)) - .cloned(), - ); + + for (_, mut tabs) in controller.tab_groups.drain() { + tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab)); + all_tabs.extend(tabs); } - controller.tab_groups.clear(); controller.tab_groups.insert(0, all_tabs); } @@ -584,21 +584,13 @@ impl GpuiMode { pub struct App { pub(crate) this: Weak, pub(crate) platform: Rc, - pub(crate) mode: GpuiMode, text_system: Arc, - flushing_effects: bool, - pending_updates: usize, + pub(crate) actions: Rc, pub(crate) active_drag: Option, pub(crate) background_executor: BackgroundExecutor, pub(crate) foreground_executor: ForegroundExecutor, - pub(crate) loading_assets: FxHashMap<(TypeId, u64), Box>, - asset_source: Arc, - pub(crate) svg_renderer: SvgRenderer, - http_client: Arc, - pub(crate) globals_by_type: FxHashMap>, pub(crate) entities: EntityMap, - pub(crate) window_update_stack: Vec, pub(crate) new_entity_observers: SubscriberSet, pub(crate) windows: SlotMap>>, pub(crate) window_handles: FxHashMap, @@ -609,20 +601,41 @@ pub struct App { pub(crate) global_action_listeners: FxHashMap>>, pending_effects: VecDeque, - pub(crate) pending_notifications: FxHashSet, - pub(crate) pending_global_notifications: FxHashSet, + pub(crate) observers: SubscriberSet, - // TypeId is the type of the event that the listener callback expects pub(crate) event_listeners: SubscriberSet, pub(crate) keystroke_observers: SubscriberSet<(), KeystrokeObserver>, pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, + pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, pub(crate) quit_observers: SubscriberSet<(), QuitHandler>, pub(crate) restart_observers: SubscriberSet<(), Handler>, - pub(crate) restart_path: Option, pub(crate) window_closed_observers: SubscriberSet<(), WindowClosedHandler>, + + /// Per-App element arena. This isolates element allocations between different + /// App instances (important for tests where multiple Apps run concurrently). + pub(crate) element_arena: RefCell, + /// Per-App event arena. + pub(crate) event_arena: Arena, + + // Drop globals last. We need to ensure all tasks owned by entities and + // callbacks are marked cancelled at this point as this will also shutdown + // the tokio runtime. As any task attempting to spawn a blocking tokio task, + // might panic. + pub(crate) globals_by_type: FxHashMap>, + + // assets + pub(crate) loading_assets: FxHashMap<(TypeId, u64), Box>, + asset_source: Arc, + pub(crate) svg_renderer: SvgRenderer, + http_client: Arc, + + // below is plain data, the drop order is insignificant here + pub(crate) pending_notifications: FxHashSet, + pub(crate) pending_global_notifications: FxHashSet, + pub(crate) restart_path: Option, pub(crate) layout_id_buffer: Vec, // We recycle this memory across layout requests. pub(crate) propagate_event: bool, pub(crate) prompt_builder: Option, @@ -635,8 +648,19 @@ pub struct App { pub(crate) inspector_element_registry: InspectorElementRegistry, #[cfg(any(test, feature = "test-support", debug_assertions))] pub(crate) name: Option<&'static str>, + pub(crate) text_rendering_mode: Rc>, + + pub(crate) window_update_stack: Vec, + pub(crate) mode: GpuiMode, + flushing_effects: bool, + pending_updates: usize, quit_mode: QuitMode, quitting: bool, + + // We need to ensure the leak detector drops last, after all tasks, callbacks and things have been dropped. + // Otherwise it may report false positives. + #[cfg(any(test, feature = "leak-detection"))] + _ref_counts: Arc>, } impl App { @@ -646,10 +670,10 @@ impl App { asset_source: Arc, http_client: Arc, ) -> Rc { - let executor = platform.background_executor(); + let background_executor = platform.background_executor(); let foreground_executor = platform.foreground_executor(); assert!( - executor.is_main_thread(), + background_executor.is_main_thread(), "must construct App on main thread" ); @@ -658,17 +682,21 @@ impl App { let keyboard_layout = platform.keyboard_layout(); let keyboard_mapper = platform.keyboard_mapper(); + #[cfg(any(test, feature = "leak-detection"))] + let _ref_counts = entities.ref_counts_drop_handle(); + let app = Rc::new_cyclic(|this| AppCell { app: RefCell::new(App { this: this.clone(), platform: platform.clone(), text_system, + text_rendering_mode: Rc::new(Cell::new(TextRenderingMode::default())), mode: GpuiMode::Production, actions: Rc::new(ActionRegistry::default()), flushing_effects: false, pending_updates: 0, active_drag: None, - background_executor: executor, + background_executor, foreground_executor, svg_renderer: SvgRenderer::new(asset_source.clone()), loading_assets: Default::default(), @@ -696,6 +724,7 @@ impl App { keystroke_observers: SubscriberSet::new(), keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), + thermal_state_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), restart_observers: SubscriberSet::new(), @@ -713,6 +742,11 @@ impl App { #[cfg(any(test, feature = "test-support", debug_assertions))] name: None, + element_arena: RefCell::new(Arena::new(1024 * 1024)), + event_arena: Arena::new(1024 * 1024), + + #[cfg(any(test, feature = "leak-detection"))] + _ref_counts, }), }); @@ -733,16 +767,61 @@ impl App { } })); - platform.on_quit(Box::new({ - let cx = app.clone(); + platform.on_thermal_state_change(Box::new({ + let app = Rc::downgrade(&app); move || { - cx.borrow_mut().shutdown(); + if let Some(app) = app.upgrade() { + let cx = &mut app.borrow_mut(); + cx.thermal_state_observers + .clone() + .retain(&(), move |callback| (callback)(cx)); + } + } + })); + + platform.on_quit(Box::new({ + let cx = Rc::downgrade(&app); + move || { + if let Some(cx) = cx.upgrade() { + cx.borrow_mut().shutdown(); + } } })); app } + #[doc(hidden)] + pub fn ref_counts_drop_handle(&self) -> impl Sized + use<> { + self.entities.ref_counts_drop_handle() + } + + /// Captures a snapshot of all entities that currently have alive handles. + /// + /// The returned [`LeakDetectorSnapshot`] can later be passed to + /// [`assert_no_new_leaks`](Self::assert_no_new_leaks) to verify that no + /// entities created after the snapshot are still alive. + #[cfg(any(test, feature = "leak-detection"))] + pub fn leak_detector_snapshot(&self) -> LeakDetectorSnapshot { + self.entities.leak_detector_snapshot() + } + + /// Asserts that no entities created after `snapshot` still have alive handles. + /// + /// Entities that were already tracked at the time of the snapshot are ignored, + /// even if they still have handles. Only *new* entities (those whose + /// `EntityId` was not present in the snapshot) are considered leaks. + /// + /// # Panics + /// + /// Panics if any new entity handles exist. The panic message lists every + /// leaked entity with its type name, and includes allocation-site backtraces + /// when `LEAK_BACKTRACE` is set. + #[cfg(any(test, feature = "leak-detection"))] + pub fn assert_no_new_leaks(&self, snapshot: &LeakDetectorSnapshot) { + self.entities.assert_no_new_leaks(snapshot) + } + /// Quit the application gracefully. Handlers registered with [`Context::on_app_quit`] /// will be given 100ms to complete before exiting. pub fn shutdown(&mut self) { @@ -759,7 +838,7 @@ impl App { let futures = futures::future::join_all(futures); if self - .background_executor + .foreground_executor .block_with_timeout(SHUTDOWN_TIMEOUT, futures) .is_err() { @@ -845,10 +924,12 @@ impl App { &mut self, callback: impl FnOnce(&mut App) -> R, ) -> (R, FxHashSet) { - let accessed_entities_start = self.entities.accessed_entities.borrow().clone(); + let accessed_entities_start = self.entities.accessed_entities.get_mut().clone(); let result = callback(self); - let accessed_entities_end = self.entities.accessed_entities.borrow().clone(); - let entities_accessed_in_callback = accessed_entities_end + let entities_accessed_in_callback = self + .entities + .accessed_entities + .get_mut() .difference(&accessed_entities_start) .copied() .collect::>(); @@ -1075,16 +1156,45 @@ impl App { .cloned() } + /// Returns the current thermal state of the system. + pub fn thermal_state(&self) -> ThermalState { + self.platform.thermal_state() + } + + /// Invokes a handler when the thermal state changes + pub fn on_thermal_state_change(&self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut App), + { + let (subscription, activate) = self.thermal_state_observers.insert( + (), + Box::new(move |cx| { + callback(cx); + true + }), + ); + activate(); + subscription + } + /// Returns the appearance of the application's windows. pub fn window_appearance(&self) -> WindowAppearance { self.platform.window_appearance() } - /// Writes data to the primary selection buffer. - /// Only available on Linux. - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - pub fn write_to_primary(&self, item: ClipboardItem) { - self.platform.write_to_primary(item) + /// Reads data from the platform clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() + } + + /// Sets the text rendering mode for the application. + pub fn set_text_rendering_mode(&mut self, mode: TextRenderingMode) { + self.text_rendering_mode.set(mode); + } + + /// Returns the current text rendering mode for the application. + pub fn text_rendering_mode(&self) -> TextRenderingMode { + self.text_rendering_mode.get() } /// Writes data to the platform clipboard. @@ -1099,9 +1209,31 @@ impl App { self.platform.read_from_primary() } - /// Reads data from the platform clipboard. - pub fn read_from_clipboard(&self) -> Option { - self.platform.read_from_clipboard() + /// Writes data to the primary selection buffer. + /// Only available on Linux. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn write_to_primary(&self, item: ClipboardItem) { + self.platform.write_to_primary(item) + } + + /// Reads data from macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn read_from_find_pasteboard(&self) -> Option { + self.platform.read_from_find_pasteboard() + } + + /// Writes data to macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn write_to_find_pasteboard(&self, item: ClipboardItem) { + self.platform.write_to_find_pasteboard(item) } /// Writes credentials to the platform keychain. @@ -1268,7 +1400,7 @@ impl App { emitter, event_type, event, - } => self.apply_emit_effect(emitter, event_type, event), + } => self.apply_emit_effect(emitter, event_type, &*event), Effect::RefreshWindows => { self.apply_refresh_effect(); @@ -1305,6 +1437,7 @@ impl App { } if self.pending_effects.is_empty() { + self.event_arena.clear(); break; } } @@ -1362,12 +1495,12 @@ impl App { .retain(&emitter, |handler| handler(self)); } - fn apply_emit_effect(&mut self, emitter: EntityId, event_type: TypeId, event: Box) { + fn apply_emit_effect(&mut self, emitter: EntityId, event_type: TypeId, event: &dyn Any) { self.event_listeners .clone() .retain(&emitter, |(stored_type, handler)| { if *stored_type == event_type { - handler(event.as_ref(), self) + handler(event, self) } else { true } @@ -1425,29 +1558,33 @@ impl App { cx.window_update_stack.push(window.handle.id); let result = update(root_view, &mut window, cx); - cx.window_update_stack.pop(); + fn trail(id: WindowId, window: Box, cx: &mut App) -> Option<()> { + cx.window_update_stack.pop(); - if window.removed { - cx.window_handles.remove(&id); - cx.windows.remove(id); + if window.removed { + cx.window_handles.remove(&id); + cx.windows.remove(id); - cx.window_closed_observers.clone().retain(&(), |callback| { - callback(cx); - true - }); + cx.window_closed_observers.clone().retain(&(), |callback| { + callback(cx); + true + }); - let quit_on_empty = match cx.quit_mode { - QuitMode::Explicit => false, - QuitMode::LastWindowClosed => true, - QuitMode::Default => cfg!(not(target_os = "macos")), - }; + let quit_on_empty = match cx.quit_mode { + QuitMode::Explicit => false, + QuitMode::LastWindowClosed => true, + QuitMode::Default => cfg!(not(target_os = "macos")), + }; - if quit_on_empty && cx.windows.is_empty() { - cx.quit(); + if quit_on_empty && cx.windows.is_empty() { + cx.quit(); + } + } else { + cx.windows.get_mut(id)?.replace(window); } - } else { - cx.windows.get_mut(id)?.replace(window); + Some(()) } + trail(id, window, cx)?; Some(result) }) @@ -1492,7 +1629,7 @@ impl App { let mut cx = self.to_async(); self.foreground_executor - .spawn(async move { f(&mut cx).await }) + .spawn(async move { f(&mut cx).await }.boxed_local()) } /// Spawns the future returned by the given function on the main thread with @@ -1510,7 +1647,7 @@ impl App { let mut cx = self.to_async(); self.foreground_executor - .spawn_with_priority(priority, async move { f(&mut cx).await }) + .spawn_with_priority(priority, async move { f(&mut cx).await }.boxed_local()) } /// Schedules the given function to be run at the end of the current effect cycle, allowing entities @@ -1777,7 +1914,10 @@ impl App { /// Register a global handler for actions invoked via the keyboard. These handlers are run at /// the end of the bubble phase for actions, and so will only be invoked if there are no other /// handlers or if they called `cx.propagate()`. - pub fn on_action(&mut self, listener: impl Fn(&A, &mut Self) + 'static) { + pub fn on_action( + &mut self, + listener: impl Fn(&A, &mut Self) + 'static, + ) -> &mut Self { self.global_action_listeners .entry(TypeId::of::()) .or_default() @@ -1787,6 +1927,7 @@ impl App { listener(action, cx) } })); + self } /// Event handlers propagate events by default. Call this method to stop dispatching to @@ -1835,6 +1976,18 @@ impl App { self.actions.action_schemas(generator) } + /// Get the schema for a specific action by name. + /// Returns `None` if the action is not found. + /// Returns `Some(None)` if the action exists but has no schema. + /// Returns `Some(Some(schema))` if the action exists and has a schema. + pub fn action_schema_by_name( + &self, + name: &str, + generator: &mut schemars::SchemaGenerator, + ) -> Option> { + self.actions.action_schema_by_name(name, generator) + } + /// Get a map from a deprecated action name to the canonical name. pub fn deprecated_actions_to_preferred_actions(&self) -> &HashMap<&'static str, &'static str> { self.actions.deprecated_aliases() @@ -1896,8 +2049,11 @@ impl App { pub(crate) fn clear_pending_keystrokes(&mut self) { for window in self.windows() { window - .update(self, |_, window, _| { - window.clear_pending_keystrokes(); + .update(self, |_, window, cx| { + if window.pending_input_keystrokes().is_some() { + window.clear_pending_keystrokes(); + window.pending_input_changed(cx); + } }) .ok(); } @@ -1921,7 +2077,8 @@ impl App { } /// Sets the menu bar for this application. This will replace any existing menu bar. - pub fn set_menus(&self, menus: Vec) { + pub fn set_menus(&self, menus: impl IntoIterator) { + let menus: Vec = menus.into_iter().collect(); self.platform.set_menus(menus, &self.keymap.borrow()); } @@ -1954,7 +2111,7 @@ impl App { &self, menus: Vec, entries: Vec>, - ) -> Vec> { + ) -> Task>> { self.platform.update_jump_list(menus, entries) } @@ -2188,8 +2345,6 @@ impl App { } impl AppContext for App { - type Result = T; - /// Builds an entity that is owned by the application. /// /// The given function will be invoked with a [`Context`] and must return an object representing the entity. An @@ -2211,7 +2366,7 @@ impl AppContext for App { }) } - fn reserve_entity(&mut self) -> Self::Result> { + fn reserve_entity(&mut self) -> Reservation { Reservation(self.entities.reserve()) } @@ -2219,7 +2374,7 @@ impl AppContext for App { &mut self, reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { + ) -> Entity { self.update(|cx| { let slot = reservation.0; let entity = build_entity(&mut Context::new_context(cx, slot.downgrade())); @@ -2252,11 +2407,7 @@ impl AppContext for App { GpuiBorrow::new(handle.clone(), self) } - fn read_entity( - &self, - handle: &Entity, - read: impl FnOnce(&T, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static, { @@ -2301,7 +2452,7 @@ impl AppContext for App { self.background_executor.spawn(future) } - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global, { @@ -2318,7 +2469,7 @@ pub(crate) enum Effect { Emit { emitter: EntityId, event_type: TypeId, - event: Box, + event: ArenaBox, }, RefreshWindows, NotifyGlobalObservers { diff --git a/src/app/async_context.rs b/src/app/async_context.rs index f5dcd30ae9..e8cca03047 100644 --- a/src/app/async_context.rs +++ b/src/app/async_context.rs @@ -1,18 +1,23 @@ use crate::{ AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BorrowAppContext, - Entity, EventEmitter, Focusable, ForegroundExecutor, Global, PromptButton, PromptLevel, Render, - Reservation, Result, Subscription, Task, VisualContext, Window, WindowHandle, + Entity, EventEmitter, Focusable, ForegroundExecutor, Global, GpuiBorrow, PromptButton, + PromptLevel, Render, Reservation, Result, Subscription, Task, VisualContext, Window, + WindowHandle, }; -use anyhow::{Context as _, anyhow}; +use anyhow::{Context as _, bail}; use derive_more::{Deref, DerefMut}; use futures::channel::oneshot; +use futures::future::FutureExt; use std::{future::Future, rc::Weak}; use super::{Context, WeakEntity}; /// An async-friendly version of [App] with a static lifetime so it can be held across `await` points in async code. /// You're provided with an instance when calling [App::spawn], and you can also create one with [App::to_async]. -/// Internally, this holds a weak reference to an `App`, so its methods are fallible to protect against cases where the [App] is dropped. +/// +/// Internally, this holds a weak reference to an `App`. Methods will panic if the app has been dropped, +/// but this should not happen in practice when using foreground tasks spawned via `cx.spawn()`, +/// as the executor checks if the app is alive before running each task. #[derive(Clone)] pub struct AsyncApp { pub(crate) app: Weak, @@ -20,64 +25,61 @@ pub struct AsyncApp { pub(crate) foreground_executor: ForegroundExecutor, } -impl AppContext for AsyncApp { - type Result = Result; +impl AsyncApp { + fn app(&self) -> std::rc::Rc { + self.app + .upgrade() + .expect("app was released before async operation completed") + } +} - fn new( - &mut self, - build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { - let app = self.app.upgrade().context("app was released")?; +impl AppContext for AsyncApp { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { + let app = self.app(); let mut app = app.borrow_mut(); - Ok(app.new(build_entity)) + app.new(build_entity) } - fn reserve_entity(&mut self) -> Result> { - let app = self.app.upgrade().context("app was released")?; + fn reserve_entity(&mut self) -> Reservation { + let app = self.app(); let mut app = app.borrow_mut(); - Ok(app.reserve_entity()) + app.reserve_entity() } fn insert_entity( &mut self, reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, - ) -> Result> { - let app = self.app.upgrade().context("app was released")?; + ) -> Entity { + let app = self.app(); let mut app = app.borrow_mut(); - Ok(app.insert_entity(reservation, build_entity)) + app.insert_entity(reservation, build_entity) } fn update_entity( &mut self, handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, - ) -> Self::Result { - let app = self.app.upgrade().context("app was released")?; + ) -> R { + let app = self.app(); let mut app = app.borrow_mut(); - Ok(app.update_entity(handle, update)) + app.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, _handle: &Entity) -> Self::Result> + fn as_mut<'a, T>(&'a mut self, _handle: &Entity) -> GpuiBorrow<'a, T> where T: 'static, { - Err(anyhow!( - "Cannot as_mut with an async context. Try calling update() first" - )) + panic!("Cannot as_mut with an async context. Try calling update() first") } - fn read_entity( - &self, - handle: &Entity, - callback: impl FnOnce(&T, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, callback: impl FnOnce(&T, &App) -> R) -> R where T: 'static, { - let app = self.app.upgrade().context("app was released")?; + let app = self.app(); let lock = app.borrow(); - Ok(lock.read_entity(handle, callback)) + lock.read_entity(handle, callback) } fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result @@ -86,6 +88,9 @@ impl AppContext for AsyncApp { { let app = self.app.upgrade().context("app was released")?; let mut lock = app.try_borrow_mut()?; + if lock.quitting { + bail!("app is quitting"); + } lock.update_window(window, f) } @@ -99,9 +104,13 @@ impl AppContext for AsyncApp { { let app = self.app.upgrade().context("app was released")?; let lock = app.borrow(); + if lock.quitting { + bail!("app is quitting"); + } lock.read_window(window, read) } + #[track_caller] fn background_spawn(&self, future: impl Future + Send + 'static) -> Task where R: Send + 'static, @@ -109,23 +118,22 @@ impl AppContext for AsyncApp { self.background_executor.spawn(future) } - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global, { - let app = self.app.upgrade().context("app was released")?; + let app = self.app(); let mut lock = app.borrow_mut(); - Ok(lock.update(|this| this.read_global(callback))) + lock.update(|this| this.read_global(callback)) } } impl AsyncApp { /// Schedules all windows in the application to be redrawn. - pub fn refresh(&self) -> Result<()> { - let app = self.app.upgrade().context("app was released")?; + pub fn refresh(&self) { + let app = self.app(); let mut lock = app.borrow_mut(); lock.refresh_windows(); - Ok(()) } /// Get an executor which can be used to spawn futures in the background. @@ -139,10 +147,10 @@ impl AsyncApp { } /// Invoke the given function in the context of the app, then flush any effects produced during its invocation. - pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> Result { - let app = self.app.upgrade().context("app was released")?; + pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { + let app = self.app(); let mut lock = app.borrow_mut(); - Ok(lock.update(f)) + lock.update(f) } /// Arrange for the given callback to be invoked whenever the given entity emits an event of a given type. @@ -150,16 +158,15 @@ impl AsyncApp { pub fn subscribe( &mut self, entity: &Entity, - mut on_event: impl FnMut(Entity, &Event, &mut App) + 'static, - ) -> Result + on_event: impl FnMut(Entity, &Event, &mut App) + 'static, + ) -> Subscription where T: 'static + EventEmitter, Event: 'static, { - let app = self.app.upgrade().context("app was released")?; + let app = self.app(); let mut lock = app.borrow_mut(); - let subscription = lock.subscribe(entity, on_event); - Ok(subscription) + lock.subscribe(entity, on_event) } /// Open a window with the given options based on the root view returned by the given function. @@ -171,8 +178,11 @@ impl AsyncApp { where V: 'static + Render, { - let app = self.app.upgrade().context("app was released")?; + let app = self.app(); let mut lock = app.borrow_mut(); + if lock.quitting { + bail!("app is quitting"); + } lock.open_window(options, build_root_view) } @@ -185,65 +195,57 @@ impl AsyncApp { { let mut cx = self.clone(); self.foreground_executor - .spawn(async move { f(&mut cx).await }) + .spawn(async move { f(&mut cx).await }.boxed_local()) } /// Determine whether global state of the specified type has been assigned. - /// Returns an error if the `App` has been dropped. - pub fn has_global(&self) -> Result { - let app = self.app.upgrade().context("app was released")?; + pub fn has_global(&self) -> bool { + let app = self.app(); let app = app.borrow_mut(); - Ok(app.has_global::()) + app.has_global::() } /// Reads the global state of the specified type, passing it to the given callback. /// /// Panics if no global state of the specified type has been assigned. - /// Returns an error if the `App` has been dropped. - pub fn read_global(&self, read: impl FnOnce(&G, &App) -> R) -> Result { - let app = self.app.upgrade().context("app was released")?; + pub fn read_global(&self, read: impl FnOnce(&G, &App) -> R) -> R { + let app = self.app(); let app = app.borrow_mut(); - Ok(read(app.global(), &app)) + read(app.global(), &app) } /// Reads the global state of the specified type, passing it to the given callback. /// /// Similar to [`AsyncApp::read_global`], but returns an error instead of panicking - /// if no state of the specified type has been assigned. - /// - /// Returns an error if no state of the specified type has been assigned the `App` has been dropped. pub fn try_read_global(&self, read: impl FnOnce(&G, &App) -> R) -> Option { - let app = self.app.upgrade()?; + let app = self.app(); let app = app.borrow_mut(); + if app.quitting { + return None; + } Some(read(app.try_global()?, &app)) } /// Reads the global state of the specified type, passing it to the given callback. /// A default value is assigned if a global of this type has not yet been assigned. - /// - /// # Errors - /// If the app has ben dropped this returns an error. - pub fn try_read_default_global( + pub fn read_default_global( &self, read: impl FnOnce(&G, &App) -> R, - ) -> Result { - let app = self.app.upgrade().context("app was released")?; + ) -> R { + let app = self.app(); let mut app = app.borrow_mut(); app.update(|cx| { cx.default_global::(); }); - Ok(read(app.try_global().context("app was released")?, &app)) + read(app.global(), &app) } /// A convenience method for [`App::update_global`](BorrowAppContext::update_global) /// for updating the global state of the specified type. - pub fn update_global( - &self, - update: impl FnOnce(&mut G, &mut App) -> R, - ) -> Result { - let app = self.app.upgrade().context("app was released")?; + pub fn update_global(&self, update: impl FnOnce(&mut G, &mut App) -> R) -> R { + let app = self.app(); let mut app = app.borrow_mut(); - Ok(app.update(|cx| cx.update_global(update))) + app.update(|cx| cx.update_global(update)) } /// Run something using this entity and cx, when the returned struct is dropped @@ -334,7 +336,7 @@ impl AsyncWindowContext { { let mut cx = self.clone(); self.foreground_executor - .spawn(async move { f(&mut cx).await }) + .spawn(async move { f(&mut cx).await }.boxed_local()) } /// Present a platform dialog. @@ -359,54 +361,41 @@ impl AsyncWindowContext { } impl AppContext for AsyncWindowContext { - type Result = Result; - - fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Result> + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity where T: 'static, { - self.app - .update_window(self.window, |_, _, cx| cx.new(build_entity)) + self.app.new(build_entity) } - fn reserve_entity(&mut self) -> Result> { - self.app - .update_window(self.window, |_, _, cx| cx.reserve_entity()) + fn reserve_entity(&mut self) -> Reservation { + self.app.reserve_entity() } fn insert_entity( &mut self, reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { - self.app.update_window(self.window, |_, _, cx| { - cx.insert_entity(reservation, build_entity) - }) + ) -> Entity { + self.app.insert_entity(reservation, build_entity) } fn update_entity( &mut self, handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, - ) -> Result { - self.app - .update_window(self.window, |_, _, cx| cx.update_entity(handle, update)) + ) -> R { + self.app.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, _: &Entity) -> Self::Result> + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> where T: 'static, { - Err(anyhow!( - "Cannot use as_mut() from an async context, call `update`" - )) + panic!("Cannot use as_mut() from an async context, call `update`") } - fn read_entity( - &self, - handle: &Entity, - read: impl FnOnce(&T, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static, { @@ -431,6 +420,7 @@ impl AppContext for AsyncWindowContext { self.app.read_window(window, read) } + #[track_caller] fn background_spawn(&self, future: impl Future + Send + 'static) -> Task where R: Send + 'static, @@ -438,7 +428,7 @@ impl AppContext for AsyncWindowContext { self.app.background_executor.spawn(future) } - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global, { @@ -447,6 +437,8 @@ impl AppContext for AsyncWindowContext { } impl VisualContext for AsyncWindowContext { + type Result = Result; + fn window_handle(&self) -> AnyWindowHandle { self.window } @@ -454,7 +446,7 @@ impl VisualContext for AsyncWindowContext { fn new_window_entity( &mut self, build_entity: impl FnOnce(&mut Window, &mut Context) -> T, - ) -> Self::Result> { + ) -> Result> { self.app.update_window(self.window, |_, window, cx| { cx.new(|cx| build_entity(window, cx)) }) @@ -464,7 +456,7 @@ impl VisualContext for AsyncWindowContext { &mut self, view: &Entity, update: impl FnOnce(&mut T, &mut Window, &mut Context) -> R, - ) -> Self::Result { + ) -> Result { self.app.update_window(self.window, |_, window, cx| { view.update(cx, |entity, cx| update(entity, window, cx)) }) @@ -473,7 +465,7 @@ impl VisualContext for AsyncWindowContext { fn replace_root_view( &mut self, build_view: impl FnOnce(&mut Window, &mut Context) -> V, - ) -> Self::Result> + ) -> Result> where V: 'static + Render, { @@ -482,12 +474,12 @@ impl VisualContext for AsyncWindowContext { }) } - fn focus(&mut self, view: &Entity) -> Self::Result<()> + fn focus(&mut self, view: &Entity) -> Result<()> where V: Focusable, { self.app.update_window(self.window, |_, window, cx| { - view.read(cx).focus_handle(cx).focus(window); + view.read(cx).focus_handle(cx).focus(window, cx); }) } } diff --git a/src/app/context.rs b/src/app/context.rs index 27ccbecaf8..28d30ab37e 100644 --- a/src/app/context.rs +++ b/src/app/context.rs @@ -285,7 +285,7 @@ impl<'a, T: 'static> Context<'a, T> { /// Focus the given view in the given window. View type is required to implement Focusable. pub fn focus_view(&mut self, view: &Entity, window: &mut Window) { - window.focus(&view.focus_handle(self)); + window.focus(&view.focus_handle(self), self); } /// Sets a given callback to be run on the next frame. @@ -697,11 +697,19 @@ impl<'a, T: 'static> Context<'a, T> { let (subscription, activate) = self.global_observers.insert( TypeId::of::(), Box::new(move |cx| { - window_handle - .update(cx, |_, window, cx| { - view.update(cx, |view, cx| f(view, window, cx)).is_ok() - }) - .unwrap_or(false) + // If the entity has been dropped, remove this observer. + if view.upgrade().is_none() { + return false; + } + // If the window is unavailable (e.g. temporarily taken during a + // nested update, or already closed), skip this notification but + // keep the observer alive so it can fire on future changes. + let Ok(entity_alive) = window_handle.update(cx, |_, window, cx| { + view.update(cx, |view, cx| f(view, window, cx)).is_ok() + }) else { + return true; + }; + entity_alive }), ); self.defer(move |_| activate()); @@ -732,7 +740,7 @@ impl<'a, T: 'static> Context<'a, T> { { let view = self.entity(); window.defer(self, move |window, cx| { - view.read(cx).focus_handle(cx).focus(window) + view.read(cx).focus_handle(cx).focus(window, cx) }) } } @@ -744,17 +752,19 @@ impl Context<'_, T> { T: EventEmitter, Evt: 'static, { + let event = self + .event_arena + .alloc(|| event) + .map(|it| it as &mut dyn Any); self.app.pending_effects.push_back(Effect::Emit { emitter: self.entity_state.entity_id, event_type: TypeId::of::(), - event: Box::new(event), + event, }); } } impl AppContext for Context<'_, T> { - type Result = U; - #[inline] fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> U) -> Entity { self.app.new(build_entity) @@ -770,7 +780,7 @@ impl AppContext for Context<'_, T> { &mut self, reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> U, - ) -> Self::Result> { + ) -> Entity { self.app.insert_entity(reservation, build_entity) } @@ -784,7 +794,7 @@ impl AppContext for Context<'_, T> { } #[inline] - fn as_mut<'a, E>(&'a mut self, handle: &Entity) -> Self::Result> + fn as_mut<'a, E>(&'a mut self, handle: &Entity) -> super::GpuiBorrow<'a, E> where E: 'static, { @@ -792,11 +802,7 @@ impl AppContext for Context<'_, T> { } #[inline] - fn read_entity( - &self, - handle: &Entity, - read: impl FnOnce(&U, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&U, &App) -> R) -> R where U: 'static, { @@ -832,7 +838,7 @@ impl AppContext for Context<'_, T> { } #[inline] - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global, { diff --git a/src/app/entity_map.rs b/src/app/entity_map.rs index 8c1bdfa1ce..78c9a7f5c3 100644 --- a/src/app/entity_map.rs +++ b/src/app/entity_map.rs @@ -11,7 +11,6 @@ use std::{ fmt::{self, Display}, hash::{Hash, Hasher}, marker::PhantomData, - mem, num::NonZeroU64, sync::{ Arc, Weak, @@ -21,7 +20,7 @@ use std::{ }; use super::Context; -use crate::util::atomic_incr_if_not_zero; +use crate::local_util::atomic_incr_if_not_zero; #[cfg(any(test, feature = "leak-detection"))] use collections::HashMap; @@ -60,7 +59,8 @@ pub(crate) struct EntityMap { ref_counts: Arc>, } -struct EntityRefCounts { +#[doc(hidden)] +pub(crate) struct EntityRefCounts { counts: SlotMap, dropped_entity_ids: Vec, #[cfg(any(test, feature = "leak-detection"))] @@ -84,6 +84,32 @@ impl EntityMap { } } + #[doc(hidden)] + pub fn ref_counts_drop_handle(&self) -> Arc> { + self.ref_counts.clone() + } + + /// Captures a snapshot of all entities that currently have alive handles. + /// + /// The returned [`LeakDetectorSnapshot`] can later be passed to + /// [`assert_no_new_leaks`](Self::assert_no_new_leaks) to verify that no + /// entities created after the snapshot are still alive. + #[cfg(any(test, feature = "leak-detection"))] + pub fn leak_detector_snapshot(&self) -> LeakDetectorSnapshot { + self.ref_counts.read().leak_detector.snapshot() + } + + /// Asserts that no entities created after `snapshot` still have alive handles. + /// + /// See [`LeakDetector::assert_no_new_leaks`] for details. + #[cfg(any(test, feature = "leak-detection"))] + pub fn assert_no_new_leaks(&self, snapshot: &LeakDetectorSnapshot) { + self.ref_counts + .read() + .leak_detector + .assert_no_new_leaks(snapshot) + } + /// Reserve a slot for an entity, which you can subsequently use with `insert`. pub fn reserve(&self) -> Slot { let id = self.ref_counts.write().counts.insert(1.into()); @@ -95,7 +121,7 @@ impl EntityMap { where T: 'static, { - let mut accessed_entities = self.accessed_entities.borrow_mut(); + let mut accessed_entities = self.accessed_entities.get_mut(); accessed_entities.insert(slot.entity_id); let handle = slot.0; @@ -107,7 +133,7 @@ impl EntityMap { #[track_caller] pub fn lease(&mut self, pointer: &Entity) -> Lease { self.assert_valid_context(pointer); - let mut accessed_entities = self.accessed_entities.borrow_mut(); + let mut accessed_entities = self.accessed_entities.get_mut(); accessed_entities.insert(pointer.entity_id); let entity = Some( @@ -147,21 +173,20 @@ impl EntityMap { pub fn extend_accessed(&mut self, entities: &FxHashSet) { self.accessed_entities - .borrow_mut() + .get_mut() .extend(entities.iter().copied()); } pub fn clear_accessed(&mut self) { - self.accessed_entities.borrow_mut().clear(); + self.accessed_entities.get_mut().clear(); } pub fn take_dropped(&mut self) -> Vec<(EntityId, Box)> { - let mut ref_counts = self.ref_counts.write(); - let dropped_entity_ids = mem::take(&mut ref_counts.dropped_entity_ids); - let mut accessed_entities = self.accessed_entities.borrow_mut(); + let mut ref_counts = &mut *self.ref_counts.write(); + let dropped_entity_ids = ref_counts.dropped_entity_ids.drain(..); + let mut accessed_entities = self.accessed_entities.get_mut(); dropped_entity_ids - .into_iter() .filter_map(|entity_id| { let count = ref_counts.counts.remove(entity_id).unwrap(); debug_assert_eq!( @@ -227,7 +252,12 @@ pub struct AnyEntity { } impl AnyEntity { - fn new(id: EntityId, entity_type: TypeId, entity_map: Weak>) -> Self { + fn new( + id: EntityId, + entity_type: TypeId, + entity_map: Weak>, + #[cfg(any(test, feature = "leak-detection"))] type_name: &'static str, + ) -> Self { Self { entity_id: id, entity_type, @@ -238,7 +268,7 @@ impl AnyEntity { .unwrap() .write() .leak_detector - .handle_created(id), + .handle_created(id, Some(type_name)), entity_map, } } @@ -301,7 +331,7 @@ impl Clone for AnyEntity { .unwrap() .write() .leak_detector - .handle_created(self.entity_id), + .handle_created(self.entity_id, None), } } } @@ -397,7 +427,13 @@ impl Entity { T: 'static, { Self { - any_entity: AnyEntity::new(id, TypeId::of::(), entity_map), + any_entity: AnyEntity::new( + id, + TypeId::of::(), + entity_map, + #[cfg(any(test, feature = "leak-detection"))] + std::any::type_name::(), + ), entity_type: PhantomData, } } @@ -431,11 +467,7 @@ impl Entity { /// Read the entity referenced by this handle with the given function. #[inline] - pub fn read_with( - &self, - cx: &C, - f: impl FnOnce(&T, &App) -> R, - ) -> C::Result { + pub fn read_with(&self, cx: &C, f: impl FnOnce(&T, &App) -> R) -> R { cx.read_entity(self, f) } @@ -445,18 +477,18 @@ impl Entity { &self, cx: &mut C, update: impl FnOnce(&mut T, &mut Context) -> R, - ) -> C::Result { + ) -> R { cx.update_entity(self, update) } /// Updates the entity referenced by this handle with the given function. #[inline] - pub fn as_mut<'a, C: AppContext>(&self, cx: &'a mut C) -> C::Result> { + pub fn as_mut<'a, C: AppContext>(&self, cx: &'a mut C) -> GpuiBorrow<'a, T> { cx.as_mut(self) } /// Updates the entity referenced by this handle with the given function. - pub fn write(&self, cx: &mut C, value: T) -> C::Result<()> { + pub fn write(&self, cx: &mut C, value: T) { self.update(cx, |entity, cx| { *entity = value; cx.notify(); @@ -465,7 +497,7 @@ impl Entity { /// Updates the entity referenced by this handle with the given function if /// the referenced entity still exists, within a visual context that has a window. - /// Returns an error if the entity has been released. + /// Returns an error if the window has been closed. #[inline] pub fn update_in( &self, @@ -580,7 +612,7 @@ impl AnyWeakEntity { .unwrap() .write() .leak_detector - .handle_created(self.entity_id), + .handle_created(self.entity_id, None), }) } @@ -749,13 +781,9 @@ impl WeakEntity { ) -> Result where C: AppContext, - Result>: crate::Flatten, { - crate::Flatten::flatten( - self.upgrade() - .context("entity released") - .map(|this| cx.update_entity(&this, update)), - ) + let entity = self.upgrade().context("entity released")?; + Ok(cx.update_entity(&entity, update)) } /// Updates the entity referenced by this handle with the given function if @@ -768,14 +796,13 @@ impl WeakEntity { ) -> Result where C: VisualContext, - Result>: crate::Flatten, { let window = cx.window_handle(); - let this = self.upgrade().context("entity released")?; + let entity = self.upgrade().context("entity released")?; - crate::Flatten::flatten(window.update(cx, |_, window, cx| { - this.update(cx, |entity, cx| update(entity, window, cx)) - })) + window.update(cx, |_, window, cx| { + entity.update(cx, |entity, cx| update(entity, window, cx)) + }) } /// Reads the entity referenced by this handle with the given function if @@ -784,13 +811,9 @@ impl WeakEntity { pub fn read_with(&self, cx: &C, read: impl FnOnce(&T, &App) -> R) -> Result where C: AppContext, - Result>: crate::Flatten, { - crate::Flatten::flatten( - self.upgrade() - .context("entity released") - .map(|this| cx.read_entity(&this, read)), - ) + let entity = self.upgrade().context("entity released")?; + Ok(cx.read_entity(&entity, read)) } /// Create a new weak entity that can never be upgraded. @@ -907,7 +930,23 @@ pub(crate) struct HandleId { #[cfg(any(test, feature = "leak-detection"))] pub(crate) struct LeakDetector { next_handle_id: u64, - entity_handles: HashMap>>, + entity_handles: HashMap, +} + +/// A snapshot of the set of alive entities at a point in time. +/// +/// Created by [`LeakDetector::snapshot`]. Can later be passed to +/// [`LeakDetector::assert_no_new_leaks`] to verify that no new entity +/// handles remain between the snapshot and the current state. +#[cfg(any(test, feature = "leak-detection"))] +pub struct LeakDetectorSnapshot { + entity_ids: collections::HashSet, +} + +#[cfg(any(test, feature = "leak-detection"))] +struct EntityLeakData { + handles: HashMap>, + type_name: &'static str, } #[cfg(any(test, feature = "leak-detection"))] @@ -918,11 +957,21 @@ impl LeakDetector { /// the handle is dropped. If `LEAK_BACKTRACE` is set, captures a backtrace /// at the allocation site. #[track_caller] - pub fn handle_created(&mut self, entity_id: EntityId) -> HandleId { + pub fn handle_created( + &mut self, + entity_id: EntityId, + type_name: Option<&'static str>, + ) -> HandleId { let id = util::post_inc(&mut self.next_handle_id); let handle_id = HandleId { id }; - let handles = self.entity_handles.entry(entity_id).or_default(); - handles.insert( + let handles = self + .entity_handles + .entry(entity_id) + .or_insert_with(|| EntityLeakData { + handles: HashMap::default(), + type_name: type_name.unwrap_or(""), + }); + handles.handles.insert( handle_id, LEAK_BACKTRACE.then(backtrace::Backtrace::new_unresolved), ); @@ -934,8 +983,14 @@ impl LeakDetector { /// This removes the handle from tracking. The `handle_id` should be the same /// one returned by `handle_created` when the handle was allocated. pub fn handle_released(&mut self, entity_id: EntityId, handle_id: HandleId) { - let handles = self.entity_handles.entry(entity_id).or_default(); - handles.remove(&handle_id); + if let std::collections::hash_map::Entry::Occupied(mut data) = + self.entity_handles.entry(entity_id) + { + data.get_mut().handles.remove(&handle_id); + if data.get().handles.is_empty() { + data.remove(); + } + } } /// Asserts that all handles to the given entity have been released. @@ -947,11 +1002,10 @@ impl LeakDetector { /// otherwise it suggests setting the environment variable to get more info. pub fn assert_released(&mut self, entity_id: EntityId) { use std::fmt::Write as _; - let handles = self.entity_handles.entry(entity_id).or_default(); - if !handles.is_empty() { + if let Some(data) = self.entity_handles.remove(&entity_id) { let mut out = String::new(); - for backtrace in handles.values_mut() { - if let Some(mut backtrace) = backtrace.take() { + for (_, backtrace) in data.handles { + if let Some(mut backtrace) = backtrace { backtrace.resolve(); writeln!(out, "Leaked handle:\n{:?}", backtrace).unwrap(); } else { @@ -965,6 +1019,96 @@ impl LeakDetector { panic!("{out}"); } } + + /// Captures a snapshot of all entity IDs that currently have alive handles. + /// + /// The returned [`LeakDetectorSnapshot`] can later be passed to + /// [`assert_no_new_leaks`](Self::assert_no_new_leaks) to verify that no + /// entities created after the snapshot are still alive. + pub fn snapshot(&self) -> LeakDetectorSnapshot { + LeakDetectorSnapshot { + entity_ids: self.entity_handles.keys().copied().collect(), + } + } + + /// Asserts that no entities created after `snapshot` still have alive handles. + /// + /// Entities that were already tracked at the time of the snapshot are ignored, + /// even if they still have handles. Only *new* entities (those whose + /// `EntityId` was not present in the snapshot) are considered leaks. + /// + /// # Panics + /// + /// Panics if any new entity handles exist. The panic message lists every + /// leaked entity with its type name, and includes allocation-site backtraces + /// when `LEAK_BACKTRACE` is set. + pub fn assert_no_new_leaks(&self, snapshot: &LeakDetectorSnapshot) { + use std::fmt::Write as _; + + let mut out = String::new(); + for (entity_id, data) in &self.entity_handles { + if snapshot.entity_ids.contains(entity_id) { + continue; + } + for (_, backtrace) in &data.handles { + if let Some(backtrace) = backtrace { + let mut backtrace = backtrace.clone(); + backtrace.resolve(); + writeln!( + out, + "Leaked handle for entity {} ({entity_id:?}):\n{:?}", + data.type_name, backtrace + ) + .unwrap(); + } else { + writeln!( + out, + "Leaked handle for entity {} ({entity_id:?}): (export LEAK_BACKTRACE to find allocation site)", + data.type_name + ) + .unwrap(); + } + } + } + + if !out.is_empty() { + panic!("New entity leaks detected since snapshot:\n{out}"); + } + } +} + +#[cfg(any(test, feature = "leak-detection"))] +impl Drop for LeakDetector { + fn drop(&mut self) { + use std::fmt::Write; + + if self.entity_handles.is_empty() || std::thread::panicking() { + return; + } + + let mut out = String::new(); + for (entity_id, data) in self.entity_handles.drain() { + for (_handle, backtrace) in data.handles { + if let Some(mut backtrace) = backtrace { + backtrace.resolve(); + writeln!( + out, + "Leaked handle for entity {} ({entity_id:?}):\n{:?}", + data.type_name, backtrace + ) + .unwrap(); + } else { + writeln!( + out, + "Leaked handle for entity {} ({entity_id:?}): (export LEAK_BACKTRACE to find allocation site)", + data.type_name + ) + .unwrap(); + } + } + } + panic!("Exited with leaked handles:\n{out}"); + } } #[cfg(test)] @@ -1022,4 +1166,42 @@ mod test { vec![1], ); } + + #[test] + fn test_leak_detector_snapshot_no_leaks() { + let mut entity_map = EntityMap::new(); + + let slot = entity_map.reserve::(); + let pre_existing = entity_map.insert(slot, TestEntity { i: 1 }); + + let snapshot = entity_map.leak_detector_snapshot(); + + let slot = entity_map.reserve::(); + let temporary = entity_map.insert(slot, TestEntity { i: 2 }); + drop(temporary); + + entity_map.assert_no_new_leaks(&snapshot); + + drop(pre_existing); + } + + #[test] + #[should_panic(expected = "New entity leaks detected since snapshot")] + fn test_leak_detector_snapshot_detects_new_leak() { + let mut entity_map = EntityMap::new(); + + let slot = entity_map.reserve::(); + let pre_existing = entity_map.insert(slot, TestEntity { i: 1 }); + + let snapshot = entity_map.leak_detector_snapshot(); + + let slot = entity_map.reserve::(); + let leaked = entity_map.insert(slot, TestEntity { i: 2 }); + + // `leaked` is still alive, so this should panic. + entity_map.assert_no_new_leaks(&snapshot); + + drop(pre_existing); + drop(leaked); + } } diff --git a/src/app/headless_app_context.rs b/src/app/headless_app_context.rs new file mode 100644 index 0000000000..90dc8c8f0c --- /dev/null +++ b/src/app/headless_app_context.rs @@ -0,0 +1,275 @@ +//! Cross-platform headless app context for tests that need real text shaping. +//! +//! This replaces the macOS-only `HeadlessMetalAppContext` with a platform-neutral +//! implementation backed by `TestPlatform`. Tests supply a real `PlatformTextSystem` +//! (e.g. `DirectWriteTextSystem` on Windows, `MacTextSystem` on macOS) to get +//! accurate glyph measurements while keeping everything else deterministic. +//! +//! Optionally, a renderer factory can be provided to enable real GPU rendering +//! and screenshot capture via [`HeadlessAppContext::capture_screenshot`]. + +use crate::{ + AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds, + Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer, + PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem, + Window, WindowBounds, WindowHandle, WindowOptions, + app::{GpuiBorrow, GpuiMode}, +}; +use anyhow::Result; +use image::RgbaImage; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A cross-platform headless app context for tests that need real text shaping. +/// +/// Unlike the old `HeadlessMetalAppContext`, this works on any platform. It uses +/// `TestPlatform` for deterministic scheduling and accepts a pluggable +/// `PlatformTextSystem` so tests get real glyph measurements. +/// +/// # Usage +/// +/// ```ignore +/// let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new("fallback")); +/// let mut cx = HeadlessAppContext::with_platform( +/// text_system, +/// Arc::new(Assets), +/// || gpui_platform::current_headless_renderer(), +/// ); +/// ``` +pub struct HeadlessAppContext { + /// The underlying app cell. + pub app: Rc, + /// The background executor for running async tasks. + pub background_executor: BackgroundExecutor, + /// The foreground executor for running tasks on the main thread. + pub foreground_executor: ForegroundExecutor, + dispatcher: TestDispatcher, + text_system: Arc, +} + +impl HeadlessAppContext { + /// Creates a new headless app context with the given text system. + pub fn new(platform_text_system: Arc) -> Self { + Self::with_platform(platform_text_system, Arc::new(()), || None) + } + + /// Creates a new headless app context with a custom text system and asset source. + pub fn with_asset_source( + platform_text_system: Arc, + asset_source: Arc, + ) -> Self { + Self::with_platform(platform_text_system, asset_source, || None) + } + + /// Creates a new headless app context with the given text system, asset source, + /// and an optional renderer factory for screenshot support. + pub fn with_platform( + platform_text_system: Arc, + asset_source: Arc, + renderer_factory: impl Fn() -> Option> + 'static, + ) -> Self { + let seed = std::env::var("SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + + let renderer_factory: Box Option>> = + Box::new(renderer_factory); + let platform = TestPlatform::with_platform( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system.clone(), + Some(renderer_factory), + ); + + let text_system = Arc::new(TextSystem::new(platform_text_system)); + let http_client = http_client::FakeHttpClient::with_404_response(); + let app = App::new_app(platform, asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + background_executor, + foreground_executor, + dispatcher, + text_system, + } + } + + /// Opens a window for headless rendering. + pub fn open_window( + &mut self, + size: Size, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{point, px}; + + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size, + }; + + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + build_root, + ) + } + + /// Runs all pending tasks until parked. + pub fn run_until_parked(&self) { + self.dispatcher.run_until_parked(); + } + + /// Advances the simulated clock. + pub fn advance_clock(&self, duration: Duration) { + self.dispatcher.advance_clock(duration); + } + + /// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading). + pub fn allow_parking(&self) { + self.dispatcher.allow_parking(); + } + + /// Disables parking mode, returning to deterministic test execution. + pub fn forbid_parking(&self) { + self.dispatcher.forbid_parking(); + } + + /// Updates app state. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + f(&mut app) + } + + /// Updates a window and calls draw to render. + pub fn update_window( + &mut self, + window: AnyWindowHandle, + f: impl FnOnce(AnyView, &mut Window, &mut App) -> R, + ) -> Result { + let mut app = self.app.borrow_mut(); + app.update_window(window, f) + } + + /// Captures a screenshot from a window. + /// + /// Requires that the context was created with a renderer factory that + /// returns `Some` via [`HeadlessAppContext::with_platform`]. + pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result { + let mut app = self.app.borrow_mut(); + app.update_window(window, |_, window, _| window.render_to_image())? + } + + /// Returns the text system. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Returns the background executor. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Returns the foreground executor. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } +} + +impl Drop for HeadlessAppContext { + fn drop(&mut self) { + // Shut down the app so windows are closed and entity handles are + // released before the LeakDetector runs. + self.app.borrow_mut().shutdown(); + } +} + +impl AppContext for HeadlessAppContext { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { + let mut app = self.app.borrow_mut(); + app.new(build_entity) + } + + fn reserve_entity(&mut self) -> Reservation { + let mut app = self.app.borrow_mut(); + app.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Entity { + let mut app = self.app.borrow_mut(); + app.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + let mut app = self.app.borrow_mut(); + app.update_entity(handle, update) + } + + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> + where + T: 'static, + { + panic!("Cannot use as_mut with HeadlessAppContext. Call update() instead.") + } + + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R + where + T: 'static, + { + let app = self.app.borrow(); + app.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R + where + G: Global, + { + let app = self.app.borrow(); + app.read_global(callback) + } +} diff --git a/src/app/test_app.rs b/src/app/test_app.rs new file mode 100644 index 0000000000..268fa891b5 --- /dev/null +++ b/src/app/test_app.rs @@ -0,0 +1,607 @@ +//! A clean testing API for GPUI applications. +//! +//! `TestApp` provides a simpler alternative to `TestAppContext` with: +//! - Automatic effect flushing after updates +//! - Clean window creation and inspection +//! - Input simulation helpers +//! +//! # Example +//! ```ignore +//! #[test] +//! fn test_my_view() { +//! let mut app = TestApp::new(); +//! +//! let mut window = app.open_window(|window, cx| { +//! MyView::new(window, cx) +//! }); +//! +//! window.update(|view, window, cx| { +//! view.do_something(cx); +//! }); +//! +//! // Check rendered state +//! assert_eq!(window.title(), Some("Expected Title")); +//! } +//! ``` + +use crate::{ + AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext, + Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, + PlatformTextSystem, Point, Render, Size, Task, TestDispatcher, TestPlatform, TextSystem, + Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, +}; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A test application context with a clean API. +/// +/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after +/// each update and provides simpler window management. +pub struct TestApp { + app: Rc, + platform: Rc, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + #[allow(dead_code)] + dispatcher: TestDispatcher, + text_system: Arc, +} + +impl TestApp { + /// Create a new test application. + pub fn new() -> Self { + Self::with_seed(0) + } + + /// Create a new test application with a specific random seed. + pub fn with_seed(seed: u64) -> Self { + Self::build(seed, None, Arc::new(())) + } + + /// Create a new test application with a custom text system for real font shaping. + pub fn with_text_system(text_system: Arc) -> Self { + Self::build(0, Some(text_system), Arc::new(())) + } + + /// Create a new test application with a custom text system and asset source. + pub fn with_text_system_and_assets( + text_system: Arc, + asset_source: Arc, + ) -> Self { + Self::build(0, Some(text_system), asset_source) + } + + fn build( + seed: u64, + platform_text_system: Option>, + asset_source: Arc, + ) -> Self { + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + let platform = match platform_text_system.clone() { + Some(ts) => TestPlatform::with_text_system( + background_executor.clone(), + foreground_executor.clone(), + ts, + ), + None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()), + }; + let http_client = http_client::FakeHttpClient::with_404_response(); + let text_system = Arc::new(TextSystem::new( + platform_text_system.unwrap_or_else(|| platform.text_system.clone()), + )); + + let app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + platform, + background_executor, + foreground_executor, + dispatcher, + text_system, + } + } + + /// Run a closure with mutable access to the App context. + /// Automatically runs until parked after the closure completes. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let result = { + let mut app = self.app.borrow_mut(); + app.update(f) + }; + self.run_until_parked(); + result + } + + /// Run a closure with read-only access to the App context. + pub fn read(&self, f: impl FnOnce(&App) -> R) -> R { + let app = self.app.borrow(); + f(&app) + } + + /// Create a new entity in the app. + pub fn new_entity( + &mut self, + build: impl FnOnce(&mut Context) -> T, + ) -> Entity { + self.update(|cx| cx.new(build)) + } + + /// Update an entity. + pub fn update_entity( + &mut self, + entity: &Entity, + f: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + self.update(|cx| entity.update(cx, f)) + } + + /// Read an entity. + pub fn read_entity( + &self, + entity: &Entity, + f: impl FnOnce(&T, &App) -> R, + ) -> R { + self.read(|cx| f(entity.read(cx), cx)) + } + + /// Open a test window with the given root view, using maximized bounds. + pub fn open_window( + &mut self, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> TestAppWindow { + let bounds = self.read(|cx| Bounds::maximized(None, cx)); + let handle = self.update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_view(window, cx)), + ) + .unwrap() + }); + + TestAppWindow { + handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } + + /// Open a test window with specific options. + pub fn open_window_with_options( + &mut self, + options: WindowOptions, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> TestAppWindow { + let handle = self.update(|cx| { + cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx))) + .unwrap() + }); + + TestAppWindow { + handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } + + /// Run pending tasks until there's nothing left to do. + pub fn run_until_parked(&self) { + self.background_executor.run_until_parked(); + } + + /// Advance the simulated clock by the given duration. + pub fn advance_clock(&self, duration: Duration) { + self.background_executor.advance_clock(duration); + } + + /// Spawn a future on the foreground executor. + pub fn spawn(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task + where + Fut: Future + 'static, + R: 'static, + { + self.foreground_executor.spawn(f(self.to_async())) + } + + /// Spawn a future on the background executor. + pub fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + /// Get an async handle to the app. + pub fn to_async(&self) -> AsyncApp { + AsyncApp { + app: Rc::downgrade(&self.app), + background_executor: self.background_executor.clone(), + foreground_executor: self.foreground_executor.clone(), + } + } + + /// Get the background executor. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Get the foreground executor. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } + + /// Get the text system. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Check if a global of the given type exists. + pub fn has_global(&self) -> bool { + self.read(|cx| cx.has_global::()) + } + + /// Set a global value. + pub fn set_global(&mut self, global: G) { + self.update(|cx| cx.set_global(global)); + } + + /// Read a global value. + pub fn read_global(&self, f: impl FnOnce(&G, &App) -> R) -> R { + self.read(|cx| f(cx.global(), cx)) + } + + /// Update a global value. + pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R { + self.update(|cx| cx.update_global(f)) + } + + // Platform simulation methods + + /// Write text to the simulated clipboard. + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform.write_to_clipboard(item); + } + + /// Read from the simulated clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() + } + + /// Get URLs that have been opened via `cx.open_url()`. + pub fn opened_url(&self) -> Option { + self.platform.opened_url.borrow().clone() + } + + /// Check if a file path prompt is pending. + pub fn did_prompt_for_new_path(&self) -> bool { + self.platform.did_prompt_for_new_path() + } + + /// Simulate answering a path selection dialog. + pub fn simulate_new_path_selection( + &self, + select: impl FnOnce(&std::path::Path) -> Option, + ) { + self.platform.simulate_new_path_selection(select); + } + + /// Check if a prompt dialog is pending. + pub fn has_pending_prompt(&self) -> bool { + self.platform.has_pending_prompt() + } + + /// Simulate answering a prompt dialog. + pub fn simulate_prompt_answer(&self, button: &str) { + self.platform.simulate_prompt_answer(button); + } + + /// Get all open windows. + pub fn windows(&self) -> Vec { + self.read(|cx| cx.windows()) + } +} + +impl Default for TestApp { + fn default() -> Self { + Self::new() + } +} + +/// A test window with inspection and simulation capabilities. +pub struct TestAppWindow { + handle: WindowHandle, + app: Rc, + platform: Rc, + background_executor: BackgroundExecutor, +} + +impl TestAppWindow { + /// Get the window handle. + pub fn handle(&self) -> WindowHandle { + self.handle + } + + /// Get the root view entity. + pub fn root(&self) -> Entity { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |root_view, _, _| { + root_view.downcast::().expect("root view type mismatch") + }) + .expect("window not found") + } + + /// Update the root view. + pub fn update(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context) -> R) -> R { + let result = { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |root_view, window, cx| { + let view = root_view.downcast::().expect("root view type mismatch"); + view.update(cx, |view, cx| f(view, window, cx)) + }) + .expect("window not found") + }; + self.background_executor.run_until_parked(); + result + } + + /// Read the root view. + pub fn read(&self, f: impl FnOnce(&V, &App) -> R) -> R { + let app = self.app.borrow(); + let view = self + .app + .borrow() + .windows + .get(self.handle.window_id()) + .and_then(|w| w.as_ref()) + .and_then(|w| w.root.clone()) + .and_then(|r| r.downcast::().ok()) + .expect("window or root view not found"); + f(view.read(&app), &app) + } + + /// Get the window title. + pub fn title(&self) -> Option { + let app = self.app.borrow(); + app.read_window(&self.handle, |_, _cx| { + // TODO: expose title through Window API + None + }) + .unwrap() + } + + /// Simulate a keystroke. + pub fn simulate_keystroke(&mut self, keystroke: &str) { + let keystroke = Keystroke::parse(keystroke).unwrap(); + { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.dispatch_keystroke(keystroke, cx); + }) + .unwrap(); + } + self.background_executor.run_until_parked(); + } + + /// Simulate multiple keystrokes (space-separated). + pub fn simulate_keystrokes(&mut self, keystrokes: &str) { + for keystroke in keystrokes.split(' ') { + self.simulate_keystroke(keystroke); + } + } + + /// Simulate typing text. + pub fn simulate_input(&mut self, input: &str) { + for char in input.chars() { + self.simulate_keystroke(&char.to_string()); + } + } + + /// Simulate a mouse move. + pub fn simulate_mouse_move(&mut self, position: Point) { + self.simulate_event(MouseMoveEvent { + position, + modifiers: Default::default(), + pressed_button: None, + }); + } + + /// Simulate a mouse down event. + pub fn simulate_mouse_down(&mut self, position: Point, button: MouseButton) { + self.simulate_event(MouseDownEvent { + position, + button, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + }); + } + + /// Simulate a mouse up event. + pub fn simulate_mouse_up(&mut self, position: Point, button: MouseButton) { + self.simulate_event(MouseUpEvent { + position, + button, + modifiers: Default::default(), + click_count: 1, + }); + } + + /// Simulate a click at the given position. + pub fn simulate_click(&mut self, position: Point, button: MouseButton) { + self.simulate_mouse_down(position, button); + self.simulate_mouse_up(position, button); + } + + /// Simulate a scroll event. + pub fn simulate_scroll(&mut self, position: Point, delta: Point) { + self.simulate_event(crate::ScrollWheelEvent { + position, + delta: crate::ScrollDelta::Pixels(delta), + modifiers: Default::default(), + touch_phase: crate::TouchPhase::Moved, + }); + } + + /// Simulate an input event. + pub fn simulate_event(&mut self, event: E) { + let platform_input = event.to_platform_input(); + { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.dispatch_event(platform_input, cx); + }) + .unwrap(); + } + self.background_executor.run_until_parked(); + } + + /// Simulate resizing the window. + pub fn simulate_resize(&mut self, size: Size) { + let window_id = self.handle.window_id(); + let mut app = self.app.borrow_mut(); + if let Some(Some(window)) = app.windows.get_mut(window_id) { + if let Some(test_window) = window.platform_window.as_test() { + test_window.simulate_resize(size); + } + } + drop(app); + self.background_executor.run_until_parked(); + } + + /// Force a redraw of the window. + pub fn draw(&mut self) { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + } +} + +impl Clone for TestAppWindow { + fn clone(&self) -> Self { + Self { + handle: self.handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FocusHandle, Focusable, div, prelude::*}; + + struct Counter { + count: usize, + focus_handle: FocusHandle, + } + + impl Counter { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + count: 0, + focus_handle, + } + } + + fn increment(&mut self, _cx: &mut Context) { + self.count += 1; + } + } + + impl Focusable for Counter { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for Counter { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child(format!("Count: {}", self.count)) + } + } + + #[test] + fn test_basic_usage() { + let mut app = TestApp::new(); + + let mut window = app.open_window(Counter::new); + + window.update(|counter, _window, cx| { + counter.increment(cx); + }); + + window.read(|counter, _| { + assert_eq!(counter.count, 1); + }); + + drop(window); + app.update(|cx| cx.shutdown()); + } + + #[test] + fn test_entity_creation() { + let mut app = TestApp::new(); + + let entity = app.new_entity(|cx| Counter { + count: 42, + focus_handle: cx.focus_handle(), + }); + + app.read_entity(&entity, |counter, _| { + assert_eq!(counter.count, 42); + }); + + app.update_entity(&entity, |counter, _cx| { + counter.count += 1; + }); + + app.read_entity(&entity, |counter, _| { + assert_eq!(counter.count, 43); + }); + } + + #[test] + fn test_globals() { + let mut app = TestApp::new(); + + struct MyGlobal(String); + impl Global for MyGlobal {} + + assert!(!app.has_global::()); + + app.set_global(MyGlobal("hello".into())); + + assert!(app.has_global::()); + + app.read_global::(|global, _| { + assert_eq!(global.0, "hello"); + }); + + app.update_global::(|global, _| { + global.0 = "world".into(); + }); + + app.read_global::(|global, _| { + assert_eq!(global.0, "world"); + }); + } +} diff --git a/src/app/test_context.rs b/src/app/test_context.rs index 5be2e394e8..d8f459df3c 100644 --- a/src/app/test_context.rs +++ b/src/app/test_context.rs @@ -5,11 +5,11 @@ use crate::{ ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, - WindowHandle, WindowOptions, app::GpuiMode, + WindowHandle, WindowOptions, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; -use rand::{SeedableRng, rngs::StdRng}; + use std::{ cell::RefCell, future::Future, ops::Deref, path::PathBuf, rc::Rc, sync::Arc, time::Duration, }; @@ -18,8 +18,6 @@ use std::{ /// an implementation of `Context` with additional methods that are useful in tests. #[derive(Clone)] pub struct TestAppContext { - #[doc(hidden)] - pub app: Rc, #[doc(hidden)] pub background_executor: BackgroundExecutor, #[doc(hidden)] @@ -30,20 +28,17 @@ pub struct TestAppContext { text_system: Arc, fn_name: Option<&'static str>, on_quit: Rc>>>, + #[doc(hidden)] + pub app: Rc, } impl AppContext for TestAppContext { - type Result = T; - - fn new( - &mut self, - build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut app = self.app.borrow_mut(); app.new(build_entity) } - fn reserve_entity(&mut self) -> Self::Result> { + fn reserve_entity(&mut self) -> crate::Reservation { let mut app = self.app.borrow_mut(); app.reserve_entity() } @@ -52,7 +47,7 @@ impl AppContext for TestAppContext { &mut self, reservation: crate::Reservation, build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { + ) -> Entity { let mut app = self.app.borrow_mut(); app.insert_entity(reservation, build_entity) } @@ -61,23 +56,19 @@ impl AppContext for TestAppContext { &mut self, handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, - ) -> Self::Result { + ) -> R { let mut app = self.app.borrow_mut(); app.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, _: &Entity) -> Self::Result> + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> super::GpuiBorrow<'a, T> where T: 'static, { panic!("Cannot use as_mut with a test app context. Try calling update() first") } - fn read_entity( - &self, - handle: &Entity, - read: impl FnOnce(&T, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static, { @@ -112,7 +103,7 @@ impl AppContext for TestAppContext { self.background_executor.spawn(future) } - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global, { @@ -132,7 +123,7 @@ impl TestAppContext { let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); - let mut app = App::new_app(platform.clone(), asset_source, http_client); + let app = App::new_app(platform.clone(), asset_source, http_client); app.borrow_mut().mode = GpuiMode::test(); Self { @@ -154,7 +145,7 @@ impl TestAppContext { /// Create a single TestAppContext, for non-multi-client tests pub fn single() -> Self { - let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); + let dispatcher = TestDispatcher::new(0); Self::build(dispatcher, None) } @@ -241,6 +232,33 @@ impl TestAppContext { .unwrap() } + /// Opens a new window with a specific size. + /// + /// Unlike `add_window` which uses maximized bounds, this allows controlling + /// the window dimensions, which is important for layout-sensitive tests. + pub fn open_window( + &mut self, + window_size: Size, + build_window: F, + ) -> WindowHandle + where + F: FnOnce(&mut Window, &mut Context) -> V, + V: 'static + Render, + { + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Point::default(), + size: window_size, + })), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_window(window, cx)), + ) + .unwrap() + } + /// Adds a new window with no content. pub fn add_empty_window(&mut self) -> &mut VisualTestContext { let mut cx = self.app.borrow_mut(); @@ -411,8 +429,8 @@ impl TestAppContext { } /// Wait until there are no more pending tasks. - pub fn run_until_parked(&mut self) { - self.background_executor.run_until_parked() + pub fn run_until_parked(&self) { + self.dispatcher.run_until_parked(); } /// Simulate dispatching an action to the currently focused node in the window. @@ -530,22 +548,25 @@ impl TestAppContext { let mut notifications = self.notifications(entity); use futures::FutureExt as _; - use smol::future::FutureExt as _; + use futures_concurrency::future::Race as _; - async { - loop { - if entity.update(self, &mut predicate) { - return Ok(()); - } + ( + async { + loop { + if entity.update(self, &mut predicate) { + return Ok(()); + } - if notifications.next().await.is_none() { - bail!("entity dropped") + if notifications.next().await.is_none() { + bail!("entity dropped") + } } - } - } - .race(timer.map(|_| Err(anyhow!("condition timed out")))) - .await - .unwrap(); + }, + timer.map(|_| Err(anyhow!("condition timed out"))), + ) + .race() + .await + .unwrap(); } /// Set a name for this App. @@ -594,20 +615,13 @@ impl Entity { tx.try_send(()).ok(); }); - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - cx.executor().advance_clock(advance_clock_by); async move { - let notification = crate::util::smol_timeout(duration, rx.recv()) + rx.recv() .await - .expect("next notification timed out"); + .expect("entity dropped while test was waiting for its next notification"); drop(subscription); - notification.expect("entity dropped while test was waiting for its next notification") } } } @@ -647,31 +661,25 @@ impl Entity { let handle = self.downgrade(); async move { - crate::util::smol_timeout(Duration::from_secs(1), async move { - loop { - { - let cx = cx.borrow(); - let cx = &*cx; - if predicate( - handle - .upgrade() - .expect("view dropped with pending condition") - .read(cx), - cx, - ) { - break; - } + loop { + { + let cx = cx.borrow(); + let cx = &*cx; + if predicate( + handle + .upgrade() + .expect("view dropped with pending condition") + .read(cx), + cx, + ) { + break; } - - cx.borrow().background_executor().start_waiting(); - rx.recv() - .await - .expect("view dropped with pending condition"); - cx.borrow().background_executor().finish_waiting(); } - }) - .await - .expect("condition timed out"); + + rx.recv() + .await + .expect("view dropped with pending condition"); + } drop(subscriptions); } } @@ -838,6 +846,8 @@ impl VisualTestContext { E: Element, { self.update(|window, cx| { + let _arena_scope = ElementArenaScope::enter(&cx.element_arena); + window.invalidator.set_phase(DrawPhase::Prepaint); let mut element = Drawable::new(f(window, cx)); element.layout_as_root(space.into(), window, cx); @@ -849,6 +859,9 @@ impl VisualTestContext { window.invalidator.set_phase(DrawPhase::None); window.refresh(); + drop(element); + cx.element_arena.borrow_mut().clear(); + (request_layout_state, prepaint_state) }) } @@ -916,16 +929,11 @@ impl VisualTestContext { } impl AppContext for VisualTestContext { - type Result = ::Result; - - fn new( - &mut self, - build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { self.cx.new(build_entity) } - fn reserve_entity(&mut self) -> Self::Result> { + fn reserve_entity(&mut self) -> crate::Reservation { self.cx.reserve_entity() } @@ -933,7 +941,7 @@ impl AppContext for VisualTestContext { &mut self, reservation: crate::Reservation, build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result> { + ) -> Entity { self.cx.insert_entity(reservation, build_entity) } @@ -941,25 +949,21 @@ impl AppContext for VisualTestContext { &mut self, handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, - ) -> Self::Result + ) -> R where T: 'static, { self.cx.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> Self::Result> + fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> super::GpuiBorrow<'a, T> where T: 'static, { self.cx.as_mut(handle) } - fn read_entity( - &self, - handle: &Entity, - read: impl FnOnce(&T, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static, { @@ -991,7 +995,7 @@ impl AppContext for VisualTestContext { self.cx.background_spawn(future) } - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global, { @@ -1000,6 +1004,8 @@ impl AppContext for VisualTestContext { } impl VisualContext for VisualTestContext { + type Result = T; + /// Get the underlying window handle underlying this context. fn window_handle(&self) -> AnyWindowHandle { self.window @@ -1008,30 +1014,30 @@ impl VisualContext for VisualTestContext { fn new_window_entity( &mut self, build_entity: impl FnOnce(&mut Window, &mut Context) -> T, - ) -> Self::Result> { + ) -> Entity { self.window .update(&mut self.cx, |_, window, cx| { cx.new(|cx| build_entity(window, cx)) }) - .unwrap() + .expect("window was unexpectedly closed") } fn update_window_entity( &mut self, view: &Entity, update: impl FnOnce(&mut V, &mut Window, &mut Context) -> R, - ) -> Self::Result { + ) -> R { self.window .update(&mut self.cx, |_, window, cx| { view.update(cx, |v, cx| update(v, window, cx)) }) - .unwrap() + .expect("window was unexpectedly closed") } fn replace_root_view( &mut self, build_view: impl FnOnce(&mut Window, &mut Context) -> V, - ) -> Self::Result> + ) -> Entity where V: 'static + Render, { @@ -1039,15 +1045,15 @@ impl VisualContext for VisualTestContext { .update(&mut self.cx, |_, window, cx| { window.replace_root(cx, build_view) }) - .unwrap() + .expect("window was unexpectedly closed") } - fn focus(&mut self, view: &Entity) -> Self::Result<()> { + fn focus(&mut self, view: &Entity) { self.window .update(&mut self.cx, |_, window, cx| { - view.read(cx).focus_handle(cx).focus(window) + view.read(cx).focus_handle(cx).focus(window, cx) }) - .unwrap() + .expect("window was unexpectedly closed") } } diff --git a/src/app/visual_test_context.rs b/src/app/visual_test_context.rs new file mode 100644 index 0000000000..f0fbf47f1f --- /dev/null +++ b/src/app/visual_test_context.rs @@ -0,0 +1,475 @@ +use crate::{ + Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, + Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, + Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, + Render, Result, Size, Task, TestDispatcher, TextSystem, VisualTestPlatform, Window, + WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, +}; +use anyhow::anyhow; +use image::RgbaImage; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A test context that uses real macOS rendering instead of mocked rendering. +/// This is used for visual tests that need to capture actual screenshots. +/// +/// Unlike `TestAppContext` which uses `TestPlatform` with mocked rendering, +/// `VisualTestAppContext` uses the real `MacPlatform` to produce actual rendered output. +/// +/// Windows created through this context are positioned off-screen (at coordinates like -10000, -10000) +/// so they are invisible to the user but still fully rendered by the compositor. +#[derive(Clone)] +pub struct VisualTestAppContext { + /// The underlying app cell + pub app: Rc, + /// The background executor for running async tasks + pub background_executor: BackgroundExecutor, + /// The foreground executor for running tasks on the main thread + pub foreground_executor: ForegroundExecutor, + /// The test dispatcher for deterministic task scheduling + dispatcher: TestDispatcher, + platform: Rc, + text_system: Arc, +} + +impl VisualTestAppContext { + /// Creates a new `VisualTestAppContext` with real macOS platform rendering + /// but deterministic task scheduling via TestDispatcher. + /// + /// This provides: + /// - Real Metal/compositor rendering for accurate screenshots + /// - Deterministic task scheduling via TestDispatcher + /// - Controllable time via `advance_clock` + /// + /// Note: This uses a no-op asset source, so SVG icons won't render. + /// Use `with_asset_source` to provide real assets for icon rendering. + pub fn new(platform: Rc) -> Self { + Self::with_asset_source(platform, Arc::new(())) + } + + /// Creates a new `VisualTestAppContext` with a custom asset source. + /// + /// Use this when you need SVG icons to render properly in visual tests. + /// Pass the real `Assets` struct to enable icon rendering. + pub fn with_asset_source( + platform: Rc, + asset_source: Arc, + ) -> Self { + // Use a seeded RNG for deterministic behavior + let seed = std::env::var("SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + // Create a visual test platform that combines real Mac rendering + // with controllable TestDispatcher for deterministic task scheduling + let platform = Rc::new(VisualTestPlatform::new(platform, seed)); + + // Get the dispatcher and executors from the platform + let dispatcher = platform.dispatcher().clone(); + let background_executor = platform.background_executor(); + let foreground_executor = platform.foreground_executor(); + + let text_system = Arc::new(TextSystem::new(platform.text_system())); + + let http_client = http_client::FakeHttpClient::with_404_response(); + + let mut app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + background_executor, + foreground_executor, + dispatcher, + platform, + text_system, + } + } + + /// Opens a window positioned off-screen for invisible rendering. + /// + /// The window is positioned at (-10000, -10000) so it's not visible on any display, + /// but it's still fully rendered by the compositor and can be captured via ScreenCaptureKit. + /// + /// # Arguments + /// * `size` - The size of the window to create + /// * `build_root` - A closure that builds the root view for the window + pub fn open_offscreen_window( + &mut self, + size: Size, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{point, px}; + + let bounds = Bounds { + origin: point(px(-10000.0), px(-10000.0)), + size, + }; + + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: true, + ..Default::default() + }, + build_root, + ) + } + + /// Opens an off-screen window with default size (1280x800). + pub fn open_offscreen_window_default( + &mut self, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{px, size}; + self.open_offscreen_window(size(px(1280.0), px(800.0)), build_root) + } + + /// Returns whether screen capture is supported on this platform. + pub fn is_screen_capture_supported(&self) -> bool { + self.platform.is_screen_capture_supported() + } + + /// Returns the text system used by this context. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Returns the background executor. + pub fn executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + /// Returns the foreground executor. + pub fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + /// Runs all pending foreground and background tasks until there's nothing left to do. + /// This is essential for processing async operations like tooltip timers. + pub fn run_until_parked(&self) { + self.dispatcher.run_until_parked(); + } + + /// Advances the simulated clock by the given duration and processes any tasks + /// that become ready. This is essential for testing time-based behaviors like + /// tooltip delays. + pub fn advance_clock(&self, duration: Duration) { + self.dispatcher.advance_clock(duration); + } + + /// Updates the app state. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + f(&mut app) + } + + /// Reads from the app state. + pub fn read(&self, f: impl FnOnce(&App) -> R) -> R { + let app = self.app.borrow(); + f(&app) + } + + /// Updates a window. + pub fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + /// Spawns a task on the foreground executor. + pub fn spawn(&self, f: F) -> Task + where + F: Future + 'static, + R: 'static, + { + self.foreground_executor.spawn(f) + } + + /// Checks if a global of type G exists. + pub fn has_global(&self) -> bool { + let app = self.app.borrow(); + app.has_global::() + } + + /// Reads a global value. + pub fn read_global(&self, f: impl FnOnce(&G, &App) -> R) -> R { + let app = self.app.borrow(); + f(app.global::(), &app) + } + + /// Sets a global value. + pub fn set_global(&mut self, global: G) { + let mut app = self.app.borrow_mut(); + app.set_global(global); + } + + /// Updates a global value. + pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R { + let mut lock = self.app.borrow_mut(); + lock.update(|cx| { + let mut global = cx.lease_global::(); + let result = f(&mut global, cx); + cx.end_global_lease(global); + result + }) + } + + /// Simulates a sequence of keystrokes on the given window. + /// + /// Keystrokes are specified as a space-separated string, e.g., "cmd-p escape". + pub fn simulate_keystrokes(&mut self, window: AnyWindowHandle, keystrokes: &str) { + for keystroke_text in keystrokes.split_whitespace() { + let keystroke = Keystroke::parse(keystroke_text) + .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_text)); + self.dispatch_keystroke(window, keystroke); + } + self.run_until_parked(); + } + + /// Dispatches a single keystroke to a window. + pub fn dispatch_keystroke(&mut self, window: AnyWindowHandle, keystroke: Keystroke) { + self.update_window(window, |_, window, cx| { + window.dispatch_keystroke(keystroke, cx); + }) + .ok(); + } + + /// Simulates typing text input on the given window. + pub fn simulate_input(&mut self, window: AnyWindowHandle, input: &str) { + for char in input.chars() { + let key = char.to_string(); + let keystroke = Keystroke { + modifiers: Modifiers::default(), + key: key.clone(), + key_char: Some(key), + }; + self.dispatch_keystroke(window, keystroke); + } + self.run_until_parked(); + } + + /// Simulates a mouse move event. + pub fn simulate_mouse_move( + &mut self, + window: AnyWindowHandle, + position: Point, + button: impl Into>, + modifiers: Modifiers, + ) { + self.simulate_event( + window, + MouseMoveEvent { + position, + modifiers, + pressed_button: button.into(), + }, + ); + } + + /// Simulates a mouse down event. + pub fn simulate_mouse_down( + &mut self, + window: AnyWindowHandle, + position: Point, + button: MouseButton, + modifiers: Modifiers, + ) { + self.simulate_event( + window, + MouseDownEvent { + position, + modifiers, + button, + click_count: 1, + first_mouse: false, + }, + ); + } + + /// Simulates a mouse up event. + pub fn simulate_mouse_up( + &mut self, + window: AnyWindowHandle, + position: Point, + button: MouseButton, + modifiers: Modifiers, + ) { + self.simulate_event( + window, + MouseUpEvent { + position, + modifiers, + button, + click_count: 1, + }, + ); + } + + /// Simulates a click (mouse down followed by mouse up). + pub fn simulate_click( + &mut self, + window: AnyWindowHandle, + position: Point, + modifiers: Modifiers, + ) { + self.simulate_mouse_down(window, position, MouseButton::Left, modifiers); + self.simulate_mouse_up(window, position, MouseButton::Left, modifiers); + } + + /// Simulates an input event on the given window. + pub fn simulate_event(&mut self, window: AnyWindowHandle, event: E) { + self.update_window(window, |_, window, cx| { + window.dispatch_event(event.to_platform_input(), cx); + }) + .ok(); + self.run_until_parked(); + } + + /// Dispatches an action to the given window. + pub fn dispatch_action(&mut self, window: AnyWindowHandle, action: impl Action) { + self.update_window(window, |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx); + }) + .ok(); + self.run_until_parked(); + } + + /// Writes to the clipboard. + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform.write_to_clipboard(item); + } + + /// Reads from the clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() + } + + /// Waits for a condition to become true, with a timeout. + pub async fn wait_for( + &mut self, + entity: &Entity, + predicate: impl Fn(&T) -> bool, + timeout: Duration, + ) -> Result<()> { + let start = web_time::Instant::now(); + loop { + { + let app = self.app.borrow(); + if predicate(entity.read(&app)) { + return Ok(()); + } + } + + if start.elapsed() > timeout { + return Err(anyhow!("Timed out waiting for condition")); + } + + self.run_until_parked(); + self.background_executor + .timer(Duration::from_millis(10)) + .await; + } + } + + /// Captures a screenshot of the specified window using direct texture capture. + /// + /// This renders the scene to a Metal texture and reads the pixels directly, + /// which does not require the window to be visible on screen. + #[cfg(any(test, feature = "test-support"))] + pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result { + self.update_window(window, |_, window, _cx| window.render_to_image())? + } + + /// Waits for animations to complete by waiting a couple of frames. + pub async fn wait_for_animations(&self) { + self.background_executor + .timer(Duration::from_millis(32)) + .await; + self.run_until_parked(); + } +} + +impl AppContext for VisualTestAppContext { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { + let mut app = self.app.borrow_mut(); + app.new(build_entity) + } + + fn reserve_entity(&mut self) -> crate::Reservation { + let mut app = self.app.borrow_mut(); + app.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: crate::Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Entity { + let mut app = self.app.borrow_mut(); + app.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + let mut app = self.app.borrow_mut(); + app.update_entity(handle, update) + } + + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> crate::GpuiBorrow<'a, T> + where + T: 'static, + { + panic!("Cannot use as_mut with a visual test app context. Try calling update() first") + } + + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R + where + T: 'static, + { + let app = self.app.borrow(); + app.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R + where + G: Global, + { + let app = self.app.borrow(); + callback(app.global::(), &app) + } +} diff --git a/src/assets.rs b/src/assets.rs index 8930b58f8d..231a571ecd 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -33,9 +33,10 @@ impl AssetSource for () { pub struct ImageId(pub usize); #[derive(PartialEq, Eq, Hash, Clone)] -pub(crate) struct RenderImageParams { - pub(crate) image_id: ImageId, - pub(crate) frame_index: usize, +#[expect(missing_docs)] +pub struct RenderImageParams { + pub image_id: ImageId, + pub frame_index: usize, } /// A cached and processed image, in BGRA format diff --git a/src/bounds_tree.rs b/src/bounds_tree.rs index d621609bf7..9cf86a2cc9 100644 --- a/src/bounds_tree.rs +++ b/src/bounds_tree.rs @@ -5,14 +5,91 @@ use std::{ ops::{Add, Sub}, }; +/// Maximum children per internal node (R-tree style branching factor). +/// Higher values = shorter tree = fewer cache misses, but more work per node. +const MAX_CHILDREN: usize = 12; + +/// A spatial tree optimized for finding maximum ordering among intersecting bounds. +/// +/// This is an R-tree variant specifically designed for the use case of assigning +/// z-order to overlapping UI elements. Key optimizations: +/// - Tracks the leaf with global max ordering for O(1) fast-path queries +/// - Uses higher branching factor (4) for lower tree height +/// - Aggressive pruning during search based on max_order metadata #[derive(Debug)] pub(crate) struct BoundsTree where U: Clone + Debug + Default + PartialEq, { - root: Option, + /// All nodes stored contiguously for cache efficiency. nodes: Vec>, - stack: Vec, + /// Index of the root node, if any. + root: Option, + /// Index of the leaf with the highest ordering (for fast-path lookups). + max_leaf: Option, + /// Reusable stack for tree traversal during insertion. + insert_path: Vec, + /// Reusable stack for search operations. + search_stack: Vec, +} + +/// A node in the bounds tree. +#[derive(Debug, Clone)] +struct Node +where + U: Clone + Debug + Default + PartialEq, +{ + /// Bounding box containing this node and all descendants. + bounds: Bounds, + /// Maximum ordering value in this subtree. + max_order: u32, + /// Node-specific data. + kind: NodeKind, +} + +#[derive(Debug, Clone)] +enum NodeKind { + /// Leaf node containing actual bounds data. + Leaf { + /// The ordering assigned to this bounds. + order: u32, + }, + /// Internal node with children. + Internal { + /// Indices of child nodes (2 to MAX_CHILDREN). + children: NodeChildren, + }, +} + +/// Fixed-size array for child indices, avoiding heap allocation. +#[derive(Debug, Clone)] +struct NodeChildren { + // Keeps an invariant where the max order child is always at the end + indices: [usize; MAX_CHILDREN], + len: u8, +} + +impl NodeChildren { + fn new() -> Self { + Self { + indices: [0; MAX_CHILDREN], + len: 0, + } + } + + fn push(&mut self, index: usize) { + debug_assert!((self.len as usize) < MAX_CHILDREN); + self.indices[self.len as usize] = index; + self.len += 1; + } + + fn len(&self) -> usize { + self.len as usize + } + + fn as_slice(&self) -> &[usize] { + &self.indices[..self.len as usize] + } } impl BoundsTree @@ -26,158 +103,250 @@ where + Half + Default, { + /// Clears all nodes from the tree. pub fn clear(&mut self) { - self.root = None; self.nodes.clear(); - self.stack.clear(); + self.root = None; + self.max_leaf = None; + self.insert_path.clear(); + self.search_stack.clear(); } + /// Inserts bounds into the tree and returns its assigned ordering. + /// + /// The ordering is one greater than the maximum ordering of any + /// existing bounds that intersect with the new bounds. pub fn insert(&mut self, new_bounds: Bounds) -> u32 { - // If the tree is empty, make the root the new leaf. - let Some(mut index) = self.root else { - let new_node = self.push_leaf(new_bounds, 1); - self.root = Some(new_node); - return 1; + // Find maximum ordering among intersecting bounds + let max_intersecting = self.find_max_ordering(&new_bounds); + let ordering = max_intersecting + 1; + + // Insert the new leaf + let new_leaf_idx = self.insert_leaf(new_bounds, ordering); + + // Update max_leaf tracking + self.max_leaf = match self.max_leaf { + None => Some(new_leaf_idx), + Some(old_idx) if self.nodes[old_idx].max_order < ordering => Some(new_leaf_idx), + some => some, }; - // Search for the best place to add the new leaf based on heuristics. - let mut max_intersecting_ordering = 0; - while let Node::Internal { - left, - right, - bounds: node_bounds, - .. - } = &mut self.nodes[index] - { - let left = *left; - let right = *right; - *node_bounds = node_bounds.union(&new_bounds); - self.stack.push(index); - - // Descend to the best-fit child, based on which one would increase - // the surface area the least. This attempts to keep the tree balanced - // in terms of surface area. If there is an intersection with the other child, - // add its keys to the intersections vector. - let left_cost = new_bounds.union(self.nodes[left].bounds()).half_perimeter(); - let right_cost = new_bounds - .union(self.nodes[right].bounds()) - .half_perimeter(); - if left_cost < right_cost { - max_intersecting_ordering = - self.find_max_ordering(right, &new_bounds, max_intersecting_ordering); - index = left; - } else { - max_intersecting_ordering = - self.find_max_ordering(left, &new_bounds, max_intersecting_ordering); - index = right; - } - } - - // We've found a leaf ('index' now refers to a leaf node). - // We'll insert a new parent node above the leaf and attach our new leaf to it. - let sibling = index; - - // Check for collision with the located leaf node - let Node::Leaf { - bounds: sibling_bounds, - order: sibling_ordering, - .. - } = &self.nodes[index] - else { - unreachable!(); - }; - if sibling_bounds.intersects(&new_bounds) { - max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering); - } - - let ordering = max_intersecting_ordering + 1; - let new_node = self.push_leaf(new_bounds, ordering); - let new_parent = self.push_internal(sibling, new_node); - - // If there was an old parent, we need to update its children indices. - if let Some(old_parent) = self.stack.last().copied() { - let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else { - unreachable!(); - }; - - if *left == sibling { - *left = new_parent; - } else { - *right = new_parent; - } - } else { - // If the old parent was the root, the new parent is the new root. - self.root = Some(new_parent); - } - - for node_index in self.stack.drain(..).rev() { - let Node::Internal { - max_order: max_ordering, - .. - } = &mut self.nodes[node_index] - else { - unreachable!() - }; - if *max_ordering >= ordering { - break; - } - *max_ordering = ordering; - } - ordering } - fn find_max_ordering(&self, index: usize, bounds: &Bounds, mut max_ordering: u32) -> u32 { - match &self.nodes[index] { - Node::Leaf { - bounds: node_bounds, - order: ordering, - .. - } => { - if bounds.intersects(node_bounds) { - max_ordering = cmp::max(*ordering, max_ordering); - } + /// Finds the maximum ordering among all bounds that intersect with the query. + fn find_max_ordering(&mut self, query: &Bounds) -> u32 { + let Some(root_idx) = self.root else { + return 0; + }; + + // Fast path: check if the max-ordering leaf intersects + if let Some(max_idx) = self.max_leaf { + let max_node = &self.nodes[max_idx]; + if query.intersects(&max_node.bounds) { + return max_node.max_order; } - Node::Internal { - left, - right, - bounds: node_bounds, - max_order: node_max_ordering, - .. - } => { - if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering { - let left_max_ordering = self.nodes[*left].max_ordering(); - let right_max_ordering = self.nodes[*right].max_ordering(); - if left_max_ordering > right_max_ordering { - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - } else { - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); + } + + // Slow path: search the tree + self.search_stack.clear(); + self.search_stack.push(root_idx); + + let mut max_found = 0u32; + + while let Some(node_idx) = self.search_stack.pop() { + let node = &self.nodes[node_idx]; + + // Pruning: skip if this subtree can't improve our result + if node.max_order <= max_found { + continue; + } + + // Spatial pruning: skip if bounds don't intersect + if !query.intersects(&node.bounds) { + continue; + } + + match &node.kind { + NodeKind::Leaf { order } => { + max_found = cmp::max(max_found, *order); + } + NodeKind::Internal { children } => { + // Children are maintained with highest max_order at the end. + // Push in forward order to highest (last) is popped first. + for &child_idx in children.as_slice() { + if self.nodes[child_idx].max_order > max_found { + self.search_stack.push(child_idx); + } } } } } - max_ordering + + max_found } - fn push_leaf(&mut self, bounds: Bounds, order: u32) -> usize { - self.nodes.push(Node::Leaf { bounds, order }); - self.nodes.len() - 1 - } - - fn push_internal(&mut self, left: usize, right: usize) -> usize { - let left_node = &self.nodes[left]; - let right_node = &self.nodes[right]; - let new_bounds = left_node.bounds().union(right_node.bounds()); - let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering()); - self.nodes.push(Node::Internal { - bounds: new_bounds, - left, - right, - max_order: max_ordering, + /// Inserts a leaf node with the given bounds and ordering. + /// Returns the index of the new leaf. + fn insert_leaf(&mut self, bounds: Bounds, order: u32) -> usize { + let new_leaf_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: bounds.clone(), + max_order: order, + kind: NodeKind::Leaf { order }, }); - self.nodes.len() - 1 + + let Some(root_idx) = self.root else { + // Tree is empty, new leaf becomes root + self.root = Some(new_leaf_idx); + return new_leaf_idx; + }; + + // If root is a leaf, create internal node with both + if matches!(self.nodes[root_idx].kind, NodeKind::Leaf { .. }) { + let root_bounds = self.nodes[root_idx].bounds.clone(); + let root_order = self.nodes[root_idx].max_order; + + let mut children = NodeChildren::new(); + // Max end invariant + if order > root_order { + children.push(root_idx); + children.push(new_leaf_idx); + } else { + children.push(new_leaf_idx); + children.push(root_idx); + } + + let new_root_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: root_bounds.union(&bounds), + max_order: cmp::max(root_order, order), + kind: NodeKind::Internal { children }, + }); + self.root = Some(new_root_idx); + return new_leaf_idx; + } + + // Descend to find the best internal node to insert into + self.insert_path.clear(); + let mut current_idx = root_idx; + + loop { + let current = &self.nodes[current_idx]; + let NodeKind::Internal { children } = ¤t.kind else { + unreachable!("Should only traverse internal nodes"); + }; + + self.insert_path.push(current_idx); + + // Find the best child to descend into + let mut best_child_idx = children.as_slice()[0]; + let mut best_child_pos = 0; + let mut best_cost = bounds + .union(&self.nodes[best_child_idx].bounds) + .half_perimeter(); + + for (pos, &child_idx) in children.as_slice().iter().enumerate().skip(1) { + let cost = bounds.union(&self.nodes[child_idx].bounds).half_perimeter(); + if cost < best_cost { + best_cost = cost; + best_child_idx = child_idx; + best_child_pos = pos; + } + } + + // Check if best child is a leaf or internal + if matches!(self.nodes[best_child_idx].kind, NodeKind::Leaf { .. }) { + // Best child is a leaf. Check if current node has room for another child. + if children.len() < MAX_CHILDREN { + // Add new leaf directly to this node + let node = &mut self.nodes[current_idx]; + + if let NodeKind::Internal { children } = &mut node.kind { + children.push(new_leaf_idx); + // Swap new leaf only if it has the highest max_order + if order <= node.max_order { + let last = children.len() - 1; + children.indices.swap(last - 1, last); + } + } + + node.bounds = node.bounds.union(&bounds); + node.max_order = cmp::max(node.max_order, order); + break; + } else { + // Node is full, create new internal with [best_leaf, new_leaf] + let sibling_bounds = self.nodes[best_child_idx].bounds.clone(); + let sibling_order = self.nodes[best_child_idx].max_order; + + let mut new_children = NodeChildren::new(); + // Max end invariant + if order > sibling_order { + new_children.push(best_child_idx); + new_children.push(new_leaf_idx); + } else { + new_children.push(new_leaf_idx); + new_children.push(best_child_idx); + } + + let new_internal_idx = self.nodes.len(); + let new_internal_max = cmp::max(sibling_order, order); + self.nodes.push(Node { + bounds: sibling_bounds.union(&bounds), + max_order: new_internal_max, + kind: NodeKind::Internal { + children: new_children, + }, + }); + + // Replace the leaf with the new internal in parent + let parent = &mut self.nodes[current_idx]; + if let NodeKind::Internal { children } = &mut parent.kind { + let children_len = children.len(); + + children.indices[best_child_pos] = new_internal_idx; + + // If new internal has highest max_order, swap it to the end + // to maintain sorting invariant + if new_internal_max > parent.max_order { + children.indices.swap(best_child_pos, children_len - 1); + } + } + break; + } + } else { + // Best child is internal, continue descent + current_idx = best_child_idx; + } + } + + // Propagate bounds and max_order updates up the tree + let mut updated_child_idx = None; + for &node_idx in self.insert_path.iter().rev() { + let node = &mut self.nodes[node_idx]; + node.bounds = node.bounds.union(&bounds); + + if node.max_order < order { + node.max_order = order; + + // Swap updated child to end (skip first iteration since the invariant is already handled by previous cases) + if let Some(child_idx) = updated_child_idx { + if let NodeKind::Internal { children } = &mut node.kind { + if let Some(pos) = children.as_slice().iter().position(|&c| c == child_idx) + { + let last = children.len() - 1; + if pos != last { + children.indices.swap(pos, last); + } + } + } + } + } + + updated_child_idx = Some(node_idx); + } + + new_leaf_idx } } @@ -187,50 +356,11 @@ where { fn default() -> Self { BoundsTree { - root: None, nodes: Vec::new(), - stack: Vec::new(), - } - } -} - -#[derive(Debug, Clone)] -enum Node -where - U: Clone + Debug + Default + PartialEq, -{ - Leaf { - bounds: Bounds, - order: u32, - }, - Internal { - left: usize, - right: usize, - bounds: Bounds, - max_order: u32, - }, -} - -impl Node -where - U: Clone + Debug + Default + PartialEq, -{ - fn bounds(&self) -> &Bounds { - match self { - Node::Leaf { bounds, .. } => bounds, - Node::Internal { bounds, .. } => bounds, - } - } - - fn max_ordering(&self) -> u32 { - match self { - Node::Leaf { - order: ordering, .. - } => *ordering, - Node::Internal { - max_order: max_ordering, - .. - } => *max_ordering, + root: None, + max_leaf: None, + insert_path: Vec::new(), + search_stack: Vec::new(), } } } diff --git a/src/color.rs b/src/color.rs index 3af5731bb5..75585bcd90 100644 --- a/src/color.rs +++ b/src/color.rs @@ -23,7 +23,7 @@ pub fn rgba(hex: u32) -> Rgba { } /// Swap from RGBA with premultiplied alpha to BGRA -pub(crate) fn swap_rgba_pa_to_bgra(color: &mut [u8]) { +pub fn swap_rgba_pa_to_bgra(color: &mut [u8]) { color.swap(0, 2); if color[3] > 0 { let a = color[3] as f32 / 255.; @@ -658,6 +658,7 @@ pub(crate) enum BackgroundTag { Solid = 0, LinearGradient = 1, PatternSlash = 2, + Checkerboard = 3, } /// A color space for color interpolation. @@ -701,20 +702,21 @@ impl std::fmt::Debug for Background { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self.tag { BackgroundTag::Solid => write!(f, "Solid({:?})", self.solid), - BackgroundTag::LinearGradient => { - write!( - f, - "LinearGradient({}, {:?}, {:?})", - self.gradient_angle_or_pattern_height, self.colors[0], self.colors[1] - ) - } - BackgroundTag::PatternSlash => { - write!( - f, - "PatternSlash({:?}, {})", - self.solid, self.gradient_angle_or_pattern_height - ) - } + BackgroundTag::LinearGradient => write!( + f, + "LinearGradient({}, {:?}, {:?})", + self.gradient_angle_or_pattern_height, self.colors[0], self.colors[1] + ), + BackgroundTag::PatternSlash => write!( + f, + "PatternSlash({:?}, {})", + self.solid, self.gradient_angle_or_pattern_height + ), + BackgroundTag::Checkerboard => write!( + f, + "Checkerboard({:?}, {})", + self.solid, self.gradient_angle_or_pattern_height + ), } } } @@ -734,19 +736,29 @@ impl Default for Background { } /// Creates a hash pattern background -pub fn pattern_slash(color: Hsla, width: f32, interval: f32) -> Background { +pub fn pattern_slash(color: impl Into, width: f32, interval: f32) -> Background { let width_scaled = (width * 255.0) as u32; let interval_scaled = (interval * 255.0) as u32; let height = ((width_scaled * 0xFFFF) + interval_scaled) as f32; Background { tag: BackgroundTag::PatternSlash, - solid: color, + solid: color.into(), gradient_angle_or_pattern_height: height, ..Default::default() } } +/// Creates a checkerboard pattern background +pub fn checkerboard(color: impl Into, size: f32) -> Background { + Background { + tag: BackgroundTag::Checkerboard, + solid: color.into(), + gradient_angle_or_pattern_height: size, + ..Default::default() + } +} + /// Creates a solid background color. pub fn solid_background(color: impl Into) -> Background { Background { @@ -808,6 +820,15 @@ impl LinearColorStop { } impl Background { + /// Returns the solid color if this is a solid background, None otherwise. + pub fn as_solid(&self) -> Option { + if self.tag == BackgroundTag::Solid { + Some(self.solid) + } else { + None + } + } + /// Use specified color space for color interpolation. /// /// @@ -833,6 +854,7 @@ impl Background { BackgroundTag::Solid => self.solid.is_transparent(), BackgroundTag::LinearGradient => self.colors.iter().all(|c| c.color.is_transparent()), BackgroundTag::PatternSlash => self.solid.is_transparent(), + BackgroundTag::Checkerboard => self.solid.is_transparent(), } } } diff --git a/src/default_colors.rs b/src/colors.rs similarity index 52% rename from src/default_colors.rs rename to src/colors.rs index e53ac32b8e..ef11ef57fd 100644 --- a/src/default_colors.rs +++ b/src/colors.rs @@ -7,18 +7,12 @@ use std::sync::Arc; /// These are used for styling base components, examples and more. #[derive(Clone, Debug)] pub struct Colors { - /// Primary text color + /// Text color pub text: Rgba, - /// Muted/secondary text color - pub text_muted: Rgba, /// Selected text color pub selected_text: Rgba, - /// Background color (root level) + /// Background color pub background: Rgba, - /// Surface color (cards, panels, elevated containers) - pub surface: Rgba, - /// Surface color on hover - pub surface_hover: Rgba, /// Disabled color pub disabled: Rgba, /// Selected color @@ -29,24 +23,6 @@ pub struct Colors { pub separator: Rgba, /// Container color pub container: Rgba, - /// Accent/primary action color (macOS blue) - pub accent: Rgba, - /// Accent color on hover - pub accent_hover: Rgba, - /// Accent color when active/pressed - pub accent_active: Rgba, - /// Success/positive color - pub success: Rgba, - /// Success color on hover - pub success_hover: Rgba, - /// Warning/caution color - pub warning: Rgba, - /// Warning color on hover - pub warning_hover: Rgba, - /// Error/destructive color - pub error: Rgba, - /// Error color on hover - pub error_hover: Rgba, } impl Default for Colors { @@ -64,85 +40,31 @@ impl Colors { } } - /// Returns the default dark colors + /// Returns the default dark colors. pub fn dark() -> Self { Self { - // Text text: rgb(0xffffff), - text_muted: rgb(0x98989d), selected_text: rgb(0xffffff), disabled: rgb(0x565656), - - // Backgrounds - background: rgb(0x1e1e1e), - surface: rgb(0x2d2d2d), - surface_hover: rgb(0x3d3d3d), + selected: rgb(0x2457ca), + background: rgb(0x222222), + border: rgb(0x000000), + separator: rgb(0xd9d9d9), container: rgb(0x262626), - - // Borders - border: rgb(0x3d3d3d), - separator: rgb(0x3d3d3d), - - // Selection - selected: rgb(0x0058d0), - - // Accent (macOS blue) - accent: rgb(0x0a84ff), - accent_hover: rgb(0x409cff), - accent_active: rgb(0x0071e3), - - // Success (green) - success: rgb(0x30d158), - success_hover: rgb(0x28cd52), - - // Warning (yellow/orange) - warning: rgb(0xffd60a), - warning_hover: rgb(0xffcc00), - - // Error (red) - error: rgb(0xff453a), - error_hover: rgb(0xff6961), } } - /// Returns the default light colors + /// Returns the default light colors. pub fn light() -> Self { Self { - // Text - text: rgb(0x1d1d1f), - text_muted: rgb(0x86868b), + text: rgb(0x252525), selected_text: rgb(0xffffff), - disabled: rgb(0xb0b0b0), - - // Backgrounds background: rgb(0xffffff), - surface: rgb(0xf5f5f7), - surface_hover: rgb(0xe8e8ed), - container: rgb(0xf5f5f7), - - // Borders - border: rgb(0xd2d2d7), - separator: rgb(0xd2d2d7), - - // Selection - selected: rgb(0x0066cc), - - // Accent (macOS blue) - accent: rgb(0x007aff), - accent_hover: rgb(0x0071e3), - accent_active: rgb(0x0058d0), - - // Success (green) - success: rgb(0x28cd41), - success_hover: rgb(0x23b839), - - // Warning (yellow/orange) - warning: rgb(0xff9f0a), - warning_hover: rgb(0xe68f09), - - // Error (red) - error: rgb(0xff3b30), - error_hover: rgb(0xe6352b), + disabled: rgb(0xb0b0b0), + selected: rgb(0x2a63d9), + border: rgb(0xd9d9d9), + separator: rgb(0xe6e6e6), + container: rgb(0xf4f5f5), } } diff --git a/src/element.rs b/src/element.rs index 2c695486c5..b5c3bcc3fb 100644 --- a/src/element.rs +++ b/src/element.rs @@ -32,9 +32,9 @@ //! your own custom layout algorithm or rendering a code editor. use crate::{ - App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ELEMENT_ARENA, ElementId, - FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window, - util::FluentBuilder, + App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, FocusHandle, + InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window, + local_util::FluentBuilder, window::with_element_arena, }; use derive_more::{Deref, DerefMut}; use std::{ @@ -197,8 +197,27 @@ impl Component { } } +fn prepaint_component( + (element, name): &mut (AnyElement, &'static str), + window: &mut Window, + cx: &mut App, +) { + window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { + element.prepaint(window, cx); + }) +} + +fn paint_component( + (element, name): &mut (AnyElement, &'static str), + window: &mut Window, + cx: &mut App, +) { + window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { + element.paint(window, cx); + }) +} impl Element for Component { - type RequestLayoutState = AnyElement; + type RequestLayoutState = (AnyElement, &'static str); type PrepaintState = (); fn id(&self) -> Option { @@ -220,7 +239,7 @@ impl Element for Component { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - window.with_global_id(ElementId::Name(type_name::().into()), |_, window| { + window.with_id(ElementId::Name(type_name::().into()), |window| { let mut element = self .component .take() @@ -229,7 +248,7 @@ impl Element for Component { .into_any_element(); let layout_id = element.request_layout(window, cx); - (layout_id, element) + (layout_id, (element, type_name::())) }) } @@ -238,13 +257,11 @@ impl Element for Component { _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, _: Bounds, - element: &mut AnyElement, + state: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) { - window.with_global_id(ElementId::Name(type_name::().into()), |_, window| { - element.prepaint(window, cx); - }) + prepaint_component(state, window, cx); } fn paint( @@ -252,14 +269,12 @@ impl Element for Component { _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, _: Bounds, - element: &mut Self::RequestLayoutState, + state: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { - window.with_global_id(ElementId::Name(type_name::().into()), |_, window| { - element.paint(window, cx); - }) + paint_component(state, window, cx); } } @@ -548,18 +563,22 @@ where &mut self.element } + #[inline] fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> LayoutId { Drawable::request_layout(self, window, cx) } + #[inline] fn prepaint(&mut self, window: &mut Window, cx: &mut App) { Drawable::prepaint(self, window, cx); } + #[inline] fn paint(&mut self, window: &mut Window, cx: &mut App) { Drawable::paint(self, window, cx); } + #[inline] fn layout_as_root( &mut self, available_space: Size, @@ -579,8 +598,7 @@ impl AnyElement { E: 'static + Element, E::RequestLayoutState: Any, { - let element = ELEMENT_ARENA - .with_borrow_mut(|arena| arena.alloc(|| Drawable::new(element))) + let element = with_element_arena(|arena| arena.alloc(|| Drawable::new(element))) .map(|element| element as &mut dyn ElementObject); AnyElement(element) } diff --git a/src/elements/animation.rs b/src/elements/animation.rs index e72fb00456..882d2d01e2 100644 --- a/src/elements/animation.rs +++ b/src/elements/animation.rs @@ -1,7 +1,5 @@ -use std::{ - rc::Rc, - time::{Duration, Instant}, -}; +use crate::scheduler::Instant; +use std::{rc::Rc, time::Duration}; use crate::{ AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window, diff --git a/src/elements/deferred.rs b/src/elements/deferred.rs index 9498734198..25245fa4b6 100644 --- a/src/elements/deferred.rs +++ b/src/elements/deferred.rs @@ -62,7 +62,7 @@ impl Element for Deferred { ) { let child = self.child.take().unwrap(); let element_offset = window.element_offset(); - window.defer_draw(child, element_offset, self.priority) + window.defer_draw(child, element_offset, self.priority, None) } fn paint( diff --git a/src/elements/div.rs b/src/elements/div.rs index 821f155f96..8a3505ee19 100644 --- a/src/elements/div.rs +++ b/src/elements/div.rs @@ -15,13 +15,15 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, - MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, size, }; @@ -166,6 +168,38 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn capture_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Capture && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + /// Bind the given callback to the mouse up event for the given button, during the bubble phase. /// The imperative API equivalent to [`InteractiveElement::on_mouse_up`]. /// @@ -321,6 +355,43 @@ impl Interactivity { })); } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { + self.pinch_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn capture_pinch( + &mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) { + self.pinch_listeners + .push(Box::new(move |event, phase, _hitbox, window, cx| { + if phase == DispatchPhase::Capture { + (listener)(event, window, cx); + } else { + cx.propagate(); + } + })); + } + /// Bind the given callback to an action dispatch during the capture phase. /// The imperative API equivalent to [`InteractiveElement::capture_action`]. /// @@ -490,6 +561,20 @@ impl Interactivity { })); } + /// Bind the given callback to non-primary click events of this element. + /// The imperative API equivalent to [`StatefulInteractiveElement::on_aux_click`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_aux_click(&mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) + where + Self: Sized, + { + self.aux_click_listeners + .push(Rc::new(move |event, window, cx| { + listener(event, window, cx) + })); + } + /// On drag initiation, this callback will be used to create a new view to render the dragged value for a /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with /// the [`Self::on_drag_move`] API. @@ -589,6 +674,16 @@ impl Interactivity { pub fn block_mouse_except_scroll(&mut self) { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn has_pinch_listeners(&self) -> bool { + !self.pinch_listeners.is_empty() + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + fn has_pinch_listeners(&self) -> bool { + false + } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -622,7 +717,7 @@ pub trait InteractiveElement: Sized { /// Set whether this element is a tab stop. /// /// When false, the element remains in tab-index order but cannot be reached via keyboard navigation. - /// Useful for container elements: focus the container, then call `window.focus_next()` to focus + /// Useful for container elements: focus the container, then call `window.focus_next(cx)` to focus /// the first tab stop inside it while having the container element itself be unreachable via the keyboard. /// Should only be used with `tab_index`. fn tab_stop(mut self, tab_stop: bool) -> Self { @@ -769,6 +864,30 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_pressure(listener); + self + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn capture_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_mouse_pressure(listener); + self + } + /// Bind the given callback to the mouse down event, on any button, during the capture phase, /// when the mouse is outside of the bounds of this element. /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`]. @@ -835,6 +954,34 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { + self.interactivity().on_pinch(listener); + self + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn capture_pinch( + mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_pinch(listener); + self + } /// Capture the given action, before normal action dispatch can fire. /// The fluent API equivalent to [`Interactivity::capture_action`]. /// @@ -1134,6 +1281,21 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Bind the given callback to non-primary click events of this element. + /// The fluent API equivalent to [`Interactivity::on_aux_click`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_aux_click( + mut self, + listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self + where + Self: Sized, + { + self.interactivity().on_aux_click(listener); + self + } + /// On drag initiation, this callback will be used to create a new view to render the dragged value for a /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with /// the [`InteractiveElement::on_drag_move`] API. @@ -1197,13 +1359,18 @@ pub(crate) type MouseDownListener = Box; pub(crate) type MouseUpListener = Box; - +pub(crate) type MousePressureListener = + Box; pub(crate) type MouseMoveListener = Box; pub(crate) type ScrollWheelListener = Box; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) type PinchListener = + Box; + pub(crate) type ClickListener = Rc; pub(crate) type DragListener = @@ -1238,6 +1405,7 @@ pub fn div() -> Div { children: SmallVec::default(), prepaint_listener: None, image_cache: None, + prepaint_order_fn: None, } } @@ -1247,6 +1415,7 @@ pub struct Div { children: SmallVec<[StackSafe; 2]>, prepaint_listener: Option>, &mut Window, &mut App) + 'static>>, image_cache: Option>, + prepaint_order_fn: Option SmallVec<[usize; 8]>>>, } impl Div { @@ -1265,6 +1434,22 @@ impl Div { self.image_cache = Some(Box::new(cache)); self } + + /// Specify a function that determines the order in which children are prepainted. + /// + /// The function is called at prepaint time and should return a vector of child indices + /// in the desired prepaint order. Each index should appear exactly once. + /// + /// This is useful when the prepaint of one child affects state that another child reads. + /// For example, in split editor views, the editor with an autoscroll request should + /// be prepainted first so its scroll position update is visible to the other editor. + pub fn with_dynamic_prepaint_order( + mut self, + order_fn: impl Fn(&mut Window, &mut App) -> SmallVec<[usize; 8]> + 'static, + ) -> Self { + self.prepaint_order_fn = Some(Box::new(order_fn)); + self + } } /// A frame state for a `Div` element, which contains layout IDs for its children. @@ -1368,6 +1553,11 @@ impl Element for Div { window: &mut Window, cx: &mut App, ) -> Option { + let image_cache = self + .image_cache + .as_mut() + .map(|provider| provider.provide(window, cx)); + let has_prepaint_listener = self.prepaint_listener.is_some(); let mut children_bounds = Vec::with_capacity(if has_prepaint_listener { request_layout.child_layout_ids.len() @@ -1422,16 +1612,27 @@ impl Element for Div { return hitbox; } - window.with_element_offset(scroll_offset, |window| { - for child in &mut self.children { - child.prepaint(window, cx); + window.with_image_cache(image_cache, |window| { + window.with_element_offset(scroll_offset, |window| { + if let Some(order_fn) = &self.prepaint_order_fn { + let order = order_fn(window, cx); + for idx in order { + if let Some(child) = self.children.get_mut(idx) { + child.prepaint(window, cx); + } + } + } else { + for child in &mut self.children { + child.prepaint(window, cx); + } + } + }); + + if let Some(listener) = self.prepaint_listener.as_ref() { + listener(children_bounds, window, cx); } }); - if let Some(listener) = self.prepaint_listener.as_ref() { - listener(children_bounds, window, cx); - } - hitbox }, ) @@ -1521,8 +1722,11 @@ pub struct Interactivity { pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>, pub(crate) mouse_down_listeners: Vec, pub(crate) mouse_up_listeners: Vec, + pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, pub(crate) modifiers_changed_listeners: Vec, @@ -1530,6 +1734,7 @@ pub struct Interactivity { pub(crate) drop_listeners: Vec<(TypeId, DropListener)>, pub(crate) can_drop_predicate: Option, pub(crate) click_listeners: Vec, + pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, @@ -1672,6 +1877,11 @@ impl Interactivity { let clicked_state = clicked_state.borrow(); self.active = Some(clicked_state.element); } + if self.hover_style.is_some() || self.group_hover_style.is_some() { + element_state + .hover_state + .get_or_insert_with(Default::default); + } if let Some(active_tooltip) = element_state.active_tooltip.as_ref() { if self.tooltip_builder.is_some() { self.tooltip_id = set_tooltip_on_window(active_tooltip, window); @@ -1714,10 +1924,13 @@ impl Interactivity { || self.group_hover_style.is_some() || self.hover_listener.is_some() || !self.mouse_up_listeners.is_empty() + || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.click_listeners.is_empty() + || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() + || self.has_pinch_listeners() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() || self.tooltip_builder.is_some() @@ -1757,18 +1970,18 @@ impl Interactivity { // high for the maximum scroll, we round the scroll max to 2 decimal // places here. let padded_content_size = self.content_size + padding_size; - let scroll_max = (padded_content_size - bounds.size) + let scroll_max = Point::from(padded_content_size - bounds.size) .map(round_to_two_decimals) .max(&Default::default()); // Clamp scroll offset in case scroll max is smaller now (e.g., if children // were removed or the bounds became larger). let mut scroll_offset = scroll_offset.borrow_mut(); - scroll_offset.x = scroll_offset.x.clamp(-scroll_max.width, px(0.)); + scroll_offset.x = scroll_offset.x.clamp(-scroll_max.x, px(0.)); if scroll_to_bottom { - scroll_offset.y = -scroll_max.height; + scroll_offset.y = -scroll_max.y; } else { - scroll_offset.y = scroll_offset.y.clamp(-scroll_max.height, px(0.)); + scroll_offset.y = scroll_offset.y.clamp(-scroll_max.y, px(0.)); } if let Some(mut scroll_handle_state) = tracked_scroll_handle { @@ -1912,12 +2125,12 @@ impl Interactivity { ) { use crate::{BorderStyle, TextAlign}; - if global_id.is_some() + if let Some(global_id) = global_id && (style.debug || style.debug_below || cx.has_global::()) && hitbox.is_hovered(window) { const FONT_SIZE: crate::Pixels = crate::Pixels(10.); - let element_id = format!("{:?}", global_id.unwrap()); + let element_id = format!("{global_id:?}"); let str_len = element_id.len(); let render_debug_text = |window: &mut Window| { @@ -1940,7 +2153,7 @@ impl Interactivity { origin: hitbox.origin, size: text.size(FONT_SIZE), }; - if self.source_location.is_some() + if let Some(source_location) = self.source_location && text_bounds.contains(&window.mouse_position()) && window.modifiers().secondary() { @@ -1971,7 +2184,6 @@ impl Interactivity { window.on_mouse_event({ let hitbox = hitbox.clone(); - let location = self.source_location.unwrap(); move |e: &crate::MouseDownEvent, phase, window, cx| { if text_bounds.contains(&e.position) && phase.capture() @@ -1984,9 +2196,9 @@ impl Interactivity { eprintln!( "This element was created at:\n{}:{}:{}", - dir.join(location.file()).to_string_lossy(), - location.line(), - location.column() + dir.join(source_location.file()).to_string_lossy(), + source_location.line(), + source_location.column() ); } } @@ -2037,12 +2249,12 @@ impl Interactivity { // This behavior can be suppressed by using `cx.prevent_default()`. if let Some(focus_handle) = self.tracked_focus_handle.clone() { let hitbox = hitbox.clone(); - window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _| { + window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) && !window.default_prevented() { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); // If there is a parent that is also focusable, prevent it // from transferring focus because we already did so. window.prevent_default(); @@ -2064,6 +2276,13 @@ impl Interactivity { }) } + for listener in self.mouse_pressure_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MousePressureEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.mouse_move_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { @@ -2078,25 +2297,70 @@ impl Interactivity { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + for listener in self.pinch_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + if self.hover_style.is_some() || self.base_style.mouse_cursor.is_some() || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() { let hitbox = hitbox.clone(); - let was_hovered = hitbox.is_hovered(window); + let hover_state = self.hover_style.as_ref().and_then(|_| { + element_state + .as_ref() + .and_then(|state| state.hover_state.as_ref()) + .cloned() + }); let current_view = window.current_view(); + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { let hovered = hitbox.is_hovered(window); + let was_hovered = hover_state + .as_ref() + .is_some_and(|state| state.borrow().element); if phase == DispatchPhase::Capture && hovered != was_hovered { - cx.notify(current_view); + if let Some(hover_state) = &hover_state { + hover_state.borrow_mut().element = hovered; + cx.notify(current_view); + } } }); } + + if let Some(group_hover) = self.group_hover_style.as_ref() { + if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { + let hover_state = element_state + .as_ref() + .and_then(|element| element.hover_state.as_ref()) + .cloned(); + let current_view = window.current_view(); + + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { + let group_hovered = group_hitbox_id.is_hovered(window); + let was_group_hovered = hover_state + .as_ref() + .is_some_and(|state| state.borrow().group); + if phase == DispatchPhase::Capture && group_hovered != was_group_hovered { + if let Some(hover_state) = &hover_state { + hover_state.borrow_mut().group = group_hovered; + } + cx.notify(current_view); + } + }); + } + } + let drag_cursor_style = self.base_style.as_ref().mouse_cursor; let mut drag_listener = mem::take(&mut self.drag_listener); let drop_listeners = mem::take(&mut self.drop_listeners); let click_listeners = mem::take(&mut self.click_listeners); + let aux_click_listeners = mem::take(&mut self.aux_click_listeners); let can_drop_predicate = mem::take(&mut self.can_drop_predicate); if !drop_listeners.is_empty() { @@ -2133,7 +2397,10 @@ impl Interactivity { } if let Some(element_state) = element_state { - if !click_listeners.is_empty() || drag_listener.is_some() { + if !click_listeners.is_empty() + || !aux_click_listeners.is_empty() + || drag_listener.is_some() + { let pending_mouse_down = element_state .pending_mouse_down .get_or_insert_with(Default::default) @@ -2147,9 +2414,10 @@ impl Interactivity { window.on_mouse_event({ let pending_mouse_down = pending_mouse_down.clone(); let hitbox = hitbox.clone(); + let has_aux_click_listeners = !aux_click_listeners.is_empty(); move |event: &MouseDownEvent, phase, window, _cx| { if phase == DispatchPhase::Bubble - && event.button == MouseButton::Left + && (event.button == MouseButton::Left || has_aux_click_listeners) && hitbox.is_hovered(window) { *pending_mouse_down.borrow_mut() = Some(event.clone()); @@ -2171,6 +2439,7 @@ impl Interactivity { && !cx.has_active_drag() && (event.position - mouse_down.position).magnitude() > DRAG_THRESHOLD && let Some((drag_value, drag_listener)) = drag_listener.take() + && mouse_down.button == MouseButton::Left { *clicked_state.borrow_mut() = ElementClickedState::default(); let cursor_offset = event.position - hitbox.origin; @@ -2247,12 +2516,24 @@ impl Interactivity { // Fire click handlers during the bubble phase. DispatchPhase::Bubble => { if let Some(mouse_down) = captured_mouse_down.take() { + let btn = mouse_down.button; + let mouse_click = ClickEvent::Mouse(MouseClickEvent { down: mouse_down, up: event.clone(), }); - for listener in &click_listeners { - listener(&mouse_click, window, cx); + + match btn { + MouseButton::Left => { + for listener in &click_listeners { + listener(&mouse_click, window, cx); + } + } + _ => { + for listener in &aux_click_listeners { + listener(&mouse_click, window, cx); + } + } } } } @@ -2263,7 +2544,7 @@ impl Interactivity { if let Some(hover_listener) = self.hover_listener.take() { let hitbox = hitbox.clone(); let was_hovered = element_state - .hover_state + .hover_listener_state .get_or_insert_with(Default::default) .clone(); let has_mouse_down = element_state @@ -2308,7 +2589,8 @@ impl Interactivity { let pending_mouse_down = pending_mouse_down.clone(); let source_bounds = hitbox.bounds; move |window: &Window| { - pending_mouse_down.borrow().is_none() + !window.last_input_was_keyboard() + && pending_mouse_down.borrow().is_none() && source_bounds.contains(&window.mouse_position()) } }); @@ -2328,18 +2610,24 @@ impl Interactivity { ); } + // We unconditionally bind both the mouse up and mouse down active state handlers + // Because we might not get a chance to render a frame before the mouse up event arrives. let active_state = element_state .clicked_state .get_or_insert_with(Default::default) .clone(); - if active_state.borrow().is_clicked() { + + { + let active_state = active_state.clone(); window.on_mouse_event(move |_: &MouseUpEvent, phase, window, _cx| { - if phase == DispatchPhase::Capture { + if phase == DispatchPhase::Capture && active_state.borrow().is_clicked() { *active_state.borrow_mut() = ElementClickedState::default(); window.refresh(); } }); - } else { + } + + { let active_group_hitbox = self .group_active_style .as_ref() @@ -2514,22 +2802,46 @@ impl Interactivity { } } - if let Some(hitbox) = hitbox { - if !cx.has_active_drag() { - if let Some(group_hover) = self.group_hover_style.as_ref() - && let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) - && group_hitbox_id.is_hovered(window) - { - style.refine(&group_hover.style); - } + if !cx.has_active_drag() { + if let Some(group_hover) = self.group_hover_style.as_ref() { + let is_group_hovered = + if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { + group_hitbox_id.is_hovered(window) + } else if let Some(element_state) = element_state.as_ref() { + element_state + .hover_state + .as_ref() + .map(|state| state.borrow().group) + .unwrap_or(false) + } else { + false + }; - if let Some(hover_style) = self.hover_style.as_ref() - && hitbox.is_hovered(window) - { - style.refine(hover_style); + if is_group_hovered { + style.refine(&group_hover.style); } } + if let Some(hover_style) = self.hover_style.as_ref() { + let is_hovered = if let Some(hitbox) = hitbox { + hitbox.is_hovered(window) + } else if let Some(element_state) = element_state.as_ref() { + element_state + .hover_state + .as_ref() + .map(|state| state.borrow().element) + .unwrap_or(false) + } else { + false + }; + + if is_hovered { + style.refine(hover_style); + } + } + } + + if let Some(hitbox) = hitbox { if let Some(drag) = cx.active_drag.take() { let mut can_drop = true; if let Some(can_drop_predicate) = &self.can_drop_predicate { @@ -2588,7 +2900,8 @@ impl Interactivity { pub struct InteractiveElementState { pub(crate) focus_handle: Option, pub(crate) clicked_state: Option>>, - pub(crate) hover_state: Option>>, + pub(crate) hover_state: Option>>, + pub(crate) hover_listener_state: Option>>, pub(crate) pending_mouse_down: Option>>>, pub(crate) scroll_offset: Option>>>, pub(crate) active_tooltip: Option>>>, @@ -2610,6 +2923,16 @@ impl ElementClickedState { } } +/// Whether or not the element or a group that contains it is hovered. +#[derive(Copy, Clone, Default, Eq, PartialEq)] +pub struct ElementHoverState { + /// True if this element's group is hovered, false otherwise + pub group: bool, + + /// True if this element is hovered, false otherwise + pub element: bool, +} + pub(crate) enum ActiveTooltip { /// Currently delaying before showing the tooltip. WaitingForShow { _task: Task<()> }, @@ -3061,7 +3384,7 @@ impl ScrollAnchor { struct ScrollHandleState { offset: Rc>>, bounds: Bounds, - max_offset: Size, + max_offset: Point, child_bounds: Vec>, scroll_to_bottom: bool, overflow: Point, @@ -3105,7 +3428,7 @@ impl ScrollHandle { } /// Get the maximum scroll offset. - pub fn max_offset(&self) -> Size { + pub fn max_offset(&self) -> Point { self.0.borrow().max_offset } @@ -3193,7 +3516,11 @@ impl ScrollHandle { match active_item.strategy { ScrollStrategy::FirstVisible => { if state.overflow.y == Overflow::Scroll { - if bounds.top() + scroll_offset.y < state.bounds.top() { + let child_height = bounds.size.height; + let viewport_height = state.bounds.size.height; + if child_height > viewport_height { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.top() + scroll_offset.y < state.bounds.top() { scroll_offset.y = state.bounds.top() - bounds.top(); } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { scroll_offset.y = state.bounds.bottom() - bounds.bottom(); @@ -3206,7 +3533,11 @@ impl ScrollHandle { } if state.overflow.x == Overflow::Scroll { - if bounds.left() + scroll_offset.x < state.bounds.left() { + let child_width = bounds.size.width; + let viewport_width = state.bounds.size.width; + if child_width > viewport_width { + scroll_offset.x = state.bounds.left() - bounds.left(); + } else if bounds.left() + scroll_offset.x < state.bounds.left() { scroll_offset.x = state.bounds.left() - bounds.left(); } else if bounds.right() + scroll_offset.x > state.bounds.right() { scroll_offset.x = state.bounds.right() - bounds.right(); @@ -3268,3 +3599,46 @@ impl ScrollHandle { self.0.borrow().child_bounds.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_handle_aligns_wide_children_to_left_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(80.), px(20.))); + state.child_bounds = vec![Bounds::new(point(px(25.), px(0.)), size(px(200.), px(20.)))]; + state.overflow.x = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().x, px(-25.)); + } + + #[test] + fn scroll_handle_aligns_tall_children_to_top_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(80.))); + state.child_bounds = vec![Bounds::new(point(px(0.), px(25.)), size(px(20.), px(200.)))]; + state.overflow.y = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().y, px(-25.)); + } +} diff --git a/src/elements/img.rs b/src/elements/img.rs index fcba6a6a4e..e3ffe42449 100644 --- a/src/elements/img.rs +++ b/src/elements/img.rs @@ -4,9 +4,10 @@ use crate::{ Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px, }; -use anyhow::{Context as _, Result}; +use anyhow::Result; -use futures::{AsyncReadExt, Future}; +use crate::scheduler::Instant; +use futures::Future; use image::{ AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, @@ -19,7 +20,7 @@ use std::{ path::{Path, PathBuf}, str::FromStr, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; use thiserror::Error; use util::ResultExt; @@ -49,7 +50,7 @@ pub enum ImageSource { } fn is_uri(uri: &str) -> bool { - http_client::Uri::from_str(uri).is_ok() + url::Url::from_str(uri).is_ok() } impl From for ImageSource { @@ -602,6 +603,9 @@ impl Asset for ImageAssetLoader { let bytes = match source.clone() { Resource::Path(uri) => fs::read(uri.as_ref())?, Resource::Uri(uri) => { + use anyhow::Context as _; + use futures::AsyncReadExt as _; + let mut response = client .get(uri.as_ref(), ().into(), true) .await diff --git a/src/elements/list.rs b/src/elements/list.rs index 78566208c8..b84241e9e0 100644 --- a/src/elements/list.rs +++ b/src/elements/list.rs @@ -71,6 +71,16 @@ struct StateInner { scroll_handler: Option>, scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, + pending_scroll: Option, +} + +/// Keeps track of a fractional scroll position within an item for restoration +/// after remeasurement. +struct PendingScrollFraction { + /// The index of the item to scroll within. + item_ix: usize, + /// Fractional offset (0.0 to 1.0) within the item's height. + fraction: f32, } /// Whether the list is scrolling from top to bottom or bottom to top. @@ -225,6 +235,7 @@ impl ListState { reset: false, scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), + pending_scroll: None, }))); this.splice(0..0, item_count); this @@ -254,6 +265,45 @@ impl ListState { self.splice(0..old_count, element_count); } + /// Remeasure all items while preserving proportional scroll position. + /// + /// 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(), + }); + + // 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); + + 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 + }; + + state.pending_scroll = Some(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction, + }); + } + } + } + + state.items = SumTree::from_iter(new_items, ()); + state.measuring_behavior.reset(); + } + /// The number of items in this list. pub fn item_count(&self) -> usize { self.0.borrow().items.summary().count @@ -441,7 +491,7 @@ impl ListState { /// Returns the maximum scroll offset according to the items we have measured. /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. - pub fn max_offset_for_scrollbar(&self) -> Size { + pub fn max_offset_for_scrollbar(&self) -> Point { let state = self.0.borrow(); let bounds = state.last_layout_bounds.unwrap_or_default(); @@ -449,7 +499,7 @@ impl ListState { .scrollbar_drag_start_height .unwrap_or_else(|| state.items.summary().height); - Size::new(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) + point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) } /// Returns the current scroll offset adjusted for the scrollbar @@ -476,8 +526,12 @@ impl ListState { } impl StateInner { - fn visible_range(&self, height: Pixels, scroll_top: &ListOffset) -> Range { - let mut cursor = self.items.cursor::(()); + fn visible_range( + items: &SumTree, + height: Pixels, + scroll_top: &ListOffset, + ) -> Range { + let mut cursor = items.cursor::(()); cursor.seek(&Count(scroll_top.item_ix), Bias::Right); let start_y = cursor.start().height + scroll_top.offset_in_item; cursor.seek_forward(&Height(start_y + height), Bias::Left); @@ -520,9 +574,9 @@ impl StateInner { }); } - if self.scroll_handler.is_some() { - let visible_range = self.visible_range(height, scroll_top); - self.scroll_handler.as_mut().unwrap()( + if let Some(handler) = self.scroll_handler.as_mut() { + let visible_range = Self::visible_range(&self.items, height, scroll_top); + handler( &ListScrollEvent { visible_range, count: self.items.summary().count, @@ -644,6 +698,20 @@ impl StateInner { let mut element = render_item(item_index, window, cx); let element_size = element.layout_as_root(available_item_space, window, cx); size = Some(element_size); + + // If there's a pending scroll adjustment for the scroll-top + // item, apply it, ensuring proportional scroll position is + // 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); + } + } + } + if visible_height < available_height { item_layouts.push_back(ItemLayout { index: item_index, @@ -1035,6 +1103,7 @@ impl Element for List { ); state.items = new_items; + state.measuring_behavior.reset(); } let padding = style @@ -1184,16 +1253,16 @@ impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height { mod test { use gpui::{ScrollDelta, ScrollWheelEvent}; + use std::cell::Cell; + use std::rc::Rc; - use crate::{self as gpui, TestAppContext}; + use crate::{ + self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled, + TestAppContext, Window, div, list, point, px, size, + }; #[gpui::test] fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) { - use crate::{ - AppContext, Context, Element, IntoElement, ListState, Render, Styled, Window, div, - list, point, px, size, - }; - let cx = cx.add_empty_window(); let state = ListState::new(5, crate::ListAlignment::Top, px(10.)); @@ -1217,7 +1286,7 @@ mod test { // Paint cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { - cx.new(|_| TestView(state.clone())) + cx.new(|_| TestView(state.clone())).into_any_element() }); // Reset @@ -1237,11 +1306,6 @@ mod test { #[gpui::test] fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) { - use crate::{ - AppContext, Context, Element, IntoElement, ListState, Render, Styled, Window, div, - list, point, px, size, - }; - let cx = cx.add_empty_window(); let state = ListState::new(5, crate::ListAlignment::Top, px(10.)); @@ -1259,7 +1323,7 @@ mod test { // Paint cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { - cx.new(|_| TestView(state.clone())) + cx.new(|_| TestView(state.clone())).into_any_element() }); // Test positive distance: start at item 1, move down 30px @@ -1284,4 +1348,105 @@ mod test { assert_eq!(offset.item_ix, 0); assert_eq!(offset.offset_in_item, px(0.)); } + + #[gpui::test] + fn test_measure_all_after_width_change(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()))); + + // First draw at width 100: all 10 items measured (total 500px). + // Viewport is 200px, so max scroll offset should be 300px. + 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.)); + + // Second draw at a different width: items get invalidated. + // Without the fix, max_offset would drop because unmeasured items + // contribute 0 height. + cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| { + view.into_any_element() + }); + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + } + + #[gpui::test] + fn test_remeasure(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // Create a list with 10 items, each 100px tall. We'll keep a reference + // to the item height so we can later change the height and assert how + // `ListState` handles it. + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(10, 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 |_, _, _| { + 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, + }) + }); + + // Simulate scrolling 40px inside the element with index 2. Since the + // original item height is 100px, this equates to 40% inside the item. + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(40.), + }); + + 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, 2); + assert_eq!(offset.offset_in_item, px(40.)); + + // Update the `item_height` to be 50px instead of 100px so we can assert + // that the scroll position is proportionally preserved, that is, + // instead of 40px from the top of item 2, it should be 20px, since the + // item's height has been halved. + 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, 2); + assert_eq!(offset.offset_in_item, px(20.)); + } } diff --git a/src/elements/surface.rs b/src/elements/surface.rs index b4fced1001..ac1c247b47 100644 --- a/src/elements/surface.rs +++ b/src/elements/surface.rs @@ -29,6 +29,7 @@ pub struct Surface { } /// Create a new surface element. +#[cfg(target_os = "macos")] pub fn surface(source: impl Into) -> Surface { Surface { source: source.into(), diff --git a/src/elements/svg.rs b/src/elements/svg.rs index 57b2d712e5..c4999655b8 100644 --- a/src/elements/svg.rs +++ b/src/elements/svg.rs @@ -3,8 +3,7 @@ use std::{fs, path::Path, sync::Arc}; use crate::{ App, Asset, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size, - StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px, - radians, size, + StyleRefinement, Styled, TransformationMatrix, Window, point, px, radians, size, }; use util::ResultExt; @@ -254,7 +253,7 @@ impl Transformation { .translate(center.scale(scale_factor) + self.translate.scale(scale_factor)) .rotate(self.rotate) .scale(self.scale) - .translate(center.scale(scale_factor).negate()) + .translate(center.scale(-scale_factor)) } } diff --git a/src/elements/text.rs b/src/elements/text.rs index 914e8a2865..e6abbe3252 100644 --- a/src/elements/text.rs +++ b/src/elements/text.rs @@ -2,10 +2,11 @@ use crate::{ ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow, - TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout, - register_tooltip_mouse_handlers, set_tooltip_on_window, + TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine, + WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window, }; use anyhow::Context as _; +use itertools::Itertools; use smallvec::SmallVec; use std::{ borrow::Cow, @@ -83,6 +84,14 @@ impl IntoElement for String { } } +impl IntoElement for Cow<'static, str> { + type Element = SharedString; + + fn into_element(self) -> Self::Element { + self.into() + } +} + impl Element for SharedString { type RequestLayoutState = TextLayout; type PrepaintState = (); @@ -237,7 +246,12 @@ impl StyledText { pub fn with_runs(mut self, runs: Vec) -> Self { let mut text = &**self.text; for run in &runs { - text = text.get(run.len..).expect("invalid text run"); + text = text.get(run.len..).unwrap_or_else(|| { + #[cfg(debug_assertions)] + panic!("invalid text run. Text: '{text}', run: {run:?}"); + #[cfg(not(debug_assertions))] + panic!("invalid text run"); + }); } assert!(text.is_empty(), "invalid text run"); self.runs = Some(runs); @@ -353,7 +367,7 @@ impl TextLayout { None }; - let (truncate_width, truncation_suffix) = + let (truncate_width, truncation_affix, truncate_from) = if let Some(text_overflow) = text_style.text_overflow.clone() { let width = known_dimensions.width.or(match available_space.width { crate::AvailableSpace::Definite(x) => match text_style.line_clamp { @@ -364,17 +378,24 @@ impl TextLayout { }); match text_overflow { - TextOverflow::Truncate(s) => (width, s), + TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), + TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), } } else { - (None, "".into()) + (None, "".into(), TruncateFrom::End) }; + // Only use cached layout if: + // 1. We have a cached size + // 2. wrap_width matches (or both are None) + // 3. truncate_width is None (if truncate_width is Some, we need to re-layout + // because the previous layout may have been computed without truncation) if let Some(text_layout) = element_state.0.borrow().as_ref() - && text_layout.size.is_some() + && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) + && truncate_width.is_none() { - return text_layout.size.unwrap(); + return size; } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); @@ -382,8 +403,9 @@ impl TextLayout { line_wrapper.truncate_line( text.clone(), truncate_width, - &truncation_suffix, + &truncation_affix, &runs, + truncate_from, ) } else { (text.clone(), Cow::Borrowed(&*runs)) @@ -597,14 +619,14 @@ impl TextLayout { .unwrap() .lines .iter() - .map(|s| s.text.to_string()) - .collect::>() + .map(|s| &s.text) .join("\n") } /// The text for this layout (with soft-wraps as newlines) pub fn wrapped_text(&self) -> String { - let mut lines = Vec::new(); + let mut accumulator = String::new(); + for wrapped in self.0.borrow().as_ref().unwrap().lines.iter() { let mut seen = 0; for boundary in wrapped.layout.wrap_boundaries.iter() { @@ -612,13 +634,16 @@ impl TextLayout { [boundary.glyph_ix] .index; - lines.push(wrapped.text[seen..index].to_string()); + accumulator.push_str(&wrapped.text[seen..index]); + accumulator.push('\n'); seen = index; } - lines.push(wrapped.text[seen..].to_string()); + accumulator.push_str(&wrapped.text[seen..]); + accumulator.push('\n'); } - - lines.join("\n") + // Remove trailing newline + accumulator.pop(); + accumulator } } @@ -912,3 +937,17 @@ impl IntoElement for InteractiveText { self } } + +#[cfg(test)] +mod tests { + #[test] + fn test_into_element_for() { + use crate::{ParentElement as _, SharedString, div}; + use std::borrow::Cow; + + let _ = div().child("static str"); + let _ = div().child("String".to_string()); + let _ = div().child(Cow::Borrowed("Cow")); + let _ = div().child(SharedString::from("SharedString")); + } +} diff --git a/src/elements/uniform_list.rs b/src/elements/uniform_list.rs index 1e38b0e7ac..a7486f0c00 100644 --- a/src/elements/uniform_list.rs +++ b/src/elements/uniform_list.rs @@ -712,8 +712,8 @@ mod test { #[gpui::test] fn test_scroll_strategy_nearest(cx: &mut TestAppContext) { use crate::{ - Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, actions, div, - prelude::*, px, uniform_list, + Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*, + px, uniform_list, }; use std::ops::Range; @@ -788,7 +788,7 @@ mod test { let (view, cx) = cx.add_window_view(|window, cx| { let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); TestView { scroll_handle: UniformListScrollHandle::new(), index: 0, diff --git a/src/executor.rs b/src/executor.rs index a219a20e92..b92520126c 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -1,98 +1,35 @@ -use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant, TaskTiming, profiler}; -use async_task::Runnable; +use crate::scheduler::Instant; +use crate::scheduler::Scheduler; +use crate::{App, PlatformDispatcher, PlatformScheduler}; use futures::channel::mpsc; -use parking_lot::{Condvar, Mutex}; -use smol::prelude::*; +use futures::prelude::*; use std::{ - fmt::Debug, - marker::PhantomData, - mem::{self, ManuallyDrop}, - num::NonZeroUsize, - panic::Location, - pin::Pin, - rc::Rc, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - task::{Context, Poll}, - thread::{self, ThreadId}, - time::{Duration, Instant}, + fmt::Debug, future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, + time::Duration, }; use util::TryFutureExt; -use waker_fn::waker_fn; -#[cfg(any(test, feature = "test-support"))] -use rand::rngs::StdRng; +pub use crate::scheduler::{ + FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, +}; /// A pointer to the executor that is currently running, /// for spawning background tasks. #[derive(Clone)] pub struct BackgroundExecutor { - #[doc(hidden)] - pub dispatcher: Arc, + inner: crate::scheduler::BackgroundExecutor, + dispatcher: Arc, } /// A pointer to the executor that is currently running, /// for spawning tasks on the main thread. -/// -/// This is intentionally `!Send` via the `not_send` marker field. This is because -/// `ForegroundExecutor::spawn` does not require `Send` but checks at runtime that the future is -/// only polled from the same thread it was spawned from. These checks would fail when spawning -/// foreground tasks from background threads. #[derive(Clone)] pub struct ForegroundExecutor { - #[doc(hidden)] - pub dispatcher: Arc, + inner: crate::scheduler::ForegroundExecutor, + dispatcher: Arc, not_send: PhantomData>, } -/// Realtime task priority -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[repr(u8)] -pub enum RealtimePriority { - /// Audio task - Audio, - /// Other realtime task - #[default] - Other, -} - -/// Task priority -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[repr(u8)] -pub enum Priority { - /// Realtime priority - /// - /// Spawning a task with this priority will spin it off on a separate thread dedicated just to that task. - Realtime(RealtimePriority), - /// High priority - /// - /// Only use for tasks that are critical to the user experience / responsiveness of the editor. - High, - /// Medium priority, probably suits most of your use cases. - #[default] - Medium, - /// Low priority - /// - /// Prioritize this for background work that can come in large quantities - /// to not starve the executor of resources for high priority tasks - Low, -} - -impl Priority { - #[allow(dead_code)] - pub(crate) const fn probability(&self) -> u32 { - match self { - // realtime priorities are not considered for probability scheduling - Priority::Realtime(_) => 0, - Priority::High => 60, - Priority::Medium => 30, - Priority::Low => 10, - } - } -} - /// Task is a primitive that allows work to happen in the background. /// /// It implements [`Future`] so you can `.await` on it. @@ -101,39 +38,57 @@ impl Priority { /// 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), -} +pub struct Task(crate::scheduler::Task); impl Task { - /// Creates a new task that will resolve with the value + /// Creates a new task that will resolve with the value. pub fn ready(val: T) -> Self { - Task(TaskState::Ready(Some(val))) + Task(crate::scheduler::Task::ready(val)) } - /// Detaching a task runs it to completion in the background + /// 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) { - match self { - Task(TaskState::Ready(_)) => {} - Task(TaskState::Spawned(task)) => task.detach(), - } + self.0.detach() + } + + /// Wraps a scheduler Task. + pub fn from_scheduler(task: crate::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() } } -impl Task> +impl Task> where T: 'static, E: 'static + Debug, { - /// Run the task to completion in the background and log any - /// errors that occur. + /// 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) { let location = core::panic::Location::caller(); @@ -143,53 +98,44 @@ where } } -impl Future for Task { +impl std::future::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)) => task.poll(cx), + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + // SAFETY: Task is a repr(transparent) wrapper around the 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) + } +} + +impl BackgroundExecutor { + /// Creates a new BackgroundExecutor from the given PlatformDispatcher. + pub fn new(dispatcher: Arc) -> Self { + #[cfg(any(test, feature = "test-support"))] + let scheduler: Arc = if let Some(test_dispatcher) = dispatcher.as_test() { + test_dispatcher.scheduler().clone() + } else { + Arc::new(PlatformScheduler::new(dispatcher.clone())) + }; + + #[cfg(not(any(test, feature = "test-support")))] + let scheduler: Arc = Arc::new(PlatformScheduler::new(dispatcher.clone())); + + Self { + inner: crate::scheduler::BackgroundExecutor::new(scheduler), + dispatcher, } } -} -/// A task label is an opaque identifier that you can use to -/// refer to a task in tests. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub struct TaskLabel(NonZeroUsize); - -impl Default for TaskLabel { - fn default() -> Self { - Self::new() - } -} - -impl TaskLabel { - /// Construct a new task label. - pub fn new() -> Self { - static NEXT_TASK_LABEL: AtomicUsize = AtomicUsize::new(1); - Self( - NEXT_TASK_LABEL - .fetch_add(1, Ordering::SeqCst) - .try_into() - .unwrap(), - ) - } -} - -type AnyLocalFuture = Pin>>; - -type AnyFuture = Pin>>; - -/// BackgroundExecutor lets you run things on background threads. -/// In production this is a thread pool with no ordering guarantees. -/// In tests this is simulated by running tasks one by one in a deterministic -/// (but arbitrary) order controlled by the `SEED` environment variable. -impl BackgroundExecutor { - #[doc(hidden)] - pub fn new(dispatcher: Arc) -> Self { - Self { dispatcher } + /// Returns the underlying crate::scheduler::BackgroundExecutor. + /// + /// This is used by Ex to pass the executor to thread/worktree code. + pub fn scheduler_executor(&self) -> crate::scheduler::BackgroundExecutor { + self.inner.clone() } /// Enqueues the given future to be run to completion on a background thread. @@ -198,10 +144,13 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_with_priority(Priority::default(), future) + self.spawn_with_priority(Priority::default(), future.boxed()) } - /// Enqueues the given future to be run to completion on a background thread. + /// Enqueues the given future to be run to completion on a background thread with the given priority. + /// + /// When `Priority::RealtimeAudio` is used, the task runs on a dedicated thread with + /// realtime scheduling priority, suitable for audio processing. #[track_caller] pub fn spawn_with_priority( &self, @@ -211,7 +160,11 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), None, priority) + if priority == Priority::RealtimeAudio { + Task::from_scheduler(self.inner.spawn_realtime(future)) + } else { + Task::from_scheduler(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. @@ -222,8 +175,9 @@ impl BackgroundExecutor { where R: Send, { - // We need to ensure that cancellation of the parent task does not drop the environment - // before the our own task has completed or got cancelled. + use crate::RunnableMeta; + use parking_lot::{Condvar, Mutex}; + struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); impl Drop for NotifyOnDrop<'_> { @@ -259,11 +213,7 @@ impl BackgroundExecutor { future.await }, move |runnable| { - dispatcher.dispatch( - RunnableVariant::Meta(runnable), - None, - Priority::default(), - ) + dispatcher.dispatch(runnable, Priority::default()); }, ) }; @@ -271,240 +221,6 @@ impl BackgroundExecutor { task.await } - /// Enqueues the given future to be run to completion on a background thread. - /// The given label can be used to control the priority of the task in tests. - #[track_caller] - pub fn spawn_labeled( - &self, - label: TaskLabel, - future: impl Future + Send + 'static, - ) -> Task - where - R: Send + 'static, - { - self.spawn_internal::(Box::pin(future), Some(label), Priority::default()) - } - - #[track_caller] - fn spawn_internal( - &self, - future: AnyFuture, - label: Option, - priority: Priority, - ) -> Task { - let dispatcher = self.dispatcher.clone(); - let (runnable, task) = if let Priority::Realtime(realtime) = priority { - let location = core::panic::Location::caller(); - let (mut tx, rx) = flume::bounded::>(1); - - dispatcher.spawn_realtime( - realtime, - Box::new(move || { - while let Ok(runnable) = rx.recv() { - let start = Instant::now(); - let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - - let end = Instant::now(); - timing.end = Some(end); - profiler::add_task_timing(timing); - } - }), - ); - - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn( - move |_| future, - move |runnable| { - let _ = tx.send(runnable); - }, - ) - } else { - let location = core::panic::Location::caller(); - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn( - move |_| future, - move |runnable| { - dispatcher.dispatch(RunnableVariant::Meta(runnable), label, priority) - }, - ) - }; - - runnable.schedule(); - Task(TaskState::Spawned(task)) - } - - /// Used by the test harness to run an async test in a synchronous fashion. - #[cfg(any(test, feature = "test-support"))] - #[track_caller] - pub fn block_test(&self, future: impl Future) -> R { - if let Ok(value) = self.block_internal(false, future, None) { - value - } else { - unreachable!() - } - } - - /// Block the current thread until the given future resolves. - /// Consider using `block_with_timeout` instead. - pub fn block(&self, future: impl Future) -> R { - if let Ok(value) = self.block_internal(true, future, None) { - value - } else { - unreachable!() - } - } - - #[cfg(not(any(test, feature = "test-support")))] - pub(crate) fn block_internal( - &self, - _background_only: bool, - future: Fut, - timeout: Option, - ) -> Result + use> { - use std::time::Instant; - - let mut future = Box::pin(future); - if timeout == Some(Duration::ZERO) { - return Err(future); - } - let deadline = timeout.map(|timeout| Instant::now() + timeout); - - let parker = parking::Parker::new(); - let unparker = parker.unparker(); - let waker = waker_fn(move || { - unparker.unpark(); - }); - let mut cx = std::task::Context::from_waker(&waker); - - loop { - match future.as_mut().poll(&mut cx) { - Poll::Ready(result) => return Ok(result), - Poll::Pending => { - let timeout = - deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); - if let Some(timeout) = timeout { - if !parker.park_timeout(timeout) - && deadline.is_some_and(|deadline| deadline < Instant::now()) - { - return Err(future); - } - } else { - parker.park(); - } - } - } - } - } - - #[cfg(any(test, feature = "test-support"))] - #[track_caller] - pub(crate) fn block_internal( - &self, - background_only: bool, - future: Fut, - timeout: Option, - ) -> Result + use> { - use std::sync::atomic::AtomicBool; - - use parking::Parker; - - let mut future = Box::pin(future); - if timeout == Some(Duration::ZERO) { - return Err(future); - } - let Some(dispatcher) = self.dispatcher.as_test() else { - return Err(future); - }; - - let mut max_ticks = if timeout.is_some() { - dispatcher.gen_block_on_ticks() - } else { - usize::MAX - }; - - let parker = Parker::new(); - let unparker = parker.unparker(); - - let awoken = Arc::new(AtomicBool::new(false)); - let waker = waker_fn({ - let awoken = awoken.clone(); - let unparker = unparker.clone(); - move || { - awoken.store(true, Ordering::SeqCst); - unparker.unpark(); - } - }); - let mut cx = std::task::Context::from_waker(&waker); - - let duration = Duration::from_secs( - option_env!("GPUI_TEST_TIMEOUT") - .and_then(|s| s.parse::().ok()) - .unwrap_or(180), - ); - let mut test_should_end_by = Instant::now() + duration; - - loop { - match future.as_mut().poll(&mut cx) { - Poll::Ready(result) => return Ok(result), - Poll::Pending => { - if max_ticks == 0 { - return Err(future); - } - max_ticks -= 1; - - if !dispatcher.tick(background_only) { - if awoken.swap(false, Ordering::SeqCst) { - continue; - } - - if !dispatcher.parking_allowed() { - if dispatcher.advance_clock_to_next_delayed() { - continue; - } - let mut backtrace_message = String::new(); - let mut waiting_message = String::new(); - if let Some(backtrace) = dispatcher.waiting_backtrace() { - backtrace_message = - format!("\nbacktrace of waiting future:\n{:?}", backtrace); - } - if let Some(waiting_hint) = dispatcher.waiting_hint() { - waiting_message = format!("\n waiting on: {}\n", waiting_hint); - } - panic!( - "parked with nothing left to run{waiting_message}{backtrace_message}", - ) - } - dispatcher.push_unparker(unparker.clone()); - parker.park_timeout(Duration::from_millis(1)); - if Instant::now() > test_should_end_by { - panic!("test timed out after {duration:?} with allow_parking") - } - } - } - } - } - } - - /// Block the current thread until the given future resolves - /// or `duration` has elapsed. - pub fn block_with_timeout( - &self, - duration: Duration, - future: Fut, - ) -> Result + use> { - self.block_internal(true, future, Some(duration)) - } - /// 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) @@ -544,103 +260,107 @@ impl BackgroundExecutor { /// Calling this instead of `std::time::Instant::now` allows the use /// of fake timers in tests. pub fn now(&self) -> Instant { - self.dispatcher.now() + self.inner.scheduler().clock().now() } /// Returns a task that will complete after the given duration. /// Depending on other concurrent tasks the elapsed duration may be longer /// than requested. + #[track_caller] pub fn timer(&self, duration: Duration) -> Task<()> { if duration.is_zero() { return Task::ready(()); } - let location = core::panic::Location::caller(); - let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn(move |_| async move {}, { - let dispatcher = self.dispatcher.clone(); - move |runnable| dispatcher.dispatch_after(duration, RunnableVariant::Meta(runnable)) - }); - runnable.schedule(); - Task(TaskState::Spawned(task)) + self.spawn(self.inner.scheduler().timer(duration)) } - /// in tests, start_waiting lets you indicate which task is waiting (for debugging only) - #[cfg(any(test, feature = "test-support"))] - pub fn start_waiting(&self) { - self.dispatcher.as_test().unwrap().start_waiting(); - } - - /// in tests, removes the debugging data added by start_waiting - #[cfg(any(test, feature = "test-support"))] - pub fn finish_waiting(&self) { - self.dispatcher.as_test().unwrap().finish_waiting(); - } - - /// in tests, run an arbitrary number of tasks (determined by the SEED environment variable) + /// In tests, run an arbitrary number of tasks (determined by the SEED environment variable) #[cfg(any(test, feature = "test-support"))] pub fn simulate_random_delay(&self) -> impl Future + use<> { self.dispatcher.as_test().unwrap().simulate_random_delay() } - /// in tests, indicate that a given task from `spawn_labeled` should run after everything else - #[cfg(any(test, feature = "test-support"))] - pub fn deprioritize(&self, task_label: TaskLabel) { - self.dispatcher.as_test().unwrap().deprioritize(task_label) - } - - /// in tests, move time forward. This does not run any tasks, but does make `timer`s ready. + /// In tests, move time forward. This does not run any tasks, but does make `timer`s ready. #[cfg(any(test, feature = "test-support"))] pub fn advance_clock(&self, duration: Duration) { self.dispatcher.as_test().unwrap().advance_clock(duration) } - /// in tests, run one task. + /// In tests, run one task. #[cfg(any(test, feature = "test-support"))] pub fn tick(&self) -> bool { - self.dispatcher.as_test().unwrap().tick(false) + self.dispatcher.as_test().unwrap().scheduler().tick() } - /// in tests, run all tasks that are ready to run. If after doing so - /// the test still has outstanding tasks, this will panic. (See also [`Self::allow_parking`]) + /// In tests, run tasks until the scheduler would park. + /// + /// Under the scheduler-backed test dispatcher, `tick()` will not advance the clock, so a pending + /// timer can keep `has_pending_tasks()` true even after all currently-runnable tasks have been + /// drained. To preserve the historical semantics that tests relied on (drain all work that can + /// make progress), we advance the clock to the next timer when no runnable tasks remain. #[cfg(any(test, feature = "test-support"))] pub fn run_until_parked(&self) { - self.dispatcher.as_test().unwrap().run_until_parked() + let scheduler = self.dispatcher.as_test().unwrap().scheduler(); + scheduler.run(); } - /// in tests, prevents `run_until_parked` from panicking if there are outstanding tasks. - /// This is useful when you are integrating other (non-GPUI) futures, like disk access, that - /// do take real async time to run. + /// In tests, prevents `run_until_parked` from panicking if there are outstanding tasks. #[cfg(any(test, feature = "test-support"))] pub fn allow_parking(&self) { - self.dispatcher.as_test().unwrap().allow_parking(); + self.dispatcher + .as_test() + .unwrap() + .scheduler() + .allow_parking(); + + if std::env::var("GPUI_RUN_UNTIL_PARKED_LOG").ok().as_deref() == Some("1") { + log::warn!("[gpui::executor] allow_parking: enabled"); + } } - /// undoes the effect of [`Self::allow_parking`]. + /// Sets the range of ticks to run before timing out in block_on. + #[cfg(any(test, feature = "test-support"))] + pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive) { + self.dispatcher + .as_test() + .unwrap() + .scheduler() + .set_timeout_ticks(range); + } + + /// Undoes the effect of [`Self::allow_parking`]. #[cfg(any(test, feature = "test-support"))] pub fn forbid_parking(&self) { - self.dispatcher.as_test().unwrap().forbid_parking(); + self.dispatcher + .as_test() + .unwrap() + .scheduler() + .forbid_parking(); } - /// adds detail to the "parked with nothing let to run" message. + /// In tests, returns the rng used by the dispatcher. #[cfg(any(test, feature = "test-support"))] - pub fn set_waiting_hint(&self, msg: Option) { - self.dispatcher.as_test().unwrap().set_waiting_hint(msg); - } - - /// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable - #[cfg(any(test, feature = "test-support"))] - pub fn rng(&self) -> StdRng { - self.dispatcher.as_test().unwrap().rng() + pub fn rng(&self) -> crate::scheduler::SharedRng { + self.dispatcher.as_test().unwrap().scheduler().rng() } /// How many CPUs are available to the dispatcher. pub fn num_cpus(&self) -> usize { #[cfg(any(test, feature = "test-support"))] - return 4; + if let Some(test) = self.dispatcher.as_test() { + return test.num_cpus_override().unwrap_or(4); + } + num_cpus::get() + } - #[cfg(not(any(test, feature = "test-support")))] - return num_cpus::get(); + /// Override the number of CPUs reported by this executor in tests. + /// Panics if not called on a test executor. + #[cfg(any(test, feature = "test-support"))] + pub fn set_num_cpus(&self, count: usize) { + self.dispatcher + .as_test() + .expect("set_num_cpus can only be called on a test executor") + .set_num_cpus(count); } /// Whether we're on the main thread. @@ -648,132 +368,112 @@ impl BackgroundExecutor { self.dispatcher.is_main_thread() } - #[cfg(any(test, feature = "test-support"))] - /// in tests, control the number of ticks that `block_with_timeout` will run before timing out. - pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive) { - self.dispatcher.as_test().unwrap().set_block_on_ticks(range); + #[doc(hidden)] + pub fn dispatcher(&self) -> &Arc { + &self.dispatcher } } -/// ForegroundExecutor runs things on the main thread. impl ForegroundExecutor { /// Creates a new ForegroundExecutor from the given PlatformDispatcher. pub fn new(dispatcher: Arc) -> Self { + #[cfg(any(test, feature = "test-support"))] + let (scheduler, session_id): (Arc, _) = + if let Some(test_dispatcher) = dispatcher.as_test() { + ( + test_dispatcher.scheduler().clone(), + test_dispatcher.session_id(), + ) + } else { + let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); + let session_id = platform_scheduler.allocate_session_id(); + (platform_scheduler, session_id) + }; + + #[cfg(not(any(test, feature = "test-support")))] + let (scheduler, session_id): (Arc, _) = { + let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); + let session_id = platform_scheduler.allocate_session_id(); + (platform_scheduler, session_id) + }; + + let inner = crate::scheduler::ForegroundExecutor::new(session_id, scheduler); + Self { + inner, dispatcher, not_send: PhantomData, } } - /// Enqueues the given Task to run on the main thread at some point in the future. + /// Enqueues the given Task to run on the main thread. #[track_caller] pub fn spawn(&self, future: impl Future + 'static) -> Task where R: 'static, { - self.spawn_with_priority(Priority::default(), future) + Task::from_scheduler(self.inner.spawn(future.boxed_local())) } - /// Enqueues the given Task to run on the main thread at some point in the future. + /// Enqueues the given Task to run on the main thread with the given priority. #[track_caller] pub fn spawn_with_priority( &self, - priority: Priority, + _priority: Priority, future: impl Future + 'static, ) -> Task where R: 'static, { - let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - - #[track_caller] - fn inner( - dispatcher: Arc, - future: AnyLocalFuture, - location: &'static core::panic::Location<'static>, - priority: Priority, - ) -> Task { - let (runnable, task) = spawn_local_with_source_location( - future, - move |runnable| { - dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable), priority) - }, - RunnableMeta { location }, - ); - runnable.schedule(); - Task(TaskState::Spawned(task)) - } - inner::(dispatcher, Box::pin(future), location, priority) - } -} - -/// Variant of `async_task::spawn_local` that includes the source location of the spawn in panics. -/// -/// Copy-modified from: -/// -#[track_caller] -fn spawn_local_with_source_location( - future: Fut, - schedule: S, - metadata: M, -) -> (Runnable, async_task::Task) -where - Fut: Future + 'static, - Fut::Output: 'static, - S: async_task::Schedule + Send + Sync + 'static, - M: 'static, -{ - #[inline] - fn thread_id() -> ThreadId { - std::thread_local! { - static ID: ThreadId = thread::current().id(); - } - ID.try_with(|id| *id) - .unwrap_or_else(|_| thread::current().id()) + // Priority is ignored for foreground tasks - they run in order on the main thread + Task::from_scheduler(self.inner.spawn(future)) } - struct Checked { - id: ThreadId, - inner: ManuallyDrop, - location: &'static Location<'static>, + /// Used by the test harness to run an async test in a synchronous fashion. + #[cfg(any(test, feature = "test-support"))] + #[track_caller] + pub fn block_test(&self, future: impl Future) -> R { + use std::cell::Cell; + + let scheduler = self.inner.scheduler(); + + let output = Cell::new(None); + let future = async { + output.set(Some(future.await)); + }; + let mut future = std::pin::pin!(future); + + // In async GPUI tests, we must allow foreground tasks scheduled by the test itself + // (which are associated with the test session) to make progress while we block. + // Otherwise, awaiting futures that depend on same-session foreground work can deadlock. + scheduler.block(None, future.as_mut(), None); + + output.take().expect("block_test future did not complete") } - impl Drop for Checked { - fn drop(&mut self) { - assert!( - self.id == thread_id(), - "local task dropped by a thread that didn't spawn it. Task spawned at {}", - self.location - ); - unsafe { ManuallyDrop::drop(&mut self.inner) }; - } + /// Block the current thread until the given future resolves. + /// Consider using `block_with_timeout` instead. + pub fn block_on(&self, future: impl Future) -> R { + self.inner.block_on(future) } - impl Future for Checked { - type Output = F::Output; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - assert!( - self.id == thread_id(), - "local task polled by a thread that didn't spawn it. Task spawned at {}", - self.location - ); - unsafe { self.map_unchecked_mut(|c| &mut *c.inner).poll(cx) } - } + /// Block the current thread until the given future resolves or the timeout elapses. + pub fn block_with_timeout>( + &self, + duration: Duration, + future: Fut, + ) -> Result + use> { + self.inner.block_with_timeout(duration, future) } - // Wrap the future into one that checks which thread it's on. - let future = Checked { - id: thread_id(), - inner: ManuallyDrop::new(future), - location: Location::caller(), - }; + #[doc(hidden)] + pub fn dispatcher(&self) -> &Arc { + &self.dispatcher + } - unsafe { - async_task::Builder::new() - .metadata(metadata) - .spawn_unchecked(move |_| future, schedule) + #[doc(hidden)] + pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor { + self.inner.clone() } } @@ -834,6 +534,62 @@ impl Drop for Scope<'_> { // Wait until the channel is closed, which means that all of the spawned // futures have resolved. - self.executor.block(self.rx.next()); + let future = async { + self.rx.next().await; + }; + let mut future = std::pin::pin!(future); + self.executor + .inner + .scheduler() + .block(None, future.as_mut(), None); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{App, TestDispatcher, TestPlatform}; + use std::cell::RefCell; + + /// Helper to create test infrastructure. + /// Returns (dispatcher, background_executor, app). + fn create_test_app() -> (TestDispatcher, BackgroundExecutor, Rc) { + let dispatcher = TestDispatcher::new(0); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + + let platform = TestPlatform::new(background_executor.clone(), foreground_executor); + let asset_source = Arc::new(()); + let http_client = http_client::FakeHttpClient::with_404_response(); + + let app = App::new_app(platform, asset_source, http_client); + (dispatcher, background_executor, app) + } + + #[test] + fn sanity_test_tasks_run() { + let (dispatcher, _background_executor, app) = create_test_app(); + let foreground_executor = app.borrow().foreground_executor.clone(); + + let task_ran = Rc::new(RefCell::new(false)); + + foreground_executor + .spawn({ + let task_ran = Rc::clone(&task_ran); + async move { + *task_ran.borrow_mut() = true; + } + }) + .detach(); + + // Run dispatcher while app is still alive + dispatcher.run_until_parked(); + + // Task should have run + assert!( + *task_ran.borrow(), + "Task should run normally when app is alive" + ); } } diff --git a/src/geometry.rs b/src/geometry.rs index f466624dfb..76157a06a5 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -78,6 +78,7 @@ pub trait Along { Deserialize, JsonSchema, Hash, + Neg, )] #[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[repr(C)] @@ -182,12 +183,6 @@ impl Along for Point { } } -impl Negate for Point { - fn negate(self) -> Self { - self.map(Negate::negate) - } -} - impl Point { /// Scales the point by a given factor, which is typically derived from the resolution /// of a target display to ensure proper sizing of UI elements. @@ -393,7 +388,9 @@ impl Display for Point { /// /// This struct is generic over the type `T`, which can be any type that implements `Clone`, `Default`, and `Debug`. /// It is commonly used to specify dimensions for elements in a UI, such as a window or element. -#[derive(Refineable, Default, Clone, Copy, PartialEq, Div, Hash, Serialize, Deserialize)] +#[derive( + Add, Clone, Copy, Default, Deserialize, Div, Hash, Neg, PartialEq, Refineable, Serialize, Sub, +)] #[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[repr(C)] pub struct Size { @@ -598,34 +595,6 @@ where } } -impl Sub for Size -where - T: Sub + Clone + Debug + Default + PartialEq, -{ - type Output = Size; - - fn sub(self, rhs: Self) -> Self::Output { - Size { - width: self.width - rhs.width, - height: self.height - rhs.height, - } - } -} - -impl Add for Size -where - T: Add + Clone + Debug + Default + PartialEq, -{ - type Output = Size; - - fn add(self, rhs: Self) -> Self::Output { - Size { - width: self.width + rhs.width, - height: self.height + rhs.height, - } - } -} - impl Mul for Size where T: Mul + Clone + Debug + Default + PartialEq, @@ -1112,7 +1081,10 @@ impl + Sub + Clone + Debug + Defa /// ``` pub fn intersect(&self, other: &Self) -> Self { let upper_left = self.origin.max(&other.origin); - let bottom_right = self.bottom_right().min(&other.bottom_right()); + let bottom_right = self + .bottom_right() + .min(&other.bottom_right()) + .max(&upper_left); Self::from_corners(upper_left, bottom_right) } @@ -1242,6 +1214,15 @@ where } } +impl From> for Point { + fn from(size: Size) -> Self { + Self { + x: size.width, + y: size.height, + } + } +} + impl Bounds where T: Add + Clone + Debug + Default + PartialEq, @@ -1589,7 +1570,7 @@ impl> Disp impl Size { /// Converts the size from physical to logical pixels. - pub(crate) fn to_pixels(self, scale_factor: f32) -> Size { + pub fn to_pixels(self, scale_factor: f32) -> Size { size( px(self.width.0 as f32 / scale_factor), px(self.height.0 as f32 / scale_factor), @@ -1599,7 +1580,7 @@ impl Size { impl Size { /// Converts the size from logical to physical pixels. - pub(crate) fn to_device_pixels(self, scale_factor: f32) -> Size { + pub fn to_device_pixels(self, scale_factor: f32) -> Size { size( DevicePixels((self.width.0 * scale_factor).round() as i32), DevicePixels((self.height.0 * scale_factor).round() as i32), @@ -2648,6 +2629,18 @@ impl Debug for Pixels { } } +impl std::iter::Sum for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + b) + } +} + +impl<'a> std::iter::Sum<&'a Pixels> for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + *b) + } +} + impl TryFrom<&'_ str> for Pixels { type Error = anyhow::Error; @@ -2668,6 +2661,11 @@ impl Pixels { /// The minimum value that can be represented by `Pixels`. pub const MIN: Pixels = Pixels(f32::MIN); + /// Returns the raw `f32` value of this `Pixels`. + pub fn as_f32(self) -> f32 { + self.0 + } + /// Floors the `Pixels` value to the nearest whole number. /// /// # Returns @@ -2949,9 +2947,14 @@ impl From for DevicePixels { /// display resolutions. #[derive(Clone, Copy, Default, Add, AddAssign, Sub, SubAssign, Div, DivAssign, PartialEq)] #[repr(transparent)] -pub struct ScaledPixels(pub(crate) f32); +pub struct ScaledPixels(pub f32); impl ScaledPixels { + /// Returns the raw `f32` value of this `ScaledPixels`. + pub fn as_f32(self) -> f32 { + self.0 + } + /// Floors the `ScaledPixels` value to the nearest whole number. /// /// # Returns @@ -3729,48 +3732,6 @@ impl Half for Rems { } } -/// Provides a trait for types that can negate their values. -pub trait Negate { - /// Returns the negation of the given value - fn negate(self) -> Self; -} - -impl Negate for i32 { - fn negate(self) -> Self { - -self - } -} - -impl Negate for f32 { - fn negate(self) -> Self { - -self - } -} - -impl Negate for DevicePixels { - fn negate(self) -> Self { - Self(-self.0) - } -} - -impl Negate for ScaledPixels { - fn negate(self) -> Self { - Self(-self.0) - } -} - -impl Negate for Pixels { - fn negate(self) -> Self { - Self(-self.0) - } -} - -impl Negate for Rems { - fn negate(self) -> Self { - Self(-self.0) - } -} - /// A trait for checking if a value is zero. /// /// This trait provides a method to determine if a value is considered to be zero. diff --git a/src/gpui.rs b/src/gpui.rs index b2761784ff..8acc91ca0f 100644 --- a/src/gpui.rs +++ b/src/gpui.rs @@ -1,10 +1,11 @@ #![doc = include_str!("../README.md")] -#![deny(missing_docs)] +#![warn(missing_docs)] #![allow(clippy::type_complexity)] // Not useful, GPUI makes heavy use of callbacks #![allow(clippy::collapsible_else_if)] // False positives in platform specific code #![allow(unused_mut)] // False positives in platform specific code extern crate self as gpui; +extern crate gpui_ce_macros as gpui_macros; #[macro_use] mod action; @@ -16,10 +17,12 @@ mod assets; mod bounds_tree; mod color; /// The default colors used by GPUI. -pub mod default_colors; +pub mod colors; mod element; mod elements; mod executor; +mod platform_scheduler; +pub(crate) use platform_scheduler::PlatformScheduler; mod geometry; mod global; mod input; @@ -27,13 +30,18 @@ mod inspector; mod interactive; mod key_dispatch; mod keymap; +mod local_util; mod path_builder; mod platform; pub mod prelude; -mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux"))] -mod queue; +/// Profiling utilities for task timing and thread performance tracking. +pub mod profiler; +#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +#[expect(missing_docs)] +pub mod queue; mod scene; +/// The scheduler module provides task scheduling, execution, and timing primitives. +pub mod scheduler; mod shared_string; mod shared_uri; mod style; @@ -45,10 +53,12 @@ mod taffy; #[cfg(any(test, feature = "test-support"))] pub mod test; mod text_system; -mod util; mod view; mod window; +#[cfg(any(test, feature = "test-support"))] +pub use proptest; + #[cfg(doc)] pub mod _ownership_and_data_flow; @@ -76,29 +86,31 @@ pub use asset_cache::*; pub use assets::*; pub use color::*; pub use ctor::ctor; -pub use default_colors::*; pub use element::*; pub use elements::*; pub use executor::*; pub use geometry::*; pub use global::*; -pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test}; +pub use gpui_macros::{ + AppContext, IntoElement, Render, VisualContext, derive_inspector_reflection, register_action, + test, +}; pub use http_client; pub use input::*; pub use inspector::*; pub use interactive::*; use key_dispatch::*; pub use keymap::*; +pub use local_util::{FutureExt, Timeout, command}; pub use path_builder::*; pub use platform::*; pub use profiler::*; -#[cfg(any(target_os = "windows", target_os = "linux"))] -pub(crate) use queue::{PriorityQueueReceiver, PriorityQueueSender}; +#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +pub use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; pub use shared_string::*; pub use shared_uri::*; -pub use smol::Timer; use std::{any::Any, future::Future}; pub use style::*; pub use styled::*; @@ -110,32 +122,23 @@ pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; pub use text_system::*; -#[cfg(any(test, feature = "test-support"))] -pub use util::smol_timeout; -pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; +pub use util::arc_cow::ArcCow; pub use view::*; pub use window::*; /// The context trait, allows the different contexts in GPUI to be used /// interchangeably for certain operations. pub trait AppContext { - /// The result type for this context, used for async contexts that - /// can't hold a direct reference to the application context. - type Result; - /// Create a new entity in the app context. #[expect( clippy::wrong_self_convention, reason = "`App::new` is an ubiquitous function for creating entities" )] - fn new( - &mut self, - build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result>; + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity; /// Reserve a slot for a entity to be inserted later. /// The returned [Reservation] allows you to obtain the [EntityId] for the future entity. - fn reserve_entity(&mut self) -> Self::Result>; + fn reserve_entity(&mut self) -> Reservation; /// Insert a new entity in the app context based on a [Reservation] previously obtained from [`reserve_entity`]. /// @@ -144,28 +147,24 @@ pub trait AppContext { &mut self, reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, - ) -> Self::Result>; + ) -> Entity; /// Update a entity in the app context. fn update_entity( &mut self, handle: &Entity, update: impl FnOnce(&mut T, &mut Context) -> R, - ) -> Self::Result + ) -> R where T: 'static; /// Update a entity in the app context. - fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> Self::Result> + fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> GpuiBorrow<'a, T> where T: 'static; /// Read a entity from the app context. - fn read_entity( - &self, - handle: &Entity, - read: impl FnOnce(&T, &App) -> R, - ) -> Self::Result + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R where T: 'static; @@ -189,7 +188,7 @@ pub trait AppContext { R: Send + 'static; /// Read a global from this app context - fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> Self::Result + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R where G: Global; } @@ -208,6 +207,9 @@ impl Reservation { /// This trait is used for the different visual contexts in GPUI that /// require a window to be present. pub trait VisualContext: AppContext { + /// The result type for window operations. + type Result; + /// Returns the handle of the window associated with this context. fn window_handle(&self) -> AnyWindowHandle; @@ -285,24 +287,6 @@ where } } -/// A flatten equivalent for anyhow `Result`s. -pub trait Flatten { - /// Convert this type into a simple `Result`. - fn flatten(self) -> Result; -} - -impl Flatten for Result> { - fn flatten(self) -> Result { - self? - } -} - -impl Flatten for Result { - fn flatten(self) -> Result { - self - } -} - /// Information about the GPU GPUI is running on. #[derive(Default, Debug, serde::Serialize, serde::Deserialize, Clone)] pub struct GpuSpecs { diff --git a/src/interactive.rs b/src/interactive.rs index 03acf81add..3d3ddb49f7 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {} /// A mouse event from the platform. pub trait MouseEvent: InputEvent {} +/// A gesture event from the platform. +pub trait GestureEvent: InputEvent {} + /// The key down event equivalent for the platform. #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeyDownEvent { @@ -174,6 +177,40 @@ pub struct MouseClickEvent { pub up: MouseUpEvent, } +/// The stage of a pressure click event. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PressureStage { + /// No pressure. + #[default] + Zero, + /// Normal click pressure. + Normal, + /// High pressure, enough to trigger a force click. + Force, +} + +/// A mouse pressure event from the platform. Generated when a force-sensitive trackpad is pressed hard. +/// Currently only implemented for macOS trackpads. +#[derive(Debug, Clone, Default)] +pub struct MousePressureEvent { + /// Pressure of the current stage as a float between 0 and 1 + pub pressure: f32, + /// The pressure stage of the event. + pub stage: PressureStage, + /// The position of the mouse on the window. + pub position: Point, + /// The modifiers that were held down when the mouse pressure changed. + pub modifiers: Modifiers, +} + +impl Sealed for MousePressureEvent {} +impl InputEvent for MousePressureEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MousePressure(self) + } +} +impl MouseEvent for MousePressureEvent {} + /// A click event that was generated by a keyboard button being pressed and released. #[derive(Clone, Debug, Default)] pub struct KeyboardClickEvent { @@ -250,6 +287,19 @@ impl ClickEvent { } } + /// Returns if this was a middle click + /// + /// `Keyboard`: false + /// `Mouse`: Whether the middle button was pressed and released + pub fn is_middle_click(&self) -> bool { + match self { + ClickEvent::Keyboard(_) => false, + ClickEvent::Mouse(event) => { + event.down.button == MouseButton::Middle && event.up.button == MouseButton::Middle + } + } + } + /// Returns whether the click was a standard click /// /// `Keyboard`: Always true @@ -420,6 +470,51 @@ impl Default for ScrollDelta { } } +/// A pinch gesture event from the platform, generated when the user performs +/// a pinch-to-zoom gesture (typically on a trackpad). +/// +/// Note: This event is only available on macOS and Wayland (Linux). +/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. +#[derive(Clone, Debug, Default)] +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub struct PinchEvent { + /// The position of the pinch center on the window. + pub position: Point, + + /// The zoom delta for this event. + /// Positive values indicate zooming in, negative values indicate zooming out. + /// For example, 0.1 represents a 10% zoom increase. + pub delta: f32, + + /// The modifiers that were held down during the pinch gesture. + pub modifiers: Modifiers, + + /// The phase of the pinch gesture. + pub phase: TouchPhase, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Sealed for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl InputEvent for PinchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Pinch(self) + } +} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl GestureEvent for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl MouseEvent for PinchEvent {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Deref for PinchEvent { + type Target = Modifiers; + + fn deref(&self) -> &Self::Target { + &self.modifiers + } +} + impl ScrollDelta { /// Returns true if this is a precise scroll delta in pixels. pub fn precise(&self) -> bool { @@ -510,7 +605,7 @@ impl Deref for MouseExitEvent { /// A collection of paths from the platform, such as from a file drop. #[derive(Debug, Clone, Default, Eq, PartialEq)] -pub struct ExternalPaths(pub(crate) SmallVec<[PathBuf; 2]>); +pub struct ExternalPaths(pub SmallVec<[PathBuf; 2]>); impl ExternalPaths { /// Convert this collection of paths into a slice. @@ -571,12 +666,17 @@ pub enum PlatformInput { MouseDown(MouseDownEvent), /// The mouse was released. MouseUp(MouseUpEvent), + /// Mouse pressure. + MousePressure(MousePressureEvent), /// The mouse was moved. MouseMove(MouseMoveEvent), /// The mouse exited the window. MouseExited(MouseExitEvent), /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), + /// A pinch gesture was performed. + #[cfg(any(target_os = "linux", target_os = "macos"))] + Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), } @@ -590,8 +690,11 @@ impl PlatformInput { PlatformInput::MouseDown(event) => Some(event), PlatformInput::MouseUp(event) => Some(event), PlatformInput::MouseMove(event) => Some(event), + PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } } @@ -604,8 +707,11 @@ impl PlatformInput { PlatformInput::MouseDown(_) => None, PlatformInput::MouseUp(_) => None, PlatformInput::MouseMove(_) => None, + PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } } @@ -667,8 +773,8 @@ mod test { }); window - .update(cx, |test_view, window, _cx| { - window.focus(&test_view.focus_handle) + .update(cx, |test_view, window, cx| { + window.focus(&test_view.focus_handle, cx) }) .unwrap(); diff --git a/src/key_dispatch.rs b/src/key_dispatch.rs index ae4553408f..fee75d5dad 100644 --- a/src/key_dispatch.rs +++ b/src/key_dispatch.rs @@ -199,8 +199,8 @@ impl DispatchTree { if let Some(context) = node.context.clone() { self.context_stack.push(context); } - if node.view_id.is_some() { - self.view_stack.push(node.view_id.unwrap()); + if let Some(view_id) = node.view_id { + self.view_stack.push(view_id); } self.node_stack.push(node_id); current_node_id = node.parent; @@ -462,6 +462,17 @@ impl DispatchTree { (bindings, partial, context_stack) } + /// Find the bindings that can follow the current input sequence. + pub fn possible_next_bindings_for_input( + &self, + input: &[Keystroke], + context_stack: &[KeyContext], + ) -> Vec { + self.keymap + .borrow() + .possible_next_bindings_for_input(input, context_stack) + } + /// dispatch_key processes the keystroke /// input should be set to the value of `pending` from the previous call to dispatch_key. /// This returns three instructions to the input handler: @@ -610,66 +621,38 @@ impl DispatchTree { #[cfg(test)] mod tests { use crate::{ - self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId, - Keystroke, LayoutId, Style, + self as gpui, AppContext, DispatchResult, Element, ElementId, GlobalElementId, + InspectorElementId, Keystroke, LayoutId, Style, }; use core::panic; use smallvec::SmallVec; use std::{cell::RefCell, ops::Range, rc::Rc}; use crate::{ - Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, - IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext, - UTF16Selection, Window, + ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, IntoElement, + KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription, TestAppContext, + UTF16Selection, Unbind, Window, }; - #[derive(PartialEq, Eq)] - struct TestAction; + actions!(dispatch_test, [TestAction, SecondaryTestAction]); - impl Action for TestAction { - fn name(&self) -> &'static str { - "test::TestAction" - } + fn test_dispatch_tree(bindings: Vec) -> DispatchTree { + let registry = ActionRegistry::default(); - fn name_for_type() -> &'static str - where - Self: ::std::marker::Sized, - { - "test::TestAction" - } - - fn partial_eq(&self, action: &dyn Action) -> bool { - action.as_any().downcast_ref::() == Some(self) - } - - fn boxed_clone(&self) -> std::boxed::Box { - Box::new(TestAction) - } - - fn build(_value: serde_json::Value) -> anyhow::Result> - where - Self: Sized, - { - Ok(Box::new(TestAction)) - } + DispatchTree::new( + Rc::new(RefCell::new(Keymap::new(bindings))), + Rc::new(registry), + ) } #[test] fn test_keybinding_for_action_bounds() { - let keymap = Keymap::new(vec![KeyBinding::new( + let tree = test_dispatch_tree(vec![KeyBinding::new( "cmd-n", TestAction, Some("ProjectPanel"), )]); - let mut registry = ActionRegistry::default(); - - registry.load_action::(); - - let keymap = Rc::new(RefCell::new(keymap)); - - let tree = DispatchTree::new(keymap, Rc::new(registry)); - let contexts = vec![ KeyContext::parse("Workspace").unwrap(), KeyContext::parse("ProjectPanel").unwrap(), @@ -680,6 +663,67 @@ mod tests { assert!(keybinding[0].action.partial_eq(&TestAction)) } + #[test] + fn test_bindings_for_action_hides_targeted_unbind_in_active_context() { + let tree = test_dispatch_tree(vec![ + KeyBinding::new("tab", TestAction, Some("Editor")), + KeyBinding::new( + "tab", + Unbind("dispatch_test::TestAction".into()), + Some("Editor && edit_prediction"), + ), + KeyBinding::new( + "tab", + SecondaryTestAction, + Some("Editor && showing_completions"), + ), + ]); + + let contexts = vec![ + KeyContext::parse("Workspace").unwrap(), + KeyContext::parse("Editor showing_completions edit_prediction").unwrap(), + ]; + + let bindings = tree.bindings_for_action(&TestAction, &contexts); + assert!(bindings.is_empty()); + + let highest = tree.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!(highest.is_none()); + + let fallback_bindings = tree.bindings_for_action(&SecondaryTestAction, &contexts); + assert_eq!(fallback_bindings.len(), 1); + assert!(fallback_bindings[0].action.partial_eq(&SecondaryTestAction)); + } + + #[test] + fn test_bindings_for_action_keeps_targeted_binding_outside_unbind_context() { + let tree = test_dispatch_tree(vec![ + KeyBinding::new("tab", TestAction, Some("Editor")), + KeyBinding::new( + "tab", + Unbind("dispatch_test::TestAction".into()), + Some("Editor && edit_prediction"), + ), + KeyBinding::new( + "tab", + SecondaryTestAction, + Some("Editor && showing_completions"), + ), + ]); + + let contexts = vec![ + KeyContext::parse("Workspace").unwrap(), + KeyContext::parse("Editor").unwrap(), + ]; + + let bindings = tree.bindings_for_action(&TestAction, &contexts); + assert_eq!(bindings.len(), 1); + assert!(bindings[0].action.partial_eq(&TestAction)); + + let highest = tree.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!(highest.is_some_and(|binding| binding.action.partial_eq(&TestAction))); + } + #[test] fn test_pending_has_binding_state() { let bindings = vec![ @@ -687,10 +731,7 @@ mod tests { KeyBinding::new("space", TestAction, Some("ContextA")), KeyBinding::new("space f g", TestAction, Some("ContextB")), ]; - let keymap = Rc::new(RefCell::new(Keymap::new(bindings))); - let mut registry = ActionRegistry::default(); - registry.load_action::(); - let mut tree = DispatchTree::new(keymap, Rc::new(registry)); + let mut tree = test_dispatch_tree(bindings); type DispatchPath = SmallVec<[super::DispatchNodeId; 32]>; fn dispatch( @@ -723,6 +764,213 @@ mod tests { assert!(!result.pending_has_binding); } + #[crate::test] + fn test_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) { + #[derive(Clone)] + struct CustomElement { + focus_handle: FocusHandle, + text: Rc>, + } + + impl CustomElement { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + } + } + } + + impl Element for CustomElement { + type RequestLayoutState = (); + + type PrepaintState = (); + + fn id(&self) -> Option { + Some("custom".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + window.on_action(std::any::TypeId::of::(), |_, _, _, _| {}); + } + } + + impl IntoElement for CustomElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for CustomElement { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for CustomElement { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + }); + + let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + + let pending_input_changed_count = Rc::new(RefCell::new(0usize)); + let pending_input_changed_count_for_observer = pending_input_changed_count.clone(); + + struct PendingInputObserver { + _subscription: Subscription, + } + + let _observer = cx.update(|window, cx| { + cx.new(|cx| PendingInputObserver { + _subscription: cx.observe_pending_input(window, move |_, _, _| { + *pending_input_changed_count_for_observer.borrow_mut() += 1; + }), + }) + }); + + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + cx.simulate_keystrokes("ctrl-b"); + + let count_after_pending = Rc::new(RefCell::new(0usize)); + let count_after_pending_for_assertion = count_after_pending.clone(); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + *count_after_pending.borrow_mut() = *pending_input_changed_count.borrow(); + assert!(*count_after_pending.borrow() > 0); + + window.focus(&cx.focus_handle(), cx); + + assert!(!window.has_pending_keystrokes()); + }); + + // Focus-triggered pending-input notifications are deferred to the end of the current + // effect cycle, so the observer callback should run after the focus update completes. + cx.update(|_, _| { + let count_after_focus_change = *pending_input_changed_count.borrow(); + assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow()); + }); + } + #[crate::test] fn test_input_handler_pending(cx: &mut TestAppContext) { #[derive(Clone)] @@ -876,8 +1124,9 @@ mod tests { cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); }); let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { - window.focus(&test.read(cx).focus_handle); + window.focus(&focus_handle, cx); window.activate_window(); }); cx.simulate_keystrokes("ctrl-b ["); diff --git a/src/keymap.rs b/src/keymap.rs index 33d9569170..eaf582a007 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -4,7 +4,7 @@ mod context; pub use binding::*; pub use context::*; -use crate::{Action, AsKeystroke, Keystroke, is_no_action}; +use crate::{Action, AsKeystroke, Keystroke, Unbind, is_no_action, is_unbind}; use collections::{HashMap, HashSet}; use smallvec::SmallVec; use std::any::TypeId; @@ -19,7 +19,7 @@ pub struct KeymapVersion(usize); pub struct Keymap { bindings: Vec, binding_indices_by_action_id: HashMap>, - no_action_binding_indices: Vec, + disabled_binding_indices: Vec, version: KeymapVersion, } @@ -27,6 +27,26 @@ pub struct Keymap { #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct BindingIndex(usize); +fn disabled_binding_matches_context(disabled_binding: &KeyBinding, binding: &KeyBinding) -> bool { + match ( + &disabled_binding.context_predicate, + &binding.context_predicate, + ) { + (None, _) => true, + (Some(_), None) => false, + (Some(disabled_predicate), Some(predicate)) => disabled_predicate.is_superset(predicate), + } +} + +fn binding_is_unbound(disabled_binding: &KeyBinding, binding: &KeyBinding) -> bool { + disabled_binding.keystrokes == binding.keystrokes + && disabled_binding + .action() + .as_any() + .downcast_ref::() + .is_some_and(|unbind| unbind.0.as_ref() == binding.action.name()) +} + impl Keymap { /// Create a new keymap with the given bindings. pub fn new(bindings: Vec) -> Self { @@ -44,8 +64,8 @@ impl Keymap { pub fn add_bindings>(&mut self, bindings: T) { for binding in bindings { let action_id = binding.action().as_any().type_id(); - if is_no_action(&*binding.action) { - self.no_action_binding_indices.push(self.bindings.len()); + if is_no_action(&*binding.action) || is_unbind(&*binding.action) { + self.disabled_binding_indices.push(self.bindings.len()); } else { self.binding_indices_by_action_id .entry(action_id) @@ -62,7 +82,7 @@ impl Keymap { pub fn clear(&mut self) { self.bindings.clear(); self.binding_indices_by_action_id.clear(); - self.no_action_binding_indices.clear(); + self.disabled_binding_indices.clear(); self.version.0 += 1; } @@ -90,21 +110,22 @@ impl Keymap { return None; } - for null_ix in &self.no_action_binding_indices { - if null_ix > ix { - let null_binding = &self.bindings[*null_ix]; - if null_binding.keystrokes == binding.keystrokes { - let null_binding_matches = - match (&null_binding.context_predicate, &binding.context_predicate) { - (None, _) => true, - (Some(_), None) => false, - (Some(null_predicate), Some(predicate)) => { - null_predicate.is_superset(predicate) - } - }; - if null_binding_matches { + for disabled_ix in &self.disabled_binding_indices { + if disabled_ix > ix { + let disabled_binding = &self.bindings[*disabled_ix]; + if disabled_binding.keystrokes != binding.keystrokes { + continue; + } + + if is_no_action(&*disabled_binding.action) { + if disabled_binding_matches_context(disabled_binding, binding) { return None; } + } else if is_unbind(&*disabled_binding.action) + && disabled_binding_matches_context(disabled_binding, binding) + && binding_is_unbound(disabled_binding, binding) + { + return None; } } } @@ -170,6 +191,7 @@ impl Keymap { let mut bindings: SmallVec<[_; 1]> = SmallVec::new(); let mut first_binding_index = None; + let mut unbound_bindings: Vec<&KeyBinding> = Vec::new(); for (_, ix, binding) in matched_bindings { if is_no_action(&*binding.action) { @@ -186,6 +208,19 @@ impl Keymap { // For non-user NoAction bindings, continue searching for user overrides continue; } + + if is_unbind(&*binding.action) { + unbound_bindings.push(binding); + continue; + } + + if unbound_bindings + .iter() + .any(|disabled_binding| binding_is_unbound(disabled_binding, binding)) + { + continue; + } + bindings.push(binding.clone()); first_binding_index.get_or_insert(ix); } @@ -197,7 +232,7 @@ impl Keymap { { continue; } - if is_no_action(&*binding.action) { + if is_no_action(&*binding.action) || is_unbind(&*binding.action) { pending.remove(&&binding.keystrokes); continue; } @@ -215,13 +250,51 @@ impl Keymap { Some(contexts.len()) } } + + /// Find the bindings that can follow the current input sequence. + pub fn possible_next_bindings_for_input( + &self, + input: &[Keystroke], + context_stack: &[KeyContext], + ) -> Vec { + let mut bindings = self + .bindings() + .enumerate() + .rev() + .filter_map(|(ix, binding)| { + let depth = self.binding_enabled(binding, context_stack)?; + let pending = binding.match_keystrokes(input); + match pending { + None => None, + Some(is_pending) => { + if !is_pending + || is_no_action(&*binding.action) + || is_unbind(&*binding.action) + { + return None; + } + Some((depth, BindingIndex(ix), binding)) + } + } + }) + .collect::>(); + + bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| { + depth_b.cmp(depth_a).then(ix_b.cmp(ix_a)) + }); + + bindings + .into_iter() + .map(|(_, _, binding)| binding.clone()) + .collect::>() + } } #[cfg(test)] mod tests { use super::*; use crate as gpui; - use gpui::NoAction; + use gpui::{NoAction, Unbind}; actions!( test_only, @@ -685,6 +758,76 @@ mod tests { } } + #[test] + fn test_targeted_unbind_ignores_target_context() { + let bindings = [ + KeyBinding::new("tab", ActionAlpha {}, Some("Editor")), + KeyBinding::new("tab", ActionBeta {}, Some("Editor && showing_completions")), + KeyBinding::new( + "tab", + Unbind("test_only::ActionAlpha".into()), + Some("Editor && edit_prediction"), + ), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + let (result, pending) = keymap.bindings_for_input( + &[Keystroke::parse("tab").unwrap()], + &[KeyContext::parse("Editor showing_completions edit_prediction").unwrap()], + ); + + assert!(!pending); + assert_eq!(result.len(), 1); + assert!(result[0].action.partial_eq(&ActionBeta {})); + } + + #[test] + fn test_bindings_for_action_keeps_binding_for_narrower_targeted_unbind() { + let bindings = [ + KeyBinding::new("tab", ActionAlpha {}, Some("Editor")), + KeyBinding::new( + "tab", + Unbind("test_only::ActionAlpha".into()), + Some("Editor && edit_prediction"), + ), + KeyBinding::new("tab", ActionBeta {}, Some("Editor && showing_completions")), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + assert_bindings(&keymap, &ActionAlpha {}, &["tab"]); + assert_bindings(&keymap, &ActionBeta {}, &["tab"]); + + #[track_caller] + fn assert_bindings(keymap: &Keymap, action: &dyn Action, expected: &[&str]) { + let actual = keymap + .bindings_for_action(action) + .map(|binding| binding.keystrokes[0].inner().unparse()) + .collect::>(); + assert_eq!(actual, expected, "{:?}", action); + } + } + + #[test] + fn test_bindings_for_action_removes_binding_for_broader_targeted_unbind() { + let bindings = [ + KeyBinding::new("tab", ActionAlpha {}, Some("Editor && edit_prediction")), + KeyBinding::new( + "tab", + Unbind("test_only::ActionAlpha".into()), + Some("Editor"), + ), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + assert!(keymap.bindings_for_action(&ActionAlpha {}).next().is_none()); + } + #[test] fn test_source_precedence_sorting() { // KeybindSource precedence: User (0) > Vim (1) > Base (2) > Default (3) diff --git a/src/keymap/context.rs b/src/keymap/context.rs index 960bd1752f..27f361bbe2 100644 --- a/src/keymap/context.rs +++ b/src/keymap/context.rs @@ -199,13 +199,20 @@ pub enum KeyBindingContextPredicate { impl fmt::Display for KeyBindingContextPredicate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Identifier(name) => write!(f, "{}", name), - Self::Equal(left, right) => write!(f, "{} == {}", left, right), - Self::NotEqual(left, right) => write!(f, "{} != {}", left, right), - Self::Not(pred) => write!(f, "!{}", pred), - Self::Descendant(parent, child) => write!(f, "{} > {}", parent, child), - Self::And(left, right) => write!(f, "({} && {})", left, right), - Self::Or(left, right) => write!(f, "({} || {})", left, right), + Self::Identifier(name) => write!(f, "{name}"), + Self::Equal(left, right) => write!(f, "{left} == {right}"), + Self::NotEqual(left, right) => write!(f, "{left} != {right}"), + Self::Descendant(parent, child) => write!(f, "{parent} > {child}"), + Self::Not(pred) => match pred.as_ref() { + Self::Identifier(name) => write!(f, "!{name}"), + _ => write!(f, "!({pred})"), + }, + Self::And(..) => self.fmt_joined(f, " && ", LogicalOperator::And, |node| { + matches!(node, Self::Or(..)) + }), + Self::Or(..) => self.fmt_joined(f, " || ", LogicalOperator::Or, |node| { + matches!(node, Self::And(..)) + }), } } } @@ -262,7 +269,7 @@ impl KeyBindingContextPredicate { /// Eval a predicate against a set of contexts, arranged from lowest to highest. #[allow(unused)] - pub(crate) fn eval(&self, contexts: &[KeyContext]) -> bool { + pub fn eval(&self, contexts: &[KeyContext]) -> bool { self.eval_inner(contexts, contexts) } @@ -436,6 +443,52 @@ impl KeyBindingContextPredicate { anyhow::bail!("operands of != must be identifiers"); } } + + fn fmt_joined( + &self, + f: &mut fmt::Formatter<'_>, + separator: &str, + operator: LogicalOperator, + needs_parens: impl Fn(&Self) -> bool + Copy, + ) -> fmt::Result { + let mut first = true; + self.fmt_joined_inner(f, separator, operator, needs_parens, &mut first) + } + + fn fmt_joined_inner( + &self, + f: &mut fmt::Formatter<'_>, + separator: &str, + operator: LogicalOperator, + needs_parens: impl Fn(&Self) -> bool + Copy, + first: &mut bool, + ) -> fmt::Result { + match (operator, self) { + (LogicalOperator::And, Self::And(left, right)) + | (LogicalOperator::Or, Self::Or(left, right)) => { + left.fmt_joined_inner(f, separator, operator, needs_parens, first)?; + right.fmt_joined_inner(f, separator, operator, needs_parens, first) + } + (_, node) => { + if !*first { + f.write_str(separator)?; + } + *first = false; + + if needs_parens(node) { + write!(f, "({node})") + } else { + write!(f, "{node}") + } + } + } + } +} + +#[derive(Clone, Copy)] +enum LogicalOperator { + And, + Or, } const PRECEDENCE_CHILD: u32 = 1; @@ -757,4 +810,82 @@ mod tests { assert!(not_workspace.eval(slice::from_ref(&editor_context))); assert!(!not_workspace.eval(&workspace_pane_editor)); } + + // MARK: - Display + + #[test] + fn test_context_display() { + fn ident(s: &str) -> Box { + Box::new(Identifier(SharedString::new(s))) + } + fn eq(a: &str, b: &str) -> Box { + Box::new(Equal(SharedString::new(a), SharedString::new(b))) + } + fn not_eq(a: &str, b: &str) -> Box { + Box::new(NotEqual(SharedString::new(a), SharedString::new(b))) + } + fn and( + a: Box, + b: Box, + ) -> Box { + Box::new(And(a, b)) + } + fn or( + a: Box, + b: Box, + ) -> Box { + Box::new(Or(a, b)) + } + fn descendant( + a: Box, + b: Box, + ) -> Box { + Box::new(Descendant(a, b)) + } + fn not(a: Box) -> Box { + Box::new(Not(a)) + } + + let test_cases = [ + (ident("a"), "a"), + (eq("a", "b"), "a == b"), + (not_eq("a", "b"), "a != b"), + (descendant(ident("a"), ident("b")), "a > b"), + (not(ident("a")), "!a"), + (not_eq("a", "b"), "a != b"), + (descendant(ident("a"), ident("b")), "a > b"), + (not(and(ident("a"), ident("b"))), "!(a && b)"), + (not(or(ident("a"), ident("b"))), "!(a || b)"), + (and(ident("a"), ident("b")), "a && b"), + (and(and(ident("a"), ident("b")), ident("c")), "a && b && c"), + (or(ident("a"), ident("b")), "a || b"), + (or(or(ident("a"), ident("b")), ident("c")), "a || b || c"), + (or(ident("a"), and(ident("b"), ident("c"))), "a || (b && c)"), + ( + and( + and( + and(ident("a"), eq("b", "c")), + not(descendant(ident("d"), ident("e"))), + ), + eq("f", "g"), + ), + "a && b == c && !(d > e) && f == g", + ), + ( + and(and(ident("a"), or(ident("b"), ident("c"))), ident("d")), + "a && (b || c) && d", + ), + ( + or(or(ident("a"), and(ident("b"), ident("c"))), ident("d")), + "a || (b && c) || d", + ), + ]; + + for (predicate, expected) in test_cases { + let actual = predicate.to_string(); + assert_eq!(actual, expected); + let parsed = KeyBindingContextPredicate::parse(&actual).unwrap(); + assert_eq!(parsed, *predicate); + } + } } diff --git a/src/util.rs b/src/local_util.rs similarity index 79% rename from src/util.rs rename to src/local_util.rs index 92c86810c5..ac7227cdd9 100644 --- a/src/util.rs +++ b/src/local_util.rs @@ -7,8 +7,6 @@ use std::{ time::Duration, }; -pub use util::*; - /// A helper trait for building complex objects with imperative conditionals in a fluent style. pub trait FluentBuilder { /// Imperatively modify self with the given closure. @@ -112,19 +110,37 @@ impl Future for WithTimeout { } } -#[cfg(any(test, feature = "test-support"))] -/// Uses smol executor to run a given future no longer than the timeout specified. -/// Note that this won't "rewind" on `cx.executor().advance_clock` call, truly waiting for the timeout to elapse. -pub async fn smol_timeout(timeout: Duration, f: F) -> Result -where - F: Future, -{ - let timer = async { - smol::Timer::after(timeout).await; - Err(()) - }; - let future = async move { Ok(f.await) }; - smol::future::FutureExt::race(timer, future).await +/// Utilities for creating and managing shell commands. +pub mod command { + use smol::process::Command as SmolCommand; + use std::ffi::OsStr; + use std::process::Command as StdCommand; + + /// Creates a new `smol::process::Command` with platform-specific defaults. + pub fn new_command(program: impl AsRef) -> SmolCommand { + let mut cmd = SmolCommand::new(program); + #[cfg(target_os = "windows")] + { + use smol::process::windows::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + cmd + } + + /// Creates a new `std::process::Command` with platform-specific defaults. + #[cfg(not(target_os = "windows"))] + pub fn new_std_command(program: impl AsRef) -> StdCommand { + StdCommand::new(program) + } + + /// Creates a new `std::process::Command` with platform-specific defaults. + #[cfg(target_os = "windows")] + pub fn new_std_command(program: impl AsRef) -> StdCommand { + use std::os::windows::process::CommandExt; + let mut cmd = StdCommand::new(program); + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + cmd + } } /// Increment the given atomic counter if it is not zero. diff --git a/src/platform.rs b/src/platform.rs index f120e075fe..9ec4a982ec 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -2,51 +2,62 @@ mod app_menu; mod keyboard; mod keystroke; -#[cfg(any(target_os = "linux", target_os = "freebsd"))] -mod linux; - #[cfg(target_os = "macos")] -mod mac; +pub(crate) mod mac; -#[cfg(any( - all( - any(target_os = "linux", target_os = "freebsd"), - any(feature = "x11", feature = "wayland") - ), - all(target_os = "macos", feature = "macos-blade") -))] -mod blade; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +pub(crate) mod linux; + +#[cfg(target_os = "windows")] +pub(crate) mod windows; + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_family = "wasm"))] +pub(crate) mod wgpu; + +#[cfg(target_family = "wasm")] +pub(crate) mod web; + +#[cfg(all(target_os = "linux", feature = "wayland"))] +#[expect(missing_docs)] +pub mod layer_shell; #[cfg(any(test, feature = "test-support"))] mod test; -#[cfg(target_os = "windows")] -mod windows; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +mod visual_test; #[cfg(all( feature = "screen-capture", - any( - target_os = "windows", - all( - any(target_os = "linux", target_os = "freebsd"), - any(feature = "wayland", feature = "x11"), - ) - ) + any(target_os = "windows", target_os = "linux", target_os = "freebsd",) ))] -pub(crate) mod scap_screen_capture; +pub mod scap_screen_capture; +#[cfg(all( + any(target_os = "windows", target_os = "linux"), + feature = "screen-capture" +))] +pub(crate) type PlatformScreenCaptureFrame = scap::frame::Frame; +#[cfg(not(feature = "screen-capture"))] +pub(crate) type PlatformScreenCaptureFrame = (); +#[cfg(all(target_os = "macos", feature = "screen-capture"))] +pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBuffer; + +use crate::scheduler::Instant; +pub use crate::scheduler::RunnableMeta; use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, - Point, Priority, RealtimePriority, RenderGlyphParams, RenderImage, RenderImageParams, - RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, - SystemWindowTab, Task, TaskLabel, TaskTiming, ThreadTaskTimings, Window, WindowControlArea, - hash, point, px, size, + Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, + ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, + ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; use futures::channel::oneshot; +#[cfg(any(test, feature = "test-support"))] +use image::RgbaImage; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; @@ -58,7 +69,7 @@ use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::io::Cursor; use std::ops; -use std::time::{Duration, Instant}; +use std::time::Duration; use std::{ fmt::{self, Debug}, ops::Range, @@ -73,67 +84,93 @@ pub use app_menu::*; pub use keyboard::*; pub use keystroke::*; -#[cfg(any(target_os = "linux", target_os = "freebsd"))] -pub(crate) use linux::*; -#[cfg(target_os = "macos")] -pub(crate) use mac::*; #[cfg(any(test, feature = "test-support"))] pub(crate) use test::*; -#[cfg(target_os = "windows")] -pub(crate) use windows::*; - -#[cfg(all(target_os = "linux", feature = "wayland"))] -pub use linux::layer_shell; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; +#[cfg(all(target_os = "macos", any(test, feature = "test-support")))] +pub use visual_test::VisualTestPlatform; + +#[cfg(target_os = "macos")] +pub use mac::MacPlatform; + +#[cfg(target_os = "windows")] +pub use windows::WindowsPlatform; + +/// Returns the default [`Platform`] for the current OS. +pub fn current_platform(headless: bool) -> Rc { + #[cfg(target_os = "macos")] + { + Rc::new(mac::MacPlatform::new(headless)) + } + + #[cfg(target_os = "windows")] + { + Rc::new( + windows::WindowsPlatform::new(headless).expect("failed to initialize Windows platform"), + ) + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + { + linux::current_platform(headless) + } + + #[cfg(target_family = "wasm")] + { + let _ = headless; + Rc::new(web::WebPlatform::new(true)) + } +} + /// Returns a background executor for the current platform. -pub fn background_executor() -> BackgroundExecutor { +pub fn background_executor() -> crate::BackgroundExecutor { current_platform(true).background_executor() } -#[cfg(target_os = "macos")] -pub(crate) fn current_platform(headless: bool) -> Rc { - Rc::new(MacPlatform::new(headless)) +/// Creates a new application with the current platform. +pub fn application() -> crate::Application { + crate::Application::with_platform(current_platform(false)) } -#[cfg(any(target_os = "linux", target_os = "freebsd"))] -pub(crate) fn current_platform(headless: bool) -> Rc { - #[cfg(feature = "x11")] - use anyhow::Context as _; +/// Creates a new headless application. +pub fn headless() -> crate::Application { + crate::Application::with_platform(current_platform(true)) +} - if headless { - return Rc::new(HeadlessClient::new()); +/// Unlike `application`, this function returns a single-threaded web application. +#[cfg(target_family = "wasm")] +pub fn single_threaded_web() -> crate::Application { + crate::Application::with_platform(Rc::new(web::WebPlatform::new(false))) +} + +/// Initializes panic hooks and logging for the web platform. +/// Call this before running the application in a wasm_bindgen entrypoint. +#[cfg(target_family = "wasm")] +pub fn web_init() { + console_error_panic_hook::set_once(); + web::init_logging(); +} + +/// Returns a new headless renderer for the current platform, if available. +#[cfg(feature = "test-support")] +pub fn current_headless_renderer() -> Option> { + #[cfg(target_os = "macos")] + { + Some(Box::new(mac::metal_renderer::MetalHeadlessRenderer::new())) } - match guess_compositor() { - #[cfg(feature = "wayland")] - "Wayland" => Rc::new(WaylandClient::new()), - - #[cfg(feature = "x11")] - "X11" => Rc::new( - X11Client::new() - .context("Failed to initialize X11 client.") - .unwrap(), - ), - - "Headless" => Rc::new(HeadlessClient::new()), - _ => unreachable!(), + #[cfg(not(target_os = "macos"))] + { + None } } -#[cfg(target_os = "windows")] -pub(crate) fn current_platform(_headless: bool) -> Rc { - Rc::new( - WindowsPlatform::new() - .inspect_err(|err| show_error("Failed to launch", err.to_string())) - .unwrap(), - ) -} - +// TODO(jk): return an enum instead of a string /// Return which compositor we're guessing we'll use. -/// Does not attempt to connect to the given compositor +/// Does not attempt to connect to the given compositor. #[cfg(any(target_os = "linux", target_os = "freebsd"))] #[inline] pub fn guess_compositor() -> &'static str { @@ -163,7 +200,8 @@ pub fn guess_compositor() -> &'static str { } } -pub(crate) trait Platform: 'static { +#[expect(missing_docs)] +pub trait Platform: 'static { fn background_executor(&self) -> BackgroundExecutor; fn foreground_executor(&self) -> ForegroundExecutor; fn text_system(&self) -> Arc; @@ -183,16 +221,10 @@ pub(crate) trait Platform: 'static { None } - #[cfg(feature = "screen-capture")] - fn is_screen_capture_supported(&self) -> bool; - #[cfg(not(feature = "screen-capture"))] fn is_screen_capture_supported(&self) -> bool { false } - #[cfg(feature = "screen-capture")] - fn screen_capture_sources(&self) - -> oneshot::Receiver>>>; - #[cfg(not(feature = "screen-capture"))] + fn screen_capture_sources( &self, ) -> oneshot::Receiver>>> { @@ -246,13 +278,16 @@ pub(crate) trait Platform: 'static { &self, _menus: Vec, _entries: Vec>, - ) -> Vec> { - Vec::new() + ) -> Task>> { + Task::ready(Vec::new()) } fn on_app_menu_action(&self, callback: Box); fn on_will_open_app_menu(&self, callback: Box); fn on_validate_app_menu_command(&self, callback: Box bool>); + fn thermal_state(&self) -> ThermalState; + fn on_thermal_state_change(&self, callback: Box); + fn compositor_name(&self) -> &'static str { "" } @@ -262,12 +297,18 @@ pub(crate) trait Platform: 'static { fn set_cursor_style(&self, style: CursorStyle); fn should_auto_hide_scrollbars(&self) -> bool; - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem); + fn read_from_clipboard(&self) -> Option; fn write_to_clipboard(&self, item: ClipboardItem); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn read_from_primary(&self) -> Option; - fn read_from_clipboard(&self) -> Option; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem); + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option; + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem); fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; @@ -279,7 +320,7 @@ pub(crate) trait Platform: 'static { } /// A handle to a platform's display, e.g. a monitor or laptop screen. -pub trait PlatformDisplay: Send + Sync + Debug { +pub trait PlatformDisplay: Debug { /// Get the ID for this display fn id(&self) -> DisplayId; @@ -309,6 +350,19 @@ pub trait PlatformDisplay: Send + Sync + Debug { } } +/// Thermal state of the system +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThermalState { + /// System has no thermal constraints + Nominal, + /// System is slightly constrained, reduce discretionary work + Fair, + /// System is moderately constrained, reduce CPU/GPU intensive work + Serious, + /// System is critically constrained, minimize all resource usage + Critical, +} + /// Metadata for a given [ScreenCaptureSource] #[derive(Clone)] pub struct SourceMetadata { @@ -349,6 +403,19 @@ pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); #[derive(PartialEq, Eq, Hash, Copy, Clone)] pub struct DisplayId(pub(crate) u32); +impl DisplayId { + /// Create a new `DisplayId` from a raw platform display identifier. + pub fn new(id: u32) -> Self { + Self(id) + } +} + +impl From for DisplayId { + fn from(id: u32) -> Self { + Self(id) + } +} + impl From for u32 { fn from(id: DisplayId) -> Self { id.0 @@ -461,13 +528,16 @@ impl Tiling { } #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] -pub(crate) struct RequestFrameOptions { - pub(crate) require_presentation: bool, - /// Force refresh of all rendering states when true - pub(crate) force_render: bool, +#[expect(missing_docs)] +pub struct RequestFrameOptions { + /// Whether a presentation is required. + pub require_presentation: bool, + /// Force refresh of all rendering states when true. + pub force_render: bool, } -pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { +#[expect(missing_docs)] +pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn bounds(&self) -> Bounds; fn is_maximized(&self) -> bool; fn window_bounds(&self) -> WindowBounds; @@ -491,6 +561,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn activate(&self); fn is_active(&self) -> bool; fn is_hovered(&self) -> bool; + fn background_appearance(&self) -> WindowBackgroundAppearance; fn set_title(&mut self, title: &str); fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance); fn minimize(&self); @@ -510,6 +581,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn draw(&self, scene: &Scene); fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; + fn is_subpixel_rendering_supported(&self) -> bool; // macOS specific methods fn get_title(&self) -> String { @@ -535,7 +607,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn set_tabbing_identifier(&self, _identifier: Option) {} #[cfg(target_os = "windows")] - fn get_raw_handle(&self) -> windows::HWND; + fn get_raw_handle(&self) -> windows::Win32::Foundation::HWND; // Linux specific methods fn inner_window_bounds(&self) -> WindowBounds { @@ -564,64 +636,99 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn as_test(&mut self) -> Option<&mut TestWindow> { None } + + /// Renders the given scene to a texture and returns the pixel data as an RGBA image. + /// This does not present the frame to screen - useful for visual testing where we want + /// to capture what would be rendered without displaying it or requiring the window to be visible. + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, _scene: &Scene) -> Result { + anyhow::bail!("render_to_image not implemented for this platform") + } } -/// This type is public so that our test macro can generate and use it, but it should not -/// be considered part of our public API. -#[doc(hidden)] -#[derive(Debug)] -pub struct RunnableMeta { - /// Location of the runnable - pub location: &'static core::panic::Location<'static>, +/// A renderer for headless windows that can produce real rendered output. +#[cfg(any(test, feature = "test-support"))] +pub trait PlatformHeadlessRenderer { + /// Render a scene and return the result as an RGBA image. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> Result; + + /// Returns the sprite atlas used by this renderer. + fn sprite_atlas(&self) -> Arc; } +/// Type alias for runnables with metadata. +/// Previously an enum with a single variant, now simplified to a direct type alias. #[doc(hidden)] -pub enum RunnableVariant { - Meta(Runnable), - Compat(Runnable), -} +pub type RunnableVariant = Runnable; + +#[doc(hidden)] +pub type TimerResolutionGuard = util::Deferred>; /// This type is public so that our test macro can generate and use it, but it should not /// be considered part of our public API. #[doc(hidden)] pub trait PlatformDispatcher: Send + Sync { fn get_all_timings(&self) -> Vec; - fn get_current_thread_timings(&self) -> Vec; + fn get_current_thread_timings(&self) -> ThreadTaskTimings; fn is_main_thread(&self) -> bool; - fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority); + fn dispatch(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); - fn spawn_realtime(&self, priority: RealtimePriority, f: Box); + + fn spawn_realtime(&self, f: Box); fn now(&self) -> Instant { Instant::now() } + fn increase_timer_resolution(&self) -> TimerResolutionGuard { + util::defer(Box::new(|| {})) + } + #[cfg(any(test, feature = "test-support"))] fn as_test(&self) -> Option<&TestDispatcher> { None } } -pub(crate) trait PlatformTextSystem: Send + Sync { +#[expect(missing_docs)] +pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; + /// Get all available font names. fn all_font_names(&self) -> Vec; + /// Get the font ID for a font descriptor. fn font_id(&self, descriptor: &Font) -> Result; + /// Get metrics for a font. fn font_metrics(&self, font_id: FontId) -> FontMetrics; + /// Get typographic bounds for a glyph. fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; + /// Get the advance width for a glyph. fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; + /// Get the glyph ID for a character. fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option; + /// Get raster bounds for a glyph. fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result>; + /// Rasterize a glyph. fn rasterize_glyph( &self, params: &RenderGlyphParams, raster_bounds: Bounds, ) -> Result<(Size, Vec)>; + /// Layout a line of text with the given font runs. fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout; + /// Returns the recommended text rendering mode for the given font and size. + fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels) + -> TextRenderingMode; } -pub(crate) struct NoopTextSystem; +#[expect(missing_docs)] +pub struct NoopTextSystem; +#[expect(missing_docs)] impl NoopTextSystem { #[allow(dead_code)] pub fn new() -> Self { @@ -738,13 +845,22 @@ impl PlatformTextSystem for NoopTextSystem { len: text.len(), } } + + fn recommended_rendering_mode( + &self, + _font_id: FontId, + _font_size: Pixels, + ) -> TextRenderingMode { + TextRenderingMode::Grayscale + } } // Adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.cpp // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +/// Compute gamma correction ratios for subpixel text rendering. #[allow(dead_code)] -pub(crate) fn get_gamma_correction_ratios(gamma: f32) -> [f32; 4] { +pub fn get_gamma_correction_ratios(gamma: f32) -> [f32; 4] { const GAMMA_INCORRECT_TARGET_RATIOS: [[f32; 4]; 13] = [ [0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0], // gamma = 1.0 [0.0166 / 4.0, -0.0807 / 4.0, 0.2227 / 4.0, -0.0751 / 4.0], // gamma = 1.1 @@ -776,7 +892,8 @@ pub(crate) fn get_gamma_correction_ratios(gamma: f32) -> [f32; 4] { } #[derive(PartialEq, Eq, Hash, Clone)] -pub(crate) enum AtlasKey { +#[expect(missing_docs)] +pub enum AtlasKey { Glyph(RenderGlyphParams), Svg(RenderSvgParams), Image(RenderImageParams), @@ -790,11 +907,14 @@ impl AtlasKey { ), allow(dead_code) )] - pub(crate) fn texture_kind(&self) -> AtlasTextureKind { + /// Returns the texture kind for this atlas key. + pub fn texture_kind(&self) -> AtlasTextureKind { match self { AtlasKey::Glyph(params) => { if params.is_emoji { AtlasTextureKind::Polychrome + } else if params.subpixel_rendering { + AtlasTextureKind::Subpixel } else { AtlasTextureKind::Monochrome } @@ -823,7 +943,8 @@ impl From for AtlasKey { } } -pub(crate) trait PlatformAtlas: Send + Sync { +#[expect(missing_docs)] +pub trait PlatformAtlas { fn get_or_insert_with<'a>( &self, key: &AtlasKey, @@ -832,9 +953,10 @@ pub(crate) trait PlatformAtlas: Send + Sync { fn remove(&self, key: &AtlasKey); } -struct AtlasTextureList { - textures: Vec>, - free_list: Vec, +#[doc(hidden)] +pub struct AtlasTextureList { + pub textures: Vec>, + pub free_list: Vec, } impl Default for AtlasTextureList { @@ -856,32 +978,40 @@ impl ops::Index for AtlasTextureList { impl AtlasTextureList { #[allow(unused)] - fn drain(&mut self) -> std::vec::Drain<'_, Option> { + pub fn drain(&mut self) -> std::vec::Drain<'_, Option> { self.free_list.clear(); self.textures.drain(..) } #[allow(dead_code)] - fn iter_mut(&mut self) -> impl DoubleEndedIterator { + pub fn iter_mut(&mut self) -> impl DoubleEndedIterator { self.textures.iter_mut().flatten() } } #[derive(Clone, Debug, PartialEq, Eq)] #[repr(C)] -pub(crate) struct AtlasTile { - pub(crate) texture_id: AtlasTextureId, - pub(crate) tile_id: TileId, - pub(crate) padding: u32, - pub(crate) bounds: Bounds, +#[expect(missing_docs)] +pub struct AtlasTile { + /// The texture this tile belongs to. + pub texture_id: AtlasTextureId, + /// The unique ID of this tile within its texture. + pub tile_id: TileId, + /// Padding around the tile content in pixels. + pub padding: u32, + /// The bounds of this tile within the texture. + pub bounds: Bounds, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[repr(C)] -pub(crate) struct AtlasTextureId { +#[expect(missing_docs)] +pub struct AtlasTextureId { // We use u32 instead of usize for Metal Shader Language compatibility - pub(crate) index: u32, - pub(crate) kind: AtlasTextureKind, + /// The index of this texture in the atlas. + pub index: u32, + /// The kind of content stored in this texture. + pub kind: AtlasTextureKind, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -893,14 +1023,17 @@ pub(crate) struct AtlasTextureId { ), allow(dead_code) )] -pub(crate) enum AtlasTextureKind { +#[expect(missing_docs)] +pub enum AtlasTextureKind { Monochrome = 0, Polychrome = 1, + Subpixel = 2, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[repr(C)] -pub(crate) struct TileId(pub(crate) u32); +#[expect(missing_docs)] +pub struct TileId(pub u32); impl From for TileId { fn from(id: etagere::AllocId) -> Self { @@ -914,11 +1047,13 @@ impl From for etagere::AllocId { } } -pub(crate) struct PlatformInputHandler { +#[expect(missing_docs)] +pub struct PlatformInputHandler { cx: AsyncWindowContext, handler: Box, } +#[expect(missing_docs)] #[cfg_attr( all( any(target_os = "linux", target_os = "freebsd"), @@ -931,7 +1066,7 @@ impl PlatformInputHandler { Self { cx, handler } } - fn selected_text_range(&mut self, ignore_disabled_input: bool) -> Option { + pub fn selected_text_range(&mut self, ignore_disabled_input: bool) -> Option { self.cx .update(|window, cx| { self.handler @@ -942,7 +1077,7 @@ impl PlatformInputHandler { } #[cfg_attr(target_os = "windows", allow(dead_code))] - fn marked_text_range(&mut self) -> Option> { + pub fn marked_text_range(&mut self) -> Option> { self.cx .update(|window, cx| self.handler.marked_text_range(window, cx)) .ok() @@ -953,7 +1088,7 @@ impl PlatformInputHandler { any(target_os = "linux", target_os = "freebsd", target_os = "windows"), allow(dead_code) )] - fn text_for_range( + pub fn text_for_range( &mut self, range_utf16: Range, adjusted: &mut Option>, @@ -967,7 +1102,7 @@ impl PlatformInputHandler { .flatten() } - fn replace_text_in_range(&mut self, replacement_range: Option>, text: &str) { + pub fn replace_text_in_range(&mut self, replacement_range: Option>, text: &str) { self.cx .update(|window, cx| { self.handler @@ -996,13 +1131,13 @@ impl PlatformInputHandler { } #[cfg_attr(target_os = "windows", allow(dead_code))] - fn unmark_text(&mut self) { + pub fn unmark_text(&mut self) { self.cx .update(|window, cx| self.handler.unmark_text(window, cx)) .ok(); } - fn bounds_for_range(&mut self, range_utf16: Range) -> Option> { + pub fn bounds_for_range(&mut self, range_utf16: Range) -> Option> { self.cx .update(|window, cx| self.handler.bounds_for_range(range_utf16, window, cx)) .ok() @@ -1010,11 +1145,11 @@ impl PlatformInputHandler { } #[allow(dead_code)] - fn apple_press_and_hold_enabled(&mut self) -> bool { + pub fn apple_press_and_hold_enabled(&mut self) -> bool { self.handler.apple_press_and_hold_enabled() } - pub(crate) fn dispatch_input(&mut self, input: &str, window: &mut Window, cx: &mut App) { + pub fn dispatch_input(&mut self, input: &str, window: &mut Window, cx: &mut App) { self.handler.replace_text_in_range(None, input, window, cx); } @@ -1040,9 +1175,16 @@ impl PlatformInputHandler { } #[allow(dead_code)] - pub(crate) fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { + pub fn accepts_text_input(&mut self, window: &mut Window, cx: &mut App) -> bool { self.handler.accepts_text_input(window, cx) } + + #[allow(dead_code)] + pub fn query_accepts_text_input(&mut self) -> bool { + self.cx + .update(|window, cx| self.handler.accepts_text_input(window, cx)) + .unwrap_or(true) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1217,7 +1359,8 @@ pub struct WindowOptions { ), allow(dead_code) )] -pub(crate) struct WindowParams { +#[allow(missing_docs)] +pub struct WindowParams { pub bounds: Bounds, /// The titlebar configuration of the window @@ -1348,6 +1491,10 @@ pub enum WindowKind { /// docks, notifications or wallpapers. #[cfg(all(target_os = "linux", feature = "wayland"))] LayerShell(layer_shell::LayerShellOptions), + + /// A window that appears on top of its parent window and blocks interaction with it + /// until the modal window is closed + Dialog, } /// The appearance of the window, as defined by the operating system. @@ -1403,6 +1550,18 @@ pub enum WindowBackgroundAppearance { MicaAltBackdrop, } +/// The text rendering mode to use for drawing glyphs. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum TextRenderingMode { + /// Use the platform's default text rendering mode. + #[default] + PlatformDefault, + /// Use subpixel (ClearType-style) text rendering. + Subpixel, + /// Use grayscale text rendering. + Grayscale, +} + /// The options that can be configured for a file dialog prompt #[derive(Clone, Debug)] pub struct PathPromptOptions { @@ -1456,8 +1615,9 @@ impl PromptButton { PromptButton::Cancel(label.into()) } + /// Returns true if this button is a cancel button. #[allow(dead_code)] - pub(crate) fn is_cancel(&self) -> bool { + pub fn is_cancel(&self) -> bool { matches!(self, PromptButton::Cancel(_)) } @@ -1575,7 +1735,8 @@ pub enum CursorStyle { /// A clipboard item that should be copied to the clipboard #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClipboardItem { - entries: Vec, + /// The entries in this clipboard item. + pub entries: Vec, } /// Either a ClipboardString or a ClipboardImage @@ -1775,7 +1936,7 @@ pub struct Image { /// The raw image bytes pub bytes: Vec, /// The unique ID for the image - id: u64, + pub id: u64, } impl Hash for Image { @@ -1893,8 +2054,10 @@ impl Image { /// A clipboard item that should be copied to the clipboard #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClipboardString { - pub(crate) text: String, - pub(crate) metadata: Option, + /// The text content. + pub text: String, + /// Optional metadata associated with this clipboard string. + pub metadata: Option, } impl ClipboardString { @@ -1934,7 +2097,8 @@ impl ClipboardString { } #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] - pub(crate) fn text_hash(text: &str) -> u64 { + /// Compute a hash of the given text for clipboard change detection. + pub fn text_hash(text: &str) -> u64 { let mut hasher = SeaHasher::new(); text.hash(&mut hasher); hasher.finish() diff --git a/src/platform/app_menu.rs b/src/platform/app_menu.rs index 39e7556b2d..27c20c00ba 100644 --- a/src/platform/app_menu.rs +++ b/src/platform/app_menu.rs @@ -1,5 +1,4 @@ use crate::{Action, App, Platform, SharedString}; -use util::ResultExt; /// A menu of the application, either a main menu or a submenu pub struct Menu { @@ -8,14 +7,39 @@ pub struct Menu { /// The items in the menu pub items: Vec, + + /// Whether this menu is disabled + pub disabled: bool, } impl Menu { + /// Create a new Menu with the given name + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + items: vec![], + disabled: false, + } + } + + /// Set items to be in this menu + pub fn items(mut self, items: impl IntoIterator) -> Self { + self.items = items.into_iter().collect(); + self + } + + /// Set whether this menu is disabled + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + /// Create an OwnedMenu from this Menu pub fn owned(self) -> OwnedMenu { OwnedMenu { name: self.name.to_string().into(), items: self.items.into_iter().map(|item| item.owned()).collect(), + disabled: self.disabled, } } } @@ -73,6 +97,9 @@ pub enum MenuItem { /// Whether this action is checked checked: bool, + + /// Whether this action is disabled + disabled: bool, }, } @@ -102,6 +129,7 @@ impl MenuItem { action: Box::new(action), os_action: None, checked: false, + disabled: false, } } @@ -116,6 +144,7 @@ impl MenuItem { action: Box::new(action), os_action: Some(os_action), checked: false, + disabled: false, } } @@ -129,11 +158,13 @@ impl MenuItem { action, os_action, checked, + disabled, } => OwnedMenuItem::Action { name: name.into(), action, os_action, checked, + disabled, }, MenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.owned()), } @@ -143,19 +174,49 @@ impl MenuItem { /// /// Only for [`MenuItem::Action`], otherwise, will be ignored pub fn checked(mut self, checked: bool) -> Self { + match &mut self { + MenuItem::Action { checked: old, .. } => { + *old = checked; + } + _ => {} + } + self + } + + /// Returns whether this menu item is checked + /// + /// Only for [`MenuItem::Action`], otherwise, returns false + #[inline] + pub fn is_checked(&self) -> bool { match self { - MenuItem::Action { - action, - os_action, - name, - .. - } => MenuItem::Action { - name, - action, - os_action, - checked, - }, - _ => self, + MenuItem::Action { checked, .. } => *checked, + _ => false, + } + } + + /// Set whether this menu item is disabled + pub fn disabled(mut self, disabled: bool) -> Self { + match &mut self { + MenuItem::Action { disabled: old, .. } => { + *old = disabled; + } + MenuItem::Submenu(submenu) => { + submenu.disabled = disabled; + } + _ => {} + } + self + } + + /// Returns whether this menu item is disabled + /// + /// Only for [`MenuItem::Action`] and [`MenuItem::Submenu`], otherwise, returns false + #[inline] + pub fn is_disabled(&self) -> bool { + match self { + MenuItem::Action { disabled, .. } => *disabled, + MenuItem::Submenu(submenu) => submenu.disabled, + _ => false, } } } @@ -180,6 +241,9 @@ pub struct OwnedMenu { /// The items in the menu pub items: Vec, + + /// Whether this menu is disabled + pub disabled: bool, } /// The different kinds of items that can be in a menu @@ -207,6 +271,9 @@ pub enum OwnedMenuItem { /// Whether this action is checked checked: bool, + + /// Whether this action is disabled + disabled: bool, }, } @@ -220,11 +287,13 @@ impl Clone for OwnedMenuItem { action, os_action, checked, + disabled, } => OwnedMenuItem::Action { name: name.clone(), action: action.boxed_clone(), os_action: *os_action, checked: *checked, + disabled: *disabled, }, OwnedMenuItem::SystemMenu(os_menu) => OwnedMenuItem::SystemMenu(os_menu.clone()), } @@ -263,14 +332,18 @@ pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &App) { platform.on_will_open_app_menu(Box::new({ let cx = cx.to_async(); move || { - cx.update(|cx| cx.clear_pending_keystrokes()).ok(); + if let Some(app) = cx.app.upgrade() { + app.borrow_mut().update(|cx| cx.clear_pending_keystrokes()); + } } })); platform.on_validate_app_menu_command(Box::new({ let cx = cx.to_async(); move |action| { - cx.update(|cx| cx.is_action_available(action)) + cx.app + .upgrade() + .map(|app| app.borrow_mut().update(|cx| cx.is_action_available(action))) .unwrap_or(false) } })); @@ -278,7 +351,76 @@ pub(crate) fn init_app_menus(platform: &dyn Platform, cx: &App) { platform.on_app_menu_action(Box::new({ let cx = cx.to_async(); move |action| { - cx.update(|cx| cx.dispatch_action(action)).log_err(); + if let Some(app) = cx.app.upgrade() { + app.borrow_mut().update(|cx| cx.dispatch_action(action)); + } } })); } + +#[cfg(test)] +mod tests { + use crate::Menu; + + #[test] + fn test_menu() { + let menu = Menu::new("App") + .items(vec![ + crate::MenuItem::action("Action 1", gpui::NoAction), + crate::MenuItem::separator(), + ]) + .disabled(true); + + assert_eq!(menu.name.as_ref(), "App"); + assert_eq!(menu.items.len(), 2); + assert!(menu.disabled); + } + + #[test] + fn test_menu_item_builder() { + use super::MenuItem; + + let item = MenuItem::action("Test Action", gpui::NoAction); + assert_eq!( + match &item { + MenuItem::Action { name, .. } => name.as_ref(), + _ => unreachable!(), + }, + "Test Action" + ); + assert!(matches!( + item, + MenuItem::Action { + checked: false, + disabled: false, + .. + } + )); + + assert!( + MenuItem::action("Test Action", gpui::NoAction) + .checked(true) + .is_checked() + ); + assert!( + MenuItem::action("Test Action", gpui::NoAction) + .disabled(true) + .is_disabled() + ); + + let submenu = MenuItem::submenu(super::Menu { + name: "Submenu".into(), + items: vec![], + disabled: true, + }); + assert_eq!( + match &submenu { + MenuItem::Submenu(menu) => menu.name.as_ref(), + _ => unreachable!(), + }, + "Submenu" + ); + assert!(!submenu.is_checked()); + assert!(submenu.is_disabled()); + } +} diff --git a/src/platform/blade.rs b/src/platform/blade.rs deleted file mode 100644 index 9d966d8a4e..0000000000 --- a/src/platform/blade.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[cfg(target_os = "macos")] -mod apple_compat; -mod blade_atlas; -mod blade_context; -mod blade_renderer; - -#[cfg(target_os = "macos")] -pub(crate) use apple_compat::*; -pub(crate) use blade_atlas::*; -pub(crate) use blade_context::*; -pub(crate) use blade_renderer::*; diff --git a/src/platform/blade/apple_compat.rs b/src/platform/blade/apple_compat.rs deleted file mode 100644 index a75ddfa69a..0000000000 --- a/src/platform/blade/apple_compat.rs +++ /dev/null @@ -1,60 +0,0 @@ -use super::{BladeContext, BladeRenderer, BladeSurfaceConfig}; -use blade_graphics as gpu; -use std::{ffi::c_void, ptr::NonNull}; - -#[derive(Clone)] -pub struct Context { - inner: BladeContext, -} -impl Default for Context { - fn default() -> Self { - Self { - inner: BladeContext::new().unwrap(), - } - } -} - -pub type Renderer = BladeRenderer; - -pub unsafe fn new_renderer( - context: Context, - _native_window: *mut c_void, - native_view: *mut c_void, - bounds: crate::Size, - transparent: bool, -) -> Renderer { - use raw_window_handle as rwh; - struct RawWindow { - view: *mut c_void, - } - - impl rwh::HasWindowHandle for RawWindow { - fn window_handle(&self) -> Result, rwh::HandleError> { - let view = NonNull::new(self.view).unwrap(); - let handle = rwh::AppKitWindowHandle::new(view); - Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) }) - } - } - impl rwh::HasDisplayHandle for RawWindow { - fn display_handle(&self) -> Result, rwh::HandleError> { - let handle = rwh::AppKitDisplayHandle::new(); - Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) - } - } - - BladeRenderer::new( - &context.inner, - &RawWindow { - view: native_view as *mut _, - }, - BladeSurfaceConfig { - size: gpu::Extent { - width: bounds.width as u32, - height: bounds.height as u32, - depth: 1, - }, - transparent, - }, - ) - .unwrap() -} diff --git a/src/platform/blade/blade_atlas.rs b/src/platform/blade/blade_atlas.rs deleted file mode 100644 index 9b9299df99..0000000000 --- a/src/platform/blade/blade_atlas.rs +++ /dev/null @@ -1,384 +0,0 @@ -use crate::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas, - Point, Size, platform::AtlasTextureList, -}; -use anyhow::Result; -use blade_graphics as gpu; -use blade_util::{BufferBelt, BufferBeltDescriptor}; -use collections::FxHashMap; -use etagere::BucketedAtlasAllocator; -use parking_lot::Mutex; -use std::{borrow::Cow, ops, sync::Arc}; - -pub(crate) struct BladeAtlas(Mutex); - -struct PendingUpload { - id: AtlasTextureId, - bounds: Bounds, - data: gpu::BufferPiece, -} - -struct BladeAtlasState { - gpu: Arc, - upload_belt: BufferBelt, - storage: BladeAtlasStorage, - tiles_by_key: FxHashMap, - initializations: Vec, - uploads: Vec, -} - -#[cfg(gles)] -unsafe impl Send for BladeAtlasState {} - -impl BladeAtlasState { - fn destroy(&mut self) { - self.storage.destroy(&self.gpu); - self.upload_belt.destroy(&self.gpu); - } -} - -pub struct BladeTextureInfo { - pub raw_view: gpu::TextureView, -} - -impl BladeAtlas { - pub(crate) fn new(gpu: &Arc) -> Self { - BladeAtlas(Mutex::new(BladeAtlasState { - gpu: Arc::clone(gpu), - upload_belt: BufferBelt::new(BufferBeltDescriptor { - memory: gpu::Memory::Upload, - min_chunk_size: 0x10000, - alignment: 64, // Vulkan `optimalBufferCopyOffsetAlignment` on Intel XE - }), - storage: BladeAtlasStorage::default(), - tiles_by_key: Default::default(), - initializations: Vec::new(), - uploads: Vec::new(), - })) - } - - pub(crate) fn destroy(&self) { - self.0.lock().destroy(); - } - - pub fn before_frame(&self, gpu_encoder: &mut gpu::CommandEncoder) { - let mut lock = self.0.lock(); - lock.flush(gpu_encoder); - } - - pub fn after_frame(&self, sync_point: &gpu::SyncPoint) { - let mut lock = self.0.lock(); - lock.upload_belt.flush(sync_point); - } - - pub fn get_texture_info(&self, id: AtlasTextureId) -> BladeTextureInfo { - let lock = self.0.lock(); - let texture = &lock.storage[id]; - BladeTextureInfo { - raw_view: texture.raw_view, - } - } -} - -impl PlatformAtlas for BladeAtlas { - fn get_or_insert_with<'a>( - &self, - key: &AtlasKey, - build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, - ) -> Result> { - let mut lock = self.0.lock(); - if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) - } else { - profiling::scope!("new tile"); - let Some((size, bytes)) = build()? else { - return Ok(None); - }; - let tile = lock.allocate(size, key.texture_kind()); - lock.upload_texture(tile.texture_id, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); - Ok(Some(tile)) - } - } - - fn remove(&self, key: &AtlasKey) { - let mut lock = self.0.lock(); - - let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { - return; - }; - - let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { - return; - }; - - if let Some(mut texture) = texture_slot.take() { - texture.decrement_ref_count(); - if texture.is_unreferenced() { - lock.storage[id.kind] - .free_list - .push(texture.id.index as usize); - texture.destroy(&lock.gpu); - } else { - *texture_slot = Some(texture); - } - } - } -} - -impl BladeAtlasState { - fn allocate(&mut self, size: Size, texture_kind: AtlasTextureKind) -> AtlasTile { - { - let textures = &mut self.storage[texture_kind]; - - if let Some(tile) = textures - .iter_mut() - .rev() - .find_map(|texture| texture.allocate(size)) - { - return tile; - } - } - - let texture = self.push_texture(size, texture_kind); - texture.allocate(size).unwrap() - } - - fn push_texture( - &mut self, - min_size: Size, - kind: AtlasTextureKind, - ) -> &mut BladeAtlasTexture { - const DEFAULT_ATLAS_SIZE: Size = Size { - width: DevicePixels(1024), - height: DevicePixels(1024), - }; - - let size = min_size.max(&DEFAULT_ATLAS_SIZE); - let format; - let usage; - match kind { - AtlasTextureKind::Monochrome => { - format = gpu::TextureFormat::R8Unorm; - usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; - } - AtlasTextureKind::Polychrome => { - format = gpu::TextureFormat::Bgra8Unorm; - usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; - } - } - - let raw = self.gpu.create_texture(gpu::TextureDesc { - name: "atlas", - format, - size: gpu::Extent { - width: size.width.into(), - height: size.height.into(), - depth: 1, - }, - array_layer_count: 1, - mip_level_count: 1, - sample_count: 1, - dimension: gpu::TextureDimension::D2, - usage, - external: None, - }); - let raw_view = self.gpu.create_texture_view( - raw, - gpu::TextureViewDesc { - name: "", - format, - dimension: gpu::ViewDimension::D2, - subresources: &Default::default(), - }, - ); - - let texture_list = &mut self.storage[kind]; - let index = texture_list.free_list.pop(); - - let atlas_texture = BladeAtlasTexture { - id: AtlasTextureId { - index: index.unwrap_or(texture_list.textures.len()) as u32, - kind, - }, - allocator: etagere::BucketedAtlasAllocator::new(size.into()), - format, - raw, - raw_view, - live_atlas_keys: 0, - }; - - self.initializations.push(atlas_texture.id); - - if let Some(ix) = index { - texture_list.textures[ix] = Some(atlas_texture); - texture_list.textures.get_mut(ix).unwrap().as_mut().unwrap() - } else { - texture_list.textures.push(Some(atlas_texture)); - texture_list.textures.last_mut().unwrap().as_mut().unwrap() - } - } - - fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { - let data = self.upload_belt.alloc_bytes(bytes, &self.gpu); - self.uploads.push(PendingUpload { id, bounds, data }); - } - - fn flush_initializations(&mut self, encoder: &mut gpu::CommandEncoder) { - for id in self.initializations.drain(..) { - let texture = &self.storage[id]; - encoder.init_texture(texture.raw); - } - } - - fn flush(&mut self, encoder: &mut gpu::CommandEncoder) { - self.flush_initializations(encoder); - - let mut transfers = encoder.transfer("atlas"); - for upload in self.uploads.drain(..) { - let texture = &self.storage[upload.id]; - transfers.copy_buffer_to_texture( - upload.data, - upload.bounds.size.width.to_bytes(texture.bytes_per_pixel()), - gpu::TexturePiece { - texture: texture.raw, - mip_level: 0, - array_layer: 0, - origin: [ - upload.bounds.origin.x.into(), - upload.bounds.origin.y.into(), - 0, - ], - }, - gpu::Extent { - width: upload.bounds.size.width.into(), - height: upload.bounds.size.height.into(), - depth: 1, - }, - ); - } - } -} - -#[derive(Default)] -struct BladeAtlasStorage { - monochrome_textures: AtlasTextureList, - polychrome_textures: AtlasTextureList, -} - -impl ops::Index for BladeAtlasStorage { - type Output = AtlasTextureList; - fn index(&self, kind: AtlasTextureKind) -> &Self::Output { - match kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, - } - } -} - -impl ops::IndexMut for BladeAtlasStorage { - fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { - match kind { - crate::AtlasTextureKind::Monochrome => &mut self.monochrome_textures, - crate::AtlasTextureKind::Polychrome => &mut self.polychrome_textures, - } - } -} - -impl ops::Index for BladeAtlasStorage { - type Output = BladeAtlasTexture; - fn index(&self, id: AtlasTextureId) -> &Self::Output { - let textures = match id.kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, - }; - textures[id.index as usize].as_ref().unwrap() - } -} - -impl BladeAtlasStorage { - fn destroy(&mut self, gpu: &gpu::Context) { - for mut texture in self.monochrome_textures.drain().flatten() { - texture.destroy(gpu); - } - for mut texture in self.polychrome_textures.drain().flatten() { - texture.destroy(gpu); - } - } -} - -struct BladeAtlasTexture { - id: AtlasTextureId, - allocator: BucketedAtlasAllocator, - raw: gpu::Texture, - raw_view: gpu::TextureView, - format: gpu::TextureFormat, - live_atlas_keys: u32, -} - -impl BladeAtlasTexture { - fn allocate(&mut self, size: Size) -> Option { - let allocation = self.allocator.allocate(size.into())?; - let tile = AtlasTile { - texture_id: self.id, - tile_id: allocation.id.into(), - padding: 0, - bounds: Bounds { - origin: allocation.rectangle.min.into(), - size, - }, - }; - self.live_atlas_keys += 1; - Some(tile) - } - - fn destroy(&mut self, gpu: &gpu::Context) { - gpu.destroy_texture(self.raw); - gpu.destroy_texture_view(self.raw_view); - } - - fn bytes_per_pixel(&self) -> u8 { - self.format.block_info().size - } - - fn decrement_ref_count(&mut self) { - self.live_atlas_keys -= 1; - } - - fn is_unreferenced(&mut self) -> bool { - self.live_atlas_keys == 0 - } -} - -impl From> for etagere::Size { - fn from(size: Size) -> Self { - etagere::Size::new(size.width.into(), size.height.into()) - } -} - -impl From for Point { - fn from(value: etagere::Point) -> Self { - Point { - x: DevicePixels::from(value.x), - y: DevicePixels::from(value.y), - } - } -} - -impl From for Size { - fn from(size: etagere::Size) -> Self { - Size { - width: DevicePixels::from(size.width), - height: DevicePixels::from(size.height), - } - } -} - -impl From for Bounds { - fn from(rectangle: etagere::Rectangle) -> Self { - Bounds { - origin: rectangle.min.into(), - size: rectangle.size().into(), - } - } -} diff --git a/src/platform/blade/blade_context.rs b/src/platform/blade/blade_context.rs deleted file mode 100644 index 12c68a1e70..0000000000 --- a/src/platform/blade/blade_context.rs +++ /dev/null @@ -1,80 +0,0 @@ -use anyhow::Context as _; -use blade_graphics as gpu; -use std::sync::Arc; -use util::ResultExt; - -#[cfg_attr(target_os = "macos", derive(Clone))] -pub struct BladeContext { - pub(super) gpu: Arc, -} - -impl BladeContext { - pub fn new() -> anyhow::Result { - let device_id_forced = match std::env::var("ZED_DEVICE_ID") { - Ok(val) => parse_pci_id(&val) - .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") - .log_err(), - Err(std::env::VarError::NotPresent) => None, - err => { - err.context("Failed to read value of `ZED_DEVICE_ID` environment variable") - .log_err(); - None - } - }; - let gpu = Arc::new( - unsafe { - gpu::Context::init(gpu::ContextDesc { - presentation: true, - validation: false, - device_id: device_id_forced.unwrap_or(0), - ..Default::default() - }) - } - .map_err(|e| anyhow::anyhow!("{e:?}"))?, - ); - Ok(Self { gpu }) - } -} - -fn parse_pci_id(id: &str) -> anyhow::Result { - let mut id = id.trim(); - - if id.starts_with("0x") || id.starts_with("0X") { - id = &id[2..]; - } - let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); - let is_4_chars = id.len() == 4; - anyhow::ensure!( - is_4_chars && is_hex_string, - "Expected a 4 digit PCI ID in hexadecimal format" - ); - - u32::from_str_radix(id, 16).context("parsing PCI ID as hex") -} - -#[cfg(test)] -mod tests { - use super::parse_pci_id; - - #[test] - fn test_parse_device_id() { - assert!(parse_pci_id("0xABCD").is_ok()); - assert!(parse_pci_id("ABCD").is_ok()); - assert!(parse_pci_id("abcd").is_ok()); - assert!(parse_pci_id("1234").is_ok()); - assert!(parse_pci_id("123").is_err()); - assert_eq!( - parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), - ); - - assert_eq!( - parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), - ); - assert_eq!( - parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), - ); - } -} diff --git a/src/platform/blade/blade_renderer.rs b/src/platform/blade/blade_renderer.rs deleted file mode 100644 index dd0be7db43..0000000000 --- a/src/platform/blade/blade_renderer.rs +++ /dev/null @@ -1,1040 +0,0 @@ -// Doing `if let` gives you nice scoping with passes/encoders -#![allow(irrefutable_let_patterns)] - -use super::{BladeAtlas, BladeContext}; -use crate::{ - Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, PolychromeSprite, - PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline, - get_gamma_correction_ratios, -}; -use blade_graphics as gpu; -use blade_util::{BufferBelt, BufferBeltDescriptor}; -use bytemuck::{Pod, Zeroable}; -#[cfg(target_os = "macos")] -use media::core_video::CVMetalTextureCache; -use std::sync::Arc; - -const MAX_FRAME_TIME_MS: u32 = 10000; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct GlobalParams { - viewport_size: [f32; 2], - premultiplied_alpha: u32, - pad: u32, -} - -//Note: we can't use `Bounds` directly here because -// it doesn't implement Pod + Zeroable -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct PodBounds { - origin: [f32; 2], - size: [f32; 2], -} - -impl From> for PodBounds { - fn from(bounds: Bounds) -> Self { - Self { - origin: [bounds.origin.x.0, bounds.origin.y.0], - size: [bounds.size.width.0, bounds.size.height.0], - } - } -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct SurfaceParams { - bounds: PodBounds, - content_mask: PodBounds, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderQuadsData { - globals: GlobalParams, - b_quads: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderShadowsData { - globals: GlobalParams, - b_shadows: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderPathRasterizationData { - globals: GlobalParams, - b_path_vertices: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderPathsData { - globals: GlobalParams, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_path_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderUnderlinesData { - globals: GlobalParams, - b_underlines: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderMonoSpritesData { - globals: GlobalParams, - gamma_ratios: [f32; 4], - grayscale_enhanced_contrast: f32, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_mono_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderPolySpritesData { - globals: GlobalParams, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_poly_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderSurfacesData { - globals: GlobalParams, - surface_locals: SurfaceParams, - t_y: gpu::TextureView, - t_cb_cr: gpu::TextureView, - s_surface: gpu::Sampler, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[repr(C)] -struct PathSprite { - bounds: Bounds, -} - -#[derive(Clone, Debug)] -#[repr(C)] -struct PathRasterizationVertex { - xy_position: Point, - st_position: Point, - color: Background, - bounds: Bounds, -} - -struct BladePipelines { - quads: gpu::RenderPipeline, - shadows: gpu::RenderPipeline, - path_rasterization: gpu::RenderPipeline, - paths: gpu::RenderPipeline, - underlines: gpu::RenderPipeline, - mono_sprites: gpu::RenderPipeline, - poly_sprites: gpu::RenderPipeline, - surfaces: gpu::RenderPipeline, -} - -impl BladePipelines { - fn new(gpu: &gpu::Context, surface_info: gpu::SurfaceInfo, path_sample_count: u32) -> Self { - use gpu::ShaderData as _; - - log::info!( - "Initializing Blade pipelines for surface {:?}", - surface_info - ); - let shader = gpu.create_shader(gpu::ShaderDesc { - source: include_str!("shaders.wgsl"), - }); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - - // See https://apoorvaj.io/alpha-compositing-opengl-blending-and-premultiplied-alpha/ - let blend_mode = match surface_info.alpha { - gpu::AlphaMode::Ignored => gpu::BlendState::ALPHA_BLENDING, - gpu::AlphaMode::PreMultiplied => gpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, - gpu::AlphaMode::PostMultiplied => gpu::BlendState::ALPHA_BLENDING, - }; - let color_targets = &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(blend_mode), - write_mask: gpu::ColorWrites::default(), - }]; - - Self { - quads: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "quads", - data_layouts: &[&ShaderQuadsData::layout()], - vertex: shader.at("vs_quad"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_quad")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - shadows: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "shadows", - data_layouts: &[&ShaderShadowsData::layout()], - vertex: shader.at("vs_shadow"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_shadow")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - path_rasterization: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "path_rasterization", - data_layouts: &[&ShaderPathRasterizationData::layout()], - vertex: shader.at("vs_path_rasterization"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_path_rasterization")), - // The original implementation was using ADDITIVE blende mode, - // I don't know why - // color_targets: &[gpu::ColorTargetState { - // format: PATH_TEXTURE_FORMAT, - // blend: Some(gpu::BlendState::ADDITIVE), - // write_mask: gpu::ColorWrites::default(), - // }], - color_targets: &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(gpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: gpu::ColorWrites::default(), - }], - multisample_state: gpu::MultisampleState { - sample_count: path_sample_count, - ..Default::default() - }, - }), - paths: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "paths", - data_layouts: &[&ShaderPathsData::layout()], - vertex: shader.at("vs_path"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_path")), - color_targets: &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(gpu::BlendState { - color: gpu::BlendComponent::OVER, - alpha: gpu::BlendComponent::ADDITIVE, - }), - write_mask: gpu::ColorWrites::default(), - }], - multisample_state: gpu::MultisampleState::default(), - }), - underlines: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "underlines", - data_layouts: &[&ShaderUnderlinesData::layout()], - vertex: shader.at("vs_underline"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_underline")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - mono_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "mono-sprites", - data_layouts: &[&ShaderMonoSpritesData::layout()], - vertex: shader.at("vs_mono_sprite"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_mono_sprite")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - poly_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "poly-sprites", - data_layouts: &[&ShaderPolySpritesData::layout()], - vertex: shader.at("vs_poly_sprite"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_poly_sprite")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - surfaces: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "surfaces", - data_layouts: &[&ShaderSurfacesData::layout()], - vertex: shader.at("vs_surface"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_surface")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - } - } - - fn destroy(&mut self, gpu: &gpu::Context) { - gpu.destroy_render_pipeline(&mut self.quads); - gpu.destroy_render_pipeline(&mut self.shadows); - gpu.destroy_render_pipeline(&mut self.path_rasterization); - gpu.destroy_render_pipeline(&mut self.paths); - gpu.destroy_render_pipeline(&mut self.underlines); - gpu.destroy_render_pipeline(&mut self.mono_sprites); - gpu.destroy_render_pipeline(&mut self.poly_sprites); - gpu.destroy_render_pipeline(&mut self.surfaces); - } -} - -pub struct BladeSurfaceConfig { - pub size: gpu::Extent, - pub transparent: bool, -} - -//Note: we could see some of these fields moved into `BladeContext` -// so that they are shared between windows. E.g. `pipelines`. -// But that is complicated by the fact that pipelines depend on -// the format and alpha mode. -pub struct BladeRenderer { - gpu: Arc, - surface: gpu::Surface, - surface_config: gpu::SurfaceConfig, - command_encoder: gpu::CommandEncoder, - last_sync_point: Option, - pipelines: BladePipelines, - instance_belt: BufferBelt, - atlas: Arc, - atlas_sampler: gpu::Sampler, - #[cfg(target_os = "macos")] - core_video_texture_cache: CVMetalTextureCache, - path_intermediate_texture: gpu::Texture, - path_intermediate_texture_view: gpu::TextureView, - path_intermediate_msaa_texture: Option, - path_intermediate_msaa_texture_view: Option, - rendering_parameters: RenderingParameters, -} - -impl BladeRenderer { - pub fn new( - context: &BladeContext, - window: &I, - config: BladeSurfaceConfig, - ) -> anyhow::Result { - let surface_config = gpu::SurfaceConfig { - size: config.size, - usage: gpu::TextureUsage::TARGET, - display_sync: gpu::DisplaySync::Recent, - color_space: gpu::ColorSpace::Srgb, - allow_exclusive_full_screen: false, - transparent: config.transparent, - }; - let surface = context - .gpu - .create_surface_configured(window, surface_config) - .map_err(|err| anyhow::anyhow!("Failed to create surface: {err:?}"))?; - - let command_encoder = context.gpu.create_command_encoder(gpu::CommandEncoderDesc { - name: "main", - buffer_count: 2, - }); - let rendering_parameters = RenderingParameters::from_env(context); - let pipelines = BladePipelines::new( - &context.gpu, - surface.info(), - rendering_parameters.path_sample_count, - ); - let instance_belt = BufferBelt::new(BufferBeltDescriptor { - memory: gpu::Memory::Shared, - min_chunk_size: 0x1000, - alignment: 0x40, // Vulkan `minStorageBufferOffsetAlignment` on Intel Xe - }); - let atlas = Arc::new(BladeAtlas::new(&context.gpu)); - let atlas_sampler = context.gpu.create_sampler(gpu::SamplerDesc { - name: "path rasterization sampler", - mag_filter: gpu::FilterMode::Linear, - min_filter: gpu::FilterMode::Linear, - ..Default::default() - }); - - let (path_intermediate_texture, path_intermediate_texture_view) = - create_path_intermediate_texture( - &context.gpu, - surface.info().format, - config.size.width, - config.size.height, - ); - let (path_intermediate_msaa_texture, path_intermediate_msaa_texture_view) = - create_msaa_texture_if_needed( - &context.gpu, - surface.info().format, - config.size.width, - config.size.height, - rendering_parameters.path_sample_count, - ) - .unzip(); - - #[cfg(target_os = "macos")] - let core_video_texture_cache = unsafe { - CVMetalTextureCache::new( - objc2::rc::Retained::as_ptr(&context.gpu.metal_device()) as *mut _ - ) - .unwrap() - }; - - Ok(Self { - gpu: Arc::clone(&context.gpu), - surface, - surface_config, - command_encoder, - last_sync_point: None, - pipelines, - instance_belt, - atlas, - atlas_sampler, - #[cfg(target_os = "macos")] - core_video_texture_cache, - path_intermediate_texture, - path_intermediate_texture_view, - path_intermediate_msaa_texture, - path_intermediate_msaa_texture_view, - rendering_parameters, - }) - } - - fn wait_for_gpu(&mut self) { - if let Some(last_sp) = self.last_sync_point.take() - && !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) - { - log::error!("GPU hung"); - #[cfg(target_os = "linux")] - if self.gpu.device_information().driver_name == "radv" { - log::error!( - "there's a known bug with amdgpu/radv, try setting ZED_PATH_SAMPLE_COUNT=0 as a workaround" - ); - log::error!( - "if that helps you're running into https://github.com/zed-industries/zed/issues/26143" - ); - } - log::error!( - "your device information is: {:?}", - self.gpu.device_information() - ); - while !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {} - } - } - - pub fn update_drawable_size(&mut self, size: Size) { - self.update_drawable_size_impl(size, false); - } - - /// Like `update_drawable_size` but skips the check that the size has changed. This is useful in - /// cases like restoring a window from minimization where the size is the same but the - /// renderer's swap chain needs to be recreated. - #[cfg_attr( - any(target_os = "macos", target_os = "linux", target_os = "freebsd"), - allow(dead_code) - )] - pub fn update_drawable_size_even_if_unchanged(&mut self, size: Size) { - self.update_drawable_size_impl(size, true); - } - - fn update_drawable_size_impl(&mut self, size: Size, always_resize: bool) { - let gpu_size = gpu::Extent { - width: size.width.0 as u32, - height: size.height.0 as u32, - depth: 1, - }; - - if always_resize || gpu_size != self.surface_config.size { - self.wait_for_gpu(); - self.surface_config.size = gpu_size; - self.gpu - .reconfigure_surface(&mut self.surface, self.surface_config); - self.gpu.destroy_texture(self.path_intermediate_texture); - self.gpu - .destroy_texture_view(self.path_intermediate_texture_view); - if let Some(msaa_texture) = self.path_intermediate_msaa_texture { - self.gpu.destroy_texture(msaa_texture); - } - if let Some(msaa_view) = self.path_intermediate_msaa_texture_view { - self.gpu.destroy_texture_view(msaa_view); - } - let (path_intermediate_texture, path_intermediate_texture_view) = - create_path_intermediate_texture( - &self.gpu, - self.surface.info().format, - gpu_size.width, - gpu_size.height, - ); - self.path_intermediate_texture = path_intermediate_texture; - self.path_intermediate_texture_view = path_intermediate_texture_view; - let (path_intermediate_msaa_texture, path_intermediate_msaa_texture_view) = - create_msaa_texture_if_needed( - &self.gpu, - self.surface.info().format, - gpu_size.width, - gpu_size.height, - self.rendering_parameters.path_sample_count, - ) - .unzip(); - self.path_intermediate_msaa_texture = path_intermediate_msaa_texture; - self.path_intermediate_msaa_texture_view = path_intermediate_msaa_texture_view; - } - } - - pub fn update_transparency(&mut self, transparent: bool) { - if transparent != self.surface_config.transparent { - self.wait_for_gpu(); - self.surface_config.transparent = transparent; - self.gpu - .reconfigure_surface(&mut self.surface, self.surface_config); - self.pipelines.destroy(&self.gpu); - self.pipelines = BladePipelines::new( - &self.gpu, - self.surface.info(), - self.rendering_parameters.path_sample_count, - ); - } - } - - #[cfg_attr( - any(target_os = "macos", feature = "wayland", target_os = "windows"), - allow(dead_code) - )] - pub fn viewport_size(&self) -> gpu::Extent { - self.surface_config.size - } - - pub fn sprite_atlas(&self) -> &Arc { - &self.atlas - } - - #[cfg_attr(target_os = "macos", allow(dead_code))] - pub fn gpu_specs(&self) -> GpuSpecs { - let info = self.gpu.device_information(); - - GpuSpecs { - is_software_emulated: info.is_software_emulated, - device_name: info.device_name.clone(), - driver_name: info.driver_name.clone(), - driver_info: info.driver_info.clone(), - } - } - - #[cfg(target_os = "macos")] - pub fn layer(&self) -> metal::MetalLayer { - unsafe { foreign_types::ForeignType::from_ptr(self.layer_ptr()) } - } - - #[cfg(target_os = "macos")] - pub fn layer_ptr(&self) -> *mut metal::CAMetalLayer { - objc2::rc::Retained::as_ptr(&self.surface.metal_layer()) as *mut _ - } - - #[profiling::function] - fn draw_paths_to_intermediate( - &mut self, - paths: &[Path], - width: f32, - height: f32, - ) { - self.command_encoder - .init_texture(self.path_intermediate_texture); - if let Some(msaa_texture) = self.path_intermediate_msaa_texture { - self.command_encoder.init_texture(msaa_texture); - } - - let target = if let Some(msaa_view) = self.path_intermediate_msaa_texture_view { - gpu::RenderTarget { - view: msaa_view, - init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack), - finish_op: gpu::FinishOp::ResolveTo(self.path_intermediate_texture_view), - } - } else { - gpu::RenderTarget { - view: self.path_intermediate_texture_view, - init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack), - finish_op: gpu::FinishOp::Store, - } - }; - if let mut pass = self.command_encoder.render( - "rasterize paths", - gpu::RenderTargetSet { - colors: &[target], - depth_stencil: None, - }, - ) { - let globals = GlobalParams { - viewport_size: [width, height], - premultiplied_alpha: 0, - pad: 0, - }; - let mut encoder = pass.with(&self.pipelines.path_rasterization); - - let mut vertices = Vec::new(); - for path in paths { - vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex { - xy_position: v.xy_position, - st_position: v.st_position, - color: path.color, - bounds: path.clipped_bounds(), - })); - } - let vertex_buf = unsafe { self.instance_belt.alloc_typed(&vertices, &self.gpu) }; - encoder.bind( - 0, - &ShaderPathRasterizationData { - globals, - b_path_vertices: vertex_buf, - }, - ); - encoder.draw(0, vertices.len() as u32, 0, 1); - } - } - - pub fn destroy(&mut self) { - self.wait_for_gpu(); - self.atlas.destroy(); - self.gpu.destroy_sampler(self.atlas_sampler); - self.instance_belt.destroy(&self.gpu); - self.gpu.destroy_command_encoder(&mut self.command_encoder); - self.pipelines.destroy(&self.gpu); - self.gpu.destroy_surface(&mut self.surface); - self.gpu.destroy_texture(self.path_intermediate_texture); - self.gpu - .destroy_texture_view(self.path_intermediate_texture_view); - if let Some(msaa_texture) = self.path_intermediate_msaa_texture { - self.gpu.destroy_texture(msaa_texture); - } - if let Some(msaa_view) = self.path_intermediate_msaa_texture_view { - self.gpu.destroy_texture_view(msaa_view); - } - } - - pub fn draw(&mut self, scene: &Scene) { - self.command_encoder.start(); - self.atlas.before_frame(&mut self.command_encoder); - - let frame = { - profiling::scope!("acquire frame"); - self.surface.acquire_frame() - }; - self.command_encoder.init_texture(frame.texture()); - - let globals = GlobalParams { - viewport_size: [ - self.surface_config.size.width as f32, - self.surface_config.size.height as f32, - ], - premultiplied_alpha: match self.surface.info().alpha { - gpu::AlphaMode::Ignored | gpu::AlphaMode::PostMultiplied => 0, - gpu::AlphaMode::PreMultiplied => 1, - }, - pad: 0, - }; - - let mut pass = self.command_encoder.render( - "main", - gpu::RenderTargetSet { - colors: &[gpu::RenderTarget { - view: frame.texture_view(), - init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack), - finish_op: gpu::FinishOp::Store, - }], - depth_stencil: None, - }, - ); - - profiling::scope!("render pass"); - for batch in scene.batches() { - match batch { - PrimitiveBatch::Quads(quads) => { - let instance_buf = unsafe { self.instance_belt.alloc_typed(quads, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.quads); - encoder.bind( - 0, - &ShaderQuadsData { - globals, - b_quads: instance_buf, - }, - ); - encoder.draw(0, 4, 0, quads.len() as u32); - } - PrimitiveBatch::Shadows(shadows) => { - let instance_buf = - unsafe { self.instance_belt.alloc_typed(shadows, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.shadows); - encoder.bind( - 0, - &ShaderShadowsData { - globals, - b_shadows: instance_buf, - }, - ); - encoder.draw(0, 4, 0, shadows.len() as u32); - } - PrimitiveBatch::Paths(paths) => { - let Some(first_path) = paths.first() else { - continue; - }; - drop(pass); - self.draw_paths_to_intermediate( - paths, - self.surface_config.size.width as f32, - self.surface_config.size.height as f32, - ); - pass = self.command_encoder.render( - "main", - gpu::RenderTargetSet { - colors: &[gpu::RenderTarget { - view: frame.texture_view(), - init_op: gpu::InitOp::Load, - finish_op: gpu::FinishOp::Store, - }], - depth_stencil: None, - }, - ); - let mut encoder = pass.with(&self.pipelines.paths); - // When copying paths from the intermediate texture to the drawable, - // each pixel must only be copied once, in case of transparent paths. - // - // If all paths have the same draw order, then their bounds are all - // disjoint, so we can copy each path's bounds individually. If this - // batch combines different draw orders, we perform a single copy - // for a minimal spanning rect. - let sprites = if paths.last().unwrap().order == first_path.order { - paths - .iter() - .map(|path| PathSprite { - bounds: path.clipped_bounds(), - }) - .collect() - } else { - let mut bounds = first_path.clipped_bounds(); - for path in paths.iter().skip(1) { - bounds = bounds.union(&path.clipped_bounds()); - } - vec![PathSprite { bounds }] - }; - let instance_buf = - unsafe { self.instance_belt.alloc_typed(&sprites, &self.gpu) }; - encoder.bind( - 0, - &ShaderPathsData { - globals, - t_sprite: self.path_intermediate_texture_view, - s_sprite: self.atlas_sampler, - b_path_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::Underlines(underlines) => { - let instance_buf = - unsafe { self.instance_belt.alloc_typed(underlines, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.underlines); - encoder.bind( - 0, - &ShaderUnderlinesData { - globals, - b_underlines: instance_buf, - }, - ); - encoder.draw(0, 4, 0, underlines.len() as u32); - } - PrimitiveBatch::MonochromeSprites { - texture_id, - sprites, - } => { - let tex_info = self.atlas.get_texture_info(texture_id); - let instance_buf = - unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.mono_sprites); - encoder.bind( - 0, - &ShaderMonoSpritesData { - globals, - gamma_ratios: self.rendering_parameters.gamma_ratios, - grayscale_enhanced_contrast: self - .rendering_parameters - .grayscale_enhanced_contrast, - t_sprite: tex_info.raw_view, - s_sprite: self.atlas_sampler, - b_mono_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::PolychromeSprites { - texture_id, - sprites, - } => { - let tex_info = self.atlas.get_texture_info(texture_id); - let instance_buf = - unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.poly_sprites); - encoder.bind( - 0, - &ShaderPolySpritesData { - globals, - t_sprite: tex_info.raw_view, - s_sprite: self.atlas_sampler, - b_poly_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::Surfaces(surfaces) => { - let mut _encoder = pass.with(&self.pipelines.surfaces); - - for surface in surfaces { - #[cfg(not(target_os = "macos"))] - { - let _ = surface; - continue; - }; - - #[cfg(target_os = "macos")] - { - let (t_y, t_cb_cr) = unsafe { - use core_foundation::base::TCFType as _; - use std::ptr; - - assert_eq!( - surface.image_buffer.get_pixel_format(), - core_video::pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange - ); - - let y_texture = self - .core_video_texture_cache - .create_texture_from_image( - surface.image_buffer.as_concrete_TypeRef(), - ptr::null(), - metal::MTLPixelFormat::R8Unorm, - surface.image_buffer.get_width_of_plane(0), - surface.image_buffer.get_height_of_plane(0), - 0, - ) - .unwrap(); - let cb_cr_texture = self - .core_video_texture_cache - .create_texture_from_image( - surface.image_buffer.as_concrete_TypeRef(), - ptr::null(), - metal::MTLPixelFormat::RG8Unorm, - surface.image_buffer.get_width_of_plane(1), - surface.image_buffer.get_height_of_plane(1), - 1, - ) - .unwrap(); - ( - gpu::TextureView::from_metal_texture( - &objc2::rc::Retained::retain( - foreign_types::ForeignTypeRef::as_ptr( - y_texture.as_texture_ref(), - ) - as *mut objc2::runtime::ProtocolObject< - dyn objc2_metal::MTLTexture, - >, - ) - .unwrap(), - gpu::TexelAspects::COLOR, - ), - gpu::TextureView::from_metal_texture( - &objc2::rc::Retained::retain( - foreign_types::ForeignTypeRef::as_ptr( - cb_cr_texture.as_texture_ref(), - ) - as *mut objc2::runtime::ProtocolObject< - dyn objc2_metal::MTLTexture, - >, - ) - .unwrap(), - gpu::TexelAspects::COLOR, - ), - ) - }; - - _encoder.bind( - 0, - &ShaderSurfacesData { - globals, - surface_locals: SurfaceParams { - bounds: surface.bounds.into(), - content_mask: surface.content_mask.bounds.into(), - }, - t_y, - t_cb_cr, - s_surface: self.atlas_sampler, - }, - ); - - _encoder.draw(0, 4, 0, 1); - } - } - } - } - } - drop(pass); - - self.command_encoder.present(frame); - let sync_point = self.gpu.submit(&mut self.command_encoder); - - profiling::scope!("finish"); - self.instance_belt.flush(&sync_point); - self.atlas.after_frame(&sync_point); - - self.wait_for_gpu(); - self.last_sync_point = Some(sync_point); - } -} - -fn create_path_intermediate_texture( - gpu: &gpu::Context, - format: gpu::TextureFormat, - width: u32, - height: u32, -) -> (gpu::Texture, gpu::TextureView) { - let texture = gpu.create_texture(gpu::TextureDesc { - name: "path intermediate", - format, - size: gpu::Extent { - width, - height, - depth: 1, - }, - array_layer_count: 1, - mip_level_count: 1, - sample_count: 1, - dimension: gpu::TextureDimension::D2, - usage: gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE | gpu::TextureUsage::TARGET, - external: None, - }); - let texture_view = gpu.create_texture_view( - texture, - gpu::TextureViewDesc { - name: "path intermediate view", - format, - dimension: gpu::ViewDimension::D2, - subresources: &Default::default(), - }, - ); - (texture, texture_view) -} - -fn create_msaa_texture_if_needed( - gpu: &gpu::Context, - format: gpu::TextureFormat, - width: u32, - height: u32, - sample_count: u32, -) -> Option<(gpu::Texture, gpu::TextureView)> { - if sample_count <= 1 { - return None; - } - let texture_msaa = gpu.create_texture(gpu::TextureDesc { - name: "path intermediate msaa", - format, - size: gpu::Extent { - width, - height, - depth: 1, - }, - array_layer_count: 1, - mip_level_count: 1, - sample_count, - dimension: gpu::TextureDimension::D2, - usage: gpu::TextureUsage::TARGET, - external: None, - }); - let texture_view_msaa = gpu.create_texture_view( - texture_msaa, - gpu::TextureViewDesc { - name: "path intermediate msaa view", - format, - dimension: gpu::ViewDimension::D2, - subresources: &Default::default(), - }, - ); - - Some((texture_msaa, texture_view_msaa)) -} - -/// A set of parameters that can be set using a corresponding environment variable. -struct RenderingParameters { - // Env var: ZED_PATH_SAMPLE_COUNT - // workaround for https://github.com/zed-industries/zed/issues/26143 - path_sample_count: u32, - - // Env var: ZED_FONTS_GAMMA - // Allowed range [1.0, 2.2], other values are clipped - // Default: 1.8 - gamma_ratios: [f32; 4], - // Env var: ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST - // Allowed range: [0.0, ..), other values are clipped - // Default: 1.0 - grayscale_enhanced_contrast: f32, -} - -impl RenderingParameters { - fn from_env(context: &BladeContext) -> Self { - use std::env; - - let path_sample_count = env::var("ZED_PATH_SAMPLE_COUNT") - .ok() - .and_then(|v| v.parse().ok()) - .or_else(|| { - [4, 2, 1] - .into_iter() - .find(|&n| (context.gpu.capabilities().sample_count_mask & n) != 0) - }) - .unwrap_or(1); - let gamma = env::var("ZED_FONTS_GAMMA") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.8_f32) - .clamp(1.0, 2.2); - let gamma_ratios = get_gamma_correction_ratios(gamma); - let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.0_f32) - .max(0.0); - - Self { - path_sample_count, - gamma_ratios, - grayscale_enhanced_contrast, - } - } -} diff --git a/src/platform/keystroke.rs b/src/platform/keystroke.rs index e1f1b0c9fb..c45c7c1b33 100644 --- a/src/platform/keystroke.rs +++ b/src/platform/keystroke.rs @@ -265,7 +265,8 @@ impl Keystroke { impl KeybindingKeystroke { #[cfg(target_os = "windows")] - pub(crate) fn new(inner: Keystroke, display_modifiers: Modifiers, display_key: String) -> Self { + #[expect(missing_docs)] + pub fn new(inner: Keystroke, display_modifiers: Modifiers, display_key: String) -> Self { KeybindingKeystroke { inner, display_modifiers, diff --git a/src/platform/layer_shell.rs b/src/platform/layer_shell.rs new file mode 100644 index 0000000000..8be1b5fcdb --- /dev/null +++ b/src/platform/layer_shell.rs @@ -0,0 +1,83 @@ +use bitflags::bitflags; +use thiserror::Error; + +use crate::Pixels; + +/// The layer the surface is rendered on. Multiple surfaces can share a layer, and ordering within +/// a single layer is undefined. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum Layer { + /// The background layer, typically used for wallpapers. + Background, + + /// The bottom layer. + Bottom, + + /// The top layer, typically used for fullscreen windows. + Top, + + /// The overlay layer, used for surfaces that should always be on top. + #[default] + Overlay, +} + +bitflags! { + /// Screen anchor point for layer_shell surfaces. These can be used in any combination, e.g. + /// specifying `Anchor::LEFT | Anchor::RIGHT` will stretch the surface across the width of the + /// screen. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct Anchor: u32 { + /// Anchor to the top edge of the screen. + const TOP = 1; + /// Anchor to the bottom edge of the screen. + const BOTTOM = 2; + /// Anchor to the left edge of the screen. + const LEFT = 4; + /// Anchor to the right edge of the screen. + const RIGHT = 8; + } +} + +/// Keyboard interactivity mode for the layer_shell surfaces. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum KeyboardInteractivity { + /// No keyboard inputs will be delivered to the surface and it won't be able to receive + /// keyboard focus. + None, + + /// The surface will receive exclusive keyboard focus as long as it is above the shell surface + /// layer, and no other layer_shell surfaces are above it. + Exclusive, + + /// The surface can be focused similarly to a normal window. + #[default] + OnDemand, +} + +/// Options for creating a layer_shell window. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LayerShellOptions { + /// The namespace for the surface, mostly used by compositors to apply rules, can not be + /// changed after the surface is created. + pub namespace: String, + /// The layer the surface is rendered on. + pub layer: Layer, + /// The anchor point of the surface. + pub anchor: Anchor, + /// Requests that the compositor avoids occluding an area with other surfaces. + pub exclusive_zone: Option, + /// The anchor point of the exclusive zone, will be determined using the anchor if left + /// unspecified. + pub exclusive_edge: Option, + /// Margins between the surface and its anchor point(s). + /// Specified in CSS order: top, right, bottom, left. + pub margin: Option<(Pixels, Pixels, Pixels, Pixels)>, + /// How keyboard events should be delivered to the surface. + pub keyboard_interactivity: KeyboardInteractivity, +} + +/// An error indicating that an action failed because the compositor doesn't support the required +/// layer_shell protocol. +#[derive(Debug, Error)] +#[error("Compositor doesn't support zwlr_layer_shell_v1")] +pub struct LayerShellNotSupportedError; diff --git a/src/platform/linux.rs b/src/platform/linux.rs index f7d7ed0eba..bafdc2e524 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -12,7 +12,7 @@ mod x11; #[cfg(any(feature = "wayland", feature = "x11"))] mod xdg_desktop_portal; -pub(crate) use dispatcher::*; +pub use dispatcher::*; pub(crate) use headless::*; pub(crate) use keyboard::*; pub(crate) use platform::*; @@ -23,10 +23,35 @@ pub(crate) use wayland::*; #[cfg(feature = "x11")] pub(crate) use x11::*; -#[cfg(all(feature = "screen-capture", any(feature = "wayland", feature = "x11")))] -pub(crate) type PlatformScreenCaptureFrame = scap::frame::Frame; -#[cfg(not(all(feature = "screen-capture", any(feature = "wayland", feature = "x11"))))] -pub(crate) type PlatformScreenCaptureFrame = (); +use std::rc::Rc; -#[cfg(feature = "wayland")] -pub use wayland::layer_shell; +/// Returns the default platform implementation for the current OS. +pub fn current_platform(headless: bool) -> Rc { + #[cfg(feature = "x11")] + use anyhow::Context as _; + + if headless { + return Rc::new(LinuxPlatform { + inner: HeadlessClient::new(), + }); + } + + match gpui::guess_compositor() { + #[cfg(feature = "wayland")] + "Wayland" => Rc::new(LinuxPlatform { + inner: WaylandClient::new(), + }), + + #[cfg(feature = "x11")] + "X11" => Rc::new(LinuxPlatform { + inner: X11Client::new() + .context("Failed to initialize X11 client.") + .unwrap(), + }), + + "Headless" => Rc::new(LinuxPlatform { + inner: HeadlessClient::new(), + }), + _ => unreachable!(), + } +} diff --git a/src/platform/linux/dispatcher.rs b/src/platform/linux/dispatcher.rs index d88eefd2c8..a72276cc76 100644 --- a/src/platform/linux/dispatcher.rs +++ b/src/platform/linux/dispatcher.rs @@ -1,18 +1,20 @@ -use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RealtimePriority, RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, - ThreadTaskTimings, profiler, -}; use calloop::{ EventLoop, PostAction, channel::{self, Sender}, timer::TimeoutAction, }; +use util::ResultExt; + use std::{ + mem::MaybeUninit, thread, time::{Duration, Instant}, }; -use util::ResultExt; + +use gpui::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, + PriorityQueueSender, RunnableVariant, THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, profiler, +}; struct TimerAfter { duration: Duration, @@ -35,47 +37,28 @@ impl LinuxDispatcher { let thread_count = std::thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); - // These thread should really be lower prio then the foreground - // executor let mut background_threads = (0..thread_count) .map(|i| { - let mut receiver = background_receiver.clone(); + let receiver: PriorityQueueReceiver = background_receiver.clone(); std::thread::Builder::new() .name(format!("Worker-{i}")) .spawn(move || { for runnable in receiver.iter() { let start = Instant::now(); - let mut location = match runnable { - RunnableVariant::Meta(runnable) => { - let location = runnable.metadata().location; - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } - RunnableVariant::Compat(runnable) => { - let location = core::panic::Location::caller(); - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, }; + profiler::add_task_timing(timing); + + runnable.run(); let end = Instant::now(); - location.end = Some(end); - profiler::add_task_timing(location); + timing.end = Some(end); + profiler::add_task_timing(timing); log::trace!( "background thread {}: ran runnable. took: {:?}", @@ -91,7 +74,7 @@ impl LinuxDispatcher { let (timer_sender, timer_channel) = calloop::channel::channel::(); let timer_thread = std::thread::Builder::new() .name("Timer".to_owned()) - .spawn(|| { + .spawn(move || { let mut event_loop: EventLoop<()> = EventLoop::try_new().expect("Failed to initialize timer loop!"); @@ -100,7 +83,6 @@ impl LinuxDispatcher { handle .insert_source(timer_channel, move |e, _, _| { if let channel::Event::Msg(timer) = e { - // This has to be in an option to satisfy the borrow checker. The callback below should only be scheduled once. let mut runnable = Some(timer.runnable); timer_handle .insert_source( @@ -108,31 +90,15 @@ impl LinuxDispatcher { move |_, _, _| { if let Some(runnable) = runnable.take() { let start = Instant::now(); - let mut timing = match runnable { - RunnableVariant::Meta(runnable) => { - let location = runnable.metadata().location; - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } - RunnableVariant::Compat(runnable) => { - let timing = TaskTiming { - location: core::panic::Location::caller(), - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, }; + profiler::add_task_timing(timing); + + runnable.run(); let end = Instant::now(); timing.end = Some(end); @@ -163,14 +129,16 @@ impl LinuxDispatcher { } impl PlatformDispatcher for LinuxDispatcher { - fn get_all_timings(&self) -> Vec { + fn get_all_timings(&self) -> Vec { let global_timings = GLOBAL_THREAD_TIMINGS.lock(); ThreadTaskTimings::convert(&global_timings) } - fn get_current_thread_timings(&self) -> Vec { + fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { THREAD_TIMINGS.with(|timings| { let timings = timings.lock(); + let thread_name = timings.thread_name.clone(); + let total_pushed = timings.total_pushed; let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); @@ -178,7 +146,13 @@ impl PlatformDispatcher for LinuxDispatcher { let (s1, s2) = timings.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); - vec + + gpui::ThreadTaskTimings { + thread_name, + thread_id: std::thread::current().id(), + timings: vec, + total_pushed, + } }) } @@ -186,7 +160,7 @@ impl PlatformDispatcher for LinuxDispatcher { thread::current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { self.background_sender .send(priority, runnable) .unwrap_or_else(|_| panic!("blocking sender returned without value")); @@ -214,25 +188,22 @@ impl PlatformDispatcher for LinuxDispatcher { .ok(); } - fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + fn spawn_realtime(&self, f: Box) { std::thread::spawn(move || { // SAFETY: always safe to call let thread_id = unsafe { libc::pthread_self() }; - let policy = match priority { - RealtimePriority::Audio => libc::SCHED_FIFO, - RealtimePriority::Other => libc::SCHED_RR, - }; - let sched_priority = match priority { - RealtimePriority::Audio => 65, - RealtimePriority::Other => 45, - }; + let policy = libc::SCHED_FIFO; + let sched_priority = 65; - let sched_param = libc::sched_param { sched_priority }; + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = + unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = sched_priority; // SAFETY: sched_param is a valid initialized structure let result = unsafe { libc::pthread_setschedparam(thread_id, policy, &sched_param) }; if result != 0 { - log::warn!("failed to set realtime thread priority to {:?}", priority); + log::warn!("failed to set realtime thread priority"); } f(); @@ -250,7 +221,7 @@ impl PriorityQueueCalloopSender { Self { sender: tx, ping } } - fn send(&self, priority: Priority, item: T) -> Result<(), crate::queue::SendError> { + fn send(&self, priority: Priority, item: T) -> Result<(), gpui::queue::SendError> { let res = self.sender.send(priority, item); if res.is_ok() { self.ping.ping(); @@ -330,7 +301,7 @@ impl calloop::EventSource for PriorityQueueCalloopReceiver { .process_events(readiness, token, |(), &mut ()| { let mut is_empty = true; - let mut receiver = self.receiver.clone(); + let receiver = self.receiver.clone(); for runnable in receiver.try_iter() { match runnable { Ok(r) => { @@ -447,11 +418,11 @@ mod tests { } // running 1 test -// test platform::linux::dispatcher::tests::tomato ... FAILED +// test linux::dispatcher::tests::tomato ... FAILED // failures: -// ---- platform::linux::dispatcher::tests::tomato stdout ---- +// ---- linux::dispatcher::tests::tomato stdout ---- // [crates/gpui/src/platform/linux/dispatcher.rs:262:9] // returning 1 tasks to process // [crates/gpui/src/platform/linux/dispatcher.rs:480:75] evt = Msg( @@ -459,6 +430,6 @@ mod tests { // ) // returning 0 tasks to process -// thread 'platform::linux::dispatcher::tests::tomato' (478301) panicked at crates/gpui/src/platform/linux/dispatcher.rs:515:9: +// thread 'linux::dispatcher::tests::tomato' (478301) panicked at crates/gpui/src/platform/linux/dispatcher.rs:515:9: // assertion failed: data.got_closed // note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/src/platform/linux/headless/client.rs b/src/platform/linux/headless/client.rs index 33f1bb17e3..4593f42efc 100644 --- a/src/platform/linux/headless/client.rs +++ b/src/platform/linux/headless/client.rs @@ -4,11 +4,10 @@ use std::rc::Rc; use calloop::{EventLoop, LoopHandle}; use util::ResultExt; -use crate::platform::linux::LinuxClient; -use crate::platform::{LinuxCommon, PlatformWindow}; -use crate::{ - AnyWindowHandle, CursorStyle, DisplayId, LinuxKeyboardLayout, PlatformDisplay, - PlatformKeyboardLayout, WindowParams, +use crate::platform::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; +use gpui::{ + AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout, + PlatformWindow, WindowParams, }; pub struct HeadlessClientState { @@ -31,10 +30,7 @@ impl HeadlessClient { handle .insert_source(main_receiver, |event, _, _: &mut HeadlessClient| { if let calloop::channel::Event::Msg(runnable) = event { - match runnable { - crate::RunnableVariant::Meta(runnable) => runnable.run(), - crate::RunnableVariant::Compat(runnable) => runnable.run(), - }; + runnable.run(); } }) .ok(); @@ -68,17 +64,12 @@ impl LinuxClient for HeadlessClient { None } - #[cfg(feature = "screen-capture")] - fn is_screen_capture_supported(&self) -> bool { - false - } - #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> futures::channel::oneshot::Receiver>>> + ) -> futures::channel::oneshot::Receiver>>> { - let (mut tx, rx) = futures::channel::oneshot::channel(); + let (tx, rx) = futures::channel::oneshot::channel(); tx.send(Err(anyhow::anyhow!( "Headless mode does not support screen capture." ))) @@ -112,15 +103,15 @@ impl LinuxClient for HeadlessClient { fn reveal_path(&self, _path: std::path::PathBuf) {} - fn write_to_primary(&self, _item: crate::ClipboardItem) {} + fn write_to_primary(&self, _item: gpui::ClipboardItem) {} - fn write_to_clipboard(&self, _item: crate::ClipboardItem) {} + fn write_to_clipboard(&self, _item: gpui::ClipboardItem) {} - fn read_from_primary(&self) -> Option { + fn read_from_primary(&self) -> Option { None } - fn read_from_clipboard(&self) -> Option { + fn read_from_clipboard(&self) -> Option { None } diff --git a/src/platform/linux/keyboard.rs b/src/platform/linux/keyboard.rs index 4e83cc4744..d810a2f4f7 100644 --- a/src/platform/linux/keyboard.rs +++ b/src/platform/linux/keyboard.rs @@ -1,4 +1,4 @@ -use crate::{PlatformKeyboardLayout, SharedString}; +use gpui::{PlatformKeyboardLayout, SharedString}; #[derive(Clone)] pub(crate) struct LinuxKeyboardLayout { diff --git a/src/platform/linux/platform.rs b/src/platform/linux/platform.rs index 06a81ec342..5800957232 100644 --- a/src/platform/linux/platform.rs +++ b/src/platform/linux/platform.rs @@ -9,25 +9,27 @@ use std::{ ffi::OsString, fs::File, io::Read as _, - os::fd::{AsFd, AsRawFd, FromRawFd}, + os::fd::{AsFd, FromRawFd, IntoRawFd}, time::Duration, }; +use crate::command::{new_command, new_std_command}; use anyhow::{Context as _, anyhow}; use calloop::LoopSignal; use futures::channel::oneshot; use util::ResultExt as _; -use util::command::{new_smol_command, new_std_command}; #[cfg(any(feature = "wayland", feature = "x11"))] use xkbcommon::xkb::{self, Keycode, Keysym, State}; -use crate::{ +use crate::platform::linux::{LinuxDispatcher, PriorityQueueCalloopReceiver}; +use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, - ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, - Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PlatformWindow, Point, PriorityQueueCalloopReceiver, Result, - RunnableVariant, Task, WindowAppearance, WindowParams, px, + ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, WindowParams, }; +#[cfg(any(feature = "wayland", feature = "x11"))] +use gpui::{Pixels, Point, px}; #[cfg(any(feature = "wayland", feature = "x11"))] pub(crate) const SCROLL_LINES: f32 = 3.0; @@ -36,6 +38,7 @@ pub(crate) const SCROLL_LINES: f32 = 3.0; // Taken from https://github.com/GNOME/gtk/blob/main/gtk/gtksettings.c#L320 #[cfg(any(feature = "wayland", feature = "x11"))] pub(crate) const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(400); +#[cfg(any(feature = "wayland", feature = "x11"))] pub(crate) const DOUBLE_CLICK_DISTANCE: Pixels = px(5.0); pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; @@ -43,51 +46,7 @@ pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; const FILE_PICKER_PORTAL_MISSING: &str = "Couldn't open file picker due to missing xdg-desktop-portal implementation."; -#[cfg(any(feature = "x11", feature = "wayland"))] -pub trait ResultExt { - type Ok; - - fn notify_err(self, msg: &'static str) -> Self::Ok; -} - -#[cfg(any(feature = "x11", feature = "wayland"))] -impl ResultExt for anyhow::Result { - type Ok = T; - - fn notify_err(self, msg: &'static str) -> T { - match self { - Ok(v) => v, - Err(e) => { - use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; - use futures::executor::block_on; - - let proxy = block_on(NotificationProxy::new()).expect(msg); - - let notification_id = "dev.zed.Oops"; - block_on( - proxy.add_notification( - notification_id, - Notification::new("Zed failed to launch") - .body(Some( - format!( - "{e:?}. See https://zed.dev/docs/linux for troubleshooting steps." - ) - .as_str(), - )) - .priority(Priority::High) - .icon(ashpd::desktop::Icon::with_names(&[ - "dialog-question-symbolic", - ])), - ) - ).expect(msg); - - panic!("{msg}"); - } - } - } -} - -pub trait LinuxClient { +pub(crate) trait LinuxClient { fn compositor_name(&self) -> &'static str; fn with_common(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R; fn keyboard_layout(&self) -> Box; @@ -95,12 +54,24 @@ pub trait LinuxClient { #[allow(unused)] fn display(&self, id: DisplayId) -> Option>; fn primary_display(&self) -> Option>; + #[cfg(feature = "screen-capture")] - fn is_screen_capture_supported(&self) -> bool; + fn is_screen_capture_supported(&self) -> bool { + true + } + #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> oneshot::Receiver>>>; + ) -> oneshot::Receiver>>> { + let (sources_tx, sources_rx) = oneshot::channel(); + sources_tx + .send(Err(anyhow::anyhow!( + "gpui_linux was compiled without the screen-capture feature" + ))) + .ok(); + sources_rx + } fn open_window( &self, @@ -153,9 +124,11 @@ impl LinuxCommon { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); #[cfg(any(feature = "wayland", feature = "x11"))] - let text_system = Arc::new(crate::CosmicTextSystem::new()); + let text_system = Arc::new(crate::platform::linux::CosmicTextSystem::new( + "IBM Plex Sans", + )); #[cfg(not(any(feature = "wayland", feature = "x11")))] - let text_system = Arc::new(crate::NoopTextSystem::new()); + let text_system = Arc::new(gpui::NoopTextSystem::new()); let callbacks = PlatformHandlers::default(); @@ -178,48 +151,63 @@ impl LinuxCommon { } } -impl Platform for P { +pub(crate) struct LinuxPlatform

{ + pub(crate) inner: P, +} + +impl Platform for LinuxPlatform

{ fn background_executor(&self) -> BackgroundExecutor { - self.with_common(|common| common.background_executor.clone()) + self.inner + .with_common(|common| common.background_executor.clone()) } fn foreground_executor(&self) -> ForegroundExecutor { - self.with_common(|common| common.foreground_executor.clone()) + self.inner + .with_common(|common| common.foreground_executor.clone()) } fn text_system(&self) -> Arc { - self.with_common(|common| common.text_system.clone()) + self.inner.with_common(|common| common.text_system.clone()) } fn keyboard_layout(&self) -> Box { - self.keyboard_layout() + self.inner.keyboard_layout() } fn keyboard_mapper(&self) -> Rc { - Rc::new(crate::DummyKeyboardMapper) + Rc::new(gpui::DummyKeyboardMapper) } fn on_keyboard_layout_change(&self, callback: Box) { - self.with_common(|common| common.callbacks.keyboard_layout_change = Some(callback)); + self.inner + .with_common(|common| common.callbacks.keyboard_layout_change = Some(callback)); + } + + fn on_thermal_state_change(&self, _callback: Box) {} + + fn thermal_state(&self) -> ThermalState { + ThermalState::Nominal } fn run(&self, on_finish_launching: Box) { on_finish_launching(); - LinuxClient::run(self); + LinuxClient::run(&self.inner); - let quit = self.with_common(|common| common.callbacks.quit.take()); + let quit = self + .inner + .with_common(|common| common.callbacks.quit.take()); if let Some(mut fun) = quit { fun(); } } fn quit(&self) { - self.with_common(|common| common.signal.stop()); + self.inner.with_common(|common| common.signal.stop()); } fn compositor_name(&self) -> &'static str { - self.compositor_name() + self.inner.compositor_name() } fn restart(&self, binary_path: Option) { @@ -243,17 +231,14 @@ impl Platform for P { log::info!("Restarting process, using app path: {:?}", app_path); // Script to wait for the current process to exit and then restart the app. - let script = format!( - r#" - while kill -0 {pid} 2>/dev/null; do + // Pass dynamic values as positional parameters to avoid shell interpolation issues. + let script = r#" + while kill -0 "$0" 2>/dev/null; do sleep 0.1 done - {app_path} - "#, - pid = app_pid, - app_path = app_path.display() - ); + "$1" + "#; #[allow( clippy::disallowed_methods, @@ -263,6 +248,8 @@ impl Platform for P { .arg("bash") .arg("-c") .arg(script) + .arg(&app_pid) + .arg(&app_path) .process_group(0) .spawn(); @@ -289,31 +276,31 @@ impl Platform for P { } fn primary_display(&self) -> Option> { - self.primary_display() + self.inner.primary_display() } fn displays(&self) -> Vec> { - self.displays() + self.inner.displays() } #[cfg(feature = "screen-capture")] fn is_screen_capture_supported(&self) -> bool { - self.is_screen_capture_supported() + self.inner.is_screen_capture_supported() } #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> oneshot::Receiver>>> { - self.screen_capture_sources() + ) -> oneshot::Receiver>>> { + self.inner.screen_capture_sources() } fn active_window(&self) -> Option { - self.active_window() + self.inner.active_window() } fn window_stack(&self) -> Option> { - self.window_stack() + self.inner.window_stack() } fn open_window( @@ -321,15 +308,16 @@ impl Platform for P { handle: AnyWindowHandle, options: WindowParams, ) -> anyhow::Result> { - self.open_window(handle, options) + self.inner.open_window(handle, options) } fn open_url(&self, url: &str) { - self.open_uri(url); + self.inner.open_uri(url); } fn on_open_urls(&self, callback: Box)>) { - self.with_common(|common| common.callbacks.open_urls = Some(callback)); + self.inner + .with_common(|common| common.callbacks.open_urls = Some(callback)); } fn prompt_for_paths( @@ -342,7 +330,7 @@ impl Platform for P { let _ = (done_tx.send(Ok(None)), options); #[cfg(any(feature = "wayland", feature = "x11"))] - let identifier = self.window_identifier(); + let identifier = self.inner.window_identifier(); #[cfg(any(feature = "wayland", feature = "x11"))] self.foreground_executor() @@ -357,7 +345,7 @@ impl Platform for P { .identifier(identifier.await) .modal(true) .title(title) - .accept_label(options.prompt.as_ref().map(crate::SharedString::as_str)) + .accept_label(options.prompt.as_ref().map(gpui::SharedString::as_str)) .multiple(options.multiple) .directory(options.directories) .send() @@ -379,7 +367,8 @@ impl Platform for P { response .uris() .iter() - .filter_map(|uri| uri.to_file_path().ok()) + .filter_map(|uri: &ashpd::Uri| url::Url::parse(uri.as_str()).ok()) + .filter_map(|uri: url::Url| uri.to_file_path().ok()) .collect::>(), )), Err(ashpd::Error::Response(_)) => Ok(None), @@ -402,7 +391,7 @@ impl Platform for P { let _ = (done_tx.send(Ok(None)), directory, suggested_name); #[cfg(any(feature = "wayland", feature = "x11"))] - let identifier = self.window_identifier(); + let identifier = self.inner.window_identifier(); #[cfg(any(feature = "wayland", feature = "x11"))] self.foreground_executor() @@ -441,7 +430,8 @@ impl Platform for P { Ok(response) => Ok(response .uris() .first() - .and_then(|uri| uri.to_file_path().ok())), + .and_then(|uri: &ashpd::Uri| url::Url::parse(uri.as_str()).ok()) + .and_then(|uri: url::Url| uri.to_file_path().ok())), Err(ashpd::Error::Response(_)) => Ok(None), Err(e) => Err(e.into()), }; @@ -459,14 +449,14 @@ impl Platform for P { } fn reveal_path(&self, path: &Path) { - self.reveal_path(path.to_owned()); + self.inner.reveal_path(path.to_owned()); } fn open_with_system(&self, path: &Path) { let path = path.to_owned(); self.background_executor() .spawn(async move { - let _ = new_smol_command("xdg-open") + let _ = new_command("xdg-open") .arg(path) .spawn() .context("invoking xdg-open") @@ -480,31 +470,31 @@ impl Platform for P { } fn on_quit(&self, callback: Box) { - self.with_common(|common| { + self.inner.with_common(|common| { common.callbacks.quit = Some(callback); }); } fn on_reopen(&self, callback: Box) { - self.with_common(|common| { + self.inner.with_common(|common| { common.callbacks.reopen = Some(callback); }); } fn on_app_menu_action(&self, callback: Box) { - self.with_common(|common| { + self.inner.with_common(|common| { common.callbacks.app_menu_action = Some(callback); }); } fn on_will_open_app_menu(&self, callback: Box) { - self.with_common(|common| { + self.inner.with_common(|common| { common.callbacks.will_open_app_menu = Some(callback); }); } fn on_validate_app_menu_command(&self, callback: Box bool>) { - self.with_common(|common| { + self.inner.with_common(|common| { common.callbacks.validate_app_menu_command = Some(callback); }); } @@ -516,13 +506,13 @@ impl Platform for P { } fn set_menus(&self, menus: Vec

, _keymap: &Keymap) { - self.with_common(|common| { + self.inner.with_common(|common| { common.menus = menus.into_iter().map(|menu| menu.owned()).collect(); }) } fn get_menus(&self) -> Option> { - self.with_common(|common| Some(common.menus.clone())) + self.inner.with_common(|common| Some(common.menus.clone())) } fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) { @@ -536,11 +526,11 @@ impl Platform for P { } fn set_cursor_style(&self, style: CursorStyle) { - self.set_cursor_style(style) + self.inner.set_cursor_style(style) } fn should_auto_hide_scrollbars(&self) -> bool { - self.with_common(|common| common.auto_hide_scrollbars) + self.inner.with_common(|common| common.auto_hide_scrollbars) } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { @@ -610,7 +600,7 @@ impl Platform for P { } fn window_appearance(&self) -> WindowAppearance { - self.with_common(|common| common.appearance) + self.inner.with_common(|common| common.appearance) } fn register_url_scheme(&self, _: &str) -> Task> { @@ -618,19 +608,19 @@ impl Platform for P { } fn write_to_primary(&self, item: ClipboardItem) { - self.write_to_primary(item) + self.inner.write_to_primary(item) } fn write_to_clipboard(&self, item: ClipboardItem) { - self.write_to_clipboard(item) + self.inner.write_to_clipboard(item) } fn read_from_primary(&self) -> Option { - self.read_from_primary() + self.inner.read_from_primary() } fn read_from_clipboard(&self) -> Option { - self.read_from_clipboard() + self.inner.read_from_clipboard() } fn add_recent_document(&self, _path: &Path) {} @@ -642,31 +632,45 @@ pub(super) fn open_uri_internal( uri: &str, activation_token: Option, ) { - if let Some(uri) = ashpd::url::Url::parse(uri).log_err() { + if let Some(uri) = ashpd::Uri::parse(uri).log_err() { executor .spawn(async move { - match ashpd::desktop::open_uri::OpenFileRequest::default() - .activation_token(activation_token.clone().map(ashpd::ActivationToken::from)) - .send_uri(&uri) - .await - .and_then(|e| e.response()) - { - Ok(()) => return, - Err(e) => log::error!("Failed to open with dbus: {}", e), - } - + let mut xdg_open_failed = false; for mut command in open::commands(uri.to_string()) { if let Some(token) = activation_token.as_ref() { command.env("XDG_ACTIVATION_TOKEN", token); } let program = format!("{:?}", command.get_program()); match smol::process::Command::from(command).spawn() { - Ok(mut cmd) => { - cmd.status().await.log_err(); - return; - } + Ok(mut cmd) => match cmd.status().await { + Ok(status) if status.success() => return, + Ok(status) => { + log::error!("Command {} exited with status: {}", program, status); + xdg_open_failed = true; + } + Err(e) => { + log::error!("Failed to get status from {}: {}", program, e); + xdg_open_failed = true; + } + }, Err(e) => { - log::error!("Failed to open with {}: {}", program, e) + log::error!("Failed to open with {}: {}", program, e); + xdg_open_failed = true; + } + } + } + + if xdg_open_failed { + match ashpd::desktop::open_uri::OpenFileRequest::default() + .activation_token(activation_token.map(ashpd::ActivationToken::from)) + .send_uri(&uri) + .await + .and_then(|e| e.response()) + { + Ok(()) => {} + Err(ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled)) => {} + Err(e) => { + log::error!("Failed to open with dbus: {}", e); } } } @@ -702,7 +706,7 @@ pub(super) fn reveal_path_internal( .detach(); } -#[allow(unused)] +#[cfg(any(feature = "wayland", feature = "x11"))] pub(super) fn is_within_click_distance(a: Point, b: Point) -> bool { let diff = a - b; diff.x.abs() <= DOUBLE_CLICK_DISTANCE && diff.y.abs() <= DOUBLE_CLICK_DISTANCE @@ -731,8 +735,8 @@ pub(super) fn get_xkb_compose_state(cx: &xkb::Context) -> Option Result> { - let mut file = unsafe { File::from_raw_fd(fd.as_raw_fd()) }; +pub(super) unsafe fn read_fd(fd: filedescriptor::FileDescriptor) -> Result> { + let mut file = unsafe { File::from_raw_fd(fd.into_raw_fd()) }; let mut buffer = Vec::new(); file.read_to_end(&mut buffer)?; Ok(buffer) @@ -741,39 +745,37 @@ pub(super) unsafe fn read_fd(mut fd: filedescriptor::FileDescriptor) -> Result &'static [&'static str] { - // Based on cursor names from chromium: - // https://github.com/chromium/chromium/blob/d3069cf9c973dc3627fa75f64085c6a86c8f41bf/ui/base/cursor/cursor_factory.cc#L113 - match self { - CursorStyle::Arrow => &[DEFAULT_CURSOR_ICON_NAME], - CursorStyle::IBeam => &["text", "xterm"], - CursorStyle::Crosshair => &["crosshair", "cross"], - CursorStyle::ClosedHand => &["closedhand", "grabbing", "hand2"], - CursorStyle::OpenHand => &["openhand", "grab", "hand1"], - CursorStyle::PointingHand => &["pointer", "hand", "hand2"], - CursorStyle::ResizeLeft => &["w-resize", "left_side"], - CursorStyle::ResizeRight => &["e-resize", "right_side"], - CursorStyle::ResizeLeftRight => &["ew-resize", "sb_h_double_arrow"], - CursorStyle::ResizeUp => &["n-resize", "top_side"], - CursorStyle::ResizeDown => &["s-resize", "bottom_side"], - CursorStyle::ResizeUpDown => &["sb_v_double_arrow", "ns-resize"], - CursorStyle::ResizeUpLeftDownRight => &["size_fdiag", "bd_double_arrow", "nwse-resize"], - CursorStyle::ResizeUpRightDownLeft => &["size_bdiag", "nesw-resize", "fd_double_arrow"], - CursorStyle::ResizeColumn => &["col-resize", "sb_h_double_arrow"], - CursorStyle::ResizeRow => &["row-resize", "sb_v_double_arrow"], - CursorStyle::IBeamCursorForVerticalLayout => &["vertical-text"], - CursorStyle::OperationNotAllowed => &["not-allowed", "crossed_circle"], - CursorStyle::DragLink => &["alias"], - CursorStyle::DragCopy => &["copy"], - CursorStyle::ContextualMenu => &["context-menu"], - CursorStyle::None => { - #[cfg(debug_assertions)] - panic!("CursorStyle::None should be handled separately in the client"); - #[cfg(not(debug_assertions))] - &[DEFAULT_CURSOR_ICON_NAME] - } +#[cfg(any(feature = "wayland", feature = "x11"))] +pub(super) fn cursor_style_to_icon_names(style: CursorStyle) -> &'static [&'static str] { + // Based on cursor names from chromium: + // https://github.com/chromium/chromium/blob/d3069cf9c973dc3627fa75f64085c6a86c8f41bf/ui/base/cursor/cursor_factory.cc#L113 + match style { + CursorStyle::Arrow => &[DEFAULT_CURSOR_ICON_NAME], + CursorStyle::IBeam => &["text", "xterm"], + CursorStyle::Crosshair => &["crosshair", "cross"], + CursorStyle::ClosedHand => &["closedhand", "grabbing", "hand2"], + CursorStyle::OpenHand => &["openhand", "grab", "hand1"], + CursorStyle::PointingHand => &["pointer", "hand", "hand2"], + CursorStyle::ResizeLeft => &["w-resize", "left_side"], + CursorStyle::ResizeRight => &["e-resize", "right_side"], + CursorStyle::ResizeLeftRight => &["ew-resize", "sb_h_double_arrow"], + CursorStyle::ResizeUp => &["n-resize", "top_side"], + CursorStyle::ResizeDown => &["s-resize", "bottom_side"], + CursorStyle::ResizeUpDown => &["sb_v_double_arrow", "ns-resize"], + CursorStyle::ResizeUpLeftDownRight => &["size_fdiag", "bd_double_arrow", "nwse-resize"], + CursorStyle::ResizeUpRightDownLeft => &["size_bdiag", "nesw-resize", "fd_double_arrow"], + CursorStyle::ResizeColumn => &["col-resize", "sb_h_double_arrow"], + CursorStyle::ResizeRow => &["row-resize", "sb_v_double_arrow"], + CursorStyle::IBeamCursorForVerticalLayout => &["vertical-text"], + CursorStyle::OperationNotAllowed => &["not-allowed", "crossed_circle"], + CursorStyle::DragLink => &["alias"], + CursorStyle::DragCopy => &["copy"], + CursorStyle::ContextualMenu => &["context-menu"], + CursorStyle::None => { + #[cfg(debug_assertions)] + panic!("CursorStyle::None should be handled separately in the client"); + #[cfg(not(debug_assertions))] + &[DEFAULT_CURSOR_ICON_NAME] } } } @@ -847,222 +849,256 @@ fn guess_ascii(keycode: Keycode, shift: bool) -> Option { } #[cfg(any(feature = "wayland", feature = "x11"))] -impl crate::Keystroke { - pub(super) fn from_xkb( - state: &State, - mut modifiers: crate::Modifiers, - keycode: Keycode, - ) -> Self { - let key_utf32 = state.key_get_utf32(keycode); - let key_utf8 = state.key_get_utf8(keycode); - let key_sym = state.key_get_one_sym(keycode); +pub(super) fn keystroke_from_xkb( + state: &State, + mut modifiers: gpui::Modifiers, + keycode: Keycode, +) -> gpui::Keystroke { + let key_utf32 = state.key_get_utf32(keycode); + let key_utf8 = state.key_get_utf8(keycode); + let key_sym = state.key_get_one_sym(keycode); - let key = match key_sym { - Keysym::Return => "enter".to_owned(), - Keysym::Prior => "pageup".to_owned(), - Keysym::Next => "pagedown".to_owned(), - Keysym::ISO_Left_Tab => "tab".to_owned(), - Keysym::KP_Prior => "pageup".to_owned(), - Keysym::KP_Next => "pagedown".to_owned(), - Keysym::XF86_Back => "back".to_owned(), - Keysym::XF86_Forward => "forward".to_owned(), - Keysym::XF86_Cut => "cut".to_owned(), - Keysym::XF86_Copy => "copy".to_owned(), - Keysym::XF86_Paste => "paste".to_owned(), - Keysym::XF86_New => "new".to_owned(), - Keysym::XF86_Open => "open".to_owned(), - Keysym::XF86_Save => "save".to_owned(), + let key = match key_sym { + Keysym::Return => "enter".to_owned(), + Keysym::Prior => "pageup".to_owned(), + Keysym::Next => "pagedown".to_owned(), + Keysym::ISO_Left_Tab => "tab".to_owned(), + Keysym::KP_Prior => "pageup".to_owned(), + Keysym::KP_Next => "pagedown".to_owned(), + Keysym::XF86_Back => "back".to_owned(), + Keysym::XF86_Forward => "forward".to_owned(), + Keysym::XF86_Cut => "cut".to_owned(), + Keysym::XF86_Copy => "copy".to_owned(), + Keysym::XF86_Paste => "paste".to_owned(), + Keysym::XF86_New => "new".to_owned(), + Keysym::XF86_Open => "open".to_owned(), + Keysym::XF86_Save => "save".to_owned(), - Keysym::comma => ",".to_owned(), - Keysym::period => ".".to_owned(), - Keysym::less => "<".to_owned(), - Keysym::greater => ">".to_owned(), - Keysym::slash => "/".to_owned(), - Keysym::question => "?".to_owned(), + Keysym::comma => ",".to_owned(), + Keysym::period => ".".to_owned(), + Keysym::less => "<".to_owned(), + Keysym::greater => ">".to_owned(), + Keysym::slash => "/".to_owned(), + Keysym::question => "?".to_owned(), - Keysym::semicolon => ";".to_owned(), - Keysym::colon => ":".to_owned(), - Keysym::apostrophe => "'".to_owned(), - Keysym::quotedbl => "\"".to_owned(), + Keysym::semicolon => ";".to_owned(), + Keysym::colon => ":".to_owned(), + Keysym::apostrophe => "'".to_owned(), + Keysym::quotedbl => "\"".to_owned(), - Keysym::bracketleft => "[".to_owned(), - Keysym::braceleft => "{".to_owned(), - Keysym::bracketright => "]".to_owned(), - Keysym::braceright => "}".to_owned(), - Keysym::backslash => "\\".to_owned(), - Keysym::bar => "|".to_owned(), + Keysym::bracketleft => "[".to_owned(), + Keysym::braceleft => "{".to_owned(), + Keysym::bracketright => "]".to_owned(), + Keysym::braceright => "}".to_owned(), + Keysym::backslash => "\\".to_owned(), + Keysym::bar => "|".to_owned(), - Keysym::grave => "`".to_owned(), - Keysym::asciitilde => "~".to_owned(), - Keysym::exclam => "!".to_owned(), - Keysym::at => "@".to_owned(), - Keysym::numbersign => "#".to_owned(), - Keysym::dollar => "$".to_owned(), - Keysym::percent => "%".to_owned(), - Keysym::asciicircum => "^".to_owned(), - Keysym::ampersand => "&".to_owned(), - Keysym::asterisk => "*".to_owned(), - Keysym::parenleft => "(".to_owned(), - Keysym::parenright => ")".to_owned(), - Keysym::minus => "-".to_owned(), - Keysym::underscore => "_".to_owned(), - Keysym::equal => "=".to_owned(), - Keysym::plus => "+".to_owned(), - Keysym::space => "space".to_owned(), - Keysym::BackSpace => "backspace".to_owned(), - Keysym::Tab => "tab".to_owned(), - Keysym::Delete => "delete".to_owned(), - Keysym::Escape => "escape".to_owned(), + Keysym::grave => "`".to_owned(), + Keysym::asciitilde => "~".to_owned(), + Keysym::exclam => "!".to_owned(), + Keysym::at => "@".to_owned(), + Keysym::numbersign => "#".to_owned(), + Keysym::dollar => "$".to_owned(), + Keysym::percent => "%".to_owned(), + Keysym::asciicircum => "^".to_owned(), + Keysym::ampersand => "&".to_owned(), + Keysym::asterisk => "*".to_owned(), + Keysym::parenleft => "(".to_owned(), + Keysym::parenright => ")".to_owned(), + Keysym::minus => "-".to_owned(), + Keysym::underscore => "_".to_owned(), + Keysym::equal => "=".to_owned(), + Keysym::plus => "+".to_owned(), + Keysym::space => "space".to_owned(), + Keysym::BackSpace => "backspace".to_owned(), + Keysym::Tab => "tab".to_owned(), + Keysym::Delete => "delete".to_owned(), + Keysym::Escape => "escape".to_owned(), - Keysym::Left => "left".to_owned(), - Keysym::Right => "right".to_owned(), - Keysym::Up => "up".to_owned(), - Keysym::Down => "down".to_owned(), - Keysym::Home => "home".to_owned(), - Keysym::End => "end".to_owned(), - Keysym::Insert => "insert".to_owned(), + Keysym::Left => "left".to_owned(), + Keysym::Right => "right".to_owned(), + Keysym::Up => "up".to_owned(), + Keysym::Down => "down".to_owned(), + Keysym::Home => "home".to_owned(), + Keysym::End => "end".to_owned(), + Keysym::Insert => "insert".to_owned(), - _ => { - let name = xkb::keysym_get_name(key_sym).to_lowercase(); - if key_sym.is_keypad_key() { - name.replace("kp_", "") - } else if let Some(key) = key_utf8.chars().next() - && key_utf8.len() == 1 - && key.is_ascii() + _ => { + let name = xkb::keysym_get_name(key_sym).to_lowercase(); + if key_sym.is_keypad_key() { + name.replace("kp_", "") + } else if let Some(key) = key_utf8.chars().next() + && key_utf8.len() == 1 + && key.is_ascii() + { + if key.is_ascii_graphic() { + key_utf8.to_lowercase() + // map ctrl-a to `a` + // ctrl-0..9 may emit control codes like ctrl-[, but + // we don't want to map them to `[` + } else if key_utf32 <= 0x1f + && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) { - if key.is_ascii_graphic() { - key_utf8.to_lowercase() - // map ctrl-a to `a` - // ctrl-0..9 may emit control codes like ctrl-[, but - // we don't want to map them to `[` - } else if key_utf32 <= 0x1f - && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - ((key_utf32 as u8 + 0x40) as char) - .to_ascii_lowercase() - .to_string() - } else { - name - } - } else if let Some(key_en) = guess_ascii(keycode, modifiers.shift) { - String::from(key_en) + ((key_utf32 as u8 + 0x40) as char) + .to_ascii_lowercase() + .to_string() } else { name } - } - }; - - if modifiers.shift { - // we only include the shift for upper-case letters by convention, - // so don't include for numbers and symbols, but do include for - // tab/enter, etc. - if key.chars().count() == 1 && key.to_lowercase() == key.to_uppercase() { - modifiers.shift = false; + } else if let Some(key_en) = guess_ascii(keycode, modifiers.shift) { + String::from(key_en) + } else { + name } } + }; - // Ignore control characters (and DEL) for the purposes of key_char - let key_char = - (key_utf32 >= 32 && key_utf32 != 127 && !key_utf8.is_empty()).then_some(key_utf8); - - Self { - modifiers, - key, - key_char, + if modifiers.shift { + // we only include the shift for upper-case letters by convention, + // so don't include for numbers and symbols, but do include for + // tab/enter, etc. + if key.chars().count() == 1 && key.to_lowercase() == key.to_uppercase() { + modifiers.shift = false; } } - /** - * Returns which symbol the dead key represents - * - */ - pub fn underlying_dead_key(keysym: Keysym) -> Option { - match keysym { - Keysym::dead_grave => Some("`".to_owned()), - Keysym::dead_acute => Some("´".to_owned()), - Keysym::dead_circumflex => Some("^".to_owned()), - Keysym::dead_tilde => Some("~".to_owned()), - Keysym::dead_macron => Some("¯".to_owned()), - Keysym::dead_breve => Some("˘".to_owned()), - Keysym::dead_abovedot => Some("˙".to_owned()), - Keysym::dead_diaeresis => Some("¨".to_owned()), - Keysym::dead_abovering => Some("˚".to_owned()), - Keysym::dead_doubleacute => Some("˝".to_owned()), - Keysym::dead_caron => Some("ˇ".to_owned()), - Keysym::dead_cedilla => Some("¸".to_owned()), - Keysym::dead_ogonek => Some("˛".to_owned()), - Keysym::dead_iota => Some("ͅ".to_owned()), - Keysym::dead_voiced_sound => Some("゙".to_owned()), - Keysym::dead_semivoiced_sound => Some("゚".to_owned()), - Keysym::dead_belowdot => Some("̣̣".to_owned()), - Keysym::dead_hook => Some("̡".to_owned()), - Keysym::dead_horn => Some("̛".to_owned()), - Keysym::dead_stroke => Some("̶̶".to_owned()), - Keysym::dead_abovecomma => Some("̓̓".to_owned()), - Keysym::dead_abovereversedcomma => Some("ʽ".to_owned()), - Keysym::dead_doublegrave => Some("̏".to_owned()), - Keysym::dead_belowring => Some("˳".to_owned()), - Keysym::dead_belowmacron => Some("̱".to_owned()), - Keysym::dead_belowcircumflex => Some("ꞈ".to_owned()), - Keysym::dead_belowtilde => Some("̰".to_owned()), - Keysym::dead_belowbreve => Some("̮".to_owned()), - Keysym::dead_belowdiaeresis => Some("̤".to_owned()), - Keysym::dead_invertedbreve => Some("̯".to_owned()), - Keysym::dead_belowcomma => Some("̦".to_owned()), - Keysym::dead_currency => None, - Keysym::dead_lowline => None, - Keysym::dead_aboveverticalline => None, - Keysym::dead_belowverticalline => None, - Keysym::dead_longsolidusoverlay => None, - Keysym::dead_a => None, - Keysym::dead_A => None, - Keysym::dead_e => None, - Keysym::dead_E => None, - Keysym::dead_i => None, - Keysym::dead_I => None, - Keysym::dead_o => None, - Keysym::dead_O => None, - Keysym::dead_u => None, - Keysym::dead_U => None, - Keysym::dead_small_schwa => Some("ə".to_owned()), - Keysym::dead_capital_schwa => Some("Ə".to_owned()), - Keysym::dead_greek => None, - _ => None, - } + // Ignore control characters (and DEL) for the purposes of key_char + let key_char = + (key_utf32 >= 32 && key_utf32 != 127 && !key_utf8.is_empty()).then_some(key_utf8); + + gpui::Keystroke { + modifiers, + key, + key_char, + } +} + +/** + * Returns which symbol the dead key represents + * + */ +#[cfg(any(feature = "wayland", feature = "x11"))] +pub fn keystroke_underlying_dead_key(keysym: Keysym) -> Option { + match keysym { + Keysym::dead_grave => Some("`".to_owned()), + Keysym::dead_acute => Some("´".to_owned()), + Keysym::dead_circumflex => Some("^".to_owned()), + Keysym::dead_tilde => Some("~".to_owned()), + Keysym::dead_macron => Some("¯".to_owned()), + Keysym::dead_breve => Some("˘".to_owned()), + Keysym::dead_abovedot => Some("˙".to_owned()), + Keysym::dead_diaeresis => Some("¨".to_owned()), + Keysym::dead_abovering => Some("˚".to_owned()), + Keysym::dead_doubleacute => Some("˝".to_owned()), + Keysym::dead_caron => Some("ˇ".to_owned()), + Keysym::dead_cedilla => Some("¸".to_owned()), + Keysym::dead_ogonek => Some("˛".to_owned()), + Keysym::dead_iota => Some("ͅ".to_owned()), + Keysym::dead_voiced_sound => Some("゙".to_owned()), + Keysym::dead_semivoiced_sound => Some("゚".to_owned()), + Keysym::dead_belowdot => Some("̣̣".to_owned()), + Keysym::dead_hook => Some("̡".to_owned()), + Keysym::dead_horn => Some("̛".to_owned()), + Keysym::dead_stroke => Some("̶̶".to_owned()), + Keysym::dead_abovecomma => Some("̓̓".to_owned()), + Keysym::dead_abovereversedcomma => Some("ʽ".to_owned()), + Keysym::dead_doublegrave => Some("̏".to_owned()), + Keysym::dead_belowring => Some("˳".to_owned()), + Keysym::dead_belowmacron => Some("̱".to_owned()), + Keysym::dead_belowcircumflex => Some("ꞈ".to_owned()), + Keysym::dead_belowtilde => Some("̰".to_owned()), + Keysym::dead_belowbreve => Some("̮".to_owned()), + Keysym::dead_belowdiaeresis => Some("̤".to_owned()), + Keysym::dead_invertedbreve => Some("̯".to_owned()), + Keysym::dead_belowcomma => Some("̦".to_owned()), + Keysym::dead_currency => None, + Keysym::dead_lowline => None, + Keysym::dead_aboveverticalline => None, + Keysym::dead_belowverticalline => None, + Keysym::dead_longsolidusoverlay => None, + Keysym::dead_a => None, + Keysym::dead_A => None, + Keysym::dead_e => None, + Keysym::dead_E => None, + Keysym::dead_i => None, + Keysym::dead_I => None, + Keysym::dead_o => None, + Keysym::dead_O => None, + Keysym::dead_u => None, + Keysym::dead_U => None, + Keysym::dead_small_schwa => Some("ə".to_owned()), + Keysym::dead_capital_schwa => Some("Ə".to_owned()), + Keysym::dead_greek => None, + _ => None, + } +} +#[cfg(any(feature = "wayland", feature = "x11"))] +pub(super) fn modifiers_from_xkb(keymap_state: &State) -> gpui::Modifiers { + let shift = keymap_state.mod_name_is_active(xkb::MOD_NAME_SHIFT, xkb::STATE_MODS_EFFECTIVE); + let alt = keymap_state.mod_name_is_active(xkb::MOD_NAME_ALT, xkb::STATE_MODS_EFFECTIVE); + let control = keymap_state.mod_name_is_active(xkb::MOD_NAME_CTRL, xkb::STATE_MODS_EFFECTIVE); + let platform = keymap_state.mod_name_is_active(xkb::MOD_NAME_LOGO, xkb::STATE_MODS_EFFECTIVE); + gpui::Modifiers { + shift, + alt, + control, + platform, + function: false, } } #[cfg(any(feature = "wayland", feature = "x11"))] -impl crate::Modifiers { - pub(super) fn from_xkb(keymap_state: &State) -> Self { - let shift = keymap_state.mod_name_is_active(xkb::MOD_NAME_SHIFT, xkb::STATE_MODS_EFFECTIVE); - let alt = keymap_state.mod_name_is_active(xkb::MOD_NAME_ALT, xkb::STATE_MODS_EFFECTIVE); - let control = - keymap_state.mod_name_is_active(xkb::MOD_NAME_CTRL, xkb::STATE_MODS_EFFECTIVE); - let platform = - keymap_state.mod_name_is_active(xkb::MOD_NAME_LOGO, xkb::STATE_MODS_EFFECTIVE); - Self { - shift, - alt, - control, - platform, - function: false, - } - } +pub(super) fn capslock_from_xkb(keymap_state: &State) -> gpui::Capslock { + let on = keymap_state.mod_name_is_active(xkb::MOD_NAME_CAPS, xkb::STATE_MODS_EFFECTIVE); + gpui::Capslock { on } } +/// Resolve a Linux `dev_t` to PCI vendor/device IDs via sysfs, returning a +/// [`CompositorGpuHint`] that the GPU adapter selection code can use to +/// prioritize the compositor's rendering device. #[cfg(any(feature = "wayland", feature = "x11"))] -impl crate::Capslock { - pub(super) fn from_xkb(keymap_state: &State) -> Self { - let on = keymap_state.mod_name_is_active(xkb::MOD_NAME_CAPS, xkb::STATE_MODS_EFFECTIVE); - Self { on } +pub(super) fn compositor_gpu_hint_from_dev_t( + dev: u64, +) -> Option { + fn dev_major(dev: u64) -> u32 { + ((dev >> 8) & 0xfff) as u32 | (((dev >> 32) & !0xfff) as u32) } + + fn dev_minor(dev: u64) -> u32 { + (dev & 0xff) as u32 | (((dev >> 12) & !0xff) as u32) + } + + fn read_sysfs_hex_id(path: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let trimmed = content.trim().strip_prefix("0x").unwrap_or(content.trim()); + u32::from_str_radix(trimmed, 16).ok() + } + + let major = dev_major(dev); + let minor = dev_minor(dev); + + let vendor_path = format!("/sys/dev/char/{major}:{minor}/device/vendor"); + let device_path = format!("/sys/dev/char/{major}:{minor}/device/device"); + + let vendor_id = read_sysfs_hex_id(&vendor_path)?; + let device_id = read_sysfs_hex_id(&device_path)?; + + log::info!( + "Compositor GPU hint: vendor={:#06x}, device={:#06x} (from dev {major}:{minor})", + vendor_id, + device_id, + ); + + Some(crate::platform::wgpu::CompositorGpuHint { + vendor_id, + device_id, + }) } #[cfg(test)] mod tests { use super::*; - use crate::{Point, px}; + use gpui::{Point, px}; #[test] fn test_is_within_click_distance() { diff --git a/src/platform/linux/text_system.rs b/src/platform/linux/text_system.rs index 958d509d53..0f5490fc42 100644 --- a/src/platform/linux/text_system.rs +++ b/src/platform/linux/text_system.rs @@ -1,581 +1 @@ -use crate::{ - Bounds, DevicePixels, Font, FontFeatures, FontId, FontMetrics, FontRun, FontStyle, FontWeight, - GlyphId, LineLayout, Pixels, PlatformTextSystem, Point, RenderGlyphParams, SUBPIXEL_VARIANTS_X, - SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, point, size, -}; -use anyhow::{Context as _, Ok, Result}; -use collections::HashMap; -use cosmic_text::{ - Attrs, AttrsList, CacheKey, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures, - FontSystem, ShapeBuffer, ShapeLine, SwashCache, -}; - -use itertools::Itertools; -use parking_lot::RwLock; -use pathfinder_geometry::{ - rect::{RectF, RectI}, - vector::{Vector2F, Vector2I}, -}; -use smallvec::SmallVec; -use std::{borrow::Cow, sync::Arc}; - -pub(crate) struct CosmicTextSystem(RwLock); - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct FontKey { - family: SharedString, - features: FontFeatures, -} - -impl FontKey { - fn new(family: SharedString, features: FontFeatures) -> Self { - Self { family, features } - } -} - -struct CosmicTextSystemState { - swash_cache: SwashCache, - font_system: FontSystem, - scratch: ShapeBuffer, - /// Contains all already loaded fonts, including all faces. Indexed by `FontId`. - loaded_fonts: Vec, - /// Caches the `FontId`s associated with a specific family to avoid iterating the font database - /// for every font face in a family. - font_ids_by_family_cache: HashMap>, -} - -struct LoadedFont { - font: Arc, - features: CosmicFontFeatures, - is_known_emoji_font: bool, -} - -impl CosmicTextSystem { - pub(crate) fn new() -> Self { - // todo(linux) make font loading non-blocking - let mut font_system = FontSystem::new(); - - Self(RwLock::new(CosmicTextSystemState { - font_system, - swash_cache: SwashCache::new(), - scratch: ShapeBuffer::default(), - loaded_fonts: Vec::new(), - font_ids_by_family_cache: HashMap::default(), - })) - } -} - -impl Default for CosmicTextSystem { - fn default() -> Self { - Self::new() - } -} - -impl PlatformTextSystem for CosmicTextSystem { - fn add_fonts(&self, fonts: Vec>) -> Result<()> { - self.0.write().add_fonts(fonts) - } - - fn all_font_names(&self) -> Vec { - let mut result = self - .0 - .read() - .font_system - .db() - .faces() - .filter_map(|face| face.families.first().map(|family| family.0.clone())) - .collect_vec(); - result.sort(); - result.dedup(); - result - } - - fn font_id(&self, font: &Font) -> Result { - // todo(linux): Do we need to use CosmicText's Font APIs? Can we consolidate this to use font_kit? - let mut state = self.0.write(); - let key = FontKey::new(font.family.clone(), font.features.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)?; - state.font_ids_by_family_cache.insert(key.clone(), font_ids); - state.font_ids_by_family_cache[&key].as_ref() - }; - - // todo(linux) ideally we would make fontdb's `find_best_match` pub instead of using font-kit here - let candidate_properties = candidates - .iter() - .map(|font_id| { - let database_id = state.loaded_font(*font_id).font.id(); - let face_info = state.font_system.db().face(database_id).expect(""); - face_info_into_properties(face_info) - }) - .collect::>(); - - let ix = - font_kit::matching::find_best_match(&candidate_properties, &font_into_properties(font)) - .context("requested font family contains no font matching the other parameters")?; - - Ok(candidates[ix]) - } - - fn font_metrics(&self, font_id: FontId) -> FontMetrics { - let metrics = self - .0 - .read() - .loaded_font(font_id) - .font - .as_swash() - .metrics(&[]); - - FontMetrics { - units_per_em: metrics.units_per_em as u32, - ascent: metrics.ascent, - descent: -metrics.descent, // todo(linux) confirm this is correct - line_gap: metrics.leading, - underline_position: metrics.underline_offset, - underline_thickness: metrics.stroke_size, - cap_height: metrics.cap_height, - x_height: metrics.x_height, - // todo(linux): Compute this correctly - bounding_box: Bounds { - origin: point(0.0, 0.0), - size: size(metrics.max_width, metrics.ascent + metrics.descent), - }, - } - } - - fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { - let lock = self.0.read(); - let glyph_metrics = lock.loaded_font(font_id).font.as_swash().glyph_metrics(&[]); - let glyph_id = glyph_id.0 as u16; - // todo(linux): Compute this correctly - // see https://github.com/servo/font-kit/blob/master/src/loaders/freetype.rs#L614-L620 - Ok(Bounds { - origin: point(0.0, 0.0), - size: size( - glyph_metrics.advance_width(glyph_id), - glyph_metrics.advance_height(glyph_id), - ), - }) - } - - fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { - self.0.read().advance(font_id, glyph_id) - } - - fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { - self.0.read().glyph_for_char(font_id, ch) - } - - fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { - self.0.write().raster_bounds(params) - } - - fn rasterize_glyph( - &self, - params: &RenderGlyphParams, - raster_bounds: Bounds, - ) -> Result<(Size, Vec)> { - self.0.write().rasterize_glyph(params, raster_bounds) - } - - fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { - self.0.write().layout_line(text, font_size, runs) - } -} - -impl CosmicTextSystemState { - fn loaded_font(&self, font_id: FontId) -> &LoadedFont { - &self.loaded_fonts[font_id.0] - } - - #[profiling::function] - fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { - let db = self.font_system.db_mut(); - for bytes in fonts { - match bytes { - Cow::Borrowed(embedded_font) => { - db.load_font_data(embedded_font.to_vec()); - } - Cow::Owned(bytes) => { - db.load_font_data(bytes); - } - } - } - Ok(()) - } - - #[profiling::function] - fn load_family( - &mut self, - name: &str, - features: &FontFeatures, - ) -> Result> { - // TODO: Determine the proper system UI font. - let name = crate::text_system::font_name_with_fallbacks(name, "IBM Plex Sans"); - - let families = self - .font_system - .db() - .faces() - .filter(|face| face.families.iter().any(|family| *name == family.0)) - .map(|face| (face.id, face.post_script_name.clone())) - .collect::>(); - - let mut loaded_font_ids = SmallVec::new(); - for (font_id, postscript_name) in families { - let font = self - .font_system - .get_font(font_id) - .context("Could not load font")?; - - // HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback. - let allowed_bad_font_names = [ - "SegoeFluentIcons", // NOTE: Segoe fluent icons postscript name is inconsistent - "Segoe Fluent Icons", - ]; - - if font.as_swash().charmap().map('m') == 0 - && !allowed_bad_font_names.contains(&postscript_name.as_str()) - { - self.font_system.db_mut().remove_face(font.id()); - continue; - }; - - let font_id = FontId(self.loaded_fonts.len()); - loaded_font_ids.push(font_id); - self.loaded_fonts.push(LoadedFont { - font, - features: features.try_into()?, - is_known_emoji_font: check_is_known_emoji_font(&postscript_name), - }); - } - - Ok(loaded_font_ids) - } - - fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { - let glyph_metrics = self.loaded_font(font_id).font.as_swash().glyph_metrics(&[]); - Ok(Size { - width: glyph_metrics.advance_width(glyph_id.0 as u16), - height: glyph_metrics.advance_height(glyph_id.0 as u16), - }) - } - - fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { - let glyph_id = self.loaded_font(font_id).font.as_swash().charmap().map(ch); - if glyph_id == 0 { - None - } else { - Some(GlyphId(glyph_id.into())) - } - } - - fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result> { - let font = &self.loaded_fonts[params.font_id.0].font; - let subpixel_shift = point( - params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor, - params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor, - ); - let image = self - .swash_cache - .get_image( - &mut self.font_system, - CacheKey::new( - font.id(), - params.glyph_id.0 as u16, - (params.font_size * params.scale_factor).into(), - (subpixel_shift.x, subpixel_shift.y.trunc()), - cosmic_text::CacheKeyFlags::empty(), - ) - .0, - ) - .clone() - .with_context(|| format!("no image for {params:?} in font {font:?}"))?; - Ok(Bounds { - origin: point(image.placement.left.into(), (-image.placement.top).into()), - size: size(image.placement.width.into(), image.placement.height.into()), - }) - } - - #[profiling::function] - fn rasterize_glyph( - &mut self, - params: &RenderGlyphParams, - glyph_bounds: Bounds, - ) -> Result<(Size, Vec)> { - if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 { - anyhow::bail!("glyph bounds are empty"); - } else { - let bitmap_size = glyph_bounds.size; - let font = &self.loaded_fonts[params.font_id.0].font; - let subpixel_shift = point( - params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor, - params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor, - ); - let mut image = self - .swash_cache - .get_image( - &mut self.font_system, - CacheKey::new( - font.id(), - params.glyph_id.0 as u16, - (params.font_size * params.scale_factor).into(), - (subpixel_shift.x, subpixel_shift.y.trunc()), - cosmic_text::CacheKeyFlags::empty(), - ) - .0, - ) - .clone() - .with_context(|| format!("no image for {params:?} in font {font:?}"))?; - - if params.is_emoji { - // Convert from RGBA to BGRA. - for pixel in image.data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - } - - Ok((bitmap_size, image.data)) - } - } - - /// This is used when cosmic_text has chosen a fallback font instead of using the requested - /// font, typically to handle some unicode characters. When this happens, `loaded_fonts` may not - /// yet have an entry for this fallback font, and so one is added. - /// - /// Note that callers shouldn't use this `FontId` somewhere that will retrieve the corresponding - /// `LoadedFont.features`, as it will have an arbitrarily chosen or empty value. The only - /// current use of this field is for the *input* of `layout_line`, and so it's fine to use - /// `font_id_for_cosmic_id` when computing the *output* of `layout_line`. - fn font_id_for_cosmic_id(&mut self, id: cosmic_text::fontdb::ID) -> FontId { - if let Some(ix) = self - .loaded_fonts - .iter() - .position(|loaded_font| loaded_font.font.id() == id) - { - FontId(ix) - } else { - let font = self.font_system.get_font(id).unwrap(); - let face = self.font_system.db().face(id).unwrap(); - - let font_id = FontId(self.loaded_fonts.len()); - self.loaded_fonts.push(LoadedFont { - font, - features: CosmicFontFeatures::new(), - is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name), - }); - - font_id - } - } - - #[profiling::function] - fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { - let mut attrs_list = AttrsList::new(&Attrs::new()); - let mut offs = 0; - for run in font_runs { - let loaded_font = self.loaded_font(run.font_id); - let font = self.font_system.db().face(loaded_font.font.id()).unwrap(); - - attrs_list.add_span( - offs..(offs + run.len), - &Attrs::new() - .metadata(run.font_id.0) - .family(Family::Name(&font.families.first().unwrap().0)) - .stretch(font.stretch) - .style(font.style) - .weight(font.weight) - .font_features(loaded_font.features.clone()), - ); - offs += run.len; - } - - let line = ShapeLine::new( - &mut self.font_system, - text, - &attrs_list, - cosmic_text::Shaping::Advanced, - 4, - ); - let mut layout_lines = Vec::with_capacity(1); - line.layout_to_buffer( - &mut self.scratch, - font_size.0, - None, // We do our own wrapping - cosmic_text::Wrap::None, - None, - &mut layout_lines, - None, - ); - let layout = layout_lines.first().unwrap(); - - let mut runs: Vec = Vec::new(); - for glyph in &layout.glyphs { - let mut font_id = FontId(glyph.metadata); - let mut loaded_font = self.loaded_font(font_id); - if loaded_font.font.id() != glyph.font_id { - font_id = self.font_id_for_cosmic_id(glyph.font_id); - loaded_font = self.loaded_font(font_id); - } - let is_emoji = loaded_font.is_known_emoji_font; - - // HACK: Prevent crash caused by variation selectors. - if glyph.glyph_id == 3 && is_emoji { - continue; - } - - let shaped_glyph = ShapedGlyph { - id: GlyphId(glyph.glyph_id as u32), - position: point(glyph.x.into(), glyph.y.into()), - index: glyph.start, - is_emoji, - }; - - if let Some(last_run) = runs - .last_mut() - .filter(|last_run| last_run.font_id == font_id) - { - last_run.glyphs.push(shaped_glyph); - } else { - runs.push(ShapedRun { - font_id, - glyphs: vec![shaped_glyph], - }); - } - } - - LineLayout { - font_size, - width: layout.w.into(), - ascent: layout.max_ascent.into(), - descent: layout.max_descent.into(), - runs, - len: text.len(), - } - } -} - -impl TryFrom<&FontFeatures> for CosmicFontFeatures { - type Error = anyhow::Error; - - fn try_from(features: &FontFeatures) -> Result { - let mut result = CosmicFontFeatures::new(); - for feature in features.0.iter() { - let name_bytes: [u8; 4] = feature - .0 - .as_bytes() - .try_into() - .context("Incorrect feature flag format")?; - - let tag = cosmic_text::FeatureTag::new(&name_bytes); - - result.set(tag, feature.1); - } - Ok(result) - } -} - -impl From for Bounds { - fn from(rect: RectF) -> Self { - Bounds { - origin: point(rect.origin_x(), rect.origin_y()), - size: size(rect.width(), rect.height()), - } - } -} - -impl From for Bounds { - fn from(rect: RectI) -> Self { - Bounds { - origin: point(DevicePixels(rect.origin_x()), DevicePixels(rect.origin_y())), - size: size(DevicePixels(rect.width()), DevicePixels(rect.height())), - } - } -} - -impl From for Size { - fn from(value: Vector2I) -> Self { - size(value.x().into(), value.y().into()) - } -} - -impl From for Bounds { - fn from(rect: RectI) -> Self { - Bounds { - origin: point(rect.origin_x(), rect.origin_y()), - size: size(rect.width(), rect.height()), - } - } -} - -impl From> for Vector2I { - fn from(size: Point) -> Self { - Vector2I::new(size.x as i32, size.y as i32) - } -} - -impl From for Size { - fn from(vec: Vector2F) -> Self { - size(vec.x(), vec.y()) - } -} - -impl From for cosmic_text::Weight { - fn from(value: FontWeight) -> Self { - cosmic_text::Weight(value.0 as u16) - } -} - -impl From for cosmic_text::Style { - fn from(style: FontStyle) -> Self { - match style { - FontStyle::Normal => cosmic_text::Style::Normal, - FontStyle::Italic => cosmic_text::Style::Italic, - FontStyle::Oblique => cosmic_text::Style::Oblique, - } - } -} - -fn font_into_properties(font: &crate::Font) -> font_kit::properties::Properties { - font_kit::properties::Properties { - style: match font.style { - crate::FontStyle::Normal => font_kit::properties::Style::Normal, - crate::FontStyle::Italic => font_kit::properties::Style::Italic, - crate::FontStyle::Oblique => font_kit::properties::Style::Oblique, - }, - weight: font_kit::properties::Weight(font.weight.0), - stretch: Default::default(), - } -} - -fn face_info_into_properties( - face_info: &cosmic_text::fontdb::FaceInfo, -) -> font_kit::properties::Properties { - font_kit::properties::Properties { - style: match face_info.style { - cosmic_text::Style::Normal => font_kit::properties::Style::Normal, - cosmic_text::Style::Italic => font_kit::properties::Style::Italic, - cosmic_text::Style::Oblique => font_kit::properties::Style::Oblique, - }, - // both libs use the same values for weight - weight: font_kit::properties::Weight(face_info.weight.0.into()), - stretch: match face_info.stretch { - cosmic_text::Stretch::Condensed => font_kit::properties::Stretch::CONDENSED, - cosmic_text::Stretch::Expanded => font_kit::properties::Stretch::EXPANDED, - cosmic_text::Stretch::ExtraCondensed => font_kit::properties::Stretch::EXTRA_CONDENSED, - cosmic_text::Stretch::ExtraExpanded => font_kit::properties::Stretch::EXTRA_EXPANDED, - cosmic_text::Stretch::Normal => font_kit::properties::Stretch::NORMAL, - cosmic_text::Stretch::SemiCondensed => font_kit::properties::Stretch::SEMI_CONDENSED, - cosmic_text::Stretch::SemiExpanded => font_kit::properties::Stretch::SEMI_EXPANDED, - cosmic_text::Stretch::UltraCondensed => font_kit::properties::Stretch::ULTRA_CONDENSED, - cosmic_text::Stretch::UltraExpanded => font_kit::properties::Stretch::ULTRA_EXPANDED, - }, - } -} - -fn check_is_known_emoji_font(postscript_name: &str) -> bool { - // TODO: Include other common emoji fonts - postscript_name == "NotoColorEmoji" -} +pub(crate) use crate::platform::wgpu::CosmicTextSystem; diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 366b5703e4..aa1e797404 100644 --- a/src/platform/linux/wayland.rs +++ b/src/platform/linux/wayland.rs @@ -12,38 +12,36 @@ pub(crate) use client::*; use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; -use crate::CursorStyle; +use gpui::CursorStyle; -impl CursorStyle { - pub(super) fn to_shape(self) -> Shape { - match self { - CursorStyle::Arrow => Shape::Default, - CursorStyle::IBeam => Shape::Text, - CursorStyle::Crosshair => Shape::Crosshair, - CursorStyle::ClosedHand => Shape::Grabbing, - CursorStyle::OpenHand => Shape::Grab, - CursorStyle::PointingHand => Shape::Pointer, - CursorStyle::ResizeLeft => Shape::WResize, - CursorStyle::ResizeRight => Shape::EResize, - CursorStyle::ResizeLeftRight => Shape::EwResize, - CursorStyle::ResizeUp => Shape::NResize, - CursorStyle::ResizeDown => Shape::SResize, - CursorStyle::ResizeUpDown => Shape::NsResize, - CursorStyle::ResizeUpLeftDownRight => Shape::NwseResize, - CursorStyle::ResizeUpRightDownLeft => Shape::NeswResize, - CursorStyle::ResizeColumn => Shape::ColResize, - CursorStyle::ResizeRow => Shape::RowResize, - CursorStyle::IBeamCursorForVerticalLayout => Shape::VerticalText, - CursorStyle::OperationNotAllowed => Shape::NotAllowed, - CursorStyle::DragLink => Shape::Alias, - CursorStyle::DragCopy => Shape::Copy, - CursorStyle::ContextualMenu => Shape::ContextMenu, - CursorStyle::None => { - #[cfg(debug_assertions)] - panic!("CursorStyle::None should be handled separately in the client"); - #[cfg(not(debug_assertions))] - Shape::Default - } +pub(super) fn to_shape(style: CursorStyle) -> Shape { + match style { + CursorStyle::Arrow => Shape::Default, + CursorStyle::IBeam => Shape::Text, + CursorStyle::Crosshair => Shape::Crosshair, + CursorStyle::ClosedHand => Shape::Grabbing, + CursorStyle::OpenHand => Shape::Grab, + CursorStyle::PointingHand => Shape::Pointer, + CursorStyle::ResizeLeft => Shape::WResize, + CursorStyle::ResizeRight => Shape::EResize, + CursorStyle::ResizeLeftRight => Shape::EwResize, + CursorStyle::ResizeUp => Shape::NResize, + CursorStyle::ResizeDown => Shape::SResize, + CursorStyle::ResizeUpDown => Shape::NsResize, + CursorStyle::ResizeUpLeftDownRight => Shape::NwseResize, + CursorStyle::ResizeUpRightDownLeft => Shape::NeswResize, + CursorStyle::ResizeColumn => Shape::ColResize, + CursorStyle::ResizeRow => Shape::RowResize, + CursorStyle::IBeamCursorForVerticalLayout => Shape::VerticalText, + CursorStyle::OperationNotAllowed => Shape::NotAllowed, + CursorStyle::DragLink => Shape::Alias, + CursorStyle::DragCopy => Shape::Copy, + CursorStyle::ContextualMenu => Shape::ContextMenu, + CursorStyle::None => { + #[cfg(debug_assertions)] + panic!("CursorStyle::None should be handled separately in the client"); + #[cfg(not(debug_assertions))] + Shape::Default } } } diff --git a/src/platform/linux/wayland/client.rs b/src/platform/linux/wayland/client.rs index 0e7bf8fbf8..2d5dd1b089 100644 --- a/src/platform/linux/wayland/client.rs +++ b/src/platform/linux/wayland/client.rs @@ -36,11 +36,8 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; -use wayland_protocols::wp::cursor_shape::v1::client::{ - wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, -}; -use wayland_protocols::wp::fractional_scale::v1::client::{ - wp_fractional_scale_manager_v1, wp_fractional_scale_v1, +use wayland_protocols::wp::pointer_gestures::zv1::client::{ + zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, }; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, @@ -61,6 +58,14 @@ use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::{ + wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, + xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1}, +}; +use wayland_protocols::{ + wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1}, + xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, +}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; @@ -71,31 +76,31 @@ use super::{ window::{ImeInput, WaylandWindowStatePtr}, }; -use crate::{ - AnyWindowHandle, Bounds, Capslock, CursorStyle, DOUBLE_CLICK_INTERVAL, DevicePixels, DisplayId, - FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon, - LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, - PlatformInput, PlatformKeyboardLayout, Point, ResultExt as _, SCROLL_LINES, ScrollDelta, - ScrollWheelEvent, Size, TouchPhase, WindowParams, point, profiler, px, size, -}; -use crate::{ - RunnableVariant, TaskTiming, - platform::{PlatformWindow, blade::BladeContext}, -}; -use crate::{ - SharedString, - platform::linux::{ - LinuxClient, get_xkb_compose_state, is_within_click_distance, open_uri_internal, read_fd, - reveal_path_internal, - wayland::{ - clipboard::{Clipboard, DataOffer, FILE_LIST_MIME_TYPE, TEXT_MIME_TYPES}, - cursor::Cursor, - serial::{SerialKind, SerialTracker}, - window::WaylandWindow, - }, - xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, +use crate::platform::linux::{ + DOUBLE_CLICK_INTERVAL, LinuxClient, LinuxCommon, LinuxKeyboardLayout, SCROLL_LINES, + capslock_from_xkb, cursor_style_to_icon_names, get_xkb_compose_state, is_within_click_distance, + keystroke_from_xkb, keystroke_underlying_dead_key, modifiers_from_xkb, open_uri_internal, + read_fd, reveal_path_internal, + wayland::{ + clipboard::{Clipboard, DataOffer, FILE_LIST_MIME_TYPE, TEXT_MIME_TYPES}, + cursor::Cursor, + serial::{SerialKind, SerialTracker}, + to_shape, + window::WaylandWindow, }, + xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, +}; +use crate::platform::wgpu::{CompositorGpuHint, GpuContext}; +use gpui::{ + AnyWindowHandle, Bounds, Capslock, CursorStyle, DevicePixels, DisplayId, FileDropEvent, + ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, + Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowParams, point, + profiler, px, size, +}; +use wayland_protocols::wp::linux_dmabuf::zv1::client::{ + zwp_linux_dmabuf_feedback_v1, zwp_linux_dmabuf_v1, }; /// Used to convert evdev scancode to xkb scancode @@ -122,6 +127,8 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub gesture_manager: Option, + pub dialog: Option, pub executor: ForegroundExecutor, } @@ -132,6 +139,7 @@ impl Globals { qh: QueueHandle, seat: wl_seat::WlSeat, ) -> Self { + let dialog_v = XdgWmDialogV1::interface().version; Globals { activation: globals.bind(&qh, 1..=1, ()).ok(), compositor: globals @@ -153,13 +161,15 @@ impl Globals { primary_selection_manager: globals.bind(&qh, 1..=1, ()).ok(), shm: globals.bind(&qh, 1..=1, ()).unwrap(), seat, - wm_base: globals.bind(&qh, 2..=5, ()).unwrap(), + wm_base: globals.bind(&qh, 1..=5, ()).unwrap(), viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), + dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, } @@ -199,9 +209,12 @@ pub struct Output { pub(crate) struct WaylandClientState { serial_tracker: SerialTracker, globals: Globals, - gpu_context: BladeContext, + pub gpu_context: GpuContext, + pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, + pinch_gesture: Option, + pinch_scale: f32, wl_keyboard: Option, cursor_shape_device: Option, data_device: Option, @@ -215,6 +228,7 @@ pub(crate) struct WaylandClientState { // Output to scale mapping outputs: HashMap, in_progress_outputs: HashMap, + wl_outputs: HashMap, keyboard_layout: LinuxKeyboardLayout, keymap_state: Option, compose_state: Option, @@ -242,7 +256,7 @@ pub(crate) struct WaylandClientState { cursor: Cursor, pending_activation: Option, event_loop: Option>, - common: LinuxCommon, + pub common: LinuxCommon, } pub struct DragState { @@ -298,7 +312,7 @@ impl WaylandClientStatePtr { pub fn enable_ime(&self) { let client = self.get_client(); let mut state = client.borrow_mut(); - let Some(mut text_input) = state.text_input.take() else { + let Some(text_input) = state.text_input.take() else { return; }; @@ -308,10 +322,10 @@ impl WaylandClientStatePtr { drop(state); if let Some(area) = window.get_ime_area() { text_input.set_cursor_rectangle( - area.origin.x.0 as i32, - area.origin.y.0 as i32, - area.size.width.0 as i32, - area.size.height.0 as i32, + f32::from(area.origin.x) as i32, + f32::from(area.origin.y) as i32, + f32::from(area.size.width) as i32, + f32::from(area.size.height) as i32, ); } state = client.borrow_mut(); @@ -332,17 +346,17 @@ impl WaylandClientStatePtr { pub fn update_ime_position(&self, bounds: Bounds) { let client = self.get_client(); - let mut state = client.borrow_mut(); + let state = client.borrow_mut(); if state.composing || state.text_input.is_none() || state.pre_edit_text.is_some() { return; } let text_input = state.text_input.as_ref().unwrap(); text_input.set_cursor_rectangle( - bounds.origin.x.0 as i32, - bounds.origin.y.0 as i32, - bounds.size.width.0 as i32, - bounds.size.height.0 as i32, + bounds.origin.x.as_f32() as i32, + bounds.origin.y.as_f32() as i32, + bounds.size.width.as_f32() as i32, + bounds.size.height.as_f32() as i32, ); text_input.commit(); } @@ -377,7 +391,7 @@ impl WaylandClientStatePtr { } pub fn drop_window(&self, surface_id: &ObjectId) { - let mut client = self.get_client(); + let client = self.get_client(); let mut state = client.borrow_mut(); let closed_window = state.windows.remove(surface_id).unwrap(); if let Some(window) = state.mouse_focused_window.take() @@ -451,13 +465,14 @@ impl WaylandClient { pub(crate) fn new() -> Self { let conn = Connection::connect_to_env().unwrap(); - let (globals, mut event_queue) = - registry_queue_init::(&conn).unwrap(); + let (globals, event_queue) = registry_queue_init::(&conn).unwrap(); let qh = event_queue.handle(); let mut seat: Option = None; #[allow(clippy::mutable_key_type)] let mut in_progress_outputs = HashMap::default(); + #[allow(clippy::mutable_key_type)] + let mut wl_outputs: HashMap = HashMap::default(); globals.contents().with_list(|list| { for global in list { match &global.interface[..] { @@ -477,6 +492,7 @@ impl WaylandClient { (), ); in_progress_outputs.insert(output.id(), InProgressOutput::default()); + wl_outputs.insert(output.id(), output); } _ => {} } @@ -495,32 +511,15 @@ impl WaylandClient { if let calloop::channel::Event::Msg(runnable) = event { handle.insert_idle(|_| { let start = Instant::now(); - let mut timing = match runnable { - RunnableVariant::Meta(runnable) => { - let location = runnable.metadata().location; - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } - RunnableVariant::Compat(runnable) => { - let location = core::panic::Location::caller(); - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, }; + profiler::add_task_timing(timing); + + runnable.run(); let end = Instant::now(); timing.end = Some(end); @@ -531,8 +530,8 @@ impl WaylandClient { }) .unwrap(); - // This could be unified with the notification handling in zed/main:fail_to_open_window. - let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); + let compositor_gpu = detect_compositor_gpu(); + let gpu_context = Rc::new(RefCell::new(None)); let seat = seat.unwrap(); let globals = Globals::new( @@ -552,7 +551,7 @@ impl WaylandClient { .as_ref() .map(|primary_selection_manager| primary_selection_manager.get_device(&seat, &qh, ())); - let mut cursor = Cursor::new(&conn, &globals, 24); + let cursor = Cursor::new(&conn, &globals, 24); handle .insert_source(XDPEventSource::new(&common.background_executor), { @@ -584,13 +583,16 @@ impl WaylandClient { }) .unwrap(); - let mut state = Rc::new(RefCell::new(WaylandClientState { + let state = Rc::new(RefCell::new(WaylandClientState { serial_tracker: SerialTracker::new(), globals, gpu_context, + compositor_gpu, wl_seat: seat, wl_pointer: None, wl_keyboard: None, + pinch_gesture: None, + pinch_scale: 1.0, cursor_shape_device: None, data_device, primary_selection, @@ -600,6 +602,7 @@ impl WaylandClient { composing: false, outputs: HashMap::default(), in_progress_outputs, + wl_outputs, windows: HashMap::default(), common, keyboard_layout: LinuxKeyboardLayout::new(UNKNOWN_KEYBOARD_LAYOUT_NAME), @@ -685,7 +688,7 @@ impl LinuxClient for WaylandClient { .outputs .iter() .find_map(|(object_id, output)| { - (object_id.protocol_id() == id.0).then(|| { + (object_id.protocol_id() == u32::from(id)).then(|| { Rc::new(WaylandDisplay { id: object_id.clone(), name: output.name.clone(), @@ -699,15 +702,10 @@ impl LinuxClient for WaylandClient { None } - #[cfg(feature = "screen-capture")] - fn is_screen_capture_supported(&self) -> bool { - false - } - #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> futures::channel::oneshot::Receiver>>> + ) -> futures::channel::oneshot::Receiver>>> { // TODO: Get screen capture working on wayland. Be sure to try window resizing as that may // be tricky. @@ -729,19 +727,29 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state - .keyboard_focused_window - .as_ref() - .and_then(|w| w.toplevel()); + 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 appearance = state.common.appearance; + let compositor_gpu = state.compositor_gpu.take(); let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), - &state.gpu_context, + state.gpu_context.clone(), + compositor_gpu, WaylandClientStatePtr(Rc::downgrade(&self.0)), params, - state.common.appearance, + appearance, parent, + target_output, )?; state.windows.insert(surface_id, window.0.clone()); @@ -751,7 +759,12 @@ impl LinuxClient for WaylandClient { fn set_cursor_style(&self, style: CursorStyle) { let mut state = self.0.borrow_mut(); - let need_update = state.cursor_style != Some(style); + let need_update = state.cursor_style != Some(style) + && (state.mouse_focused_window.is_none() + || state + .mouse_focused_window + .as_ref() + .is_some_and(|w| !w.is_blocked())); if need_update { let serial = state.serial_tracker.get(SerialKind::MouseEnter); @@ -764,7 +777,7 @@ impl LinuxClient for WaylandClient { .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, style.to_shape()); + 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 @@ -772,9 +785,12 @@ impl LinuxClient for WaylandClient { .clone() .expect("window is focused by pointer"); let scale = focused_window.primary_output_scale(); - state - .cursor - .set_icon(&wl_pointer, serial, style.to_icon_names(), scale); + state.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); } } } @@ -836,7 +852,7 @@ impl LinuxClient for WaylandClient { .log_err(); } - fn write_to_primary(&self, item: crate::ClipboardItem) { + fn write_to_primary(&self, item: gpui::ClipboardItem) { let mut state = self.0.borrow_mut(); let (Some(primary_selection_manager), Some(primary_selection)) = ( state.globals.primary_selection_manager.clone(), @@ -856,7 +872,7 @@ impl LinuxClient for WaylandClient { } } - fn write_to_clipboard(&self, item: crate::ClipboardItem) { + fn write_to_clipboard(&self, item: gpui::ClipboardItem) { let mut state = self.0.borrow_mut(); let (Some(data_device_manager), Some(data_device)) = ( state.globals.data_device_manager.clone(), @@ -876,11 +892,11 @@ impl LinuxClient for WaylandClient { } } - fn read_from_primary(&self) -> Option { + fn read_from_primary(&self) -> Option { self.0.borrow_mut().clipboard.read_primary() } - fn read_from_clipboard(&self) -> Option { + fn read_from_clipboard(&self) -> Option { self.0.borrow_mut().clipboard.read() } @@ -915,6 +931,70 @@ impl LinuxClient for WaylandClient { } } +struct DmabufProbeState { + device: Option, +} + +impl Dispatch for DmabufProbeState { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for DmabufProbeState { + fn event( + _: &mut Self, + _: &zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1, + _: zwp_linux_dmabuf_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for DmabufProbeState { + fn event( + state: &mut Self, + _: &zwp_linux_dmabuf_feedback_v1::ZwpLinuxDmabufFeedbackV1, + event: zwp_linux_dmabuf_feedback_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } = event { + if let Ok(bytes) = <[u8; 8]>::try_from(device.as_slice()) { + state.device = Some(u64::from_ne_bytes(bytes)); + } + } + } +} + +fn detect_compositor_gpu() -> Option { + let connection = Connection::connect_to_env().ok()?; + let (globals, mut event_queue) = registry_queue_init::(&connection).ok()?; + let queue_handle = event_queue.handle(); + + let dmabuf: zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1 = + globals.bind(&queue_handle, 4..=4, ()).ok()?; + let feedback = dmabuf.get_default_feedback(&queue_handle, ()); + + let mut state = DmabufProbeState { device: None }; + + event_queue.roundtrip(&mut state).ok()?; + + feedback.destroy(); + dmabuf.destroy(); + + crate::platform::linux::compositor_gpu_hint_from_dev_t(state.device?) +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, @@ -924,7 +1004,7 @@ impl Dispatch for WaylandClientStat _: &Connection, qh: &QueueHandle, ) { - let mut client = this.get_client(); + let client = this.get_client(); let mut state = client.borrow_mut(); match event { @@ -959,6 +1039,7 @@ impl Dispatch for WaylandClientStat state .in_progress_outputs .insert(output.id(), InProgressOutput::default()); + state.wl_outputs.insert(output.id(), output); } _ => {} }, @@ -1011,8 +1092,8 @@ impl Dispatch for WaylandClientStatePtr { } } -fn get_window( - mut state: &mut RefMut, +pub(crate) fn get_window( + state: &mut RefMut, surface_id: &ObjectId, ) -> Option { state.windows.get(surface_id).cloned() @@ -1027,7 +1108,7 @@ impl Dispatch for WaylandClientStatePtr { _: &Connection, _: &QueueHandle, ) { - let mut client = this.get_client(); + let client = this.get_client(); let mut state = client.borrow_mut(); let Some(window) = get_window(&mut state, &surface.id()) else { @@ -1050,10 +1131,10 @@ impl Dispatch for WaylandClientStatePtr { _: &Connection, _: &QueueHandle, ) { - let mut client = this.get_client(); + let client = this.get_client(); let mut state = client.borrow_mut(); - let Some(mut in_progress_output) = state.in_progress_outputs.get_mut(&output.id()) else { + let Some(in_progress_output) = state.in_progress_outputs.get_mut(&output.id()) else { return; }; @@ -1217,6 +1298,12 @@ impl Dispatch for WaylandClientStatePtr { if capabilities.contains(wl_seat::Capability::Keyboard) { let keyboard = seat.get_keyboard(qh, ()); + if let Some(text_input) = state.text_input.take() { + text_input.destroy(); + state.ime_pre_edit = None; + state.composing = false; + } + state.text_input = state .globals .text_input_manager @@ -1231,12 +1318,23 @@ impl Dispatch for WaylandClientStatePtr { } if capabilities.contains(wl_seat::Capability::Pointer) { let pointer = seat.get_pointer(qh, ()); + + if let Some(cursor_shape_device) = state.cursor_shape_device.take() { + cursor_shape_device.destroy(); + } + state.cursor_shape_device = state .globals .cursor_shape_manager .as_ref() .map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ())); + state.pinch_gesture = state.globals.gesture_manager.as_ref().map( + |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| { + gesture_manager.get_pinch_gesture(&pointer, qh, ()) + }, + ); + if let Some(wl_pointer) = &state.wl_pointer { wl_pointer.release(); } @@ -1256,7 +1354,7 @@ impl Dispatch for WaylandClientStatePtr { _: &Connection, _: &QueueHandle, ) { - let mut client = this.get_client(); + let client = this.get_client(); let mut state = client.borrow_mut(); match event { wl_keyboard::Event::RepeatInfo { rate, delay } => { @@ -1331,9 +1429,9 @@ impl Dispatch for WaylandClientStatePtr { let old_layout = keymap_state.serialize_layout(xkbcommon::xkb::STATE_LAYOUT_EFFECTIVE); keymap_state.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group); - state.modifiers = Modifiers::from_xkb(keymap_state); + state.modifiers = modifiers_from_xkb(keymap_state); let keymap_state = state.keymap_state.as_mut().unwrap(); - state.capslock = Capslock::from_xkb(keymap_state); + state.capslock = capslock_from_xkb(keymap_state); let input = PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers: state.modifiers, @@ -1369,14 +1467,14 @@ impl Dispatch for WaylandClientStatePtr { match key_state { wl_keyboard::KeyState::Pressed if !keysym.is_modifier_key() => { let mut keystroke = - Keystroke::from_xkb(keymap_state, state.modifiers, keycode); + keystroke_from_xkb(keymap_state, state.modifiers, keycode); if let Some(mut compose) = state.compose_state.take() { compose.feed(keysym); match compose.status() { xkb::Status::Composing => { keystroke.key_char = None; state.pre_edit_text = - compose.utf8().or(Keystroke::underlying_dead_key(keysym)); + compose.utf8().or(keystroke_underlying_dead_key(keysym)); let pre_edit = state.pre_edit_text.clone().unwrap_or(String::default()); drop(state); @@ -1393,7 +1491,7 @@ impl Dispatch for WaylandClientStatePtr { } xkb::Status::Cancelled => { let pre_edit = state.pre_edit_text.take(); - let new_pre_edit = Keystroke::underlying_dead_key(keysym); + let new_pre_edit = keystroke_underlying_dead_key(keysym); state.pre_edit_text = new_pre_edit.clone(); drop(state); if let Some(pre_edit) = pre_edit { @@ -1431,8 +1529,8 @@ impl Dispatch for WaylandClientStatePtr { prefer_character_input: false, }); move |event_timestamp, _metadata, this| { - let mut client = this.get_client(); - let mut state = client.borrow_mut(); + let client = this.get_client(); + let state = client.borrow(); let is_repeating = id == state.repeat.current_id && state.repeat.current_keycode.is_some() && state.keyboard_focused_window.is_some(); @@ -1458,7 +1556,7 @@ impl Dispatch for WaylandClientStatePtr { } wl_keyboard::KeyState::Released if !keysym.is_modifier_key() => { let input = PlatformInput::KeyUp(KeyUpEvent { - keystroke: Keystroke::from_xkb(keymap_state, state.modifiers, keycode), + keystroke: keystroke_from_xkb(keymap_state, state.modifiers, keycode), }); if state.repeat.current_keycode == Some(keycode) { @@ -1537,10 +1635,10 @@ impl Dispatch for WaylandClientStatePtr { window.handle_ime(ImeInput::SetMarkedText(text)); if let Some(area) = window.get_ime_area() { text_input.set_cursor_rectangle( - area.origin.x.0 as i32, - area.origin.y.0 as i32, - area.size.width.0 as i32, - area.size.height.0 as i32, + f32::from(area.origin.x) as i32, + f32::from(area.origin.y) as i32, + f32::from(area.size.width) as i32, + f32::from(area.size.height) as i32, ); if last_serial == serial { text_input.commit(); @@ -1586,7 +1684,7 @@ impl Dispatch for WaylandClientStatePtr { _: &Connection, _: &QueueHandle, ) { - let mut client = this.get_client(); + let client = this.get_client(); let mut state = client.borrow_mut(); match event { @@ -1615,12 +1713,15 @@ impl Dispatch for WaylandClientStatePtr { .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, style.to_shape()); + cursor_shape_device.set_shape(serial, to_shape(style)); } else { let scale = window.primary_output_scale(); - state - .cursor - .set_icon(wl_pointer, serial, style.to_icon_names(), scale); + state.cursor.set_icon( + wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); } } drop(state); @@ -1654,6 +1755,30 @@ impl Dispatch for WaylandClientStatePtr { state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); if let Some(window) = state.mouse_focused_window.clone() { + if window.is_blocked() { + let default_style = CursorStyle::Arrow; + if state.cursor_style != Some(default_style) { + let serial = state.serial_tracker.get(SerialKind::MouseEnter); + state.cursor_style = Some(default_style); + + if let Some(cursor_shape_device) = &state.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(default_style)); + } else { + // 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 = window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(default_style), + scale, + ); + } + } + } if state .keyboard_focused_window .as_ref() @@ -1883,6 +2008,91 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + _this: &mut Self, + _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The gesture manager doesn't generate events + } +} + +impl Dispatch + for WaylandClientStatePtr +{ + fn event( + this: &mut Self, + _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use gpui::PinchEvent; + + let client = this.get_client(); + let mut state = client.borrow_mut(); + + let Some(window) = state.mouse_focused_window.clone() else { + return; + }; + + match event { + zwp_pointer_gesture_pinch_v1::Event::Begin { + serial: _, + time: _, + surface: _, + fingers: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Started, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => { + let new_absolute_scale = scale as f32; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: zoom_delta, + modifiers: state.modifiers, + phase: TouchPhase::Moved, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::End { + serial: _, + time: _, + cancelled: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Ended, + }); + drop(state); + window.handle_input(input); + } + _ => {} + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, @@ -2018,7 +2228,7 @@ impl Dispatch for WaylandClientStatePtr { let input = PlatformInput::FileDrop(FileDropEvent::Entered { position, - paths: crate::ExternalPaths(paths), + paths: gpui::ExternalPaths(paths), }); let client = this.get_client(); @@ -2126,7 +2336,7 @@ impl Dispatch for WaylandClientStatePtr { _: &QueueHandle, ) { let client = this.get_client(); - let mut state = client.borrow_mut(); + let state = client.borrow_mut(); match event { wl_data_source::Event::Send { mime_type, fd } => { @@ -2212,7 +2422,7 @@ impl Dispatch _: &QueueHandle, ) { let client = this.get_client(); - let mut state = client.borrow_mut(); + let state = client.borrow_mut(); match event { zwp_primary_selection_source_v1::Event::Send { mime_type, fd } => { @@ -2225,3 +2435,27 @@ impl Dispatch } } } + +impl Dispatch for WaylandClientStatePtr { + fn event( + _: &mut Self, + _: &XdgWmDialogV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + _state: &mut Self, + _proxy: &XdgDialogV1, + _event: ::Event, + _data: &(), + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} diff --git a/src/platform/linux/wayland/clipboard.rs b/src/platform/linux/wayland/clipboard.rs index 9d58ad7391..a04940a25c 100644 --- a/src/platform/linux/wayland/clipboard.rs +++ b/src/platform/linux/wayland/clipboard.rs @@ -10,10 +10,8 @@ use strum::IntoEnumIterator; use wayland_client::{Connection, protocol::wl_data_offer::WlDataOffer}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1; -use crate::{ - ClipboardEntry, ClipboardItem, Image, ImageFormat, WaylandClientStatePtr, hash, - platform::linux::platform::read_fd, -}; +use crate::platform::linux::{WaylandClientStatePtr, platform::read_fd}; +use gpui::{ClipboardEntry, ClipboardItem, Image, ImageFormat, hash}; /// Text mime types that we'll offer to other programs. pub(crate) const TEXT_MIME_TYPES: [&str; 3] = @@ -241,7 +239,7 @@ impl Clipboard { calloop::Mode::Level, ), move |_, file, _| { - let mut file = unsafe { file.get_mut() }; + let file = unsafe { file.get_mut() }; loop { match file.write(&bytes[written..]) { Ok(n) if written + n == bytes.len() => { diff --git a/src/platform/linux/wayland/cursor.rs b/src/platform/linux/wayland/cursor.rs index c7c9139dea..2efa17ab01 100644 --- a/src/platform/linux/wayland/cursor.rs +++ b/src/platform/linux/wayland/cursor.rs @@ -1,4 +1,4 @@ -use crate::Globals; +use crate::platform::linux::Globals; use crate::platform::linux::{DEFAULT_CURSOR_ICON_NAME, log_cursor_icon_warning}; use anyhow::{Context as _, anyhow}; use util::ResultExt; @@ -95,7 +95,7 @@ impl Cursor { &mut self, wl_pointer: &WlPointer, serial_id: u32, - mut cursor_icon_names: &[&str], + cursor_icon_names: &[&str], scale: i32, ) { self.set_scaled_size(self.size * scale as u32); @@ -104,9 +104,9 @@ impl Cursor { log::warn!("Wayland: Unable to load cursor themes"); return; }; - let mut theme = &mut loaded_theme.theme; + let theme = &mut loaded_theme.theme; - let mut buffer: &CursorImageBuffer; + let buffer: &CursorImageBuffer; 'outer: { for cursor_icon_name in cursor_icon_names { if let Some(cursor) = theme.get_cursor(cursor_icon_name) { diff --git a/src/platform/linux/wayland/display.rs b/src/platform/linux/wayland/display.rs index c3d2fc9815..874cae8783 100644 --- a/src/platform/linux/wayland/display.rs +++ b/src/platform/linux/wayland/display.rs @@ -7,7 +7,7 @@ use anyhow::Context as _; use uuid::Uuid; use wayland_backend::client::ObjectId; -use crate::{Bounds, DisplayId, Pixels, PlatformDisplay}; +use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay}; #[derive(Debug, Clone)] pub(crate) struct WaylandDisplay { @@ -25,7 +25,7 @@ impl Hash for WaylandDisplay { impl PlatformDisplay for WaylandDisplay { fn id(&self) -> DisplayId { - DisplayId(self.id.protocol_id()) + DisplayId::new(self.id.protocol_id()) } fn uuid(&self) -> anyhow::Result { diff --git a/src/platform/linux/wayland/layer_shell.rs b/src/platform/linux/wayland/layer_shell.rs index 0f165ed8e0..a400552065 100644 --- a/src/platform/linux/wayland/layer_shell.rs +++ b/src/platform/linux/wayland/layer_shell.rs @@ -1,111 +1,26 @@ -use bitflags::bitflags; -use thiserror::Error; +pub use gpui::layer_shell::*; + use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; -use crate::Pixels; - -/// The layer the surface is rendered on. Multiple surfaces can share a layer, and ordering within -/// a single layer is undefined. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] -pub enum Layer { - /// The background layer, typically used for wallpapers. - Background, - - /// The bottom layer. - Bottom, - - /// The top layer, typically used for fullscreen windows. - Top, - - /// The overlay layer, used for surfaces that should always be on top. - #[default] - Overlay, -} - -impl From for zwlr_layer_shell_v1::Layer { - fn from(layer: Layer) -> Self { - match layer { - Layer::Background => Self::Background, - Layer::Bottom => Self::Bottom, - Layer::Top => Self::Top, - Layer::Overlay => Self::Overlay, - } +pub(crate) fn wayland_layer(layer: Layer) -> zwlr_layer_shell_v1::Layer { + match layer { + Layer::Background => zwlr_layer_shell_v1::Layer::Background, + Layer::Bottom => zwlr_layer_shell_v1::Layer::Bottom, + Layer::Top => zwlr_layer_shell_v1::Layer::Top, + Layer::Overlay => zwlr_layer_shell_v1::Layer::Overlay, } } -bitflags! { - /// Screen anchor point for layer_shell surfaces. These can be used in any combination, e.g. - /// specifying `Anchor::LEFT | Anchor::RIGHT` will stretch the surface across the width of the - /// screen. - #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] - pub struct Anchor: u32 { - /// Anchor to the top edge of the screen. - const TOP = 1; - /// Anchor to the bottom edge of the screen. - const BOTTOM = 2; - /// Anchor to the left edge of the screen. - const LEFT = 4; - /// Anchor to the right edge of the screen. - const RIGHT = 8; +pub(crate) fn wayland_anchor(anchor: Anchor) -> zwlr_layer_surface_v1::Anchor { + zwlr_layer_surface_v1::Anchor::from_bits_truncate(anchor.bits()) +} + +pub(crate) fn wayland_keyboard_interactivity( + value: KeyboardInteractivity, +) -> zwlr_layer_surface_v1::KeyboardInteractivity { + match value { + KeyboardInteractivity::None => zwlr_layer_surface_v1::KeyboardInteractivity::None, + KeyboardInteractivity::Exclusive => zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive, + KeyboardInteractivity::OnDemand => zwlr_layer_surface_v1::KeyboardInteractivity::OnDemand, } } - -impl From for zwlr_layer_surface_v1::Anchor { - fn from(anchor: Anchor) -> Self { - Self::from_bits_truncate(anchor.bits()) - } -} - -/// Keyboard interactivity mode for the layer_shell surfaces. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] -pub enum KeyboardInteractivity { - /// No keyboard inputs will be delivered to the surface and it won't be able to receive - /// keyboard focus. - None, - - /// The surface will receive exclusive keyboard focus as long as it is above the shell surface - /// layer, and no other layer_shell surfaces are above it. - Exclusive, - - /// The surface can be focused similarly to a normal window. - #[default] - OnDemand, -} - -impl From for zwlr_layer_surface_v1::KeyboardInteractivity { - fn from(value: KeyboardInteractivity) -> Self { - match value { - KeyboardInteractivity::None => Self::None, - KeyboardInteractivity::Exclusive => Self::Exclusive, - KeyboardInteractivity::OnDemand => Self::OnDemand, - } - } -} - -/// Options for creating a layer_shell window. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct LayerShellOptions { - /// The namespace for the surface, mostly used by compositors to apply rules, can not be - /// changed after the surface is created. - pub namespace: String, - /// The layer the surface is rendered on. - pub layer: Layer, - /// The anchor point of the surface. - pub anchor: Anchor, - /// Requests that the compositor avoids occluding an area with other surfaces. - pub exclusive_zone: Option, - /// The anchor point of the exclusive zone, will be determined using the anchor if left - /// unspecified. - pub exclusive_edge: Option, - /// Margins between the surface and its anchor point(s). - /// Specified in CSS order: top, right, bottom, left. - pub margin: Option<(Pixels, Pixels, Pixels, Pixels)>, - /// How keyboard events should be delivered to the surface. - pub keyboard_interactivity: KeyboardInteractivity, -} - -/// An error indicating that an action failed because the compositor doesn't support the required -/// layer_shell protocol. -#[derive(Debug, Error)] -#[error("Compositor doesn't support zwlr_layer_shell_v1")] -pub struct LayerShellNotSupportedError; diff --git a/src/platform/linux/wayland/window.rs b/src/platform/linux/wayland/window.rs index 3334ae28a3..ee122a8bb9 100644 --- a/src/platform/linux/wayland/window.rs +++ b/src/platform/linux/wayland/window.rs @@ -6,46 +6,43 @@ use std::{ sync::Arc, }; -use blade_graphics as gpu; -use collections::HashMap; +use collections::{FxHashSet, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; use wayland_backend::client::ObjectId; use wayland_client::WEnum; -use wayland_client::{Proxy, protocol::wl_surface}; +use wayland_client::{ + Proxy, + protocol::{wl_output, wl_surface}, +}; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols::{ wp::fractional_scale::v1::client::wp_fractional_scale_v1, - xdg::shell::client::xdg_toplevel::XdgToplevel, + xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, }; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; -use crate::{ - AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels, - PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, - ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, - layer_shell::LayerShellNotSupportedError, px, size, +use crate::platform::linux::wayland::{display::WaylandDisplay, serial::SerialKind}; +use crate::platform::linux::{Globals, Output, WaylandClientStatePtr, get_window}; +use crate::platform::wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; +use gpui::{ + AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, GpuSpecs, Modifiers, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, + WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, + size, }; -use crate::{ - Capslock, - platform::{ - PlatformAtlas, PlatformInputHandler, PlatformWindow, - blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}, - linux::wayland::{display::WaylandDisplay, serial::SerialKind}, - }, -}; -use crate::{WindowKind, scene::Scene}; #[derive(Default)] pub(crate) struct Callbacks { request_frame: Option>, - input: Option crate::DispatchEventResult>>, + input: Option gpui::DispatchEventResult>>, active_status_change: Option>, hover_status_change: Option>, resize: Option, f32)>>, @@ -55,11 +52,18 @@ pub(crate) struct Callbacks { appearance_changed: Option>, } +#[derive(Debug, Clone, Copy)] struct RawWindow { window: *mut c_void, display: *mut c_void, } +// Safety: The raw pointers in RawWindow point to Wayland surface/display +// which are valid for the window's lifetime. These are used only for +// passing to wgpu which needs Send+Sync for surface creation. +unsafe impl Send for RawWindow {} +unsafe impl Sync for RawWindow {} + impl rwh::HasWindowHandle for RawWindow { fn window_handle(&self) -> Result, rwh::HandleError> { let window = NonNull::new(self.window).unwrap(); @@ -87,6 +91,8 @@ struct InProgressConfigure { pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, + parent: Option, + children: FxHashSet, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -95,7 +101,7 @@ pub struct WaylandWindowState { outputs: HashMap, display: Option<(ObjectId, Output)>, globals: Globals, - renderer: BladeRenderer, + renderer: WgpuRenderer, bounds: Bounds, scale: f32, input_handler: Option, @@ -126,7 +132,8 @@ impl WaylandSurfaceState { surface: &wl_surface::WlSurface, globals: &Globals, params: &WindowParams, - parent: Option, + parent: Option, + target_output: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface if let WindowKind::LayerShell(options) = ¶ms.kind { @@ -136,35 +143,38 @@ impl WaylandSurfaceState { let layer_surface = layer_shell.get_layer_surface( &surface, - None, - options.layer.into(), + target_output.as_ref(), + super::layer_shell::wayland_layer(options.layer), options.namespace.clone(), &globals.qh, surface.id(), ); - let width = params.bounds.size.width.0; - let height = params.bounds.size.height.0; + let width = f32::from(params.bounds.size.width); + let height = f32::from(params.bounds.size.height); layer_surface.set_size(width as u32, height as u32); - layer_surface.set_anchor(options.anchor.into()); - layer_surface.set_keyboard_interactivity(options.keyboard_interactivity.into()); + layer_surface.set_anchor(super::layer_shell::wayland_anchor(options.anchor)); + layer_surface.set_keyboard_interactivity( + super::layer_shell::wayland_keyboard_interactivity(options.keyboard_interactivity), + ); if let Some(margin) = options.margin { layer_surface.set_margin( - margin.0.0 as i32, - margin.1.0 as i32, - margin.2.0 as i32, - margin.3.0 as i32, + f32::from(margin.0) as i32, + f32::from(margin.1) as i32, + f32::from(margin.2) as i32, + f32::from(margin.3) as i32, ) } if let Some(exclusive_zone) = options.exclusive_zone { - layer_surface.set_exclusive_zone(exclusive_zone.0 as i32); + layer_surface.set_exclusive_zone(f32::from(exclusive_zone) as i32); } if let Some(exclusive_edge) = options.exclusive_edge { - layer_surface.set_exclusive_edge(exclusive_edge.into()); + layer_surface + .set_exclusive_edge(super::layer_shell::wayland_anchor(exclusive_edge)); } return Ok(WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { @@ -178,12 +188,30 @@ impl WaylandSurfaceState { .get_xdg_surface(&surface, &globals.qh, surface.id()); let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); - if params.kind == WindowKind::Floating { - toplevel.set_parent(parent.as_ref()); + let xdg_parent = parent.as_ref().and_then(|w| w.toplevel()); + + if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog { + toplevel.set_parent(xdg_parent.as_ref()); } + let dialog = if params.kind == WindowKind::Dialog { + let dialog = globals.dialog.as_ref().map(|dialog| { + let xdg_dialog = dialog.get_xdg_dialog(&toplevel, &globals.qh, ()); + xdg_dialog.set_modal(); + xdg_dialog + }); + + if let Some(parent) = parent.as_ref() { + parent.add_child(surface.id()); + } + + dialog + } else { + None + }; + if let Some(size) = params.window_min_size { - toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); + toplevel.set_min_size(f32::from(size.width) as i32, f32::from(size.height) as i32); } // Attempt to set up window decorations based on the requested configuration @@ -198,6 +226,7 @@ impl WaylandSurfaceState { xdg_surface, toplevel, decoration, + dialog, })) } } @@ -206,6 +235,7 @@ pub struct WaylandXdgSurfaceState { xdg_surface: xdg_surface::XdgSurface, toplevel: xdg_toplevel::XdgToplevel, decoration: Option, + dialog: Option, } pub struct WaylandLayerSurfaceState { @@ -258,7 +288,13 @@ impl WaylandSurfaceState { xdg_surface, toplevel, decoration: _decoration, + dialog, }) => { + // drop the dialog before toplevel so compositor can explicitly unapply it's effects + if let Some(dialog) = dialog { + dialog.destroy(); + } + // The role object (toplevel) must always be destroyed before the xdg_surface. // See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy toplevel.destroy(); @@ -286,8 +322,10 @@ impl WaylandWindowState { viewport: Option, client: WaylandClientStatePtr, globals: Globals, - gpu_context: &BladeContext, + gpu_context: crate::platform::wgpu::GpuContext, + compositor_gpu: Option, options: WindowParams, + parent: Option, ) -> anyhow::Result { let renderer = { let raw_window = RawWindow { @@ -299,26 +337,33 @@ impl WaylandWindowState { .display_ptr() .cast::(), }; - let config = BladeSurfaceConfig { - size: gpu::Extent { - width: options.bounds.size.width.0 as u32, - height: options.bounds.size.height.0 as u32, - depth: 1, + let config = WgpuSurfaceConfig { + size: Size { + width: DevicePixels(f32::from(options.bounds.size.width) as i32), + height: DevicePixels(f32::from(options.bounds.size.height) as i32), }, transparent: true, }; - BladeRenderer::new(gpu_context, &raw_window, config)? + WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { xdg_state.toplevel.set_title(title.to_string()); } + // Set max window size based on the GPU's maximum texture dimension. + // This prevents the window from being resized larger than what the GPU can render. + let max_texture_size = renderer.max_texture_size() as i32; + xdg_state + .toplevel + .set_max_size(max_texture_size, max_texture_size); } Ok(Self { surface_state, acknowledged_first_configure: false, + parent, + children: FxHashSet::default(), surface, app_id: None, blur: None, @@ -391,6 +436,10 @@ impl Drop for WaylandWindow { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); let surface_id = state.surface.id(); + if let Some(parent) = state.parent.as_ref() { + parent.state.borrow_mut().children.remove(&surface_id); + } + let client = state.client.clone(); state.renderer.destroy(); @@ -444,14 +493,17 @@ impl WaylandWindow { pub fn new( handle: AnyWindowHandle, globals: Globals, - gpu_context: &BladeContext, + gpu_context: crate::platform::wgpu::GpuContext, + compositor_gpu: Option, client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, - parent: Option, + parent: Option, + target_output: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent)?; + let surface_state = + WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -472,7 +524,9 @@ impl WaylandWindow { client, globals, gpu_context, + compositor_gpu, params, + parent, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), }); @@ -501,6 +555,16 @@ impl WaylandWindowStatePtr { Rc::ptr_eq(&self.state, &other.state) } + pub fn add_child(&self, child: ObjectId) { + let mut state = self.state.borrow_mut(); + state.children.insert(child); + } + + pub fn is_blocked(&self) -> bool { + let state = self.state.borrow(); + !state.children.is_empty() + } + pub fn frame(&self) { let mut state = self.state.borrow_mut(); state.surface.frame(&state.globals.qh, state.surface.id()); @@ -537,6 +601,7 @@ impl WaylandWindowStatePtr { state.tiling = configure.tiling; // Limit interactive resizes to once per vblank if configure.resizing && state.resize_throttle { + state.surface_state.ack_configure(serial); return; } else if configure.resizing { state.resize_throttle = true; @@ -568,7 +633,7 @@ impl WaylandWindowStatePtr { state.inset(), state.tiling, ) - .map(|v| v.0 as i32) + .map(|v| f32::from(v) as i32) .map_size(|v| if v <= 0 { 1 } else { v }); state.surface_state.set_geometry( @@ -592,19 +657,19 @@ impl WaylandWindowStatePtr { match mode { WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => { self.state.borrow_mut().decorations = WindowDecorations::Server; - if let Some(mut appearance_changed) = - self.callbacks.borrow_mut().appearance_changed.as_mut() - { - appearance_changed(); + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ClientSide) => { self.state.borrow_mut().decorations = WindowDecorations::Client; // Update background to be transparent - if let Some(mut appearance_changed) = - self.callbacks.borrow_mut().appearance_changed.as_mut() - { - appearance_changed(); + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } WEnum::Value(_) => { @@ -630,7 +695,7 @@ impl WaylandWindowStatePtr { height, states, } => { - let mut size = if width == 0 || height == 0 { + let size = if width == 0 || height == 0 { None } else { Some(size(px(width as f32), px(height as f32))) @@ -737,7 +802,7 @@ impl WaylandWindowStatePtr { height, serial, } => { - let mut size = if width == 0 || height == 0 { + let size = if width == 0 || height == 0 { None } else { Some(size(px(width as f32), px(height as f32))) @@ -818,6 +883,9 @@ impl WaylandWindowStatePtr { } pub fn handle_ime(&self, ime: ImeInput) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -873,14 +941,17 @@ impl WaylandWindowStatePtr { (state.bounds.size, state.scale) }; - if let Some(ref mut fun) = self.callbacks.borrow_mut().resize { + let callback = self.callbacks.borrow_mut().resize.take(); + if let Some(mut fun) = callback { fun(size, scale); + self.callbacks.borrow_mut().resize = Some(fun); } { let state = self.state.borrow(); if let Some(viewport) = &state.viewport { - viewport.set_destination(size.width.0 as i32, size.height.0 as i32); + viewport + .set_destination(f32::from(size.width) as i32, f32::from(size.height) as i32); } } } @@ -894,6 +965,21 @@ impl WaylandWindowStatePtr { } pub fn close(&self) { + let state = self.state.borrow(); + let client = state.client.get_client(); + #[allow(clippy::mutable_key_type)] + let children = state.children.clone(); + drop(state); + + for child in children { + let mut client_state = client.borrow_mut(); + let window = get_window(&mut client_state, &child); + drop(client_state); + + if let Some(child) = window { + child.close(); + } + } let mut callbacks = self.callbacks.borrow_mut(); if let Some(fun) = callbacks.close.take() { fun() @@ -901,11 +987,17 @@ impl WaylandWindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().input - && !fun(input.clone()).propagate - { + if self.is_blocked() { return; } + let callback = self.callbacks.borrow_mut().input.take(); + if let Some(mut fun) = callback { + let result = fun(input.clone()); + self.callbacks.borrow_mut().input = Some(fun); + if !result.propagate { + return; + } + } if let PlatformInput::KeyDown(event) = input && event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) && let Some(key_char) = &event.keystroke.key_char @@ -921,23 +1013,28 @@ impl WaylandWindowStatePtr { pub fn set_focused(&self, focus: bool) { self.state.borrow_mut().active = focus; - if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { + let callback = self.callbacks.borrow_mut().active_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().active_status_change = Some(fun); } } pub fn set_hovered(&self, focus: bool) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().hover_status_change { + let callback = self.callbacks.borrow_mut().hover_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().hover_status_change = Some(fun); } } pub fn set_appearance(&mut self, appearance: WindowAppearance) { self.state.borrow_mut().appearance = appearance; - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(ref mut fun) = callbacks.appearance_changed { - (fun)() + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } @@ -1025,13 +1122,26 @@ impl PlatformWindow for WaylandWindow { fn resize(&mut self, size: Size) { let state = self.borrow(); let state_ptr = self.0.clone(); - let dp_size = size.to_device_pixels(self.scale_factor()); + + // Keep window geometry consistent with configure handling. On Wayland, window geometry is + // surface-local: resizing should not attempt to translate the window; the compositor + // controls placement. We also account for client-side decoration insets and tiling. + let window_geometry = inset_by_tiling( + Bounds { + origin: Point::default(), + size, + }, + state.inset(), + state.tiling, + ) + .map(|v| f32::from(v) as i32) + .map_size(|v| if v <= 0 { 1 } else { v }); state.surface_state.set_geometry( - state.bounds.origin.x.0 as i32, - state.bounds.origin.y.0 as i32, - dp_size.width.0, - dp_size.height.0, + window_geometry.origin.x, + window_geometry.origin.y, + window_geometry.size.width, + window_geometry.size.height, ); state @@ -1140,6 +1250,20 @@ impl PlatformWindow for WaylandWindow { update_window(state); } + fn background_appearance(&self) -> WindowBackgroundAppearance { + self.borrow().background_appearance + } + + fn is_subpixel_rendering_supported(&self) -> bool { + let client = self.borrow().client.get_client(); + let state = client.borrow(); + state + .gpu_context + .borrow() + .as_ref() + .is_some_and(|ctx| ctx.supports_dual_source_blending()) + } + fn minimize(&self) { if let Some(toplevel) = self.borrow().surface_state.toplevel() { toplevel.set_minimized(); @@ -1158,7 +1282,7 @@ impl PlatformWindow for WaylandWindow { } fn toggle_fullscreen(&self) { - let mut state = self.borrow(); + let state = self.borrow(); if let Some(toplevel) = state.surface_state.toplevel() { if !state.fullscreen { toplevel.set_fullscreen(None); @@ -1176,7 +1300,7 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().request_frame = Some(callback); } - fn on_input(&self, callback: Box crate::DispatchEventResult>) { + fn on_input(&self, callback: Box gpui::DispatchEventResult>) { self.0.callbacks.borrow_mut().input = Some(callback); } @@ -1213,6 +1337,31 @@ impl PlatformWindow for WaylandWindow { fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); + + if state.renderer.device_lost() { + let raw_window = RawWindow { + window: state.surface.id().as_ptr().cast::(), + display: state + .surface + .backend() + .upgrade() + .unwrap() + .display_ptr() + .cast::(), + }; + state.renderer.recover(&raw_window).unwrap_or_else(|err| { + panic!( + "GPU device lost and recovery failed. \ + This may happen after system suspend/resume. \ + Please restart the application.\n\nError: {err}" + ) + }); + + // The current scene references atlas textures that were cleared during recovery. + // Skip this frame and let the next frame rebuild the scene with fresh textures. + return; + } + state.renderer.draw(scene); } @@ -1233,8 +1382,8 @@ impl PlatformWindow for WaylandWindow { toplevel.show_window_menu( &state.globals.seat, serial, - position.x.0 as i32, - position.y.0 as i32, + f32::from(position.x) as i32, + f32::from(position.y) as i32, ); } } @@ -1247,7 +1396,7 @@ impl PlatformWindow for WaylandWindow { } } - fn start_window_resize(&self, edge: crate::ResizeEdge) { + fn start_window_resize(&self, edge: gpui::ResizeEdge) { let state = self.borrow(); if let Some(toplevel) = state.surface_state.toplevel() { toplevel.resize( @@ -1314,8 +1463,8 @@ fn update_window(mut state: RefMut) { let opaque = !state.is_transparent(); state.renderer.update_transparency(!opaque); - let mut opaque_area = state.window_bounds.map(|v| v.0 as i32); - opaque_area.inset(state.inset().0 as i32); + let opaque_area = state.window_bounds.map(|v| f32::from(v) as i32); + opaque_area.inset(f32::from(state.inset()) as i32); let region = state .globals @@ -1360,7 +1509,11 @@ fn update_window(mut state: RefMut) { region.destroy(); } -impl WindowDecorations { +pub(crate) trait WindowDecorationsExt { + fn to_xdg(self) -> zxdg_toplevel_decoration_v1::Mode; +} + +impl WindowDecorationsExt for WindowDecorations { fn to_xdg(self) -> zxdg_toplevel_decoration_v1::Mode { match self { WindowDecorations::Client => zxdg_toplevel_decoration_v1::Mode::ClientSide, @@ -1369,7 +1522,11 @@ impl WindowDecorations { } } -impl ResizeEdge { +pub(crate) trait ResizeEdgeWaylandExt { + fn to_xdg(self) -> xdg_toplevel::ResizeEdge; +} + +impl ResizeEdgeWaylandExt for ResizeEdge { fn to_xdg(self) -> xdg_toplevel::ResizeEdge { match self { ResizeEdge::Top => xdg_toplevel::ResizeEdge::Top, diff --git a/src/platform/linux/x11/client.rs b/src/platform/linux/x11/client.rs index 60400dada5..824ba161e9 100644 --- a/src/platform/linux/x11/client.rs +++ b/src/platform/linux/x11/client.rs @@ -1,4 +1,3 @@ -use crate::{Capslock, ResultExt as _, RunnableVariant, TaskTiming, profiler, xcb_flush}; use anyhow::{Context as _, anyhow}; use ashpd::WindowIdentifier; use calloop::{ @@ -7,6 +6,7 @@ use calloop::{ }; use collections::HashMap; use core::str; +use gpui::{Capslock, TaskTiming, profiler}; use http_client::Url; use log::Level; use smallvec::SmallVec; @@ -29,9 +29,9 @@ use x11rb::{ protocol::xkb::ConnectionExt as _, protocol::xproto::{ AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent, - ConnectionExt as _, EventMask, Visibility, + ConnectionExt as _, EventMask, ModMask, Visibility, }, - protocol::{Event, randr, render, xinput, xkb, xproto}, + protocol::{Event, dri3, randr, render, xinput, xkb, xproto}, resource_manager::Database, wrapper::ConnectionExt as _, xcb_ffi::XCBConnection, @@ -45,25 +45,27 @@ use super::{ XimHandler, button_or_scroll_from_event_detail, check_reply, clipboard::{self, Clipboard}, get_reply, get_valuator_axis_index, handle_connection_error, modifiers_from_state, - pressed_button_from_mask, + pressed_button_from_mask, xcb_flush, }; -use crate::platform::{ - LinuxCommon, PlatformWindow, - blade::BladeContext, - linux::{ - DEFAULT_CURSOR_ICON_NAME, LinuxClient, get_xkb_compose_state, is_within_click_distance, - log_cursor_icon_warning, open_uri_internal, - platform::{DOUBLE_CLICK_INTERVAL, SCROLL_LINES}, - reveal_path_internal, - xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, - }, +use crate::platform::linux::{ + DEFAULT_CURSOR_ICON_NAME, LinuxClient, capslock_from_xkb, cursor_style_to_icon_names, + get_xkb_compose_state, is_within_click_distance, keystroke_from_xkb, + keystroke_underlying_dead_key, log_cursor_icon_warning, modifiers_from_xkb, open_uri_internal, + platform::{DOUBLE_CLICK_INTERVAL, SCROLL_LINES}, + reveal_path_internal, + xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, }; -use crate::{ +use crate::platform::linux::{ + LinuxCommon, LinuxKeyboardLayout, X11Window, modifiers_from_xinput_info, +}; + +use crate::platform::wgpu::{CompositorGpuHint, GpuContext}; +use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, - LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, Platform, - PlatformDisplay, PlatformInput, PlatformKeyboardLayout, Point, RequestFrameOptions, - ScrollDelta, Size, TouchPhase, WindowParams, X11Window, modifiers_from_xinput_info, point, px, + Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, + PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, + TouchPhase, WindowParams, point, px, }; /// Value for DeviceId parameters which selects all devices. @@ -177,7 +179,8 @@ pub struct X11ClientState { pub(crate) last_location: Point, pub(crate) current_count: usize, - gpu_context: BladeContext, + pub(crate) gpu_context: GpuContext, + pub(crate) compositor_gpu: Option, pub(crate) scale_factor: f32, @@ -222,7 +225,7 @@ pub struct X11ClientState { pub struct X11ClientStatePtr(pub Weak>); impl X11ClientStatePtr { - fn get_client(&self) -> Option { + pub fn get_client(&self) -> Option { self.0.upgrade().map(X11Client) } @@ -294,7 +297,7 @@ impl X11ClientStatePtr { } #[derive(Clone)] -pub(crate) struct X11Client(Rc>); +pub(crate) struct X11Client(pub(crate) Rc>); impl X11Client { pub(crate) fn new() -> anyhow::Result { @@ -314,32 +317,15 @@ impl X11Client { // callbacks. handle.insert_idle(|_| { let start = Instant::now(); - let mut timing = match runnable { - RunnableVariant::Meta(runnable) => { - let location = runnable.metadata().location; - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } - RunnableVariant::Compat(runnable) => { - let location = core::panic::Location::caller(); - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - timing - } + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, }; + profiler::add_task_timing(timing); + + runnable.run(); let end = Instant::now(); timing.end = Some(end); @@ -437,8 +423,6 @@ impl X11Client { .to_string(); let keyboard_layout = LinuxKeyboardLayout::new(layout_name.into()); - let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); - let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection) .context("Failed to create resource database")?; let scale_factor = get_scale_factor(&xcb_connection, &resource_database, x_root_index); @@ -449,6 +433,9 @@ impl X11Client { let clipboard = Clipboard::new().context("Failed to initialize clipboard")?; + let screen = &xcb_connection.setup().roots[x_root_index]; + let compositor_gpu = detect_compositor_gpu(&xcb_connection, screen); + let xcb_connection = Rc::new(xcb_connection); let ximc = X11rbClient::init(Rc::clone(&xcb_connection), x_root_index, None).ok(); @@ -508,7 +495,8 @@ impl X11Client { last_mouse_button: None, last_location: Point::new(px(0.0), px(0.0)), current_count: 0, - gpu_context, + gpu_context: Rc::new(RefCell::new(None)), + compositor_gpu, scale_factor, xkb_context, @@ -616,6 +604,9 @@ impl X11Client { Ok(None) => { break; } + Err(err @ ConnectionError::IoError(..)) => { + return Err(EventHandlerError::from(err)); + } Err(err) => { let err = handle_connection_error(err); log::warn!("error while polling for X11 events: {err:?}"); @@ -701,7 +692,7 @@ impl X11Client { return; } - let Some((mut ximc, mut xim_handler)) = state.take_xim() else { + let Some((mut ximc, xim_handler)) = state.take_xim() else { return; }; let mut ic_attributes = ximc @@ -752,7 +743,7 @@ impl X11Client { } } - fn get_window(&self, win: xproto::Window) -> Option { + pub(crate) fn get_window(&self, win: xproto::Window) -> Option { let state = self.0.borrow(); state .windows @@ -789,12 +780,12 @@ impl X11Client { let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32(); let mut state = self.0.borrow_mut(); - if atom == state.atoms.WM_DELETE_WINDOW { + if atom == state.atoms.WM_DELETE_WINDOW && window.should_close() { // window "x" button clicked by user - if window.should_close() { - // Rest of the close logic is handled in drop_window() - window.close(); - } + // Rest of the close logic is handled in drop_window() + drop(state); + window.close(); + state = self.0.borrow_mut(); } else if atom == state.atoms._NET_WM_SYNC_REQUEST { window.state.borrow_mut().last_sync_counter = Some(x11rb::protocol::sync::Int64 { @@ -832,7 +823,7 @@ impl X11Client { state.xcb_connection.query_pointer(event.window), ) { state.xdnd_state.position = - Point::new(Pixels(pos.win_x as f32), Pixels(pos.win_y as f32)); + Point::new(px(pos.win_x as f32), px(pos.win_y as f32)); } if !state.xdnd_state.retrieved { check_reply( @@ -874,7 +865,7 @@ impl X11Client { } Event::SelectionNotify(event) => { let window = self.get_window(event.requestor)?; - let mut state = self.0.borrow_mut(); + let state = self.0.borrow_mut(); let reply = get_reply( || "Failed to get XDND_DATA", state.xcb_connection.get_property( @@ -898,7 +889,7 @@ impl X11Client { .collect(); let input = PlatformInput::FileDrop(FileDropEvent::Entered { position: state.xdnd_state.position, - paths: crate::ExternalPaths(paths), + paths: gpui::ExternalPaths(paths), }); drop(state); window.handle_input(input); @@ -944,6 +935,8 @@ impl X11Client { let window = self.get_window(event.event)?; window.set_active(false); let mut state = self.0.borrow_mut(); + // Set last scroll values to `None` so that a large delta isn't created if scrolling is done outside the window (the valuator is global) + reset_all_pointer_device_scroll_positions(&mut state.pointer_device_states); state.keyboard_focused_window = None; if let Some(compose_state) = state.compose_state.as_mut() { compose_state.reset(); @@ -984,8 +977,8 @@ impl X11Client { event.latched_group as u32, event.locked_group.into(), ); - let modifiers = Modifiers::from_xkb(&state.xkb); - let capslock = Capslock::from_xkb(&state.xkb); + let modifiers = modifiers_from_xkb(&state.xkb); + let capslock = capslock_from_xkb(&state.xkb); if state.last_modifiers_changed_event == modifiers && state.last_capslock_changed_event == capslock { @@ -1018,9 +1011,15 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; state.pre_key_char_down.take(); + + // Macros containing modifiers might result in + // the modifiers missing from the event. + // We therefore update the mask from the global state. + update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let keystroke = { let code = event.detail.into(); - let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); + let mut keystroke = keystroke_from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); if keysym.is_modifier_key() { @@ -1044,7 +1043,7 @@ impl X11Client { keystroke.key_char = None; state.pre_edit_text = compose_state .utf8() - .or(crate::Keystroke::underlying_dead_key(keysym)); + .or(keystroke_underlying_dead_key(keysym)); let pre_edit = state.pre_edit_text.clone().unwrap_or(String::default()); drop(state); @@ -1057,7 +1056,7 @@ impl X11Client { if let Some(pre_edit) = pre_edit { window.handle_ime_commit(pre_edit); } - if let Some(current_key) = Keystroke::underlying_dead_key(keysym) { + if let Some(current_key) = keystroke_underlying_dead_key(keysym) { window.handle_ime_preedit(current_key); } state = self.0.borrow_mut(); @@ -1070,7 +1069,7 @@ impl X11Client { keystroke }; drop(state); - window.handle_input(PlatformInput::KeyDown(crate::KeyDownEvent { + window.handle_input(PlatformInput::KeyDown(gpui::KeyDownEvent { keystroke, is_held: false, prefer_character_input: false, @@ -1083,9 +1082,14 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; + // Macros containing modifiers might result in + // the modifiers missing from the event. + // We therefore update the mask from the global state. + update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let keystroke = { let code = event.detail.into(); - let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); + let keystroke = keystroke_from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); if keysym.is_modifier_key() { @@ -1098,7 +1102,7 @@ impl X11Client { keystroke }; drop(state); - window.handle_input(PlatformInput::KeyUp(crate::KeyUpEvent { keystroke })); + window.handle_input(PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke })); } Event::XinputButtonPress(event) => { let window = self.get_window(event.event)?; @@ -1145,7 +1149,7 @@ impl X11Client { let current_count = state.current_count; drop(state); - window.handle_input(PlatformInput::MouseDown(crate::MouseDownEvent { + window.handle_input(PlatformInput::MouseDown(gpui::MouseDownEvent { button, position, modifiers, @@ -1191,7 +1195,7 @@ impl X11Client { Some(ButtonOrScroll::Button(button)) => { let click_count = state.current_count; drop(state); - window.handle_input(PlatformInput::MouseUp(crate::MouseUpEvent { + window.handle_input(PlatformInput::MouseUp(gpui::MouseUpEvent { button, position, modifiers, @@ -1205,6 +1209,33 @@ impl X11Client { Event::XinputMotion(event) => { let window = self.get_window(event.event)?; let mut state = self.0.borrow_mut(); + if window.is_blocked() { + // We want to set the cursor to the default arrow + // when the window is blocked + let style = CursorStyle::Arrow; + + let current_style = state + .cursor_styles + .get(&window.x_window) + .unwrap_or(&CursorStyle::Arrow); + if *current_style != style + && let Some(cursor) = state.get_cursor_icon(style) + { + state.cursor_styles.insert(window.x_window, style); + check_reply( + || "Failed to set cursor style", + state.xcb_connection.change_window_attributes( + window.x_window, + &ChangeWindowAttributesAux { + cursor: Some(cursor), + ..Default::default() + }, + ), + ) + .log_err(); + state.xcb_connection.flush().log_err(); + }; + } let pressed_button = pressed_button_from_mask(event.button_mask[0]); let position = point( px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), @@ -1215,7 +1246,7 @@ impl X11Client { drop(state); if event.valuator_mask[0] & 3 != 0 { - window.handle_input(PlatformInput::MouseMove(crate::MouseMoveEvent { + window.handle_input(PlatformInput::MouseMove(gpui::MouseMoveEvent { position, pressed_button, modifiers, @@ -1223,7 +1254,7 @@ impl X11Client { } state = self.0.borrow_mut(); - if let Some(mut pointer) = state.pointer_device_states.get_mut(&event.sourceid) { + if let Some(pointer) = state.pointer_device_states.get_mut(&event.sourceid) { let scroll_delta = get_scroll_delta_and_update_state(pointer, &event); drop(state); if let Some(scroll_delta) = scroll_delta { @@ -1257,7 +1288,7 @@ impl X11Client { drop(state); let window = self.get_window(event.event)?; - window.handle_input(PlatformInput::MouseExited(crate::MouseExitEvent { + window.handle_input(PlatformInput::MouseExited(gpui::MouseExitEvent { pressed_button, position, modifiers, @@ -1282,7 +1313,7 @@ impl X11Client { } Event::XinputDeviceChanged(event) => { let mut state = self.0.borrow_mut(); - if let Some(mut pointer) = state.pointer_device_states.get_mut(&event.sourceid) { + if let Some(pointer) = state.pointer_device_states.get_mut(&event.sourceid) { reset_pointer_device_scroll_positions(pointer); } } @@ -1310,7 +1341,7 @@ impl X11Client { match event { Event::KeyPress(event) | Event::KeyRelease(event) => { let mut state = self.0.borrow_mut(); - state.pre_key_char_down = Some(Keystroke::from_xkb( + state.pre_key_char_down = Some(keystroke_from_xkb( &state.xkb, state.modifiers, event.detail.into(), @@ -1356,7 +1387,7 @@ impl X11Client { }; let mut state = self.0.borrow_mut(); - let (mut ximc, mut xim_handler) = state.take_xim()?; + let (mut ximc, xim_handler) = state.take_xim()?; state.composing = !text.is_empty(); drop(state); window.handle_ime_preedit(text); @@ -1450,7 +1481,12 @@ impl LinuxClient for X11Client { let state = self.0.borrow(); Some(Rc::new( - X11Display::new(&state.xcb_connection, state.scale_factor, id.0 as usize).ok()?, + X11Display::new( + &state.xcb_connection, + state.scale_factor, + u32::from(id) as usize, + ) + .ok()?, )) } @@ -1462,11 +1498,9 @@ impl LinuxClient for X11Client { #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> futures::channel::oneshot::Receiver>>> + ) -> futures::channel::oneshot::Receiver>>> { - crate::platform::scap_screen_capture::scap_screen_sources( - &self.0.borrow().common.foreground_executor, - ) + gpui::scap_screen_capture::scap_screen_sources(&self.0.borrow().common.foreground_executor) } fn open_window( @@ -1478,25 +1512,33 @@ impl LinuxClient for X11Client { let parent_window = state .keyboard_focused_window .and_then(|focused_window| state.windows.get(&focused_window)) - .map(|window| window.window.x_window); + .map(|w| w.window.clone()); let x_window = state .xcb_connection .generate_id() .context("X11: Failed to generate window ID")?; + let xcb_connection = state.xcb_connection.clone(); + let client_side_decorations_supported = state.client_side_decorations_supported; + let x_root_index = state.x_root_index; + let atoms = state.atoms; + let scale_factor = state.scale_factor; + let appearance = state.common.appearance; + let compositor_gpu = state.compositor_gpu.take(); let window = X11Window::new( handle, X11ClientStatePtr(Rc::downgrade(&self.0)), state.common.foreground_executor.clone(), - &state.gpu_context, + state.gpu_context.clone(), + compositor_gpu, params, - &state.xcb_connection, - state.client_side_decorations_supported, - state.x_root_index, + &xcb_connection, + client_side_decorations_supported, + x_root_index, x_window, - &state.atoms, - state.scale_factor, - state.common.appearance, + &atoms, + scale_factor, + appearance, parent_window, )?; check_reply( @@ -1533,7 +1575,15 @@ impl LinuxClient for X11Client { .cursor_styles .get(&focused_window) .unwrap_or(&CursorStyle::Arrow); - if *current_style == style { + + let window = state + .mouse_focused_window + .and_then(|w| state.windows.get(&w)); + + let should_change = *current_style != style + && (window.is_none() || window.is_some_and(|w| !w.is_blocked())); + + if !should_change { return; } @@ -1558,15 +1608,23 @@ impl LinuxClient for X11Client { fn open_uri(&self, uri: &str) { #[cfg(any(feature = "wayland", feature = "x11"))] - open_uri_internal(self.background_executor(), uri, None); + open_uri_internal( + self.with_common(|c| c.background_executor.clone()), + uri, + None, + ); } fn reveal_path(&self, path: PathBuf) { #[cfg(any(feature = "x11", feature = "wayland"))] - reveal_path_internal(self.background_executor(), path, None); + reveal_path_internal( + self.with_common(|c| c.background_executor.clone()), + path, + None, + ); } - fn write_to_primary(&self, item: crate::ClipboardItem) { + fn write_to_primary(&self, item: gpui::ClipboardItem) { let state = self.0.borrow_mut(); state .clipboard @@ -1579,7 +1637,7 @@ impl LinuxClient for X11Client { .log_with_level(log::Level::Debug); } - fn write_to_clipboard(&self, item: crate::ClipboardItem) { + fn write_to_clipboard(&self, item: gpui::ClipboardItem) { let mut state = self.0.borrow_mut(); state .clipboard @@ -1593,7 +1651,7 @@ impl LinuxClient for X11Client { state.clipboard_item.replace(item); } - fn read_from_primary(&self) -> Option { + fn read_from_primary(&self) -> Option { let state = self.0.borrow_mut(); state .clipboard @@ -1602,7 +1660,7 @@ impl LinuxClient for X11Client { .log_with_level(log::Level::Debug) } - fn read_from_clipboard(&self) -> Option { + fn read_from_clipboard(&self) -> Option { let state = self.0.borrow_mut(); // if the last copy was from this app, return our cached item // which has metadata attached. @@ -1845,7 +1903,7 @@ impl X11ClientState { return *cursor; } - let mut result; + let result; match style { CursorStyle::None => match create_invisible_cursor(&self.xcb_connection) { Ok(loaded_cursor) => result = Ok(loaded_cursor), @@ -1853,7 +1911,7 @@ impl X11ClientState { }, _ => 'outer: { let mut errors = String::new(); - let cursor_icon_names = style.to_icon_names(); + let cursor_icon_names = cursor_style_to_icon_names(style); for cursor_icon_name in cursor_icon_names { match self .cursor_handle @@ -1930,7 +1988,30 @@ fn fp3232_to_f32(value: xinput::Fp3232) -> f32 { value.integral as f32 + value.frac as f32 / u32::MAX as f32 } -fn check_compositor_present(xcb_connection: &XCBConnection, root: u32) -> bool { +fn detect_compositor_gpu( + xcb_connection: &XCBConnection, + screen: &xproto::Screen, +) -> Option { + use std::os::fd::AsRawFd; + use std::os::unix::fs::MetadataExt; + + xcb_connection + .extension_information(dri3::X11_EXTENSION_NAME) + .ok()??; + + let reply = dri3::open(xcb_connection, screen.root, 0) + .ok()? + .reply() + .ok()?; + let fd = reply.device_fd; + + let path = format!("/proc/self/fd/{}", fd.as_raw_fd()); + let metadata = std::fs::metadata(&path).ok()?; + + crate::platform::linux::compositor_gpu_hint_from_dev_t(metadata.rdev()) +} + +fn check_compositor_present(xcb_connection: &XCBConnection, root: xproto::Window) -> bool { // Method 1: Check for _NET_WM_CM_S{root} let atom_name = format!("_NET_WM_CM_S{}", root); let atom1 = get_reply( @@ -2231,7 +2312,7 @@ fn make_scroll_wheel_event( position: Point, scroll_delta: Point, modifiers: Modifiers, -) -> crate::ScrollWheelEvent { +) -> gpui::ScrollWheelEvent { // When shift is held down, vertical scrolling turns into horizontal scrolling. let delta = if modifiers.shift { Point { @@ -2241,7 +2322,7 @@ fn make_scroll_wheel_event( } else { scroll_delta }; - crate::ScrollWheelEvent { + gpui::ScrollWheelEvent { position, delta: ScrollDelta::Lines(delta), modifiers, @@ -2516,3 +2597,19 @@ fn get_dpi_factor((width_px, height_px): (u32, u32), (width_mm, height_mm): (u64 fn valid_scale_factor(scale_factor: f32) -> bool { scale_factor.is_sign_positive() && scale_factor.is_normal() } + +#[inline] +fn update_xkb_mask_from_event_state(xkb: &mut xkbc::State, event_state: xproto::KeyButMask) { + let depressed_mods = event_state.remove((ModMask::LOCK | ModMask::M2).bits()); + let latched_mods = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED); + let locked_mods = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED); + let locked_layout = xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED); + xkb.update_mask( + depressed_mods.into(), + latched_mods, + locked_mods, + 0, + 0, + locked_layout, + ); +} diff --git a/src/platform/linux/x11/clipboard.rs b/src/platform/linux/x11/clipboard.rs index 3be5008505..d2ea58b3f8 100644 --- a/src/platform/linux/x11/clipboard.rs +++ b/src/platform/linux/x11/clipboard.rs @@ -47,7 +47,7 @@ use x11rb::{ wrapper::ConnectionExt as _, }; -use crate::{ClipboardItem, Image, ImageFormat, hash}; +use gpui::{ClipboardItem, Image, ImageFormat, hash}; type Result = std::result::Result; diff --git a/src/platform/linux/x11/display.rs b/src/platform/linux/x11/display.rs index ea2f8bb189..900c55e759 100644 --- a/src/platform/linux/x11/display.rs +++ b/src/platform/linux/x11/display.rs @@ -2,7 +2,7 @@ use anyhow::Context as _; use uuid::Uuid; use x11rb::{connection::Connection as _, xcb_ffi::XCBConnection}; -use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, Size, px}; +use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay, Size, px}; #[derive(Debug)] pub(crate) struct X11Display { @@ -38,7 +38,7 @@ impl X11Display { impl PlatformDisplay for X11Display { fn id(&self) -> DisplayId { - DisplayId(self.x_screen_index as u32) + DisplayId::new(self.x_screen_index as u32) } fn uuid(&self) -> anyhow::Result { diff --git a/src/platform/linux/x11/event.rs b/src/platform/linux/x11/event.rs index 17bcc908d3..3fb916425b 100644 --- a/src/platform/linux/x11/event.rs +++ b/src/platform/linux/x11/event.rs @@ -3,7 +3,7 @@ use x11rb::protocol::{ xproto::{self, ModMask}, }; -use crate::{Modifiers, MouseButton, NavigationDirection}; +use gpui::{Modifiers, MouseButton, NavigationDirection}; pub(crate) enum ButtonOrScroll { Button(MouseButton), diff --git a/src/platform/linux/x11/window.rs b/src/platform/linux/x11/window.rs index fe197a6701..af976dd532 100644 --- a/src/platform/linux/x11/window.rs +++ b/src/platform/linux/x11/window.rs @@ -1,16 +1,17 @@ use anyhow::{Context as _, anyhow}; use x11rb::connection::RequestConnection; -use crate::platform::blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}; -use crate::{ +use crate::platform::linux::X11ClientStatePtr; +use crate::platform::wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; +use gpui::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, X11ClientStatePtr, px, size, + WindowDecorations, WindowKind, WindowParams, px, }; -use blade_graphics as gpu; +use collections::FxHashSet; use raw_window_handle as rwh; use util::{ResultExt, maybe}; use x11rb::{ @@ -28,8 +29,7 @@ use x11rb::{ }; use std::{ - cell::RefCell, ffi::c_void, fmt::Display, num::NonZeroU32, ops::Div, ptr::NonNull, rc::Rc, - sync::Arc, + cell::RefCell, ffi::c_void, fmt::Display, num::NonZeroU32, ptr::NonNull, rc::Rc, sync::Arc, }; use super::{X11Display, XINPUT_ALL_DEVICE_GROUPS, XINPUT_ALL_DEVICES}; @@ -74,6 +74,7 @@ x11rb::atom_manager! { _NET_WM_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NOTIFICATION, _NET_WM_WINDOW_TYPE_DIALOG, + _NET_WM_STATE_MODAL, _NET_WM_SYNC, _NET_SUPPORTED, _MOTIF_WM_HINTS, @@ -87,27 +88,24 @@ x11rb::atom_manager! { fn query_render_extent( xcb: &Rc, x_window: xproto::Window, -) -> anyhow::Result { +) -> anyhow::Result> { let reply = get_reply(|| "X11 GetGeometry failed.", xcb.get_geometry(x_window))?; - Ok(gpu::Extent { - width: reply.width as u32, - height: reply.height as u32, - depth: 1, + Ok(Size { + width: DevicePixels(reply.width as i32), + height: DevicePixels(reply.height as i32), }) } -impl ResizeEdge { - fn to_moveresize(self) -> u32 { - match self { - ResizeEdge::TopLeft => 0, - ResizeEdge::Top => 1, - ResizeEdge::TopRight => 2, - ResizeEdge::Right => 3, - ResizeEdge::BottomRight => 4, - ResizeEdge::Bottom => 5, - ResizeEdge::BottomLeft => 6, - ResizeEdge::Left => 7, - } +fn resize_edge_to_moveresize(edge: ResizeEdge) -> u32 { + match edge { + ResizeEdge::TopLeft => 0, + ResizeEdge::Top => 1, + ResizeEdge::TopRight => 2, + ResizeEdge::Right => 3, + ResizeEdge::BottomRight => 4, + ResizeEdge::Bottom => 5, + ResizeEdge::BottomLeft => 6, + ResizeEdge::Left => 7, } } @@ -227,6 +225,7 @@ fn find_visuals(xcb: &XCBConnection, screen_index: usize) -> VisualSet { set } +#[derive(Debug, Clone, Copy)] struct RawWindow { connection: *mut c_void, screen_id: usize, @@ -234,10 +233,16 @@ struct RawWindow { visual_id: u32, } +// Safety: The raw pointers in RawWindow point to X11 connection +// which is valid for the window's lifetime. These are used only for +// passing to wgpu which needs Send+Sync for surface creation. +unsafe impl Send for RawWindow {} +unsafe impl Sync for RawWindow {} + #[derive(Default)] pub struct Callbacks { request_frame: Option>, - input: Option crate::DispatchEventResult>>, + input: Option gpui::DispatchEventResult>>, active_status_change: Option>, hovered_status_change: Option>, resize: Option, f32)>>, @@ -249,15 +254,19 @@ pub struct Callbacks { pub struct X11WindowState { pub destroyed: bool, + parent: Option, + children: FxHashSet, client: X11ClientStatePtr, executor: ForegroundExecutor, atoms: XcbAtoms, x_root_window: xproto::Window, + x_screen_index: usize, + visual_id: u32, pub(crate) counter_id: sync::Counter, pub(crate) last_sync_counter: Option, bounds: Bounds, scale_factor: f32, - renderer: BladeRenderer, + renderer: WgpuRenderer, display: Rc, input_handler: Option, appearance: WindowAppearance, @@ -313,12 +322,28 @@ impl rwh::HasDisplayHandle for RawWindow { impl rwh::HasWindowHandle for X11Window { fn window_handle(&self) -> Result, rwh::HandleError> { - unimplemented!() + let Some(non_zero) = NonZeroU32::new(self.0.x_window) else { + return Err(rwh::HandleError::Unavailable); + }; + let handle = rwh::XcbWindowHandle::new(non_zero); + Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) }) } } + impl rwh::HasDisplayHandle for X11Window { fn display_handle(&self) -> Result, rwh::HandleError> { - unimplemented!() + let connection = + as_raw_xcb_connection::AsRawXcbConnection::as_raw_xcb_connection(&*self.0.xcb) + as *mut _; + let Some(non_zero) = NonNull::new(connection) else { + return Err(rwh::HandleError::Unavailable); + }; + let screen_id = { + let state = self.0.state.borrow(); + u32::from(state.display.id()) as i32 + }; + let handle = rwh::XcbDisplayHandle::new(Some(non_zero), screen_id); + Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) } } @@ -385,7 +410,8 @@ impl X11WindowState { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &BladeContext, + gpu_context: crate::platform::wgpu::GpuContext, + compositor_gpu: Option, params: WindowParams, xcb: &Rc, client_side_decorations_supported: bool, @@ -394,11 +420,11 @@ impl X11WindowState { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, - parent_window: Option, + parent_window: Option, ) -> anyhow::Result { let x_screen_index = params .display_id - .map_or(x_main_screen_index, |did| did.0 as usize); + .map_or(x_main_screen_index, |did| u32::from(did) as usize); let visual_set = find_visuals(xcb, x_screen_index); @@ -427,6 +453,7 @@ impl X11WindowState { // https://stackoverflow.com/questions/43218127/x11-xlib-xcb-creating-a-window-requires-border-pixel-if-specifying-colormap-wh .border_pixel(visual_set.black_pixel) .colormap(colormap) + .override_redirect((params.kind == WindowKind::PopUp) as u32) .event_mask( xproto::EventMask::EXPOSURE | xproto::EventMask::STRUCTURE_NOTIFY @@ -490,21 +517,6 @@ impl X11WindowState { ), )?; - if let Some(size) = params.window_min_size { - let mut size_hints = WmSizeHints::new(); - let min_size = (size.width.0 as i32, size.height.0 as i32); - size_hints.min_size = Some(min_size); - check_reply( - || { - format!( - "X11 change of WM_SIZE_HINTS failed. min_size: {:?}", - min_size - ) - }, - size_hints.set_normal_hints(xcb, x_window), - )?; - } - let reply = get_reply(|| "X11 GetGeometry failed.", xcb.get_geometry(x_window))?; if reply.x == 0 && reply.y == 0 { bounds.origin.x.0 += 2; @@ -522,7 +534,7 @@ impl X11WindowState { && let Some(title) = titlebar.title { check_reply( - || "X11 ChangeProperty8 on window title failed.", + || "X11 ChangeProperty8 on WM_NAME failed.", xcb.change_property8( xproto::PropMode::REPLACE, x_window, @@ -531,6 +543,16 @@ impl X11WindowState { title.as_bytes(), ), )?; + check_reply( + || "X11 ChangeProperty8 on _NET_WM_NAME failed.", + xcb.change_property8( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_NAME, + atoms.UTF8_STRING, + title.as_bytes(), + ), + )?; } if params.kind == WindowKind::PopUp { @@ -546,8 +568,8 @@ impl X11WindowState { )?; } - if params.kind == WindowKind::Floating { - if let Some(parent_window) = parent_window { + if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog { + if let Some(parent_window) = parent_window.as_ref().map(|w| w.x_window) { // WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set // a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to // place the floating window in relation to the main window. @@ -563,11 +585,23 @@ impl X11WindowState { ), )?; } + } + let parent = if params.kind == WindowKind::Dialog + && let Some(parent) = parent_window + { + parent.add_child(x_window); + + Some(parent) + } else { + None + }; + + if params.kind == WindowKind::Dialog { // _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html check_reply( - || "X11 ChangeProperty32 setting window type for floating window failed.", + || "X11 ChangeProperty32 setting window type for dialog window failed.", xcb.change_property32( xproto::PropMode::REPLACE, x_window, @@ -576,6 +610,20 @@ impl X11WindowState { &[atoms._NET_WM_WINDOW_TYPE_DIALOG], ), )?; + + // We set the modal state for dialog windows, so that the window manager + // can handle it appropriately (e.g., prevent interaction with the parent window + // while the dialog is open). + check_reply( + || "X11 ChangeProperty32 setting modal state for dialog window failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_STATE, + xproto::AtomEnum::ATOM, + &[atoms._NET_WM_STATE_MODAL], + ), + )?; } check_reply( @@ -651,7 +699,7 @@ impl X11WindowState { window_id: x_window, visual_id: visual.id, }; - let config = BladeSurfaceConfig { + let config = WgpuSurfaceConfig { // Note: this has to be done after the GPU init, or otherwise // the sizes are immediately invalidated. size: query_render_extent(xcb, x_window)?, @@ -661,16 +709,39 @@ impl X11WindowState { // too transparent: false, }; - BladeRenderer::new(gpu_context, &raw_window, config)? + WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; + // Set max window size hints based on the GPU's maximum texture dimension. + // This prevents the window from being resized larger than what the GPU can render. + let max_texture_size = renderer.max_texture_size(); + let mut size_hints = WmSizeHints::new(); + if let Some(size) = params.window_min_size { + size_hints.min_size = + Some((f32::from(size.width) as i32, f32::from(size.height) as i32)); + } + size_hints.max_size = Some((max_texture_size as i32, max_texture_size as i32)); + check_reply( + || { + format!( + "X11 change of WM_SIZE_HINTS failed. max_size: {:?}", + max_texture_size + ) + }, + size_hints.set_normal_hints(xcb, x_window), + )?; + let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?); Ok(Self { + parent, + children: FxHashSet::default(), client, executor, display, x_root_window: visual_set.root, + x_screen_index, + visual_id: visual.id, bounds: bounds.to_pixels(scale_factor), scale_factor, renderer, @@ -707,11 +778,7 @@ impl X11WindowState { } fn content_size(&self) -> Size { - let size = self.renderer.viewport_size(); - Size { - width: size.width.into(), - height: size.height.into(), - } + self.bounds.size } } @@ -720,6 +787,11 @@ pub(crate) struct X11Window(pub X11WindowStatePtr); impl Drop for X11Window { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); + + if let Some(parent) = state.parent.as_ref() { + parent.state.borrow_mut().children.remove(&self.0.x_window); + } + state.renderer.destroy(); let destroy_x_window = maybe!({ @@ -734,8 +806,6 @@ impl Drop for X11Window { .log_err(); if destroy_x_window.is_some() { - // Mark window as destroyed so that we can filter out when X11 events - // for it still come in. state.destroyed = true; let this_ptr = self.0.clone(); @@ -764,7 +834,8 @@ impl X11Window { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &BladeContext, + gpu_context: crate::platform::wgpu::GpuContext, + compositor_gpu: Option, params: WindowParams, xcb: &Rc, client_side_decorations_supported: bool, @@ -773,7 +844,7 @@ impl X11Window { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, - parent_window: Option, + parent_window: Option, ) -> anyhow::Result { let ptr = X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( @@ -781,6 +852,7 @@ impl X11Window { client, executor, gpu_context, + compositor_gpu, params, xcb, client_side_decorations_supported, @@ -839,8 +911,8 @@ impl X11Window { self.0.xcb.translate_coordinates( self.0.x_window, state.x_root_window, - (position.x.0 * state.scale_factor) as i16, - (position.y.0 * state.scale_factor) as i16, + (f32::from(position.x) * state.scale_factor) as i16, + (f32::from(position.y) * state.scale_factor) as i16, ), ) } @@ -897,7 +969,7 @@ impl X11WindowStatePtr { } pub fn property_notify(&self, event: xproto::PropertyNotifyEvent) -> anyhow::Result<()> { - let mut state = self.state.borrow_mut(); + let state = self.state.borrow_mut(); if event.atom == state.atoms._NET_WM_STATE { self.set_wm_properties(state)?; } else if event.atom == state.atoms._GTK_EDGE_CONSTRAINTS { @@ -979,7 +1051,31 @@ impl X11WindowStatePtr { Ok(()) } + pub fn add_child(&self, child: xproto::Window) { + let mut state = self.state.borrow_mut(); + state.children.insert(child); + } + + pub fn is_blocked(&self) -> bool { + let state = self.state.borrow(); + !state.children.is_empty() + } + pub fn close(&self) { + let state = self.state.borrow(); + let client = state.client.clone(); + #[allow(clippy::mutable_key_type)] + let children = state.children.clone(); + drop(state); + + if let Some(client) = client.get_client() { + for child in children { + if let Some(child_window) = client.get_window(child) { + child_window.close(); + } + } + } + let mut callbacks = self.callbacks.borrow_mut(); if let Some(fun) = callbacks.close.take() { fun() @@ -987,18 +1083,25 @@ impl X11WindowStatePtr { } pub fn refresh(&self, request_frame_options: RequestFrameOptions) { - let mut cb = self.callbacks.borrow_mut(); - if let Some(ref mut fun) = cb.request_frame { + let callback = self.callbacks.borrow_mut().request_frame.take(); + if let Some(mut fun) = callback { fun(request_frame_options); + self.callbacks.borrow_mut().request_frame = Some(fun); } } pub fn handle_input(&self, input: PlatformInput) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().input - && !fun(input.clone()).propagate - { + if self.is_blocked() { return; } + let callback = self.callbacks.borrow_mut().input.take(); + if let Some(mut fun) = callback { + let result = fun(input.clone()); + self.callbacks.borrow_mut().input = Some(fun); + if !result.propagate { + return; + } + } if let PlatformInput::KeyDown(event) = input { // only allow shift modifier when inserting text if event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) { @@ -1016,6 +1119,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_commit(&self, text: String) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1026,6 +1132,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_preedit(&self, text: String) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1036,6 +1145,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_unmark(&self) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1046,6 +1158,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_delete(&self) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1073,13 +1188,11 @@ impl X11WindowStatePtr { } pub fn set_bounds(&self, bounds: Bounds) -> anyhow::Result<()> { - let mut resize_args = None; - let is_resize; - { + let (is_resize, content_size, scale_factor) = { let mut state = self.state.borrow_mut(); let bounds = bounds.map(|f| px(f as f32 / state.scale_factor)); - is_resize = bounds.size.width != state.bounds.size.width + let is_resize = bounds.size.width != state.bounds.size.width || bounds.size.height != state.bounds.size.height; // If it's a resize event (only width/height changed), we ignore `bounds.origin` @@ -1091,25 +1204,19 @@ impl X11WindowStatePtr { } let gpu_size = query_render_extent(&self.xcb, self.x_window)?; - if true { - state.renderer.update_drawable_size(size( - DevicePixels(gpu_size.width as i32), - DevicePixels(gpu_size.height as i32), - )); - resize_args = Some((state.content_size(), state.scale_factor)); - } + state.renderer.update_drawable_size(gpu_size); + let result = (is_resize, state.content_size(), state.scale_factor); if let Some(value) = state.last_sync_counter.take() { check_reply( || "X11 sync SetCounter failed.", sync::set_counter(&self.xcb, state.counter_id, value), )?; } - } + result + }; let mut callbacks = self.callbacks.borrow_mut(); - if let Some((content_size, scale_factor)) = resize_args - && let Some(ref mut fun) = callbacks.resize - { + if let Some(ref mut fun) = callbacks.resize { fun(content_size, scale_factor) } @@ -1121,14 +1228,18 @@ impl X11WindowStatePtr { } pub fn set_active(&self, focus: bool) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { + let callback = self.callbacks.borrow_mut().active_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().active_status_change = Some(fun); } } pub fn set_hovered(&self, focus: bool) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().hovered_status_change { + let callback = self.callbacks.borrow_mut().hovered_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().hovered_status_change = Some(fun); } } @@ -1139,9 +1250,10 @@ impl X11WindowStatePtr { state.renderer.update_transparency(is_transparent); state.appearance = appearance; drop(state); - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(ref mut fun) = callbacks.appearance_changed { - (fun)() + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } } @@ -1176,10 +1288,10 @@ impl PlatformWindow for X11Window { let [left, right, top, bottom] = state.last_insets; let [left, right, top, bottom] = [ - Pixels((left as f32) / state.scale_factor), - Pixels((right as f32) / state.scale_factor), - Pixels((top as f32) / state.scale_factor), - Pixels((bottom as f32) / state.scale_factor), + px((left as f32) / state.scale_factor), + px((right as f32) / state.scale_factor), + px((top as f32) / state.scale_factor), + px((bottom as f32) / state.scale_factor), ]; bounds.origin.x += left; @@ -1192,12 +1304,10 @@ impl PlatformWindow for X11Window { } fn content_size(&self) -> Size { - // We divide by the scale factor here because this value is queried to determine how much to draw, - // but it will be multiplied later by the scale to adjust for scaling. - let state = self.0.state.borrow(); - state - .content_size() - .map(|size| size.div(state.scale_factor)) + // After the wgpu migration, X11WindowState::content_size() returns logical pixels + // (bounds.size is already divided by scale_factor in set_bounds), so no further + // division is needed here. This matches the Wayland implementation. + self.0.state.borrow().content_size() } fn resize(&mut self, size: Size) { @@ -1258,7 +1368,7 @@ impl PlatformWindow for X11Window { .unwrap_or_default() } - fn capslock(&self) -> crate::Capslock { + fn capslock(&self) -> gpui::Capslock { self.0 .state .borrow() @@ -1384,6 +1494,28 @@ impl PlatformWindow for X11Window { state.renderer.update_transparency(transparent); } + fn background_appearance(&self) -> WindowBackgroundAppearance { + self.0.state.borrow().background_appearance + } + + fn is_subpixel_rendering_supported(&self) -> bool { + self.0 + .state + .borrow() + .client + .0 + .upgrade() + .map(|ref_cell| { + let state = ref_cell.borrow(); + state + .gpu_context + .borrow() + .as_ref() + .is_some_and(|ctx| ctx.supports_dual_source_blending()) + }) + .unwrap_or_default() + } + fn minimize(&self) { let state = self.0.state.borrow(); const WINDOW_ICONIC_STATE: u32 = 3; @@ -1435,7 +1567,7 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().request_frame = Some(callback); } - fn on_input(&self, callback: Box crate::DispatchEventResult>) { + fn on_input(&self, callback: Box gpui::DispatchEventResult>) { self.0.callbacks.borrow_mut().input = Some(callback); } @@ -1472,6 +1604,29 @@ impl PlatformWindow for X11Window { fn draw(&self, scene: &Scene) { let mut inner = self.0.state.borrow_mut(); + + if inner.renderer.device_lost() { + let raw_window = RawWindow { + connection: as_raw_xcb_connection::AsRawXcbConnection::as_raw_xcb_connection( + &*self.0.xcb, + ) as *mut _, + screen_id: inner.x_screen_index, + window_id: self.0.x_window, + visual_id: inner.visual_id, + }; + inner.renderer.recover(&raw_window).unwrap_or_else(|err| { + panic!( + "GPU device lost and recovery failed. \ + This may happen after system suspend/resume. \ + Please restart the application.\n\nError: {err}" + ) + }); + + // The current scene references atlas textures that were cleared during recovery. + // Skip this frame and let the next frame rebuild the scene with fresh textures. + return; + } + inner.renderer.draw(scene); } @@ -1522,10 +1677,11 @@ impl PlatformWindow for X11Window { } fn start_window_resize(&self, edge: ResizeEdge) { - self.send_moveresize(edge.to_moveresize()).log_err(); + self.send_moveresize(resize_edge_to_moveresize(edge)) + .log_err(); } - fn window_decorations(&self) -> crate::Decorations { + fn window_decorations(&self) -> gpui::Decorations { let state = self.0.state.borrow(); // Client window decorations require compositor support @@ -1557,7 +1713,7 @@ impl PlatformWindow for X11Window { fn set_client_inset(&self, inset: Pixels) { let mut state = self.0.state.borrow_mut(); - let dp = (inset.0 * state.scale_factor) as u32; + let dp = (f32::from(inset) * state.scale_factor) as u32; let insets = if state.fullscreen { [0, 0, 0, 0] @@ -1601,16 +1757,16 @@ impl PlatformWindow for X11Window { } } - fn request_decorations(&self, mut decorations: crate::WindowDecorations) { + fn request_decorations(&self, mut decorations: gpui::WindowDecorations) { let mut state = self.0.state.borrow_mut(); - if matches!(decorations, crate::WindowDecorations::Client) + if matches!(decorations, gpui::WindowDecorations::Client) && !state.client_side_decorations_supported { log::info!( "x11: no compositor present, falling back to server-side window decorations" ); - decorations = crate::WindowDecorations::Server; + decorations = gpui::WindowDecorations::Server; } // https://github.com/rust-windowing/winit/blob/master/src/platform_impl/linux/x11/util/hint.rs#L53-L87 @@ -1658,7 +1814,7 @@ impl PlatformWindow for X11Window { } fn update_ime_position(&self, bounds: Bounds) { - let mut state = self.0.state.borrow_mut(); + let state = self.0.state.borrow(); let client = state.client.clone(); drop(state); client.update_ime_position(bounds); diff --git a/src/platform/linux/xdg_desktop_portal.rs b/src/platform/linux/xdg_desktop_portal.rs index 722947a299..911ac319db 100644 --- a/src/platform/linux/xdg_desktop_portal.rs +++ b/src/platform/linux/xdg_desktop_portal.rs @@ -7,7 +7,7 @@ use calloop::channel::Channel; use calloop::{EventSource, Poll, PostAction, Readiness, Token, TokenFactory}; use smol::stream::StreamExt; -use crate::{BackgroundExecutor, WindowAppearance}; +use gpui::{BackgroundExecutor, WindowAppearance}; pub enum Event { WindowAppearance(WindowAppearance), @@ -32,9 +32,9 @@ impl XDPEventSource { let settings = Settings::new().await?; if let Ok(initial_appearance) = settings.color_scheme().await { - sender.send(Event::WindowAppearance(WindowAppearance::from_native( - initial_appearance, - )))?; + sender.send(Event::WindowAppearance( + window_appearance_from_color_scheme(initial_appearance), + ))?; } if let Ok(initial_theme) = settings .read::("org.gnome.desktop.interface", "cursor-theme") @@ -91,9 +91,9 @@ impl XDPEventSource { let mut appearance_changed = settings.receive_color_scheme_changed().await?; while let Some(scheme) = appearance_changed.next().await { - sender.send(Event::WindowAppearance(WindowAppearance::from_native( - scheme, - )))?; + sender.send(Event::WindowAppearance( + window_appearance_from_color_scheme(scheme), + ))?; } anyhow::Ok(()) @@ -155,17 +155,10 @@ impl EventSource for XDPEventSource { } } -impl WindowAppearance { - fn from_native(cs: ColorScheme) -> WindowAppearance { - match cs { - ColorScheme::PreferDark => WindowAppearance::Dark, - ColorScheme::PreferLight => WindowAppearance::Light, - ColorScheme::NoPreference => WindowAppearance::Light, - } - } - - #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] - fn set_native(&mut self, cs: ColorScheme) { - *self = Self::from_native(cs); +fn window_appearance_from_color_scheme(cs: ColorScheme) -> WindowAppearance { + match cs { + ColorScheme::PreferDark => WindowAppearance::Dark, + ColorScheme::PreferLight => WindowAppearance::Light, + ColorScheme::NoPreference => WindowAppearance::Light, } } diff --git a/src/platform/mac.rs b/src/platform/mac.rs index 76d636b457..fcc0aada50 100644 --- a/src/platform/mac.rs +++ b/src/platform/mac.rs @@ -1,28 +1,23 @@ -//! Macos screen have a y axis that goings up from the bottom of the screen and +//! macOS platform implementation for GPUI. +//! +//! macOS screens have a y axis that goes up from the bottom of the screen and //! an origin at the bottom left of the main display. + mod dispatcher; mod display; mod display_link; mod events; mod keyboard; +mod pasteboard; #[cfg(feature = "screen-capture")] mod screen_capture; -#[cfg(not(feature = "macos-blade"))] mod metal_atlas; -#[cfg(not(feature = "macos-blade"))] pub mod metal_renderer; -use core_video::image_buffer::CVImageBuffer; -#[cfg(not(feature = "macos-blade"))] use metal_renderer as renderer; -#[cfg(feature = "macos-blade")] -use crate::platform::blade as renderer; - -mod attributed_string; - #[cfg(feature = "font-kit")] mod open_type; @@ -33,10 +28,9 @@ mod platform; mod window; mod window_appearance; -use crate::{DevicePixels, Pixels, Size, px, size}; use cocoa::{ base::{id, nil}, - foundation::{NSAutoreleasePool, NSNotFound, NSRect, NSSize, NSString, NSUInteger}, + foundation::{NSAutoreleasePool, NSNotFound, NSString, NSUInteger}, }; use objc::runtime::{BOOL, NO, YES}; @@ -55,8 +49,7 @@ pub(crate) use window::*; #[cfg(feature = "font-kit")] pub(crate) use text_system::*; -/// A frame of video captured from a screen. -pub(crate) type PlatformScreenCaptureFrame = CVImageBuffer; +pub use platform::MacPlatform; trait BoolExt { fn to_objc(self) -> BOOL; @@ -135,29 +128,8 @@ unsafe impl objc::Encode for NSRange { } } +/// Allow NSString::alloc use here because it sets autorelease +#[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } - -impl From for Size { - fn from(value: NSSize) -> Self { - Size { - width: px(value.width as f32), - height: px(value.height as f32), - } - } -} - -impl From for Size { - fn from(rect: NSRect) -> Self { - let NSSize { width, height } = rect.size; - size(width.into(), height.into()) - } -} - -impl From for Size { - fn from(rect: NSRect) -> Self { - let NSSize { width, height } = rect.size; - size(DevicePixels(width as i32), DevicePixels(height as i32)) - } -} diff --git a/src/platform/mac/attributed_string.rs b/src/platform/mac/attributed_string.rs deleted file mode 100644 index 5f313ac699..0000000000 --- a/src/platform/mac/attributed_string.rs +++ /dev/null @@ -1,119 +0,0 @@ -use cocoa::base::id; -use cocoa::foundation::NSRange; -use objc::{class, msg_send, sel, sel_impl}; - -/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes), -/// which are needed for copying rich text (that is, text intermingled with images) -/// to the clipboard. This adds access to those APIs. -#[allow(non_snake_case)] -pub trait NSAttributedString: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSAttributedString), alloc] - } - - unsafe fn init_attributed_string(self, string: id) -> id; - unsafe fn appendAttributedString_(self, attr_string: id); - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn string(self) -> id; -} - -impl NSAttributedString for id { - unsafe fn init_attributed_string(self, string: id) -> id { - msg_send![self, initWithString: string] - } - - unsafe fn appendAttributedString_(self, attr_string: id) { - let _: () = msg_send![self, appendAttributedString: attr_string]; - } - - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFDFromRange: range documentAttributes: attrs] - } - - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFFromRange: range documentAttributes: attrs] - } - - unsafe fn string(self) -> id { - msg_send![self, string] - } -} - -pub trait NSMutableAttributedString: NSAttributedString { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSMutableAttributedString), alloc] - } -} - -impl NSMutableAttributedString for id {} - -#[cfg(test)] -mod tests { - use super::*; - use cocoa::appkit::NSImage; - use cocoa::base::nil; - use cocoa::foundation::NSString; - #[test] - #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 - fn test_nsattributed_string() { - // TODO move these to parent module once it's actually ready to be used - #[allow(non_snake_case)] - pub trait NSTextAttachment: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSTextAttachment), alloc] - } - } - - impl NSTextAttachment for id {} - - unsafe { - let image: id = msg_send![class!(NSImage), alloc]; - image.initWithContentsOfFile_(NSString::alloc(nil).init_str("test.jpeg")); - let _size = image.size(); - - let string = NSString::alloc(nil).init_str("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil).init_attributed_string(string); - let hello_string = NSString::alloc(nil).init_str("Hello World"); - let hello_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(hello_string); - attr_string.appendAttributedString_(hello_attr_string); - - let attachment = NSTextAttachment::alloc(nil); - let _: () = msg_send![attachment, setImage: image]; - let image_attr_string = - msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; - attr_string.appendAttributedString_(image_attr_string); - - let another_string = NSString::alloc(nil).init_str("Another String"); - let another_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(another_string); - attr_string.appendAttributedString_(another_attr_string); - - let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; - - /////////////////////////////////////////////////// - // pasteboard.clearContents(); - - let rtfd_data = attr_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attr_string, length]), - nil, - ); - assert_ne!(rtfd_data, nil); - // if rtfd_data != nil { - // pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD); - // } - - // let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - // NSRange::new(0, attributed_string.length()), - // nil, - // ); - // if rtf_data != nil { - // pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF); - // } - - // let plain_text = attributed_string.string(); - // pasteboard.setString_forType(plain_text, NSPasteboardTypeString); - } - } -} diff --git a/src/platform/mac/dispatch.h b/src/platform/mac/dispatch.h deleted file mode 100644 index 54f3818738..0000000000 --- a/src/platform/mac/dispatch.h +++ /dev/null @@ -1,2 +0,0 @@ -#include -#include diff --git a/src/platform/mac/dispatcher.rs b/src/platform/mac/dispatcher.rs index 1dfea82d58..dd6f546f68 100644 --- a/src/platform/mac/dispatcher.rs +++ b/src/platform/mac/dispatcher.rs @@ -1,14 +1,8 @@ -#![allow(non_upper_case_globals)] -#![allow(non_camel_case_types)] -#![allow(non_snake_case)] - -use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RealtimePriority, RunnableMeta, - RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, ThreadTaskTimings, +use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; +use gpui::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, + THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, }; - -use anyhow::Context; -use async_task::Runnable; use mach2::{ kern_return::KERN_SUCCESS, mach_time::mach_timebase_info_data_t, @@ -19,6 +13,9 @@ use mach2::{ thread_precedence_policy_data_t, thread_time_constraint_policy_data_t, }, }; +use util::ResultExt; + +use async_task::Runnable; use objc::{ class, msg_send, runtime::{BOOL, YES}, @@ -26,41 +23,43 @@ use objc::{ }; use std::{ ffi::c_void, - mem::MaybeUninit, - ptr::{NonNull, addr_of}, + ptr::NonNull, time::{Duration, Instant}, }; -use util::ResultExt; - -/// All items in the generated file are marked as pub, so we're gonna wrap it in a separate mod to prevent -/// these pub items from leaking into public API. -pub(crate) mod dispatch_sys { - include!(concat!(env!("OUT_DIR"), "/dispatch_sys.rs")); -} - -use dispatch_sys::*; -pub(crate) fn dispatch_get_main_queue() -> dispatch_queue_t { - addr_of!(_dispatch_main_q) as *const _ as dispatch_queue_t -} pub(crate) struct MacDispatcher; +impl MacDispatcher { + pub fn new() -> Self { + Self + } +} + impl PlatformDispatcher for MacDispatcher { fn get_all_timings(&self) -> Vec { let global_timings = GLOBAL_THREAD_TIMINGS.lock(); ThreadTaskTimings::convert(&global_timings) } - fn get_current_thread_timings(&self) -> Vec { + fn get_current_thread_timings(&self) -> ThreadTaskTimings { THREAD_TIMINGS.with(|timings| { - let timings = &timings.lock().timings; + let timings = timings.lock(); + let thread_name = timings.thread_name.clone(); + let total_pushed = timings.total_pushed; + let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); let (s1, s2) = timings.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); - vec + + ThreadTaskTimings { + thread_name, + thread_id: std::thread::current().id(), + timings: vec, + total_pushed, + } }) } @@ -69,99 +68,50 @@ impl PlatformDispatcher for MacDispatcher { is_main_thread == YES } - fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { - let (context, trampoline) = match runnable { - RunnableVariant::Meta(runnable) => ( - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline as unsafe extern "C" fn(*mut c_void)), - ), - RunnableVariant::Compat(runnable) => ( - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), - ), - }; + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + let context = runnable.into_raw().as_ptr() as *mut c_void; let queue_priority = match priority { - Priority::Realtime(_) => unreachable!(), - Priority::High => DISPATCH_QUEUE_PRIORITY_HIGH as isize, - Priority::Medium => DISPATCH_QUEUE_PRIORITY_DEFAULT as isize, - Priority::Low => DISPATCH_QUEUE_PRIORITY_LOW as isize, + Priority::RealtimeAudio => { + panic!("RealtimeAudio priority should use spawn_realtime, not dispatch") + } + Priority::High => DispatchQueueGlobalPriority::High, + Priority::Medium => DispatchQueueGlobalPriority::Default, + Priority::Low => DispatchQueueGlobalPriority::Low, }; unsafe { - dispatch_async_f( - dispatch_get_global_queue(queue_priority, 0), - context, - trampoline, - ); + DispatchQueue::global_queue(GlobalQueueIdentifier::Priority(queue_priority)) + .exec_async_f(context, trampoline); } } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - let (context, trampoline) = match runnable { - RunnableVariant::Meta(runnable) => ( - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline as unsafe extern "C" fn(*mut c_void)), - ), - RunnableVariant::Compat(runnable) => ( - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), - ), - }; + let context = runnable.into_raw().as_ptr() as *mut c_void; unsafe { - dispatch_async_f(dispatch_get_main_queue(), context, trampoline); + DispatchQueue::main().exec_async_f(context, trampoline); } } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - let (context, trampoline) = match runnable { - RunnableVariant::Meta(runnable) => ( - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline as unsafe extern "C" fn(*mut c_void)), - ), - RunnableVariant::Compat(runnable) => ( - runnable.into_raw().as_ptr() as *mut c_void, - Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), - ), - }; + let context = runnable.into_raw().as_ptr() as *mut c_void; + let queue = DispatchQueue::global_queue(GlobalQueueIdentifier::Priority( + DispatchQueueGlobalPriority::High, + )); + let when = DispatchTime::NOW.time(duration.as_nanos() as i64); unsafe { - let queue = - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0); - let when = dispatch_time(DISPATCH_TIME_NOW as u64, duration.as_nanos() as i64); - dispatch_after_f(when, queue, context, trampoline); + DispatchQueue::exec_after_f(when, &queue, context, trampoline); } } - fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + fn spawn_realtime(&self, f: Box) { std::thread::spawn(move || { - match priority { - RealtimePriority::Audio => set_audio_thread_priority(), - RealtimePriority::Other => set_high_thread_priority(), - } - .context(format!("for priority {:?}", priority)) - .log_err(); - + set_audio_thread_priority().log_err(); f(); }); } } -fn set_high_thread_priority() -> anyhow::Result<()> { - // SAFETY: always safe to call - let thread_id = unsafe { libc::pthread_self() }; - - // SAFETY: all sched_param members are valid when initialized to zero. - let mut sched_param = unsafe { MaybeUninit::::zeroed().assume_init() }; - sched_param.sched_priority = 45; - - let result = unsafe { libc::pthread_setschedparam(thread_id, libc::SCHED_FIFO, &sched_param) }; - if result != 0 { - anyhow::bail!("failed to set realtime thread priority") - } - - Ok(()) -} - fn set_audio_thread_priority() -> anyhow::Result<()> { // https://chromium.googlesource.com/chromium/chromium/+/master/base/threading/platform_thread_mac.mm#93 @@ -247,11 +197,11 @@ fn set_audio_thread_priority() -> anyhow::Result<()> { Ok(()) } -extern "C" fn trampoline(runnable: *mut c_void) { - let task = - unsafe { Runnable::::from_raw(NonNull::new_unchecked(runnable as *mut ())) }; +extern "C" fn trampoline(context: *mut c_void) { + let runnable = + unsafe { Runnable::::from_raw(NonNull::new_unchecked(context as *mut ())) }; - let location = task.metadata().location; + let location = runnable.metadata().location; let start = Instant::now(); let timing = TaskTiming { @@ -272,43 +222,7 @@ extern "C" fn trampoline(runnable: *mut c_void) { timings.push_back(timing); }); - task.run(); - let end = Instant::now(); - - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - let Some(last_timing) = timings.iter_mut().rev().next() else { - return; - }; - last_timing.end = Some(end); - }); -} - -extern "C" fn trampoline_compat(runnable: *mut c_void) { - let task = unsafe { Runnable::<()>::from_raw(NonNull::new_unchecked(runnable as *mut ())) }; - - let location = core::panic::Location::caller(); - - let start = Instant::now(); - let timing = TaskTiming { - location, - start, - end: None, - }; - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - if let Some(last_timing) = timings.iter_mut().rev().next() { - if last_timing.location == timing.location { - return; - } - } - - timings.push_back(timing); - }); - - task.run(); + runnable.run(); let end = Instant::now(); THREAD_TIMINGS.with(|timings| { diff --git a/src/platform/mac/display.rs b/src/platform/mac/display.rs index fe5aaba8db..4a48a1aae4 100644 --- a/src/platform/mac/display.rs +++ b/src/platform/mac/display.rs @@ -1,12 +1,13 @@ -use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; +use super::ns_string; use anyhow::Result; use cocoa::{ appkit::NSScreen, base::{id, nil}, - foundation::{NSArray, NSDictionary, NSString}, + foundation::{NSArray, NSDictionary}, }; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}; +use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; use objc::{msg_send, sel, sel_impl}; use uuid::Uuid; @@ -35,7 +36,7 @@ impl MacDisplay { let screens = NSScreen::screens(nil); let screen = cocoa::foundation::NSArray::objectAtIndex(screens, 0); let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; Self(screen_number) @@ -71,7 +72,7 @@ unsafe extern "C" { impl PlatformDisplay for MacDisplay { fn id(&self) -> DisplayId { - DisplayId(self.0) + DisplayId::new(self.0) } fn uuid(&self) -> Result { @@ -150,7 +151,7 @@ impl MacDisplay { unsafe fn get_nsscreen(&self) -> id { let screens = unsafe { NSScreen::screens(nil) }; let count = unsafe { NSArray::count(screens) }; - let screen_number_key: id = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key: id = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen = unsafe { NSArray::objectAtIndex(screens, i) }; diff --git a/src/platform/mac/display_link.rs b/src/platform/mac/display_link.rs index ce39b4141f..86e9b4072b 100644 --- a/src/platform/mac/display_link.rs +++ b/src/platform/mac/display_link.rs @@ -1,26 +1,21 @@ -use crate::{ - dispatch_get_main_queue, - dispatch_sys::{ - _dispatch_source_type_data_add, dispatch_resume, dispatch_set_context, - dispatch_source_cancel, dispatch_source_create, dispatch_source_merge_data, - dispatch_source_set_event_handler_f, dispatch_source_t, dispatch_suspend, - }, -}; use anyhow::Result; use core_graphics::display::CGDirectDisplayID; +use dispatch2::{ + _dispatch_source_type_data_add, DispatchObject, DispatchQueue, DispatchRetained, DispatchSource, +}; use std::ffi::c_void; use util::ResultExt; pub struct DisplayLink { display_link: Option, - frame_requests: dispatch_source_t, + frame_requests: DispatchRetained, } impl DisplayLink { pub fn new( display_id: CGDirectDisplayID, data: *mut c_void, - callback: unsafe extern "C" fn(*mut c_void), + callback: extern "C" fn(*mut c_void), ) -> Result { unsafe extern "C" fn display_link_callback( _display_link_out: *mut sys::CVDisplayLink, @@ -31,31 +26,27 @@ impl DisplayLink { frame_requests: *mut c_void, ) -> i32 { unsafe { - let frame_requests = frame_requests as dispatch_source_t; - dispatch_source_merge_data(frame_requests, 1); + let frame_requests = &*(frame_requests as *const DispatchSource); + frame_requests.merge_data(1); 0 } } unsafe { - let frame_requests = dispatch_source_create( - &_dispatch_source_type_data_add, + let frame_requests = DispatchSource::new( + &raw const _dispatch_source_type_data_add as *mut _, 0, 0, - dispatch_get_main_queue(), + Some(DispatchQueue::main()), ); - dispatch_set_context( - crate::dispatch_sys::dispatch_object_t { - _ds: frame_requests, - }, - data, - ); - dispatch_source_set_event_handler_f(frame_requests, Some(callback)); + frame_requests.set_context(data); + frame_requests.set_event_handler_f(callback); + frame_requests.resume(); let display_link = sys::DisplayLink::new( display_id, display_link_callback, - frame_requests as *mut c_void, + &*frame_requests as *const DispatchSource as *mut c_void, )?; Ok(Self { @@ -67,9 +58,6 @@ impl DisplayLink { pub fn start(&mut self) -> Result<()> { unsafe { - dispatch_resume(crate::dispatch_sys::dispatch_object_t { - _ds: self.frame_requests, - }); self.display_link.as_mut().unwrap().start()?; } Ok(()) @@ -77,9 +65,6 @@ impl DisplayLink { pub fn stop(&mut self) -> Result<()> { unsafe { - dispatch_suspend(crate::dispatch_sys::dispatch_object_t { - _ds: self.frame_requests, - }); self.display_link.as_mut().unwrap().stop()?; } Ok(()) @@ -97,9 +82,7 @@ impl Drop for DisplayLink { // // We might also want to upgrade to CADisplayLink, but that requires dropping old macOS support. std::mem::forget(self.display_link.take()); - unsafe { - dispatch_source_cancel(self.frame_requests); - } + self.frame_requests.cancel(); } } diff --git a/src/platform/mac/events.rs b/src/platform/mac/events.rs index acc392a5f3..8d4d9ea3ee 100644 --- a/src/platform/mac/events.rs +++ b/src/platform/mac/events.rs @@ -1,12 +1,13 @@ -use crate::{ +use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, - PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase, - platform::mac::{ - LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, - TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, - }, - point, px, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, +}; + +use super::{ + LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, + UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, }; use cocoa::{ appkit::{NSEvent, NSEventModifierFlags, NSEventPhase, NSEventType}, @@ -100,197 +101,236 @@ unsafe fn read_modifiers(native_event: id) -> Modifiers { } } -impl PlatformInput { - pub(crate) unsafe fn from_native( - native_event: id, - window_height: Option, - ) -> Option { - unsafe { - let event_type = native_event.eventType(); +pub(crate) unsafe fn platform_input_from_native( + native_event: id, + window_height: Option, +) -> Option { + unsafe { + let event_type = native_event.eventType(); - // Filter out event types that aren't in the NSEventType enum. - // See https://github.com/servo/cocoa-rs/issues/155#issuecomment-323482792 for details. - match event_type as u64 { - 0 | 21 | 32 | 33 | 35 | 36 | 37 => { - return None; - } - _ => {} + // Filter out event types that aren't in the NSEventType enum. + // See https://github.com/servo/cocoa-rs/issues/155#issuecomment-323482792 for details. + match event_type as u64 { + 0 | 21 | 32 | 33 | 35 | 36 | 37 => { + return None; } + _ => {} + } - match event_type { - NSEventType::NSFlagsChanged => { - Some(Self::ModifiersChanged(ModifiersChangedEvent { + match event_type { + NSEventType::NSFlagsChanged => { + Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent { + modifiers: read_modifiers(native_event), + capslock: Capslock { + on: native_event + .modifierFlags() + .contains(NSEventModifierFlags::NSAlphaShiftKeyMask), + }, + })) + } + NSEventType::NSKeyDown => Some(PlatformInput::KeyDown(KeyDownEvent { + keystroke: parse_keystroke(native_event), + is_held: native_event.isARepeat() == YES, + prefer_character_input: false, + })), + NSEventType::NSKeyUp => Some(PlatformInput::KeyUp(KeyUpEvent { + keystroke: parse_keystroke(native_event), + })), + NSEventType::NSLeftMouseDown + | NSEventType::NSRightMouseDown + | NSEventType::NSOtherMouseDown => { + let button = match native_event.buttonNumber() { + 0 => MouseButton::Left, + 1 => MouseButton::Right, + 2 => MouseButton::Middle, + 3 => MouseButton::Navigate(NavigationDirection::Back), + 4 => MouseButton::Navigate(NavigationDirection::Forward), + // Other mouse buttons aren't tracked currently + _ => return None, + }; + window_height.map(|window_height| { + PlatformInput::MouseDown(MouseDownEvent { + button, + position: point( + px(native_event.locationInWindow().x as f32), + // MacOS screen coordinates are relative to bottom left + window_height - px(native_event.locationInWindow().y as f32), + ), modifiers: read_modifiers(native_event), - capslock: Capslock { - on: native_event - .modifierFlags() - .contains(NSEventModifierFlags::NSAlphaShiftKeyMask), + click_count: native_event.clickCount() as usize, + first_mouse: false, + }) + }) + } + NSEventType::NSLeftMouseUp + | NSEventType::NSRightMouseUp + | NSEventType::NSOtherMouseUp => { + let button = match native_event.buttonNumber() { + 0 => MouseButton::Left, + 1 => MouseButton::Right, + 2 => MouseButton::Middle, + 3 => MouseButton::Navigate(NavigationDirection::Back), + 4 => MouseButton::Navigate(NavigationDirection::Forward), + // Other mouse buttons aren't tracked currently + _ => return None, + }; + + window_height.map(|window_height| { + PlatformInput::MouseUp(MouseUpEvent { + button, + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + modifiers: read_modifiers(native_event), + click_count: native_event.clickCount() as usize, + }) + }) + } + NSEventType::NSEventTypePressure => { + let stage = native_event.stage(); + let pressure = native_event.pressure(); + + window_height.map(|window_height| { + PlatformInput::MousePressure(MousePressureEvent { + stage: match stage { + 1 => PressureStage::Normal, + 2 => PressureStage::Force, + _ => PressureStage::Zero, }, - })) - } - NSEventType::NSKeyDown => Some(Self::KeyDown(KeyDownEvent { - keystroke: parse_keystroke(native_event), - is_held: native_event.isARepeat() == YES, - prefer_character_input: false, - })), - NSEventType::NSKeyUp => Some(Self::KeyUp(KeyUpEvent { - keystroke: parse_keystroke(native_event), - })), - NSEventType::NSLeftMouseDown - | NSEventType::NSRightMouseDown - | NSEventType::NSOtherMouseDown => { - let button = match native_event.buttonNumber() { - 0 => MouseButton::Left, - 1 => MouseButton::Right, - 2 => MouseButton::Middle, - 3 => MouseButton::Navigate(NavigationDirection::Back), - 4 => MouseButton::Navigate(NavigationDirection::Forward), - // Other mouse buttons aren't tracked currently + pressure, + modifiers: read_modifiers(native_event), + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + }) + }) + } + // Some mice (like Logitech MX Master) send navigation buttons as swipe events + NSEventType::NSEventTypeSwipe => { + let navigation_direction = match native_event.phase() { + NSEventPhase::NSEventPhaseEnded => match native_event.deltaX() { + x if x > 0.0 => Some(NavigationDirection::Back), + x if x < 0.0 => Some(NavigationDirection::Forward), _ => return None, - }; - window_height.map(|window_height| { - Self::MouseDown(MouseDownEvent { - button, + }, + _ => return None, + }; + + match navigation_direction { + Some(direction) => window_height.map(|window_height| { + PlatformInput::MouseDown(MouseDownEvent { + button: MouseButton::Navigate(direction), position: point( px(native_event.locationInWindow().x as f32), - // MacOS screen coordinates are relative to bottom left window_height - px(native_event.locationInWindow().y as f32), ), modifiers: read_modifiers(native_event), - click_count: native_event.clickCount() as usize, + click_count: 1, first_mouse: false, }) - }) + }), + _ => None, } - NSEventType::NSLeftMouseUp - | NSEventType::NSRightMouseUp - | NSEventType::NSOtherMouseUp => { - let button = match native_event.buttonNumber() { - 0 => MouseButton::Left, - 1 => MouseButton::Right, - 2 => MouseButton::Middle, - 3 => MouseButton::Navigate(NavigationDirection::Back), - 4 => MouseButton::Navigate(NavigationDirection::Forward), - // Other mouse buttons aren't tracked currently - _ => return None, - }; - - window_height.map(|window_height| { - Self::MouseUp(MouseUpEvent { - button, - position: point( - px(native_event.locationInWindow().x as f32), - window_height - px(native_event.locationInWindow().y as f32), - ), - modifiers: read_modifiers(native_event), - click_count: native_event.clickCount() as usize, - }) - }) - } - // Some mice (like Logitech MX Master) send navigation buttons as swipe events - NSEventType::NSEventTypeSwipe => { - let navigation_direction = match native_event.phase() { - NSEventPhase::NSEventPhaseEnded => match native_event.deltaX() { - x if x > 0.0 => Some(NavigationDirection::Back), - x if x < 0.0 => Some(NavigationDirection::Forward), - _ => return None, - }, - _ => return None, - }; - - match navigation_direction { - Some(direction) => window_height.map(|window_height| { - Self::MouseDown(MouseDownEvent { - button: MouseButton::Navigate(direction), - position: point( - px(native_event.locationInWindow().x as f32), - window_height - px(native_event.locationInWindow().y as f32), - ), - modifiers: read_modifiers(native_event), - click_count: 1, - first_mouse: false, - }) - }), - _ => None, - } - } - NSEventType::NSScrollWheel => window_height.map(|window_height| { - let phase = match native_event.phase() { - NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { - TouchPhase::Started - } - NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, - _ => TouchPhase::Moved, - }; - - let raw_data = point( - native_event.scrollingDeltaX() as f32, - native_event.scrollingDeltaY() as f32, - ); - - let delta = if native_event.hasPreciseScrollingDeltas() == YES { - ScrollDelta::Pixels(raw_data.map(px)) - } else { - ScrollDelta::Lines(raw_data) - }; - - Self::ScrollWheel(ScrollWheelEvent { - position: point( - px(native_event.locationInWindow().x as f32), - window_height - px(native_event.locationInWindow().y as f32), - ), - delta, - touch_phase: phase, - modifiers: read_modifiers(native_event), - }) - }), - NSEventType::NSLeftMouseDragged - | NSEventType::NSRightMouseDragged - | NSEventType::NSOtherMouseDragged => { - let pressed_button = match native_event.buttonNumber() { - 0 => MouseButton::Left, - 1 => MouseButton::Right, - 2 => MouseButton::Middle, - 3 => MouseButton::Navigate(NavigationDirection::Back), - 4 => MouseButton::Navigate(NavigationDirection::Forward), - // Other mouse buttons aren't tracked currently - _ => return None, - }; - - window_height.map(|window_height| { - Self::MouseMove(MouseMoveEvent { - pressed_button: Some(pressed_button), - position: point( - px(native_event.locationInWindow().x as f32), - window_height - px(native_event.locationInWindow().y as f32), - ), - modifiers: read_modifiers(native_event), - }) - }) - } - NSEventType::NSMouseMoved => window_height.map(|window_height| { - Self::MouseMove(MouseMoveEvent { - position: point( - px(native_event.locationInWindow().x as f32), - window_height - px(native_event.locationInWindow().y as f32), - ), - pressed_button: None, - modifiers: read_modifiers(native_event), - }) - }), - NSEventType::NSMouseExited => window_height.map(|window_height| { - Self::MouseExited(MouseExitEvent { - position: point( - px(native_event.locationInWindow().x as f32), - window_height - px(native_event.locationInWindow().y as f32), - ), - - pressed_button: None, - modifiers: read_modifiers(native_event), - }) - }), - _ => None, } + NSEventType::NSEventTypeMagnify => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let magnification = native_event.magnification() as f32; + + PlatformInput::Pinch(PinchEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta: magnification, + modifiers: read_modifiers(native_event), + phase, + }) + }), + NSEventType::NSScrollWheel => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let raw_data = point( + native_event.scrollingDeltaX() as f32, + native_event.scrollingDeltaY() as f32, + ); + + let delta = if native_event.hasPreciseScrollingDeltas() == YES { + ScrollDelta::Pixels(raw_data.map(px)) + } else { + ScrollDelta::Lines(raw_data) + }; + + PlatformInput::ScrollWheel(ScrollWheelEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta, + touch_phase: phase, + modifiers: read_modifiers(native_event), + }) + }), + NSEventType::NSLeftMouseDragged + | NSEventType::NSRightMouseDragged + | NSEventType::NSOtherMouseDragged => { + let pressed_button = match native_event.buttonNumber() { + 0 => MouseButton::Left, + 1 => MouseButton::Right, + 2 => MouseButton::Middle, + 3 => MouseButton::Navigate(NavigationDirection::Back), + 4 => MouseButton::Navigate(NavigationDirection::Forward), + // Other mouse buttons aren't tracked currently + _ => return None, + }; + + window_height.map(|window_height| { + PlatformInput::MouseMove(MouseMoveEvent { + pressed_button: Some(pressed_button), + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + modifiers: read_modifiers(native_event), + }) + }) + } + NSEventType::NSMouseMoved => window_height.map(|window_height| { + PlatformInput::MouseMove(MouseMoveEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + pressed_button: None, + modifiers: read_modifiers(native_event), + }) + }), + NSEventType::NSMouseExited => window_height.map(|window_height| { + PlatformInput::MouseExited(MouseExitEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + + pressed_button: None, + modifiers: read_modifiers(native_event), + }) + }), + _ => None, } } } @@ -299,7 +339,7 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { unsafe { use cocoa::appkit::*; - let mut characters = native_event + let characters = native_event .charactersIgnoringModifiers() .to_str() .to_string(); diff --git a/src/platform/mac/keyboard.rs b/src/platform/mac/keyboard.rs index 1409731246..b94509f38a 100644 --- a/src/platform/mac/keyboard.rs +++ b/src/platform/mac/keyboard.rs @@ -3,7 +3,7 @@ use std::ffi::{CStr, c_void}; use objc::{msg_send, runtime::Object, sel, sel_impl}; -use crate::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper}; +use gpui::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper}; use super::{ TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, kTISPropertyInputSourceID, diff --git a/src/platform/mac/metal_atlas.rs b/src/platform/mac/metal_atlas.rs index 8282530c5e..eacd9407fe 100644 --- a/src/platform/mac/metal_atlas.rs +++ b/src/platform/mac/metal_atlas.rs @@ -1,11 +1,11 @@ -use crate::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas, - Point, Size, platform::AtlasTextureList, -}; use anyhow::{Context as _, Result}; use collections::FxHashMap; use derive_more::{Deref, DerefMut}; use etagere::BucketedAtlasAllocator; +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, + PlatformAtlas, Point, Size, +}; use metal::Device; use parking_lot::Mutex; use std::borrow::Cow; @@ -13,9 +13,10 @@ use std::borrow::Cow; pub(crate) struct MetalAtlas(Mutex); impl MetalAtlas { - pub(crate) fn new(device: Device) -> Self { + pub(crate) fn new(device: Device, is_apple_gpu: bool) -> Self { MetalAtlas(Mutex::new(MetalAtlasState { device: AssertSend(device), + is_apple_gpu, monochrome_textures: Default::default(), polychrome_textures: Default::default(), tiles_by_key: Default::default(), @@ -29,6 +30,7 @@ impl MetalAtlas { struct MetalAtlasState { device: AssertSend, + is_apple_gpu: bool, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, tiles_by_key: FxHashMap, @@ -66,6 +68,7 @@ impl PlatformAtlas for MetalAtlas { let textures = match id.kind { AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, + AtlasTextureKind::Subpixel => unreachable!(), }; let Some(texture_slot) = textures @@ -99,6 +102,7 @@ impl MetalAtlasState { let textures = match texture_kind { AtlasTextureKind::Monochrome => &mut self.monochrome_textures, AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => unreachable!(), }; if let Some(tile) = textures @@ -143,14 +147,23 @@ impl MetalAtlasState { pixel_format = metal::MTLPixelFormat::BGRA8Unorm; usage = metal::MTLTextureUsage::ShaderRead; } + AtlasTextureKind::Subpixel => unreachable!(), } texture_descriptor.set_pixel_format(pixel_format); texture_descriptor.set_usage(usage); + // Shared memory mode can be used only on Apple GPU families + // https://developer.apple.com/documentation/metal/mtlresourceoptions/storagemodeshared + texture_descriptor.set_storage_mode(if self.is_apple_gpu { + metal::MTLStorageMode::Shared + } else { + metal::MTLStorageMode::Managed + }); let metal_texture = self.device.new_texture(&texture_descriptor); let texture_list = match kind { AtlasTextureKind::Monochrome => &mut self.monochrome_textures, AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => unreachable!(), }; let index = texture_list.free_list.pop(); @@ -160,7 +173,7 @@ impl MetalAtlasState { index: index.unwrap_or(texture_list.textures.len()) as u32, kind, }, - allocator: etagere::BucketedAtlasAllocator::new(size.into()), + allocator: etagere::BucketedAtlasAllocator::new(size_to_etagere(size)), metal_texture: AssertSend(metal_texture), live_atlas_keys: 0, }; @@ -179,8 +192,9 @@ impl MetalAtlasState { fn texture(&self, id: AtlasTextureId) -> &MetalAtlasTexture { let textures = match id.kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + AtlasTextureKind::Subpixel => unreachable!(), }; textures[id.index as usize].as_ref().unwrap() } @@ -195,12 +209,12 @@ struct MetalAtlasTexture { impl MetalAtlasTexture { fn allocate(&mut self, size: Size) -> Option { - let allocation = self.allocator.allocate(size.into())?; + let allocation = self.allocator.allocate(size_to_etagere(size))?; let tile = AtlasTile { texture_id: self.id, tile_id: allocation.id.into(), bounds: Bounds { - origin: allocation.rectangle.min.into(), + origin: point_from_etagere(allocation.rectangle.min), size, }, padding: 0, @@ -242,36 +256,14 @@ impl MetalAtlasTexture { } } -impl From> for etagere::Size { - fn from(size: Size) -> Self { - etagere::Size::new(size.width.into(), size.height.into()) - } +fn size_to_etagere(size: Size) -> etagere::Size { + etagere::Size::new(size.width.into(), size.height.into()) } -impl From for Point { - fn from(value: etagere::Point) -> Self { - Point { - x: DevicePixels::from(value.x), - y: DevicePixels::from(value.y), - } - } -} - -impl From for Size { - fn from(size: etagere::Size) -> Self { - Size { - width: DevicePixels::from(size.width), - height: DevicePixels::from(size.height), - } - } -} - -impl From for Bounds { - fn from(rectangle: etagere::Rectangle) -> Self { - Bounds { - origin: rectangle.min.into(), - size: rectangle.size().into(), - } +fn point_from_etagere(value: etagere::Point) -> Point { + Point { + x: DevicePixels::from(value.x), + y: DevicePixels::from(value.y), } } diff --git a/src/platform/mac/metal_renderer.rs b/src/platform/mac/metal_renderer.rs index 550041a0cc..2e8c3f59b5 100644 --- a/src/platform/mac/metal_renderer.rs +++ b/src/platform/mac/metal_renderer.rs @@ -1,9 +1,4 @@ use super::metal_atlas::MetalAtlas; -use crate::{ - AtlasTextureId, Background, Bounds, ContentMask, DevicePixels, MonochromeSprite, PaintSurface, - Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, - Surface, Underline, point, size, -}; use anyhow::Result; use block::ConcreteBlock; use cocoa::{ @@ -11,6 +6,13 @@ use cocoa::{ foundation::{NSSize, NSUInteger}, quartzcore::AutoresizingMask, }; +use gpui::{ + AtlasTextureId, Background, Bounds, ContentMask, DevicePixels, MonochromeSprite, PaintSurface, + Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, + Surface, Underline, point, size, +}; +#[cfg(any(test, feature = "test-support"))] +use image::RgbaImage; use core_foundation::base::TCFType; use core_video::{ @@ -19,7 +21,7 @@ use core_video::{ }; use foreign_types::{ForeignType, ForeignTypeRef}; use metal::{ - CAMetalLayer, CommandQueue, MTLPixelFormat, MTLResourceOptions, NSRange, + CAMetalLayer, CommandQueue, MTLGPUFamily, MTLPixelFormat, MTLResourceOptions, NSRange, RenderPassColorAttachmentDescriptorRef, }; use objc::{self, msg_send, sel, sel_impl}; @@ -28,7 +30,7 @@ use parking_lot::Mutex; use std::{cell::Cell, ffi::c_void, mem, ptr, sync::Arc}; // Exported to metal -pub(crate) type PointF = crate::Point; +pub(crate) type PointF = gpui::Point; #[cfg(not(feature = "runtime_shaders"))] const SHADERS_METALLIB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/shaders.metallib")); @@ -38,17 +40,17 @@ const SHADERS_SOURCE_FILE: &str = include_str!(concat!(env!("OUT_DIR"), "/stitch // https://developer.apple.com/documentation/metal/mtldevice/1433355-supportstexturesamplecount const PATH_SAMPLE_COUNT: u32 = 4; -pub type Context = Arc>; -pub type Renderer = MetalRenderer; +pub(crate) type Context = Arc>; +pub(crate) type Renderer = MetalRenderer; -pub unsafe fn new_renderer( +pub(crate) unsafe fn new_renderer( context: self::Context, _native_window: *mut c_void, _native_view: *mut c_void, - _bounds: crate::Size, - _transparent: bool, + _bounds: gpui::Size, + transparent: bool, ) -> Renderer { - MetalRenderer::new(context) + MetalRenderer::new(context, transparent) } pub(crate) struct InstanceBufferPool { @@ -76,12 +78,22 @@ impl InstanceBufferPool { self.buffers.clear(); } - pub(crate) fn acquire(&mut self, device: &metal::Device) -> InstanceBuffer { + pub(crate) fn acquire( + &mut self, + device: &metal::Device, + unified_memory: bool, + ) -> InstanceBuffer { let buffer = self.buffers.pop().unwrap_or_else(|| { - device.new_buffer( - self.buffer_size as u64, - MTLResourceOptions::StorageModeManaged, - ) + let options = if unified_memory { + MTLResourceOptions::StorageModeShared + // Buffers are write only which can benefit from the combined cache + // https://developer.apple.com/documentation/metal/mtlresourceoptions/cpucachemodewritecombined + | MTLResourceOptions::CPUCacheModeWriteCombined + } else { + MTLResourceOptions::StorageModeManaged + }; + + device.new_buffer(self.buffer_size as u64, options) }); InstanceBuffer { metal_buffer: buffer, @@ -98,8 +110,12 @@ impl InstanceBufferPool { pub(crate) struct MetalRenderer { device: metal::Device, - layer: metal::MetalLayer, + layer: Option, + is_apple_gpu: bool, + is_unified_memory: bool, presents_with_transaction: bool, + /// For headless rendering, tracks whether output should be opaque + opaque: bool, command_queue: CommandQueue, paths_rasterization_pipeline_state: metal::RenderPipelineState, path_sprites_pipeline_state: metal::RenderPipelineState, @@ -128,11 +144,48 @@ pub struct PathRasterizationVertex { } impl MetalRenderer { - pub fn new(instance_buffer_pool: Arc>) -> Self { + /// Creates a new MetalRenderer with a CAMetalLayer for window-based rendering. + pub fn new(instance_buffer_pool: Arc>, transparent: bool) -> Self { + let device = Self::create_device(); + + let layer = metal::MetalLayer::new(); + layer.set_device(&device); + layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + // Support direct-to-display rendering if the window is not transparent + // https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos + layer.set_opaque(!transparent); + layer.set_maximum_drawable_count(3); + // Allow texture reading for visual tests (captures screenshots without ScreenCaptureKit) + #[cfg(any(test, feature = "test-support"))] + layer.set_framebuffer_only(false); + unsafe { + let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO]; + let _: () = msg_send![&*layer, setNeedsDisplayOnBoundsChange: YES]; + let _: () = msg_send![ + &*layer, + setAutoresizingMask: AutoresizingMask::WIDTH_SIZABLE + | AutoresizingMask::HEIGHT_SIZABLE + ]; + } + + Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool) + } + + /// Creates a new headless MetalRenderer for offscreen rendering without a window. + /// + /// This renderer can render scenes to images without requiring a CAMetalLayer, + /// window, or AppKit. Use `render_scene_to_image()` to render scenes. + #[cfg(any(test, feature = "test-support"))] + pub fn new_headless(instance_buffer_pool: Arc>) -> Self { + let device = Self::create_device(); + Self::new_internal(device, None, true, instance_buffer_pool) + } + + fn create_device() -> metal::Device { // Prefer low‐power integrated GPUs on Intel Mac. On Apple // Silicon, there is only ever one GPU, so this is equivalent to // `metal::Device::system_default()`. - let device = if let Some(d) = metal::Device::all() + if let Some(d) = metal::Device::all() .into_iter() .min_by_key(|d| (d.is_removable(), !d.is_low_power())) { @@ -147,22 +200,15 @@ impl MetalRenderer { log::error!("unable to access a compatible graphics device"); std::process::exit(1); }) - }; - - let layer = metal::MetalLayer::new(); - layer.set_device(&device); - layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - layer.set_opaque(false); - layer.set_maximum_drawable_count(3); - unsafe { - let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO]; - let _: () = msg_send![&*layer, setNeedsDisplayOnBoundsChange: YES]; - let _: () = msg_send![ - &*layer, - setAutoresizingMask: AutoresizingMask::WIDTH_SIZABLE - | AutoresizingMask::HEIGHT_SIZABLE - ]; } + } + + fn new_internal( + device: metal::Device, + layer: Option, + opaque: bool, + instance_buffer_pool: Arc>, + ) -> Self { #[cfg(feature = "runtime_shaders")] let library = device .new_library_with_source(&SHADERS_SOURCE_FILE, &metal::CompileOptions::new()) @@ -179,6 +225,15 @@ impl MetalRenderer { output } + // Shared memory can be used only if CPU and GPU share the same memory space. + // https://developer.apple.com/documentation/metal/setting-resource-storage-modes + let is_unified_memory = device.has_unified_memory(); + // Apple GPU families support memoryless textures, which can significantly reduce + // memory usage by keeping render targets in on-chip tile memory instead of + // allocating backing store in system memory. + // https://developer.apple.com/documentation/metal/mtlgpufamily + let is_apple_gpu = device.supports_family(MTLGPUFamily::Apple1); + let unit_vertices = [ to_float2_bits(point(0., 0.)), to_float2_bits(point(1., 0.)), @@ -190,7 +245,12 @@ impl MetalRenderer { let unit_vertices = device.new_buffer_with_data( unit_vertices.as_ptr() as *const c_void, mem::size_of_val(&unit_vertices) as u64, - MTLResourceOptions::StorageModeManaged, + if is_unified_memory { + MTLResourceOptions::StorageModeShared + | MTLResourceOptions::CPUCacheModeWriteCombined + } else { + MTLResourceOptions::StorageModeManaged + }, ); let paths_rasterization_pipeline_state = build_path_rasterization_pipeline_state( @@ -260,7 +320,7 @@ impl MetalRenderer { ); let command_queue = device.new_command_queue(); - let sprite_atlas = Arc::new(MetalAtlas::new(device.clone())); + let sprite_atlas = Arc::new(MetalAtlas::new(device.clone(), is_apple_gpu)); let core_video_texture_cache = CVMetalTextureCache::new(None, device.clone(), None).unwrap(); @@ -268,6 +328,9 @@ impl MetalRenderer { device, layer, presents_with_transaction: false, + is_apple_gpu, + is_unified_memory, + opaque, command_queue, paths_rasterization_pipeline_state, path_sprites_pipeline_state, @@ -287,12 +350,15 @@ impl MetalRenderer { } } - pub fn layer(&self) -> &metal::MetalLayerRef { - &self.layer + pub fn layer(&self) -> Option<&metal::MetalLayerRef> { + self.layer.as_ref().map(|l| l.as_ref()) } pub fn layer_ptr(&self) -> *mut CAMetalLayer { - self.layer.as_ptr() + self.layer + .as_ref() + .map(|l| l.as_ptr()) + .unwrap_or(ptr::null_mut()) } pub fn sprite_atlas(&self) -> &Arc { @@ -301,26 +367,25 @@ impl MetalRenderer { pub fn set_presents_with_transaction(&mut self, presents_with_transaction: bool) { self.presents_with_transaction = presents_with_transaction; - self.layer - .set_presents_with_transaction(presents_with_transaction); + if let Some(layer) = &self.layer { + layer.set_presents_with_transaction(presents_with_transaction); + } } pub fn update_drawable_size(&mut self, size: Size) { - let size = NSSize { - width: size.width.0 as f64, - height: size.height.0 as f64, - }; - unsafe { - let _: () = msg_send![ - self.layer(), - setDrawableSize: size - ]; + if let Some(layer) = &self.layer { + let ns_size = NSSize { + width: size.width.0 as f64, + height: size.height.0 as f64, + }; + unsafe { + let _: () = msg_send![ + layer.as_ref(), + setDrawableSize: ns_size + ]; + } } - let device_pixels_size = Size { - width: DevicePixels(size.width as i32), - height: DevicePixels(size.height as i32), - }; - self.update_path_intermediate_textures(device_pixels_size); + self.update_path_intermediate_textures(size); } fn update_path_intermediate_textures(&mut self, size: Size) { @@ -337,14 +402,23 @@ impl MetalRenderer { texture_descriptor.set_width(size.width.0 as u64); texture_descriptor.set_height(size.height.0 as u64); texture_descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); texture_descriptor .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor)); if self.path_sample_count > 1 { - let mut msaa_descriptor = texture_descriptor; + // https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus + // Rendering MSAA textures are done in a single pass, so we can use memory-less storage on Apple Silicon + let storage_mode = if self.is_apple_gpu { + metal::MTLStorageMode::Memoryless + } else { + metal::MTLStorageMode::Private + }; + + let msaa_descriptor = texture_descriptor; msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample); - msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + msaa_descriptor.set_storage_mode(storage_mode); msaa_descriptor.set_sample_count(self.path_sample_count as _); self.path_intermediate_msaa_texture = Some(self.device.new_texture(&msaa_descriptor)); } else { @@ -352,8 +426,11 @@ impl MetalRenderer { } } - pub fn update_transparency(&self, _transparent: bool) { - // todo(mac)? + pub fn update_transparency(&mut self, transparent: bool) { + self.opaque = !transparent; + if let Some(layer) = &self.layer { + layer.set_opaque(!transparent); + } } pub fn destroy(&self) { @@ -361,7 +438,15 @@ impl MetalRenderer { } pub fn draw(&mut self, scene: &Scene) { - let layer = self.layer.clone(); + let layer = match &self.layer { + Some(l) => l.clone(), + None => { + log::error!( + "draw() called on headless renderer - use render_scene_to_image() instead" + ); + return; + } + }; let viewport_size = layer.drawable_size(); let viewport_size: Size = size( (viewport_size.width.ceil() as i32).into(), @@ -378,7 +463,10 @@ impl MetalRenderer { }; loop { - let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device); + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); let command_buffer = self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size); @@ -426,21 +514,246 @@ impl MetalRenderer { } } + /// Renders the scene to a texture and returns the pixel data as an RGBA image. + /// This does not present the frame to screen - useful for visual testing + /// where we want to capture what would be rendered without displaying it. + /// + /// Note: This requires a layer-backed renderer. For headless rendering, + /// use `render_scene_to_image()` instead. + #[cfg(any(test, feature = "test-support"))] + pub fn render_to_image(&mut self, scene: &Scene) -> Result { + let layer = self + .layer + .clone() + .ok_or_else(|| anyhow::anyhow!("render_to_image requires a layer-backed renderer"))?; + let viewport_size = layer.drawable_size(); + let viewport_size: Size = size( + (viewport_size.width.ceil() as i32).into(), + (viewport_size.height.ceil() as i32).into(), + ); + let drawable = layer + .next_drawable() + .ok_or_else(|| anyhow::anyhow!("Failed to get drawable for render_to_image"))?; + + loop { + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); + + let command_buffer = + self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size); + + match command_buffer { + Ok(command_buffer) => { + let instance_buffer_pool = self.instance_buffer_pool.clone(); + let instance_buffer = Cell::new(Some(instance_buffer)); + let block = ConcreteBlock::new(move |_| { + if let Some(instance_buffer) = instance_buffer.take() { + instance_buffer_pool.lock().release(instance_buffer); + } + }); + let block = block.copy(); + command_buffer.add_completed_handler(&block); + + // Commit and wait for completion without presenting + command_buffer.commit(); + command_buffer.wait_until_completed(); + + // Read pixels from the texture + let texture = drawable.texture(); + let width = texture.width() as u32; + let height = texture.height() as u32; + let bytes_per_row = width as usize * 4; + let buffer_size = height as usize * bytes_per_row; + + let mut pixels = vec![0u8; buffer_size]; + + let region = metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: width as u64, + height: height as u64, + depth: 1, + }, + }; + + texture.get_bytes( + pixels.as_mut_ptr() as *mut std::ffi::c_void, + bytes_per_row as u64, + region, + 0, + ); + + // Convert BGRA to RGBA (swap B and R channels) + for chunk in pixels.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + + return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| { + anyhow::anyhow!("Failed to create RgbaImage from pixel data") + }); + } + Err(err) => { + log::error!( + "failed to render: {}. retrying with larger instance buffer size", + err + ); + let mut instance_buffer_pool = self.instance_buffer_pool.lock(); + let buffer_size = instance_buffer_pool.buffer_size; + if buffer_size >= 256 * 1024 * 1024 { + anyhow::bail!("instance buffer size grew too large: {}", buffer_size); + } + instance_buffer_pool.reset(buffer_size * 2); + log::info!( + "increased instance buffer size to {}", + instance_buffer_pool.buffer_size + ); + } + } + } + } + + /// Renders a scene to an image without requiring a window or CAMetalLayer. + /// + /// This is the primary method for headless rendering. It creates an offscreen + /// texture, renders the scene to it, and returns the pixel data as an RGBA image. + #[cfg(any(test, feature = "test-support"))] + pub fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> Result { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size); + } + + // Update path intermediate textures for this size + self.update_path_intermediate_textures(size); + + // Create an offscreen texture as render target + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor + .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); + let target_texture = self.device.new_texture(&texture_descriptor); + + loop { + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); + + let command_buffer = + self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size); + + match command_buffer { + Ok(command_buffer) => { + let instance_buffer_pool = self.instance_buffer_pool.clone(); + let instance_buffer = Cell::new(Some(instance_buffer)); + let block = ConcreteBlock::new(move |_| { + if let Some(instance_buffer) = instance_buffer.take() { + instance_buffer_pool.lock().release(instance_buffer); + } + }); + let block = block.copy(); + command_buffer.add_completed_handler(&block); + + // On discrete GPUs (non-unified memory), Managed textures + // require an explicit blit synchronize before the CPU can + // read back the rendered data. Without this, get_bytes + // returns stale zeros. + if !self.is_unified_memory { + let blit = command_buffer.new_blit_command_encoder(); + blit.synchronize_resource(&target_texture); + blit.end_encoding(); + } + + // Commit and wait for completion + command_buffer.commit(); + command_buffer.wait_until_completed(); + + // Read pixels from the texture + let width = size.width.0 as u32; + let height = size.height.0 as u32; + let bytes_per_row = width as usize * 4; + let buffer_size = height as usize * bytes_per_row; + + let mut pixels = vec![0u8; buffer_size]; + + let region = metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: width as u64, + height: height as u64, + depth: 1, + }, + }; + + target_texture.get_bytes( + pixels.as_mut_ptr() as *mut std::ffi::c_void, + bytes_per_row as u64, + region, + 0, + ); + + // Convert BGRA to RGBA (swap B and R channels) + for chunk in pixels.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + + return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| { + anyhow::anyhow!("Failed to create RgbaImage from pixel data") + }); + } + Err(err) => { + log::error!( + "failed to render: {}. retrying with larger instance buffer size", + err + ); + let mut instance_buffer_pool = self.instance_buffer_pool.lock(); + let buffer_size = instance_buffer_pool.buffer_size; + if buffer_size >= 256 * 1024 * 1024 { + anyhow::bail!("instance buffer size grew too large: {}", buffer_size); + } + instance_buffer_pool.reset(buffer_size * 2); + log::info!( + "increased instance buffer size to {}", + instance_buffer_pool.buffer_size + ); + } + } + } + } + fn draw_primitives( &mut self, scene: &Scene, instance_buffer: &mut InstanceBuffer, drawable: &metal::MetalDrawableRef, viewport_size: Size, + ) -> Result { + self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size) + } + + fn draw_primitives_to_texture( + &mut self, + scene: &Scene, + instance_buffer: &mut InstanceBuffer, + texture: &metal::TextureRef, + viewport_size: Size, ) -> Result { let command_queue = self.command_queue.clone(); let command_buffer = command_queue.new_command_buffer(); - let alpha = if self.layer.is_opaque() { 1. } else { 0. }; + let alpha = if self.opaque { 1. } else { 0. }; let mut instance_offset = 0; - let mut command_encoder = new_command_encoder( + let mut command_encoder = new_command_encoder_for_texture( command_buffer, - drawable, + texture, viewport_size, |color_attachment| { color_attachment.set_load_action(metal::MTLLoadAction::Clear); @@ -450,21 +763,22 @@ impl MetalRenderer { for batch in scene.batches() { let ok = match batch { - PrimitiveBatch::Shadows(shadows) => self.draw_shadows( - shadows, + PrimitiveBatch::Shadows(range) => self.draw_shadows( + &scene.shadows[range], instance_buffer, &mut instance_offset, viewport_size, command_encoder, ), - PrimitiveBatch::Quads(quads) => self.draw_quads( - quads, + PrimitiveBatch::Quads(range) => self.draw_quads( + &scene.quads[range], instance_buffer, &mut instance_offset, viewport_size, command_encoder, ), - PrimitiveBatch::Paths(paths) => { + PrimitiveBatch::Paths(range) => { + let paths = &scene.paths[range]; command_encoder.end_encoding(); let did_draw = self.draw_paths_to_intermediate( @@ -475,9 +789,9 @@ impl MetalRenderer { command_buffer, ); - command_encoder = new_command_encoder( + command_encoder = new_command_encoder_for_texture( command_buffer, - drawable, + texture, viewport_size, |color_attachment| { color_attachment.set_load_action(metal::MTLLoadAction::Load); @@ -496,42 +810,39 @@ impl MetalRenderer { false } } - PrimitiveBatch::Underlines(underlines) => self.draw_underlines( - underlines, + PrimitiveBatch::Underlines(range) => self.draw_underlines( + &scene.underlines[range], instance_buffer, &mut instance_offset, viewport_size, command_encoder, ), - PrimitiveBatch::MonochromeSprites { - texture_id, - sprites, - } => self.draw_monochrome_sprites( - texture_id, - sprites, - instance_buffer, - &mut instance_offset, - viewport_size, - command_encoder, - ), - PrimitiveBatch::PolychromeSprites { - texture_id, - sprites, - } => self.draw_polychrome_sprites( - texture_id, - sprites, - instance_buffer, - &mut instance_offset, - viewport_size, - command_encoder, - ), - PrimitiveBatch::Surfaces(surfaces) => self.draw_surfaces( - surfaces, + PrimitiveBatch::MonochromeSprites { texture_id, range } => self + .draw_monochrome_sprites( + texture_id, + &scene.monochrome_sprites[range], + instance_buffer, + &mut instance_offset, + viewport_size, + command_encoder, + ), + PrimitiveBatch::PolychromeSprites { texture_id, range } => self + .draw_polychrome_sprites( + texture_id, + &scene.polychrome_sprites[range], + instance_buffer, + &mut instance_offset, + viewport_size, + command_encoder, + ), + PrimitiveBatch::Surfaces(range) => self.draw_surfaces( + &scene.surfaces[range], instance_buffer, &mut instance_offset, viewport_size, command_encoder, ), + PrimitiveBatch::SubpixelSprites { .. } => unreachable!(), }; if !ok { command_encoder.end_encoding(); @@ -550,10 +861,14 @@ impl MetalRenderer { command_encoder.end_encoding(); - instance_buffer.metal_buffer.did_modify_range(NSRange { - location: 0, - length: instance_offset as NSUInteger, - }); + if !self.is_unified_memory { + // Sync the instance buffer to the GPU + instance_buffer.metal_buffer.did_modify_range(NSRange { + location: 0, + length: instance_offset as NSUInteger, + }); + } + Ok(command_buffer.to_owned()) } @@ -1166,9 +1481,9 @@ impl MetalRenderer { } } -fn new_command_encoder<'a>( +fn new_command_encoder_for_texture<'a>( command_buffer: &'a metal::CommandBufferRef, - drawable: &'a metal::MetalDrawableRef, + texture: &'a metal::TextureRef, viewport_size: Size, configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef), ) -> &'a metal::RenderCommandEncoderRef { @@ -1177,7 +1492,7 @@ fn new_command_encoder<'a>( .color_attachments() .object_at(0) .unwrap(); - color_attachment.set_texture(Some(drawable.texture())); + color_attachment.set_texture(Some(texture)); color_attachment.set_store_action(metal::MTLStoreAction::Store); configure_color_attachment(color_attachment); @@ -1363,3 +1678,32 @@ pub struct SurfaceBounds { pub bounds: Bounds, pub content_mask: ContentMask, } + +#[cfg(any(test, feature = "test-support"))] +pub struct MetalHeadlessRenderer { + renderer: MetalRenderer, +} + +#[cfg(any(test, feature = "test-support"))] +impl MetalHeadlessRenderer { + pub fn new() -> Self { + let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default())); + let renderer = MetalRenderer::new_headless(instance_buffer_pool); + Self { renderer } + } +} + +#[cfg(any(test, feature = "test-support"))] +impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.renderer.render_scene_to_image(scene, size) + } + + fn sprite_atlas(&self) -> Arc { + self.renderer.sprite_atlas().clone() + } +} diff --git a/src/platform/mac/open_type.rs b/src/platform/mac/open_type.rs index 37a29559fd..048ba13dd1 100644 --- a/src/platform/mac/open_type.rs +++ b/src/platform/mac/open_type.rs @@ -1,6 +1,5 @@ #![allow(unused, non_upper_case_globals)] -use crate::{FontFallbacks, FontFeatures}; use cocoa::appkit::CGFloat; use core_foundation::{ array::{ @@ -25,6 +24,7 @@ use core_text::{ }, }; use font_kit::font::Font as FontKitFont; +use gpui::{FontFallbacks, FontFeatures}; use std::ptr; pub fn apply_features_and_fallbacks( @@ -52,6 +52,11 @@ pub fn apply_features_and_fallbacks( &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks, ); + + for value in &values { + CFRelease(*value as _); + } + let new_descriptor = CTFontDescriptorCreateWithAttributes(attrs); CFRelease(attrs as _); let new_descriptor = CTFontDescriptor::wrap_under_create_rule(new_descriptor); diff --git a/src/platform/mac/pasteboard.rs b/src/platform/mac/pasteboard.rs new file mode 100644 index 0000000000..d2c07a0cac --- /dev/null +++ b/src/platform/mac/pasteboard.rs @@ -0,0 +1,525 @@ +use core::slice; +use std::ffi::{CStr, c_void}; +use std::path::PathBuf; + +use cocoa::{ + appkit::{ + NSFilenamesPboardType, NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, + NSPasteboardTypeTIFF, + }, + base::{id, nil}, + foundation::{NSArray, NSData, NSFastEnumeration, NSString}, +}; +use objc::{msg_send, runtime::Object, sel, sel_impl}; +use smallvec::SmallVec; +use strum::IntoEnumIterator as _; + +use super::ns_string; +use gpui::{ + ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash, +}; + +pub struct Pasteboard { + inner: id, + text_hash_type: id, + metadata_type: id, +} + +impl Pasteboard { + pub fn general() -> Self { + unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) } + } + + pub fn find() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) } + } + + #[cfg(test)] + pub fn unique() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) } + } + + unsafe fn new(inner: id) -> Self { + Self { + inner, + text_hash_type: unsafe { ns_string("zed-text-hash") }, + metadata_type: unsafe { ns_string("zed-metadata") }, + } + } + + pub fn read(&self) -> Option { + unsafe { + // Check for file paths first + let filenames = NSPasteboard::propertyListForType(self.inner, NSFilenamesPboardType); + if filenames != nil && NSArray::count(filenames) > 0 { + let mut paths = SmallVec::new(); + for file in filenames.iter() { + let f = NSString::UTF8String(file); + let path = CStr::from_ptr(f).to_string_lossy().into_owned(); + paths.push(PathBuf::from(path)); + } + if !paths.is_empty() { + let mut entries = vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths))]; + + // Also include the string representation so text editors can + // paste the path as text. + if let Some(string_item) = self.read_string_from_pasteboard() { + entries.push(string_item); + } + + return Some(ClipboardItem { entries }); + } + } + + // Next, check for a plain string. + if let Some(string_entry) = self.read_string_from_pasteboard() { + return Some(ClipboardItem { + entries: vec![string_entry], + }); + } + + // Finally, try the various supported image types. + for format in ImageFormat::iter() { + if let Some(item) = self.read_image(format) { + return Some(item); + } + } + } + + None + } + + fn read_image(&self, format: ImageFormat) -> Option { + let ut_type: UTType = format.into(); + + unsafe { + let types: id = self.inner.types(); + if msg_send![types, containsObject: ut_type.inner()] { + self.data_for_type(ut_type.inner_mut()).map(|bytes| { + let bytes = bytes.to_vec(); + let id = hash(&bytes); + + ClipboardItem { + entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], + } + }) + } else { + None + } + } + } + + unsafe fn read_string_from_pasteboard(&self) -> Option { + unsafe { + let pasteboard_types: id = self.inner.types(); + let string_type: id = ns_string("public.utf8-plain-text"); + + if !msg_send![pasteboard_types, containsObject: string_type] { + return None; + } + + let data = self.inner.dataForType(string_type); + let text_bytes: &[u8] = if data == nil { + return None; + } else if data.bytes().is_null() { + // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc + // "If the length of the NSData object is 0, this property returns nil." + &[] + } else { + slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize) + }; + + let text = String::from_utf8_lossy(text_bytes).to_string(); + let metadata = self + .data_for_type(self.text_hash_type) + .and_then(|hash_bytes| { + let hash_bytes = hash_bytes.try_into().ok()?; + let hash = u64::from_be_bytes(hash_bytes); + let metadata = self.data_for_type(self.metadata_type)?; + + if hash == ClipboardString::text_hash(&text) { + String::from_utf8(metadata.to_vec()).ok() + } else { + None + } + }); + + Some(ClipboardEntry::String(ClipboardString { text, metadata })) + } + } + + unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> { + unsafe { + let data = self.inner.dataForType(kind); + if data == nil { + None + } else { + Some(slice::from_raw_parts( + data.bytes() as *mut u8, + data.length() as usize, + )) + } + } + } + + pub fn write(&self, item: ClipboardItem) { + unsafe { + match item.entries.as_slice() { + [] => { + // Writing an empty list of entries just clears the clipboard. + self.inner.clearContents(); + } + [ClipboardEntry::String(string)] => { + self.write_plaintext(string); + } + [ClipboardEntry::Image(image)] => { + self.write_image(image); + } + [ClipboardEntry::ExternalPaths(_)] => {} + _ => { + // Agus NB: We're currently only writing string entries to the clipboard when we have more than one. + // + // This was the existing behavior before I refactored the outer clipboard code: + // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110 + // + // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor. + + let mut combined = ClipboardString { + text: String::new(), + metadata: None, + }; + + for entry in item.entries { + match entry { + ClipboardEntry::String(text) => { + combined.text.push_str(&text.text()); + if combined.metadata.is_none() { + combined.metadata = text.metadata; + } + } + _ => {} + } + } + + self.write_plaintext(&combined); + } + } + } + } + + fn write_plaintext(&self, string: &ClipboardString) { + unsafe { + self.inner.clearContents(); + + let text_bytes = NSData::dataWithBytes_length_( + nil, + string.text.as_ptr() as *const c_void, + string.text.len() as u64, + ); + self.inner + .setData_forType(text_bytes, NSPasteboardTypeString); + + if let Some(metadata) = string.metadata.as_ref() { + let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); + let hash_bytes = NSData::dataWithBytes_length_( + nil, + hash_bytes.as_ptr() as *const c_void, + hash_bytes.len() as u64, + ); + self.inner.setData_forType(hash_bytes, self.text_hash_type); + + let metadata_bytes = NSData::dataWithBytes_length_( + nil, + metadata.as_ptr() as *const c_void, + metadata.len() as u64, + ); + self.inner + .setData_forType(metadata_bytes, self.metadata_type); + } + } + } + + unsafe fn write_image(&self, image: &Image) { + unsafe { + self.inner.clearContents(); + + let bytes = NSData::dataWithBytes_length_( + nil, + image.bytes.as_ptr() as *const c_void, + image.bytes.len() as u64, + ); + + self.inner + .setData_forType(bytes, Into::::into(image.format).inner_mut()); + } + } +} + +#[link(name = "AppKit", kind = "framework")] +unsafe extern "C" { + /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc) + pub static NSPasteboardNameFind: id; +} + +impl From for UTType { + fn from(value: ImageFormat) -> Self { + match value { + ImageFormat::Png => Self::png(), + ImageFormat::Jpeg => Self::jpeg(), + ImageFormat::Tiff => Self::tiff(), + ImageFormat::Webp => Self::webp(), + ImageFormat::Gif => Self::gif(), + ImageFormat::Bmp => Self::bmp(), + ImageFormat::Svg => Self::svg(), + ImageFormat::Ico => Self::ico(), + } + } +} + +// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ +pub struct UTType(id); + +impl UTType { + pub fn png() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png + Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType + } + + pub fn jpeg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg + Self(unsafe { ns_string("public.jpeg") }) + } + + pub fn gif() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif + Self(unsafe { ns_string("com.compuserve.gif") }) + } + + pub fn webp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp + Self(unsafe { ns_string("org.webmproject.webp") }) + } + + pub fn bmp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp + Self(unsafe { ns_string("com.microsoft.bmp") }) + } + + pub fn svg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg + Self(unsafe { ns_string("public.svg-image") }) + } + + pub fn ico() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico + Self(unsafe { ns_string("com.microsoft.ico") }) + } + + pub fn tiff() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff + Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType + } + + fn inner(&self) -> *const Object { + self.0 + } + + pub fn inner_mut(&self) -> *mut Object { + self.0 as *mut _ + } +} + +#[cfg(test)] +mod tests { + use cocoa::{ + appkit::{NSFilenamesPboardType, NSPasteboard, NSPasteboardTypeString}, + base::{id, nil}, + foundation::{NSArray, NSData}, + }; + use std::ffi::c_void; + + use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, ImageFormat}; + + use super::*; + + unsafe fn simulate_external_file_copy(pasteboard: &Pasteboard, paths: &[&str]) { + unsafe { + let ns_paths: Vec = paths.iter().map(|p| ns_string(p)).collect(); + let ns_array = NSArray::arrayWithObjects(nil, &ns_paths); + + let mut types = vec![NSFilenamesPboardType]; + types.push(NSPasteboardTypeString); + + let types_array = NSArray::arrayWithObjects(nil, &types); + pasteboard.inner.declareTypes_owner(types_array, nil); + + pasteboard + .inner + .setPropertyList_forType(ns_array, NSFilenamesPboardType); + + let joined = paths.join("\n"); + let bytes = NSData::dataWithBytes_length_( + nil, + joined.as_ptr() as *const c_void, + joined.len() as u64, + ); + pasteboard + .inner + .setData_forType(bytes, NSPasteboardTypeString); + } + } + + #[test] + fn test_string() { + let pasteboard = Pasteboard::unique(); + assert_eq!(pasteboard.read(), None); + + let item = ClipboardItem::new_string("1".to_string()); + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let item = ClipboardItem { + entries: vec![ClipboardEntry::String( + ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), + )], + }; + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let text_from_other_app = "text from other app"; + unsafe { + let bytes = NSData::dataWithBytes_length_( + nil, + text_from_other_app.as_ptr() as *const c_void, + text_from_other_app.len() as u64, + ); + pasteboard + .inner + .setData_forType(bytes, NSPasteboardTypeString); + } + assert_eq!( + pasteboard.read(), + Some(ClipboardItem::new_string(text_from_other_app.to_string())) + ); + } + + #[test] + fn test_read_external_path() { + let pasteboard = Pasteboard::unique(); + + unsafe { + simulate_external_file_copy(&pasteboard, &["/test.txt"]); + } + + let item = pasteboard.read().expect("should read clipboard item"); + + // Test both ExternalPaths and String entries exist + assert_eq!(item.entries.len(), 2); + + // Test first entry is ExternalPaths + match &item.entries[0] { + ClipboardEntry::ExternalPaths(ep) => { + assert_eq!(ep.paths(), &[PathBuf::from("/test.txt")]); + } + other => panic!("expected ExternalPaths, got {:?}", other), + } + + // Test second entry is String + match &item.entries[1] { + ClipboardEntry::String(s) => { + assert_eq!(s.text(), "/test.txt"); + } + other => panic!("expected String, got {:?}", other), + } + } + + #[test] + fn test_read_external_paths_with_spaces() { + let pasteboard = Pasteboard::unique(); + let paths = ["/some file with spaces.txt"]; + + unsafe { + simulate_external_file_copy(&pasteboard, &paths); + } + + let item = pasteboard.read().expect("should read clipboard item"); + + match &item.entries[0] { + ClipboardEntry::ExternalPaths(ep) => { + assert_eq!(ep.paths(), &[PathBuf::from("/some file with spaces.txt")]); + } + other => panic!("expected ExternalPaths, got {:?}", other), + } + } + + #[test] + fn test_read_multiple_external_paths() { + let pasteboard = Pasteboard::unique(); + let paths = ["/file.txt", "/image.png"]; + + unsafe { + simulate_external_file_copy(&pasteboard, &paths); + } + + let item = pasteboard.read().expect("should read clipboard item"); + assert_eq!(item.entries.len(), 2); + + // Test both ExternalPaths and String entries exist + match &item.entries[0] { + ClipboardEntry::ExternalPaths(ep) => { + assert_eq!( + ep.paths(), + &[PathBuf::from("/file.txt"), PathBuf::from("/image.png"),] + ); + } + other => panic!("expected ExternalPaths, got {:?}", other), + } + + match &item.entries[1] { + ClipboardEntry::String(s) => { + assert_eq!(s.text(), "/file.txt\n/image.png"); + assert_eq!(s.metadata, None); + } + other => panic!("expected String, got {:?}", other), + } + } + + #[test] + fn test_read_image() { + let pasteboard = Pasteboard::unique(); + + // Smallest valid PNG: 1x1 transparent pixel + let png_bytes: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + + unsafe { + let ns_png_type = NSPasteboardTypePNG; + let types_array = NSArray::arrayWithObjects(nil, &[ns_png_type]); + pasteboard.inner.declareTypes_owner(types_array, nil); + + let data = NSData::dataWithBytes_length_( + nil, + png_bytes.as_ptr() as *const c_void, + png_bytes.len() as u64, + ); + pasteboard.inner.setData_forType(data, ns_png_type); + } + + let item = pasteboard.read().expect("should read PNG image"); + + // Test Image entry exists + assert_eq!(item.entries.len(), 1); + match &item.entries[0] { + ClipboardEntry::Image(img) => { + assert_eq!(img.format, ImageFormat::Png); + assert_eq!(img.bytes, png_bytes); + } + other => panic!("expected Image, got {:?}", other), + } + } +} diff --git a/src/platform/mac/platform.rs b/src/platform/mac/platform.rs index c2363afe27..69f8e91e6f 100644 --- a/src/platform/mac/platform.rs +++ b/src/platform/mac/platform.rs @@ -1,29 +1,19 @@ use super::{ - BoolExt, MacKeyboardLayout, MacKeyboardMapper, - attributed_string::{NSAttributedString, NSMutableAttributedString}, - events::key_to_native, - renderer, -}; -use crate::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, - CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, - MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + BoolExt, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, MacWindow, + events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, }; +use crate::command::{new_command, new_std_command}; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, - NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString, - NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, + NSControl as _, NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, + NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ - NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString, - NSUInteger, NSURL, + NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL, }, }; use core_foundation::{ @@ -35,7 +25,14 @@ use core_foundation::{ string::{CFString, CFStringRef}, }; use ctor::ctor; +use dispatch2::DispatchQueue; use futures::channel::oneshot; +use gpui::{ + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, + KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowParams, +}; use itertools::Itertools; use objc::{ class, @@ -49,7 +46,6 @@ use ptr::null_mut; use semver::Version; use std::{ cell::Cell, - convert::TryInto, ffi::{CStr, OsStr, c_void}, os::{raw::c_char, unix::ffi::OsStrExt}, path::{Path, PathBuf}, @@ -58,11 +54,7 @@ use std::{ slice, str, sync::{Arc, OnceLock}, }; -use strum::IntoEnumIterator; -use util::{ - ResultExt, - command::{new_smol_command, new_std_command}, -}; +use util::ResultExt; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -151,12 +143,18 @@ unsafe fn build_classes() { on_keyboard_layout_change as extern "C" fn(&mut Object, Sel, id), ); + decl.add_method( + sel!(onThermalStateChange:), + on_thermal_state_change as extern "C" fn(&mut Object, Sel, id), + ); + decl.register() } } } -pub(crate) struct MacPlatform(Mutex); +/// The macOS implementation of the GPUI platform. +pub struct MacPlatform(Mutex); pub(crate) struct MacPlatformState { background_executor: BackgroundExecutor, @@ -164,11 +162,11 @@ pub(crate) struct MacPlatformState { text_system: Arc, renderer_context: renderer::Context, headless: bool, - pasteboard: id, - text_hash_pasteboard_type: id, - metadata_pasteboard_type: id, + general_pasteboard: Pasteboard, + find_pasteboard: Pasteboard, reopen: Option>, on_keyboard_layout_change: Option>, + on_thermal_state_change: Option>, quit: Option>, menu_command: Option>, validate_menu_command: Option bool>>, @@ -181,21 +179,16 @@ pub(crate) struct MacPlatformState { keyboard_mapper: Rc, } -impl Default for MacPlatform { - fn default() -> Self { - Self::new(false) - } -} - impl MacPlatform { - pub(crate) fn new(headless: bool) -> Self { - let dispatcher = Arc::new(MacDispatcher); + /// Creates a new MacPlatform. + pub fn new(headless: bool) -> Self { + let dispatcher = Arc::new(MacDispatcher::new()); #[cfg(feature = "font-kit")] - let text_system = Arc::new(crate::MacTextSystem::new()); + let text_system = Arc::new(super::MacTextSystem::new()); #[cfg(not(feature = "font-kit"))] - let text_system = Arc::new(crate::NoopTextSystem::new()); + let text_system = Arc::new(gpui::NoopTextSystem::new()); let keyboard_layout = MacKeyboardLayout::new(); let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); @@ -206,9 +199,8 @@ impl MacPlatform { background_executor: BackgroundExecutor::new(dispatcher.clone()), foreground_executor: ForegroundExecutor::new(dispatcher), renderer_context: renderer::Context::default(), - pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) }, - text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") }, - metadata_pasteboard_type: unsafe { ns_string("zed-metadata") }, + general_pasteboard: Pasteboard::general(), + find_pasteboard: Pasteboard::find(), reopen: None, quit: None, menu_command: None, @@ -219,25 +211,12 @@ impl MacPlatform { finish_launching: None, dock_menu: None, on_keyboard_layout_change: None, + on_thermal_state_change: None, menus: None, keyboard_mapper, })) } - unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> { - unsafe { - let data = pasteboard.dataForType(kind); - if data == nil { - None - } else { - Some(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )) - } - } - } - unsafe fn create_menu_bar( &self, menus: &Vec, @@ -318,6 +297,7 @@ impl MacPlatform { action, os_action, checked, + disabled, } => { // Note that this is intentionally using earlier bindings, whereas typically // later ones take display precedence. See the discussion on @@ -343,14 +323,14 @@ impl MacPlatform { .map(|binding| binding.keystrokes()); let selector = match os_action { - Some(crate::OsAction::Cut) => selector("cut:"), - Some(crate::OsAction::Copy) => selector("copy:"), - Some(crate::OsAction::Paste) => selector("paste:"), - Some(crate::OsAction::SelectAll) => selector("selectAll:"), + Some(gpui::OsAction::Cut) => selector("cut:"), + Some(gpui::OsAction::Copy) => selector("copy:"), + Some(gpui::OsAction::Paste) => selector("paste:"), + Some(gpui::OsAction::SelectAll) => selector("selectAll:"), // "undo:" and "redo:" are always disabled in our case, as // we don't have a NSTextView/NSTextField to enable them on. - Some(crate::OsAction::Undo) => selector("handleGPUIMenuItem:"), - Some(crate::OsAction::Redo) => selector("handleGPUIMenuItem:"), + Some(gpui::OsAction::Undo) => selector("handleGPUIMenuItem:"), + Some(gpui::OsAction::Redo) => selector("handleGPUIMenuItem:"), None => selector("handleGPUIMenuItem:"), }; @@ -415,13 +395,18 @@ impl MacPlatform { if *checked { item.setState_(NSVisualEffectState::Active); } + item.setEnabled_(if *disabled { NO } else { YES }); let tag = actions.len() as NSInteger; let _: () = msg_send![item, setTag: tag]; actions.push(action.boxed_clone()); item } - MenuItem::Submenu(Menu { name, items }) => { + MenuItem::Submenu(Menu { + name, + items, + disabled, + }) => { let item = NSMenuItem::new(nil).autorelease(); let submenu = NSMenu::new(nil).autorelease(); submenu.setDelegate_(delegate); @@ -429,6 +414,7 @@ impl MacPlatform { submenu.addItem_(Self::create_menu_item(item, delegate, actions, keymap)); } item.setSubmenu_(submenu); + item.setEnabled_(if *disabled { NO } else { YES }); item.setTitle_(ns_string(name)); item } @@ -470,7 +456,7 @@ impl Platform for MacPlatform { self.0.lock().background_executor.clone() } - fn foreground_executor(&self) -> crate::ForegroundExecutor { + fn foreground_executor(&self) -> gpui::ForegroundExecutor { self.0.lock().foreground_executor.clone() } @@ -515,13 +501,11 @@ impl Platform for MacPlatform { // this, we make quitting the application asynchronous so that we aren't holding borrows to // the app state on the stack when we actually terminate the app. - use super::dispatcher::{dispatch_get_main_queue, dispatch_sys::dispatch_async_f}; - unsafe { - dispatch_async_f(dispatch_get_main_queue(), ptr::null_mut(), Some(quit)); + DispatchQueue::main().exec_async_f(ptr::null_mut(), quit); } - unsafe extern "C" fn quit(_: *mut c_void) { + extern "C" fn quit(_: *mut c_void) { unsafe { let app = NSApplication::sharedApplication(nil); let _: () = msg_send![app, terminate: nil]; @@ -609,14 +593,14 @@ impl Platform for MacPlatform { #[cfg(feature = "screen-capture")] fn is_screen_capture_supported(&self) -> bool { let min_version = cocoa::foundation::NSOperatingSystemVersion::new(12, 3, 0); - super::is_macos_version_at_least(min_version) + crate::is_macos_version_at_least(min_version) } #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, - ) -> oneshot::Receiver>>> { - super::screen_capture::get_sources() + ) -> oneshot::Receiver>>> { + crate::screen_capture::get_sources() } fn active_window(&self) -> Option { @@ -639,6 +623,7 @@ impl Platform for MacPlatform { handle, options, self.foreground_executor(), + self.background_executor(), renderer_context, ))) } @@ -647,7 +632,7 @@ impl Platform for MacPlatform { unsafe { let app = NSApplication::sharedApplication(nil); let appearance: id = msg_send![app, effectiveAppearance]; - WindowAppearance::from_native(appearance) + super::window_appearance::window_appearance_from_native(appearance) } } @@ -710,7 +695,7 @@ impl Platform for MacPlatform { } self.background_executor() - .spawn(async { crate::Flatten::flatten(done_rx.await.map_err(|e| anyhow!(e))) }) + .spawn(async { done_rx.await.map_err(|e| anyhow!(e))? }) } fn on_open_urls(&self, callback: Box)>) { @@ -869,12 +854,12 @@ impl Platform for MacPlatform { .lock() .background_executor .spawn(async move { - if let Some(mut child) = new_smol_command("open") + let mut child: Option = new_command("open") .arg(path) .spawn() .context("invoking open command") - .log_err() - { + .log_err(); + if let Some(mut child) = child { child.status().await.log_err(); } }) @@ -905,6 +890,24 @@ impl Platform for MacPlatform { self.0.lock().validate_menu_command = Some(callback); } + fn on_thermal_state_change(&self, callback: Box) { + self.0.lock().on_thermal_state_change = Some(callback); + } + + fn thermal_state(&self) -> ThermalState { + unsafe { + let process_info: id = msg_send![class!(NSProcessInfo), processInfo]; + let state: NSInteger = msg_send![process_info, thermalState]; + match state { + 0 => ThermalState::Nominal, + 1 => ThermalState::Fair, + 2 => ThermalState::Serious, + 3 => ThermalState::Critical, + _ => ThermalState::Nominal, + } + } + } + fn keyboard_layout(&self) -> Box { Box::new(MacKeyboardLayout::new()) } @@ -1034,117 +1037,24 @@ impl Platform for MacPlatform { } } - fn write_to_clipboard(&self, item: ClipboardItem) { - use crate::ClipboardEntry; - - unsafe { - // We only want to use NSAttributedString if there are multiple entries to write. - if item.entries.len() <= 1 { - match item.entries.first() { - Some(entry) => match entry { - ClipboardEntry::String(string) => { - self.write_plaintext_to_clipboard(string); - } - ClipboardEntry::Image(image) => { - self.write_image_to_clipboard(image); - } - ClipboardEntry::ExternalPaths(_) => {} - }, - None => { - // Writing an empty list of entries just clears the clipboard. - let state = self.0.lock(); - state.pasteboard.clearContents(); - } - } - } else { - let mut any_images = false; - let attributed_string = { - let mut buf = NSMutableAttributedString::alloc(nil) - // TODO can we skip this? Or at least part of it? - .init_attributed_string(NSString::alloc(nil).init_str("")); - - for entry in item.entries { - if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry - { - let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(NSString::alloc(nil).init_str(&text)); - - buf.appendAttributedString_(to_append); - } - } - - buf - }; - - let state = self.0.lock(); - state.pasteboard.clearContents(); - - // Only set rich text clipboard types if we actually have 1+ images to include. - if any_images { - let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attributed_string, length]), - nil, - ); - if rtfd_data != nil { - state - .pasteboard - .setData_forType(rtfd_data, NSPasteboardTypeRTFD); - } - - let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - NSRange::new(0, attributed_string.length()), - nil, - ); - if rtf_data != nil { - state - .pasteboard - .setData_forType(rtf_data, NSPasteboardTypeRTF); - } - } - - let plain_text = attributed_string.string(); - state - .pasteboard - .setString_forType(plain_text, NSPasteboardTypeString); - } - } - } - fn read_from_clipboard(&self) -> Option { let state = self.0.lock(); - let pasteboard = state.pasteboard; + state.general_pasteboard.read() + } - // First, see if it's a string. - unsafe { - let types: id = pasteboard.types(); - let string_type: id = ns_string("public.utf8-plain-text"); + fn write_to_clipboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.general_pasteboard.write(item); + } - if msg_send![types, containsObject: string_type] { - let data = pasteboard.dataForType(string_type); - if data == nil { - return None; - } else if data.bytes().is_null() { - // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc - // "If the length of the NSData object is 0, this property returns nil." - return Some(self.read_string_from_clipboard(&state, &[])); - } else { - let bytes = - slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + fn read_from_find_pasteboard(&self) -> Option { + let state = self.0.lock(); + state.find_pasteboard.read() + } - return Some(self.read_string_from_clipboard(&state, bytes)); - } - } - - // If it wasn't a string, try the various supported image types. - for format in ImageFormat::iter() { - if let Some(item) = try_clipboard_image(pasteboard, format) { - return Some(item); - } - } - } - - // If it wasn't a string or a supported image type, give up. - None + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.find_pasteboard.write(item); } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { @@ -1253,116 +1163,6 @@ impl Platform for MacPlatform { } } -impl MacPlatform { - unsafe fn read_string_from_clipboard( - &self, - state: &MacPlatformState, - text_bytes: &[u8], - ) -> ClipboardItem { - unsafe { - let text = String::from_utf8_lossy(text_bytes).to_string(); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type) - .and_then(|hash_bytes| { - let hash_bytes = hash_bytes.try_into().ok()?; - let hash = u64::from_be_bytes(hash_bytes); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?; - - if hash == ClipboardString::text_hash(&text) { - String::from_utf8(metadata.to_vec()).ok() - } else { - None - } - }); - - ClipboardItem { - entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], - } - } - } - - unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let text_bytes = NSData::dataWithBytes_length_( - nil, - string.text.as_ptr() as *const c_void, - string.text.len() as u64, - ); - state - .pasteboard - .setData_forType(text_bytes, NSPasteboardTypeString); - - if let Some(metadata) = string.metadata.as_ref() { - let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); - let hash_bytes = NSData::dataWithBytes_length_( - nil, - hash_bytes.as_ptr() as *const c_void, - hash_bytes.len() as u64, - ); - state - .pasteboard - .setData_forType(hash_bytes, state.text_hash_pasteboard_type); - - let metadata_bytes = NSData::dataWithBytes_length_( - nil, - metadata.as_ptr() as *const c_void, - metadata.len() as u64, - ); - state - .pasteboard - .setData_forType(metadata_bytes, state.metadata_pasteboard_type); - } - } - } - - unsafe fn write_image_to_clipboard(&self, image: &Image) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let bytes = NSData::dataWithBytes_length_( - nil, - image.bytes.as_ptr() as *const c_void, - image.bytes.len() as u64, - ); - - state - .pasteboard - .setData_forType(bytes, Into::::into(image.format).inner_mut()); - } - } -} - -fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option { - let mut ut_type: UTType = format.into(); - - unsafe { - let types: id = pasteboard.types(); - if msg_send![types, containsObject: ut_type.inner()] { - let data = pasteboard.dataForType(ut_type.inner_mut()); - if data == nil { - None - } else { - let bytes = Vec::from(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )); - let id = hash(&bytes); - - Some(ClipboardItem { - entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], - }) - } - } else { - None - } - } -} - unsafe fn path_from_objc(path: id) -> PathBuf { let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding]; let bytes = unsafe { path.UTF8String() as *const u8 }; @@ -1409,6 +1209,14 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { object: nil ]; + let thermal_name = ns_string("NSProcessInfoThermalStateDidChangeNotification"); + let process_info: id = msg_send![class!(NSProcessInfo), processInfo]; + let _: () = msg_send![notification_center, addObserver: this as id + selector: sel!(onThermalStateChange:) + name: thermal_name + object: process_info + ]; + let platform = get_mac_platform(this); let callback = platform.0.lock().finish_launching.take(); if let Some(callback) = callback { @@ -1455,6 +1263,31 @@ extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) { } } +extern "C" fn on_thermal_state_change(this: &mut Object, _: Sel, _: id) { + // Defer to the next run loop iteration to avoid re-entrant borrows of the App RefCell, + // as NSNotificationCenter delivers this notification synchronously and it may fire while + // the App is already borrowed (same pattern as quit() above). + let platform = unsafe { get_mac_platform(this) }; + let platform_ptr = platform as *const MacPlatform as *mut c_void; + unsafe { + DispatchQueue::main().exec_async_f(platform_ptr, on_thermal_state_change); + } + + extern "C" fn on_thermal_state_change(context: *mut c_void) { + let platform = unsafe { &*(context as *const MacPlatform) }; + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.on_thermal_state_change.take() { + drop(lock); + callback(); + platform + .0 + .lock() + .on_thermal_state_change + .get_or_insert(callback); + } + } +} + extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) { let urls = unsafe { (0..urls.count()) @@ -1534,7 +1367,7 @@ extern "C" fn menu_will_open(this: &mut Object, _: Sel, _: id) { extern "C" fn handle_dock_menu(this: &mut Object, _: Sel, _: id) -> id { unsafe { let platform = get_mac_platform(this); - let mut state = platform.0.lock(); + let state = platform.0.lock(); if let Some(id) = state.dock_menu { id } else { @@ -1543,10 +1376,6 @@ extern "C" fn handle_dock_menu(this: &mut Object, _: Sel, _: id) -> id { } } -unsafe fn ns_string(string: &str) -> id { - unsafe { NSString::alloc(nil).init_str(string).autorelease() } -} - unsafe fn ns_url_to_path(url: id) -> Result { let path: *mut c_char = msg_send![url, fileSystemRepresentation]; anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe { @@ -1607,120 +1436,3 @@ mod security { pub const errSecUserCanceled: OSStatus = -128; pub const errSecItemNotFound: OSStatus = -25300; } - -impl From for UTType { - fn from(value: ImageFormat) -> Self { - match value { - ImageFormat::Png => Self::png(), - ImageFormat::Jpeg => Self::jpeg(), - ImageFormat::Tiff => Self::tiff(), - ImageFormat::Webp => Self::webp(), - ImageFormat::Gif => Self::gif(), - ImageFormat::Bmp => Self::bmp(), - ImageFormat::Svg => Self::svg(), - ImageFormat::Ico => Self::ico(), - } - } -} - -// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ -struct UTType(id); - -impl UTType { - pub fn png() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png - Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType - } - - pub fn jpeg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg - Self(unsafe { ns_string("public.jpeg") }) - } - - pub fn gif() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif - Self(unsafe { ns_string("com.compuserve.gif") }) - } - - pub fn webp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp - Self(unsafe { ns_string("org.webmproject.webp") }) - } - - pub fn bmp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp - Self(unsafe { ns_string("com.microsoft.bmp") }) - } - - pub fn svg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg - Self(unsafe { ns_string("public.svg-image") }) - } - - pub fn ico() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico - Self(unsafe { ns_string("com.microsoft.ico") }) - } - - pub fn tiff() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff - Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType - } - - fn inner(&self) -> *const Object { - self.0 - } - - fn inner_mut(&self) -> *mut Object { - self.0 as *mut _ - } -} - -#[cfg(test)] -mod tests { - use crate::ClipboardItem; - - use super::*; - - #[test] - fn test_clipboard() { - let platform = build_platform(); - assert_eq!(platform.read_from_clipboard(), None); - - let item = ClipboardItem::new_string("1".to_string()); - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let item = ClipboardItem { - entries: vec![ClipboardEntry::String( - ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), - )], - }; - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let text_from_other_app = "text from other app"; - unsafe { - let bytes = NSData::dataWithBytes_length_( - nil, - text_from_other_app.as_ptr() as *const c_void, - text_from_other_app.len() as u64, - ); - platform - .0 - .lock() - .pasteboard - .setData_forType(bytes, NSPasteboardTypeString); - } - assert_eq!( - platform.read_from_clipboard(), - Some(ClipboardItem::new_string(text_from_other_app.to_string())) - ); - } - - fn build_platform() -> MacPlatform { - let platform = MacPlatform::new(false); - platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) }; - platform - } -} diff --git a/src/platform/mac/screen_capture.rs b/src/platform/mac/screen_capture.rs index 4d4ffa6896..f358262e72 100644 --- a/src/platform/mac/screen_capture.rs +++ b/src/platform/mac/screen_capture.rs @@ -1,13 +1,9 @@ -use crate::{ - DevicePixels, ForegroundExecutor, SharedString, SourceMetadata, - platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, - size, -}; +use super::ns_string; use anyhow::{Result, anyhow}; use block::ConcreteBlock; use cocoa::{ base::{YES, id, nil}, - foundation::{NSArray, NSString}, + foundation::NSArray, }; use collections::HashMap; use core_foundation::base::TCFType; @@ -17,6 +13,10 @@ use core_graphics::display::{ }; use ctor::ctor; use futures::channel::oneshot; +use gpui::{ + DevicePixels, ForegroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, + SharedString, SourceMetadata, size, +}; use media::core_media::{CMSampleBuffer, CMSampleBufferRef}; use metal::NSInteger; use objc::{ @@ -109,13 +109,21 @@ impl ScreenCaptureSource for MacScreenCaptureSource { let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64]; let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate]; - let (mut tx, rx) = oneshot::channel(); + // Stream contains filter, configuration, and delegate internally so we release them here + // to prevent a memory leak when steam is dropped + let _: () = msg_send![filter, release]; + let _: () = msg_send![configuration, release]; + let _: () = msg_send![delegate, release]; + + let (tx, rx) = oneshot::channel(); let mut error: id = nil; let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id]; if error != nil { let message: id = msg_send![error, localizedDescription]; - tx.send(Err(anyhow!("failed to add stream output {message:?}"))) + let _: () = msg_send![stream, release]; + let _: () = msg_send![output, release]; + tx.send(Err(anyhow!("failed to add stream output {message:?}"))) .ok(); return rx; } @@ -131,8 +139,10 @@ impl ScreenCaptureSource for MacScreenCaptureSource { }; Ok(Box::new(stream) as Box) } else { + let _: () = msg_send![stream, release]; + let _: () = msg_send![output, release]; let message: id = msg_send![error, localizedDescription]; - Err(anyhow!("failed to stop screen capture stream {message:?}")) + Err(anyhow!("failed to start screen capture stream {message:?}")) }; if let Some(tx) = tx.borrow_mut().take() { tx.send(result).ok(); @@ -195,7 +205,7 @@ unsafe fn screen_id_to_human_label() -> HashMap { let screens: id = msg_send![class!(NSScreen), screens]; let count: usize = msg_send![screens, count]; let mut map = HashMap::default(); - let screen_number_key = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen: id = msg_send![screens, objectAtIndex: i]; let device_desc: id = msg_send![screen, deviceDescription]; @@ -232,11 +242,11 @@ unsafe fn screen_id_to_human_label() -> HashMap { pub(crate) fn get_sources() -> oneshot::Receiver>>> { unsafe { - let (mut tx, rx) = oneshot::channel(); + let (tx, rx) = oneshot::channel(); let tx = Rc::new(RefCell::new(Some(tx))); let screen_id_to_label = screen_id_to_human_label(); let block = ConcreteBlock::new(move |shareable_content: id, error: id| { - let Some(mut tx) = tx.borrow_mut().take() else { + let Some(tx) = tx.borrow_mut().take() else { return; }; diff --git a/src/platform/mac/shaders.metal b/src/platform/mac/shaders.metal index 7c3886031a..3c6adac335 100644 --- a/src/platform/mac/shaders.metal +++ b/src/platform/mac/shaders.metal @@ -1140,7 +1140,7 @@ float4 over(float4 below, float4 above) { GradientColor prepare_fill_color(uint tag, uint color_space, Hsla solid, Hsla color0, Hsla color1) { GradientColor out; - if (tag == 0 || tag == 2) { + if (tag == 0 || tag == 2 || tag == 3) { out.solid = hsla_to_rgba(solid); } else if (tag == 1) { out.color0 = hsla_to_rgba(color0); @@ -1233,6 +1233,19 @@ float4 fill_color(Background background, color.a *= saturate(0.5 - distance); break; } + case 3: { + // checkerboard + float size = background.gradient_angle_or_pattern_height; + float2 relative_position = position - float2(bounds.origin.x, bounds.origin.y); + + float x_index = floor(relative_position.x / size); + float y_index = floor(relative_position.y / size); + float should_be_colored = fmod(x_index + y_index, 2.0); + + color = solid_color; + color.a *= saturate(should_be_colored); + break; + } } return color; diff --git a/src/platform/mac/status_item.rs b/src/platform/mac/status_item.rs deleted file mode 100644 index 21cc86090c..0000000000 --- a/src/platform/mac/status_item.rs +++ /dev/null @@ -1,388 +0,0 @@ -use crate::{ - geometry::{ - rect::RectF, - vector::{vec2f, Vector2F}, - }, - platform::{ - self, - mac::{platform::NSViewLayerContentsRedrawDuringViewResize, renderer::Renderer}, - Event, FontSystem, WindowBounds, - }, - Scene, -}; -use cocoa::{ - appkit::{NSScreen, NSSquareStatusItemLength, NSStatusBar, NSStatusItem, NSView, NSWindow}, - base::{id, nil, YES}, - foundation::{NSPoint, NSRect, NSSize}, -}; -use ctor::ctor; -use foreign_types::ForeignTypeRef; -use objc::{ - class, - declare::ClassDecl, - msg_send, - rc::StrongPtr, - runtime::{Class, Object, Protocol, Sel}, - sel, sel_impl, -}; -use std::{ - cell::RefCell, - ffi::c_void, - ptr, - rc::{Rc, Weak}, - sync::Arc, -}; - -use super::screen::Screen; - -static mut VIEW_CLASS: *const Class = ptr::null(); -const STATE_IVAR: &str = "state"; - -#[ctor] -unsafe fn build_classes() { - VIEW_CLASS = { - let mut decl = ClassDecl::new("GPUIStatusItemView", class!(NSView)).unwrap(); - decl.add_ivar::<*mut c_void>(STATE_IVAR); - - decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); - - 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!(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!(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!(viewDidChangeEffectiveAppearance), - view_did_change_effective_appearance as extern "C" fn(&Object, Sel), - ); - - decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); - decl.add_method( - sel!(displayLayer:), - display_layer as extern "C" fn(&Object, Sel, id), - ); - - decl.register() - }; -} - -pub struct StatusItem(Rc>); - -struct StatusItemState { - native_item: StrongPtr, - native_view: StrongPtr, - renderer: Renderer, - scene: Option, - event_callback: Option bool>>, - appearance_changed_callback: Option>, -} - -impl StatusItem { - pub fn add(fonts: Arc) -> Self { - unsafe { - let renderer = Renderer::new(false, fonts); - let status_bar = NSStatusBar::systemStatusBar(nil); - let native_item = - StrongPtr::retain(status_bar.statusItemWithLength_(NSSquareStatusItemLength)); - - let button = native_item.button(); - let _: () = msg_send![button, setHidden: YES]; - - let native_view = msg_send![VIEW_CLASS, alloc]; - let state = Rc::new(RefCell::new(StatusItemState { - native_item, - native_view: StrongPtr::new(native_view), - renderer, - scene: None, - event_callback: None, - appearance_changed_callback: None, - })); - - let parent_view = button.superview().superview(); - NSView::initWithFrame_( - native_view, - NSRect::new(NSPoint::new(0., 0.), NSView::frame(parent_view).size), - ); - (*native_view).set_ivar( - STATE_IVAR, - Weak::into_raw(Rc::downgrade(&state)) as *const c_void, - ); - native_view.setWantsBestResolutionOpenGLSurface_(YES); - native_view.setWantsLayer(YES); - let _: () = msg_send![ - native_view, - setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize - ]; - - parent_view.addSubview_(native_view); - - { - let state = state.borrow(); - let layer = state.renderer.layer(); - let scale_factor = state.scale_factor(); - let size = state.content_size() * scale_factor; - layer.set_contents_scale(scale_factor.into()); - layer.set_drawable_size(metal::CGSize::new(size.x().into(), size.y().into())); - } - - Self(state) - } - } -} - -impl platform::Window for StatusItem { - fn bounds(&self) -> WindowBounds { - self.0.borrow().bounds() - } - - fn content_size(&self) -> Vector2F { - self.0.borrow().content_size() - } - - fn scale_factor(&self) -> f32 { - self.0.borrow().scale_factor() - } - - fn appearance(&self) -> platform::Appearance { - unsafe { - let appearance: id = - msg_send![self.0.borrow().native_item.button(), effectiveAppearance]; - platform::Appearance::from_native(appearance) - } - } - - fn screen(&self) -> Rc { - unsafe { - Rc::new(Screen { - native_screen: self.0.borrow().native_window().screen(), - }) - } - } - - fn mouse_position(&self) -> Vector2F { - unimplemented!() - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - - fn set_input_handler(&mut self, _: Box) {} - - fn prompt( - &self, - _: crate::platform::PromptLevel, - _: &str, - _: &[&str], - ) -> postage::oneshot::Receiver { - unimplemented!() - } - - fn activate(&self) { - unimplemented!() - } - - fn set_title(&mut self, _: &str) { - unimplemented!() - } - - fn set_edited(&mut self, _: bool) { - unimplemented!() - } - - fn show_character_palette(&self) { - unimplemented!() - } - - fn minimize(&self) { - unimplemented!() - } - - fn zoom(&self) { - unimplemented!() - } - - fn present_scene(&mut self, scene: Scene) { - self.0.borrow_mut().scene = Some(scene); - unsafe { - let _: () = msg_send![*self.0.borrow().native_view, setNeedsDisplay: YES]; - } - } - - fn toggle_fullscreen(&self) { - unimplemented!() - } - - fn on_event(&mut self, callback: Box bool>) { - self.0.borrow_mut().event_callback = Some(callback); - } - - fn on_active_status_change(&mut self, _: Box) {} - - fn on_resize(&mut self, _: Box) {} - - fn on_fullscreen(&mut self, _: Box) {} - - fn on_moved(&mut self, _: Box) {} - - fn on_should_close(&mut self, _: Box bool>) {} - - fn on_close(&mut self, _: Box) {} - - fn on_appearance_changed(&mut self, callback: Box) { - self.0.borrow_mut().appearance_changed_callback = Some(callback); - } - - fn is_topmost_for_position(&self, _: Vector2F) -> bool { - true - } -} - -impl StatusItemState { - fn bounds(&self) -> WindowBounds { - unsafe { - let window: id = self.native_window(); - let screen_frame = window.screen().visibleFrame(); - let window_frame = NSWindow::frame(window); - let origin = vec2f( - window_frame.origin.x as f32, - (window_frame.origin.y - screen_frame.size.height - window_frame.size.height) - as f32, - ); - let size = vec2f( - window_frame.size.width as f32, - window_frame.size.height as f32, - ); - WindowBounds::Fixed(RectF::new(origin, size)) - } - } - - fn content_size(&self) -> Vector2F { - unsafe { - let NSSize { width, height, .. } = - NSView::frame(self.native_item.button().superview().superview()).size; - vec2f(width as f32, height as f32) - } - } - - fn scale_factor(&self) -> f32 { - unsafe { - let window: id = msg_send![self.native_item.button(), window]; - NSScreen::backingScaleFactor(window.screen()) as f32 - } - } - - pub fn native_window(&self) -> id { - unsafe { msg_send![self.native_item.button(), window] } - } -} - -extern "C" fn dealloc_view(this: &Object, _: Sel) { - unsafe { - drop_state(this); - - let _: () = msg_send![super(this, class!(NSView)), dealloc]; - } -} - -extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { - unsafe { - if let Some(state) = get_state(this).upgrade() { - let mut state_borrow = state.as_ref().borrow_mut(); - if let Some(event) = - Event::from_native(native_event, Some(state_borrow.content_size().y())) - { - if let Some(mut callback) = state_borrow.event_callback.take() { - drop(state_borrow); - callback(event); - state.borrow_mut().event_callback = Some(callback); - } - } - } - } -} - -extern "C" fn make_backing_layer(this: &Object, _: Sel) -> id { - if let Some(state) = unsafe { get_state(this).upgrade() } { - let state = state.borrow(); - state.renderer.layer().as_ptr() as id - } else { - nil - } -} - -extern "C" fn display_layer(this: &Object, _: Sel, _: id) { - unsafe { - if let Some(state) = get_state(this).upgrade() { - let mut state = state.borrow_mut(); - if let Some(scene) = state.scene.take() { - state.renderer.render(&scene); - } - } - } -} - -extern "C" fn view_did_change_effective_appearance(this: &Object, _: Sel) { - unsafe { - if let Some(state) = get_state(this).upgrade() { - let mut state_borrow = state.as_ref().borrow_mut(); - if let Some(mut callback) = state_borrow.appearance_changed_callback.take() { - drop(state_borrow); - callback(); - state.borrow_mut().appearance_changed_callback = Some(callback); - } - } - } -} - -unsafe fn get_state(object: &Object) -> Weak> { - let raw: *mut c_void = *object.get_ivar(STATE_IVAR); - let weak1 = Weak::from_raw(raw as *mut RefCell); - let weak2 = weak1.clone(); - let _ = Weak::into_raw(weak1); - weak2 -} - -unsafe fn drop_state(object: &Object) { - let raw: *const c_void = *object.get_ivar(STATE_IVAR); - Weak::from_raw(raw as *const RefCell); -} diff --git a/src/platform/mac/text_system.rs b/src/platform/mac/text_system.rs index 3faf4e6491..adc3e0f9bd 100644 --- a/src/platform/mac/text_system.rs +++ b/src/platform/mac/text_system.rs @@ -1,13 +1,8 @@ -use crate::{ - Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, - FontStyle, FontWeight, GlyphId, LineLayout, Pixels, PlatformTextSystem, Point, - RenderGlyphParams, Result, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString, Size, - point, px, size, swap_rgba_pa_to_bgra, -}; use anyhow::anyhow; use cocoa::appkit::CGFloat; use collections::HashMap; use core_foundation::{ + array::{CFArray, CFArrayRef}, attributed_string::CFMutableAttributedString, base::{CFRange, TCFType}, number::CFNumber, @@ -21,8 +16,10 @@ use core_graphics::{ }; use core_text::{ font::CTFont, + font_collection::CTFontCollectionRef, font_descriptor::{ - kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, kCTFontWidthTrait, + CTFontDescriptor, kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, + kCTFontWidthTrait, }, line::CTLine, string_attributes::kCTFontAttributeName, @@ -36,11 +33,17 @@ use font_kit::{ source::SystemSource, sources::mem::MemSource, }; +use gpui::{ + Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, + FontStyle, FontWeight, GlyphId, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, + Result, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, + point, px, size, swap_rgba_pa_to_bgra, +}; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use pathfinder_geometry::{ rect::{RectF, RectI}, transform2d::Transform2F, - vector::{Vector2F, Vector2I}, + vector::Vector2F, }; use smallvec::SmallVec; use std::{borrow::Cow, char, convert::TryFrom, sync::Arc}; @@ -50,7 +53,8 @@ use super::open_type::apply_features_and_fallbacks; #[allow(non_upper_case_globals)] const kCGImageAlphaOnly: u32 = 7; -pub(crate) struct MacTextSystem(RwLock); +/// macOS text system using CoreText for font shaping. +pub struct MacTextSystem(RwLock); #[derive(Clone, PartialEq, Eq, Hash)] struct FontKey { @@ -70,7 +74,8 @@ struct MacTextSystemState { } impl MacTextSystem { - pub(crate) fn new() -> Self { + /// Create a new MacTextSystem. + pub fn new() -> Self { Self(RwLock::new(MacTextSystemState { memory_source: MemSource::empty(), system_source: SystemSource::new(), @@ -97,7 +102,26 @@ impl PlatformTextSystem for MacTextSystem { fn all_font_names(&self) -> Vec { let mut names = Vec::new(); let collection = core_text::font_collection::create_for_all_families(); - let Some(descriptors) = collection.get_descriptors() else { + // NOTE: We intentionally avoid using `collection.get_descriptors()` here because + // it has a memory leak bug in core-text v21.0.0. The upstream code uses + // `wrap_under_get_rule` but `CTFontCollectionCreateMatchingFontDescriptors` + // follows the Create Rule (caller owns the result), so it should use + // `wrap_under_create_rule`. We call the function directly with correct memory management. + unsafe extern "C" { + fn CTFontCollectionCreateMatchingFontDescriptors( + collection: CTFontCollectionRef, + ) -> CFArrayRef; + } + let descriptors: Option> = unsafe { + let array_ref = + CTFontCollectionCreateMatchingFontDescriptors(collection.as_concrete_TypeRef()); + if array_ref.is_null() { + None + } else { + Some(CFArray::wrap_under_create_rule(array_ref)) + } + }; + let Some(descriptors) = descriptors else { return names; }; for descriptor in descriptors.into_iter() { @@ -137,8 +161,8 @@ impl PlatformTextSystem for MacTextSystem { let ix = font_kit::matching::find_best_match( &candidate_properties, &font_kit::properties::Properties { - style: font.style.into(), - weight: font.weight.into(), + style: fontkit_style(font.style), + weight: fontkit_weight(font.weight), stretch: Default::default(), }, )?; @@ -150,13 +174,13 @@ impl PlatformTextSystem for MacTextSystem { } fn font_metrics(&self, font_id: FontId) -> FontMetrics { - self.0.read().fonts[font_id.0].metrics().into() + font_kit_metrics_to_metrics(self.0.read().fonts[font_id.0].metrics()) } fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { - Ok(self.0.read().fonts[font_id.0] - .typographic_bounds(glyph_id.0)? - .into()) + Ok(bounds_from_rect( + self.0.read().fonts[font_id.0].typographic_bounds(glyph_id.0)?, + )) } fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { @@ -182,6 +206,14 @@ impl PlatformTextSystem for MacTextSystem { fn layout_line(&self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { self.0.write().layout_line(text, font_size, font_runs) } + + fn recommended_rendering_mode( + &self, + _font_id: FontId, + _font_size: Pixels, + ) -> TextRenderingMode { + TextRenderingMode::Grayscale + } } impl MacTextSystemState { @@ -211,7 +243,7 @@ impl MacTextSystemState { features: &FontFeatures, fallbacks: Option<&FontFallbacks>, ) -> Result> { - let name = crate::text_system::font_name_with_fallbacks(name, ".AppleSystemUIFont"); + let name = gpui::font_name_with_fallbacks(name, ".AppleSystemUIFont"); let mut font_ids = SmallVec::new(); let family = self @@ -291,7 +323,9 @@ impl MacTextSystemState { } fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { - Ok(self.fonts[font_id.0].advance(glyph_id.0)?.into()) + Ok(size_from_vector2f( + self.fonts[font_id.0].advance(glyph_id.0)?, + )) } fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { @@ -327,15 +361,22 @@ impl MacTextSystemState { fn raster_bounds(&self, params: &RenderGlyphParams) -> Result> { let font = &self.fonts[params.font_id.0]; let scale = Transform2F::from_scale(params.scale_factor); - Ok(font - .raster_bounds( - params.glyph_id.0, - params.font_size.into(), - scale, - HintingOptions::None, - font_kit::canvas::RasterizationOptions::GrayscaleAa, - )? - .into()) + let mut bounds: Bounds = bounds_from_rect_i(font.raster_bounds( + params.glyph_id.0, + params.font_size.into(), + scale, + HintingOptions::None, + font_kit::canvas::RasterizationOptions::GrayscaleAa, + )?); + + // Add 3% of font size as padding, clamped between 1 and 5 pixels + // to avoid clipping of anti-aliased edges. + let pad = + ((params.font_size.as_f32() * 0.03 * params.scale_factor).ceil() as i32).clamp(1, 5); + bounds.origin.x -= DevicePixels(pad); + bounds.size.width += DevicePixels(pad); + + Ok(bounds) } fn rasterize_glyph( @@ -450,12 +491,12 @@ impl MacTextSystemState { let font = &self.fonts[run.font_id.0]; let font_metrics = font.metrics(); - let font_scale = font_size.0 / font_metrics.units_per_em as f32; + let font_scale = f32::from(font_size) / font_metrics.units_per_em as f32; max_ascent = max_ascent.max(font_metrics.ascent * font_scale); max_descent = max_descent.max(-font_metrics.descent * font_scale); let font_size = if break_ligature { - px(font_size.0.next_up()) + px(f32::from(font_size).next_up()) } else { font_size }; @@ -484,7 +525,7 @@ impl MacTextSystemState { }; let font_id = self.id_for_native_font(font); - let mut glyphs = match runs.last_mut() { + let glyphs = match runs.last_mut() { Some(run) if run.font_id == font_id => &mut run.glyphs, _ => { runs.push(ShapedRun { @@ -500,7 +541,7 @@ impl MacTextSystemState { .zip(run.positions().iter()) .zip(run.string_indices().iter()) { - let mut glyph_utf16_ix = usize::try_from(glyph_utf16_ix).unwrap(); + let glyph_utf16_ix = usize::try_from(glyph_utf16_ix).unwrap(); if ix_converter.utf16_ix > glyph_utf16_ix { // We cannot reuse current index converter, as it can only seek forward. Restart the search. ix_converter = StringIndexConverter::new(text); @@ -556,80 +597,68 @@ impl<'a> StringIndexConverter<'a> { } } -impl From for FontMetrics { - fn from(metrics: Metrics) -> Self { - FontMetrics { - units_per_em: metrics.units_per_em, - ascent: metrics.ascent, - descent: metrics.descent, - line_gap: metrics.line_gap, - underline_position: metrics.underline_position, - underline_thickness: metrics.underline_thickness, - cap_height: metrics.cap_height, - x_height: metrics.x_height, - bounding_box: metrics.bounding_box.into(), - } +fn font_kit_metrics_to_metrics(metrics: Metrics) -> FontMetrics { + FontMetrics { + units_per_em: metrics.units_per_em, + ascent: metrics.ascent, + descent: metrics.descent, + line_gap: metrics.line_gap, + underline_position: metrics.underline_position, + underline_thickness: metrics.underline_thickness, + cap_height: metrics.cap_height, + x_height: metrics.x_height, + bounding_box: bounds_from_rect(metrics.bounding_box), } } -impl From for Bounds { - fn from(rect: RectF) -> Self { - Bounds { - origin: point(rect.origin_x(), rect.origin_y()), - size: size(rect.width(), rect.height()), - } +fn bounds_from_rect(rect: RectF) -> Bounds { + Bounds { + origin: point(rect.origin_x(), rect.origin_y()), + size: size(rect.width(), rect.height()), } } -impl From for Bounds { - fn from(rect: RectI) -> Self { - Bounds { - origin: point(DevicePixels(rect.origin_x()), DevicePixels(rect.origin_y())), - size: size(DevicePixels(rect.width()), DevicePixels(rect.height())), - } +fn bounds_from_rect_i(rect: RectI) -> Bounds { + Bounds { + origin: point(DevicePixels(rect.origin_x()), DevicePixels(rect.origin_y())), + size: size(DevicePixels(rect.width()), DevicePixels(rect.height())), } } -impl From for Size { - fn from(value: Vector2I) -> Self { - size(value.x().into(), value.y().into()) - } +// impl From for Size { +// fn from(value: Vector2I) -> Self { +// size(value.x().into(), value.y().into()) +// } +// } + +// impl From for Bounds { +// fn from(rect: RectI) -> Self { +// Bounds { +// origin: point(rect.origin_x(), rect.origin_y()), +// size: size(rect.width(), rect.height()), +// } +// } +// } + +// impl From> for Vector2I { +// fn from(size: Point) -> Self { +// Vector2I::new(size.x as i32, size.y as i32) +// } +// } + +fn size_from_vector2f(vec: Vector2F) -> Size { + size(vec.x(), vec.y()) } -impl From for Bounds { - fn from(rect: RectI) -> Self { - Bounds { - origin: point(rect.origin_x(), rect.origin_y()), - size: size(rect.width(), rect.height()), - } - } +fn fontkit_weight(value: FontWeight) -> FontkitWeight { + FontkitWeight(value.0) } -impl From> for Vector2I { - fn from(size: Point) -> Self { - Vector2I::new(size.x as i32, size.y as i32) - } -} - -impl From for Size { - fn from(vec: Vector2F) -> Self { - size(vec.x(), vec.y()) - } -} - -impl From for FontkitWeight { - fn from(value: FontWeight) -> Self { - FontkitWeight(value.0) - } -} - -impl From for FontkitStyle { - fn from(style: FontStyle) -> Self { - match style { - FontStyle::Normal => FontkitStyle::Normal, - FontStyle::Italic => FontkitStyle::Italic, - FontStyle::Oblique => FontkitStyle::Oblique, - } +fn fontkit_style(style: FontStyle) -> FontkitStyle { + match style { + FontStyle::Normal => FontkitStyle::Normal, + FontStyle::Italic => FontkitStyle::Italic, + FontStyle::Oblique => FontkitStyle::Oblique, } } @@ -676,7 +705,8 @@ mod lenient_font_attributes { #[cfg(test)] mod tests { - use crate::{FontRun, GlyphId, MacTextSystem, PlatformTextSystem, font, px}; + use super::MacTextSystem; + use gpui::{FontRun, GlyphId, PlatformTextSystem, font, px}; #[test] fn test_layout_line_bom_char() { diff --git a/src/platform/mac/window.rs b/src/platform/mac/window.rs index 23752fc53e..fca15ac669 100644 --- a/src/platform/mac/window.rs +++ b/src/platform/mac/window.rs @@ -1,13 +1,9 @@ -use super::{BoolExt, MacDisplay, NSRange, NSStringExt, ns_string, renderer}; -use crate::{ - AnyWindowHandle, Bounds, Capslock, DisplayLink, ExternalPaths, FileDropEvent, - ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - SharedString, Size, SystemWindowTab, Timer, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowKind, WindowParams, dispatch_get_main_queue, - dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, px, size, +use super::{ + BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, events::platform_input_from_native, + ns_string, renderer, }; +#[cfg(any(test, feature = "test-support"))] +use anyhow::Result; use block::ConcreteBlock; use cocoa::{ appkit::{ @@ -25,6 +21,18 @@ use cocoa::{ NSUserDefaults, }, }; +use dispatch2::DispatchQueue; +use gpui::{ + AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, ExternalPaths, FileDropEvent, + ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, + PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, + RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, point, + px, size, +}; +#[cfg(any(test, feature = "test-support"))] +use image::RgbaImage; use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; use ctor::ctor; @@ -62,9 +70,12 @@ static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = NSWindowStyleMask::from_bits_retain(1 << 7); +// WindowLevel const value ref: https://docs.rs/core-graphics2/0.4.1/src/core_graphics2/window_level.rs.html #[allow(non_upper_case_globals)] const NSNormalWindowLevel: NSInteger = 0; #[allow(non_upper_case_globals)] +const NSFloatingWindowLevel: NSInteger = 3; +#[allow(non_upper_case_globals)] const NSPopUpWindowLevel: NSInteger = 101; #[allow(non_upper_case_globals)] const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01; @@ -153,10 +164,18 @@ unsafe fn build_classes() { 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!(magnifyWithEvent:), + 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), @@ -387,14 +406,16 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C struct MacWindowState { handle: AnyWindowHandle, - executor: ForegroundExecutor, + foreground_executor: ForegroundExecutor, + background_executor: BackgroundExecutor, native_window: id, native_view: NonNull, blurred_view: Option, + background_appearance: WindowBackgroundAppearance, display_link: Option, renderer: renderer::Renderer, request_frame_callback: Option>, - event_callback: Option crate::DispatchEventResult>>, + event_callback: Option gpui::DispatchEventResult>>, activate_callback: Option>, resize_callback: Option, f32)>>, moved_callback: Option>, @@ -419,6 +440,8 @@ struct MacWindowState { select_previous_tab_callback: Option>, toggle_tab_bar_callback: Option>, activated_least_once: bool, + // The parent window if this window is a sheet (Dialog kind) + sheet_parent: Option, } impl MacWindowState { @@ -498,9 +521,14 @@ impl MacWindowState { } fn is_maximized(&self) -> bool { + fn rect_to_size(rect: NSRect) -> Size { + let NSSize { width, height } = rect.size; + size(width.into(), height.into()) + } + unsafe { let bounds = self.bounds(); - let screen_size = self.native_window.screen().visibleFrame().into(); + let screen_size = rect_to_size(self.native_window.screen().visibleFrame()); bounds.size == screen_size } } @@ -516,7 +544,7 @@ impl MacWindowState { let mut window_frame = unsafe { NSWindow::frame(self.native_window) }; let screen = unsafe { NSWindow::screen(self.native_window) }; if screen == nil { - return Bounds::new(point(px(0.), px(0.)), crate::DEFAULT_WINDOW_SIZE); + return Bounds::new(point(px(0.), px(0.)), gpui::DEFAULT_WINDOW_SIZE); } let screen_frame = unsafe { NSScreen::frame(screen) }; @@ -583,7 +611,8 @@ impl MacWindow { window_min_size, tabbing_identifier, }: WindowParams, - executor: ForegroundExecutor, + foreground_executor: ForegroundExecutor, + background_executor: BackgroundExecutor, renderer_context: renderer::Context, ) -> Self { unsafe { @@ -618,11 +647,16 @@ impl MacWindow { } let native_window: id = match kind { - WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc], + WindowKind::Normal => { + msg_send![WINDOW_CLASS, alloc] + } WindowKind::PopUp => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } + WindowKind::Floating | WindowKind::Dialog => { + msg_send![PANEL_CLASS, alloc] + } }; let display = display_id @@ -652,11 +686,14 @@ impl MacWindow { let window_rect = NSRect::new( NSPoint::new( - screen_frame.origin.x + bounds.origin.x.0 as f64, + screen_frame.origin.x + bounds.origin.x.as_f32() as f64, screen_frame.origin.y - + (display.bounds().size.height - bounds.origin.y).0 as f64, + + (display.bounds().size.height - bounds.origin.y).as_f32() as f64, + ), + NSSize::new( + bounds.size.width.as_f32() as f64, + bounds.size.height.as_f32() as f64, ), - NSSize::new(bounds.size.width.0 as f64, bounds.size.height.0 as f64), ); let native_window = native_window.initWithContentRect_styleMask_backing_defer_screen_( @@ -684,16 +721,18 @@ impl MacWindow { let mut window = Self(Arc::new(Mutex::new(MacWindowState { handle, - executor, + foreground_executor, + background_executor, native_window, native_view: NonNull::new_unchecked(native_view), blurred_view: None, + background_appearance: WindowBackgroundAppearance::Opaque, display_link: None, renderer: renderer::new_renderer( renderer_context, native_window as *mut _, native_view as *mut _, - bounds.size.map(|pixels| pixels.0), + bounds.size.map(|pixels| pixels.as_f32()), false, ), request_frame_callback: None, @@ -725,6 +764,7 @@ impl MacWindow { select_previous_tab_callback: None, toggle_tab_bar_callback: None, activated_least_once: false, + sheet_parent: None, }))); (*native_window).set_ivar( @@ -775,13 +815,22 @@ impl MacWindow { content_view.addSubview_(native_view.autorelease()); native_window.makeFirstResponder_(native_view); + let app: id = NSApplication::sharedApplication(nil); + let main_window: id = msg_send![app, mainWindow]; + let mut sheet_parent = None; + match kind { WindowKind::Normal | WindowKind::Floating => { - native_window.setLevel_(NSNormalWindowLevel); + if kind == WindowKind::Floating { + // Let the window float keep above normal windows. + native_window.setLevel_(NSFloatingWindowLevel); + } else { + native_window.setLevel_(NSNormalWindowLevel); + } native_window.setAcceptsMouseMovedEvents_(YES); if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -812,10 +861,23 @@ impl MacWindow { NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary ); } + WindowKind::Dialog => { + if !main_window.is_null() { + let parent = { + let active_sheet: id = msg_send![main_window, attachedSheet]; + if active_sheet.is_null() { + main_window + } else { + active_sheet + } + }; + let _: () = + msg_send![parent, beginSheet: native_window completionHandler: nil]; + sheet_parent = Some(parent); + } + } } - let app = NSApplication::sharedApplication(nil); - let main_window: id = msg_send![app, mainWindow]; if allows_automatic_window_tabbing && !main_window.is_null() && main_window != native_window @@ -857,7 +919,11 @@ impl MacWindow { // the window position might be incorrect if the main screen (the screen that contains the window that has focus) // is different from the primary screen. NSWindow::setFrameTopLeftPoint_(native_window, window_rect.origin); - window.0.lock().move_traffic_light(); + { + let mut window_state = window.0.lock(); + window_state.move_traffic_light(); + window_state.sheet_parent = sheet_parent; + } pool.drain(); @@ -904,8 +970,8 @@ impl MacWindow { pub fn get_user_tabbing_preference() -> Option { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleWindowTabbingMode"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let value: id = if !dict.is_null() { @@ -934,14 +1000,18 @@ impl Drop for MacWindow { let mut this = self.0.lock(); this.renderer.destroy(); let window = this.native_window; + let sheet_parent = this.sheet_parent.take(); this.display_link.take(); unsafe { this.native_window.setDelegate_(nil); } this.input_handler.take(); - this.executor + this.foreground_executor .spawn(async move { unsafe { + if let Some(parent) = sheet_parent { + let _: () = msg_send![parent, endSheet: window]; + } window.close(); window.autorelease(); } @@ -970,12 +1040,12 @@ impl PlatformWindow for MacWindow { fn resize(&mut self, size: Size) { let this = self.0.lock(); let window = this.native_window; - this.executor + this.foreground_executor .spawn(async move { unsafe { window.setContentSize_(NSSize { - width: size.width.0 as f64, - height: size.height.0 as f64, + width: size.width.as_f32() as f64, + height: size.height.as_f32() as f64, }); } }) @@ -984,34 +1054,32 @@ impl PlatformWindow for MacWindow { fn merge_all_windows(&self) { let native_window = self.0.lock().native_window; - unsafe extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) { - let native_window = context as id; - let _: () = msg_send![native_window, mergeAllWindows:nil]; + extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) { + unsafe { + let native_window = context as id; + let _: () = msg_send![native_window, mergeAllWindows:nil]; + } } unsafe { - dispatch_async_f( - dispatch_get_main_queue(), - native_window as *mut std::ffi::c_void, - Some(merge_windows_async), - ); + DispatchQueue::main() + .exec_async_f(native_window as *mut std::ffi::c_void, merge_windows_async); } } fn move_tab_to_new_window(&self) { let native_window = self.0.lock().native_window; - unsafe extern "C" fn move_tab_async(context: *mut std::ffi::c_void) { - let native_window = context as id; - let _: () = msg_send![native_window, moveTabToNewWindow:nil]; - let _: () = msg_send![native_window, makeKeyAndOrderFront: nil]; + extern "C" fn move_tab_async(context: *mut std::ffi::c_void) { + unsafe { + let native_window = context as id; + let _: () = msg_send![native_window, moveTabToNewWindow:nil]; + let _: () = msg_send![native_window, makeKeyAndOrderFront: nil]; + } } unsafe { - dispatch_async_f( - dispatch_get_main_queue(), - native_window as *mut std::ffi::c_void, - Some(move_tab_async), - ); + DispatchQueue::main() + .exec_async_f(native_window as *mut std::ffi::c_void, move_tab_async); } } @@ -1033,7 +1101,7 @@ impl PlatformWindow for MacWindow { } if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -1048,7 +1116,7 @@ impl PlatformWindow for MacWindow { fn appearance(&self) -> WindowAppearance { unsafe { let appearance: id = msg_send![self.0.lock().native_window, effectiveAppearance]; - WindowAppearance::from_native(appearance) + super::window_appearance::window_appearance_from_native(appearance) } } @@ -1059,10 +1127,8 @@ impl PlatformWindow for MacWindow { return None; } let device_description: id = msg_send![screen, deviceDescription]; - let screen_number: id = NSDictionary::valueForKey_( - device_description, - NSString::alloc(nil).init_str("NSScreenNumber"), - ); + let screen_number: id = + NSDictionary::valueForKey_(device_description, ns_string("NSScreenNumber")); let screen_number: u32 = msg_send![screen_number, unsignedIntValue]; @@ -1188,13 +1254,14 @@ impl PlatformWindow for MacWindow { let (done_tx, done_rx) = oneshot::channel(); let done_tx = Cell::new(Some(done_tx)); let block = ConcreteBlock::new(move |answer: NSInteger| { + let _: () = msg_send![alert, release]; if let Some(done_tx) = done_tx.take() { let _ = done_tx.send(answer.try_into().unwrap()); } }); let block = block.copy(); let native_window = self.0.lock().native_window; - let executor = self.0.lock().executor.clone(); + let executor = self.0.lock().foreground_executor.clone(); executor .spawn(async move { let _: () = msg_send![ @@ -1211,7 +1278,7 @@ impl PlatformWindow for MacWindow { fn activate(&self) { let window = self.0.lock().native_window; - let executor = self.0.lock().executor.clone(); + let executor = self.0.lock().foreground_executor.clone(); executor .spawn(async move { unsafe { @@ -1256,6 +1323,7 @@ impl PlatformWindow for MacWindow { fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { let mut this = self.0.as_ref().lock(); + this.background_appearance = background_appearance; let opaque = background_appearance == WindowBackgroundAppearance::Opaque; this.renderer.update_transparency(!opaque); @@ -1310,6 +1378,14 @@ impl PlatformWindow for MacWindow { } } + fn background_appearance(&self) -> WindowBackgroundAppearance { + self.0.as_ref().lock().background_appearance + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + fn set_edited(&mut self, edited: bool) { unsafe { let window = self.0.lock().native_window; @@ -1324,7 +1400,7 @@ impl PlatformWindow for MacWindow { fn show_character_palette(&self) { let this = self.0.lock(); let window = this.native_window; - this.executor + this.foreground_executor .spawn(async move { unsafe { let app = NSApplication::sharedApplication(nil); @@ -1344,7 +1420,7 @@ impl PlatformWindow for MacWindow { fn zoom(&self) { let this = self.0.lock(); let window = this.native_window; - this.executor + this.foreground_executor .spawn(async move { unsafe { window.zoom_(nil); @@ -1356,7 +1432,7 @@ impl PlatformWindow for MacWindow { fn toggle_fullscreen(&self) { let this = self.0.lock(); let window = this.native_window; - this.executor + this.foreground_executor .spawn(async move { unsafe { window.toggleFullScreen_(nil); @@ -1380,7 +1456,7 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().request_frame_callback = Some(callback); } - fn on_input(&self, callback: Box crate::DispatchEventResult>) { + fn on_input(&self, callback: Box gpui::DispatchEventResult>) { self.0.as_ref().lock().event_callback = Some(callback); } @@ -1469,7 +1545,7 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback); } - fn draw(&self, scene: &crate::Scene) { + fn draw(&self, scene: &gpui::Scene) { let mut this = self.0.lock(); this.renderer.draw(scene); } @@ -1478,12 +1554,12 @@ impl PlatformWindow for MacWindow { self.0.lock().renderer.sprite_atlas().clone() } - fn gpu_specs(&self) -> Option { + fn gpu_specs(&self) -> Option { None } fn update_ime_position(&self, _bounds: Bounds) { - let executor = self.0.lock().executor.clone(); + let executor = self.0.lock().foreground_executor.clone(); executor .spawn(async move { unsafe { @@ -1501,12 +1577,12 @@ impl PlatformWindow for MacWindow { fn titlebar_double_click(&self) { let this = self.0.lock(); let window = this.native_window; - this.executor + this.foreground_executor .spawn(async move { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleActionOnDoubleClick"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleActionOnDoubleClick"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let action: id = if !dict.is_null() { @@ -1550,10 +1626,16 @@ impl PlatformWindow for MacWindow { unsafe { let app = NSApplication::sharedApplication(nil); - let mut event: id = msg_send![app, currentEvent]; + let event: id = msg_send![app, currentEvent]; let _: () = msg_send![window, performWindowDragWithEvent: event]; } } + + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &gpui::Scene) -> Result { + let mut this = self.0.lock(); + this.renderer.render_to_image(scene) + } } impl rwh::HasWindowHandle for MacWindow { @@ -1673,7 +1755,7 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: let mut lock = window_state.as_ref().lock(); let window_height = lock.content_size().height; - let event = unsafe { PlatformInput::from_native(native_event, Some(window_height)) }; + let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; let Some(event) = event else { return NO; @@ -1691,7 +1773,7 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: }; match event { - PlatformInput::KeyDown(mut key_down_event) => { + PlatformInput::KeyDown(key_down_event) => { // For certain keystrokes, macOS will first dispatch a "key equivalent" event. // If that event isn't handled, it will then dispatch a "key down" event. GPUI // makes no distinction between these two types of events, so we need to ignore @@ -1717,10 +1799,13 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // may need them even if there is no marked text; // however we skip keys with control or the input handler adds control-characters to the buffer. // and keys with function, as the input handler swallows them. + // and keys with platform (Cmd), so that Cmd+key events (e.g. Cmd+`) are not + // consumed by the IME on non-QWERTY / dead-key layouts. if is_composing || (key_down_event.keystroke.key_char.is_none() && !key_down_event.keystroke.modifiers.control - && !key_down_event.keystroke.modifiers.function) + && !key_down_event.keystroke.modifiers.function + && !key_down_event.keystroke.modifiers.platform) { { let mut lock = window_state.as_ref().lock(); @@ -1790,7 +1875,7 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let weak_window_state = Arc::downgrade(&window_state); let mut lock = window_state.as_ref().lock(); let window_height = lock.content_size().height; - let event = unsafe { PlatformInput::from_native(native_event, Some(window_height)) }; + let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { match &mut event { @@ -1871,12 +1956,13 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { // with these ones. if !lock.external_files_dragged { lock.synthetic_drag_counter += 1; - let executor = lock.executor.clone(); + let executor = lock.foreground_executor.clone(); executor .spawn(synthetic_drag( weak_window_state, lock.synthetic_drag_counter, event.clone(), + lock.background_executor.clone(), )) .detach(); } @@ -1953,7 +2039,7 @@ extern "C" fn window_will_enter_fullscreen(this: &Object, _: Sel, _: id) { extern "C" fn window_will_exit_fullscreen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.as_ref().lock(); + let lock = window_state.as_ref().lock(); let min_version = NSOperatingSystemVersion::new(15, 3, 0); @@ -1984,11 +2070,13 @@ fn update_window_scale_factor(window_state: &Arc>) { let scale_factor = lock.scale_factor(); let size = lock.content_size(); let drawable_size = size.to_device_pixels(scale_factor); - unsafe { - let _: () = msg_send![ - lock.renderer.layer(), - setContentsScale: scale_factor as f64 - ]; + if let Some(layer) = lock.renderer.layer() { + unsafe { + let _: () = msg_send![ + layer, + setContentsScale: scale_factor as f64 + ]; + } } lock.renderer.update_drawable_size(drawable_size); @@ -2012,7 +2100,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) { extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.lock(); + let lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; // When opening a pop-up while the application isn't active, Cocoa sends a spurious @@ -2025,13 +2113,15 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) // in theory, we're not supposed to invoke this method manually but it balances out // the spurious `becomeKeyWindow` event and helps us work around that bug. if selector == sel!(windowDidBecomeKey:) && !is_active { + let native_window = lock.native_window; + drop(lock); unsafe { - let _: () = msg_send![lock.native_window, resignKeyWindow]; - return; + let _: () = msg_send![native_window, resignKeyWindow]; } + return; } - let executor = lock.executor.clone(); + let executor = lock.foreground_executor.clone(); drop(lock); // When a window becomes active, trigger an immediate synchronous frame request to prevent @@ -2046,7 +2136,6 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) if lock.activated_least_once { if let Some(mut callback) = lock.request_frame_callback.take() { - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(true); lock.stop_display_link(); drop(lock); @@ -2054,7 +2143,6 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let mut lock = window_state.lock(); lock.request_frame_callback = Some(callback); - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(false); lock.start_display_link(); } @@ -2120,13 +2208,20 @@ extern "C" fn view_did_change_backing_properties(this: &Object, _: Sel) { } extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) { + fn convert(value: NSSize) -> Size { + Size { + width: px(value.width as f32), + height: px(value.height as f32), + } + } + let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); - let new_size = Size::::from(size); + let new_size = convert(size); let old_size = unsafe { let old_frame: NSRect = msg_send![this, frame]; - Size::::from(old_frame.size) + convert(old_frame.size) }; if old_size == new_size { @@ -2154,7 +2249,6 @@ extern "C" fn display_layer(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.lock(); if let Some(mut callback) = lock.request_frame_callback.take() { - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(true); lock.stop_display_link(); drop(lock); @@ -2162,13 +2256,12 @@ extern "C" fn display_layer(this: &Object, _: Sel, _: id) { let mut lock = window_state.lock(); lock.request_frame_callback = Some(callback); - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(false); lock.start_display_link(); } } -unsafe extern "C" fn step(view: *mut c_void) { +extern "C" fn step(view: *mut c_void) { let view = view as id; let window_state = unsafe { get_window_state(&*view) }; let mut lock = window_state.lock(); @@ -2223,12 +2316,15 @@ extern "C" fn first_rect_for_character_range( |bounds| { NSRect::new( NSPoint::new( - frame.origin.x + bounds.origin.x.0 as f64, + frame.origin.x + bounds.origin.x.as_f32() as f64, frame.origin.y + frame.size.height - - bounds.origin.y.0 as f64 - - bounds.size.height.0 as f64, + - bounds.origin.y.as_f32() as f64 + - bounds.size.height.as_f32() as f64, + ), + NSSize::new( + bounds.size.width.as_f32() as f64, + bounds.size.height.as_f32() as f64, ), - NSSize::new(bounds.size.width.0 as f64, bounds.size.height.0 as f64), ) }, ) @@ -2331,7 +2427,7 @@ extern "C" fn do_command_by_selector(this: &Object, _: Sel, _: Sel) { let mut event_callback = lock.event_callback.take(); drop(lock); - if let Some((keystroke, mut callback)) = keystroke.zip(event_callback.as_mut()) { + if let Some((keystroke, callback)) = keystroke.zip(event_callback.as_mut()) { let handled = (callback)(PlatformInput::KeyDown(KeyDownEvent { keystroke, is_held: false, @@ -2384,11 +2480,9 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr let window_state = unsafe { get_window_state(this) }; let position = drag_event_position(&window_state, dragging_info); let paths = external_paths_from_event(dragging_info); - if let Some(event) = - paths.map(|paths| PlatformInput::FileDrop(FileDropEvent::Entered { position, paths })) - && send_new_event(&window_state, event) + if let Some(event) = paths.map(|paths| FileDropEvent::Entered { position, paths }) + && send_file_drop_event(window_state, event) { - window_state.lock().external_files_dragged = true; return NSDragOperationCopy; } NSDragOperationNone @@ -2397,10 +2491,7 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation { let window_state = unsafe { get_window_state(this) }; let position = drag_event_position(&window_state, dragging_info); - if send_new_event( - &window_state, - PlatformInput::FileDrop(FileDropEvent::Pending { position }), - ) { + if send_file_drop_event(window_state, FileDropEvent::Pending { position }) { NSDragOperationCopy } else { NSDragOperationNone @@ -2409,21 +2500,13 @@ extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDr extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - send_new_event( - &window_state, - PlatformInput::FileDrop(FileDropEvent::Exited), - ); - window_state.lock().external_files_dragged = false; + send_file_drop_event(window_state, FileDropEvent::Exited); } extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) -> BOOL { let window_state = unsafe { get_window_state(this) }; let position = drag_event_position(&window_state, dragging_info); - send_new_event( - &window_state, - PlatformInput::FileDrop(FileDropEvent::Submit { position }), - ) - .to_objc() + send_file_drop_event(window_state, FileDropEvent::Submit { position }).to_objc() } fn external_paths_from_event(dragging_info: *mut Object) -> Option { @@ -2445,19 +2528,17 @@ fn external_paths_from_event(dragging_info: *mut Object) -> Option>, drag_id: usize, event: MouseMoveEvent, + executor: BackgroundExecutor, ) { loop { - Timer::after(Duration::from_millis(16)).await; + executor.timer(Duration::from_millis(16)).await; if let Some(window_state) = window_state.upgrade() { let mut lock = window_state.lock(); if lock.synthetic_drag_counter == drag_id { @@ -2473,11 +2554,27 @@ async fn synthetic_drag( } } -fn send_new_event(window_state_lock: &Mutex, e: PlatformInput) -> bool { - let window_state = window_state_lock.lock().event_callback.take(); - if let Some(mut callback) = window_state { - callback(e); - window_state_lock.lock().event_callback = Some(callback); +/// Sends the specified FileDropEvent using `PlatformInput::FileDrop` to the window +/// state and updates the window state according to the event passed. +fn send_file_drop_event( + window_state: Arc>, + file_drop_event: FileDropEvent, +) -> bool { + let external_files_dragged = match file_drop_event { + FileDropEvent::Entered { .. } => Some(true), + FileDropEvent::Exited => Some(false), + _ => None, + }; + + let mut lock = window_state.lock(); + if let Some(mut callback) = lock.event_callback.take() { + drop(lock); + callback(PlatformInput::FileDrop(file_drop_event)); + let mut lock = window_state.lock(); + lock.event_callback = Some(callback); + if let Some(external_files_dragged) = external_files_dragged { + lock.external_files_dragged = external_files_dragged; + } true } else { false @@ -2508,7 +2605,7 @@ where unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { unsafe { let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; screen_number as CGDirectDisplayID @@ -2554,7 +2651,7 @@ unsafe fn remove_layer_background(layer: id) { // `description` reflects its name and some parameters. Currently `NSVisualEffectView` // uses a `CAFilter` named "colorSaturate". If one day they switch to `CIFilter`, the // `description` will still contain "Saturat" ("... inputSaturation = ..."). - let test_string: id = NSString::alloc(nil).init_str("Saturat").autorelease(); + let test_string: id = ns_string("Saturat"); let count = NSArray::count(filters); for i in 0..count { let description: id = msg_send![filters.objectAtIndex(i), description]; diff --git a/src/platform/mac/window_appearance.rs b/src/platform/mac/window_appearance.rs index 65c409d30c..02704bb0fc 100644 --- a/src/platform/mac/window_appearance.rs +++ b/src/platform/mac/window_appearance.rs @@ -1,31 +1,29 @@ -use crate::WindowAppearance; use cocoa::{ appkit::{NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight}, base::id, foundation::NSString, }; +use gpui::WindowAppearance; use objc::{msg_send, sel, sel_impl}; use std::ffi::CStr; -impl WindowAppearance { - pub(crate) unsafe fn from_native(appearance: id) -> Self { - let name: id = msg_send![appearance, name]; - unsafe { - if name == NSAppearanceNameVibrantLight { - Self::VibrantLight - } else if name == NSAppearanceNameVibrantDark { - Self::VibrantDark - } else if name == NSAppearanceNameAqua { - Self::Light - } else if name == NSAppearanceNameDarkAqua { - Self::Dark - } else { - println!( - "unknown appearance: {:?}", - CStr::from_ptr(name.UTF8String()) - ); - Self::Light - } +pub(crate) unsafe fn window_appearance_from_native(appearance: id) -> WindowAppearance { + let name: id = msg_send![appearance, name]; + unsafe { + if name == NSAppearanceNameVibrantLight { + WindowAppearance::VibrantLight + } else if name == NSAppearanceNameVibrantDark { + WindowAppearance::VibrantDark + } else if name == NSAppearanceNameAqua { + WindowAppearance::Light + } else if name == NSAppearanceNameDarkAqua { + WindowAppearance::Dark + } else { + println!( + "unknown appearance: {:?}", + CStr::from_ptr(name.UTF8String()) + ); + WindowAppearance::Light } } } diff --git a/src/platform/scap_screen_capture.rs b/src/platform/scap_screen_capture.rs index d6d19cd810..2c827bb0d8 100644 --- a/src/platform/scap_screen_capture.rs +++ b/src/platform/scap_screen_capture.rs @@ -15,7 +15,7 @@ use std::sync::atomic::{self, AtomicBool}; /// `scap_default_target_source` should be used instead on Wayland, since `scap_screen_sources` /// won't return any results. #[allow(dead_code)] -pub(crate) fn scap_screen_sources( +pub fn scap_screen_sources( foreground_executor: &ForegroundExecutor, ) -> oneshot::Receiver>>> { let (sources_tx, sources_rx) = oneshot::channel(); diff --git a/src/platform/test/dispatcher.rs b/src/platform/test/dispatcher.rs index c271430586..e001eddb25 100644 --- a/src/platform/test/dispatcher.rs +++ b/src/platform/test/dispatcher.rs @@ -1,267 +1,112 @@ -use crate::{PlatformDispatcher, Priority, RunnableVariant, TaskLabel}; -use backtrace::Backtrace; -use collections::{HashMap, HashSet, VecDeque}; -use parking::Unparker; -use parking_lot::Mutex; -use rand::prelude::*; +use crate::scheduler::Instant; +use crate::scheduler::{Clock, Scheduler, SessionId, TestScheduler, TestSchedulerConfig, Yield}; +use crate::{PlatformDispatcher, Priority, RunnableVariant}; use std::{ - future::Future, - ops::RangeInclusive, - pin::Pin, - sync::Arc, - task::{Context, Poll}, - time::{Duration, Instant}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; -use util::post_inc; - -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -struct TestDispatcherId(usize); +/// TestDispatcher provides deterministic async execution for tests. +/// +/// This implementation delegates task scheduling to the scheduler crate's `TestScheduler`. +/// Access the scheduler directly via `scheduler()` for clock, rng, and parking control. #[doc(hidden)] pub struct TestDispatcher { - id: TestDispatcherId, - state: Arc>, -} - -struct TestDispatcherState { - random: StdRng, - foreground: HashMap>, - background: Vec, - deprioritized_background: Vec, - delayed: Vec<(Duration, RunnableVariant)>, - start_time: Instant, - time: Duration, - is_main_thread: bool, - next_id: TestDispatcherId, - allow_parking: bool, - waiting_hint: Option, - waiting_backtrace: Option, - deprioritized_task_labels: HashSet, - block_on_ticks: RangeInclusive, - unparkers: Vec, + session_id: SessionId, + scheduler: Arc, + num_cpus_override: Arc, } impl TestDispatcher { - pub fn new(random: StdRng) -> Self { - let state = TestDispatcherState { - random, - foreground: HashMap::default(), - background: Vec::new(), - deprioritized_background: Vec::new(), - delayed: Vec::new(), - time: Duration::ZERO, - start_time: Instant::now(), - is_main_thread: true, - next_id: TestDispatcherId(1), + pub fn new(seed: u64) -> Self { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig { + seed, + randomize_order: true, allow_parking: false, - waiting_hint: None, - waiting_backtrace: None, - deprioritized_task_labels: Default::default(), - block_on_ticks: 0..=1000, - unparkers: Default::default(), - }; + capture_pending_traces: std::env::var("PENDING_TRACES") + .map_or(false, |var| var == "1" || var == "true"), + timeout_ticks: 0..=1000, + })); + Self::from_scheduler(scheduler) + } + pub fn from_scheduler(scheduler: Arc) -> Self { TestDispatcher { - id: TestDispatcherId(0), - state: Arc::new(Mutex::new(state)), + session_id: scheduler.allocate_session_id(), + scheduler, + num_cpus_override: Arc::new(AtomicUsize::new(0)), } } + pub fn scheduler(&self) -> &Arc { + &self.scheduler + } + + pub fn session_id(&self) -> SessionId { + self.session_id + } + + pub fn drain_tasks(&self) { + self.scheduler.drain_tasks(); + } + pub fn advance_clock(&self, by: Duration) { - let new_now = self.state.lock().time + by; - loop { - self.run_until_parked(); - let state = self.state.lock(); - let next_due_time = state.delayed.first().map(|(time, _)| *time); - drop(state); - if let Some(due_time) = next_due_time - && due_time <= new_now - { - self.state.lock().time = due_time; - continue; - } - break; - } - self.state.lock().time = new_now; + self.scheduler.advance_clock(by); } - pub fn advance_clock_to_next_delayed(&self) -> bool { - let next_due_time = self.state.lock().delayed.first().map(|(time, _)| *time); - if let Some(next_due_time) = next_due_time { - self.state.lock().time = next_due_time; - return true; - } - false + pub fn advance_clock_to_next_timer(&self) -> bool { + self.scheduler.advance_clock_to_next_timer() } - pub fn simulate_random_delay(&self) -> impl 'static + Send + Future + use<> { - struct YieldNow { - pub(crate) count: usize, - } - - impl Future for YieldNow { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll { - if self.count > 0 { - self.count -= 1; - cx.waker().wake_by_ref(); - Poll::Pending - } else { - Poll::Ready(()) - } - } - } - - YieldNow { - count: self.state.lock().random.random_range(0..10), - } + pub fn simulate_random_delay(&self) -> Yield { + self.scheduler.yield_random() } pub fn tick(&self, background_only: bool) -> bool { - let mut state = self.state.lock(); - - while let Some((deadline, _)) = state.delayed.first() { - if *deadline > state.time { - break; - } - let (_, runnable) = state.delayed.remove(0); - state.background.push(runnable); + if background_only { + self.scheduler.tick_background_only() + } else { + self.scheduler.tick() } - - let foreground_len: usize = if background_only { - 0 - } else { - state - .foreground - .values() - .map(|runnables| runnables.len()) - .sum() - }; - let background_len = state.background.len(); - - let runnable; - let main_thread; - if foreground_len == 0 && background_len == 0 { - let deprioritized_background_len = state.deprioritized_background.len(); - if deprioritized_background_len == 0 { - return false; - } - let ix = state.random.random_range(0..deprioritized_background_len); - main_thread = false; - runnable = state.deprioritized_background.swap_remove(ix); - } else { - main_thread = state.random.random_ratio( - foreground_len as u32, - (foreground_len + background_len) as u32, - ); - if main_thread { - let state = &mut *state; - runnable = state - .foreground - .values_mut() - .filter(|runnables| !runnables.is_empty()) - .choose(&mut state.random) - .unwrap() - .pop_front() - .unwrap(); - } else { - let ix = state.random.random_range(0..background_len); - runnable = state.background.swap_remove(ix); - }; - }; - - let was_main_thread = state.is_main_thread; - state.is_main_thread = main_thread; - drop(state); - - // todo(localcc): add timings to tests - match runnable { - RunnableVariant::Meta(runnable) => runnable.run(), - RunnableVariant::Compat(runnable) => runnable.run(), - }; - - self.state.lock().is_main_thread = was_main_thread; - - true - } - - pub fn deprioritize(&self, task_label: TaskLabel) { - self.state - .lock() - .deprioritized_task_labels - .insert(task_label); } pub fn run_until_parked(&self) { while self.tick(false) {} } - pub fn parking_allowed(&self) -> bool { - self.state.lock().allow_parking - } - pub fn allow_parking(&self) { - self.state.lock().allow_parking = true + self.scheduler.allow_parking(); } pub fn forbid_parking(&self) { - self.state.lock().allow_parking = false + self.scheduler.forbid_parking(); } - pub fn set_waiting_hint(&self, msg: Option) { - self.state.lock().waiting_hint = msg + /// Override the value returned by `BackgroundExecutor::num_cpus()` in tests. + /// A value of 0 means no override (the default of 4 is used). + pub fn set_num_cpus(&self, count: usize) { + self.num_cpus_override.store(count, Ordering::SeqCst); } - pub fn waiting_hint(&self) -> Option { - self.state.lock().waiting_hint.clone() - } - - pub fn start_waiting(&self) { - self.state.lock().waiting_backtrace = Some(Backtrace::new_unresolved()); - } - - pub fn finish_waiting(&self) { - self.state.lock().waiting_backtrace.take(); - } - - pub fn waiting_backtrace(&self) -> Option { - self.state.lock().waiting_backtrace.take().map(|mut b| { - b.resolve(); - b - }) - } - - pub fn rng(&self) -> StdRng { - self.state.lock().random.clone() - } - - pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive) { - self.state.lock().block_on_ticks = range; - } - - pub fn gen_block_on_ticks(&self) -> usize { - let mut lock = self.state.lock(); - let block_on_ticks = lock.block_on_ticks.clone(); - lock.random.random_range(block_on_ticks) - } - - pub fn unpark_all(&self) { - self.state.lock().unparkers.retain(|parker| parker.unpark()); - } - - pub fn push_unparker(&self, unparker: Unparker) { - let mut state = self.state.lock(); - state.unparkers.push(unparker); + /// Returns the overridden CPU count, or `None` if no override is set. + pub fn num_cpus_override(&self) -> Option { + match self.num_cpus_override.load(Ordering::SeqCst) { + 0 => None, + n => Some(n), + } } } impl Clone for TestDispatcher { fn clone(&self) -> Self { - let id = post_inc(&mut self.state.lock().next_id.0); + let session_id = self.scheduler.allocate_session_id(); Self { - id: TestDispatcherId(id), - state: self.state.clone(), + session_id, + scheduler: self.scheduler.clone(), + num_cpus_override: self.num_cpus_override.clone(), } } } @@ -271,55 +116,45 @@ impl PlatformDispatcher for TestDispatcher { Vec::new() } - fn get_current_thread_timings(&self) -> Vec { - Vec::new() + fn get_current_thread_timings(&self) -> crate::ThreadTaskTimings { + crate::ThreadTaskTimings { + thread_name: None, + thread_id: std::thread::current().id(), + timings: Vec::new(), + total_pushed: 0, + } } fn is_main_thread(&self) -> bool { - self.state.lock().is_main_thread + self.scheduler.is_main_thread() } fn now(&self) -> Instant { - let state = self.state.lock(); - state.start_time + state.time + self.scheduler.clock().now() } - fn dispatch(&self, runnable: RunnableVariant, label: Option, _priority: Priority) { - { - let mut state = self.state.lock(); - if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) { - state.deprioritized_background.push(runnable); - } else { - state.background.push(runnable); - } - } - self.unpark_all(); + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + self.scheduler + .schedule_background_with_priority(runnable, priority); } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - self.state - .lock() - .foreground - .entry(self.id) - .or_default() - .push_back(runnable); - self.unpark_all(); + self.scheduler + .schedule_foreground(self.session_id, runnable); } - fn dispatch_after(&self, duration: std::time::Duration, runnable: RunnableVariant) { - let mut state = self.state.lock(); - let next_time = state.time + duration; - let ix = match state.delayed.binary_search_by_key(&next_time, |e| e.0) { - Ok(ix) | Err(ix) => ix, - }; - state.delayed.insert(ix, (next_time, runnable)); + fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { + panic!( + "dispatch_after should not be called in tests. \ + Use BackgroundExecutor::timer() which uses the scheduler's native timer." + ); } fn as_test(&self) -> Option<&TestDispatcher> { Some(self) } - fn spawn_realtime(&self, _priority: crate::RealtimePriority, f: Box) { + fn spawn_realtime(&self, f: Box) { std::thread::spawn(move || { f(); }); diff --git a/src/platform/test/platform.rs b/src/platform/test/platform.rs index dfada36466..a59b21f038 100644 --- a/src/platform/test/platform.rs +++ b/src/platform/test/platform.rs @@ -1,9 +1,9 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, - ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task, - TestDisplay, TestWindow, WindowAppearance, WindowParams, size, + PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, + Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -15,11 +15,6 @@ use std::{ rc::{Rc, Weak}, sync::Arc, }; -#[cfg(target_os = "windows")] -use windows::Win32::{ - Graphics::Imaging::{CLSID_WICImagingFactory, IWICImagingFactory}, - System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance}, -}; /// TestPlatform implements the Platform trait for use in tests. pub(crate) struct TestPlatform { @@ -32,13 +27,14 @@ pub(crate) struct TestPlatform { current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex>, pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, pub text_system: Arc, pub expect_restart: RefCell>>>, - #[cfg(target_os = "windows")] - bitmap_factory: std::mem::ManuallyDrop, + headless_renderer_factory: Option Option>>>, weak: Weak, } @@ -93,18 +89,30 @@ pub(crate) struct TestPrompts { impl TestPlatform { pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc { - #[cfg(target_os = "windows")] - let bitmap_factory = unsafe { - windows::Win32::System::Ole::OleInitialize(None) - .expect("unable to initialize Windows OLE"); - std::mem::ManuallyDrop::new( - CoCreateInstance(&CLSID_WICImagingFactory, None, CLSCTX_INPROC_SERVER) - .expect("Error creating bitmap factory."), - ) - }; + Self::with_platform( + executor, + foreground_executor, + Arc::new(NoopTextSystem), + None, + ) + } - let text_system = Arc::new(NoopTextSystem); + pub fn with_text_system( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + ) -> Rc { + Self::with_platform(executor, foreground_executor, text_system, None) + } + pub fn with_platform( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + headless_renderer_factory: Option< + Box Option>>, + >, + ) -> Rc { Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, foreground_executor, @@ -117,11 +125,12 @@ impl TestPlatform { current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex::new(None), weak: weak.clone(), opened_url: Default::default(), - #[cfg(target_os = "windows")] - bitmap_factory, text_system, + headless_renderer_factory, }) } @@ -135,7 +144,6 @@ impl TestPlatform { .new_path .pop_front() .expect("no pending new path prompt"); - self.background_executor().set_waiting_hint(None); tx.send(Ok(select_path(&path))).ok(); } @@ -147,7 +155,6 @@ impl TestPlatform { .multiple_choice .pop_front() .expect("no pending multiple choice prompt"); - self.background_executor().set_waiting_hint(None); let Some(ix) = prompt.answers.iter().position(|a| a == response) else { panic!( "PROMPT: {}\n{:?}\n{:?}\nCannot respond with {}", @@ -182,8 +189,6 @@ impl TestPlatform { ) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); let answers: Vec = answers.iter().map(|s| s.label().to_string()).collect(); - self.background_executor() - .set_waiting_hint(Some(format!("PROMPT: {:?} {:?}", msg, detail))); self.prompts .borrow_mut() .multiple_choice @@ -246,6 +251,12 @@ impl Platform for TestPlatform { fn on_keyboard_layout_change(&self, _: Box) {} + fn on_thermal_state_change(&self, _: Box) {} + + fn thermal_state(&self) -> ThermalState { + ThermalState::Nominal + } + fn run(&self, _on_finish_launching: Box) { unimplemented!() } @@ -282,12 +293,10 @@ impl Platform for TestPlatform { Some(self.active_display.clone()) } - #[cfg(feature = "screen-capture")] fn is_screen_capture_supported(&self) -> bool { true } - #[cfg(feature = "screen-capture")] fn screen_capture_sources( &self, ) -> oneshot::Receiver>>> { @@ -314,11 +323,13 @@ impl Platform for TestPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + let renderer = self.headless_renderer_factory.as_ref().and_then(|f| f()); let window = TestWindow::new( handle, params, self.weak.clone(), self.active_display.clone(), + renderer, ); Ok(Box::new(window)) } @@ -348,8 +359,6 @@ impl Platform for TestPlatform { _suggested_name: Option<&str>, ) -> oneshot::Receiver>> { let (tx, rx) = oneshot::channel(); - self.background_executor() - .set_waiting_hint(Some(format!("PROMPT FOR PATH: {:?}", directory))); self.prompts .borrow_mut() .new_path @@ -398,9 +407,8 @@ impl Platform for TestPlatform { false } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem) { - *self.current_primary_item.lock() = Some(item); + fn read_from_clipboard(&self) -> Option { + self.current_clipboard_item.lock().clone() } fn write_to_clipboard(&self, item: ClipboardItem) { @@ -412,8 +420,19 @@ impl Platform for TestPlatform { self.current_primary_item.lock().clone() } - fn read_from_clipboard(&self) -> Option { - self.current_clipboard_item.lock().clone() + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem) { + *self.current_primary_item.lock() = Some(item); + } + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option { + self.current_find_pasteboard_item.lock().clone() + } + + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + *self.current_find_pasteboard_item.lock() = Some(item); } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { @@ -444,16 +463,6 @@ impl TestScreenCaptureSource { } } -#[cfg(target_os = "windows")] -impl Drop for TestPlatform { - fn drop(&mut self) { - unsafe { - std::mem::ManuallyDrop::drop(&mut self.bitmap_factory); - windows::Win32::System::Ole::OleUninitialize(); - } - } -} - struct TestKeyboardLayout; impl PlatformKeyboardLayout for TestKeyboardLayout { diff --git a/src/platform/test/window.rs b/src/platform/test/window.rs index 9e87f4504d..583450c9e9 100644 --- a/src/platform/test/window.rs +++ b/src/platform/test/window.rs @@ -1,10 +1,12 @@ use crate::{ - AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, PromptButton, RequestFrameOptions, Size, TestPlatform, TileId, WindowAppearance, + AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; +use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::{ @@ -19,7 +21,9 @@ pub(crate) struct TestWindowState { pub(crate) title: Option, pub(crate) edited: bool, platform: Weak, + // TODO: Replace with `Rc` sprite_atlas: Arc, + renderer: Option>, pub(crate) should_close_handler: Option bool>>, hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, @@ -32,7 +36,7 @@ pub(crate) struct TestWindowState { } #[derive(Clone)] -pub(crate) struct TestWindow(pub(crate) Rc>); +pub struct TestWindow(pub(crate) Rc>); impl HasWindowHandle for TestWindow { fn window_handle( @@ -51,18 +55,24 @@ impl HasDisplayHandle for TestWindow { } impl TestWindow { - pub fn new( + pub(crate) fn new( handle: AnyWindowHandle, params: WindowParams, platform: Weak, display: Rc, + renderer: Option>, ) -> Self { + let sprite_atlas: Arc = match &renderer { + Some(r) => r.sprite_atlas(), + None => Arc::new(TestAtlas::new()), + }; Self(Rc::new(Mutex::new(TestWindowState { bounds: params.bounds, display, platform, handle, - sprite_atlas: Arc::new(TestAtlas::new()), + sprite_atlas, + renderer, title: Default::default(), edited: false, should_close_handler: None, @@ -80,10 +90,11 @@ impl TestWindow { pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); + // Always update bounds, even if no callback is registered + lock.bounds.size = size; let Some(mut callback) = lock.resize_callback.take() else { return; }; - lock.bounds.size = size; drop(lock); callback(size, scale_factor); self.0.lock().resize_callback = Some(callback); @@ -199,6 +210,14 @@ impl PlatformWindow for TestWindow { false } + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + fn set_title(&mut self, title: &str) { self.0.lock().title = Some(title.to_owned()); } @@ -266,12 +285,25 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, _scene: &crate::Scene) {} + fn draw(&self, _scene: &Scene) {} fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() } + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let mut state = self.0.lock(); + let size = state.bounds.size; + if let Some(renderer) = &mut state.renderer { + let scale_factor = 2.0; + let device_size: Size = size.to_device_pixels(scale_factor); + renderer.render_scene_to_image(scene, device_size) + } else { + anyhow::bail!("render_to_image not available: no HeadlessRenderer configured") + } + } + fn as_test(&mut self) -> Option<&mut TestWindow> { Some(self) } diff --git a/src/platform/visual_test.rs b/src/platform/visual_test.rs new file mode 100644 index 0000000000..8b9bec7edd --- /dev/null +++ b/src/platform/visual_test.rs @@ -0,0 +1,254 @@ +//! Visual test platform that combines real rendering (macOs-only for now) with controllable TestDispatcher. +//! +//! This platform is used for visual tests that need: +//! - Real rendering (e.g. Metal/compositor) for accurate screenshots +//! - Deterministic task scheduling via TestDispatcher +//! - Controllable time via `advance_clock` + +use crate::ScreenCaptureSource; +use crate::{ + AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, Keymap, + Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, + TestDispatcher, WindowAppearance, WindowParams, +}; +use anyhow::Result; +use futures::channel::oneshot; +use parking_lot::Mutex; + +use std::{ + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +/// A platform that combines real Mac rendering with controllable TestDispatcher. +/// +/// This allows visual tests to: +/// - Render real UI via Metal for accurate screenshots +/// - Control task scheduling deterministically via TestDispatcher +/// - Advance simulated time for testing time-based behaviors (tooltips, animations, etc.) +pub struct VisualTestPlatform { + dispatcher: TestDispatcher, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + platform: Rc, + clipboard: Mutex>, + find_pasteboard: Mutex>, +} + +impl VisualTestPlatform { + /// Creates a new VisualTestPlatform with the given random seed. + /// + /// The seed is used for deterministic random number generation in the TestDispatcher. + pub fn new(platform: Rc, seed: u64) -> Self { + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + + Self { + dispatcher, + background_executor, + foreground_executor, + platform, + clipboard: Mutex::new(None), + find_pasteboard: Mutex::new(None), + } + } + + /// Returns a reference to the TestDispatcher for controlling task scheduling and time. + pub fn dispatcher(&self) -> &TestDispatcher { + &self.dispatcher + } +} + +impl Platform for VisualTestPlatform { + fn background_executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + fn text_system(&self) -> Arc { + self.platform.text_system() + } + + fn run(&self, _on_finish_launching: Box) { + panic!("VisualTestPlatform::run should not be called in tests") + } + + fn quit(&self) {} + + fn restart(&self, _binary_path: Option) {} + + fn activate(&self, _ignoring_other_apps: bool) {} + + fn hide(&self) {} + + fn hide_other_apps(&self) {} + + fn unhide_other_apps(&self) {} + + fn displays(&self) -> Vec> { + self.platform.displays() + } + + fn primary_display(&self) -> Option> { + self.platform.primary_display() + } + + fn active_window(&self) -> Option { + self.platform.active_window() + } + + fn window_stack(&self) -> Option> { + self.platform.window_stack() + } + + fn is_screen_capture_supported(&self) -> bool { + self.platform.is_screen_capture_supported() + } + + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + self.platform.screen_capture_sources() + } + + fn open_window( + &self, + handle: AnyWindowHandle, + options: WindowParams, + ) -> Result> { + self.platform.open_window(handle, options) + } + + fn window_appearance(&self) -> WindowAppearance { + self.platform.window_appearance() + } + + fn open_url(&self, url: &str) { + self.platform.open_url(url) + } + + fn on_open_urls(&self, _callback: Box)>) {} + + fn register_url_scheme(&self, _url: &str) -> Task> { + Task::ready(Ok(())) + } + + fn prompt_for_paths( + &self, + _options: PathPromptOptions, + ) -> oneshot::Receiver>>> { + let (tx, rx) = oneshot::channel(); + tx.send(Ok(None)).ok(); + rx + } + + fn prompt_for_new_path( + &self, + _directory: &Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + let (tx, rx) = oneshot::channel(); + tx.send(Ok(None)).ok(); + rx + } + + fn can_select_mixed_files_and_dirs(&self) -> bool { + true + } + + fn reveal_path(&self, path: &Path) { + self.platform.reveal_path(path) + } + + fn open_with_system(&self, path: &Path) { + self.platform.open_with_system(path) + } + + fn on_quit(&self, _callback: Box) {} + + fn on_reopen(&self, _callback: Box) {} + + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + + fn get_menus(&self) -> Option> { + None + } + + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} + + fn on_app_menu_action(&self, _callback: Box) {} + + fn on_will_open_app_menu(&self, _callback: Box) {} + + fn on_validate_app_menu_command(&self, _callback: Box bool>) {} + + fn app_path(&self) -> Result { + self.platform.app_path() + } + + fn path_for_auxiliary_executable(&self, name: &str) -> Result { + self.platform.path_for_auxiliary_executable(name) + } + + fn set_cursor_style(&self, style: CursorStyle) { + self.platform.set_cursor_style(style) + } + + fn should_auto_hide_scrollbars(&self) -> bool { + self.platform.should_auto_hide_scrollbars() + } + + fn read_from_clipboard(&self) -> Option { + self.clipboard.lock().clone() + } + + fn write_to_clipboard(&self, item: ClipboardItem) { + *self.clipboard.lock() = Some(item); + } + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option { + self.find_pasteboard.lock().clone() + } + + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + *self.find_pasteboard.lock() = Some(item); + } + + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { + Task::ready(Ok(())) + } + + fn read_credentials(&self, _url: &str) -> Task)>>> { + Task::ready(Ok(None)) + } + + fn delete_credentials(&self, _url: &str) -> Task> { + Task::ready(Ok(())) + } + + fn keyboard_layout(&self) -> Box { + self.platform.keyboard_layout() + } + + fn keyboard_mapper(&self) -> Rc { + self.platform.keyboard_mapper() + } + + fn on_keyboard_layout_change(&self, _callback: Box) {} + + fn thermal_state(&self) -> super::ThermalState { + super::ThermalState::Nominal + } + + fn on_thermal_state_change(&self, _callback: Box) {} +} diff --git a/src/platform/web.rs b/src/platform/web.rs new file mode 100644 index 0000000000..d96d14af06 --- /dev/null +++ b/src/platform/web.rs @@ -0,0 +1,16 @@ +mod dispatcher; +mod display; +mod events; +mod http_client; +mod keyboard; +mod logging; +mod platform; +mod window; + +pub use dispatcher::WebDispatcher; +pub use display::WebDisplay; +pub use http_client::FetchHttpClient; +pub use keyboard::WebKeyboardLayout; +pub use logging::init_logging; +pub use platform::WebPlatform; +pub use window::WebWindow; diff --git a/src/platform/web/dispatcher.rs b/src/platform/web/dispatcher.rs new file mode 100644 index 0000000000..9c45de1b0e --- /dev/null +++ b/src/platform/web/dispatcher.rs @@ -0,0 +1,333 @@ +use gpui::{ + PlatformDispatcher, Priority, PriorityQueueReceiver, PriorityQueueSender, RunnableVariant, + ThreadTaskTimings, +}; +use std::sync::Arc; +use std::sync::atomic::AtomicI32; +use std::time::Duration; +use wasm_bindgen::prelude::*; +use web_time::Instant; + +#[cfg(feature = "multithreaded")] +const MIN_BACKGROUND_THREADS: usize = 2; + +#[cfg(feature = "multithreaded")] +fn shared_memory_supported() -> bool { + let global = js_sys::global(); + let has_shared_array_buffer = + js_sys::Reflect::has(&global, &JsValue::from_str("SharedArrayBuffer")).unwrap_or(false); + let has_atomics = js_sys::Reflect::has(&global, &JsValue::from_str("Atomics")).unwrap_or(false); + let memory = js_sys::WebAssembly::Memory::from(wasm_bindgen::memory()); + let buffer = memory.buffer(); + let is_shared_buffer = buffer.is_instance_of::(); + has_shared_array_buffer && has_atomics && is_shared_buffer +} + +enum MainThreadItem { + Runnable(RunnableVariant), + Delayed { + runnable: RunnableVariant, + millis: i32, + }, + // TODO-Wasm: Shouldn't these run on their own dedicated thread? + RealtimeFunction(Box), +} + +struct MainThreadMailbox { + sender: PriorityQueueSender, + receiver: parking_lot::Mutex>, + signal: AtomicI32, +} + +impl MainThreadMailbox { + fn new() -> Self { + let (sender, receiver) = PriorityQueueReceiver::new(); + Self { + sender, + receiver: parking_lot::Mutex::new(receiver), + signal: AtomicI32::new(0), + } + } + + fn post(&self, priority: Priority, item: MainThreadItem) { + if self.sender.spin_send(priority, item).is_err() { + log::error!("MainThreadMailbox::send failed: receiver disconnected"); + } + + // TODO-Wasm: Verify this lock-free protocol + let view = self.signal_view(); + js_sys::Atomics::store(&view, 0, 1).ok(); + js_sys::Atomics::notify(&view, 0).ok(); + } + + fn drain(&self, window: &web_sys::Window) { + let mut receiver = self.receiver.lock(); + loop { + // We need these `spin` variants because we can't acquire a lock on the main thread. + // TODO-WASM: Should we do something different? + match receiver.spin_try_pop() { + Ok(Some(item)) => execute_on_main_thread(window, item), + Ok(None) => break, + Err(_) => break, + } + } + } + + fn signal_view(&self) -> js_sys::Int32Array { + let byte_offset = self.signal.as_ptr() as u32; + let memory = js_sys::WebAssembly::Memory::from(wasm_bindgen::memory()); + js_sys::Int32Array::new_with_byte_offset_and_length(&memory.buffer(), byte_offset, 1) + } + + fn run_waker_loop(self: &Arc, window: web_sys::Window) { + if !shared_memory_supported() { + log::warn!("SharedArrayBuffer not available; main thread mailbox waker loop disabled"); + return; + } + + let mailbox = Arc::clone(self); + wasm_bindgen_futures::spawn_local(async move { + let view = mailbox.signal_view(); + loop { + js_sys::Atomics::store(&view, 0, 0).expect("Atomics.store failed"); + + let result = match js_sys::Atomics::wait_async(&view, 0, 0) { + Ok(result) => result, + Err(error) => { + log::error!("Atomics.waitAsync failed: {error:?}"); + break; + } + }; + + let is_async = js_sys::Reflect::get(&result, &JsValue::from_str("async")) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !is_async { + log::error!("Atomics.waitAsync returned synchronously; waker loop exiting"); + break; + } + + let promise: js_sys::Promise = + js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .expect("waitAsync result missing 'value'") + .unchecked_into(); + + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + + mailbox.drain(&window); + } + }); + } +} + +pub struct WebDispatcher { + main_thread_id: std::thread::ThreadId, + browser_window: web_sys::Window, + background_sender: PriorityQueueSender, + main_thread_mailbox: Arc, + supports_threads: bool, + #[cfg(feature = "multithreaded")] + _background_threads: Vec>, +} + +// Safety: `web_sys::Window` is only accessed from the main thread +// All other fields are `Send + Sync` by construction. +unsafe impl Send for WebDispatcher {} +unsafe impl Sync for WebDispatcher {} + +impl WebDispatcher { + pub fn new(browser_window: web_sys::Window, allow_threads: bool) -> Self { + #[cfg(feature = "multithreaded")] + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + #[cfg(not(feature = "multithreaded"))] + let (background_sender, _) = PriorityQueueReceiver::new(); + + let main_thread_mailbox = Arc::new(MainThreadMailbox::new()); + + #[cfg(feature = "multithreaded")] + let supports_threads = allow_threads && shared_memory_supported(); + #[cfg(not(feature = "multithreaded"))] + let supports_threads = false; + + if supports_threads { + main_thread_mailbox.run_waker_loop(browser_window.clone()); + } else { + log::warn!( + "SharedArrayBuffer not available; falling back to single-threaded dispatcher" + ); + } + + #[cfg(feature = "multithreaded")] + let background_threads = if supports_threads { + let thread_count = browser_window + .navigator() + .hardware_concurrency() + .max(MIN_BACKGROUND_THREADS as f64) as usize; + + // TODO-Wasm: Is it bad to have web workers blocking for a long time like this? + (0..thread_count) + .map(|i| { + let mut receiver = background_receiver.clone(); + wasm_thread::Builder::new() + .name(format!("background-worker-{i}")) + .spawn(move || { + loop { + let runnable: RunnableVariant = match receiver.pop() { + Ok(runnable) => runnable, + Err(_) => { + log::info!( + "background-worker-{i}: channel disconnected, exiting" + ); + break; + } + }; + + runnable.run(); + } + }) + .expect("failed to spawn background worker thread") + }) + .collect::>() + } else { + Vec::new() + }; + + Self { + main_thread_id: std::thread::current().id(), + browser_window, + background_sender, + main_thread_mailbox, + supports_threads, + #[cfg(feature = "multithreaded")] + _background_threads: background_threads, + } + } + + fn on_main_thread(&self) -> bool { + std::thread::current().id() == self.main_thread_id + } +} + +impl PlatformDispatcher for WebDispatcher { + fn get_all_timings(&self) -> Vec { + // TODO-Wasm: should we panic here? + Vec::new() + } + + fn get_current_thread_timings(&self) -> ThreadTaskTimings { + ThreadTaskTimings { + thread_name: None, + thread_id: std::thread::current().id(), + timings: Vec::new(), + total_pushed: 0, + } + } + + fn is_main_thread(&self) -> bool { + self.on_main_thread() + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + if !self.supports_threads { + self.dispatch_on_main_thread(runnable, priority); + return; + } + + let result = if self.on_main_thread() { + self.background_sender.spin_send(priority, runnable) + } else { + self.background_sender.send(priority, runnable) + }; + + if let Err(error) = result { + log::error!("dispatch: failed to send to background queue: {error:?}"); + } + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + if self.on_main_thread() { + schedule_runnable(&self.browser_window, runnable, priority); + } else { + self.main_thread_mailbox + .post(priority, MainThreadItem::Runnable(runnable)); + } + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let millis = duration.as_millis().min(i32::MAX as u128) as i32; + if self.on_main_thread() { + let callback = Closure::once_into_js(move || { + runnable.run(); + }); + self.browser_window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.unchecked_ref(), + millis, + ) + .ok(); + } else { + self.main_thread_mailbox + .post(Priority::High, MainThreadItem::Delayed { runnable, millis }); + } + } + + fn spawn_realtime(&self, function: Box) { + if self.on_main_thread() { + let callback = Closure::once_into_js(move || { + function(); + }); + self.browser_window + .queue_microtask(callback.unchecked_ref()); + } else { + self.main_thread_mailbox + .post(Priority::High, MainThreadItem::RealtimeFunction(function)); + } + } + + fn now(&self) -> Instant { + Instant::now() + } +} + +fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) { + match item { + MainThreadItem::Runnable(runnable) => { + runnable.run(); + } + MainThreadItem::Delayed { runnable, millis } => { + let callback = Closure::once_into_js(move || { + runnable.run(); + }); + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.unchecked_ref(), + millis, + ) + .ok(); + } + MainThreadItem::RealtimeFunction(function) => { + function(); + } + } +} + +fn schedule_runnable(window: &web_sys::Window, runnable: RunnableVariant, priority: Priority) { + let callback = Closure::once_into_js(move || { + runnable.run(); + }); + let callback: &js_sys::Function = callback.unchecked_ref(); + + match priority { + Priority::RealtimeAudio => { + window.queue_microtask(callback); + } + _ => { + // TODO-Wasm: this ought to enqueue so we can dequeue with proper priority + window + .set_timeout_with_callback_and_timeout_and_arguments_0(callback, 0) + .ok(); + } + } +} diff --git a/src/platform/web/display.rs b/src/platform/web/display.rs new file mode 100644 index 0000000000..5023e7de33 --- /dev/null +++ b/src/platform/web/display.rs @@ -0,0 +1,98 @@ +use anyhow::Result; +use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay, Point, Size, px}; + +#[derive(Debug)] +pub struct WebDisplay { + id: DisplayId, + uuid: uuid::Uuid, + browser_window: web_sys::Window, +} + +// Safety: WASM is single-threaded — there is no concurrent access to `web_sys::Window`. +unsafe impl Send for WebDisplay {} +unsafe impl Sync for WebDisplay {} + +impl WebDisplay { + pub fn new(browser_window: web_sys::Window) -> Self { + WebDisplay { + id: DisplayId::new(1), + uuid: uuid::Uuid::new_v4(), + browser_window, + } + } + + fn screen_size(&self) -> Size { + let Some(screen) = self.browser_window.screen().ok() else { + return Size { + width: px(1920.), + height: px(1080.), + }; + }; + + let width = screen.width().unwrap_or(1920) as f32; + let height = screen.height().unwrap_or(1080) as f32; + + Size { + width: px(width), + height: px(height), + } + } + + fn viewport_size(&self) -> Size { + let width = self + .browser_window + .inner_width() + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(1920.0) as f32; + let height = self + .browser_window + .inner_height() + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(1080.0) as f32; + + Size { + width: px(width), + height: px(height), + } + } +} + +impl PlatformDisplay for WebDisplay { + fn id(&self) -> DisplayId { + self.id + } + + fn uuid(&self) -> Result { + Ok(self.uuid) + } + + fn bounds(&self) -> Bounds { + let size = self.screen_size(); + Bounds { + origin: Point::default(), + size, + } + } + + fn visible_bounds(&self) -> Bounds { + let size = self.viewport_size(); + Bounds { + origin: Point::default(), + size, + } + } + + fn default_bounds(&self) -> Bounds { + let visible = self.visible_bounds(); + let width = visible.size.width * 0.75; + let height = visible.size.height * 0.75; + let origin_x = (visible.size.width - width) / 2.0; + let origin_y = (visible.size.height - height) / 2.0; + Bounds { + origin: Point::new(origin_x, origin_y), + size: Size { width, height }, + } + } +} diff --git a/src/platform/web/events.rs b/src/platform/web/events.rs new file mode 100644 index 0000000000..46be646cb5 --- /dev/null +++ b/src/platform/web/events.rs @@ -0,0 +1,682 @@ +use std::rc::Rc; + +use gpui::{ + Capslock, DispatchEventResult, ExternalPaths, FileDropEvent, KeyDownEvent, KeyUpEvent, + Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, + MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformInput, Point, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, +}; +use smallvec::smallvec; +use wasm_bindgen::prelude::*; + +use crate::window::WebWindowInner; + +pub struct WebEventListeners { + #[allow(dead_code)] + closures: Vec>, +} + +pub(crate) struct ClickState { + last_position: Point, + last_time: f64, + current_count: usize, +} + +impl Default for ClickState { + fn default() -> Self { + Self { + last_position: Point::default(), + last_time: 0.0, + current_count: 0, + } + } +} + +impl ClickState { + fn register_click(&mut self, position: Point, time: f64) -> usize { + let distance = ((f32::from(position.x) - f32::from(self.last_position.x)).powi(2) + + (f32::from(position.y) - f32::from(self.last_position.y)).powi(2)) + .sqrt(); + + if (time - self.last_time) < 400.0 && distance < 5.0 { + self.current_count += 1; + } else { + self.current_count = 1; + } + + self.last_position = position; + self.last_time = time; + self.current_count + } +} + +impl WebWindowInner { + pub fn register_event_listeners(self: &Rc) -> WebEventListeners { + let mut closures = vec![ + self.register_pointer_down(), + self.register_pointer_up(), + self.register_pointer_move(), + self.register_pointer_leave(), + self.register_wheel(), + self.register_context_menu(), + self.register_dragover(), + self.register_drop(), + self.register_dragleave(), + self.register_key_down(), + self.register_key_up(), + self.register_composition_start(), + self.register_composition_update(), + self.register_composition_end(), + self.register_focus(), + self.register_blur(), + self.register_pointer_enter(), + self.register_pointer_leave_hover(), + ]; + closures.extend(self.register_visibility_change()); + closures.extend(self.register_appearance_change()); + + WebEventListeners { closures } + } + + fn listen( + self: &Rc, + event_name: &str, + handler: impl FnMut(JsValue) + 'static, + ) -> Closure { + let closure = Closure::::new(handler); + self.canvas + .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) + .ok(); + closure + } + + fn listen_input( + self: &Rc, + event_name: &str, + handler: impl FnMut(JsValue) + 'static, + ) -> Closure { + let closure = Closure::::new(handler); + self.input_element + .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) + .ok(); + closure + } + + /// Registers a listener with `{passive: false}` so that `preventDefault()` works. + /// Needed for events like `wheel` which are passive by default in modern browsers. + fn listen_non_passive( + self: &Rc, + event_name: &str, + handler: impl FnMut(JsValue) + 'static, + ) -> Closure { + let closure = Closure::::new(handler); + let canvas_js: &JsValue = self.canvas.as_ref(); + let callback_js: &JsValue = closure.as_ref(); + let options = js_sys::Object::new(); + js_sys::Reflect::set(&options, &"passive".into(), &false.into()).ok(); + if let Ok(add_fn_val) = js_sys::Reflect::get(canvas_js, &"addEventListener".into()) { + if let Ok(add_fn) = add_fn_val.dyn_into::() { + add_fn + .call3(canvas_js, &event_name.into(), callback_js, &options) + .ok(); + } + } + closure + } + + fn dispatch_input(&self, input: PlatformInput) -> Option { + let mut borrowed = self.callbacks.borrow_mut(); + borrowed.input.as_mut().map(|callback| callback(input)) + } + + fn register_pointer_down(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("pointerdown", move |event: JsValue| { + let event: web_sys::PointerEvent = event.unchecked_into(); + event.prevent_default(); + this.input_element.focus().ok(); + + let button = dom_mouse_button_to_gpui(event.button()); + let position = pointer_position_in_element(&event); + let modifiers = modifiers_from_mouse_event(&event, this.is_mac); + let time = js_sys::Date::now(); + + this.pressed_button.set(Some(button)); + let click_count = this.click_state.borrow_mut().register_click(position, time); + + { + let mut current_state = this.state.borrow_mut(); + current_state.mouse_position = position; + current_state.modifiers = modifiers; + } + + this.dispatch_input(PlatformInput::MouseDown(MouseDownEvent { + button, + position, + modifiers, + click_count, + first_mouse: false, + })); + }) + } + + fn register_pointer_up(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("pointerup", move |event: JsValue| { + let event: web_sys::PointerEvent = event.unchecked_into(); + event.prevent_default(); + + let button = dom_mouse_button_to_gpui(event.button()); + let position = pointer_position_in_element(&event); + let modifiers = modifiers_from_mouse_event(&event, this.is_mac); + + this.pressed_button.set(None); + let click_count = this.click_state.borrow().current_count; + + { + let mut current_state = this.state.borrow_mut(); + current_state.mouse_position = position; + current_state.modifiers = modifiers; + } + + this.dispatch_input(PlatformInput::MouseUp(MouseUpEvent { + button, + position, + modifiers, + click_count, + })); + }) + } + + fn register_pointer_move(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("pointermove", move |event: JsValue| { + let event: web_sys::PointerEvent = event.unchecked_into(); + event.prevent_default(); + + let position = pointer_position_in_element(&event); + let modifiers = modifiers_from_mouse_event(&event, this.is_mac); + let current_pressed = this.pressed_button.get(); + + { + let mut current_state = this.state.borrow_mut(); + current_state.mouse_position = position; + current_state.modifiers = modifiers; + } + + this.dispatch_input(PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button: current_pressed, + modifiers, + })); + }) + } + + fn register_pointer_leave(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("pointerleave", move |event: JsValue| { + let event: web_sys::PointerEvent = event.unchecked_into(); + + let position = pointer_position_in_element(&event); + let modifiers = modifiers_from_mouse_event(&event, this.is_mac); + let current_pressed = this.pressed_button.get(); + + { + let mut current_state = this.state.borrow_mut(); + current_state.mouse_position = position; + current_state.modifiers = modifiers; + } + + this.dispatch_input(PlatformInput::MouseExited(MouseExitEvent { + position, + pressed_button: current_pressed, + modifiers, + })); + }) + } + + fn register_wheel(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_non_passive("wheel", move |event: JsValue| { + let event: web_sys::WheelEvent = event.unchecked_into(); + event.prevent_default(); + + let mouse_event: &web_sys::MouseEvent = event.as_ref(); + let position = mouse_position_in_element(mouse_event); + let modifiers = modifiers_from_wheel_event(mouse_event, this.is_mac); + + let delta_mode = event.delta_mode(); + let delta = if delta_mode == 1 { + ScrollDelta::Lines(point(-event.delta_x() as f32, -event.delta_y() as f32)) + } else { + ScrollDelta::Pixels(point( + px(-event.delta_x() as f32), + px(-event.delta_y() as f32), + )) + }; + + { + let mut current_state = this.state.borrow_mut(); + current_state.modifiers = modifiers; + } + + this.dispatch_input(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta, + modifiers, + touch_phase: TouchPhase::Moved, + })); + }) + } + + fn register_context_menu(self: &Rc) -> Closure { + self.listen("contextmenu", move |event: JsValue| { + let event: web_sys::Event = event.unchecked_into(); + event.prevent_default(); + }) + } + + fn register_dragover(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("dragover", move |event: JsValue| { + let event: web_sys::DragEvent = event.unchecked_into(); + event.prevent_default(); + + let mouse_event: &web_sys::MouseEvent = event.as_ref(); + let position = mouse_position_in_element(mouse_event); + + { + let mut current_state = this.state.borrow_mut(); + current_state.mouse_position = position; + } + + this.dispatch_input(PlatformInput::FileDrop(FileDropEvent::Pending { position })); + }) + } + + fn register_drop(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("drop", move |event: JsValue| { + let event: web_sys::DragEvent = event.unchecked_into(); + event.prevent_default(); + + let mouse_event: &web_sys::MouseEvent = event.as_ref(); + let position = mouse_position_in_element(mouse_event); + + { + let mut current_state = this.state.borrow_mut(); + current_state.mouse_position = position; + } + + let paths = extract_file_paths_from_drag(&event); + + this.dispatch_input(PlatformInput::FileDrop(FileDropEvent::Entered { + position, + paths: ExternalPaths(paths), + })); + + this.dispatch_input(PlatformInput::FileDrop(FileDropEvent::Submit { position })); + }) + } + + fn register_dragleave(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("dragleave", move |_event: JsValue| { + this.dispatch_input(PlatformInput::FileDrop(FileDropEvent::Exited)); + }) + } + + fn register_key_down(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("keydown", move |event: JsValue| { + let event: web_sys::KeyboardEvent = event.unchecked_into(); + + let modifiers = modifiers_from_keyboard_event(&event, this.is_mac); + let capslock = capslock_from_keyboard_event(&event); + + { + let mut current_state = this.state.borrow_mut(); + current_state.modifiers = modifiers; + current_state.capslock = capslock; + } + + this.dispatch_input(PlatformInput::ModifiersChanged(ModifiersChangedEvent { + modifiers, + capslock, + })); + + let key = dom_key_to_gpui_key(&event); + + if is_modifier_only_key(&key) { + return; + } + + event.prevent_default(); + + let is_held = event.repeat(); + let key_char = compute_key_char(&event, &key, &modifiers); + + let keystroke = Keystroke { + modifiers, + key, + key_char: key_char.clone(), + }; + + let result = this.dispatch_input(PlatformInput::KeyDown(KeyDownEvent { + keystroke, + is_held, + prefer_character_input: false, + })); + + if let Some(result) = result { + if !result.propagate { + return; + } + } + + if this.is_composing.get() || event.is_composing() { + return; + } + + if modifiers.is_subset_of(&Modifiers::shift()) { + if let Some(text) = key_char { + this.with_input_handler(|handler| { + handler.replace_text_in_range(None, &text); + }); + } + } + }) + } + + fn register_key_up(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("keyup", move |event: JsValue| { + let event: web_sys::KeyboardEvent = event.unchecked_into(); + + let modifiers = modifiers_from_keyboard_event(&event, this.is_mac); + let capslock = capslock_from_keyboard_event(&event); + + { + let mut current_state = this.state.borrow_mut(); + current_state.modifiers = modifiers; + current_state.capslock = capslock; + } + + this.dispatch_input(PlatformInput::ModifiersChanged(ModifiersChangedEvent { + modifiers, + capslock, + })); + + let key = dom_key_to_gpui_key(&event); + + if is_modifier_only_key(&key) { + return; + } + + event.prevent_default(); + + let key_char = compute_key_char(&event, &key, &modifiers); + + let keystroke = Keystroke { + modifiers, + key, + key_char, + }; + + this.dispatch_input(PlatformInput::KeyUp(KeyUpEvent { keystroke })); + }) + } + + fn register_composition_start(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("compositionstart", move |_event: JsValue| { + this.is_composing.set(true); + }) + } + + fn register_composition_update(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("compositionupdate", move |event: JsValue| { + let event: web_sys::CompositionEvent = event.unchecked_into(); + let data = event.data().unwrap_or_default(); + this.is_composing.set(true); + this.with_input_handler(|handler| { + handler.replace_and_mark_text_in_range(None, &data, None); + }); + }) + } + + fn register_composition_end(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("compositionend", move |event: JsValue| { + let event: web_sys::CompositionEvent = event.unchecked_into(); + let data = event.data().unwrap_or_default(); + this.is_composing.set(false); + this.with_input_handler(|handler| { + handler.replace_text_in_range(None, &data); + handler.unmark_text(); + }); + this.input_element.set_value(""); + }) + } + + fn register_focus(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("focus", move |_event: JsValue| { + { + let mut state = this.state.borrow_mut(); + state.is_active = true; + } + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.active_status_change { + callback(true); + } + }) + } + + fn register_blur(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("blur", move |_event: JsValue| { + { + let mut state = this.state.borrow_mut(); + state.is_active = false; + } + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.active_status_change { + callback(false); + } + }) + } + + fn register_pointer_enter(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("pointerenter", move |_event: JsValue| { + { + let mut state = this.state.borrow_mut(); + state.is_hovered = true; + } + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.hover_status_change { + callback(true); + } + }) + } + + fn register_pointer_leave_hover(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("pointerleave", move |_event: JsValue| { + { + let mut state = this.state.borrow_mut(); + state.is_hovered = false; + } + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.hover_status_change { + callback(false); + } + }) + } +} + +fn dom_key_to_gpui_key(event: &web_sys::KeyboardEvent) -> String { + let key = event.key(); + match key.as_str() { + "Enter" => "enter".to_string(), + "Backspace" => "backspace".to_string(), + "Tab" => "tab".to_string(), + "Escape" => "escape".to_string(), + "Delete" => "delete".to_string(), + " " => "space".to_string(), + "ArrowLeft" => "left".to_string(), + "ArrowRight" => "right".to_string(), + "ArrowUp" => "up".to_string(), + "ArrowDown" => "down".to_string(), + "Home" => "home".to_string(), + "End" => "end".to_string(), + "PageUp" => "pageup".to_string(), + "PageDown" => "pagedown".to_string(), + "Insert" => "insert".to_string(), + "Control" => "control".to_string(), + "Alt" => "alt".to_string(), + "Shift" => "shift".to_string(), + "Meta" => "platform".to_string(), + "CapsLock" => "capslock".to_string(), + other => { + if let Some(rest) = other.strip_prefix('F') { + if let Ok(number) = rest.parse::() { + if (1..=35).contains(&number) { + return format!("f{number}"); + } + } + } + other.to_lowercase() + } + } +} + +fn dom_mouse_button_to_gpui(button: i16) -> MouseButton { + match button { + 0 => MouseButton::Left, + 1 => MouseButton::Middle, + 2 => MouseButton::Right, + 3 => MouseButton::Navigate(NavigationDirection::Back), + 4 => MouseButton::Navigate(NavigationDirection::Forward), + _ => MouseButton::Left, + } +} + +fn modifiers_from_keyboard_event(event: &web_sys::KeyboardEvent, _is_mac: bool) -> Modifiers { + Modifiers { + control: event.ctrl_key(), + alt: event.alt_key(), + shift: event.shift_key(), + platform: event.meta_key(), + function: false, + } +} + +fn modifiers_from_mouse_event(event: &web_sys::PointerEvent, _is_mac: bool) -> Modifiers { + let mouse_event: &web_sys::MouseEvent = event.as_ref(); + Modifiers { + control: mouse_event.ctrl_key(), + alt: mouse_event.alt_key(), + shift: mouse_event.shift_key(), + platform: mouse_event.meta_key(), + function: false, + } +} + +fn modifiers_from_wheel_event(event: &web_sys::MouseEvent, _is_mac: bool) -> Modifiers { + Modifiers { + control: event.ctrl_key(), + alt: event.alt_key(), + shift: event.shift_key(), + platform: event.meta_key(), + function: false, + } +} + +fn capslock_from_keyboard_event(event: &web_sys::KeyboardEvent) -> Capslock { + Capslock { + on: event.get_modifier_state("CapsLock"), + } +} + +pub(crate) fn is_mac_platform(browser_window: &web_sys::Window) -> bool { + let navigator = browser_window.navigator(); + + #[allow(deprecated)] + // navigator.platform() is deprecated but navigator.userAgentData is not widely available yet + if let Ok(platform) = navigator.platform() { + if platform.contains("Mac") { + return true; + } + } + + if let Ok(user_agent) = navigator.user_agent() { + return user_agent.contains("Mac"); + } + + false +} + +fn is_modifier_only_key(key: &str) -> bool { + matches!( + key, + "control" | "alt" | "shift" | "platform" | "capslock" | "compose" | "process" + ) +} + +fn compute_key_char( + event: &web_sys::KeyboardEvent, + gpui_key: &str, + modifiers: &Modifiers, +) -> Option { + if modifiers.platform || modifiers.control { + return None; + } + + if is_modifier_only_key(gpui_key) { + return None; + } + + if gpui_key == "space" { + return Some(" ".to_string()); + } + + let raw_key = event.key(); + + if raw_key.len() == 1 { + return Some(raw_key); + } + + None +} + +fn pointer_position_in_element(event: &web_sys::PointerEvent) -> Point { + let mouse_event: &web_sys::MouseEvent = event.as_ref(); + mouse_position_in_element(mouse_event) +} + +fn mouse_position_in_element(event: &web_sys::MouseEvent) -> Point { + // offset_x/offset_y give position relative to the target element's padding edge + point(px(event.offset_x() as f32), px(event.offset_y() as f32)) +} + +fn extract_file_paths_from_drag( + event: &web_sys::DragEvent, +) -> smallvec::SmallVec<[std::path::PathBuf; 2]> { + let mut paths = smallvec![]; + let Some(data_transfer) = event.data_transfer() else { + return paths; + }; + let file_list = data_transfer.files(); + let Some(files) = file_list else { + return paths; + }; + for index in 0..files.length() { + if let Some(file) = files.get(index) { + paths.push(std::path::PathBuf::from(file.name())); + } + } + paths +} diff --git a/src/platform/web/http_client.rs b/src/platform/web/http_client.rs new file mode 100644 index 0000000000..14d58cf457 --- /dev/null +++ b/src/platform/web/http_client.rs @@ -0,0 +1,199 @@ +use anyhow::anyhow; +use futures::AsyncReadExt as _; +use http_client::{AsyncBody, HttpClient, RedirectPolicy}; +use std::future::Future; +use std::pin::Pin; +use std::task::Poll; +use wasm_bindgen::JsCast as _; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(catch, js_name = "fetch")] + fn global_fetch(input: &web_sys::Request) -> Result; +} + +pub struct FetchHttpClient { + user_agent: Option, +} + +impl Default for FetchHttpClient { + fn default() -> Self { + Self { user_agent: None } + } +} + +#[cfg(feature = "multithreaded")] +impl FetchHttpClient { + /// # Safety + /// + /// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment. + pub unsafe fn new() -> Self { + Self::default() + } + + /// # Safety + /// + /// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment. + pub unsafe fn with_user_agent(user_agent: &str) -> anyhow::Result { + Ok(Self { + user_agent: Some(http_client::http::header::HeaderValue::from_str( + user_agent, + )?), + }) + } +} + +#[cfg(not(feature = "multithreaded"))] +impl FetchHttpClient { + pub fn new() -> Self { + Self::default() + } + + pub fn with_user_agent(user_agent: &str) -> anyhow::Result { + Ok(Self { + user_agent: Some(http_client::http::header::HeaderValue::from_str( + user_agent, + )?), + }) + } +} + +/// Wraps a `!Send` future to satisfy the `Send` bound on `BoxFuture`. +/// +/// Safety: only valid in WASM contexts where the `FetchHttpClient` is +/// confined to a single thread (guaranteed by the caller via unsafe +/// constructors when `multithreaded` is enabled, or by the absence of +/// threads when it is not). +struct AssertSend(F); + +unsafe impl Send for AssertSend {} + +impl Future for AssertSend { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { + // Safety: pin projection for a single-field newtype wrapper. + let inner = unsafe { self.map_unchecked_mut(|this| &mut this.0) }; + inner.poll(cx) + } +} + +impl HttpClient for FetchHttpClient { + fn user_agent(&self) -> Option<&http_client::http::header::HeaderValue> { + self.user_agent.as_ref() + } + + fn proxy(&self) -> Option<&http_client::Url> { + None + } + + fn send( + &self, + req: http_client::http::Request, + ) -> futures::future::BoxFuture<'static, anyhow::Result>> + { + let (parts, body) = req.into_parts(); + + Box::pin(AssertSend(async move { + let body_bytes = read_body_to_bytes(body).await?; + + let init = web_sys::RequestInit::new(); + init.set_method(parts.method.as_str()); + + if let Some(redirect_policy) = parts.extensions.get::() { + match redirect_policy { + RedirectPolicy::NoFollow => { + init.set_redirect(web_sys::RequestRedirect::Manual); + } + RedirectPolicy::FollowLimit(_) | RedirectPolicy::FollowAll => { + init.set_redirect(web_sys::RequestRedirect::Follow); + } + } + } + + if let Some(ref bytes) = body_bytes { + let uint8array = js_sys::Uint8Array::from(bytes.as_slice()); + init.set_body(uint8array.as_ref()); + } + + let url = parts.uri.to_string(); + let request = web_sys::Request::new_with_str_and_init(&url, &init) + .map_err(|error| anyhow!("failed to create fetch Request: {error:?}"))?; + + let request_headers = request.headers(); + for (name, value) in &parts.headers { + let value_str = value + .to_str() + .map_err(|_| anyhow!("non-ASCII header value for {name}"))?; + request_headers + .set(name.as_str(), value_str) + .map_err(|error| anyhow!("failed to set header {name}: {error:?}"))?; + } + + let promise = global_fetch(&request) + .map_err(|error| anyhow!("fetch threw an error: {error:?}"))?; + let response_value = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|error| anyhow!("fetch failed: {error:?}"))?; + + let web_response: web_sys::Response = response_value + .dyn_into() + .map_err(|error| anyhow!("fetch result is not a Response: {error:?}"))?; + + let status = web_response.status(); + let mut builder = http_client::http::Response::builder().status(status); + + // `Headers` is a JS iterable yielding `[name, value]` pairs. + // `js_sys::Array::from` calls `Array.from()` which accepts any iterable. + let header_pairs = js_sys::Array::from(&web_response.headers()); + for index in 0..header_pairs.length() { + match header_pairs.get(index).dyn_into::() { + Ok(pair) => match (pair.get(0).as_string(), pair.get(1).as_string()) { + (Some(name), Some(value)) => { + builder = builder.header(name, value); + } + (name, value) => { + log::warn!( + "skipping response header at index {index}: \ + name={name:?}, value={value:?}" + ); + } + }, + Err(entry) => { + log::warn!("skipping non-array header entry at index {index}: {entry:?}"); + } + } + } + + // The entire response body is eagerly buffered into memory via + // `arrayBuffer()`. The Fetch API does not expose a synchronous + // streaming interface; streaming would require `ReadableStream` + // interop which is significantly more complex. + let body_promise = web_response + .array_buffer() + .map_err(|error| anyhow!("failed to initiate response body read: {error:?}"))?; + let body_value = wasm_bindgen_futures::JsFuture::from(body_promise) + .await + .map_err(|error| anyhow!("failed to read response body: {error:?}"))?; + let array_buffer: js_sys::ArrayBuffer = body_value + .dyn_into() + .map_err(|error| anyhow!("response body is not an ArrayBuffer: {error:?}"))?; + let response_bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); + + builder + .body(AsyncBody::from(response_bytes)) + .map_err(|error| anyhow!(error)) + })) + } +} + +async fn read_body_to_bytes(mut body: AsyncBody) -> anyhow::Result>> { + let mut buffer = Vec::new(); + body.read_to_end(&mut buffer).await?; + if buffer.is_empty() { + Ok(None) + } else { + Ok(Some(buffer)) + } +} diff --git a/src/platform/web/keyboard.rs b/src/platform/web/keyboard.rs new file mode 100644 index 0000000000..0ab4f7aa4a --- /dev/null +++ b/src/platform/web/keyboard.rs @@ -0,0 +1,19 @@ +use gpui::PlatformKeyboardLayout; + +pub struct WebKeyboardLayout; + +impl WebKeyboardLayout { + pub fn new() -> Self { + WebKeyboardLayout + } +} + +impl PlatformKeyboardLayout for WebKeyboardLayout { + fn id(&self) -> &str { + "us" + } + + fn name(&self) -> &str { + "US" + } +} diff --git a/src/platform/web/logging.rs b/src/platform/web/logging.rs new file mode 100644 index 0000000000..773118eeb2 --- /dev/null +++ b/src/platform/web/logging.rs @@ -0,0 +1,37 @@ +use log::{Level, Log, Metadata, Record}; + +struct ConsoleLogger; + +impl Log for ConsoleLogger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + let message = format!( + "[{}] {}: {}", + record.level(), + record.target(), + record.args() + ); + let js_string = wasm_bindgen::JsValue::from_str(&message); + + match record.level() { + Level::Error => web_sys::console::error_1(&js_string), + Level::Warn => web_sys::console::warn_1(&js_string), + Level::Info => web_sys::console::info_1(&js_string), + Level::Debug | Level::Trace => web_sys::console::log_1(&js_string), + } + } + + fn flush(&self) {} +} + +pub fn init_logging() { + log::set_logger(&ConsoleLogger).ok(); + log::set_max_level(log::LevelFilter::Info); +} diff --git a/src/platform/web/platform.rs b/src/platform/web/platform.rs new file mode 100644 index 0000000000..2af56afe64 --- /dev/null +++ b/src/platform/web/platform.rs @@ -0,0 +1,344 @@ +use crate::dispatcher::WebDispatcher; +use crate::display::WebDisplay; +use crate::keyboard::WebKeyboardLayout; +use crate::platform::wgpu::WgpuContext; +use crate::window::WebWindow; +use anyhow::Result; +use futures::channel::oneshot; +use gpui::{ + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, + ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, + ThermalState, WindowAppearance, WindowParams, +}; +use std::{ + borrow::Cow, + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +static BUNDLED_FONTS: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"), + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf"), + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf"), + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBoldItalic.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), +]; + +pub struct WebPlatform { + browser_window: web_sys::Window, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + active_window: RefCell>, + active_display: Rc, + callbacks: RefCell, + wgpu_context: Rc>>, +} + +#[derive(Default)] +struct WebPlatformCallbacks { + open_urls: Option)>>, + quit: Option>, + reopen: Option>, + app_menu_action: Option>, + will_open_app_menu: Option>, + validate_app_menu_command: Option bool>>, + keyboard_layout_change: Option>, + thermal_state_change: Option>, +} + +impl WebPlatform { + pub fn new(allow_multi_threading: bool) -> Self { + let browser_window = + web_sys::window().expect("must be running in a browser window context"); + let dispatcher = Arc::new(WebDispatcher::new( + browser_window.clone(), + allow_multi_threading, + )); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + let text_system = Arc::new( + crate::platform::wgpu::CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"), + ); + let fonts = BUNDLED_FONTS + .iter() + .map(|bytes| Cow::Borrowed(*bytes)) + .collect(); + if let Err(error) = text_system.add_fonts(fonts) { + log::error!("failed to load bundled fonts: {error:#}"); + } + let text_system: Arc = text_system; + let active_display: Rc = + Rc::new(WebDisplay::new(browser_window.clone())); + + Self { + browser_window, + background_executor, + foreground_executor, + text_system, + active_window: RefCell::new(None), + active_display, + callbacks: RefCell::new(WebPlatformCallbacks::default()), + wgpu_context: Rc::new(RefCell::new(None)), + } + } +} + +impl Platform for WebPlatform { + fn background_executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + fn text_system(&self) -> Arc { + self.text_system.clone() + } + + fn run(&self, on_finish_launching: Box) { + let wgpu_context = self.wgpu_context.clone(); + wasm_bindgen_futures::spawn_local(async move { + match WgpuContext::new_web().await { + Ok(context) => { + log::info!("WebGPU context initialized successfully"); + *wgpu_context.borrow_mut() = Some(context); + on_finish_launching(); + } + Err(err) => { + log::error!("Failed to initialize WebGPU context: {err:#}"); + on_finish_launching(); + } + } + }); + } + + fn quit(&self) { + log::warn!("WebPlatform::quit called, but quitting is not supported in the browser ."); + } + + fn restart(&self, _binary_path: Option) {} + + fn activate(&self, _ignoring_other_apps: bool) {} + + fn hide(&self) {} + + fn hide_other_apps(&self) {} + + fn unhide_other_apps(&self) {} + + fn displays(&self) -> Vec> { + vec![self.active_display.clone()] + } + + fn primary_display(&self) -> Option> { + Some(self.active_display.clone()) + } + + fn active_window(&self) -> Option { + *self.active_window.borrow() + } + + fn open_window( + &self, + handle: AnyWindowHandle, + params: WindowParams, + ) -> anyhow::Result> { + let context_ref = self.wgpu_context.borrow(); + let context = context_ref.as_ref().ok_or_else(|| { + anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?") + })?; + + let window = WebWindow::new(handle, params, context, self.browser_window.clone())?; + *self.active_window.borrow_mut() = Some(handle); + Ok(Box::new(window)) + } + + fn window_appearance(&self) -> WindowAppearance { + let Ok(Some(media_query)) = self + .browser_window + .match_media("(prefers-color-scheme: dark)") + else { + return WindowAppearance::Light; + }; + if media_query.matches() { + WindowAppearance::Dark + } else { + WindowAppearance::Light + } + } + + fn open_url(&self, url: &str) { + if let Err(error) = self.browser_window.open_with_url(url) { + log::warn!("Failed to open URL '{url}': {error:?}"); + } + } + + fn on_open_urls(&self, callback: Box)>) { + self.callbacks.borrow_mut().open_urls = Some(callback); + } + + fn register_url_scheme(&self, _url: &str) -> Task> { + Task::ready(Ok(())) + } + + fn prompt_for_paths( + &self, + _options: PathPromptOptions, + ) -> oneshot::Receiver>>> { + let (tx, rx) = oneshot::channel(); + tx.send(Err(anyhow::anyhow!( + "prompt_for_paths is not supported on the web" + ))) + .ok(); + rx + } + + fn prompt_for_new_path( + &self, + _directory: &Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + let (sender, receiver) = oneshot::channel(); + sender + .send(Err(anyhow::anyhow!( + "prompt_for_new_path is not supported on the web" + ))) + .ok(); + receiver + } + + fn can_select_mixed_files_and_dirs(&self) -> bool { + false + } + + fn reveal_path(&self, _path: &Path) {} + + fn open_with_system(&self, _path: &Path) {} + + fn on_quit(&self, callback: Box) { + self.callbacks.borrow_mut().quit = Some(callback); + } + + fn on_reopen(&self, callback: Box) { + self.callbacks.borrow_mut().reopen = Some(callback); + } + + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} + + fn on_app_menu_action(&self, callback: Box) { + self.callbacks.borrow_mut().app_menu_action = Some(callback); + } + + fn on_will_open_app_menu(&self, callback: Box) { + self.callbacks.borrow_mut().will_open_app_menu = Some(callback); + } + + fn on_validate_app_menu_command(&self, callback: Box bool>) { + self.callbacks.borrow_mut().validate_app_menu_command = Some(callback); + } + + fn thermal_state(&self) -> ThermalState { + ThermalState::Nominal + } + + fn on_thermal_state_change(&self, callback: Box) { + self.callbacks.borrow_mut().thermal_state_change = Some(callback); + } + + fn compositor_name(&self) -> &'static str { + "Web" + } + + fn app_path(&self) -> Result { + Err(anyhow::anyhow!("app_path is not available on the web")) + } + + fn path_for_auxiliary_executable(&self, _name: &str) -> Result { + Err(anyhow::anyhow!( + "path_for_auxiliary_executable is not available on the web" + )) + } + + fn set_cursor_style(&self, style: CursorStyle) { + let css_cursor = match style { + CursorStyle::Arrow => "default", + CursorStyle::IBeam => "text", + CursorStyle::Crosshair => "crosshair", + CursorStyle::ClosedHand => "grabbing", + CursorStyle::OpenHand => "grab", + CursorStyle::PointingHand => "pointer", + CursorStyle::ResizeLeft | CursorStyle::ResizeRight | CursorStyle::ResizeLeftRight => { + "ew-resize" + } + CursorStyle::ResizeUp | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown => { + "ns-resize" + } + CursorStyle::ResizeUpLeftDownRight => "nesw-resize", + CursorStyle::ResizeUpRightDownLeft => "nwse-resize", + CursorStyle::ResizeColumn => "col-resize", + CursorStyle::ResizeRow => "row-resize", + CursorStyle::IBeamCursorForVerticalLayout => "vertical-text", + CursorStyle::OperationNotAllowed => "not-allowed", + CursorStyle::DragLink => "alias", + CursorStyle::DragCopy => "copy", + CursorStyle::ContextualMenu => "context-menu", + 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:?}"); + } + } + } + } + + fn should_auto_hide_scrollbars(&self) -> bool { + true + } + + fn read_from_clipboard(&self) -> Option { + None + } + + fn write_to_clipboard(&self, _item: ClipboardItem) {} + + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "credential storage is not available on the web" + ))) + } + + fn read_credentials(&self, _url: &str) -> Task)>>> { + Task::ready(Ok(None)) + } + + fn delete_credentials(&self, _url: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "credential storage is not available on the web" + ))) + } + + fn keyboard_layout(&self) -> Box { + Box::new(WebKeyboardLayout) + } + + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + + fn on_keyboard_layout_change(&self, callback: Box) { + self.callbacks.borrow_mut().keyboard_layout_change = Some(callback); + } +} diff --git a/src/platform/web/window.rs b/src/platform/web/window.rs new file mode 100644 index 0000000000..c33f8c0142 --- /dev/null +++ b/src/platform/web/window.rs @@ -0,0 +1,730 @@ +use crate::display::WebDisplay; +use crate::events::{ClickState, WebEventListeners, is_mac_platform}; +use std::sync::Arc; +use std::{cell::Cell, cell::RefCell, rc::Rc}; + +use crate::platform::wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui::{ + AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, + Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + ResizeEdge, Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, +}; +use wasm_bindgen::prelude::*; + +#[derive(Default)] +pub(crate) struct WebWindowCallbacks { + pub(crate) request_frame: Option>, + pub(crate) input: Option DispatchEventResult>>, + pub(crate) active_status_change: Option>, + pub(crate) hover_status_change: Option>, + pub(crate) resize: Option, f32)>>, + pub(crate) moved: Option>, + pub(crate) should_close: Option bool>>, + pub(crate) close: Option>, + pub(crate) appearance_changed: Option>, + pub(crate) hit_test_window_control: Option Option>>, +} + +pub(crate) struct WebWindowMutableState { + pub(crate) renderer: WgpuRenderer, + pub(crate) bounds: Bounds, + pub(crate) scale_factor: f32, + pub(crate) max_texture_dimension: u32, + pub(crate) title: String, + pub(crate) input_handler: Option, + pub(crate) is_fullscreen: bool, + pub(crate) is_active: bool, + pub(crate) is_hovered: bool, + pub(crate) mouse_position: Point, + pub(crate) modifiers: Modifiers, + pub(crate) capslock: Capslock, +} + +pub(crate) struct WebWindowInner { + pub(crate) browser_window: web_sys::Window, + pub(crate) canvas: web_sys::HtmlCanvasElement, + pub(crate) input_element: web_sys::HtmlInputElement, + pub(crate) has_device_pixel_support: bool, + pub(crate) is_mac: bool, + pub(crate) state: RefCell, + pub(crate) callbacks: RefCell, + pub(crate) click_state: RefCell, + pub(crate) pressed_button: Cell>, + pub(crate) last_physical_size: Cell<(u32, u32)>, + pub(crate) notify_scale: Cell, + pub(crate) is_composing: Cell, + mql_handle: RefCell>, + pending_physical_size: Cell>, +} + +pub struct WebWindow { + inner: Rc, + display: Rc, + #[allow(dead_code)] + handle: AnyWindowHandle, + _raf_closure: Closure, + _resize_observer: Option, + _resize_observer_closure: Closure, + _event_listeners: WebEventListeners, +} + +impl WebWindow { + pub fn new( + handle: AnyWindowHandle, + _params: WindowParams, + context: &WgpuContext, + browser_window: web_sys::Window, + ) -> anyhow::Result { + let document = browser_window + .document() + .ok_or_else(|| anyhow::anyhow!("No `document` found on window"))?; + + let canvas: web_sys::HtmlCanvasElement = document + .create_element("canvas") + .map_err(|e| anyhow::anyhow!("Failed to create canvas element: {e:?}"))? + .dyn_into() + .map_err(|e| anyhow::anyhow!("Created element is not a canvas: {e:?}"))?; + + let dpr = browser_window.device_pixel_ratio() as f32; + let max_texture_dimension = context.device.limits().max_texture_dimension_2d; + let has_device_pixel_support = check_device_pixel_support(); + + canvas.set_tab_index(-1); + + let style = canvas.style(); + style + .set_property("width", "100%") + .map_err(|e| anyhow::anyhow!("Failed to set canvas width style: {e:?}"))?; + style + .set_property("height", "100%") + .map_err(|e| anyhow::anyhow!("Failed to set canvas height style: {e:?}"))?; + style + .set_property("display", "block") + .map_err(|e| anyhow::anyhow!("Failed to set canvas display style: {e:?}"))?; + style + .set_property("outline", "none") + .map_err(|e| anyhow::anyhow!("Failed to set canvas outline style: {e:?}"))?; + style + .set_property("touch-action", "none") + .map_err(|e| anyhow::anyhow!("Failed to set touch-action style: {e:?}"))?; + + let body = document + .body() + .ok_or_else(|| anyhow::anyhow!("No `body` found on document"))?; + body.append_child(&canvas) + .map_err(|e| anyhow::anyhow!("Failed to append canvas to body: {e:?}"))?; + + let input_element: web_sys::HtmlInputElement = document + .create_element("input") + .map_err(|e| anyhow::anyhow!("Failed to create input element: {e:?}"))? + .dyn_into() + .map_err(|e| anyhow::anyhow!("Created element is not an input: {e:?}"))?; + let input_style = input_element.style(); + input_style.set_property("position", "fixed").ok(); + input_style.set_property("top", "0").ok(); + input_style.set_property("left", "0").ok(); + input_style.set_property("width", "1px").ok(); + input_style.set_property("height", "1px").ok(); + input_style.set_property("opacity", "0").ok(); + body.append_child(&input_element) + .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; + input_element.focus().ok(); + + let device_size = Size { + width: DevicePixels(0), + height: DevicePixels(0), + }; + + let renderer_config = WgpuSurfaceConfig { + size: device_size, + transparent: false, + }; + + let renderer = WgpuRenderer::new_from_canvas(context, &canvas, renderer_config)?; + + let display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); + + let initial_bounds = Bounds { + origin: Point::default(), + size: Size::default(), + }; + + let mutable_state = WebWindowMutableState { + renderer, + bounds: initial_bounds, + scale_factor: dpr, + max_texture_dimension, + title: String::new(), + input_handler: None, + is_fullscreen: false, + is_active: true, + is_hovered: false, + mouse_position: Point::default(), + modifiers: Modifiers::default(), + capslock: Capslock::default(), + }; + + let is_mac = is_mac_platform(&browser_window); + + let inner = Rc::new(WebWindowInner { + browser_window, + canvas, + input_element, + has_device_pixel_support, + is_mac, + state: RefCell::new(mutable_state), + callbacks: RefCell::new(WebWindowCallbacks::default()), + click_state: RefCell::new(ClickState::default()), + pressed_button: Cell::new(None), + last_physical_size: Cell::new((0, 0)), + notify_scale: Cell::new(false), + is_composing: Cell::new(false), + mql_handle: RefCell::new(None), + pending_physical_size: Cell::new(None), + }); + + let raf_closure = inner.create_raf_closure(); + inner.schedule_raf(&raf_closure); + + let resize_observer_closure = Self::create_resize_observer_closure(Rc::clone(&inner)); + let resize_observer = + web_sys::ResizeObserver::new(resize_observer_closure.as_ref().unchecked_ref()).ok(); + + if let Some(ref observer) = resize_observer { + inner.observe_canvas(observer); + inner.watch_dpr_changes(observer); + } + + let event_listeners = inner.register_event_listeners(); + + Ok(Self { + inner, + display, + handle, + _raf_closure: raf_closure, + _resize_observer: resize_observer, + _resize_observer_closure: resize_observer_closure, + _event_listeners: event_listeners, + }) + } + + fn create_resize_observer_closure( + inner: Rc, + ) -> Closure { + Closure::new(move |entries: js_sys::Array| { + let entry: web_sys::ResizeObserverEntry = match entries.get(0).dyn_into().ok() { + Some(entry) => entry, + None => return, + }; + + let dpr = inner.browser_window.device_pixel_ratio(); + let dpr_f32 = dpr as f32; + + let (physical_width, physical_height, logical_width, logical_height) = + if inner.has_device_pixel_support { + let size: web_sys::ResizeObserverSize = entry + .device_pixel_content_box_size() + .get(0) + .unchecked_into(); + let pw = size.inline_size() as u32; + let ph = size.block_size() as u32; + let lw = pw as f64 / dpr; + let lh = ph as f64 / dpr; + (pw, ph, lw as f32, lh as f32) + } else { + // Safari fallback: use contentRect (always CSS px). + let rect = entry.content_rect(); + let lw = rect.width() as f32; + let lh = rect.height() as f32; + let pw = (lw as f64 * dpr).round() as u32; + let ph = (lh as f64 * dpr).round() as u32; + (pw, ph, lw, lh) + }; + + let scale_changed = inner.notify_scale.replace(false); + let prev = inner.last_physical_size.get(); + let size_changed = prev != (physical_width, physical_height); + + if !scale_changed && !size_changed { + return; + } + inner + .last_physical_size + .set((physical_width, physical_height)); + + // Skip rendering to a zero-size canvas (e.g. display:none). + if physical_width == 0 || physical_height == 0 { + let mut s = inner.state.borrow_mut(); + s.bounds.size = Size::default(); + s.scale_factor = dpr_f32; + // Still fire the callback so GPUI knows the window is gone. + drop(s); + let mut cbs = inner.callbacks.borrow_mut(); + if let Some(ref mut callback) = cbs.resize { + callback(Size::default(), dpr_f32); + } + return; + } + + let max_texture_dimension = inner.state.borrow().max_texture_dimension; + let clamped_width = physical_width.min(max_texture_dimension); + let clamped_height = physical_height.min(max_texture_dimension); + + inner + .pending_physical_size + .set(Some((clamped_width, clamped_height))); + + { + let mut s = inner.state.borrow_mut(); + s.bounds.size = Size { + width: px(logical_width), + height: px(logical_height), + }; + s.scale_factor = dpr_f32; + } + + let new_size = Size { + width: px(logical_width), + height: px(logical_height), + }; + + let mut cbs = inner.callbacks.borrow_mut(); + if let Some(ref mut callback) = cbs.resize { + callback(new_size, dpr_f32); + } + }) + } +} + +impl WebWindowInner { + fn create_raf_closure(self: &Rc) -> Closure { + let raf_handle: Rc>> = Rc::new(RefCell::new(None)); + let raf_handle_inner = Rc::clone(&raf_handle); + + let this = Rc::clone(self); + let closure = Closure::new(move || { + { + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.request_frame { + callback(RequestFrameOptions { + require_presentation: true, + force_render: false, + }); + } + } + + // Re-schedule for the next frame + if let Some(ref func) = *raf_handle_inner.borrow() { + this.browser_window.request_animation_frame(func).ok(); + } + }); + + let js_func: js_sys::Function = + closure.as_ref().unchecked_ref::().clone(); + *raf_handle.borrow_mut() = Some(js_func); + + closure + } + + fn schedule_raf(&self, closure: &Closure) { + self.browser_window + .request_animation_frame(closure.as_ref().unchecked_ref()) + .ok(); + } + + fn observe_canvas(&self, observer: &web_sys::ResizeObserver) { + observer.unobserve(&self.canvas); + if self.has_device_pixel_support { + let options = web_sys::ResizeObserverOptions::new(); + options.set_box(web_sys::ResizeObserverBoxOptions::DevicePixelContentBox); + observer.observe_with_options(&self.canvas, &options); + } else { + observer.observe(&self.canvas); + } + } + + fn watch_dpr_changes(self: &Rc, observer: &web_sys::ResizeObserver) { + let current_dpr = self.browser_window.device_pixel_ratio(); + let media_query = + format!("(resolution: {current_dpr}dppx), (-webkit-device-pixel-ratio: {current_dpr})"); + let Some(mql) = self.browser_window.match_media(&media_query).ok().flatten() else { + return; + }; + + let this = Rc::clone(self); + let observer = observer.clone(); + + let closure = Closure::::new(move |_event: JsValue| { + this.notify_scale.set(true); + this.observe_canvas(&observer); + this.watch_dpr_changes(&observer); + }); + + mql.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref()) + .ok(); + + *self.mql_handle.borrow_mut() = Some(MqlHandle { + mql, + _closure: closure, + }); + } + + pub(crate) fn register_visibility_change( + self: &Rc, + ) -> Option> { + let document = self.browser_window.document()?; + let this = Rc::clone(self); + + let closure = Closure::::new(move |_event: JsValue| { + let is_visible = this + .browser_window + .document() + .map(|doc| { + let state_str: String = js_sys::Reflect::get(&doc, &"visibilityState".into()) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + state_str == "visible" + }) + .unwrap_or(true); + + { + let mut state = this.state.borrow_mut(); + state.is_active = is_visible; + } + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.active_status_change { + callback(is_visible); + } + }); + + document + .add_event_listener_with_callback("visibilitychange", closure.as_ref().unchecked_ref()) + .ok(); + + Some(closure) + } + + pub(crate) fn with_input_handler( + &self, + f: impl FnOnce(&mut PlatformInputHandler) -> R, + ) -> Option { + let mut handler = self.state.borrow_mut().input_handler.take()?; + let result = f(&mut handler); + self.state.borrow_mut().input_handler = Some(handler); + Some(result) + } + + pub(crate) fn register_appearance_change( + self: &Rc, + ) -> Option> { + let mql = self + .browser_window + .match_media("(prefers-color-scheme: dark)") + .ok()??; + + let this = Rc::clone(self); + let closure = Closure::::new(move |_event: JsValue| { + let mut callbacks = this.callbacks.borrow_mut(); + if let Some(ref mut callback) = callbacks.appearance_changed { + callback(); + } + }); + + mql.add_event_listener_with_callback("change", closure.as_ref().unchecked_ref()) + .ok(); + + Some(closure) + } +} + +fn current_appearance(browser_window: &web_sys::Window) -> WindowAppearance { + let is_dark = browser_window + .match_media("(prefers-color-scheme: dark)") + .ok() + .flatten() + .map(|mql| mql.matches()) + .unwrap_or(false); + + if is_dark { + WindowAppearance::Dark + } else { + WindowAppearance::Light + } +} + +struct MqlHandle { + mql: web_sys::MediaQueryList, + _closure: Closure, +} + +impl Drop for MqlHandle { + fn drop(&mut self) { + self.mql + .remove_event_listener_with_callback("change", self._closure.as_ref().unchecked_ref()) + .ok(); + } +} + +// Safari does not support `devicePixelContentBoxSize`, so detect whether it's available. +fn check_device_pixel_support() -> bool { + let global: JsValue = js_sys::global().into(); + let Ok(constructor) = js_sys::Reflect::get(&global, &"ResizeObserverEntry".into()) else { + return false; + }; + let Ok(prototype) = js_sys::Reflect::get(&constructor, &"prototype".into()) else { + return false; + }; + let descriptor = js_sys::Object::get_own_property_descriptor( + &prototype.unchecked_into::(), + &"devicePixelContentBoxSize".into(), + ); + !descriptor.is_undefined() +} + +impl raw_window_handle::HasWindowHandle for WebWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + let canvas_ref: &JsValue = self.inner.canvas.as_ref(); + let obj = std::ptr::NonNull::from(canvas_ref).cast::(); + let handle = raw_window_handle::WebCanvasWindowHandle::new(obj); + Ok(unsafe { raw_window_handle::WindowHandle::borrow_raw(handle.into()) }) + } +} + +impl raw_window_handle::HasDisplayHandle for WebWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(raw_window_handle::DisplayHandle::web()) + } +} + +impl PlatformWindow for WebWindow { + fn bounds(&self) -> Bounds { + self.inner.state.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.inner.state.borrow().bounds.size + } + + fn resize(&mut self, size: Size) { + let style = self.inner.canvas.style(); + style + .set_property("width", &format!("{}px", f32::from(size.width))) + .ok(); + style + .set_property("height", &format!("{}px", f32::from(size.height))) + .ok(); + } + + fn scale_factor(&self) -> f32 { + self.inner.state.borrow().scale_factor + } + + fn appearance(&self) -> WindowAppearance { + current_appearance(&self.inner.browser_window) + } + + fn display(&self) -> Option> { + Some(self.display.clone()) + } + + fn mouse_position(&self) -> Point { + self.inner.state.borrow().mouse_position + } + + fn modifiers(&self) -> Modifiers { + self.inner.state.borrow().modifiers + } + + fn capslock(&self) -> Capslock { + self.inner.state.borrow().capslock + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.inner.state.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.inner.state.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + None + } + + fn activate(&self) { + self.inner.state.borrow_mut().is_active = true; + } + + fn is_active(&self) -> bool { + self.inner.state.borrow().is_active + } + + fn is_hovered(&self) -> bool { + self.inner.state.borrow().is_hovered + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, title: &str) { + self.inner.state.borrow_mut().title = title.to_owned(); + if let Some(document) = self.inner.browser_window.document() { + document.set_title(title); + } + } + + fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} + + fn minimize(&self) { + log::warn!("WebWindow::minimize is not supported in the browser"); + } + + fn zoom(&self) { + log::warn!("WebWindow::zoom is not supported in the browser"); + } + + fn toggle_fullscreen(&self) { + let mut state = self.inner.state.borrow_mut(); + state.is_fullscreen = !state.is_fullscreen; + + if state.is_fullscreen { + let canvas: &web_sys::Element = self.inner.canvas.as_ref(); + canvas.request_fullscreen().ok(); + } else { + if let Some(document) = self.inner.browser_window.document() { + document.exit_fullscreen(); + } + } + } + + fn is_fullscreen(&self) -> bool { + self.inner.state.borrow().is_fullscreen + } + + fn on_request_frame(&self, callback: Box) { + self.inner.callbacks.borrow_mut().request_frame = Some(callback); + } + + fn on_input(&self, callback: Box DispatchEventResult>) { + self.inner.callbacks.borrow_mut().input = Some(callback); + } + + fn on_active_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().active_status_change = Some(callback); + } + + fn on_hover_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); + } + + fn on_resize(&self, callback: Box, f32)>) { + self.inner.callbacks.borrow_mut().resize = Some(callback); + } + + fn on_moved(&self, callback: Box) { + self.inner.callbacks.borrow_mut().moved = Some(callback); + } + + fn on_should_close(&self, callback: Box bool>) { + self.inner.callbacks.borrow_mut().should_close = Some(callback); + } + + fn on_close(&self, callback: Box) { + self.inner.callbacks.borrow_mut().close = Some(callback); + } + + fn on_hit_test_window_control(&self, callback: Box Option>) { + self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); + } + + fn on_appearance_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); + } + + fn draw(&self, scene: &Scene) { + if let Some((width, height)) = self.inner.pending_physical_size.take() { + if self.inner.canvas.width() != width || self.inner.canvas.height() != height { + self.inner.canvas.set_width(width); + self.inner.canvas.set_height(height); + } + + let mut state = self.inner.state.borrow_mut(); + state.renderer.update_drawable_size(Size { + width: DevicePixels(width as i32), + height: DevicePixels(height as i32), + }); + drop(state); + } + + self.inner.state.borrow_mut().renderer.draw(scene); + } + + fn completed_frame(&self) { + // On web, presentation happens automatically via wgpu surface present + } + + fn sprite_atlas(&self) -> Arc { + self.inner.state.borrow().renderer.sprite_atlas().clone() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + self.inner + .state + .borrow() + .renderer + .supports_dual_source_blending() + } + + fn gpu_specs(&self) -> Option { + Some(self.inner.state.borrow().renderer.gpu_specs()) + } + + fn update_ime_position(&self, _bounds: Bounds) {} + + fn request_decorations(&self, _decorations: WindowDecorations) {} + + fn show_window_menu(&self, _position: Point) {} + + fn start_window_move(&self) {} + + fn start_window_resize(&self, _edge: ResizeEdge) {} + + fn window_decorations(&self) -> Decorations { + Decorations::Server + } + + fn set_app_id(&mut self, _app_id: &str) {} + + fn window_controls(&self) -> WindowControls { + WindowControls { + fullscreen: true, + maximize: false, + minimize: false, + window_menu: false, + } + } + + fn set_client_inset(&self, _inset: Pixels) {} +} diff --git a/src/platform/wgpu.rs b/src/platform/wgpu.rs new file mode 100644 index 0000000000..452c3c03f5 --- /dev/null +++ b/src/platform/wgpu.rs @@ -0,0 +1,10 @@ +mod cosmic_text_system; +mod wgpu_atlas; +mod wgpu_context; +mod wgpu_renderer; + +pub use cosmic_text_system::*; +pub use wgpu; +pub use wgpu_atlas::*; +pub use wgpu_context::*; +pub use wgpu_renderer::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; diff --git a/src/platform/wgpu/cosmic_text_system.rs b/src/platform/wgpu/cosmic_text_system.rs new file mode 100644 index 0000000000..81285598a9 --- /dev/null +++ b/src/platform/wgpu/cosmic_text_system.rs @@ -0,0 +1,645 @@ +use anyhow::{Context as _, Ok, Result}; +use collections::HashMap; +use cosmic_text::{ + Attrs, AttrsList, 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, +}; + +use itertools::Itertools; +use parking_lot::RwLock; +use smallvec::SmallVec; +use std::{borrow::Cow, sync::Arc}; +use swash::{ + scale::{Render, ScaleContext, Source, StrikeWith}, + zeno::{Format, Vector}, +}; + +pub struct CosmicTextSystem(RwLock); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct FontKey { + family: SharedString, + features: FontFeatures, +} + +impl FontKey { + fn new(family: SharedString, features: FontFeatures) -> Self { + Self { family, features } + } +} + +struct CosmicTextSystemState { + font_system: FontSystem, + scratch: ShapeBuffer, + swash_scale_context: ScaleContext, + /// Contains all already loaded fonts, including all faces. Indexed by `FontId`. + loaded_fonts: Vec, + /// Caches the `FontId`s associated with a specific family to avoid iterating the font database + /// for every font face in a family. + font_ids_by_family_cache: HashMap>, + system_font_fallback: String, +} + +struct LoadedFont { + font: Arc, + features: CosmicFontFeatures, + is_known_emoji_font: bool, +} + +impl CosmicTextSystem { + pub fn new(system_font_fallback: &str) -> Self { + let font_system = FontSystem::new(); + + Self(RwLock::new(CosmicTextSystemState { + font_system, + scratch: ShapeBuffer::default(), + swash_scale_context: ScaleContext::new(), + loaded_fonts: Vec::new(), + font_ids_by_family_cache: HashMap::default(), + system_font_fallback: system_font_fallback.to_string(), + })) + } + + pub fn new_without_system_fonts(system_font_fallback: &str) -> Self { + let font_system = FontSystem::new_with_locale_and_db( + "en-US".to_string(), + cosmic_text::fontdb::Database::new(), + ); + + Self(RwLock::new(CosmicTextSystemState { + font_system, + scratch: ShapeBuffer::default(), + swash_scale_context: ScaleContext::new(), + loaded_fonts: Vec::new(), + font_ids_by_family_cache: HashMap::default(), + system_font_fallback: system_font_fallback.to_string(), + })) + } +} + +impl PlatformTextSystem for CosmicTextSystem { + fn add_fonts(&self, fonts: Vec>) -> Result<()> { + self.0.write().add_fonts(fonts) + } + + fn all_font_names(&self) -> Vec { + let mut result = self + .0 + .read() + .font_system + .db() + .faces() + .filter_map(|face| face.families.first().map(|family| family.0.clone())) + .collect_vec(); + result.sort(); + result.dedup(); + result + } + + fn font_id(&self, font: &Font) -> Result { + let mut state = self.0.write(); + let key = FontKey::new(font.family.clone(), font.features.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)?; + state.font_ids_by_family_cache.insert(key.clone(), font_ids); + state.font_ids_by_family_cache[&key].as_ref() + }; + + let ix = find_best_match(font, candidates, &state)?; + + Ok(candidates[ix]) + } + + fn font_metrics(&self, font_id: FontId) -> FontMetrics { + let metrics = self + .0 + .read() + .loaded_font(font_id) + .font + .as_swash() + .metrics(&[]); + + FontMetrics { + units_per_em: metrics.units_per_em as u32, + ascent: metrics.ascent, + descent: -metrics.descent, + line_gap: metrics.leading, + underline_position: metrics.underline_offset, + underline_thickness: metrics.stroke_size, + cap_height: metrics.cap_height, + x_height: metrics.x_height, + bounding_box: Bounds { + origin: point(0.0, 0.0), + size: size(metrics.max_width, metrics.ascent + metrics.descent), + }, + } + } + + fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let lock = self.0.read(); + let glyph_metrics = lock.loaded_font(font_id).font.as_swash().glyph_metrics(&[]); + let glyph_id = glyph_id.0 as u16; + Ok(Bounds { + origin: point(0.0, 0.0), + size: size( + glyph_metrics.advance_width(glyph_id), + glyph_metrics.advance_height(glyph_id), + ), + }) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + self.0.read().advance(font_id, glyph_id) + } + + fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { + self.0.read().glyph_for_char(font_id, ch) + } + + fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { + self.0.write().raster_bounds(params) + } + + fn rasterize_glyph( + &self, + params: &RenderGlyphParams, + raster_bounds: Bounds, + ) -> Result<(Size, Vec)> { + self.0.write().rasterize_glyph(params, raster_bounds) + } + + fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { + self.0.write().layout_line(text, font_size, runs) + } + + fn recommended_rendering_mode( + &self, + _font_id: FontId, + _font_size: Pixels, + ) -> TextRenderingMode { + TextRenderingMode::Subpixel + } +} + +impl CosmicTextSystemState { + fn loaded_font(&self, font_id: FontId) -> &LoadedFont { + &self.loaded_fonts[font_id.0] + } + + #[profiling::function] + fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { + let db = self.font_system.db_mut(); + for bytes in fonts { + match bytes { + Cow::Borrowed(embedded_font) => { + db.load_font_data(embedded_font.to_vec()); + } + Cow::Owned(bytes) => { + db.load_font_data(bytes); + } + } + } + Ok(()) + } + + #[profiling::function] + fn load_family( + &mut self, + name: &str, + features: &FontFeatures, + ) -> Result> { + let name = gpui::font_name_with_fallbacks(name, &self.system_font_fallback); + + let families = self + .font_system + .db() + .faces() + .filter(|face| face.families.iter().any(|family| *name == family.0)) + .map(|face| (face.id, face.post_script_name.clone())) + .collect::>(); + + let mut loaded_font_ids = SmallVec::new(); + for (font_id, postscript_name) in families { + let font = self + .font_system + .get_font(font_id, cosmic_text::Weight::NORMAL) + .context("Could not load font")?; + + // HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback. + let allowed_bad_font_names = [ + "SegoeFluentIcons", // NOTE: Segoe fluent icons postscript name is inconsistent + "Segoe Fluent Icons", + ]; + + if font.as_swash().charmap().map('m') == 0 + && !allowed_bad_font_names.contains(&postscript_name.as_str()) + { + self.font_system.db_mut().remove_face(font.id()); + continue; + }; + + let font_id = FontId(self.loaded_fonts.len()); + loaded_font_ids.push(font_id); + self.loaded_fonts.push(LoadedFont { + font, + features: cosmic_font_features(features)?, + is_known_emoji_font: check_is_known_emoji_font(&postscript_name), + }); + } + + Ok(loaded_font_ids) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let glyph_metrics = self.loaded_font(font_id).font.as_swash().glyph_metrics(&[]); + Ok(Size { + width: glyph_metrics.advance_width(glyph_id.0 as u16), + height: glyph_metrics.advance_height(glyph_id.0 as u16), + }) + } + + fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { + let glyph_id = self.loaded_font(font_id).font.as_swash().charmap().map(ch); + if glyph_id == 0 { + None + } else { + Some(GlyphId(glyph_id.into())) + } + } + + fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result> { + let image = self.render_glyph_image(params)?; + Ok(Bounds { + origin: point(image.placement.left.into(), (-image.placement.top).into()), + size: size(image.placement.width.into(), image.placement.height.into()), + }) + } + + #[profiling::function] + fn rasterize_glyph( + &mut self, + params: &RenderGlyphParams, + glyph_bounds: Bounds, + ) -> Result<(Size, Vec)> { + if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 { + anyhow::bail!("glyph bounds are empty"); + } + + let mut image = self.render_glyph_image(params)?; + let bitmap_size = glyph_bounds.size; + match image.content { + swash::scale::image::Content::Color | swash::scale::image::Content::SubpixelMask => { + // Convert from RGBA to BGRA. + for pixel in image.data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok((bitmap_size, image.data)) + } + swash::scale::image::Content::Mask => Ok((bitmap_size, image.data)), + } + } + + fn render_glyph_image( + &mut self, + params: &RenderGlyphParams, + ) -> Result { + let loaded_font = &self.loaded_fonts[params.font_id.0]; + let font_ref = loaded_font.font.as_swash(); + let pixel_size = f32::from(params.font_size); + + let subpixel_offset = Vector::new( + params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor, + params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor, + ); + + let mut scaler = self + .swash_scale_context + .builder(font_ref) + .size(pixel_size * params.scale_factor) + .hint(true) + .build(); + + let sources: &[Source] = if params.is_emoji { + &[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ] + } else { + &[Source::Outline] + }; + + let mut renderer = Render::new(sources); + if params.subpixel_rendering { + // There seems to be a bug in Swash where the B and R values are swapped. + renderer + .format(Format::subpixel_bgra()) + .offset(subpixel_offset); + } else { + renderer.format(Format::Alpha).offset(subpixel_offset); + } + + let glyph_id: u16 = params.glyph_id.0.try_into()?; + renderer + .render(&mut scaler, glyph_id) + .with_context(|| format!("unable to render glyph via swash for {params:?}")) + } + + /// This is used when cosmic_text has chosen a fallback font instead of using the requested + /// font, typically to handle some unicode characters. When this happens, `loaded_fonts` may not + /// yet have an entry for this fallback font, and so one is added. + /// + /// Note that callers shouldn't use this `FontId` somewhere that will retrieve the corresponding + /// `LoadedFont.features`, as it will have an arbitrarily chosen or empty value. The only + /// current use of this field is for the *input* of `layout_line`, and so it's fine to use + /// `font_id_for_cosmic_id` when computing the *output* of `layout_line`. + fn font_id_for_cosmic_id(&mut self, id: cosmic_text::fontdb::ID) -> Result { + if let Some(ix) = self + .loaded_fonts + .iter() + .position(|loaded_font| loaded_font.font.id() == id) + { + Ok(FontId(ix)) + } else { + let font = self + .font_system + .get_font(id, cosmic_text::Weight::NORMAL) + .context("failed to get fallback font from cosmic-text font system")?; + let face = self + .font_system + .db() + .face(id) + .context("fallback font face not found in cosmic-text database")?; + + let font_id = FontId(self.loaded_fonts.len()); + self.loaded_fonts.push(LoadedFont { + font, + features: CosmicFontFeatures::new(), + is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name), + }); + + Ok(font_id) + } + } + + #[profiling::function] + fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { + let mut attrs_list = AttrsList::new(&Attrs::new()); + let mut offs = 0; + for run in font_runs { + 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; + continue; + }; + let Some(first_family) = face.families.first() else { + log::warn!( + "font face has no family names for font_id {:?}", + run.font_id + ); + offs += run.len; + 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 line = ShapeLine::new( + &mut self.font_system, + text, + &attrs_list, + cosmic_text::Shaping::Advanced, + 4, + ); + let mut layout_lines = Vec::with_capacity(1); + line.layout_to_buffer( + &mut self.scratch, + f32::from(font_size), + None, // We do our own wrapping + cosmic_text::Wrap::None, + None, + &mut layout_lines, + None, + cosmic_text::Hinting::Disabled, + ); + + let Some(layout) = layout_lines.first() else { + return LineLayout { + font_size, + width: Pixels::ZERO, + ascent: Pixels::ZERO, + descent: Pixels::ZERO, + runs: Vec::new(), + len: text.len(), + }; + }; + + let mut runs: Vec = Vec::new(); + for glyph in &layout.glyphs { + let mut font_id = FontId(glyph.metadata); + let mut loaded_font = self.loaded_font(font_id); + if loaded_font.font.id() != glyph.font_id { + match self.font_id_for_cosmic_id(glyph.font_id) { + std::result::Result::Ok(resolved_id) => { + font_id = resolved_id; + loaded_font = self.loaded_font(font_id); + } + Err(error) => { + log::warn!( + "failed to resolve cosmic font id {:?}: {error:#}", + glyph.font_id + ); + continue; + } + } + } + let is_emoji = loaded_font.is_known_emoji_font; + + // HACK: Prevent crash caused by variation selectors. + if glyph.glyph_id == 3 && is_emoji { + continue; + } + + let shaped_glyph = ShapedGlyph { + id: GlyphId(glyph.glyph_id as u32), + position: point(glyph.x.into(), glyph.y.into()), + index: glyph.start, + is_emoji, + }; + + if let Some(last_run) = runs + .last_mut() + .filter(|last_run| last_run.font_id == font_id) + { + last_run.glyphs.push(shaped_glyph); + } else { + runs.push(ShapedRun { + font_id, + glyphs: vec![shaped_glyph], + }); + } + } + + LineLayout { + font_size, + width: layout.w.into(), + ascent: layout.max_ascent.into(), + descent: layout.max_descent.into(), + runs, + len: text.len(), + } + } +} + +#[cfg(feature = "font-kit")] +fn find_best_match( + font: &Font, + candidates: &[FontId], + state: &CosmicTextSystemState, +) -> Result { + let candidate_properties = candidates + .iter() + .map(|font_id| { + let database_id = state.loaded_font(*font_id).font.id(); + let face_info = state + .font_system + .db() + .face(database_id) + .context("font face not found in database")?; + Ok(face_info_into_properties(face_info)) + }) + .collect::>>()?; + + let ix = + font_kit::matching::find_best_match(&candidate_properties, &font_into_properties(font)) + .context("requested font family contains no font matching the other parameters")?; + + Ok(ix) +} + +#[cfg(not(feature = "font-kit"))] +fn find_best_match( + font: &Font, + candidates: &[FontId], + state: &CosmicTextSystemState, +) -> Result { + if candidates.is_empty() { + anyhow::bail!("requested font family contains no font matching the other parameters"); + } + if candidates.len() == 1 { + return Ok(0); + } + + let target_weight = font.weight.0; + let target_italic = matches!( + font.style, + gpui::FontStyle::Italic | gpui::FontStyle::Oblique + ); + + let mut best_index = 0; + let mut best_score = u32::MAX; + + for (index, font_id) in candidates.iter().enumerate() { + let database_id = state.loaded_font(*font_id).font.id(); + let face_info = state + .font_system + .db() + .face(database_id) + .context("font face not found in database")?; + + let is_italic = matches!( + face_info.style, + cosmic_text::Style::Italic | cosmic_text::Style::Oblique + ); + let style_penalty: u32 = if is_italic == target_italic { 0 } else { 1000 }; + let weight_diff = (face_info.weight.0 as i32 - target_weight as i32).unsigned_abs(); + let score = style_penalty + weight_diff; + + if score < best_score { + best_score = score; + best_index = index; + } + } + + Ok(best_index) +} + +fn cosmic_font_features(features: &FontFeatures) -> Result { + let mut result = CosmicFontFeatures::new(); + for feature in features.0.iter() { + let name_bytes: [u8; 4] = feature + .0 + .as_bytes() + .try_into() + .context("Incorrect feature flag format")?; + + let tag = cosmic_text::FeatureTag::new(&name_bytes); + + result.set(tag, feature.1); + } + Ok(result) +} + +#[cfg(feature = "font-kit")] +fn font_into_properties(font: &gpui::Font) -> font_kit::properties::Properties { + font_kit::properties::Properties { + style: match font.style { + gpui::FontStyle::Normal => font_kit::properties::Style::Normal, + gpui::FontStyle::Italic => font_kit::properties::Style::Italic, + gpui::FontStyle::Oblique => font_kit::properties::Style::Oblique, + }, + weight: font_kit::properties::Weight(font.weight.0), + stretch: Default::default(), + } +} + +#[cfg(feature = "font-kit")] +fn face_info_into_properties( + face_info: &cosmic_text::fontdb::FaceInfo, +) -> font_kit::properties::Properties { + font_kit::properties::Properties { + style: match face_info.style { + cosmic_text::Style::Normal => font_kit::properties::Style::Normal, + cosmic_text::Style::Italic => font_kit::properties::Style::Italic, + cosmic_text::Style::Oblique => font_kit::properties::Style::Oblique, + }, + weight: font_kit::properties::Weight(face_info.weight.0.into()), + stretch: match face_info.stretch { + cosmic_text::Stretch::Condensed => font_kit::properties::Stretch::CONDENSED, + cosmic_text::Stretch::Expanded => font_kit::properties::Stretch::EXPANDED, + cosmic_text::Stretch::ExtraCondensed => font_kit::properties::Stretch::EXTRA_CONDENSED, + cosmic_text::Stretch::ExtraExpanded => font_kit::properties::Stretch::EXTRA_EXPANDED, + cosmic_text::Stretch::Normal => font_kit::properties::Stretch::NORMAL, + cosmic_text::Stretch::SemiCondensed => font_kit::properties::Stretch::SEMI_CONDENSED, + cosmic_text::Stretch::SemiExpanded => font_kit::properties::Stretch::SEMI_EXPANDED, + cosmic_text::Stretch::UltraCondensed => font_kit::properties::Stretch::ULTRA_CONDENSED, + cosmic_text::Stretch::UltraExpanded => font_kit::properties::Stretch::ULTRA_EXPANDED, + }, + } +} + +fn check_is_known_emoji_font(postscript_name: &str) -> bool { + // TODO: Include other common emoji fonts + postscript_name == "NotoColorEmoji" +} diff --git a/src/platform/blade/shaders.wgsl b/src/platform/wgpu/shaders.wgsl similarity index 94% rename from src/platform/blade/shaders.wgsl rename to src/platform/wgpu/shaders.wgsl index 2981b1446c..12ce7d29b0 100644 --- a/src/platform/blade/shaders.wgsl +++ b/src/platform/wgpu/shaders.wgsl @@ -46,12 +46,22 @@ fn enhance_contrast(alpha: f32, k: f32) -> f32 { return alpha * (k + 1.0) / (alpha * k + 1.0); } +fn enhance_contrast3(alpha: vec3, k: f32) -> vec3 { + return alpha * (k + 1.0) / (alpha * k + 1.0); +} + fn apply_alpha_correction(a: f32, b: f32, g: vec4) -> f32 { let brightness_adjustment = g.x * b + g.y; let correction = brightness_adjustment * a + (g.z * b + g.w); return a + a * (1.0 - a) * correction; } +fn apply_alpha_correction3(a: vec3, b: vec3, g: vec4) -> vec3 { + let brightness_adjustment = g.x * b + g.y; + let correction = brightness_adjustment * a + (g.z * b + g.w); + return a + a * (1.0 - a) * correction; +} + fn apply_contrast_and_gamma_correction(sample: f32, color: vec3, enhanced_contrast_factor: f32, gamma_ratios: vec4) -> f32 { let enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); let brightness = color_brightness(color); @@ -60,17 +70,30 @@ fn apply_contrast_and_gamma_correction(sample: f32, color: vec3, enhanced_c return apply_alpha_correction(contrasted, brightness, gamma_ratios); } +fn apply_contrast_and_gamma_correction3(sample: vec3, color: vec3, enhanced_contrast_factor: f32, gamma_ratios: vec4) -> vec3 { + let enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); + + let contrasted = enhance_contrast3(sample, enhanced_contrast); + return apply_alpha_correction3(contrasted, color, gamma_ratios); +} + struct GlobalParams { viewport_size: vec2, premultiplied_alpha: u32, pad: u32, } -var globals: GlobalParams; -var gamma_ratios: vec4; -var grayscale_enhanced_contrast: f32; -var t_sprite: texture_2d; -var s_sprite: sampler; +struct GammaParams { + gamma_ratios: vec4, + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + pad: vec2, +} + +@group(0) @binding(0) var globals: GlobalParams; +@group(0) @binding(1) var gamma_params: GammaParams; +@group(1) @binding(1) var t_sprite: texture_2d; +@group(1) @binding(2) var s_sprite: sampler; const M_PI_F: f32 = 3.1415926; const GRAYSCALE_FACTORS: vec3 = vec3(0.2126, 0.7152, 0.0722); @@ -110,6 +133,7 @@ struct Background { // 0u is Solid // 1u is LinearGradient // 2u is PatternSlash + // 3u is Checkerboard tag: u32, // 0u is sRGB linear color // 1u is Oklab color @@ -380,7 +404,7 @@ fn prepare_gradient_color(tag: u32, color_space: u32, solid: Hsla, colors: array) -> GradientColor { var result = GradientColor(); - if (tag == 0u || tag == 2u) { + if (tag == 0u || tag == 2u || tag == 3u) { result.solid = hsla_to_rgba(solid); } else if (tag == 1u) { // The hsla_to_rgba is returns a linear sRGB color @@ -454,6 +478,7 @@ fn gradient_color(background: Background, position: vec2, bounds: Bounds, } } case 2u: { + // pattern slash let gradient_angle_or_pattern_height = background.gradient_angle_or_pattern_height; let pattern_width = (gradient_angle_or_pattern_height / 65535.0f) / 255.0f; let pattern_interval = (gradient_angle_or_pattern_height % 65535.0f) / 255.0f; @@ -471,6 +496,18 @@ fn gradient_color(background: Background, position: vec2, bounds: Bounds, background_color = solid_color; background_color.a *= saturate(0.5 - distance); } + case 3u: { + // checkerboard + let size = background.gradient_angle_or_pattern_height; + let relative_position = position - bounds.origin; + + let x_index = floor(relative_position.x / size); + let y_index = floor(relative_position.y / size); + let should_be_colored = (x_index + y_index) % 2.0; + + background_color = solid_color; + background_color.a *= saturate(should_be_colored); + } } return background_color; @@ -488,7 +525,7 @@ struct Quad { corner_radii: Corners, border_widths: Edges, } -var b_quads: array; +@group(1) @binding(0) var b_quads: array; struct QuadVarying { @builtin(position) position: vec4, @@ -918,7 +955,7 @@ struct Shadow { content_mask: Bounds, color: Hsla, } -var b_shadows: array; +@group(1) @binding(0) var b_shadows: array; struct ShadowVarying { @builtin(position) position: vec4, @@ -990,12 +1027,12 @@ struct PathRasterizationVertex { bounds: Bounds, } -var b_path_vertices: array; +@group(1) @binding(0) var b_path_vertices: array; struct PathRasterizationVarying { @builtin(position) position: vec4, @location(0) st_position: vec2, - @location(1) vertex_id: u32, + @location(1) @interpolate(flat) vertex_id: u32, //TODO: use `clip_distance` once Naga supports it @location(3) clip_distances: vec4, } @@ -1034,14 +1071,14 @@ fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4(color.rgb * color.a * alpha, color.a * alpha); } @@ -1050,7 +1087,7 @@ fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4 b_path_sprites: array; +@group(1) @binding(0) var b_path_sprites: array; struct PathVarying { @builtin(position) position: vec4, @@ -1091,7 +1128,7 @@ struct Underline { thickness: f32, wavy: u32, } -var b_underlines: array; +@group(1) @binding(0) var b_underlines: array; struct UnderlineVarying { @builtin(position) position: vec4, @@ -1157,7 +1194,7 @@ struct MonochromeSprite { tile: AtlasTile, transformation: TransformationMatrix, } -var b_mono_sprites: array; +@group(1) @binding(0) var b_mono_sprites: array; struct MonoSpriteVarying { @builtin(position) position: vec4, @@ -1183,14 +1220,13 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index @fragment fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4 { let sample = textureSample(t_sprite, s_sprite, input.tile_position).r; - let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, grayscale_enhanced_contrast, gamma_ratios); + let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, gamma_params.grayscale_enhanced_contrast, gamma_params.gamma_ratios); // Alpha clip after using the derivatives. if (any(input.clip_distances < vec4(0.0))) { return vec4(0.0); } - // convert to srgb space as the rest of the code (output swapchain) expects that return blend_color(input.color, alpha_corrected); } @@ -1206,7 +1242,7 @@ struct PolychromeSprite { corner_radii: Corners, tile: AtlasTile, } -var b_poly_sprites: array; +@group(1) @binding(0) var b_poly_sprites: array; struct PolySpriteVarying { @builtin(position) position: vec4, @@ -1254,10 +1290,10 @@ struct SurfaceParams { content_mask: Bounds, } -var surface_locals: SurfaceParams; -var t_y: texture_2d; -var t_cb_cr: texture_2d; -var s_surface: sampler; +@group(1) @binding(0) var surface_locals: SurfaceParams; +@group(1) @binding(1) var t_y: texture_2d; +@group(1) @binding(2) var t_cb_cr: texture_2d; +@group(1) @binding(3) var s_surface: sampler; const ycbcr_to_RGB = mat4x4( vec4( 1.0000f, 1.0000f, 1.0000f, 0.0), diff --git a/src/platform/wgpu/shaders_subpixel.wgsl b/src/platform/wgpu/shaders_subpixel.wgsl new file mode 100644 index 0000000000..9f1f73de4c --- /dev/null +++ b/src/platform/wgpu/shaders_subpixel.wgsl @@ -0,0 +1,53 @@ +// --- subpixel sprites --- // + +struct SubpixelSprite { + order: u32, + pad: u32, + bounds: Bounds, + content_mask: Bounds, + color: Hsla, + tile: AtlasTile, + transformation: TransformationMatrix, +} +@group(1) @binding(0) var b_subpixel_sprites: array; + +struct SubpixelSpriteOutput { + @builtin(position) position: vec4, + @location(0) tile_position: vec2, + @location(1) @interpolate(flat) color: vec4, + @location(3) clip_distances: vec4, +} + +struct SubpixelSpriteFragmentOutput { + @location(0) @blend_src(0) foreground: vec4, + @location(0) @blend_src(1) alpha: vec4, +} + +@vertex +fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> SubpixelSpriteOutput { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let sprite = b_subpixel_sprites[instance_id]; + + var out = SubpixelSpriteOutput(); + out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); + out.tile_position = to_tile_position(unit_vertex, sprite.tile); + out.color = hsla_to_rgba(sprite.color); + out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); + return out; +} + +@fragment +fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutput { + let sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; + let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, gamma_params.subpixel_enhanced_contrast, gamma_params.gamma_ratios); + + // Alpha clip after using the derivatives. + if (any(input.clip_distances < vec4(0.0))) { + return SubpixelSpriteFragmentOutput(vec4(0.0), vec4(0.0)); + } + + var out = SubpixelSpriteFragmentOutput(); + out.foreground = vec4(input.color.rgb, 1.0); + out.alpha = vec4(input.color.a * alpha_corrected, 1.0); + return out; +} diff --git a/src/platform/wgpu/wgpu_atlas.rs b/src/platform/wgpu/wgpu_atlas.rs new file mode 100644 index 0000000000..3eba5c533f --- /dev/null +++ b/src/platform/wgpu/wgpu_atlas.rs @@ -0,0 +1,343 @@ +use anyhow::{Context as _, Result}; +use collections::FxHashMap; +use etagere::{BucketedAtlasAllocator, size2}; +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, + PlatformAtlas, Point, Size, +}; +use parking_lot::Mutex; +use std::{borrow::Cow, ops, sync::Arc}; + +fn device_size_to_etagere(size: Size) -> etagere::Size { + size2(size.width.0, size.height.0) +} + +fn etagere_point_to_device(point: etagere::Point) -> Point { + Point { + x: DevicePixels(point.x), + y: DevicePixels(point.y), + } +} + +pub struct WgpuAtlas(Mutex); + +struct PendingUpload { + id: AtlasTextureId, + bounds: Bounds, + data: Vec, +} + +struct WgpuAtlasState { + device: Arc, + queue: Arc, + max_texture_size: u32, + storage: WgpuAtlasStorage, + tiles_by_key: FxHashMap, + pending_uploads: Vec, +} + +pub struct WgpuTextureInfo { + pub view: wgpu::TextureView, +} + +impl WgpuAtlas { + pub fn new(device: Arc, queue: Arc) -> Self { + let max_texture_size = device.limits().max_texture_dimension_2d; + WgpuAtlas(Mutex::new(WgpuAtlasState { + device, + queue, + max_texture_size, + storage: WgpuAtlasStorage::default(), + tiles_by_key: Default::default(), + pending_uploads: Vec::new(), + })) + } + + pub fn before_frame(&self) { + let mut lock = self.0.lock(); + lock.flush_uploads(); + } + + pub fn get_texture_info(&self, id: AtlasTextureId) -> WgpuTextureInfo { + let lock = self.0.lock(); + let texture = &lock.storage[id]; + WgpuTextureInfo { + view: texture.view.clone(), + } + } + + /// Handles device lost by clearing all textures and cached tiles. + /// The atlas will lazily recreate textures as needed on subsequent frames. + pub fn handle_device_lost(&self, device: Arc, queue: Arc) { + let mut lock = self.0.lock(); + lock.device = device; + lock.queue = queue; + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); + } +} + +impl PlatformAtlas for WgpuAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result> { + let mut lock = self.0.lock(); + if let Some(tile) = lock.tiles_by_key.get(key) { + Ok(Some(tile.clone())) + } else { + profiling::scope!("new tile"); + let Some((size, bytes)) = build()? else { + return Ok(None); + }; + let tile = lock + .allocate(size, key.texture_kind()) + .context("failed to allocate")?; + lock.upload_texture(tile.texture_id, tile.bounds, &bytes); + lock.tiles_by_key.insert(key.clone(), tile.clone()); + Ok(Some(tile)) + } + } + + fn remove(&self, key: &AtlasKey) { + let mut lock = self.0.lock(); + + let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { + return; + }; + + let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { + return; + }; + + if let Some(mut texture) = texture_slot.take() { + texture.decrement_ref_count(); + if texture.is_unreferenced() { + lock.storage[id.kind] + .free_list + .push(texture.id.index as usize); + } else { + *texture_slot = Some(texture); + } + } + } +} + +impl WgpuAtlasState { + fn allocate( + &mut self, + size: Size, + texture_kind: AtlasTextureKind, + ) -> Option { + { + let textures = &mut self.storage[texture_kind]; + + if let Some(tile) = textures + .iter_mut() + .rev() + .find_map(|texture| texture.allocate(size)) + { + return Some(tile); + } + } + + let texture = self.push_texture(size, texture_kind); + texture.allocate(size) + } + + fn push_texture( + &mut self, + min_size: Size, + kind: AtlasTextureKind, + ) -> &mut WgpuAtlasTexture { + const DEFAULT_ATLAS_SIZE: Size = Size { + width: DevicePixels(1024), + height: DevicePixels(1024), + }; + let max_texture_size = self.max_texture_size as i32; + let max_atlas_size = Size { + width: DevicePixels(max_texture_size), + height: DevicePixels(max_texture_size), + }; + + let size = min_size.min(&max_atlas_size).max(&DEFAULT_ATLAS_SIZE); + let format = match kind { + AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, + AtlasTextureKind::Subpixel => wgpu::TextureFormat::Bgra8Unorm, + AtlasTextureKind::Polychrome => wgpu::TextureFormat::Bgra8Unorm, + }; + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("atlas"), + size: wgpu::Extent3d { + width: size.width.0 as u32, + height: size.height.0 as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let texture_list = &mut self.storage[kind]; + let index = texture_list.free_list.pop(); + + let atlas_texture = WgpuAtlasTexture { + id: AtlasTextureId { + index: index.unwrap_or(texture_list.textures.len()) as u32, + kind, + }, + allocator: BucketedAtlasAllocator::new(device_size_to_etagere(size)), + format, + texture, + view, + live_atlas_keys: 0, + }; + + if let Some(ix) = index { + texture_list.textures[ix] = Some(atlas_texture); + texture_list + .textures + .get_mut(ix) + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } else { + texture_list.textures.push(Some(atlas_texture)); + texture_list + .textures + .last_mut() + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } + } + + fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { + self.pending_uploads.push(PendingUpload { + id, + bounds, + data: bytes.to_vec(), + }); + } + + fn flush_uploads(&mut self) { + for upload in self.pending_uploads.drain(..) { + let texture = &self.storage[upload.id]; + let bytes_per_pixel = texture.bytes_per_pixel(); + + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: upload.bounds.origin.x.0 as u32, + y: upload.bounds.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + &upload.data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(upload.bounds.size.width.0 as u32 * bytes_per_pixel as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: upload.bounds.size.width.0 as u32, + height: upload.bounds.size.height.0 as u32, + depth_or_array_layers: 1, + }, + ); + } + } +} + +#[derive(Default)] +struct WgpuAtlasStorage { + monochrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, + polychrome_textures: AtlasTextureList, +} + +impl ops::Index for WgpuAtlasStorage { + type Output = AtlasTextureList; + fn index(&self, kind: AtlasTextureKind) -> &Self::Output { + match kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + } + } +} + +impl ops::IndexMut for WgpuAtlasStorage { + fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { + match kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + } + } +} + +impl ops::Index for WgpuAtlasStorage { + type Output = WgpuAtlasTexture; + fn index(&self, id: AtlasTextureId) -> &Self::Output { + let textures = match id.kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + }; + textures[id.index as usize] + .as_ref() + .expect("texture must exist") + } +} + +struct WgpuAtlasTexture { + id: AtlasTextureId, + allocator: BucketedAtlasAllocator, + texture: wgpu::Texture, + view: wgpu::TextureView, + format: wgpu::TextureFormat, + live_atlas_keys: u32, +} + +impl WgpuAtlasTexture { + fn allocate(&mut self, size: Size) -> Option { + let allocation = self.allocator.allocate(device_size_to_etagere(size))?; + let tile = AtlasTile { + texture_id: self.id, + tile_id: allocation.id.into(), + padding: 0, + bounds: Bounds { + origin: etagere_point_to_device(allocation.rectangle.min), + size, + }, + }; + self.live_atlas_keys += 1; + Some(tile) + } + + fn bytes_per_pixel(&self) -> u8 { + match self.format { + wgpu::TextureFormat::R8Unorm => 1, + wgpu::TextureFormat::Bgra8Unorm => 4, + _ => 4, + } + } + + fn decrement_ref_count(&mut self) { + self.live_atlas_keys -= 1; + } + + fn is_unreferenced(&self) -> bool { + self.live_atlas_keys == 0 + } +} diff --git a/src/platform/wgpu/wgpu_context.rs b/src/platform/wgpu/wgpu_context.rs new file mode 100644 index 0000000000..d6bf04894b --- /dev/null +++ b/src/platform/wgpu/wgpu_context.rs @@ -0,0 +1,387 @@ +#[cfg(not(target_family = "wasm"))] +use anyhow::Context as _; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_family = "wasm"))] +use util::ResultExt; + +pub struct WgpuContext { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: Arc, + pub queue: Arc, + dual_source_blending: bool, + device_lost: Arc, +} + +#[derive(Clone, Copy)] +pub struct CompositorGpuHint { + pub vendor_id: u32, + pub device_id: u32, +} + +impl WgpuContext { + #[cfg(not(target_family = "wasm"))] + pub fn new( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + ) -> anyhow::Result { + let device_id_filter = match std::env::var("ZED_DEVICE_ID") { + Ok(val) => parse_pci_id(&val) + .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") + .log_err(), + Err(std::env::VarError::NotPresent) => None, + err => { + err.context("Failed to read value of `ZED_DEVICE_ID` environment variable") + .log_err(); + None + } + }; + + // Select an adapter by actually testing surface configuration with the real device. + // This is the only reliable way to determine compatibility on hybrid GPU systems. + let (adapter, device, queue, dual_source_blending) = + pollster::block_on(Self::select_adapter_and_device( + &instance, + device_id_filter, + surface, + compositor_gpu.as_ref(), + ))?; + + let device_lost = Arc::new(AtomicBool::new(false)); + device.set_device_lost_callback({ + let device_lost = Arc::clone(&device_lost); + move |reason, message| { + log::error!("wgpu device lost: reason={reason:?}, message={message}"); + if reason != wgpu::DeviceLostReason::Destroyed { + device_lost.store(true, Ordering::Relaxed); + } + } + }); + + log::info!( + "Selected GPU adapter: {:?} ({:?})", + adapter.get_info().name, + adapter.get_info().backend + ); + + Ok(Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + dual_source_blending, + device_lost, + }) + } + + #[cfg(target_family = "wasm")] + pub async fn new_web() -> anyhow::Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to request GPU adapter: {e}"))?; + + log::info!( + "Selected GPU adapter: {:?} ({:?})", + adapter.get_info().name, + adapter.get_info().backend + ); + + let device_lost = Arc::new(AtomicBool::new(false)); + let (device, queue, dual_source_blending) = Self::create_device(&adapter).await?; + + Ok(Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + dual_source_blending, + device_lost, + }) + } + + async fn create_device( + adapter: &wgpu::Adapter, + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { + let dual_source_blending = adapter + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + + let mut required_features = wgpu::Features::empty(); + if dual_source_blending { + required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; + } else { + log::warn!( + "Dual-source blending not available on this GPU. \ + Subpixel text antialiasing will be disabled." + ); + } + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("gpui_device"), + required_features, + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; + + Ok((device, queue, dual_source_blending)) + } + + #[cfg(not(target_family = "wasm"))] + pub fn instance(display: Box) -> wgpu::Instance { + wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: Some(display), + }) + } + + pub fn check_compatible_with_surface(&self, surface: &wgpu::Surface<'_>) -> anyhow::Result<()> { + let caps = surface.get_capabilities(&self.adapter); + if caps.formats.is_empty() { + let info = self.adapter.get_info(); + anyhow::bail!( + "Adapter {:?} (backend={:?}, device={:#06x}) is not compatible with the \ + display surface for this window.", + info.name, + info.backend, + info.device, + ); + } + Ok(()) + } + + /// Select an adapter and create a device, testing that the surface can actually be configured. + /// This is the only reliable way to determine compatibility on hybrid GPU systems, where + /// adapters may report surface compatibility via get_capabilities() but fail when actually + /// configuring (e.g., NVIDIA reporting Vulkan Wayland support but failing because the + /// Wayland compositor runs on the Intel GPU). + #[cfg(not(target_family = "wasm"))] + async fn select_adapter_and_device( + instance: &wgpu::Instance, + device_id_filter: Option, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option<&CompositorGpuHint>, + ) -> anyhow::Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue, bool)> { + let mut adapters: Vec<_> = instance.enumerate_adapters(wgpu::Backends::all()).await; + + if adapters.is_empty() { + anyhow::bail!("No GPU adapters found"); + } + + if let Some(device_id) = device_id_filter { + log::info!("ZED_DEVICE_ID filter: {:#06x}", device_id); + } + + // Sort adapters into a single priority order. Tiers (from highest to lowest): + // + // 1. ZED_DEVICE_ID match — explicit user override + // 2. Compositor GPU match — the GPU the display server is rendering on + // 3. Device type (Discrete > Integrated > Other > Virtual > Cpu). + // "Other" ranks above "Virtual" because OpenGL seems to count as "Other". + // 4. Backend — prefer Vulkan/Metal/Dx12 over GL/etc. + adapters.sort_by_key(|adapter| { + let info = adapter.get_info(); + + // Backends like OpenGL report device=0 for all adapters, so + // device-based matching is only meaningful when non-zero. + let device_known = info.device != 0; + + let user_override: u8 = match device_id_filter { + Some(id) if device_known && info.device == id => 0, + _ => 1, + }; + + let compositor_match: u8 = match compositor_gpu { + Some(hint) + if device_known + && info.vendor == hint.vendor_id + && info.device == hint.device_id => + { + 0 + } + _ => 1, + }; + + let type_priority: u8 = match info.device_type { + wgpu::DeviceType::DiscreteGpu => 0, + wgpu::DeviceType::IntegratedGpu => 1, + wgpu::DeviceType::Other => 2, + wgpu::DeviceType::VirtualGpu => 3, + wgpu::DeviceType::Cpu => 4, + }; + + let backend_priority: u8 = match info.backend { + wgpu::Backend::Vulkan => 0, + wgpu::Backend::Metal => 0, + wgpu::Backend::Dx12 => 0, + _ => 1, + }; + + ( + user_override, + compositor_match, + type_priority, + backend_priority, + ) + }); + + // Log all available adapters (in sorted order) + log::info!("Found {} GPU adapter(s):", adapters.len()); + for adapter in &adapters { + let info = adapter.get_info(); + log::info!( + " - {} (vendor={:#06x}, device={:#06x}, backend={:?}, type={:?})", + info.name, + info.vendor, + info.device, + info.backend, + info.device_type, + ); + } + + // Test each adapter by creating a device and configuring the surface + for adapter in adapters { + let info = adapter.get_info(); + log::info!("Testing adapter: {} ({:?})...", info.name, info.backend); + + match Self::try_adapter_with_surface(&adapter, surface).await { + Ok((device, queue, dual_source_blending)) => { + log::info!( + "Selected GPU (passed configuration test): {} ({:?})", + info.name, + info.backend + ); + return Ok((adapter, device, queue, dual_source_blending)); + } + Err(e) => { + log::info!( + " Adapter {} ({:?}) failed: {}, trying next...", + info.name, + info.backend, + e + ); + } + } + } + + anyhow::bail!("No GPU adapter found that can configure the display surface") + } + + /// Try to use an adapter with a surface by creating a device and testing configuration. + /// Returns the device and queue if successful, allowing them to be reused. + #[cfg(not(target_family = "wasm"))] + async fn try_adapter_with_surface( + adapter: &wgpu::Adapter, + surface: &wgpu::Surface<'_>, + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { + let caps = surface.get_capabilities(adapter); + if caps.formats.is_empty() { + anyhow::bail!("no compatible surface formats"); + } + if caps.alpha_modes.is_empty() { + anyhow::bail!("no compatible alpha modes"); + } + + let (device, queue, dual_source_blending) = Self::create_device(adapter).await?; + let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + + let test_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: caps.formats[0], + width: 64, + height: 64, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + }; + + surface.configure(&device, &test_config); + + let error = error_scope.pop().await; + if let Some(e) = error { + anyhow::bail!("surface configuration failed: {e}"); + } + + Ok((device, queue, dual_source_blending)) + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.dual_source_blending + } + + /// Returns true if the GPU device was lost (e.g., due to driver crash, suspend/resume). + /// When this returns true, the context should be recreated. + pub fn device_lost(&self) -> bool { + self.device_lost.load(Ordering::Relaxed) + } + + /// Returns a clone of the device_lost flag for sharing with renderers. + pub(crate) fn device_lost_flag(&self) -> Arc { + Arc::clone(&self.device_lost) + } +} + +#[cfg(not(target_family = "wasm"))] +fn parse_pci_id(id: &str) -> anyhow::Result { + let mut id = id.trim(); + + if id.starts_with("0x") || id.starts_with("0X") { + id = &id[2..]; + } + let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); + let is_4_chars = id.len() == 4; + anyhow::ensure!( + is_4_chars && is_hex_string, + "Expected a 4 digit PCI ID in hexadecimal format" + ); + + u32::from_str_radix(id, 16).context("parsing PCI ID as hex") +} + +#[cfg(test)] +mod tests { + use super::parse_pci_id; + + #[test] + fn test_parse_device_id() { + assert!(parse_pci_id("0xABCD").is_ok()); + assert!(parse_pci_id("ABCD").is_ok()); + assert!(parse_pci_id("abcd").is_ok()); + assert!(parse_pci_id("1234").is_ok()); + assert!(parse_pci_id("123").is_err()); + assert_eq!( + parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), + ); + + assert_eq!( + parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), + ); + } +} diff --git a/src/platform/wgpu/wgpu_renderer.rs b/src/platform/wgpu/wgpu_renderer.rs new file mode 100644 index 0000000000..4da255a02d --- /dev/null +++ b/src/platform/wgpu/wgpu_renderer.rs @@ -0,0 +1,1768 @@ +use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; +use bytemuck::{Pod, Zeroable}; +use gpui::{ + AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, + PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, SubpixelSprite, + Underline, get_gamma_correction_ratios, +}; +use log::warn; +#[cfg(not(target_family = "wasm"))] +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use std::cell::RefCell; +use std::num::NonZeroU64; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct GlobalParams { + viewport_size: [f32; 2], + premultiplied_alpha: u32, + pad: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct PodBounds { + origin: [f32; 2], + size: [f32; 2], +} + +impl From> for PodBounds { + fn from(bounds: Bounds) -> Self { + Self { + origin: [bounds.origin.x.0, bounds.origin.y.0], + size: [bounds.size.width.0, bounds.size.height.0], + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct SurfaceParams { + bounds: PodBounds, + content_mask: PodBounds, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct GammaParams { + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + _pad: [f32; 2], +} + +#[derive(Clone, Debug)] +#[repr(C)] +struct PathSprite { + bounds: Bounds, +} + +#[derive(Clone, Debug)] +#[repr(C)] +struct PathRasterizationVertex { + xy_position: Point, + st_position: Point, + color: Background, + bounds: Bounds, +} + +pub struct WgpuSurfaceConfig { + pub size: Size, + pub transparent: bool, +} + +struct WgpuPipelines { + quads: wgpu::RenderPipeline, + shadows: wgpu::RenderPipeline, + path_rasterization: wgpu::RenderPipeline, + paths: wgpu::RenderPipeline, + underlines: wgpu::RenderPipeline, + mono_sprites: wgpu::RenderPipeline, + subpixel_sprites: Option, + poly_sprites: wgpu::RenderPipeline, + #[allow(dead_code)] + surfaces: wgpu::RenderPipeline, +} + +struct WgpuBindGroupLayouts { + globals: wgpu::BindGroupLayout, + instances: wgpu::BindGroupLayout, + instances_with_texture: wgpu::BindGroupLayout, + surfaces: wgpu::BindGroupLayout, +} + +/// Shared GPU context reference, used to coordinate device recovery across multiple windows. +pub type GpuContext = Rc>>; + +/// GPU resources that must be dropped together during device recovery. +struct WgpuResources { + device: Arc, + queue: Arc, + surface: wgpu::Surface<'static>, + pipelines: WgpuPipelines, + bind_group_layouts: WgpuBindGroupLayouts, + atlas_sampler: wgpu::Sampler, + globals_buffer: wgpu::Buffer, + globals_bind_group: wgpu::BindGroup, + path_globals_bind_group: wgpu::BindGroup, + instance_buffer: wgpu::Buffer, + path_intermediate_texture: Option, + path_intermediate_view: Option, + path_msaa_texture: Option, + path_msaa_view: Option, +} + +pub struct WgpuRenderer { + /// Shared GPU context for device recovery coordination (unused on WASM). + #[allow(dead_code)] + context: Option, + /// Compositor GPU hint for adapter selection (unused on WASM). + #[allow(dead_code)] + compositor_gpu: Option, + resources: Option, + surface_config: wgpu::SurfaceConfiguration, + atlas: Arc, + path_globals_offset: u64, + gamma_offset: u64, + instance_buffer_capacity: u64, + max_buffer_size: u64, + storage_buffer_alignment: u64, + rendering_params: RenderingParameters, + dual_source_blending: bool, + adapter_info: wgpu::AdapterInfo, + transparent_alpha_mode: wgpu::CompositeAlphaMode, + opaque_alpha_mode: wgpu::CompositeAlphaMode, + max_texture_size: u32, + last_error: Arc>>, + failed_frame_count: u32, + device_lost: std::sync::Arc, +} + +impl WgpuRenderer { + fn resources(&self) -> &WgpuResources { + self.resources + .as_ref() + .expect("GPU resources not available") + } + + fn resources_mut(&mut self) -> &mut WgpuResources { + self.resources + .as_mut() + .expect("GPU resources not available") + } + + /// Creates a new WgpuRenderer from raw window handles. + /// + /// The `gpu_context` is a shared reference that coordinates GPU context across + /// multiple windows. The first window to create a renderer will initialize the + /// context; subsequent windows will share it. + /// + /// # Safety + /// The caller must ensure that the window handle remains valid for the lifetime + /// of the returned renderer. + #[cfg(not(target_family = "wasm"))] + pub fn new( + gpu_context: GpuContext, + window: &W, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + ) -> anyhow::Result + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + // Fall back to the display handle already provided via InstanceDescriptor::display. + raw_display_handle: None, + raw_window_handle: window_handle.as_raw(), + }; + + // Use the existing context's instance if available, otherwise create a new one. + // The surface must be created with the same instance that will be used for + // adapter selection, otherwise wgpu will panic. + let instance = gpu_context + .borrow() + .as_ref() + .map(|ctx| ctx.instance.clone()) + .unwrap_or_else(|| WgpuContext::instance(Box::new(window.clone()))); + + // Safety: The caller guarantees that the window handle is valid for the + // lifetime of this renderer. In practice, the RawWindow struct is created + // from the native window handles and the surface is dropped before the window. + let surface = unsafe { + instance + .create_surface_unsafe(target) + .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))? + }; + + let mut ctx_ref = gpu_context.borrow_mut(); + let context = match ctx_ref.as_mut() { + Some(context) => { + context.check_compatible_with_surface(&surface)?; + context + } + None => ctx_ref.insert(WgpuContext::new(instance, &surface, compositor_gpu)?), + }; + + let atlas = Arc::new(WgpuAtlas::new( + Arc::clone(&context.device), + Arc::clone(&context.queue), + )); + + Self::new_internal( + Some(Rc::clone(&gpu_context)), + context, + surface, + config, + compositor_gpu, + atlas, + ) + } + + #[cfg(target_family = "wasm")] + pub fn new_from_canvas( + context: &WgpuContext, + canvas: &web_sys::HtmlCanvasElement, + config: WgpuSurfaceConfig, + ) -> anyhow::Result { + let surface = context + .instance + .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) + .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))?; + + let atlas = Arc::new(WgpuAtlas::new( + Arc::clone(&context.device), + Arc::clone(&context.queue), + )); + + Self::new_internal(None, context, surface, config, None, atlas) + } + + fn new_internal( + gpu_context: Option, + context: &WgpuContext, + surface: wgpu::Surface<'static>, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + atlas: Arc, + ) -> anyhow::Result { + let surface_caps = surface.get_capabilities(&context.adapter); + let preferred_formats = [ + wgpu::TextureFormat::Bgra8Unorm, + wgpu::TextureFormat::Rgba8Unorm, + ]; + let surface_format = preferred_formats + .iter() + .find(|f| surface_caps.formats.contains(f)) + .copied() + .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()).copied()) + .or_else(|| surface_caps.formats.first().copied()) + .ok_or_else(|| { + anyhow::anyhow!( + "Surface reports no supported texture formats for adapter {:?}", + context.adapter.get_info().name + ) + })?; + + let pick_alpha_mode = + |preferences: &[wgpu::CompositeAlphaMode]| -> anyhow::Result { + preferences + .iter() + .find(|p| surface_caps.alpha_modes.contains(p)) + .copied() + .or_else(|| surface_caps.alpha_modes.first().copied()) + .ok_or_else(|| { + anyhow::anyhow!( + "Surface reports no supported alpha modes for adapter {:?}", + context.adapter.get_info().name + ) + }) + }; + + let transparent_alpha_mode = pick_alpha_mode(&[ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Inherit, + ])?; + + let opaque_alpha_mode = pick_alpha_mode(&[ + wgpu::CompositeAlphaMode::Opaque, + wgpu::CompositeAlphaMode::Inherit, + ])?; + + let alpha_mode = if config.transparent { + transparent_alpha_mode + } else { + opaque_alpha_mode + }; + + let device = Arc::clone(&context.device); + let max_texture_size = device.limits().max_texture_dimension_2d; + + let requested_width = config.size.width.0 as u32; + let requested_height = config.size.height.0 as u32; + let clamped_width = requested_width.min(max_texture_size); + let clamped_height = requested_height.min(max_texture_size); + + if clamped_width != requested_width || clamped_height != requested_height { + warn!( + "Requested surface size ({}, {}) exceeds maximum texture dimension {}. \ + Clamping to ({}, {}). Window content may not fill the entire window.", + requested_width, requested_height, max_texture_size, clamped_width, clamped_height + ); + } + + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + width: clamped_width.max(1), + height: clamped_height.max(1), + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: vec![], + }; + // Configure the surface immediately. The adapter selection process already validated + // that this adapter can successfully configure this surface. + surface.configure(&context.device, &surface_config); + + let queue = Arc::clone(&context.queue); + let dual_source_blending = context.supports_dual_source_blending(); + + let rendering_params = RenderingParameters::new(&context.adapter, surface_format); + let bind_group_layouts = Self::create_bind_group_layouts(&device); + let pipelines = Self::create_pipelines( + &device, + &bind_group_layouts, + surface_format, + alpha_mode, + rendering_params.path_sample_count, + dual_source_blending, + ); + + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("atlas_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let globals_size = std::mem::size_of::() as u64; + let gamma_size = std::mem::size_of::() as u64; + let path_globals_offset = globals_size.next_multiple_of(uniform_alignment); + let gamma_offset = (path_globals_offset + globals_size).next_multiple_of(uniform_alignment); + + let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("globals_buffer"), + size: gamma_offset + gamma_size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let max_buffer_size = device.limits().max_buffer_size; + let storage_buffer_alignment = device.limits().min_storage_buffer_offset_alignment as u64; + let initial_instance_buffer_capacity = 2 * 1024 * 1024; + let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance_buffer"), + size: initial_instance_buffer_capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("globals_bind_group"), + layout: &bind_group_layouts.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: 0, + size: Some(NonZeroU64::new(globals_size).unwrap()), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: Some(NonZeroU64::new(gamma_size).unwrap()), + }), + }, + ], + }); + + let path_globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("path_globals_bind_group"), + layout: &bind_group_layouts.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: path_globals_offset, + size: Some(NonZeroU64::new(globals_size).unwrap()), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: Some(NonZeroU64::new(gamma_size).unwrap()), + }), + }, + ], + }); + + let adapter_info = context.adapter.get_info(); + + let last_error: Arc>> = Arc::new(Mutex::new(None)); + let last_error_clone = Arc::clone(&last_error); + device.on_uncaptured_error(Arc::new(move |error| { + let mut guard = last_error_clone.lock().unwrap(); + *guard = Some(error.to_string()); + })); + + let resources = WgpuResources { + device, + queue, + surface, + pipelines, + bind_group_layouts, + atlas_sampler, + globals_buffer, + globals_bind_group, + path_globals_bind_group, + instance_buffer, + // Defer intermediate texture creation to first draw call via ensure_intermediate_textures(). + // This avoids panics when the device/surface is in an invalid state during initialization. + path_intermediate_texture: None, + path_intermediate_view: None, + path_msaa_texture: None, + path_msaa_view: None, + }; + + Ok(Self { + context: gpu_context, + compositor_gpu, + resources: Some(resources), + surface_config, + atlas, + path_globals_offset, + gamma_offset, + instance_buffer_capacity: initial_instance_buffer_capacity, + max_buffer_size, + storage_buffer_alignment, + rendering_params, + dual_source_blending, + adapter_info, + transparent_alpha_mode, + opaque_alpha_mode, + max_texture_size, + last_error, + failed_frame_count: 0, + device_lost: context.device_lost_flag(), + }) + } + + fn create_bind_group_layouts(device: &wgpu::Device) -> WgpuBindGroupLayouts { + let globals = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("globals_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + ], + }); + + let storage_buffer_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + + let instances = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("instances_layout"), + entries: &[storage_buffer_entry(0)], + }); + + let instances_with_texture = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("instances_with_texture_layout"), + entries: &[ + storage_buffer_entry(0), + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let surfaces = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("surfaces_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + WgpuBindGroupLayouts { + globals, + instances, + instances_with_texture, + surfaces, + } + } + + fn create_pipelines( + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + surface_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + path_sample_count: u32, + dual_source_blending: bool, + ) -> WgpuPipelines { + let base_shader_source = include_str!("shaders.wgsl"); + let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpui_shaders"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(base_shader_source)), + }); + + let subpixel_shader_source = include_str!("shaders_subpixel.wgsl"); + let subpixel_shader_module = if dual_source_blending { + let combined = format!( + "enable dual_source_blending;\n{base_shader_source}\n{subpixel_shader_source}" + ); + Some(device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpui_subpixel_shaders"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Owned(combined)), + })) + } else { + None + }; + + let blend_mode = match alpha_mode { + wgpu::CompositeAlphaMode::PreMultiplied => { + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING + } + _ => wgpu::BlendState::ALPHA_BLENDING, + }; + + let color_target = wgpu::ColorTargetState { + format: surface_format, + blend: Some(blend_mode), + write_mask: wgpu::ColorWrites::ALL, + }; + + let create_pipeline = |name: &str, + vs_entry: &str, + fs_entry: &str, + globals_layout: &wgpu::BindGroupLayout, + data_layout: &wgpu::BindGroupLayout, + topology: wgpu::PrimitiveTopology, + color_targets: &[Option], + sample_count: u32, + module: &wgpu::ShaderModule| { + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&format!("{name}_layout")), + bind_group_layouts: &[Some(globals_layout), Some(data_layout)], + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(name), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(vs_entry), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fs_entry), + targets: color_targets, + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + + let quads = create_pipeline( + "quads", + "vs_quad", + "fs_quad", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let shadows = create_pipeline( + "shadows", + "vs_shadow", + "fs_shadow", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let path_rasterization = create_pipeline( + "path_rasterization", + "vs_path_rasterization", + "fs_path_rasterization", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleList, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + path_sample_count, + &shader_module, + ); + + let paths_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + + let paths = create_pipeline( + "paths", + "vs_path", + "fs_path", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(paths_blend), + write_mask: wgpu::ColorWrites::ALL, + })], + 1, + &shader_module, + ); + + let underlines = create_pipeline( + "underlines", + "vs_underline", + "fs_underline", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let mono_sprites = create_pipeline( + "mono_sprites", + "vs_mono_sprite", + "fs_mono_sprite", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let subpixel_sprites = if let Some(subpixel_module) = &subpixel_shader_module { + let subpixel_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Src1, + dst_factor: wgpu::BlendFactor::OneMinusSrc1, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + Some(create_pipeline( + "subpixel_sprites", + "vs_subpixel_sprite", + "fs_subpixel_sprite", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(subpixel_blend), + write_mask: wgpu::ColorWrites::COLOR, + })], + 1, + subpixel_module, + )) + } else { + None + }; + + let poly_sprites = create_pipeline( + "poly_sprites", + "vs_poly_sprite", + "fs_poly_sprite", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let surfaces = create_pipeline( + "surfaces", + "vs_surface", + "fs_surface", + &layouts.globals, + &layouts.surfaces, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target)], + 1, + &shader_module, + ); + + WgpuPipelines { + quads, + shadows, + path_rasterization, + paths, + underlines, + mono_sprites, + subpixel_sprites, + poly_sprites, + surfaces, + } + } + + fn create_path_intermediate( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + ) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_intermediate"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + } + + fn create_msaa_if_needed( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + sample_count: u32, + ) -> Option<(wgpu::Texture, wgpu::TextureView)> { + if sample_count <= 1 { + return None; + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_msaa"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Some((texture, view)) + } + + pub fn update_drawable_size(&mut self, size: Size) { + let width = size.width.0 as u32; + let height = size.height.0 as u32; + + if width != self.surface_config.width || height != self.surface_config.height { + let clamped_width = width.min(self.max_texture_size); + let clamped_height = height.min(self.max_texture_size); + + if clamped_width != width || clamped_height != height { + warn!( + "Requested surface size ({}, {}) exceeds maximum texture dimension {}. \ + Clamping to ({}, {}). Window content may not fill the entire window.", + width, height, self.max_texture_size, clamped_width, clamped_height + ); + } + + self.surface_config.width = clamped_width.max(1); + self.surface_config.height = clamped_height.max(1); + let surface_config = self.surface_config.clone(); + + let resources = self.resources_mut(); + + // Wait for any in-flight GPU work to complete before destroying textures + if let Err(e) = resources.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) { + warn!("Failed to poll device during resize: {e:?}"); + } + + // Destroy old textures before allocating new ones to avoid GPU memory spikes + if let Some(ref texture) = resources.path_intermediate_texture { + texture.destroy(); + } + if let Some(ref texture) = resources.path_msaa_texture { + texture.destroy(); + } + + resources + .surface + .configure(&resources.device, &surface_config); + + // Invalidate intermediate textures - they will be lazily recreated + // in draw() after we confirm the surface is healthy. This avoids + // panics when the device/surface is in an invalid state during resize. + resources.path_intermediate_texture = None; + resources.path_intermediate_view = None; + resources.path_msaa_texture = None; + resources.path_msaa_view = None; + } + } + + fn ensure_intermediate_textures(&mut self) { + if self.resources().path_intermediate_texture.is_some() { + return; + } + + let format = self.surface_config.format; + let width = self.surface_config.width; + let height = self.surface_config.height; + let path_sample_count = self.rendering_params.path_sample_count; + let resources = self.resources_mut(); + + let (t, v) = Self::create_path_intermediate(&resources.device, format, width, height); + resources.path_intermediate_texture = Some(t); + resources.path_intermediate_view = Some(v); + + let (path_msaa_texture, path_msaa_view) = Self::create_msaa_if_needed( + &resources.device, + format, + width, + height, + path_sample_count, + ) + .map(|(t, v)| (Some(t), Some(v))) + .unwrap_or((None, None)); + resources.path_msaa_texture = path_msaa_texture; + resources.path_msaa_view = path_msaa_view; + } + + pub fn update_transparency(&mut self, transparent: bool) { + let new_alpha_mode = if transparent { + self.transparent_alpha_mode + } else { + self.opaque_alpha_mode + }; + + if new_alpha_mode != self.surface_config.alpha_mode { + self.surface_config.alpha_mode = new_alpha_mode; + let surface_config = self.surface_config.clone(); + let path_sample_count = self.rendering_params.path_sample_count; + let dual_source_blending = self.dual_source_blending; + let resources = self.resources_mut(); + resources + .surface + .configure(&resources.device, &surface_config); + resources.pipelines = Self::create_pipelines( + &resources.device, + &resources.bind_group_layouts, + surface_config.format, + surface_config.alpha_mode, + path_sample_count, + dual_source_blending, + ); + } + } + + #[allow(dead_code)] + pub fn viewport_size(&self) -> Size { + Size { + width: DevicePixels(self.surface_config.width as i32), + height: DevicePixels(self.surface_config.height as i32), + } + } + + pub fn sprite_atlas(&self) -> &Arc { + &self.atlas + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.dual_source_blending + } + + pub fn gpu_specs(&self) -> GpuSpecs { + GpuSpecs { + is_software_emulated: self.adapter_info.device_type == wgpu::DeviceType::Cpu, + device_name: self.adapter_info.name.clone(), + driver_name: self.adapter_info.driver.clone(), + driver_info: self.adapter_info.driver_info.clone(), + } + } + + pub fn max_texture_size(&self) -> u32 { + self.max_texture_size + } + + pub fn draw(&mut self, scene: &Scene) { + let last_error = self.last_error.lock().unwrap().take(); + if let Some(error) = last_error { + self.failed_frame_count += 1; + log::error!( + "GPU error during frame (failure {} of 20): {error}", + self.failed_frame_count + ); + if self.failed_frame_count > 20 { + panic!("Too many consecutive GPU errors. Last error: {error}"); + } + } else { + self.failed_frame_count = 0; + } + + self.atlas.before_frame(); + + let frame = match self.resources().surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) => frame, + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { + // Textures must be destroyed before the surface can be reconfigured. + drop(frame); + let surface_config = self.surface_config.clone(); + let resources = self.resources_mut(); + resources + .surface + .configure(&resources.device, &surface_config); + return; + } + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + let surface_config = self.surface_config.clone(); + let resources = self.resources_mut(); + resources + .surface + .configure(&resources.device, &surface_config); + return; + } + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { + return; + } + wgpu::CurrentSurfaceTexture::Validation => { + *self.last_error.lock().unwrap() = + Some("Surface texture validation error".to_string()); + return; + } + }; + + // Now that we know the surface is healthy, ensure intermediate textures exist + self.ensure_intermediate_textures(); + + let frame_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let gamma_params = GammaParams { + gamma_ratios: self.rendering_params.gamma_ratios, + grayscale_enhanced_contrast: self.rendering_params.grayscale_enhanced_contrast, + subpixel_enhanced_contrast: self.rendering_params.subpixel_enhanced_contrast, + _pad: [0.0; 2], + }; + + let globals = GlobalParams { + viewport_size: [ + self.surface_config.width as f32, + self.surface_config.height as f32, + ], + premultiplied_alpha: if self.surface_config.alpha_mode + == wgpu::CompositeAlphaMode::PreMultiplied + { + 1 + } else { + 0 + }, + pad: 0, + }; + + let path_globals = GlobalParams { + premultiplied_alpha: 0, + ..globals + }; + + { + let resources = self.resources(); + resources.queue.write_buffer( + &resources.globals_buffer, + 0, + bytemuck::bytes_of(&globals), + ); + resources.queue.write_buffer( + &resources.globals_buffer, + self.path_globals_offset, + bytemuck::bytes_of(&path_globals), + ); + resources.queue.write_buffer( + &resources.globals_buffer, + self.gamma_offset, + bytemuck::bytes_of(&gamma_params), + ); + } + + loop { + let mut instance_offset: u64 = 0; + let mut overflow = false; + + let mut encoder = + self.resources() + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("main_encoder"), + }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + for batch in scene.batches() { + let ok = match batch { + PrimitiveBatch::Quads(range) => { + self.draw_quads(&scene.quads[range], &mut instance_offset, &mut pass) + } + PrimitiveBatch::Shadows(range) => self.draw_shadows( + &scene.shadows[range], + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::Paths(range) => { + let paths = &scene.paths[range]; + if paths.is_empty() { + continue; + } + + drop(pass); + + let did_draw = self.draw_paths_to_intermediate( + &mut encoder, + paths, + &mut instance_offset, + ); + + pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main_pass_continued"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + if did_draw { + self.draw_paths_from_intermediate( + paths, + &mut instance_offset, + &mut pass, + ) + } else { + false + } + } + PrimitiveBatch::Underlines(range) => self.draw_underlines( + &scene.underlines[range], + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::MonochromeSprites { texture_id, range } => self + .draw_monochrome_sprites( + &scene.monochrome_sprites[range], + texture_id, + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::SubpixelSprites { texture_id, range } => self + .draw_subpixel_sprites( + &scene.subpixel_sprites[range], + texture_id, + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::PolychromeSprites { texture_id, range } => self + .draw_polychrome_sprites( + &scene.polychrome_sprites[range], + texture_id, + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::Surfaces(_surfaces) => { + // Surfaces are macOS-only for video playback + // Not implemented for Linux/wgpu + true + } + }; + if !ok { + overflow = true; + break; + } + } + } + + if overflow { + drop(encoder); + if self.instance_buffer_capacity >= self.max_buffer_size { + log::error!( + "instance buffer size grew too large: {}", + self.instance_buffer_capacity + ); + frame.present(); + return; + } + self.grow_instance_buffer(); + continue; + } + + self.resources() + .queue + .submit(std::iter::once(encoder.finish())); + frame.present(); + return; + } + } + + fn draw_quads( + &self, + quads: &[Quad], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let data = unsafe { Self::instance_bytes(quads) }; + self.draw_instances( + data, + quads.len() as u32, + &self.resources().pipelines.quads, + instance_offset, + pass, + ) + } + + fn draw_shadows( + &self, + shadows: &[Shadow], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let data = unsafe { Self::instance_bytes(shadows) }; + self.draw_instances( + data, + shadows.len() as u32, + &self.resources().pipelines.shadows, + instance_offset, + pass, + ) + } + + fn draw_underlines( + &self, + underlines: &[Underline], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let data = unsafe { Self::instance_bytes(underlines) }; + self.draw_instances( + data, + underlines.len() as u32, + &self.resources().pipelines.underlines, + instance_offset, + pass, + ) + } + + fn draw_monochrome_sprites( + &self, + sprites: &[MonochromeSprite], + texture_id: AtlasTextureId, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let tex_info = self.atlas.get_texture_info(texture_id); + let data = unsafe { Self::instance_bytes(sprites) }; + self.draw_instances_with_texture( + data, + sprites.len() as u32, + &tex_info.view, + &self.resources().pipelines.mono_sprites, + instance_offset, + pass, + ) + } + + fn draw_subpixel_sprites( + &self, + sprites: &[SubpixelSprite], + texture_id: AtlasTextureId, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let tex_info = self.atlas.get_texture_info(texture_id); + let data = unsafe { Self::instance_bytes(sprites) }; + let resources = self.resources(); + let pipeline = resources + .pipelines + .subpixel_sprites + .as_ref() + .unwrap_or(&resources.pipelines.mono_sprites); + self.draw_instances_with_texture( + data, + sprites.len() as u32, + &tex_info.view, + pipeline, + instance_offset, + pass, + ) + } + + fn draw_polychrome_sprites( + &self, + sprites: &[PolychromeSprite], + texture_id: AtlasTextureId, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let tex_info = self.atlas.get_texture_info(texture_id); + let data = unsafe { Self::instance_bytes(sprites) }; + self.draw_instances_with_texture( + data, + sprites.len() as u32, + &tex_info.view, + &self.resources().pipelines.poly_sprites, + instance_offset, + pass, + ) + } + + fn draw_instances( + &self, + data: &[u8], + instance_count: u32, + pipeline: &wgpu::RenderPipeline, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + if instance_count == 0 { + return true; + } + let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else { + return false; + }; + let resources = self.resources(); + let bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &resources.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(offset, size), + }], + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); + pass.set_bind_group(1, &bind_group, &[]); + pass.draw(0..4, 0..instance_count); + true + } + + fn draw_instances_with_texture( + &self, + data: &[u8], + instance_count: u32, + texture_view: &wgpu::TextureView, + pipeline: &wgpu::RenderPipeline, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + if instance_count == 0 { + return true; + } + let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else { + return false; + }; + let resources = self.resources(); + let bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &resources.bind_group_layouts.instances_with_texture, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(offset, size), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&resources.atlas_sampler), + }, + ], + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); + pass.set_bind_group(1, &bind_group, &[]); + pass.draw(0..4, 0..instance_count); + true + } + + unsafe fn instance_bytes(instances: &[T]) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + instances.as_ptr() as *const u8, + std::mem::size_of_val(instances), + ) + } + } + + fn draw_paths_from_intermediate( + &self, + paths: &[Path], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let first_path = &paths[0]; + let sprites: Vec = if paths.last().map(|p| &p.order) == Some(&first_path.order) + { + paths + .iter() + .map(|p| PathSprite { + bounds: p.clipped_bounds(), + }) + .collect() + } else { + let mut bounds = first_path.clipped_bounds(); + for path in paths.iter().skip(1) { + bounds = bounds.union(&path.clipped_bounds()); + } + vec![PathSprite { bounds }] + }; + + let resources = self.resources(); + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { + return true; + }; + + let sprite_data = unsafe { Self::instance_bytes(&sprites) }; + self.draw_instances_with_texture( + sprite_data, + sprites.len() as u32, + path_intermediate_view, + &resources.pipelines.paths, + instance_offset, + pass, + ) + } + + fn draw_paths_to_intermediate( + &self, + encoder: &mut wgpu::CommandEncoder, + paths: &[Path], + instance_offset: &mut u64, + ) -> bool { + let mut vertices = Vec::new(); + for path in paths { + let bounds = path.clipped_bounds(); + vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex { + xy_position: v.xy_position, + st_position: v.st_position, + color: path.color, + bounds, + })); + } + + if vertices.is_empty() { + return true; + } + + let vertex_data = unsafe { Self::instance_bytes(&vertices) }; + let Some((vertex_offset, vertex_size)) = + self.write_to_instance_buffer(instance_offset, vertex_data) + else { + return false; + }; + + let resources = self.resources(); + let data_bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("path_rasterization_bind_group"), + layout: &resources.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(vertex_offset, vertex_size), + }], + }); + + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { + return true; + }; + + let (target_view, resolve_target) = if let Some(ref msaa_view) = resources.path_msaa_view { + (msaa_view, Some(path_intermediate_view)) + } else { + (path_intermediate_view, None) + }; + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("path_rasterization_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + pass.set_pipeline(&resources.pipelines.path_rasterization); + pass.set_bind_group(0, &resources.path_globals_bind_group, &[]); + pass.set_bind_group(1, &data_bind_group, &[]); + pass.draw(0..vertices.len() as u32, 0..1); + } + + true + } + + fn grow_instance_buffer(&mut self) { + let new_capacity = (self.instance_buffer_capacity * 2).min(self.max_buffer_size); + log::info!("increased instance buffer size to {}", new_capacity); + let resources = self.resources_mut(); + resources.instance_buffer = resources.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance_buffer"), + size: new_capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.instance_buffer_capacity = new_capacity; + } + + fn write_to_instance_buffer( + &self, + instance_offset: &mut u64, + data: &[u8], + ) -> Option<(u64, NonZeroU64)> { + let offset = (*instance_offset).next_multiple_of(self.storage_buffer_alignment); + let size = (data.len() as u64).max(16); + if offset + size > self.instance_buffer_capacity { + return None; + } + let resources = self.resources(); + resources + .queue + .write_buffer(&resources.instance_buffer, offset, data); + *instance_offset = offset + size; + Some((offset, NonZeroU64::new(size).expect("size is at least 16"))) + } + + fn instance_binding(&self, offset: u64, size: NonZeroU64) -> wgpu::BindingResource<'_> { + wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &self.resources().instance_buffer, + offset, + size: Some(size), + }) + } + + pub fn destroy(&mut self) { + // Release surface-bound GPU resources eagerly so the underlying native + // window can be destroyed before the renderer itself is dropped. + self.resources.take(); + } + + /// Returns true if the GPU device was lost and recovery is needed. + pub fn device_lost(&self) -> bool { + self.device_lost.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Recovers from a lost GPU device by recreating the renderer with a new context. + /// + /// Call this after detecting `device_lost()` returns true. + /// + /// This method coordinates recovery across multiple windows: + /// - The first window to call this will recreate the shared context + /// - Subsequent windows will adopt the already-recovered context + #[cfg(not(target_family = "wasm"))] + pub fn recover(&mut self, window: &W) -> anyhow::Result<()> + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let gpu_context = self.context.as_ref().expect("recover requires gpu_context"); + + // Check if another window already recovered the context + let needs_new_context = gpu_context + .borrow() + .as_ref() + .is_none_or(|ctx| ctx.device_lost()); + + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + + let surface = if needs_new_context { + log::warn!("GPU device lost, recreating context..."); + + // Drop old resources to release Arc/Arc and GPU resources + self.resources = None; + *gpu_context.borrow_mut() = None; + + // Wait for GPU driver to stabilize (350ms copied from windows :shrug:) + std::thread::sleep(std::time::Duration::from_millis(350)); + + let instance = WgpuContext::instance(Box::new(window.clone())); + let surface = create_surface(&instance, window_handle.as_raw())?; + let new_context = WgpuContext::new(instance, &surface, self.compositor_gpu)?; + *gpu_context.borrow_mut() = Some(new_context); + surface + } else { + let ctx_ref = gpu_context.borrow(); + let instance = &ctx_ref.as_ref().unwrap().instance; + create_surface(instance, window_handle.as_raw())? + }; + + let config = WgpuSurfaceConfig { + size: gpui::Size { + width: gpui::DevicePixels(self.surface_config.width as i32), + height: gpui::DevicePixels(self.surface_config.height as i32), + }, + transparent: self.surface_config.alpha_mode != wgpu::CompositeAlphaMode::Opaque, + }; + let gpu_context = Rc::clone(gpu_context); + let ctx_ref = gpu_context.borrow(); + let context = ctx_ref.as_ref().expect("context should exist"); + + self.resources = None; + self.atlas + .handle_device_lost(Arc::clone(&context.device), Arc::clone(&context.queue)); + + *self = Self::new_internal( + Some(gpu_context.clone()), + context, + surface, + config, + self.compositor_gpu, + self.atlas.clone(), + )?; + + log::info!("GPU recovery complete"); + Ok(()) + } +} + +#[cfg(not(target_family = "wasm"))] +fn create_surface( + instance: &wgpu::Instance, + raw_window_handle: raw_window_handle::RawWindowHandle, +) -> anyhow::Result> { + unsafe { + instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + // Fall back to the display handle already provided via InstanceDescriptor::display. + raw_display_handle: None, + raw_window_handle, + }) + .map_err(|e| anyhow::anyhow!("{e}")) + } +} + +struct RenderingParameters { + path_sample_count: u32, + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, +} + +impl RenderingParameters { + fn new(adapter: &wgpu::Adapter, surface_format: wgpu::TextureFormat) -> Self { + use std::env; + + let format_features = adapter.get_texture_format_features(surface_format); + let path_sample_count = [4, 2, 1] + .into_iter() + .find(|&n| format_features.flags.sample_count_supported(n)) + .unwrap_or(1); + + let gamma = env::var("ZED_FONTS_GAMMA") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.8_f32) + .clamp(1.0, 2.2); + let gamma_ratios = get_gamma_correction_ratios(gamma); + + let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.0_f32) + .max(0.0); + + let subpixel_enhanced_contrast = env::var("ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.5_f32) + .max(0.0); + + Self { + path_sample_count, + gamma_ratios, + grayscale_enhanced_contrast, + subpixel_enhanced_contrast, + } + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 9cd1a7d05f..d3ac9465ea 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -32,9 +32,6 @@ pub(crate) use vsync::*; pub(crate) use window::*; pub(crate) use wrapper::*; -pub(crate) use windows::Win32::Foundation::HWND; +pub use platform::WindowsPlatform; -#[cfg(feature = "screen-capture")] -pub(crate) type PlatformScreenCaptureFrame = scap::frame::Frame; -#[cfg(not(feature = "screen-capture"))] -pub(crate) type PlatformScreenCaptureFrame = (); +pub(crate) use windows::Win32::Foundation::HWND; diff --git a/src/platform/windows/alpha_correction.hlsl b/src/platform/windows/alpha_correction.hlsl index b0a9ca2e6b..5a34a4ebf2 100644 --- a/src/platform/windows/alpha_correction.hlsl +++ b/src/platform/windows/alpha_correction.hlsl @@ -17,12 +17,22 @@ float enhance_contrast(float alpha, float k) { return alpha * (k + 1.0f) / (alpha * k + 1.0f); } +float3 enhance_contrast3(float3 alpha, float k) { + return alpha * (k + 1.0f) / (alpha * k + 1.0f); +} + float apply_alpha_correction(float a, float b, float4 g) { float brightness_adjustment = g.x * b + g.y; float correction = brightness_adjustment * a + (g.z * b + g.w); return a + a * (1.0f - a) * correction; } +float3 apply_alpha_correction3(float3 a, float3 b, float4 g) { + float3 brightness_adjustment = g.x * b + g.y; + float3 correction = brightness_adjustment * a + (g.z * b + g.w); + return a + a * (1.0f - a) * correction; +} + float apply_contrast_and_gamma_correction(float sample, float3 color, float enhanced_contrast_factor, float4 gamma_ratios) { float enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); float brightness = color_brightness(color); @@ -30,3 +40,10 @@ float apply_contrast_and_gamma_correction(float sample, float3 color, float enha float contrasted = enhance_contrast(sample, enhanced_contrast); return apply_alpha_correction(contrasted, brightness, gamma_ratios); } + +float3 apply_contrast_and_gamma_correction3(float3 sample, float3 color, float enhanced_contrast_factor, float4 gamma_ratios) { + float enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); + + float3 contrasted = enhance_contrast3(sample, enhanced_contrast); + return apply_alpha_correction3(contrasted, color, gamma_ratios); +} diff --git a/src/platform/windows/clipboard.rs b/src/platform/windows/clipboard.rs index 2a5e8dcbbe..cd0694ab31 100644 --- a/src/platform/windows/clipboard.rs +++ b/src/platform/windows/clipboard.rs @@ -8,24 +8,22 @@ use windows::Win32::{ System::{ DataExchange::{ CloseClipboard, CountClipboardFormats, EmptyClipboard, EnumClipboardFormats, - GetClipboardData, GetClipboardFormatNameW, IsClipboardFormatAvailable, OpenClipboard, - RegisterClipboardFormatW, SetClipboardData, + GetClipboardData, GetClipboardFormatNameW, OpenClipboard, RegisterClipboardFormatW, + SetClipboardData, }, Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock}, - Ole::{CF_HDROP, CF_UNICODETEXT}, + Ole::{CF_DIB, CF_HDROP, CF_UNICODETEXT}, }, UI::Shell::{DragQueryFileW, HDROP}, }; -use windows_core::PCWSTR; +use windows::core::{Owned, PCWSTR}; -use crate::{ +use gpui::{ ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash, }; -// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-dragqueryfilew const DRAGDROP_GET_FILES_COUNT: u32 = 0xFFFFFFFF; -// Clipboard formats static CLIPBOARD_HASH_FORMAT: LazyLock = LazyLock::new(|| register_clipboard_format(windows::core::w!("GPUI internal text hash"))); static CLIPBOARD_METADATA_FORMAT: LazyLock = @@ -39,89 +37,15 @@ static CLIPBOARD_PNG_FORMAT: LazyLock = static CLIPBOARD_JPG_FORMAT: LazyLock = LazyLock::new(|| register_clipboard_format(windows::core::w!("JFIF"))); -// Helper maps and sets -static FORMATS_MAP: LazyLock> = LazyLock::new(|| { - let mut formats_map = FxHashMap::default(); - formats_map.insert(CF_UNICODETEXT.0 as u32, ClipboardFormatType::Text); - formats_map.insert(*CLIPBOARD_PNG_FORMAT, ClipboardFormatType::Image); - formats_map.insert(*CLIPBOARD_GIF_FORMAT, ClipboardFormatType::Image); - formats_map.insert(*CLIPBOARD_JPG_FORMAT, ClipboardFormatType::Image); - formats_map.insert(*CLIPBOARD_SVG_FORMAT, ClipboardFormatType::Image); - formats_map.insert(CF_HDROP.0 as u32, ClipboardFormatType::Files); - formats_map -}); static IMAGE_FORMATS_MAP: LazyLock> = LazyLock::new(|| { - let mut formats_map = FxHashMap::default(); - formats_map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png); - formats_map.insert(*CLIPBOARD_GIF_FORMAT, ImageFormat::Gif); - formats_map.insert(*CLIPBOARD_JPG_FORMAT, ImageFormat::Jpeg); - formats_map.insert(*CLIPBOARD_SVG_FORMAT, ImageFormat::Svg); - formats_map + let mut map = FxHashMap::default(); + map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png); + map.insert(*CLIPBOARD_GIF_FORMAT, ImageFormat::Gif); + map.insert(*CLIPBOARD_JPG_FORMAT, ImageFormat::Jpeg); + map.insert(*CLIPBOARD_SVG_FORMAT, ImageFormat::Svg); + map }); -#[derive(Debug, Clone, Copy)] -enum ClipboardFormatType { - Text, - Image, - Files, -} - -pub(crate) fn write_to_clipboard(item: ClipboardItem) { - with_clipboard(|| write_to_clipboard_inner(item)); -} - -pub(crate) fn read_from_clipboard() -> Option { - with_clipboard(|| { - with_best_match_format(|item_format| match format_to_type(item_format) { - ClipboardFormatType::Text => read_string_from_clipboard(), - ClipboardFormatType::Image => read_image_from_clipboard(item_format), - ClipboardFormatType::Files => read_files_from_clipboard(), - }) - }) - .flatten() -} - -pub(crate) fn with_file_names(hdrop: HDROP, mut f: F) -where - F: FnMut(String), -{ - let file_count = unsafe { DragQueryFileW(hdrop, DRAGDROP_GET_FILES_COUNT, None) }; - for file_index in 0..file_count { - let filename_length = unsafe { DragQueryFileW(hdrop, file_index, None) } as usize; - let mut buffer = vec![0u16; filename_length + 1]; - let ret = unsafe { DragQueryFileW(hdrop, file_index, Some(buffer.as_mut_slice())) }; - if ret == 0 { - log::error!("unable to read file name of dragged file"); - continue; - } - match String::from_utf16(&buffer[0..filename_length]) { - Ok(file_name) => f(file_name), - Err(e) => { - log::error!("dragged file name is not UTF-16: {}", e) - } - } - } -} - -fn with_clipboard(f: F) -> Option -where - F: FnOnce() -> T, -{ - match unsafe { OpenClipboard(None) } { - Ok(()) => { - let result = f(); - if let Err(e) = unsafe { CloseClipboard() } { - log::error!("Failed to close clipboard: {e}",); - } - Some(result) - } - Err(e) => { - log::error!("Failed to open clipboard: {e}",); - None - } - } -} - fn register_clipboard_format(format: PCWSTR) -> u32 { let ret = unsafe { RegisterClipboardFormatW(format) }; if ret == 0 { @@ -138,262 +62,327 @@ fn register_clipboard_format(format: PCWSTR) -> u32 { ret } -#[inline] -fn format_to_type(item_format: u32) -> &'static ClipboardFormatType { - FORMATS_MAP.get(&item_format).unwrap() +fn get_clipboard_data(format: u32) -> Option { + let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0); + LockedGlobal::lock(global) } -// Currently, we only write the first item. -fn write_to_clipboard_inner(item: ClipboardItem) -> Result<()> { - unsafe { - EmptyClipboard()?; - } - match item.entries().first() { - Some(entry) => match entry { - ClipboardEntry::String(string) => { - write_string_to_clipboard(string)?; +pub(crate) fn write_to_clipboard(item: ClipboardItem) { + let Some(_clip) = ClipboardGuard::open() else { + return; + }; + + let result: Result<()> = (|| { + unsafe { EmptyClipboard()? }; + for entry in item.entries() { + match entry { + ClipboardEntry::String(string) => write_string(string)?, + ClipboardEntry::Image(image) => write_image(image)?, + ClipboardEntry::ExternalPaths(_) => {} } - ClipboardEntry::Image(image) => { - write_image_to_clipboard(image)?; - } - ClipboardEntry::ExternalPaths(_) => {} - }, - None => { - // Writing an empty list of entries just clears the clipboard. } + Ok(()) + })(); + + if let Err(e) = result { + log::error!("Failed to write to clipboard: {e}"); } - Ok(()) } -fn write_string_to_clipboard(item: &ClipboardString) -> Result<()> { - let encode_wide = item.text.encode_utf16().chain(Some(0)).collect_vec(); - set_data_to_clipboard(&encode_wide, CF_UNICODETEXT.0 as u32)?; +pub(crate) fn read_from_clipboard() -> Option { + let _clip = ClipboardGuard::open()?; - if let Some(metadata) = item.metadata.as_ref() { - let hash_result = { - let hash = ClipboardString::text_hash(&item.text); - hash.to_ne_bytes() - }; - let encode_wide = - unsafe { std::slice::from_raw_parts(hash_result.as_ptr().cast::(), 4) }; - set_data_to_clipboard(encode_wide, *CLIPBOARD_HASH_FORMAT)?; + let mut entries = Vec::new(); + let mut have_text = false; + let mut have_image = false; + let mut have_files = false; - let metadata_wide = metadata.encode_utf16().chain(Some(0)).collect_vec(); - set_data_to_clipboard(&metadata_wide, *CLIPBOARD_METADATA_FORMAT)?; - } - Ok(()) -} - -fn set_data_to_clipboard(data: &[T], format: u32) -> Result<()> { - unsafe { - let global = GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of_val(data))?; - let handle = GlobalLock(global); - std::ptr::copy_nonoverlapping(data.as_ptr(), handle as _, data.len()); - let _ = GlobalUnlock(global); - SetClipboardData(format, Some(HANDLE(global.0)))?; - } - Ok(()) -} - -// Here writing PNG to the clipboard to better support other apps. For more info, please ref to -// the PR. -fn write_image_to_clipboard(item: &Image) -> Result<()> { - match item.format { - ImageFormat::Svg => set_data_to_clipboard(item.bytes(), *CLIPBOARD_SVG_FORMAT)?, - ImageFormat::Gif => { - set_data_to_clipboard(item.bytes(), *CLIPBOARD_GIF_FORMAT)?; - let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Gif)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - ImageFormat::Png => { - set_data_to_clipboard(item.bytes(), *CLIPBOARD_PNG_FORMAT)?; - let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Png)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - ImageFormat::Jpeg => { - set_data_to_clipboard(item.bytes(), *CLIPBOARD_JPG_FORMAT)?; - let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Jpeg)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - other => { - log::warn!( - "Clipboard unsupported image format: {:?}, convert to PNG instead.", - item.format - ); - let png_bytes = convert_image_to_png_format(item.bytes(), other)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - } - Ok(()) -} - -fn convert_image_to_png_format(bytes: &[u8], image_format: ImageFormat) -> Result> { - let image = image::load_from_memory_with_format(bytes, image_format.into())?; - let mut output_buf = Vec::new(); - image.write_to( - &mut std::io::Cursor::new(&mut output_buf), - image::ImageFormat::Png, - )?; - Ok(output_buf) -} - -// Here, we enumerate all formats on the clipboard and find the first one that we can process. -// The reason we don't use `GetPriorityClipboardFormat` is that it sometimes returns the -// wrong format. -// For instance, when copying a JPEG image from Microsoft Word, there may be several formats -// on the clipboard: Jpeg, Png, Svg. -// If we use `GetPriorityClipboardFormat`, it will return Svg, which is not what we want. -fn with_best_match_format(f: F) -> Option -where - F: Fn(u32) -> Option, -{ - let mut text = None; - let mut image = None; - let mut files = None; let count = unsafe { CountClipboardFormats() }; - let mut clipboard_format = 0; + let mut format = 0; for _ in 0..count { - clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) }; - let Some(item_format) = FORMATS_MAP.get(&clipboard_format) else { - continue; - }; - let bucket = match item_format { - ClipboardFormatType::Text if text.is_none() => &mut text, - ClipboardFormatType::Image if image.is_none() => &mut image, - ClipboardFormatType::Files if files.is_none() => &mut files, - _ => continue, - }; - if let Some(entry) = f(clipboard_format) { - *bucket = Some(entry); + format = unsafe { EnumClipboardFormats(format) }; + + if !have_text && format == CF_UNICODETEXT.0 as u32 { + if let Some(entry) = read_string() { + entries.push(entry); + have_text = true; + } + } else if !have_image && is_image_format(format) { + if let Some(entry) = read_image(format) { + entries.push(entry); + have_image = true; + } + } else if !have_files && format == CF_HDROP.0 as u32 { + if let Some(entry) = read_files() { + entries.push(entry); + have_files = true; + } } } - if let Some(entry) = [image, files, text].into_iter().flatten().next() { - return Some(ClipboardItem { - entries: vec![entry], - }); - } - - // log the formats that we don't support yet. - { - clipboard_format = 0; - for _ in 0..count { - clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) }; - let mut buffer = [0u16; 64]; - unsafe { GetClipboardFormatNameW(clipboard_format, &mut buffer) }; - let format_name = String::from_utf16_lossy(&buffer); - log::warn!( - "Try to paste with unsupported clipboard format: {}, {}.", - clipboard_format, - format_name - ); - } - } - None -} - -fn read_string_from_clipboard() -> Option { - let text = with_clipboard_data(CF_UNICODETEXT.0 as u32, |data_ptr, _| { - let pcwstr = PCWSTR(data_ptr as *const u16); - String::from_utf16_lossy(unsafe { pcwstr.as_wide() }) - })?; - let Some(hash) = read_hash_from_clipboard() else { - return Some(ClipboardEntry::String(ClipboardString::new(text))); - }; - let Some(metadata) = read_metadata_from_clipboard() else { - return Some(ClipboardEntry::String(ClipboardString::new(text))); - }; - if hash == ClipboardString::text_hash(&text) { - Some(ClipboardEntry::String(ClipboardString { - text, - metadata: Some(metadata), - })) - } else { - Some(ClipboardEntry::String(ClipboardString::new(text))) - } -} - -fn read_hash_from_clipboard() -> Option { - if unsafe { IsClipboardFormatAvailable(*CLIPBOARD_HASH_FORMAT).is_err() } { + if entries.is_empty() { + log_unsupported_clipboard_formats(); return None; } - with_clipboard_data(*CLIPBOARD_HASH_FORMAT, |data_ptr, size| { - if size < 8 { - return None; + Some(ClipboardItem { entries }) +} + +pub(crate) fn with_file_names(hdrop: HDROP, mut f: F) +where + F: FnMut(String), +{ + let file_count = unsafe { DragQueryFileW(hdrop, DRAGDROP_GET_FILES_COUNT, None) }; + for file_index in 0..file_count { + let filename_length = unsafe { DragQueryFileW(hdrop, file_index, None) } as usize; + let mut buffer = vec![0u16; filename_length + 1]; + let ret = unsafe { DragQueryFileW(hdrop, file_index, Some(buffer.as_mut_slice())) }; + if ret == 0 { + log::error!("unable to read file name of dragged file"); + continue; } - let hash_bytes: [u8; 8] = unsafe { - std::slice::from_raw_parts(data_ptr.cast::(), 8) - .try_into() - .ok() - }?; - Some(u64::from_ne_bytes(hash_bytes)) - })? + match String::from_utf16(&buffer[0..filename_length]) { + Ok(file_name) => f(file_name), + Err(e) => log::error!("dragged file name is not UTF-16: {}", e), + } + } } -fn read_metadata_from_clipboard() -> Option { - unsafe { IsClipboardFormatAvailable(*CLIPBOARD_METADATA_FORMAT).ok()? }; - with_clipboard_data(*CLIPBOARD_METADATA_FORMAT, |data_ptr, _size| { - let pcwstr = PCWSTR(data_ptr as *const u16); - String::from_utf16_lossy(unsafe { pcwstr.as_wide() }) - }) +fn set_clipboard_bytes(data: &[T], format: u32) -> Result<()> { + unsafe { + let global = Owned::new(GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of_val(data))?); + let ptr = GlobalLock(*global); + anyhow::ensure!(!ptr.is_null(), "GlobalLock returned null"); + std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as _, data.len()); + GlobalUnlock(*global).ok(); + SetClipboardData(format, Some(HANDLE(global.0)))?; + // SetClipboardData succeeded — the system now owns the memory. + std::mem::forget(global); + } + Ok(()) } -fn read_image_from_clipboard(format: u32) -> Option { - let image_format = format_number_to_image_format(format)?; - read_image_for_type(format, *image_format) +fn get_clipboard_string(format: u32) -> Option { + let locked = get_clipboard_data(format)?; + let bytes = locked.as_bytes(); + let words_len = bytes.len() / std::mem::size_of::(); + if words_len == 0 { + return Some(String::new()); + } + let slice = unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const u16, words_len) }; + let actual_len = slice.iter().position(|&c| c == 0).unwrap_or(words_len); + Some(String::from_utf16_lossy(&slice[..actual_len])) } -#[inline] -fn format_number_to_image_format(format_number: u32) -> Option<&'static ImageFormat> { - IMAGE_FORMATS_MAP.get(&format_number) +fn is_image_format(format: u32) -> bool { + IMAGE_FORMATS_MAP.contains_key(&format) || format == CF_DIB.0 as u32 } -fn read_image_for_type(format_number: u32, format: ImageFormat) -> Option { - let (bytes, id) = with_clipboard_data(format_number, |data_ptr, size| { - let bytes = unsafe { std::slice::from_raw_parts(data_ptr as *mut u8 as _, size).to_vec() }; - let id = hash(&bytes); - (bytes, id) - })?; - Some(ClipboardEntry::Image(Image { format, bytes, id })) +fn write_string(item: &ClipboardString) -> Result<()> { + let wide: Vec = item.text.encode_utf16().chain(Some(0)).collect_vec(); + set_clipboard_bytes(&wide, CF_UNICODETEXT.0 as u32)?; + + if let Some(metadata) = item.metadata.as_ref() { + let hash_bytes = ClipboardString::text_hash(&item.text).to_ne_bytes(); + set_clipboard_bytes(&hash_bytes, *CLIPBOARD_HASH_FORMAT)?; + + let wide: Vec = metadata.encode_utf16().chain(Some(0)).collect_vec(); + set_clipboard_bytes(&wide, *CLIPBOARD_METADATA_FORMAT)?; + } + Ok(()) } -fn read_files_from_clipboard() -> Option { - let filenames = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr, _size| { - let hdrop = HDROP(data_ptr); - let mut filenames = Vec::new(); - with_file_names(hdrop, |file_name| { - filenames.push(std::path::PathBuf::from(file_name)); - }); - filenames - })?; +fn write_image(item: &Image) -> Result<()> { + let native_format = match item.format { + ImageFormat::Svg => Some(*CLIPBOARD_SVG_FORMAT), + ImageFormat::Gif => Some(*CLIPBOARD_GIF_FORMAT), + ImageFormat::Png => Some(*CLIPBOARD_PNG_FORMAT), + ImageFormat::Jpeg => Some(*CLIPBOARD_JPG_FORMAT), + _ => None, + }; + if let Some(format) = native_format { + set_clipboard_bytes(item.bytes(), format)?; + } + + // Also provide a PNG copy for broad compatibility. + // SVG can't be rasterized by the image crate, so skip it. + if item.format != ImageFormat::Svg && native_format != Some(*CLIPBOARD_PNG_FORMAT) { + if let Some(png_bytes) = convert_to_png(item.bytes(), item.format) { + set_clipboard_bytes(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; + } + } + Ok(()) +} + +fn convert_to_png(bytes: &[u8], format: ImageFormat) -> Option> { + let img_format = gpui_to_image_format(format)?; + let image = image::load_from_memory_with_format(bytes, img_format) + .map_err(|e| log::warn!("Failed to decode image for PNG conversion: {e}")) + .ok()?; + let mut buf = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) + .map_err(|e| log::warn!("Failed to encode PNG: {e}")) + .ok()?; + Some(buf) +} + +fn read_string() -> Option { + let text = get_clipboard_string(CF_UNICODETEXT.0 as u32)?; + let metadata = read_clipboard_metadata(&text); + Some(ClipboardEntry::String(ClipboardString { text, metadata })) +} + +fn read_clipboard_metadata(text: &str) -> Option { + let locked = get_clipboard_data(*CLIPBOARD_HASH_FORMAT)?; + let hash_bytes: [u8; 8] = locked.as_bytes().get(..8)?.try_into().ok()?; + let hash = u64::from_ne_bytes(hash_bytes); + if hash != ClipboardString::text_hash(text) { + return None; + } + get_clipboard_string(*CLIPBOARD_METADATA_FORMAT) +} + +fn read_image(format: u32) -> Option { + let locked = get_clipboard_data(format)?; + let (bytes, image_format) = if format == CF_DIB.0 as u32 { + (convert_dib_to_bmp(locked.as_bytes())?, ImageFormat::Bmp) + } else { + let image_format = *IMAGE_FORMATS_MAP.get(&format)?; + (locked.as_bytes().to_vec(), image_format) + }; + let id = hash(&bytes); + Some(ClipboardEntry::Image(Image { + format: image_format, + bytes, + id, + })) +} + +fn read_files() -> Option { + let locked = get_clipboard_data(CF_HDROP.0 as u32)?; + let hdrop = HDROP(locked.ptr as *mut _); + let mut filenames = Vec::new(); + with_file_names(hdrop, |name| filenames.push(std::path::PathBuf::from(name))); Some(ClipboardEntry::ExternalPaths(ExternalPaths( filenames.into(), ))) } -fn with_clipboard_data(format: u32, f: F) -> Option -where - F: FnOnce(*mut std::ffi::c_void, usize) -> R, -{ - let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0); - let size = unsafe { GlobalSize(global) }; - let data_ptr = unsafe { GlobalLock(global) }; - let result = f(data_ptr, size); - unsafe { GlobalUnlock(global).ok() }; - Some(result) +/// DIB is BMP without the 14-byte BITMAPFILEHEADER. Prepend one. +fn convert_dib_to_bmp(dib: &[u8]) -> Option> { + if dib.len() < 40 { + return None; + } + + let header_size = u32::from_le_bytes(dib[0..4].try_into().ok()?); + let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?); + let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?); + + let color_table_size = if bit_count <= 8 { + let colors_used = u32::from_le_bytes(dib[32..36].try_into().ok()?); + (if colors_used == 0 { + 1u32 << bit_count + } else { + colors_used + }) * 4 + } else if compression == 3 { + 12 // BI_BITFIELDS + } else { + 0 + }; + + let pixel_offset = 14 + header_size + color_table_size; + let file_size = 14 + dib.len() as u32; + + let mut bmp = Vec::with_capacity(file_size as usize); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&file_size.to_le_bytes()); + bmp.extend_from_slice(&[0u8; 4]); // reserved + bmp.extend_from_slice(&pixel_offset.to_le_bytes()); + bmp.extend_from_slice(dib); + Some(bmp) } -impl From for image::ImageFormat { - fn from(value: ImageFormat) -> Self { - match value { - ImageFormat::Png => image::ImageFormat::Png, - ImageFormat::Jpeg => image::ImageFormat::Jpeg, - ImageFormat::Webp => image::ImageFormat::WebP, - ImageFormat::Gif => image::ImageFormat::Gif, - // TODO: ImageFormat::Svg - ImageFormat::Bmp => image::ImageFormat::Bmp, - ImageFormat::Tiff => image::ImageFormat::Tiff, - _ => unreachable!(), +fn log_unsupported_clipboard_formats() { + let count = unsafe { CountClipboardFormats() }; + let mut format = 0; + for _ in 0..count { + format = unsafe { EnumClipboardFormats(format) }; + let mut buffer = [0u16; 64]; + unsafe { GetClipboardFormatNameW(format, &mut buffer) }; + let format_name = String::from_utf16_lossy(&buffer); + log::warn!( + "Try to paste with unsupported clipboard format: {}, {}.", + format, + format_name + ); + } +} + +fn gpui_to_image_format(value: ImageFormat) -> Option { + match value { + ImageFormat::Png => Some(image::ImageFormat::Png), + ImageFormat::Jpeg => Some(image::ImageFormat::Jpeg), + ImageFormat::Webp => Some(image::ImageFormat::WebP), + ImageFormat::Gif => Some(image::ImageFormat::Gif), + ImageFormat::Bmp => Some(image::ImageFormat::Bmp), + ImageFormat::Tiff => Some(image::ImageFormat::Tiff), + other => { + log::warn!("No image crate equivalent for format: {other:?}"); + None } } } + +struct ClipboardGuard; + +impl ClipboardGuard { + fn open() -> Option { + match unsafe { OpenClipboard(None) } { + Ok(()) => Some(Self), + Err(e) => { + log::error!("Failed to open clipboard: {e}"); + None + } + } + } +} + +impl Drop for ClipboardGuard { + fn drop(&mut self) { + if let Err(e) = unsafe { CloseClipboard() } { + log::error!("Failed to close clipboard: {e}"); + } + } +} + +struct LockedGlobal { + global: HGLOBAL, + ptr: *const u8, + size: usize, +} + +impl LockedGlobal { + fn lock(global: HGLOBAL) -> Option { + let size = unsafe { GlobalSize(global) }; + let ptr = unsafe { GlobalLock(global) }; + if ptr.is_null() { + return None; + } + Some(Self { + global, + ptr: ptr as *const u8, + size, + }) + } + + fn as_bytes(&self) -> &[u8] { + unsafe { std::slice::from_raw_parts(self.ptr, self.size) } + } +} + +impl Drop for LockedGlobal { + fn drop(&mut self) { + unsafe { GlobalUnlock(self.global).ok() }; + } +} diff --git a/src/platform/windows/destination_list.rs b/src/platform/windows/destination_list.rs index 1bfc97d935..d6967c01d2 100644 --- a/src/platform/windows/destination_list.rs +++ b/src/platform/windows/destination_list.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; use itertools::Itertools; use smallvec::SmallVec; @@ -20,25 +20,25 @@ use windows::{ core::{GUID, HSTRING, Interface}, }; -use crate::{Action, MenuItem}; +use gpui::{Action, MenuItem, SharedString}; pub(crate) struct JumpList { pub(crate) dock_menus: Vec, - pub(crate) recent_workspaces: Vec>, + pub(crate) recent_workspaces: Arc<[SmallVec<[PathBuf; 2]>]>, } impl JumpList { pub(crate) fn new() -> Self { Self { - dock_menus: Vec::new(), - recent_workspaces: Vec::new(), + dock_menus: Vec::default(), + recent_workspaces: Arc::default(), } } } pub(crate) struct DockMenuItem { - pub(crate) name: String, - pub(crate) description: String, + pub(crate) name: SharedString, + pub(crate) description: SharedString, pub(crate) action: Box, } @@ -46,11 +46,11 @@ impl DockMenuItem { pub(crate) fn new(item: MenuItem) -> anyhow::Result { match item { MenuItem::Action { name, action, .. } => Ok(Self { - name: name.clone().into(), + name: name.clone(), description: if name == "New Window" { - "Opens a new window".to_string() + "Opens a new window".into() } else { - name.into() + name }, action, }), @@ -62,11 +62,12 @@ impl DockMenuItem { // This code is based on the example from Microsoft: // https://github.com/microsoft/Windows-classic-samples/blob/main/Samples/Win7Samples/winui/shell/appshellintegration/RecipePropertyHandler/RecipePropertyHandler.cpp pub(crate) fn update_jump_list( - jump_list: &JumpList, + recent_workspaces: &[SmallVec<[PathBuf; 2]>], + dock_menus: &[(SharedString, SharedString)], ) -> anyhow::Result>> { let (list, removed) = create_destination_list()?; - add_recent_folders(&list, &jump_list.recent_workspaces, removed.as_ref())?; - add_dock_menu(&list, &jump_list.dock_menus)?; + add_recent_folders(&list, recent_workspaces, removed.as_ref())?; + add_dock_menu(&list, dock_menus)?; unsafe { list.CommitList() }?; Ok(removed) } @@ -110,14 +111,17 @@ fn create_destination_list() -> anyhow::Result<(ICustomDestinationList, Vec anyhow::Result<()> { +fn add_dock_menu( + list: &ICustomDestinationList, + dock_menus: &[(SharedString, SharedString)], +) -> anyhow::Result<()> { unsafe { let tasks: IObjectCollection = CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?; - for (idx, dock_menu) in dock_menus.iter().enumerate() { + for (idx, (name, description)) in dock_menus.iter().enumerate() { let argument = HSTRING::from(format!("--dock-action {}", idx)); - let description = HSTRING::from(dock_menu.description.as_str()); - let display = dock_menu.name.as_str(); + let description = HSTRING::from(description.as_str()); + let display = name.as_str(); let task = create_shell_link(argument, description, None, display)?; tasks.AddObject(&task)?; } diff --git a/src/platform/windows/direct_write.rs b/src/platform/windows/direct_write.rs index 22b8e6231a..5cdd01f70a 100644 --- a/src/platform/windows/direct_write.rs +++ b/src/platform/windows/direct_write.rs @@ -1,9 +1,12 @@ -use std::{borrow::Cow, sync::Arc}; +use std::{ + borrow::Cow, + ffi::{c_uint, c_void}, + mem::ManuallyDrop, +}; -use ::util::ResultExt; +use ::util::{ResultExt, maybe}; use anyhow::{Context, Result}; use collections::HashMap; -use itertools::Itertools; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use windows::{ Win32::{ @@ -21,82 +24,59 @@ use windows::{ use windows_numerics::Vector2; use crate::*; +use gpui::*; #[derive(Debug)] struct FontInfo { - font_family: String, + font_family_h: HSTRING, font_face: IDWriteFontFace3, features: IDWriteTypography, fallbacks: Option, - is_system_font: bool, + font_collection: IDWriteFontCollection1, } -pub(crate) struct DirectWriteTextSystem(RwLock); +pub(crate) struct DirectWriteTextSystem { + components: DirectWriteComponents, + state: RwLock, +} -struct DirectWriteComponent { - locale: String, +struct DirectWriteComponents { + locale: HSTRING, factory: IDWriteFactory5, in_memory_loader: IDWriteInMemoryFontFileLoader, builder: IDWriteFontSetBuilder1, - text_renderer: Arc, + text_renderer: TextRendererWrapper, + system_ui_font_name: SharedString, + system_subpixel_rendering: bool, +} - gpu_state: GPUState, +impl Drop for DirectWriteComponents { + fn drop(&mut self) { + unsafe { + let _ = self + .factory + .UnregisterFontFileLoader(&self.in_memory_loader); + } + } } struct GPUState { device: ID3D11Device, device_context: ID3D11DeviceContext, - sampler: [Option; 1], + sampler: Option, blend_state: ID3D11BlendState, vertex_shader: ID3D11VertexShader, pixel_shader: ID3D11PixelShader, } struct DirectWriteState { - components: DirectWriteComponent, - system_ui_font_name: SharedString, + gpu_state: GPUState, system_font_collection: IDWriteFontCollection1, custom_font_collection: IDWriteFontCollection1, fonts: Vec, - font_selections: HashMap, - font_id_by_identifier: HashMap, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -struct FontIdentifier { - postscript_name: String, - weight: i32, - style: i32, -} - -impl DirectWriteComponent { - pub fn new(directx_devices: &DirectXDevices) -> Result { - // todo: ideally this would not be a large unsafe block but smaller isolated ones for easier auditing - unsafe { - let factory: IDWriteFactory5 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; - // The `IDWriteInMemoryFontFileLoader` here is supported starting from - // Windows 10 Creators Update, which consequently requires the entire - // `DirectWriteTextSystem` to run on `win10 1703`+. - let in_memory_loader = factory.CreateInMemoryFontFileLoader()?; - factory.RegisterFontFileLoader(&in_memory_loader)?; - let builder = factory.CreateFontSetBuilder()?; - let mut locale_vec = vec![0u16; LOCALE_NAME_MAX_LENGTH as usize]; - GetUserDefaultLocaleName(&mut locale_vec); - let locale = String::from_utf16_lossy(&locale_vec); - let text_renderer = Arc::new(TextRendererWrapper::new(&locale)); - - let gpu_state = GPUState::new(directx_devices)?; - - Ok(DirectWriteComponent { - locale, - factory, - in_memory_loader, - builder, - text_renderer, - gpu_state, - }) - } - } + font_to_font_id: HashMap, + font_info_cache: HashMap, + layout_line_scratch: Vec, } impl GPUState { @@ -148,7 +128,7 @@ impl GPUState { MaxLOD: 0.0, }; unsafe { device.CreateSamplerState(&desc, Some(&mut sampler)) }?; - [sampler] + sampler }; let vertex_shader = { @@ -184,13 +164,38 @@ impl GPUState { impl DirectWriteTextSystem { pub(crate) fn new(directx_devices: &DirectXDevices) -> Result { - let components = DirectWriteComponent::new(directx_devices)?; + let factory: IDWriteFactory5 = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)? }; + // The `IDWriteInMemoryFontFileLoader` here is supported starting from + // Windows 10 Creators Update, which consequently requires the entire + // `DirectWriteTextSystem` to run on `win10 1703`+. + let in_memory_loader = unsafe { factory.CreateInMemoryFontFileLoader()? }; + unsafe { factory.RegisterFontFileLoader(&in_memory_loader)? }; + let builder = unsafe { factory.CreateFontSetBuilder()? }; + let mut locale = [0u16; LOCALE_NAME_MAX_LENGTH as usize]; + unsafe { GetUserDefaultLocaleName(&mut locale) }; + let locale = HSTRING::from_wide(&locale); + let text_renderer = TextRendererWrapper::new(locale.clone()); + + let gpu_state = GPUState::new(directx_devices)?; + + let system_subpixel_rendering = get_system_subpixel_rendering(); + let system_ui_font_name = get_system_ui_font_name(); + let components = DirectWriteComponents { + locale, + factory, + in_memory_loader, + builder, + text_renderer, + system_ui_font_name, + system_subpixel_rendering, + }; + let system_font_collection = unsafe { - let mut result = std::mem::zeroed(); + let mut result = None; components .factory .GetSystemFontCollection(false, &mut result, true)?; - result.unwrap() + result.context("Failed to get system font collection")? }; let custom_font_set = unsafe { components.builder.CreateFontSet()? }; let custom_font_collection = unsafe { @@ -198,68 +203,67 @@ impl DirectWriteTextSystem { .factory .CreateFontCollectionFromFontSet(&custom_font_set)? }; - let system_ui_font_name = get_system_ui_font_name(); - Ok(Self(RwLock::new(DirectWriteState { + Ok(Self { components, - system_ui_font_name, - system_font_collection, - custom_font_collection, - fonts: Vec::new(), - font_selections: HashMap::default(), - font_id_by_identifier: HashMap::default(), - }))) + state: RwLock::new(DirectWriteState { + gpu_state, + system_font_collection, + custom_font_collection, + fonts: Vec::new(), + font_to_font_id: HashMap::default(), + font_info_cache: HashMap::default(), + layout_line_scratch: Vec::new(), + }), + }) } pub(crate) fn handle_gpu_lost(&self, directx_devices: &DirectXDevices) -> Result<()> { - self.0.write().handle_gpu_lost(directx_devices) + self.state.write().handle_gpu_lost(directx_devices) } } impl PlatformTextSystem for DirectWriteTextSystem { fn add_fonts(&self, fonts: Vec>) -> Result<()> { - self.0.write().add_fonts(fonts) + self.state.write().add_fonts(&self.components, fonts) } fn all_font_names(&self) -> Vec { - self.0.read().all_font_names() + self.state.read().all_font_names(&self.components) } fn font_id(&self, font: &Font) -> Result { - let lock = self.0.upgradable_read(); - if let Some(font_id) = lock.font_selections.get(font) { + let lock = self.state.upgradable_read(); + if let Some(font_id) = lock.font_to_font_id.get(font) { Ok(*font_id) } else { - let mut lock = RwLockUpgradableReadGuard::upgrade(lock); - let font_id = lock - .select_font(font) - .with_context(|| format!("Failed to select font: {:?}", font))?; - lock.font_selections.insert(font.clone(), font_id); - Ok(font_id) + RwLockUpgradableReadGuard::upgrade(lock) + .select_and_cache_font(&self.components, font) + .with_context(|| format!("Failed to select font: {:?}", font)) } } fn font_metrics(&self, font_id: FontId) -> FontMetrics { - self.0.read().font_metrics(font_id) + self.state.read().font_metrics(font_id) } fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { - self.0.read().get_typographic_bounds(font_id, glyph_id) + self.state.read().get_typographic_bounds(font_id, glyph_id) } fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> anyhow::Result> { - self.0.read().get_advance(font_id, glyph_id) + self.state.read().get_advance(font_id, glyph_id) } fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { - self.0.read().glyph_for_char(font_id, ch) + self.state.read().glyph_for_char(font_id, ch) } fn glyph_raster_bounds( &self, params: &RenderGlyphParams, ) -> anyhow::Result> { - self.0.read().raster_bounds(params) + self.state.read().raster_bounds(&self.components, params) } fn rasterize_glyph( @@ -267,76 +271,138 @@ impl PlatformTextSystem for DirectWriteTextSystem { params: &RenderGlyphParams, raster_bounds: Bounds, ) -> anyhow::Result<(Size, Vec)> { - self.0.read().rasterize_glyph(params, raster_bounds) + self.state + .read() + .rasterize_glyph(&self.components, params, raster_bounds) } fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { - self.0 + self.state .write() - .layout_line(text, font_size, runs) + .layout_line(&self.components, text, font_size, runs) .log_err() .unwrap_or(LineLayout { font_size, ..Default::default() }) } + + fn recommended_rendering_mode( + &self, + _font_id: FontId, + _font_size: Pixels, + ) -> TextRenderingMode { + if self.components.system_subpixel_rendering { + TextRenderingMode::Subpixel + } else { + TextRenderingMode::Grayscale + } + } } impl DirectWriteState { - fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { + fn select_and_cache_font( + &mut self, + components: &DirectWriteComponents, + font: &Font, + ) -> Option { + let select_font = |this: &mut DirectWriteState, font: &Font| -> Option { + let info = [&this.custom_font_collection, &this.system_font_collection] + .into_iter() + .find_map(|font_collection| unsafe { + DirectWriteState::make_font_from_font_collection( + font, + font_collection, + &components.factory, + &this.system_font_collection, + &components.system_ui_font_name, + ) + })?; + + let font_id = FontId(this.fonts.len()); + let font_face_key = info.font_face.cast::().unwrap().as_raw().addr(); + this.fonts.push(info); + this.font_info_cache.insert(font_face_key, font_id); + Some(font_id) + }; + + let mut font_id = select_font(self, font); + if font_id.is_none() { + // try updating system fonts and reselect + let mut collection = None; + let font_collection_updated = unsafe { + components + .factory + .GetSystemFontCollection(false, &mut collection, true) + } + .log_err() + .is_some(); + if font_collection_updated && let Some(collection) = collection { + self.system_font_collection = collection; + } + font_id = select_font(self, font); + }; + let font_id = font_id?; + self.font_to_font_id.insert(font.clone(), font_id); + Some(font_id) + } + + fn add_fonts( + &mut self, + components: &DirectWriteComponents, + fonts: Vec>, + ) -> Result<()> { for font_data in fonts { match font_data { Cow::Borrowed(data) => unsafe { - let font_file = self - .components + let font_file = components .in_memory_loader .CreateInMemoryFontFileReference( - &self.components.factory, - data.as_ptr() as _, + &components.factory, + data.as_ptr().cast(), data.len() as _, None, )?; - self.components.builder.AddFontFile(&font_file)?; + components.builder.AddFontFile(&font_file)?; }, Cow::Owned(data) => unsafe { - let font_file = self - .components + let font_file = components .in_memory_loader .CreateInMemoryFontFileReference( - &self.components.factory, - data.as_ptr() as _, + &components.factory, + data.as_ptr().cast(), data.len() as _, None, )?; - self.components.builder.AddFontFile(&font_file)?; + components.builder.AddFontFile(&font_file)?; }, } } - let set = unsafe { self.components.builder.CreateFontSet()? }; - let collection = unsafe { - self.components - .factory - .CreateFontCollectionFromFontSet(&set)? - }; + let set = unsafe { components.builder.CreateFontSet()? }; + let collection = unsafe { components.factory.CreateFontCollectionFromFontSet(&set)? }; self.custom_font_collection = collection; Ok(()) } fn generate_font_fallbacks( - &self, fallbacks: &FontFallbacks, + factory: &IDWriteFactory5, + system_font_collection: &IDWriteFontCollection1, ) -> Result> { - if fallbacks.fallback_list().is_empty() { + let fallback_list = fallbacks.fallback_list(); + if fallback_list.is_empty() { return Ok(None); } unsafe { - let builder = self.components.factory.CreateFontFallbackBuilder()?; - let font_set = &self.system_font_collection.GetFontSet()?; - for family_name in fallbacks.fallback_list() { + let builder = factory.CreateFontFallbackBuilder()?; + let font_set = &system_font_collection.GetFontSet()?; + let mut unicode_ranges = Vec::new(); + for family_name in fallback_list { + let family_name = HSTRING::from(family_name); let Some(fonts) = font_set .GetMatchingFonts( - &HSTRING::from(family_name), + &family_name, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, @@ -345,206 +411,109 @@ impl DirectWriteState { else { continue; }; - if fonts.GetFontCount() == 0 { - log::error!("No matching font found for {}", family_name); + let Ok(font_face) = fonts.GetFontFaceReference(0) else { continue; - } - let font = fonts.GetFontFaceReference(0)?.CreateFontFace()?; + }; + let font = font_face.CreateFontFace()?; let mut count = 0; font.GetUnicodeRanges(None, &mut count).ok(); if count == 0 { continue; } - let mut unicode_ranges = vec![DWRITE_UNICODE_RANGE::default(); count as usize]; + unicode_ranges.clear(); + unicode_ranges.resize_with(count as usize, DWRITE_UNICODE_RANGE::default); let Some(_) = font .GetUnicodeRanges(Some(&mut unicode_ranges), &mut count) .log_err() else { continue; }; - let target_family_name = HSTRING::from(family_name); builder.AddMapping( &unicode_ranges, - &[target_family_name.as_ptr()], + &[family_name.as_ptr()], None, None, None, 1.0, )?; } - let system_fallbacks = self.components.factory.GetSystemFontFallback()?; + let system_fallbacks = factory.GetSystemFontFallback()?; builder.AddMappings(&system_fallbacks)?; Ok(Some(builder.CreateFontFallback()?)) } } unsafe fn generate_font_features( - &self, + factory: &IDWriteFactory5, font_features: &FontFeatures, ) -> Result { - let direct_write_features = unsafe { self.components.factory.CreateTypography()? }; + let direct_write_features = unsafe { factory.CreateTypography()? }; apply_font_features(&direct_write_features, font_features)?; Ok(direct_write_features) } - unsafe fn get_font_id_from_font_collection( - &mut self, - family_name: &str, - font_weight: FontWeight, - font_style: FontStyle, - font_features: &FontFeatures, - font_fallbacks: Option<&FontFallbacks>, - is_system_font: bool, - ) -> Option { - let collection = if is_system_font { - &self.system_font_collection + unsafe fn make_font_from_font_collection( + &Font { + ref family, + ref features, + ref fallbacks, + weight, + style, + }: &Font, + collection: &IDWriteFontCollection1, + factory: &IDWriteFactory5, + system_font_collection: &IDWriteFontCollection1, + system_ui_font_name: &SharedString, + ) -> Option { + const SYSTEM_UI_FONT_NAME: &str = ".SystemUIFont"; + let family = if family == SYSTEM_UI_FONT_NAME { + system_ui_font_name } else { - &self.custom_font_collection + gpui::font_name_with_fallbacks_shared(&family, &system_ui_font_name) }; let fontset = unsafe { collection.GetFontSet().log_err()? }; + let font_family_h = HSTRING::from(family.as_str()); let font = unsafe { fontset .GetMatchingFonts( - &HSTRING::from(family_name), - font_weight.into(), + &font_family_h, + font_weight_to_dwrite(weight), DWRITE_FONT_STRETCH_NORMAL, - font_style.into(), + font_style_to_dwrite(style), ) .log_err()? }; let total_number = unsafe { font.GetFontCount() }; for index in 0..total_number { - let Some(font_face_ref) = (unsafe { font.GetFontFaceReference(index).log_err() }) - else { - continue; - }; - let Some(font_face) = (unsafe { font_face_ref.CreateFontFace().log_err() }) else { - continue; - }; - let Some(identifier) = get_font_identifier(&font_face, &self.components.locale) else { - continue; - }; - let Some(direct_write_features) = - (unsafe { self.generate_font_features(font_features).log_err() }) - else { - continue; - }; - let fallbacks = font_fallbacks - .and_then(|fallbacks| self.generate_font_fallbacks(fallbacks).log_err().flatten()); - let font_info = FontInfo { - font_family: family_name.to_owned(), - font_face, - features: direct_write_features, - fallbacks, - is_system_font, - }; - let font_id = FontId(self.fonts.len()); - self.fonts.push(font_info); - self.font_id_by_identifier.insert(identifier, font_id); - return Some(font_id); + let res = maybe!({ + let font_face_ref = unsafe { font.GetFontFaceReference(index).log_err()? }; + let font_face = unsafe { font_face_ref.CreateFontFace().log_err()? }; + let direct_write_features = + unsafe { Self::generate_font_features(factory, features).log_err()? }; + let fallbacks = fallbacks.as_ref().and_then(|fallbacks| { + Self::generate_font_fallbacks(fallbacks, factory, system_font_collection) + .log_err() + .flatten() + }); + let font_info = FontInfo { + font_family_h: font_family_h.clone(), + font_face, + features: direct_write_features, + fallbacks, + font_collection: collection.clone(), + }; + Some(font_info) + }); + if res.is_some() { + return res; + } } None } - unsafe fn update_system_font_collection(&mut self) { - let mut collection = unsafe { std::mem::zeroed() }; - if unsafe { - self.components - .factory - .GetSystemFontCollection(false, &mut collection, true) - .log_err() - .is_some() - } { - self.system_font_collection = collection.unwrap(); - } - } - - fn select_font(&mut self, target_font: &Font) -> Option { - unsafe { - if target_font.family == ".SystemUIFont" { - let family = self.system_ui_font_name.clone(); - self.find_font_id( - family.as_ref(), - target_font.weight, - target_font.style, - &target_font.features, - target_font.fallbacks.as_ref(), - ) - } else { - let family = self.system_ui_font_name.clone(); - self.find_font_id( - font_name_with_fallbacks(target_font.family.as_ref(), family.as_ref()), - target_font.weight, - target_font.style, - &target_font.features, - target_font.fallbacks.as_ref(), - ) - .or_else(|| { - #[cfg(any(test, feature = "test-support"))] - { - panic!("ERROR: {} font not found!", target_font.family); - } - #[cfg(not(any(test, feature = "test-support")))] - { - log::error!("{} not found, use {} instead.", target_font.family, family); - self.get_font_id_from_font_collection( - family.as_ref(), - target_font.weight, - target_font.style, - &target_font.features, - target_font.fallbacks.as_ref(), - true, - ) - } - }) - } - } - } - - unsafe fn find_font_id( - &mut self, - family_name: &str, - weight: FontWeight, - style: FontStyle, - features: &FontFeatures, - fallbacks: Option<&FontFallbacks>, - ) -> Option { - // try to find target font in custom font collection first - unsafe { - self.get_font_id_from_font_collection( - family_name, - weight, - style, - features, - fallbacks, - false, - ) - .or_else(|| { - self.get_font_id_from_font_collection( - family_name, - weight, - style, - features, - fallbacks, - true, - ) - }) - .or_else(|| { - self.update_system_font_collection(); - self.get_font_id_from_font_collection( - family_name, - weight, - style, - features, - fallbacks, - true, - ) - }) - } - } - fn layout_line( &mut self, + components: &DirectWriteComponents, text: &str, font_size: Pixels, font_runs: &[FontRun], @@ -556,38 +525,34 @@ impl DirectWriteState { }); } unsafe { - let text_renderer = self.components.text_renderer.clone(); - let text_wide = text.encode_utf16().collect_vec(); + self.layout_line_scratch.clear(); + self.layout_line_scratch.extend(text.encode_utf16()); + let text_wide = &*self.layout_line_scratch; let mut utf8_offset = 0usize; let mut utf16_offset = 0u32; let text_layout = { let first_run = &font_runs[0]; let font_info = &self.fonts[first_run.font_id.0]; - let collection = if font_info.is_system_font { - &self.system_font_collection - } else { - &self.custom_font_collection - }; - let format: IDWriteTextFormat1 = self - .components + let collection = &font_info.font_collection; + let format: IDWriteTextFormat1 = components .factory .CreateTextFormat( - &HSTRING::from(&font_info.font_family), + &font_info.font_family_h, collection, font_info.font_face.GetWeight(), font_info.font_face.GetStyle(), DWRITE_FONT_STRETCH_NORMAL, - font_size.0, - &HSTRING::from(&self.components.locale), + font_size.as_f32(), + &components.locale, )? .cast()?; if let Some(ref fallbacks) = font_info.fallbacks { format.SetFontFallback(fallbacks)?; } - let layout = self.components.factory.CreateTextLayout( - &text_wide, + let layout = components.factory.CreateTextLayout( + text_wide, &format, f32::INFINITY, f32::INFINITY, @@ -605,43 +570,34 @@ impl DirectWriteState { layout }; - let mut first_run = true; - let mut ascent = Pixels::default(); - let mut descent = Pixels::default(); - let mut break_ligatures = false; - for run in font_runs { - if first_run { - first_run = false; - let mut metrics = vec![DWRITE_LINE_METRICS::default(); 4]; - let mut line_count = 0u32; - text_layout.GetLineMetrics(Some(&mut metrics), &mut line_count as _)?; - ascent = px(metrics[0].baseline); - descent = px(metrics[0].height - metrics[0].baseline); - break_ligatures = !break_ligatures; - continue; - } + let (ascent, descent) = { + let mut first_metrics = [DWRITE_LINE_METRICS::default(); 4]; + let mut line_count = 0u32; + text_layout.GetLineMetrics(Some(&mut first_metrics), &mut line_count)?; + ( + px(first_metrics[0].baseline), + px(first_metrics[0].height - first_metrics[0].baseline), + ) + }; + let mut break_ligatures = true; + for run in &font_runs[1..] { let font_info = &self.fonts[run.font_id.0]; let current_text = &text[utf8_offset..(utf8_offset + run.len)]; utf8_offset += run.len; let current_text_utf16_length = current_text.encode_utf16().count() as u32; - let collection = if font_info.is_system_font { - &self.system_font_collection - } else { - &self.custom_font_collection - }; + let collection = &font_info.font_collection; let text_range = DWRITE_TEXT_RANGE { startPosition: utf16_offset, length: current_text_utf16_length, }; utf16_offset += current_text_utf16_length; text_layout.SetFontCollection(collection, text_range)?; - text_layout - .SetFontFamilyName(&HSTRING::from(&font_info.font_family), text_range)?; + text_layout.SetFontFamilyName(&font_info.font_family_h, text_range)?; let font_size = if break_ligatures { - font_size.0.next_up() + font_size.as_f32().next_up() } else { - font_size.0 + font_size.as_f32() }; text_layout.SetFontSize(font_size, text_range)?; text_layout.SetFontStyle(font_info.font_face.GetStyle(), text_range)?; @@ -654,13 +610,14 @@ impl DirectWriteState { let mut runs = Vec::new(); let renderer_context = RendererContext { text_system: self, + components, index_converter: StringIndexConverter::new(text), runs: &mut runs, width: 0.0, }; text_layout.Draw( - Some(&renderer_context as *const _ as _), - &text_renderer.0, + Some((&raw const renderer_context).cast::()), + &components.text_renderer.0, 0.0, 0.0, )?; @@ -708,6 +665,7 @@ impl DirectWriteState { fn create_glyph_run_analysis( &self, + components: &DirectWriteComponents, params: &RenderGlyphParams, ) -> Result { let font = &self.fonts[params.font_id.0]; @@ -715,8 +673,8 @@ impl DirectWriteState { let advance = [0.0]; let offset = [DWRITE_GLYPH_OFFSET::default()]; let glyph_run = DWRITE_GLYPH_RUN { - fontFace: unsafe { std::mem::transmute_copy(&font.font_face) }, - fontEmSize: params.font_size.0, + fontFace: ManuallyDrop::new(Some(unsafe { std::ptr::read(&***font.font_face) })), + fontEmSize: params.font_size.as_f32(), glyphCount: 1, glyphIndices: glyph_id.as_ptr(), glyphAdvances: advance.as_ptr(), @@ -734,14 +692,15 @@ impl DirectWriteState { }; let baseline_origin_x = params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor; - let baseline_origin_y = - params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor; + let baseline_origin_y = params.subpixel_variant.y as f32 + / gpui::SUBPIXEL_VARIANTS_Y as f32 + / params.scale_factor; let mut rendering_mode = DWRITE_RENDERING_MODE1::default(); let mut grid_fit_mode = DWRITE_GRID_FIT_MODE::default(); unsafe { font.font_face.GetRecommendedRenderingMode( - params.font_size.0, + params.font_size.as_f32(), // Using 96 as scale is applied by the transform 96.0, 96.0, @@ -759,14 +718,20 @@ impl DirectWriteState { m => m, }; + let antialias_mode = if params.subpixel_rendering { + DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE + } else { + DWRITE_TEXT_ANTIALIAS_MODE_GRAYSCALE + }; + let glyph_analysis = unsafe { - self.components.factory.CreateGlyphRunAnalysis( + components.factory.CreateGlyphRunAnalysis( &glyph_run, Some(&transform), rendering_mode, DWRITE_MEASURING_MODE_NATURAL, grid_fit_mode, - DWRITE_TEXT_ANTIALIAS_MODE_GRAYSCALE, + antialias_mode, baseline_origin_x, baseline_origin_y, ) @@ -774,10 +739,20 @@ impl DirectWriteState { Ok(glyph_analysis) } - fn raster_bounds(&self, params: &RenderGlyphParams) -> Result> { - let glyph_analysis = self.create_glyph_run_analysis(params)?; + fn raster_bounds( + &self, + components: &DirectWriteComponents, + params: &RenderGlyphParams, + ) -> Result> { + let glyph_analysis = self.create_glyph_run_analysis(components, params)?; - let bounds = unsafe { glyph_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_ALIASED_1x1)? }; + let texture_type = if params.subpixel_rendering { + DWRITE_TEXTURE_CLEARTYPE_3x1 + } else { + DWRITE_TEXTURE_ALIASED_1x1 + }; + + let bounds = unsafe { glyph_analysis.GetAlphaTextureBounds(texture_type)? }; if bounds.right < bounds.left { Ok(Bounds { @@ -797,19 +772,20 @@ impl DirectWriteState { fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { let font_info = &self.fonts[font_id.0]; - let codepoints = [ch as u32]; - let mut glyph_indices = vec![0u16; 1]; + let codepoints = ch as u32; + let mut glyph_indices = 0u16; unsafe { font_info .font_face - .GetGlyphIndices(codepoints.as_ptr(), 1, glyph_indices.as_mut_ptr()) + .GetGlyphIndices(&raw const codepoints, 1, &raw mut glyph_indices) .log_err() } - .map(|_| GlyphId(glyph_indices[0] as u32)) + .map(|_| GlyphId(glyph_indices as u32)) } fn rasterize_glyph( &self, + components: &DirectWriteComponents, params: &RenderGlyphParams, glyph_bounds: Bounds, ) -> Result<(Size, Vec)> { @@ -818,17 +794,17 @@ impl DirectWriteState { } let bitmap_data = if params.is_emoji { - if let Ok(color) = self.rasterize_color(params, glyph_bounds) { + if let Ok(color) = self.rasterize_color(components, params, glyph_bounds) { color } else { - let monochrome = self.rasterize_monochrome(params, glyph_bounds)?; + let monochrome = self.rasterize_monochrome(components, params, glyph_bounds)?; monochrome .into_iter() .flat_map(|pixel| [0, 0, 0, pixel]) .collect::>() } } else { - self.rasterize_monochrome(params, glyph_bounds)? + self.rasterize_monochrome(components, params, glyph_bounds)? }; Ok((glyph_bounds.size, bitmap_data)) @@ -836,31 +812,72 @@ impl DirectWriteState { fn rasterize_monochrome( &self, + components: &DirectWriteComponents, params: &RenderGlyphParams, glyph_bounds: Bounds, ) -> Result> { - let mut bitmap_data = - vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize]; + let glyph_analysis = self.create_glyph_run_analysis(components, params)?; + if !params.subpixel_rendering { + let mut bitmap_data = + vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize]; + unsafe { + glyph_analysis.CreateAlphaTexture( + DWRITE_TEXTURE_ALIASED_1x1, + &RECT { + left: glyph_bounds.origin.x.0, + top: glyph_bounds.origin.y.0, + right: glyph_bounds.size.width.0 + glyph_bounds.origin.x.0, + bottom: glyph_bounds.size.height.0 + glyph_bounds.origin.y.0, + }, + &mut bitmap_data, + )?; + } + + return Ok(bitmap_data); + } + + let width = glyph_bounds.size.width.0 as usize; + let height = glyph_bounds.size.height.0 as usize; + let pixel_count = width * height; + + let mut bitmap_data = vec![0u8; pixel_count * 4]; - let glyph_analysis = self.create_glyph_run_analysis(params)?; unsafe { glyph_analysis.CreateAlphaTexture( - DWRITE_TEXTURE_ALIASED_1x1, + DWRITE_TEXTURE_CLEARTYPE_3x1, &RECT { left: glyph_bounds.origin.x.0, top: glyph_bounds.origin.y.0, right: glyph_bounds.size.width.0 + glyph_bounds.origin.x.0, bottom: glyph_bounds.size.height.0 + glyph_bounds.origin.y.0, }, - &mut bitmap_data, + &mut bitmap_data[..pixel_count * 3], )?; } + // The output buffer expects RGBA data, so pad the alpha channel with zeros. + for pixel_ix in (0..pixel_count).rev() { + let src = pixel_ix * 3; + let dst = pixel_ix * 4; + ( + bitmap_data[dst], + bitmap_data[dst + 1], + bitmap_data[dst + 2], + bitmap_data[dst + 3], + ) = ( + bitmap_data[src], + bitmap_data[src + 1], + bitmap_data[src + 2], + 0, + ); + } + Ok(bitmap_data) } fn rasterize_color( &self, + components: &DirectWriteComponents, params: &RenderGlyphParams, glyph_bounds: Bounds, ) -> Result> { @@ -888,8 +905,8 @@ impl DirectWriteState { ascenderOffset: glyph_bounds.origin.y.0 as f32 / params.scale_factor, }]; let glyph_run = DWRITE_GLYPH_RUN { - fontFace: unsafe { std::mem::transmute_copy(&font.font_face) }, - fontEmSize: params.font_size.0, + fontFace: ManuallyDrop::new(Some(unsafe { std::ptr::read(&***font.font_face) })), + fontEmSize: params.font_size.as_f32(), glyphCount: 1, glyphIndices: glyph_id.as_ptr(), glyphAdvances: advance.as_ptr(), @@ -900,7 +917,7 @@ impl DirectWriteState { // todo: support formats other than COLR let color_enumerator = unsafe { - self.components.factory.TranslateColorGlyphRun( + components.factory.TranslateColorGlyphRun( Vector2::new(baseline_origin_x, baseline_origin_y), &glyph_run, None, @@ -912,13 +929,14 @@ impl DirectWriteState { }?; let mut glyph_layers = Vec::new(); + let mut alpha_data = Vec::new(); loop { let color_run = unsafe { color_enumerator.GetCurrentRun() }?; let color_run = unsafe { &*color_run }; let image_format = color_run.glyphImageFormat & !DWRITE_GLYPH_IMAGE_FORMATS_TRUETYPE; if image_format == DWRITE_GLYPH_IMAGE_FORMATS_COLR { let color_analysis = unsafe { - self.components.factory.CreateGlyphRunAnalysis( + components.factory.CreateGlyphRunAnalysis( &color_run.Base.glyphRun as *const _, Some(&transform), DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC, @@ -938,7 +956,8 @@ impl DirectWriteState { color_bounds.bottom - color_bounds.top, ); if color_size.width > 0 && color_size.height > 0 { - let mut alpha_data = vec![0u8; (color_size.width * color_size.height) as usize]; + alpha_data.clear(); + alpha_data.resize((color_size.width * color_size.height) as usize, 0); unsafe { color_analysis.CreateAlphaTexture( DWRITE_TEXTURE_ALIASED_1x1, @@ -958,7 +977,7 @@ impl DirectWriteState { }; let bounds = bounds(point(color_bounds.left, color_bounds.top), color_size); glyph_layers.push(GlyphLayerTexture::new( - &self.components.gpu_state, + &self.gpu_state, run_color, bounds, &alpha_data, @@ -974,7 +993,7 @@ impl DirectWriteState { } } - let gpu_state = &self.components.gpu_state; + let gpu_state = &self.gpu_state; let params_buffer = { let desc = D3D11_BUFFER_DESC { ByteWidth: std::mem::size_of::() as u32, @@ -991,7 +1010,7 @@ impl DirectWriteState { .device .CreateBuffer(&desc, None, Some(&mut buffer)) }?; - [buffer] + buffer }; let render_target_texture = { @@ -1035,7 +1054,7 @@ impl DirectWriteState { Some(&mut rtv), ) }?; - [rtv] + rtv }; let staging_texture = { @@ -1067,15 +1086,22 @@ impl DirectWriteState { unsafe { device_context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP) }; unsafe { device_context.VSSetShader(&gpu_state.vertex_shader, None) }; unsafe { device_context.PSSetShader(&gpu_state.pixel_shader, None) }; - unsafe { device_context.VSSetConstantBuffers(0, Some(¶ms_buffer)) }; - unsafe { device_context.PSSetConstantBuffers(0, Some(¶ms_buffer)) }; - unsafe { device_context.OMSetRenderTargets(Some(&render_target_view), None) }; - unsafe { device_context.PSSetSamplers(0, Some(&gpu_state.sampler)) }; + unsafe { + device_context.VSSetConstantBuffers(0, Some(std::slice::from_ref(¶ms_buffer))) + }; + unsafe { + device_context.PSSetConstantBuffers(0, Some(std::slice::from_ref(¶ms_buffer))) + }; + unsafe { + device_context.OMSetRenderTargets(Some(std::slice::from_ref(&render_target_view)), None) + }; + unsafe { device_context.PSSetSamplers(0, Some(std::slice::from_ref(&gpu_state.sampler))) }; unsafe { device_context.OMSetBlendState(&gpu_state.blend_state, None, 0xffffffff) }; let crate::FontInfo { gamma_ratios, grayscale_enhanced_contrast, + .. } = DirectXRenderer::get_font_info(); for layer in glyph_layers { @@ -1089,7 +1115,7 @@ impl DirectWriteState { unsafe { let mut dest = std::mem::zeroed(); gpu_state.device_context.Map( - params_buffer[0].as_ref().unwrap(), + params_buffer.as_ref().unwrap(), 0, D3D11_MAP_WRITE_DISCARD, 0, @@ -1098,7 +1124,7 @@ impl DirectWriteState { std::ptr::copy_nonoverlapping(¶ms as *const _, dest.pData as *mut _, 1); gpu_state .device_context - .Unmap(params_buffer[0].as_ref().unwrap(), 0); + .Unmap(params_buffer.as_ref().unwrap(), 0); }; let texture = [Some(layer.texture_view)]; @@ -1214,12 +1240,12 @@ impl DirectWriteState { } } - fn all_font_names(&self) -> Vec { + fn all_font_names(&self, components: &DirectWriteComponents) -> Vec { let mut result = - get_font_names_from_collection(&self.system_font_collection, &self.components.locale); + get_font_names_from_collection(&self.system_font_collection, &components.locale); result.extend(get_font_names_from_collection( &self.custom_font_collection, - &self.components.locale, + &components.locale, )); result } @@ -1228,18 +1254,7 @@ impl DirectWriteState { try_to_recover_from_device_lost(|| { GPUState::new(directx_devices).context("Recreating GPU state for DirectWrite") }) - .map(|gpu_state| self.components.gpu_state = gpu_state) - } -} - -impl Drop for DirectWriteState { - fn drop(&mut self) { - unsafe { - let _ = self - .components - .factory - .UnregisterFontFileLoader(&self.components.in_memory_loader); - } + .map(|gpu_state| self.gpu_state = gpu_state) } } @@ -1252,7 +1267,7 @@ struct GlyphLayerTexture { } impl GlyphLayerTexture { - pub fn new( + fn new( gpu_state: &GPUState, run_color: Rgba, bounds: Bounds, @@ -1324,10 +1339,10 @@ struct GlyphLayerTextureParams { _pad: [f32; 3], } -struct TextRendererWrapper(pub IDWriteTextRenderer); +struct TextRendererWrapper(IDWriteTextRenderer); impl TextRendererWrapper { - pub fn new(locale_str: &str) -> Self { + fn new(locale_str: HSTRING) -> Self { let inner = TextRenderer::new(locale_str); TextRendererWrapper(inner.into()) } @@ -1335,19 +1350,18 @@ impl TextRendererWrapper { #[implement(IDWriteTextRenderer)] struct TextRenderer { - locale: String, + locale: HSTRING, } impl TextRenderer { - pub fn new(locale_str: &str) -> Self { - TextRenderer { - locale: locale_str.to_owned(), - } + fn new(locale: HSTRING) -> Self { + TextRenderer { locale } } } struct RendererContext<'t, 'a, 'b> { text_system: &'t mut DirectWriteState, + components: &'a DirectWriteComponents, index_converter: StringIndexConverter<'a>, runs: &'b mut Vec, width: f32, @@ -1362,7 +1376,7 @@ struct ClusterAnalyzer<'t> { } impl<'t> ClusterAnalyzer<'t> { - pub fn new(cluster_map: &'t [u16], glyph_count: usize) -> Self { + fn new(cluster_map: &'t [u16], glyph_count: usize) -> Self { ClusterAnalyzer { utf16_idx: 0, glyph_idx: 0, @@ -1458,35 +1472,53 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { ) -> windows::core::Result<()> { let glyphrun = unsafe { &*glyphrun }; let glyph_count = glyphrun.glyphCount as usize; - if glyph_count == 0 || glyphrun.fontFace.is_none() { + if glyph_count == 0 { return Ok(()); } let desc = unsafe { &*glyphrundescription }; - let context = unsafe { - &mut *(clientdrawingcontext as *const RendererContext as *mut RendererContext) - }; - let font_face = glyphrun.fontFace.as_ref().unwrap(); - // This `cast()` action here should never fail since we are running on Win10+, and - // `IDWriteFontFace3` requires Win10 - let font_face = &font_face.cast::().unwrap(); - let Some((font_identifier, font_struct, color_font)) = - get_font_identifier_and_font_struct(font_face, &self.locale) - else { + let context = unsafe { &mut *(clientdrawingcontext.cast::().cast_mut()) }; + let Some(font_face) = glyphrun.fontFace.as_ref() else { return Ok(()); }; - - let font_id = if let Some(id) = context - .text_system - .font_id_by_identifier - .get(&font_identifier) - { - *id - } else if let Some(id) = context.text_system.select_font(&font_struct) { - id - } else { - return Err(Error::new(DWRITE_E_NOFONT, "Failed to select font")); + // This `cast()` action here should never fail since we are running on Win10+, and + // `IDWriteFontFace3` requires Win10 + let Ok(font_face) = &font_face.cast::() else { + return Err(Error::new( + DWRITE_E_UNSUPPORTEDOPERATION, + "Failed to cast font face", + )); }; + let font_face_key = font_face.cast::().unwrap().as_raw().addr(); + let font_id = context + .text_system + .font_info_cache + .get(&font_face_key) + .copied() + // in some circumstances, we might be getting served a FontFace that we did not create ourselves + // so create a new font from it and cache it accordingly. The usual culprit here seems to be Segoe UI Symbol + .map_or_else( + || { + let font = font_face_to_font(font_face, &self.locale) + .ok_or_else(|| Error::new(DWRITE_E_NOFONT, "Failed to create font"))?; + let font_id = match context.text_system.font_to_font_id.get(&font) { + Some(&font_id) => font_id, + None => context + .text_system + .select_and_cache_font(context.components, &font) + .ok_or_else(|| Error::new(DWRITE_E_NOFONT, "Failed to create font"))?, + }; + context + .text_system + .font_info_cache + .insert(font_face_key, font_id); + windows::core::Result::Ok(font_id) + }, + Ok, + )?; + + let color_font = unsafe { font_face.IsColorFont().as_bool() }; + let glyph_ids = unsafe { std::slice::from_raw_parts(glyphrun.glyphIndices, glyph_count) }; let glyph_advances = unsafe { std::slice::from_raw_parts(glyphrun.glyphAdvances, glyph_count) }; @@ -1495,7 +1527,7 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { let cluster_map = unsafe { std::slice::from_raw_parts(desc.clusterMap, desc.stringLength as usize) }; - let mut cluster_analyzer = ClusterAnalyzer::new(cluster_map, glyph_count); + let cluster_analyzer = ClusterAnalyzer::new(cluster_map, glyph_count); let mut utf16_idx = desc.textPosition as usize; let mut glyph_idx = 0; let mut glyphs = Vec::with_capacity(glyph_count); @@ -1508,8 +1540,8 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl { .enumerate() { let id = GlyphId(*glyph_id as u32); - let is_emoji = color_font - && is_color_glyph(font_face, id, &context.text_system.components.factory); + let is_emoji = + color_font && is_color_glyph(font_face, id, &context.components.factory); let this_glyph_idx = glyph_idx + cluster_glyph_idx; glyphs.push(ShapedGlyph { id, @@ -1612,42 +1644,34 @@ impl<'a> StringIndexConverter<'a> { } } -impl Into for FontStyle { - fn into(self) -> DWRITE_FONT_STYLE { - match self { - FontStyle::Normal => DWRITE_FONT_STYLE_NORMAL, - FontStyle::Italic => DWRITE_FONT_STYLE_ITALIC, - FontStyle::Oblique => DWRITE_FONT_STYLE_OBLIQUE, - } +fn font_style_to_dwrite(style: FontStyle) -> DWRITE_FONT_STYLE { + match style { + FontStyle::Normal => DWRITE_FONT_STYLE_NORMAL, + FontStyle::Italic => DWRITE_FONT_STYLE_ITALIC, + FontStyle::Oblique => DWRITE_FONT_STYLE_OBLIQUE, } } -impl From for FontStyle { - fn from(value: DWRITE_FONT_STYLE) -> Self { - match value.0 { - 0 => FontStyle::Normal, - 1 => FontStyle::Italic, - 2 => FontStyle::Oblique, - _ => unreachable!(), - } +fn font_style_from_dwrite(value: DWRITE_FONT_STYLE) -> FontStyle { + match value.0 { + 0 => FontStyle::Normal, + 1 => FontStyle::Italic, + 2 => FontStyle::Oblique, + _ => unreachable!(), } } -impl Into for FontWeight { - fn into(self) -> DWRITE_FONT_WEIGHT { - DWRITE_FONT_WEIGHT(self.0 as i32) - } +fn font_weight_to_dwrite(weight: FontWeight) -> DWRITE_FONT_WEIGHT { + DWRITE_FONT_WEIGHT(weight.0 as i32) } -impl From for FontWeight { - fn from(value: DWRITE_FONT_WEIGHT) -> Self { - FontWeight(value.0 as f32) - } +fn font_weight_from_dwrite(value: DWRITE_FONT_WEIGHT) -> FontWeight { + FontWeight(value.0 as f32) } fn get_font_names_from_collection( collection: &IDWriteFontCollection1, - locale: &str, + locale: &HSTRING, ) -> Vec { unsafe { let mut result = Vec::new(); @@ -1669,60 +1693,18 @@ fn get_font_names_from_collection( } } -fn get_font_identifier_and_font_struct( - font_face: &IDWriteFontFace3, - locale: &str, -) -> Option<(FontIdentifier, Font, bool)> { - let postscript_name = get_postscript_name(font_face, locale).log_err()?; +fn font_face_to_font(font_face: &IDWriteFontFace3, locale: &HSTRING) -> Option { let localized_family_name = unsafe { font_face.GetFamilyNames().log_err() }?; let family_name = get_name(localized_family_name, locale).log_err()?; let weight = unsafe { font_face.GetWeight() }; let style = unsafe { font_face.GetStyle() }; - let identifier = FontIdentifier { - postscript_name, - weight: weight.0, - style: style.0, - }; - let font_struct = Font { + Some(Font { family: family_name.into(), features: FontFeatures::default(), - weight: weight.into(), - style: style.into(), + weight: font_weight_from_dwrite(weight), + style: font_style_from_dwrite(style), fallbacks: None, - }; - let is_emoji = unsafe { font_face.IsColorFont().as_bool() }; - Some((identifier, font_struct, is_emoji)) -} - -#[inline] -fn get_font_identifier(font_face: &IDWriteFontFace3, locale: &str) -> Option { - let weight = unsafe { font_face.GetWeight().0 }; - let style = unsafe { font_face.GetStyle().0 }; - get_postscript_name(font_face, locale) - .log_err() - .map(|postscript_name| FontIdentifier { - postscript_name, - weight, - style, - }) -} - -#[inline] -fn get_postscript_name(font_face: &IDWriteFontFace3, locale: &str) -> Result { - let mut info = None; - let mut exists = BOOL(0); - unsafe { - font_face.GetInformationalStrings( - DWRITE_INFORMATIONAL_STRING_POSTSCRIPT_NAME, - &mut info, - &mut exists, - )? - }; - if !exists.as_bool() || info.is_none() { - anyhow::bail!("No postscript name found for font face"); - } - - get_name(info.unwrap(), locale) + }) } // https://learn.microsoft.com/en-us/windows/win32/api/dwrite/ne-dwrite-dwrite_font_feature_tag @@ -1790,16 +1772,10 @@ const fn make_direct_write_tag(tag_name: &str) -> DWRITE_FONT_FEATURE_TAG { } #[inline] -fn get_name(string: IDWriteLocalizedStrings, locale: &str) -> Result { +fn get_name(string: IDWriteLocalizedStrings, locale: &HSTRING) -> Result { let mut locale_name_index = 0u32; let mut exists = BOOL(0); - unsafe { - string.FindLocaleName( - &HSTRING::from(locale), - &mut locale_name_index, - &mut exists as _, - )? - }; + unsafe { string.FindLocaleName(locale, &mut locale_name_index, &mut exists as _)? }; if !exists.as_bool() { unsafe { string.FindLocaleName( @@ -1820,6 +1796,23 @@ fn get_name(string: IDWriteLocalizedStrings, locale: &str) -> Result { Ok(String::from_utf16_lossy(&name_vec[..name_length])) } +fn get_system_subpixel_rendering() -> bool { + let mut value = c_uint::default(); + let result = unsafe { + SystemParametersInfoW( + SPI_GETFONTSMOOTHINGTYPE, + 0, + Some((&mut value) as *mut c_uint as *mut c_void), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS::default(), + ) + }; + if result.log_err().is_some() { + value == FE_FONTSMOOTHINGCLEARTYPE + } else { + true + } +} + fn get_system_ui_font_name() -> SharedString { unsafe { let mut info: LOGFONTW = std::mem::zeroed(); @@ -1852,7 +1845,7 @@ fn is_color_glyph( factory: &IDWriteFactory5, ) -> bool { let glyph_run = DWRITE_GLYPH_RUN { - fontFace: unsafe { std::mem::transmute_copy(font_face) }, + fontFace: ManuallyDrop::new(Some(unsafe { std::ptr::read(&****font_face) })), fontEmSize: 14.0, glyphCount: 1, glyphIndices: &(glyph_id.0 as u16), @@ -1886,7 +1879,7 @@ const DEFAULT_LOCALE_NAME: PCWSTR = windows::core::w!("en-US"); #[cfg(test)] mod tests { - use crate::platform::windows::direct_write::ClusterAnalyzer; + use crate::direct_write::ClusterAnalyzer; #[test] fn test_cluster_map() { diff --git a/src/platform/windows/directx_atlas.rs b/src/platform/windows/directx_atlas.rs index 9deae392d1..a2ded660ca 100644 --- a/src/platform/windows/directx_atlas.rs +++ b/src/platform/windows/directx_atlas.rs @@ -9,9 +9,9 @@ use windows::Win32::Graphics::{ Dxgi::Common::*, }; -use crate::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas, - Point, Size, platform::AtlasTextureList, +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTextureList, AtlasTile, Bounds, DevicePixels, + PlatformAtlas, Point, Size, }; pub(crate) struct DirectXAtlas(Mutex); @@ -21,6 +21,7 @@ struct DirectXAtlasState { device_context: ID3D11DeviceContext, monochrome_textures: AtlasTextureList, polychrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, tiles_by_key: FxHashMap, } @@ -40,6 +41,7 @@ impl DirectXAtlas { device_context: device_context.clone(), monochrome_textures: Default::default(), polychrome_textures: Default::default(), + subpixel_textures: Default::default(), tiles_by_key: Default::default(), })) } @@ -63,6 +65,7 @@ impl DirectXAtlas { lock.device_context = device_context.clone(); lock.monochrome_textures = AtlasTextureList::default(); lock.polychrome_textures = AtlasTextureList::default(); + lock.subpixel_textures = AtlasTextureList::default(); lock.tiles_by_key.clear(); } } @@ -102,6 +105,7 @@ impl PlatformAtlas for DirectXAtlas { let textures = match id.kind { AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, AtlasTextureKind::Polychrome => &mut lock.polychrome_textures, + AtlasTextureKind::Subpixel => &mut lock.subpixel_textures, }; let Some(texture_slot) = textures.textures.get_mut(id.index as usize) else { @@ -130,6 +134,7 @@ impl DirectXAtlasState { let textures = match texture_kind { AtlasTextureKind::Monochrome => &mut self.monochrome_textures, AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, }; if let Some(tile) = textures @@ -175,6 +180,11 @@ impl DirectXAtlasState { bind_flag = D3D11_BIND_SHADER_RESOURCE; bytes_per_pixel = 4; } + AtlasTextureKind::Subpixel => { + pixel_format = DXGI_FORMAT_R8G8B8A8_UNORM; + bind_flag = D3D11_BIND_SHADER_RESOURCE; + bytes_per_pixel = 4; + } } let texture_desc = D3D11_TEXTURE2D_DESC { Width: size.width.0 as u32, @@ -204,6 +214,7 @@ impl DirectXAtlasState { let texture_list = match kind { AtlasTextureKind::Monochrome => &mut self.monochrome_textures, AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, }; let index = texture_list.free_list.pop(); let view = unsafe { @@ -219,7 +230,7 @@ impl DirectXAtlasState { kind, }, bytes_per_pixel, - allocator: etagere::BucketedAtlasAllocator::new(size.into()), + allocator: etagere::BucketedAtlasAllocator::new(device_size_to_etagere(size)), texture, view, live_atlas_keys: 0, @@ -235,24 +246,27 @@ impl DirectXAtlasState { fn texture(&self, id: AtlasTextureId) -> &DirectXAtlasTexture { match id.kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures[id.index as usize] + AtlasTextureKind::Monochrome => &self.monochrome_textures[id.index as usize] .as_ref() .unwrap(), - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures[id.index as usize] + AtlasTextureKind::Polychrome => &self.polychrome_textures[id.index as usize] .as_ref() .unwrap(), + AtlasTextureKind::Subpixel => { + &self.subpixel_textures[id.index as usize].as_ref().unwrap() + } } } } impl DirectXAtlasTexture { fn allocate(&mut self, size: Size) -> Option { - let allocation = self.allocator.allocate(size.into())?; + let allocation = self.allocator.allocate(device_size_to_etagere(size))?; let tile = AtlasTile { texture_id: self.id, tile_id: allocation.id.into(), bounds: Bounds { - origin: allocation.rectangle.min.into(), + origin: etagere_point_to_device(allocation.rectangle.min), size, }, padding: 0, @@ -295,17 +309,13 @@ impl DirectXAtlasTexture { } } -impl From> for etagere::Size { - fn from(size: Size) -> Self { - etagere::Size::new(size.width.into(), size.height.into()) - } +fn device_size_to_etagere(size: Size) -> etagere::Size { + etagere::Size::new(size.width.into(), size.height.into()) } -impl From for Point { - fn from(value: etagere::Point) -> Self { - Point { - x: DevicePixels::from(value.x), - y: DevicePixels::from(value.y), - } +fn etagere_point_to_device(value: etagere::Point) -> Point { + Point { + x: DevicePixels::from(value.x), + y: DevicePixels::from(value.y), } } diff --git a/src/platform/windows/directx_devices.rs b/src/platform/windows/directx_devices.rs index 980093719a..882e404a56 100644 --- a/src/platform/windows/directx_devices.rs +++ b/src/platform/windows/directx_devices.rs @@ -48,32 +48,20 @@ impl DirectXDevices { let debug_layer_available = check_debug_layer_available(); let dxgi_factory = get_dxgi_factory(debug_layer_available).context("Creating DXGI factory")?; - let adapter = + let (adapter, device, device_context, feature_level) = get_adapter(&dxgi_factory, debug_layer_available).context("Getting DXGI adapter")?; - let (device, device_context) = { - let mut context: Option = None; - let mut feature_level = D3D_FEATURE_LEVEL::default(); - let device = get_device( - &adapter, - Some(&mut context), - Some(&mut feature_level), - debug_layer_available, - ) - .context("Creating Direct3D device")?; - match feature_level { - D3D_FEATURE_LEVEL_11_1 => { - log::info!("Created device with Direct3D 11.1 feature level.") - } - D3D_FEATURE_LEVEL_11_0 => { - log::info!("Created device with Direct3D 11.0 feature level.") - } - D3D_FEATURE_LEVEL_10_1 => { - log::info!("Created device with Direct3D 10.1 feature level.") - } - _ => unreachable!(), + match feature_level { + D3D_FEATURE_LEVEL_11_1 => { + log::info!("Created device with Direct3D 11.1 feature level.") } - (device, context.unwrap()) - }; + D3D_FEATURE_LEVEL_11_0 => { + log::info!("Created device with Direct3D 11.0 feature level.") + } + D3D_FEATURE_LEVEL_10_1 => { + log::info!("Created device with Direct3D 10.1 feature level.") + } + _ => unreachable!(), + } Ok(Self { adapter, @@ -115,7 +103,15 @@ fn get_dxgi_factory(debug_layer_available: bool) -> Result { } #[inline] -fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Result { +fn get_adapter( + dxgi_factory: &IDXGIFactory6, + debug_layer_available: bool, +) -> Result<( + IDXGIAdapter1, + ID3D11Device, + ID3D11DeviceContext, + D3D_FEATURE_LEVEL, +)> { for adapter_index in 0.. { let adapter: IDXGIAdapter1 = unsafe { dxgi_factory.EnumAdapters(adapter_index)?.cast()? }; if let Ok(desc) = unsafe { adapter.GetDesc1() } { @@ -124,13 +120,19 @@ fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Res .to_string(); log::info!("Using GPU: {}", gpu_name); } - // Check to see whether the adapter supports Direct3D 11, but don't - // create the actual device yet. - if get_device(&adapter, None, None, debug_layer_available) - .log_err() - .is_some() + // Check to see whether the adapter supports Direct3D 11 and create + // the device if it does. + let mut context: Option = None; + let mut feature_level = D3D_FEATURE_LEVEL::default(); + if let Some(device) = get_device( + &adapter, + Some(&mut context), + Some(&mut feature_level), + debug_layer_available, + ) + .log_err() { - return Ok(adapter); + return Ok((adapter, device, context.unwrap(), feature_level)); } } diff --git a/src/platform/windows/directx_renderer.rs b/src/platform/windows/directx_renderer.rs index 608ac2c3b0..2955b23429 100644 --- a/src/platform/windows/directx_renderer.rs +++ b/src/platform/windows/directx_renderer.rs @@ -19,12 +19,9 @@ use windows::{ core::Interface, }; -use crate::{ - platform::windows::directx_renderer::shader_resources::{ - RawShaderBytes, ShaderModule, ShaderTarget, - }, - *, -}; +use crate::directx_renderer::shader_resources::{RawShaderBytes, ShaderModule, ShaderTarget}; +use crate::*; +use gpui::*; pub(crate) const DISABLE_DIRECT_COMPOSITION: &str = "GPUI_DISABLE_DIRECT_COMPOSITION"; const RENDER_TARGET_FORMAT: DXGI_FORMAT = DXGI_FORMAT_B8G8R8A8_UNORM; @@ -34,6 +31,7 @@ const PATH_MULTISAMPLE_COUNT: u32 = 4; pub(crate) struct FontInfo { pub gamma_ratios: [f32; 4], pub grayscale_enhanced_contrast: f32, + pub subpixel_enhanced_contrast: f32, } pub(crate) struct DirectXRenderer { @@ -89,6 +87,7 @@ struct DirectXRenderPipelines { path_sprite_pipeline: PipelineState, underline_pipeline: PipelineState, mono_sprites: PipelineState, + subpixel_sprites: PipelineState, poly_sprites: PipelineState, } @@ -181,7 +180,7 @@ impl DirectXRenderer { self.atlas.clone() } - fn pre_draw(&self) -> Result<()> { + fn pre_draw(&self, clear_color: &[f32; 4]) -> Result<()> { let resources = self.resources.as_ref().expect("resources missing"); let device_context = &self .devices @@ -195,7 +194,7 @@ impl DirectXRenderer { gamma_ratios: self.font_info.gamma_ratios, viewport_size: [resources.viewport.Width, resources.viewport.Height], grayscale_enhanced_contrast: self.font_info.grayscale_enhanced_contrast, - _pad: 0, + subpixel_enhanced_contrast: self.font_info.subpixel_enhanced_contrast, }], )?; unsafe { @@ -204,7 +203,7 @@ impl DirectXRenderer { .render_target_view .as_ref() .context("missing render target view")?, - &[0.0; 4], + clear_color, ); device_context .OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); @@ -299,40 +298,53 @@ impl DirectXRenderer { Ok(()) } - pub(crate) fn draw(&mut self, scene: &Scene) -> Result<()> { + pub(crate) fn draw( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result<()> { if self.skip_draws { // skip drawing this frame, we just recovered from a device lost event // and so likely do not have the textures anymore that are required for drawing return Ok(()); } - self.pre_draw()?; + self.pre_draw(&match background_appearance { + WindowBackgroundAppearance::Opaque => [1.0f32; 4], + _ => [0.0f32; 4], + })?; + + self.upload_scene_buffers(scene)?; + for batch in scene.batches() { match batch { - PrimitiveBatch::Shadows(shadows) => self.draw_shadows(shadows), - PrimitiveBatch::Quads(quads) => self.draw_quads(quads), - PrimitiveBatch::Paths(paths) => { + PrimitiveBatch::Shadows(range) => self.draw_shadows(range.start, range.len()), + PrimitiveBatch::Quads(range) => self.draw_quads(range.start, range.len()), + PrimitiveBatch::Paths(range) => { + let paths = &scene.paths[range]; self.draw_paths_to_intermediate(paths)?; self.draw_paths_from_intermediate(paths) } - PrimitiveBatch::Underlines(underlines) => self.draw_underlines(underlines), - PrimitiveBatch::MonochromeSprites { - texture_id, - sprites, - } => self.draw_monochrome_sprites(texture_id, sprites), - PrimitiveBatch::PolychromeSprites { - texture_id, - sprites, - } => self.draw_polychrome_sprites(texture_id, sprites), - PrimitiveBatch::Surfaces(surfaces) => self.draw_surfaces(surfaces), + PrimitiveBatch::Underlines(range) => self.draw_underlines(range.start, range.len()), + PrimitiveBatch::MonochromeSprites { texture_id, range } => { + self.draw_monochrome_sprites(texture_id, range.start, range.len()) + } + PrimitiveBatch::SubpixelSprites { texture_id, range } => { + self.draw_subpixel_sprites(texture_id, range.start, range.len()) + } + PrimitiveBatch::PolychromeSprites { texture_id, range } => { + self.draw_polychrome_sprites(texture_id, range.start, range.len()) + } + PrimitiveBatch::Surfaces(range) => self.draw_surfaces(&scene.surfaces[range]), } .context(format!( "scene too large:\ - {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces", + {} paths, {} shadows, {} quads, {} underlines, {} mono, {} subpixel, {} poly, {} surfaces", scene.paths.len(), scene.shadows.len(), scene.quads.len(), scene.underlines.len(), scene.monochrome_sprites.len(), + scene.subpixel_sprites.len(), scene.polychrome_sprites.len(), scene.surfaces.len(), ))?; @@ -384,17 +396,67 @@ impl DirectXRenderer { Ok(()) } - fn draw_shadows(&mut self, shadows: &[Shadow]) -> Result<()> { - if shadows.is_empty() { + fn upload_scene_buffers(&mut self, scene: &Scene) -> Result<()> { + let devices = self.devices.as_ref().context("devices missing")?; + + if !scene.shadows.is_empty() { + self.pipelines.shadow_pipeline.update_buffer( + &devices.device, + &devices.device_context, + &scene.shadows, + )?; + } + + if !scene.quads.is_empty() { + self.pipelines.quad_pipeline.update_buffer( + &devices.device, + &devices.device_context, + &scene.quads, + )?; + } + + if !scene.underlines.is_empty() { + self.pipelines.underline_pipeline.update_buffer( + &devices.device, + &devices.device_context, + &scene.underlines, + )?; + } + + if !scene.monochrome_sprites.is_empty() { + self.pipelines.mono_sprites.update_buffer( + &devices.device, + &devices.device_context, + &scene.monochrome_sprites, + )?; + } + + if !scene.subpixel_sprites.is_empty() { + self.pipelines.subpixel_sprites.update_buffer( + &devices.device, + &devices.device_context, + &scene.subpixel_sprites, + )?; + } + + if !scene.polychrome_sprites.is_empty() { + self.pipelines.poly_sprites.update_buffer( + &devices.device, + &devices.device_context, + &scene.polychrome_sprites, + )?; + } + + Ok(()) + } + + fn draw_shadows(&mut self, start: usize, len: usize) -> Result<()> { + if len == 0 { return Ok(()); } let devices = self.devices.as_ref().context("devices missing")?; - self.pipelines.shadow_pipeline.update_buffer( + self.pipelines.shadow_pipeline.draw_range( &devices.device, - &devices.device_context, - shadows, - )?; - self.pipelines.shadow_pipeline.draw( &devices.device_context, slice::from_ref( &self @@ -404,23 +466,19 @@ impl DirectXRenderer { .viewport, ), slice::from_ref(&self.globals.global_params_buffer), - D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, 4, - shadows.len() as u32, + start as u32, + len as u32, ) } - fn draw_quads(&mut self, quads: &[Quad]) -> Result<()> { - if quads.is_empty() { + fn draw_quads(&mut self, start: usize, len: usize) -> Result<()> { + if len == 0 { return Ok(()); } let devices = self.devices.as_ref().context("devices missing")?; - self.pipelines.quad_pipeline.update_buffer( + self.pipelines.quad_pipeline.draw_range( &devices.device, - &devices.device_context, - quads, - )?; - self.pipelines.quad_pipeline.draw( &devices.device_context, slice::from_ref( &self @@ -430,9 +488,9 @@ impl DirectXRenderer { .viewport, ), slice::from_ref(&self.globals.global_params_buffer), - D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, 4, - quads.len() as u32, + start as u32, + len as u32, ) } @@ -547,77 +605,92 @@ impl DirectXRenderer { ) } - fn draw_underlines(&mut self, underlines: &[Underline]) -> Result<()> { - if underlines.is_empty() { + fn draw_underlines(&mut self, start: usize, len: usize) -> Result<()> { + if len == 0 { return Ok(()); } let devices = self.devices.as_ref().context("devices missing")?; let resources = self.resources.as_ref().context("resources missing")?; - self.pipelines.underline_pipeline.update_buffer( + self.pipelines.underline_pipeline.draw_range( &devices.device, - &devices.device_context, - underlines, - )?; - self.pipelines.underline_pipeline.draw( &devices.device_context, slice::from_ref(&resources.viewport), slice::from_ref(&self.globals.global_params_buffer), - D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, 4, - underlines.len() as u32, + start as u32, + len as u32, ) } fn draw_monochrome_sprites( &mut self, texture_id: AtlasTextureId, - sprites: &[MonochromeSprite], + start: usize, + len: usize, ) -> Result<()> { - if sprites.is_empty() { + if len == 0 { return Ok(()); } let devices = self.devices.as_ref().context("devices missing")?; let resources = self.resources.as_ref().context("resources missing")?; - self.pipelines.mono_sprites.update_buffer( - &devices.device, - &devices.device_context, - sprites, - )?; let texture_view = self.atlas.get_texture_view(texture_id); - self.pipelines.mono_sprites.draw_with_texture( + self.pipelines.mono_sprites.draw_range_with_texture( + &devices.device, &devices.device_context, &texture_view, slice::from_ref(&resources.viewport), slice::from_ref(&self.globals.global_params_buffer), slice::from_ref(&self.globals.sampler), - sprites.len() as u32, + start as u32, + len as u32, + ) + } + + fn draw_subpixel_sprites( + &mut self, + texture_id: AtlasTextureId, + start: usize, + len: usize, + ) -> Result<()> { + if len == 0 { + return Ok(()); + } + let devices = self.devices.as_ref().context("devices missing")?; + let resources = self.resources.as_ref().context("resources missing")?; + let texture_view = self.atlas.get_texture_view(texture_id); + self.pipelines.subpixel_sprites.draw_range_with_texture( + &devices.device, + &devices.device_context, + &texture_view, + slice::from_ref(&resources.viewport), + slice::from_ref(&self.globals.global_params_buffer), + slice::from_ref(&self.globals.sampler), + start as u32, + len as u32, ) } fn draw_polychrome_sprites( &mut self, texture_id: AtlasTextureId, - sprites: &[PolychromeSprite], + start: usize, + len: usize, ) -> Result<()> { - if sprites.is_empty() { + if len == 0 { return Ok(()); } - let devices = self.devices.as_ref().context("devices missing")?; let resources = self.resources.as_ref().context("resources missing")?; - self.pipelines.poly_sprites.update_buffer( - &devices.device, - &devices.device_context, - sprites, - )?; let texture_view = self.atlas.get_texture_view(texture_id); - self.pipelines.poly_sprites.draw_with_texture( + self.pipelines.poly_sprites.draw_range_with_texture( + &devices.device, &devices.device_context, &texture_view, slice::from_ref(&resources.viewport), slice::from_ref(&self.globals.global_params_buffer), slice::from_ref(&self.globals.sampler), - sprites.len() as u32, + start as u32, + len as u32, ) } @@ -665,8 +738,9 @@ impl DirectXRenderer { let render_params: IDWriteRenderingParams1 = factory.CreateRenderingParams().unwrap().cast().unwrap(); FontInfo { - gamma_ratios: get_gamma_correction_ratios(render_params.GetGamma()), + gamma_ratios: gpui::get_gamma_correction_ratios(render_params.GetGamma()), grayscale_enhanced_contrast: render_params.GetGrayscaleEnhancedContrast(), + subpixel_enhanced_contrast: render_params.GetEnhancedContrast(), } }) } @@ -789,6 +863,13 @@ impl DirectXRenderPipelines { 512, create_blend_state(device)?, )?; + let subpixel_sprites = PipelineState::new( + device, + "subpixel_sprite_pipeline", + ShaderModule::SubpixelSprite, + 512, + create_blend_state_for_subpixel_rendering(device)?, + )?; let poly_sprites = PipelineState::new( device, "polychrome_sprite_pipeline", @@ -804,6 +885,7 @@ impl DirectXRenderPipelines { path_sprite_pipeline, underline_pipeline, mono_sprites, + subpixel_sprites, poly_sprites, }) } @@ -878,7 +960,7 @@ struct GlobalParams { gamma_ratios: [f32; 4], viewport_size: [f32; 2], grayscale_enhanced_contrast: f32, - _pad: u32, + subpixel_enhanced_contrast: f32, } struct PipelineState { @@ -931,7 +1013,7 @@ impl PipelineState { ) -> Result<()> { if self.buffer_size < data.len() { let new_buffer_size = data.len().next_power_of_two(); - log::info!( + log::debug!( "Updating {} buffer size from {} to {}", self.label, self.buffer_size, @@ -999,6 +1081,64 @@ impl PipelineState { } Ok(()) } + + fn draw_range( + &self, + device: &ID3D11Device, + device_context: &ID3D11DeviceContext, + viewport: &[D3D11_VIEWPORT], + global_params: &[Option], + vertex_count: u32, + first_instance: u32, + instance_count: u32, + ) -> Result<()> { + let view = create_buffer_view_range(device, &self.buffer, first_instance, instance_count)?; + set_pipeline_state( + device_context, + slice::from_ref(&view), + D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + viewport, + &self.vertex, + &self.fragment, + global_params, + &self.blend_state, + ); + unsafe { + device_context.DrawInstanced(vertex_count, instance_count, 0, 0); + } + Ok(()) + } + + fn draw_range_with_texture( + &self, + device: &ID3D11Device, + device_context: &ID3D11DeviceContext, + texture: &[Option], + viewport: &[D3D11_VIEWPORT], + global_params: &[Option], + sampler: &[Option], + first_instance: u32, + instance_count: u32, + ) -> Result<()> { + let view = create_buffer_view_range(device, &self.buffer, first_instance, instance_count)?; + set_pipeline_state( + device_context, + slice::from_ref(&view), + D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, + viewport, + &self.vertex, + &self.fragment, + global_params, + &self.blend_state, + ); + unsafe { + device_context.PSSetSamplers(0, Some(sampler)); + device_context.VSSetShaderResources(0, Some(texture)); + device_context.PSSetShaderResources(0, Some(texture)); + device_context.DrawInstanced(4, instance_count, 0, 0); + } + Ok(()) + } } #[derive(Clone, Copy)] @@ -1235,8 +1375,6 @@ fn set_rasterizer_state(device: &ID3D11Device, device_context: &ID3D11DeviceCont // https://learn.microsoft.com/en-us/windows/win32/api/d3d11/ns-d3d11-d3d11_blend_desc #[inline] fn create_blend_state(device: &ID3D11Device) -> Result { - // If the feature level is set to greater than D3D_FEATURE_LEVEL_9_3, the display - // device performs the blend in linear space, which is ideal. let mut desc = D3D11_BLEND_DESC::default(); desc.RenderTarget[0].BlendEnable = true.into(); desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; @@ -1253,6 +1391,27 @@ fn create_blend_state(device: &ID3D11Device) -> Result { } } +#[inline] +fn create_blend_state_for_subpixel_rendering(device: &ID3D11Device) -> Result { + let mut desc = D3D11_BLEND_DESC::default(); + desc.RenderTarget[0].BlendEnable = true.into(); + desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; + desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; + desc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC1_COLOR; + desc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC1_COLOR; + // It does not make sense to draw transparent subpixel-rendered text, since it cannot be meaningfully alpha-blended onto anything else. + desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; + desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ZERO; + desc.RenderTarget[0].RenderTargetWriteMask = + D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8 & !D3D11_COLOR_WRITE_ENABLE_ALPHA.0 as u8; + + unsafe { + let mut state = None; + device.CreateBlendState(&desc, Some(&mut state))?; + Ok(state.unwrap()) + } +} + #[inline] fn create_blend_state_for_path_rasterization(device: &ID3D11Device) -> Result { // If the feature level is set to greater than D3D_FEATURE_LEVEL_9_3, the display @@ -1340,6 +1499,32 @@ fn create_buffer_view( Ok(view) } +#[inline] +fn create_buffer_view_range( + device: &ID3D11Device, + buffer: &ID3D11Buffer, + first_element: u32, + num_elements: u32, +) -> Result> { + let desc = D3D11_SHADER_RESOURCE_VIEW_DESC { + Format: DXGI_FORMAT_UNKNOWN, + ViewDimension: D3D11_SRV_DIMENSION_BUFFER, + Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 { + Buffer: D3D11_BUFFER_SRV { + Anonymous1: D3D11_BUFFER_SRV_0 { + FirstElement: first_element, + }, + Anonymous2: D3D11_BUFFER_SRV_1 { + NumElements: num_elements, + }, + }, + }, + }; + let mut view = None; + unsafe { device.CreateShaderResourceView(buffer, Some(&desc), Some(&mut view)) }?; + Ok(view) +} + #[inline] fn update_buffer( device_context: &ID3D11DeviceContext, @@ -1410,6 +1595,7 @@ pub(crate) mod shader_resources { PathRasterization, PathSprite, MonochromeSprite, + SubpixelSprite, PolychromeSprite, EmojiRasterization, } @@ -1477,6 +1663,10 @@ pub(crate) mod shader_resources { ShaderTarget::Vertex => MONOCHROME_SPRITE_VERTEX_BYTES, ShaderTarget::Fragment => MONOCHROME_SPRITE_FRAGMENT_BYTES, }, + ShaderModule::SubpixelSprite => match target { + ShaderTarget::Vertex => SUBPIXEL_SPRITE_VERTEX_BYTES, + ShaderTarget::Fragment => SUBPIXEL_SPRITE_FRAGMENT_BYTES, + }, ShaderModule::PolychromeSprite => match target { ShaderTarget::Vertex => POLYCHROME_SPRITE_VERTEX_BYTES, ShaderTarget::Fragment => POLYCHROME_SPRITE_FRAGMENT_BYTES, @@ -1519,7 +1709,7 @@ pub(crate) mod shader_resources { let mut compile_blob = None; let mut error_blob = None; let shader_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join(&format!("src/platform/windows/{}", shader_name)) + .join(&format!("src/{}", shader_name)) .canonicalize()?; let entry_point = PCSTR::from_raw(entry.as_ptr()); @@ -1561,7 +1751,7 @@ pub(crate) mod shader_resources { #[cfg(debug_assertions)] impl ShaderModule { - pub fn as_str(&self) -> &str { + pub fn as_str(self) -> &'static str { match self { ShaderModule::Quad => "quad", ShaderModule::Shadow => "shadow", @@ -1569,6 +1759,7 @@ pub(crate) mod shader_resources { ShaderModule::PathRasterization => "path_rasterization", ShaderModule::PathSprite => "path_sprite", ShaderModule::MonochromeSprite => "monochrome_sprite", + ShaderModule::SubpixelSprite => "subpixel_sprite", ShaderModule::PolychromeSprite => "polychrome_sprite", ShaderModule::EmojiRasterization => "emoji_rasterization", } diff --git a/src/platform/windows/dispatcher.rs b/src/platform/windows/dispatcher.rs index 0720d414c9..a5cfd9dc10 100644 --- a/src/platform/windows/dispatcher.rs +++ b/src/platform/windows/dispatcher.rs @@ -12,18 +12,19 @@ use windows::{ }, Win32::{ Foundation::{LPARAM, WPARAM}, + Media::{timeBeginPeriod, timeEndPeriod}, System::Threading::{ GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, - THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_TIME_CRITICAL, + THREAD_PRIORITY_TIME_CRITICAL, }, UI::WindowsAndMessaging::PostMessageW, }, }; -use crate::{ - GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, Priority, PriorityQueueSender, - RealtimePriority, RunnableVariant, SafeHwnd, THREAD_TIMINGS, TaskLabel, TaskTiming, - ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, profiler, +use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; +use gpui::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueSender, RunnableVariant, + THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, TimerResolutionGuard, }; pub(crate) struct WindowsDispatcher { @@ -56,7 +57,8 @@ impl WindowsDispatcher { let handler = { let mut task_wrapper = Some(runnable); WorkItemHandler::new(move |_| { - Self::execute_runnable(task_wrapper.take().unwrap()); + let runnable = task_wrapper.take().unwrap(); + Self::execute_runnable(runnable); Ok(()) }) }; @@ -68,7 +70,8 @@ impl WindowsDispatcher { let handler = { let mut task_wrapper = Some(runnable); TimerElapsedHandler::new(move |_| { - Self::execute_runnable(task_wrapper.take().unwrap()); + let runnable = task_wrapper.take().unwrap(); + Self::execute_runnable(runnable); Ok(()) }) }; @@ -79,38 +82,20 @@ impl WindowsDispatcher { pub(crate) fn execute_runnable(runnable: RunnableVariant) { let start = Instant::now(); - let mut timing = match runnable { - RunnableVariant::Meta(runnable) => { - let location = runnable.metadata().location; - let timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - - timing - } - RunnableVariant::Compat(runnable) => { - let timing = TaskTiming { - location: core::panic::Location::caller(), - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - - timing - } + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, }; + gpui::profiler::add_task_timing(timing); + + runnable.run(); let end = Instant::now(); timing.end = Some(end); - profiler::add_task_timing(timing); + gpui::profiler::add_task_timing(timing); } } @@ -120,9 +105,11 @@ impl PlatformDispatcher for WindowsDispatcher { ThreadTaskTimings::convert(&global_thread_timings) } - fn get_current_thread_timings(&self) -> Vec { + fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { THREAD_TIMINGS.with(|timings| { let timings = timings.lock(); + let thread_name = timings.thread_name.clone(); + let total_pushed = timings.total_pushed; let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); @@ -130,7 +117,13 @@ impl PlatformDispatcher for WindowsDispatcher { let (s1, s2) = timings.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); - vec + + gpui::ThreadTaskTimings { + thread_name, + thread_id: std::thread::current().id(), + timings: vec, + total_pushed, + } }) } @@ -138,18 +131,16 @@ impl PlatformDispatcher for WindowsDispatcher { current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority) { + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { let priority = match priority { - Priority::Realtime(_) => unreachable!(), + Priority::RealtimeAudio => { + panic!("RealtimeAudio priority should use spawn_realtime, not dispatch") + } Priority::High => WorkItemPriority::High, Priority::Medium => WorkItemPriority::Normal, Priority::Low => WorkItemPriority::Low, }; self.dispatch_on_threadpool(priority, runnable); - - if let Some(label) = label { - log::debug!("TaskLabel: {label:?}"); - } } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { @@ -185,27 +176,31 @@ impl PlatformDispatcher for WindowsDispatcher { self.dispatch_on_threadpool_after(runnable, duration); } - fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + fn spawn_realtime(&self, f: Box) { std::thread::spawn(move || { // SAFETY: always safe to call let thread_handle = unsafe { GetCurrentThread() }; - let thread_priority = match priority { - RealtimePriority::Audio => THREAD_PRIORITY_TIME_CRITICAL, - RealtimePriority::Other => THREAD_PRIORITY_HIGHEST, - }; - // SAFETY: thread_handle is a valid handle to a thread unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) } .context("thread priority class") .log_err(); // SAFETY: thread_handle is a valid handle to a thread - unsafe { SetThreadPriority(thread_handle, thread_priority) } + unsafe { SetThreadPriority(thread_handle, THREAD_PRIORITY_TIME_CRITICAL) } .context("thread priority") .log_err(); f(); }); } + + fn increase_timer_resolution(&self) -> TimerResolutionGuard { + unsafe { + timeBeginPeriod(1); + } + util::defer(Box::new(|| unsafe { + timeEndPeriod(1); + })) + } } diff --git a/src/platform/windows/display.rs b/src/platform/windows/display.rs index 720d459c1c..1931a6949f 100644 --- a/src/platform/windows/display.rs +++ b/src/platform/windows/display.rs @@ -15,7 +15,8 @@ use windows::{ core::*, }; -use crate::{Bounds, DevicePixels, DisplayId, Pixels, PlatformDisplay, logical_point, point, size}; +use crate::logical_point; +use gpui::{Bounds, DevicePixels, DisplayId, Pixels, PlatformDisplay, point, size}; #[derive(Debug, Clone, Copy)] pub(crate) struct WindowsDisplay { @@ -34,7 +35,9 @@ unsafe impl Sync for WindowsDisplay {} impl WindowsDisplay { pub(crate) fn new(display_id: DisplayId) -> Option { - let screen = available_monitors().into_iter().nth(display_id.0 as _)?; + let screen = available_monitors() + .into_iter() + .nth(u32::from(display_id) as _)?; let info = get_monitor_info(screen).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; let work_area = info.monitorInfo.rcWork; @@ -63,7 +66,7 @@ impl WindowsDisplay { (work_area.right - work_area.left) as f32 / scale_factor, (work_area.bottom - work_area.top) as f32 / scale_factor, ) - .map(crate::px), + .map(gpui::px), }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), @@ -90,7 +93,7 @@ impl WindowsDisplay { Ok(WindowsDisplay { handle: monitor, - display_id: DisplayId(display_id as _), + display_id: DisplayId::new(display_id as _), scale_factor, bounds: Bounds { origin: logical_point( @@ -106,7 +109,7 @@ impl WindowsDisplay { (work_area.right - work_area.left) as f32 / scale_factor, (work_area.bottom - work_area.top) as f32 / scale_factor, ) - .map(crate::px), + .map(gpui::px), }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), @@ -145,7 +148,7 @@ impl WindowsDisplay { (work_area.right - work_area.left) as f32 / scale_factor, (work_area.bottom - work_area.top) as f32 / scale_factor, ) - .map(crate::px), + .map(gpui::px), }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), @@ -173,8 +176,8 @@ impl WindowsDisplay { pub fn check_given_bounds(&self, bounds: Bounds) -> bool { let center = bounds.center(); let center = POINT { - x: (center.x.0 * self.scale_factor) as i32, - y: (center.y.0 * self.scale_factor) as i32, + x: (center.x.as_f32() * self.scale_factor) as i32, + y: (center.y.as_f32() * self.scale_factor) as i32, }; let monitor = unsafe { MonitorFromPoint(center, MONITOR_DEFAULTTONULL) }; if monitor.is_invalid() { @@ -193,17 +196,12 @@ impl WindowsDisplay { .enumerate() .filter_map(|(id, handle)| { Some(Rc::new( - WindowsDisplay::new_with_handle_and_id(handle, DisplayId(id as _)).ok()?, + WindowsDisplay::new_with_handle_and_id(handle, DisplayId::new(id as _)).ok()?, ) as Rc) }) .collect() } - /// Check if this monitor is still online - pub fn is_connected(hmonitor: HMONITOR) -> bool { - available_monitors().iter().contains(&hmonitor) - } - pub fn physical_bounds(&self) -> Bounds { self.physical_bounds } diff --git a/src/platform/windows/events.rs b/src/platform/windows/events.rs index f648f45cf4..3506ae2a2c 100644 --- a/src/platform/windows/events.rs +++ b/src/platform/windows/events.rs @@ -18,6 +18,7 @@ use windows::{ }; use crate::*; +use gpui::*; pub(crate) const WM_GPUI_CURSOR_STYLE_CHANGED: u32 = WM_USER + 1; pub(crate) const WM_GPUI_CLOSE_ONE_WINDOW: u32 = WM_USER + 2; @@ -29,7 +30,6 @@ pub(crate) const WM_GPUI_GPU_DEVICE_LOST: u32 = WM_USER + 7; pub(crate) const WM_GPUI_KEYDOWN: u32 = WM_USER + 8; const SIZE_MOVE_LOOP_TIMER_ID: usize = 1; -const AUTO_HIDE_TASKBAR_THICKNESS_PX: i32 = 1; impl WindowsWindowInner { pub(crate) fn handle_msg( @@ -40,6 +40,11 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> LRESULT { let handled = match msg { + // eagerly activate the window, so calls to `active_window` will work correctly + WM_MOUSEACTIVATE => { + unsafe { SetActiveWindow(handle).ok() }; + None + } WM_ACTIVATE => self.handle_activate_msg(wparam), WM_CREATE => self.handle_create_msg(handle), WM_MOVE => self.handle_move_msg(handle, lparam), @@ -123,13 +128,13 @@ impl WindowsWindowInner { ); self.state.origin.set(origin); let size = self.state.logical_size.get(); - let center_x = origin.x.0 + size.width.0 / 2.; - let center_y = origin.y.0 + size.height.0 / 2.; + let center_x = origin.x.as_f32() + size.width.as_f32() / 2.; + let center_y = origin.y.as_f32() + size.height.as_f32() / 2.; let monitor_bounds = self.state.display.get().bounds(); - if center_x < monitor_bounds.left().0 - || center_x > monitor_bounds.right().0 - || center_y < monitor_bounds.top().0 - || center_y > monitor_bounds.bottom().0 + if center_x < monitor_bounds.left().as_f32() + || center_x > monitor_bounds.right().as_f32() + || center_y < monitor_bounds.top().as_f32() + || center_y > monitor_bounds.bottom().as_f32() { // center of the window may have moved to another monitor let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONULL) }; @@ -156,10 +161,10 @@ impl WindowsWindowInner { unsafe { let minmax_info = &mut *(lparam.0 as *mut MINMAXINFO); - minmax_info.ptMinTrackSize.x = - min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset.get(); - minmax_info.ptMinTrackSize.y = - min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset.get(); + minmax_info.ptMinTrackSize.x = min_size.width.scale(scale_factor).as_f32() as i32 + + boarder_offset.width_offset.get(); + minmax_info.ptMinTrackSize.y = min_size.height.scale(scale_factor).as_f32() as i32 + + boarder_offset.height_offset.get(); } Some(0) } @@ -266,6 +271,14 @@ impl WindowsWindowInner { fn handle_destroy_msg(&self, handle: HWND) -> Option { let callback = { self.state.callbacks.close.take() }; + // Re-enable parent window if this was a modal dialog + if let Some(parent_hwnd) = self.parent_hwnd { + unsafe { + let _ = EnableWindow(parent_hwnd, true); + let _ = SetForegroundWindow(parent_hwnd); + } + } + if let Some(callback) = callback { callback(); } @@ -565,46 +578,78 @@ impl WindowsWindowInner { let caret_position = input_handler.bounds_for_range(caret_range.range)?; Some(POINT { // logical to physical - x: (caret_position.origin.x.0 * scale_factor) as i32, - y: (caret_position.origin.y.0 * scale_factor) as i32 - + ((caret_position.size.height.0 * scale_factor) as i32 / 2), + x: (caret_position.origin.x.as_f32() * scale_factor) as i32, + y: (caret_position.origin.y.as_f32() * scale_factor) as i32 + + ((caret_position.size.height.as_f32() * scale_factor) as i32 / 2), }) }) } fn handle_ime_position(&self, handle: HWND) -> Option { - unsafe { - let ctx = ImmGetContext(handle); + if let Some(caret_position) = self.retrieve_caret_position() { + self.update_ime_position(handle, caret_position); + } + Some(0) + } - let Some(caret_position) = self.retrieve_caret_position() else { - return Some(0); - }; - { - let config = COMPOSITIONFORM { + pub(crate) fn update_ime_position(&self, handle: HWND, caret_position: POINT) { + let Some(ctx) = ImeContext::get(handle) else { + return; + }; + unsafe { + ImmSetCompositionWindow( + *ctx, + &COMPOSITIONFORM { dwStyle: CFS_POINT, ptCurrentPos: caret_position, ..Default::default() - }; - ImmSetCompositionWindow(ctx, &config as _).ok().log_err(); - } - { - let config = CANDIDATEFORM { + }, + ) + .ok() + .log_err(); + + ImmSetCandidateWindow( + *ctx, + &CANDIDATEFORM { dwStyle: CFS_CANDIDATEPOS, ptCurrentPos: caret_position, ..Default::default() - }; - ImmSetCandidateWindow(ctx, &config as _).ok().log_err(); + }, + ) + .ok() + .log_err(); + } + } + + fn update_ime_enabled(&self, handle: HWND) { + let ime_enabled = self + .with_input_handler(|input_handler| input_handler.query_accepts_text_input()) + .unwrap_or(false); + if ime_enabled == self.state.ime_enabled.get() { + return; + } + self.state.ime_enabled.set(ime_enabled); + unsafe { + if ime_enabled { + ImmAssociateContextEx(handle, HIMC::default(), IACE_DEFAULT) + .ok() + .log_err(); + } else { + if let Some(ctx) = ImeContext::get(handle) { + ImmNotifyIME(*ctx, NI_COMPOSITIONSTR, CPS_COMPLETE, 0) + .ok() + .log_err(); + } + ImmAssociateContextEx(handle, HIMC::default(), 0) + .ok() + .log_err(); } - ImmReleaseContext(handle, ctx).ok().log_err(); - Some(0) } } fn handle_ime_composition(&self, handle: HWND, lparam: LPARAM) -> Option { - let ctx = unsafe { ImmGetContext(handle) }; - let result = self.handle_ime_composition_inner(ctx, lparam); - unsafe { ImmReleaseContext(handle, ctx).ok().log_err() }; - result + let ctx = ImeContext::get(handle)?; + self.handle_ime_composition_inner(*ctx, lparam) } fn handle_ime_composition_inner(&self, ctx: HIMC, lparam: LPARAM) -> Option { @@ -617,22 +662,34 @@ impl WindowsWindowInner { })?; Some(0) } else { + if lparam & GCS_RESULTSTR.0 > 0 { + let comp_result = parse_ime_composition_string(ctx, GCS_RESULTSTR)?; + self.with_input_handler(|input_handler| { + input_handler + .replace_text_in_range(None, &String::from_utf16_lossy(&comp_result)); + })?; + } if lparam & GCS_COMPSTR.0 > 0 { let comp_string = parse_ime_composition_string(ctx, GCS_COMPSTR)?; let caret_pos = (!comp_string.is_empty() && lparam & GCS_CURSORPOS.0 > 0).then(|| { - let pos = retrieve_composition_cursor_position(ctx); + let cursor_pos = retrieve_composition_cursor_position(ctx); + let pos = if should_use_ime_cursor_position(ctx, cursor_pos) { + cursor_pos + } else { + comp_string.len() + }; pos..pos }); self.with_input_handler(|input_handler| { - input_handler.replace_and_mark_text_in_range(None, &comp_string, caret_pos); + input_handler.replace_and_mark_text_in_range( + None, + &String::from_utf16_lossy(&comp_string), + caret_pos, + ); })?; } - if lparam & GCS_RESULTSTR.0 > 0 { - let comp_result = parse_ime_composition_string(ctx, GCS_RESULTSTR)?; - self.with_input_handler(|input_handler| { - input_handler.replace_text_in_range(None, &comp_result); - })?; + if lparam & (GCS_RESULTSTR.0 | GCS_COMPSTR.0) > 0 { return Some(0); } @@ -641,7 +698,6 @@ impl WindowsWindowInner { } } - /// SEE: https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-nccalcsize fn handle_calc_client_size( &self, handle: HWND, @@ -652,43 +708,17 @@ impl WindowsWindowInner { return None; } - let is_maximized = self.state.is_maximized(); - let insets = get_client_area_insets(handle, is_maximized, self.windows_version); - // wparam is TRUE so lparam points to an NCCALCSIZE_PARAMS structure - let mut params = lparam.0 as *mut NCCALCSIZE_PARAMS; - let mut requested_client_rect = unsafe { &mut ((*params).rgrc) }; - - requested_client_rect[0].left += insets.left; - requested_client_rect[0].top += insets.top; - requested_client_rect[0].right -= insets.right; - requested_client_rect[0].bottom -= insets.bottom; - - // Fix auto hide taskbar not showing. This solution is based on the approach - // used by Chrome. However, it may result in one row of pixels being obscured - // in our client area. But as Chrome says, "there seems to be no better solution." - if is_maximized - && let Some(taskbar_position) = self.system_settings().auto_hide_taskbar_position.get() - { - // For the auto-hide taskbar, adjust in by 1 pixel on taskbar edge, - // so the window isn't treated as a "fullscreen app", which would cause - // the taskbar to disappear. - match taskbar_position { - AutoHideTaskbarPosition::Left => { - requested_client_rect[0].left += AUTO_HIDE_TASKBAR_THICKNESS_PX - } - AutoHideTaskbarPosition::Top => { - requested_client_rect[0].top += AUTO_HIDE_TASKBAR_THICKNESS_PX - } - AutoHideTaskbarPosition::Right => { - requested_client_rect[0].right -= AUTO_HIDE_TASKBAR_THICKNESS_PX - } - AutoHideTaskbarPosition::Bottom => { - requested_client_rect[0].bottom -= AUTO_HIDE_TASKBAR_THICKNESS_PX - } + unsafe { + let params = lparam.0 as *mut NCCALCSIZE_PARAMS; + let saved_top = (*params).rgrc[0].top; + let result = DefWindowProcW(handle, WM_NCCALCSIZE, wparam, lparam); + (*params).rgrc[0].top = saved_top; + if self.state.is_maximized() { + let dpi = GetDpiForWindow(handle); + (*params).rgrc[0].top += get_frame_thicknessx(dpi); } + Some(result.0 as isize) } - - Some(0) } fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { @@ -785,34 +815,8 @@ impl WindowsWindowInner { Some(0) } - /// The following conditions will trigger this event: - /// 1. The monitor on which the window is located goes offline or changes resolution. - /// 2. Another monitor goes offline, is plugged in, or changes resolution. - /// - /// In either case, the window will only receive information from the monitor on which - /// it is located. - /// - /// For example, in the case of condition 2, where the monitor on which the window is - /// located has actually changed nothing, it will still receive this event. fn handle_display_change_msg(&self, handle: HWND) -> Option { - // NOTE: - // Even the `lParam` holds the resolution of the screen, we just ignore it. - // Because WM_DPICHANGED, WM_MOVE, WM_SIZE will come first, window reposition and resize - // are handled there. - // So we only care about if monitor is disconnected. - let previous_monitor = self.state.display.get(); - if WindowsDisplay::is_connected(previous_monitor.handle) { - // we are fine, other display changed - return None; - } - // display disconnected - // in this case, the OS will move our window to another monitor, and minimize it. - // we deminimize the window and query the monitor after moving - unsafe { - let _ = ShowWindow(handle, SW_SHOWNORMAL); - }; let new_monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONULL) }; - // all monitors disconnected if new_monitor.is_invalid() { log::error!("No monitor detected!"); return None; @@ -931,8 +935,7 @@ impl WindowsWindowInner { click_count, first_mouse: false, }); - let result = func(input); - let handled = !result.propagate || result.default_prevented; + let handled = !func(input).propagate; self.state.callbacks.input.set(Some(func)); if handled { @@ -1064,18 +1067,14 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { if wparam.0 != 0 { - let display = self.state.display.get(); self.state.click_state.system_update(wparam.0); self.state.border_offset.update(handle).log_err(); // system settings may emit a window message which wants to take the refcell self.state, so drop it - self.system_settings().update(display, wparam.0); + self.system_settings().update(wparam.0); } else { self.handle_system_theme_changed(handle, lparam)?; }; - // Force to trigger WM_NCCALCSIZE event to ensure that we handle auto hide - // taskbar correctly. - notify_frame_changed(handle); Some(0) } @@ -1154,6 +1153,7 @@ impl WindowsWindowInner { }); self.state.callbacks.request_frame.set(Some(request_frame)); + self.update_ime_enabled(handle); unsafe { ValidateRect(Some(handle), None).ok().log_err() }; Some(0) @@ -1236,6 +1236,36 @@ impl WindowsWindowInner { } } +struct ImeContext { + hwnd: HWND, + himc: HIMC, +} + +impl ImeContext { + fn get(hwnd: HWND) -> Option { + let himc = unsafe { ImmGetContext(hwnd) }; + if himc.is_invalid() { + return None; + } + Some(Self { hwnd, himc }) + } +} + +impl std::ops::Deref for ImeContext { + type Target = HIMC; + fn deref(&self) -> &HIMC { + &self.himc + } +} + +impl Drop for ImeContext { + fn drop(&mut self) { + unsafe { + ImmReleaseContext(self.hwnd, self.himc).ok().log_err(); + } + } +} + fn handle_key_event( wparam: WPARAM, lparam: LPARAM, @@ -1435,7 +1465,7 @@ fn process_key(vkey: VIRTUAL_KEY, scan_code: u16) -> (Option, bool) { ) } -fn parse_ime_composition_string(ctx: HIMC, comp_type: IME_COMPOSITION_STRING) -> Option { +fn parse_ime_composition_string(ctx: HIMC, comp_type: IME_COMPOSITION_STRING) -> Option> { unsafe { let string_len = ImmGetCompositionStringW(ctx, comp_type, None, 0); if string_len >= 0 { @@ -1450,7 +1480,7 @@ fn parse_ime_composition_string(ctx: HIMC, comp_type: IME_COMPOSITION_STRING) -> buffer.as_mut_ptr().cast::(), string_len as usize / 2, ); - Some(String::from_utf16_lossy(wstring)) + Some(wstring.to_vec()) } else { None } @@ -1462,6 +1492,35 @@ fn retrieve_composition_cursor_position(ctx: HIMC) -> usize { unsafe { ImmGetCompositionStringW(ctx, GCS_CURSORPOS, None, 0) as usize } } +fn should_use_ime_cursor_position(ctx: HIMC, cursor_pos: usize) -> bool { + let attrs_size = unsafe { ImmGetCompositionStringW(ctx, GCS_COMPATTR, None, 0) } as usize; + if attrs_size == 0 { + return false; + } + + let mut attrs = vec![0u8; attrs_size]; + let result = unsafe { + ImmGetCompositionStringW( + ctx, + GCS_COMPATTR, + Some(attrs.as_mut_ptr() as *mut _), + attrs_size as u32, + ) + }; + if result <= 0 { + return false; + } + + // Keep the cursor adjacent to the inserted text by only using the suggested position + // if it's adjacent to unconverted text. + let at_cursor_is_input = cursor_pos < attrs.len() && attrs[cursor_pos] == (ATTR_INPUT as u8); + let before_cursor_is_input = cursor_pos > 0 + && (cursor_pos - 1) < attrs.len() + && attrs[cursor_pos - 1] == (ATTR_INPUT as u8); + + at_cursor_is_input || before_cursor_is_input +} + #[inline] fn is_virtual_key_pressed(vkey: VIRTUAL_KEY) -> bool { unsafe { GetKeyState(vkey.0 as i32) < 0 } @@ -1484,44 +1543,6 @@ pub(crate) fn current_capslock() -> Capslock { Capslock { on } } -fn get_client_area_insets( - handle: HWND, - is_maximized: bool, - windows_version: WindowsVersion, -) -> RECT { - // For maximized windows, Windows outdents the window rect from the screen's client rect - // by `frame_thickness` on each edge, meaning `insets` must contain `frame_thickness` - // on all sides (including the top) to avoid the client area extending onto adjacent - // monitors. - // - // For non-maximized windows, things become complicated: - // - // - On Windows 10 - // The top inset must be zero, since if there is any nonclient area, Windows will draw - // a full native titlebar outside the client area. (This doesn't occur in the maximized - // case.) - // - // - On Windows 11 - // The top inset is calculated using an empirical formula that I derived through various - // tests. Without this, the top 1-2 rows of pixels in our window would be obscured. - let dpi = unsafe { GetDpiForWindow(handle) }; - let frame_thickness = get_frame_thicknessx(dpi); - let top_insets = if is_maximized { - frame_thickness - } else { - match windows_version { - WindowsVersion::Win10 => 0, - WindowsVersion::Win11 => (dpi as f32 / USER_DEFAULT_SCREEN_DPI as f32).round() as i32, - } - }; - RECT { - left: frame_thickness, - top: top_insets, - right: frame_thickness, - bottom: frame_thickness, - } -} - // there is some additional non-visible space when talking about window // borders on Windows: // - SM_CXSIZEFRAME: The resize handle. diff --git a/src/platform/windows/keyboard.rs b/src/platform/windows/keyboard.rs index 627988be57..8164bc1564 100644 --- a/src/platform/windows/keyboard.rs +++ b/src/platform/windows/keyboard.rs @@ -10,7 +10,7 @@ use windows::Win32::UI::{ WindowsAndMessaging::KL_NAMELENGTH, }; -use crate::{ +use gpui::{ KeybindingKeystroke, Keystroke, Modifiers, PlatformKeyboardLayout, PlatformKeyboardMapper, }; @@ -316,7 +316,8 @@ const CANDIDATE_VKEYS: &[VIRTUAL_KEY] = &[ #[cfg(test)] mod tests { - use crate::{Keystroke, Modifiers, PlatformKeyboardMapper, WindowsKeyboardMapper}; + use crate::WindowsKeyboardMapper; + use gpui::{Keystroke, Modifiers, PlatformKeyboardMapper}; #[test] fn test_keyboard_mapper() { diff --git a/src/platform/windows/platform.rs b/src/platform/windows/platform.rs index fa847bca6b..ecf7b95d59 100644 --- a/src/platform/windows/platform.rs +++ b/src/platform/windows/platform.rs @@ -28,17 +28,19 @@ use windows::{ }; use crate::*; +use gpui::*; -pub(crate) struct WindowsPlatform { +pub struct WindowsPlatform { inner: Rc, raw_window_handles: Arc>>, // The below members will never change throughout the entire lifecycle of the app. + headless: bool, icon: HICON, background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, - text_system: Arc, - windows_version: WindowsVersion, - drop_target_helper: IDropTargetHelper, + text_system: Arc, + direct_write_text_system: Option>, + drop_target_helper: Option, /// Flag to instruct the `VSyncProvider` thread to invalidate the directx devices /// as resizing them has failed, causing us to have lost at least the render target. invalidate_devices: Arc, @@ -76,11 +78,10 @@ struct PlatformCallbacks { } impl WindowsPlatformState { - fn new(directx_devices: DirectXDevices) -> Self { + fn new(directx_devices: Option) -> Self { let callbacks = PlatformCallbacks::default(); let jump_list = JumpList::new(); let current_cursor = load_cursor(CursorStyle::Arrow); - let directx_devices = Some(directx_devices); Self { callbacks, @@ -93,11 +94,29 @@ impl WindowsPlatformState { } impl WindowsPlatform { - pub(crate) fn new() -> Result { + pub fn new(headless: bool) -> Result { unsafe { OleInitialize(None).context("unable to initialize Windows OLE")?; } - let directx_devices = DirectXDevices::new().context("Creating DirectX devices")?; + let (directx_devices, text_system, direct_write_text_system) = if !headless { + let devices = DirectXDevices::new().context("Creating DirectX devices")?; + let dw_text_system = Arc::new( + DirectWriteTextSystem::new(&devices) + .context("Error creating DirectWriteTextSystem")?, + ); + ( + Some(devices), + dw_text_system.clone() as Arc, + Some(dw_text_system), + ) + } else { + ( + None, + Arc::new(gpui::NoopTextSystem::new()) as Arc, + None, + ) + }; + let (main_sender, main_receiver) = PriorityQueueReceiver::new(); let validation_number = if usize::BITS == 64 { rand::random::() as usize @@ -105,10 +124,7 @@ impl WindowsPlatform { rand::random::() as usize }; let raw_window_handles = Arc::new(RwLock::new(SmallVec::new())); - let text_system = Arc::new( - DirectWriteTextSystem::new(&directx_devices) - .context("Error creating DirectWriteTextSystem")?, - ); + register_platform_window_class(); let mut context = PlatformWindowCreateContext { inner: None, @@ -116,7 +132,7 @@ impl WindowsPlatform { validation_number, main_sender: Some(main_sender), main_receiver: Some(main_receiver), - directx_devices: Some(directx_devices), + directx_devices, dispatcher: None, }; let result = unsafe { @@ -150,29 +166,37 @@ impl WindowsPlatform { let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher); - let drop_target_helper: IDropTargetHelper = unsafe { - CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER) - .context("Error creating drop target helper.")? + let drop_target_helper: Option = if !headless { + Some(unsafe { + CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER) + .context("Error creating drop target helper.")? + }) + } else { + None + }; + let icon = if !headless { + load_icon().unwrap_or_default() + } else { + HICON::default() }; - let icon = load_icon().unwrap_or_default(); - let windows_version = WindowsVersion::new().context("Error retrieve windows version")?; Ok(Self { inner, handle, raw_window_handles, + headless, icon, background_executor, foreground_executor, text_system, + direct_write_text_system, disable_direct_composition, - windows_version, drop_target_helper, invalidate_devices: Arc::new(AtomicBool::new(false)), }) } - pub fn window_from_hwnd(&self, hwnd: HWND) -> Option> { + pub(crate) fn window_from_hwnd(&self, hwnd: HWND) -> Option> { self.raw_window_handles .read() .iter() @@ -195,8 +219,7 @@ impl WindowsPlatform { icon: self.icon, executor: self.foreground_executor.clone(), current_cursor: self.inner.state.current_cursor.get(), - windows_version: self.windows_version, - drop_target_helper: self.drop_target_helper.clone(), + drop_target_helper: self.drop_target_helper.clone().unwrap(), validation_number: self.inner.validation_number, main_receiver: self.inner.main_receiver.clone(), platform_window_handle: self.handle, @@ -214,14 +237,25 @@ impl WindowsPlatform { } }); self.inner.state.jump_list.borrow_mut().dock_menus = actions; - update_jump_list(&self.inner.state.jump_list.borrow()).log_err(); + let borrow = self.inner.state.jump_list.borrow(); + let dock_menus = borrow + .dock_menus + .iter() + .map(|menu| (menu.name.clone(), menu.description.clone())) + .collect::>(); + let recent_workspaces = borrow.recent_workspaces.clone(); + self.background_executor + .spawn(async move { + update_jump_list(&recent_workspaces, &dock_menus).log_err(); + }) + .detach(); } fn update_jump_list( &self, menus: Vec, entries: Vec>, - ) -> Vec> { + ) -> Task>> { let mut actions = Vec::new(); menus.into_iter().for_each(|menu| { if let Some(dock_menu) = DockMenuItem::new(menu).log_err() { @@ -230,8 +264,18 @@ impl WindowsPlatform { }); let mut jump_list = self.inner.state.jump_list.borrow_mut(); jump_list.dock_menus = actions; - jump_list.recent_workspaces = entries; - update_jump_list(&jump_list).log_err().unwrap_or_default() + jump_list.recent_workspaces = entries.into(); + let dock_menus = jump_list + .dock_menus + .iter() + .map(|menu| (menu.name.clone(), menu.description.clone())) + .collect::>(); + let recent_workspaces = jump_list.recent_workspaces.clone(); + self.background_executor.spawn(async move { + update_jump_list(&recent_workspaces, &dock_menus) + .log_err() + .unwrap_or_default() + }) } fn find_current_active_window(&self) -> Option { @@ -247,11 +291,17 @@ impl WindowsPlatform { } fn begin_vsync_thread(&self) { - let mut directx_device = self.inner.state.directx_devices.borrow().clone().unwrap(); + let Some(directx_devices) = self.inner.state.directx_devices.borrow().clone() else { + return; + }; + let Some(direct_write_text_system) = &self.direct_write_text_system else { + return; + }; + let mut directx_device = directx_devices; let platform_window: SafeHwnd = self.handle.into(); let validation_number = self.inner.validation_number; let all_windows = Arc::downgrade(&self.raw_window_handles); - let text_system = Arc::downgrade(&self.text_system); + let text_system = Arc::downgrade(direct_write_text_system); let invalidate_devices = self.invalidate_devices.clone(); std::thread::Builder::new() @@ -336,9 +386,17 @@ impl Platform for WindowsPlatform { .set(Some(callback)); } + fn on_thermal_state_change(&self, _callback: Box) {} + + fn thermal_state(&self) -> ThermalState { + ThermalState::Nominal + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); - self.begin_vsync_thread(); + if !self.headless { + self.begin_vsync_thread(); + } let mut msg = MSG::default(); unsafe { @@ -383,20 +441,28 @@ impl Platform for WindowsPlatform { app_path.display(), ); - #[allow( - clippy::disallowed_methods, - reason = "We are restarting ourselves, using std command thus is fine" - )] // todo(shell): There might be no powershell on the system - let restart_process = - util::command::new_std_command(util::shell::get_windows_system_shell()) - .arg("-command") - .arg(script) - .spawn(); + // Defer spawning to the foreground executor so it runs after the + // current `AppCell` borrow is released. On Windows, `Command::spawn()` + // can pump the Win32 message loop (via `CreateProcessW`), which + // re-enters message handling possibly resulting in another mutable + // borrow of the `AppCell` ending up with a double borrow panic + self.foreground_executor + .spawn(async move { + #[allow( + clippy::disallowed_methods, + reason = "We are restarting ourselves, using std command thus is fine" + )] + let restart_process = crate::command::new_std_command("powershell") + .arg("-command") + .arg(script) + .spawn(); - match restart_process { - Ok(_) => self.quit(), - Err(e) => log::error!("failed to spawn restart script: {:?}", e), - } + match restart_process { + Ok(_) => unsafe { PostQuitMessage(0) }, + Err(e) => log::error!("failed to spawn restart script: {:?}", e), + } + }) + .detach(); } fn activate(&self, _ignoring_other_apps: bool) {} @@ -430,7 +496,7 @@ impl Platform for WindowsPlatform { fn screen_capture_sources( &self, ) -> oneshot::Receiver>>> { - crate::platform::scap_screen_capture::scap_screen_sources(&self.foreground_executor) + gpui::scap_screen_capture::scap_screen_sources(&self.foreground_executor) } fn active_window(&self) -> Option { @@ -617,7 +683,7 @@ impl Platform for WindowsPlatform { } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { - let mut password = password.to_vec(); + let password = password.to_vec(); let mut username = username.encode_utf16().chain(Some(0)).collect_vec(); let mut target_name = windows_credentials_target_name(url) .encode_utf16() @@ -635,13 +701,20 @@ impl Platform for WindowsPlatform { UserName: PWSTR::from_raw(username.as_mut_ptr()), ..CREDENTIALW::default() }; - unsafe { CredWriteW(&credentials, 0) }?; + unsafe { + CredWriteW(&credentials, 0).map_err(|err| { + anyhow!( + "Failed to write credentials to Windows Credential Manager: {}", + err, + ) + })?; + } Ok(()) }) } fn read_credentials(&self, url: &str) -> Task)>>> { - let mut target_name = windows_credentials_target_name(url) + let target_name = windows_credentials_target_name(url) .encode_utf16() .chain(Some(0)) .collect_vec(); @@ -659,7 +732,7 @@ impl Platform for WindowsPlatform { if let Err(err) = result { // ERROR_NOT_FOUND means the credential doesn't exist. // Return Ok(None) to match macOS and Linux behavior. - if err.code().0 == ERROR_NOT_FOUND.0 as i32 { + if err.code() == ERROR_NOT_FOUND.to_hresult() { return Ok(None); } return Err(err.into()); @@ -683,7 +756,7 @@ impl Platform for WindowsPlatform { } fn delete_credentials(&self, url: &str) -> Task> { - let mut target_name = windows_credentials_target_name(url) + let target_name = windows_credentials_target_name(url) .encode_utf16() .chain(Some(0)) .collect_vec(); @@ -719,19 +792,14 @@ impl Platform for WindowsPlatform { &self, menus: Vec, entries: Vec>, - ) -> Vec> { + ) -> Task>> { self.update_jump_list(menus, entries) } } impl WindowsPlatformInner { fn new(context: &mut PlatformWindowCreateContext) -> Result> { - let state = WindowsPlatformState::new( - context - .directx_devices - .take() - .context("missing directx devices")?, - ); + let state = WindowsPlatformState::new(context.directx_devices.take()); Ok(Rc::new(Self { state, raw_window_handles: context.raw_window_handles.clone(), @@ -838,6 +906,8 @@ impl WindowsPlatformInner { let peek_msg = |msg: &mut _, msg_kind| unsafe { PeekMessageW(msg, None, 0, 0, PM_REMOVE | msg_kind).as_bool() }; + // We need to process a paint message here as otherwise we will re-enter `run_foreground_task` before painting if we have work remaining. + // The reason for this is that windows prefers custom application message processing over system messages. if peek_msg(&mut msg, PM_QS_PAINT) { process_message(&msg); } @@ -933,7 +1003,6 @@ pub(crate) struct WindowCreationInfo { pub(crate) icon: HICON, pub(crate) executor: ForegroundExecutor, pub(crate) current_cursor: Option, - pub(crate) windows_version: WindowsVersion, pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, @@ -1271,7 +1340,8 @@ unsafe extern "system" fn window_procedure( #[cfg(test)] mod tests { - use crate::{ClipboardItem, read_from_clipboard, write_to_clipboard}; + use crate::{read_from_clipboard, write_to_clipboard}; + use gpui::ClipboardItem; #[test] fn test_clipboard() { diff --git a/src/platform/windows/shaders.hlsl b/src/platform/windows/shaders.hlsl index d6168eea09..f508387daf 100644 --- a/src/platform/windows/shaders.hlsl +++ b/src/platform/windows/shaders.hlsl @@ -4,12 +4,17 @@ cbuffer GlobalParams: register(b0) { float4 gamma_ratios; float2 global_viewport_size; float grayscale_enhanced_contrast; - uint _pad; + float subpixel_enhanced_contrast; }; Texture2D t_sprite: register(t0); SamplerState s_sprite: register(s0); +struct SubpixelSpriteFragmentOutput { + float4 foreground : SV_Target0; + float4 alpha : SV_Target1; +}; + struct Bounds { float2 origin; float2 size; @@ -304,7 +309,7 @@ float quad_sdf(float2 pt, Bounds bounds, Corners corner_radii) { GradientColor prepare_gradient_color(uint tag, uint color_space, Hsla solid, LinearColorStop colors[2]) { GradientColor output; - if (tag == 0 || tag == 2) { + if (tag == 0 || tag == 2 || tag == 3) { output.solid = hsla_to_rgba(solid); } else if (tag == 1) { output.color0 = hsla_to_rgba(colors[0].color); @@ -397,6 +402,19 @@ float4 gradient_color(Background background, color.a *= saturate(0.5 - distance); break; } + case 3: { + // checkerboard + float size = background.gradient_angle_or_pattern_height; + float2 relative_position = position - bounds.origin; + + float x_index = floor(relative_position.x / size); + float y_index = floor(relative_position.y / size); + float should_be_colored = (x_index + y_index) % 2.0; + + color = solid_color; + color.a *= saturate(should_be_colored); + break; + } } return color; @@ -1119,6 +1137,20 @@ float4 monochrome_sprite_fragment(MonochromeSpriteFragmentInput input): SV_Targe return float4(input.color.rgb, input.color.a * alpha_corrected); } +MonochromeSpriteVertexOutput subpixel_sprite_vertex(uint vertex_id: SV_VertexID, uint sprite_id: SV_InstanceID) { + return monochrome_sprite_vertex(vertex_id, sprite_id); +} + +SubpixelSpriteFragmentOutput subpixel_sprite_fragment(MonochromeSpriteFragmentInput input) { + float3 sample = t_sprite.Sample(s_sprite, input.tile_position).rgb; + float3 alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, subpixel_enhanced_contrast, gamma_ratios); + + SubpixelSpriteFragmentOutput output; + output.foreground = float4(input.color.rgb, 1.0f); + output.alpha = float4(input.color.a * alpha_corrected, 1.0f); + return output; +} + /* ** ** Polychrome sprites diff --git a/src/platform/windows/system_settings.rs b/src/platform/windows/system_settings.rs index f5ef5ce31e..53214f40de 100644 --- a/src/platform/windows/system_settings.rs +++ b/src/platform/windows/system_settings.rs @@ -4,24 +4,16 @@ use std::{ }; use ::util::ResultExt; -use windows::Win32::UI::{ - Shell::{ABM_GETSTATE, ABM_GETTASKBARPOS, ABS_AUTOHIDE, APPBARDATA, SHAppBarMessage}, - WindowsAndMessaging::{ - SPI_GETWHEELSCROLLCHARS, SPI_GETWHEELSCROLLLINES, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, - SystemParametersInfoW, - }, +use windows::Win32::UI::WindowsAndMessaging::{ + SPI_GETWHEELSCROLLCHARS, SPI_GETWHEELSCROLLLINES, SYSTEM_PARAMETERS_INFO_ACTION, + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SystemParametersInfoW, }; -use crate::*; - -use super::WindowsDisplay; - /// Windows settings pulled from SystemParametersInfo /// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-systemparametersinfow #[derive(Default, Debug, Clone)] pub(crate) struct WindowsSystemSettings { pub(crate) mouse_wheel_settings: MouseWheelSettings, - pub(crate) auto_hide_taskbar_position: Cell>, } #[derive(Default, Debug, Clone)] @@ -33,24 +25,19 @@ pub(crate) struct MouseWheelSettings { } impl WindowsSystemSettings { - pub(crate) fn new(display: WindowsDisplay) -> Self { + pub(crate) fn new() -> Self { let mut settings = Self::default(); - settings.init(display); + settings.init(); settings } - fn init(&self, display: WindowsDisplay) { + fn init(&mut self) { self.mouse_wheel_settings.update(); - self.auto_hide_taskbar_position - .set(AutoHideTaskbarPosition::new(display).log_err().flatten()); } - pub(crate) fn update(&self, display: WindowsDisplay, wparam: usize) { - match wparam { - // SPI_SETWORKAREA - 47 => self.update_taskbar_position(display), - // SPI_GETWHEELSCROLLLINES, SPI_GETWHEELSCROLLCHARS - 104 | 108 => self.update_mouse_wheel_settings(), + pub(crate) fn update(&self, wparam: usize) { + match SYSTEM_PARAMETERS_INFO_ACTION(wparam as u32) { + SPI_GETWHEELSCROLLLINES | SPI_GETWHEELSCROLLCHARS => self.update_mouse_wheel_settings(), _ => {} } } @@ -58,11 +45,6 @@ impl WindowsSystemSettings { fn update_mouse_wheel_settings(&self) { self.mouse_wheel_settings.update(); } - - fn update_taskbar_position(&self, display: WindowsDisplay) { - self.auto_hide_taskbar_position - .set(AutoHideTaskbarPosition::new(display).log_err().flatten()); - } } impl MouseWheelSettings { @@ -103,100 +85,3 @@ impl MouseWheelSettings { } } } - -#[derive(Debug, Clone, Copy, Default)] -pub(crate) enum AutoHideTaskbarPosition { - Left, - Right, - Top, - #[default] - Bottom, -} - -impl AutoHideTaskbarPosition { - fn new(display: WindowsDisplay) -> anyhow::Result> { - if !check_auto_hide_taskbar_enable() { - // If auto hide taskbar is not enable, we do nothing in this case. - return Ok(None); - } - let mut info = APPBARDATA { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let ret = unsafe { SHAppBarMessage(ABM_GETTASKBARPOS, &mut info) }; - if ret == 0 { - anyhow::bail!( - "Unable to retrieve taskbar position: {}", - std::io::Error::last_os_error() - ); - } - let taskbar_bounds: Bounds = Bounds::new( - point(info.rc.left.into(), info.rc.top.into()), - size( - (info.rc.right - info.rc.left).into(), - (info.rc.bottom - info.rc.top).into(), - ), - ); - let display_bounds = display.physical_bounds(); - if display_bounds.intersect(&taskbar_bounds) != taskbar_bounds { - // This case indicates that taskbar is not on the current monitor. - return Ok(None); - } - if taskbar_bounds.bottom() == display_bounds.bottom() - && taskbar_bounds.right() == display_bounds.right() - { - if taskbar_bounds.size.height < display_bounds.size.height - && taskbar_bounds.size.width == display_bounds.size.width - { - return Ok(Some(Self::Bottom)); - } - if taskbar_bounds.size.width < display_bounds.size.width - && taskbar_bounds.size.height == display_bounds.size.height - { - return Ok(Some(Self::Right)); - } - log::error!( - "Unrecognized taskbar bounds {:?} give display bounds {:?}", - taskbar_bounds, - display_bounds - ); - return Ok(None); - } - if taskbar_bounds.top() == display_bounds.top() - && taskbar_bounds.left() == display_bounds.left() - { - if taskbar_bounds.size.height < display_bounds.size.height - && taskbar_bounds.size.width == display_bounds.size.width - { - return Ok(Some(Self::Top)); - } - if taskbar_bounds.size.width < display_bounds.size.width - && taskbar_bounds.size.height == display_bounds.size.height - { - return Ok(Some(Self::Left)); - } - log::error!( - "Unrecognized taskbar bounds {:?} give display bounds {:?}", - taskbar_bounds, - display_bounds - ); - return Ok(None); - } - log::error!( - "Unrecognized taskbar bounds {:?} give display bounds {:?}", - taskbar_bounds, - display_bounds - ); - Ok(None) - } -} - -/// Check if auto hide taskbar is enable or not. -fn check_auto_hide_taskbar_enable() -> bool { - let mut info = APPBARDATA { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let ret = unsafe { SHAppBarMessage(ABM_GETSTATE, &mut info) } as u32; - ret == ABS_AUTOHIDE -} diff --git a/src/platform/windows/util.rs b/src/platform/windows/util.rs index af71dfe4a1..fe5093dede 100644 --- a/src/platform/windows/util.rs +++ b/src/platform/windows/util.rs @@ -7,35 +7,15 @@ use windows::{ Color, ViewManagement::{UIColorType, UISettings}, }, - Wdk::System::SystemServices::RtlGetVersion, Win32::{ Foundation::*, Graphics::Dwm::*, System::LibraryLoader::LoadLibraryA, UI::WindowsAndMessaging::*, }, - core::{BOOL, HSTRING, PCSTR}, + core::{BOOL, PCSTR}, }; use crate::*; - -#[derive(Debug, Clone, Copy)] -pub(crate) enum WindowsVersion { - Win10, - Win11, -} - -impl WindowsVersion { - pub(crate) fn new() -> anyhow::Result { - let mut version = unsafe { std::mem::zeroed() }; - let status = unsafe { RtlGetVersion(&mut version) }; - - status.ok()?; - if version.dwBuildNumber >= 22000 { - Ok(WindowsVersion::Win11) - } else { - Ok(WindowsVersion::Win10) - } - } -} +use gpui::*; pub(crate) trait HiLoWord { fn hiword(&self) -> u16; @@ -117,6 +97,8 @@ pub(crate) fn load_cursor(style: CursorStyle) -> Option { static HAND: OnceLock = OnceLock::new(); static SIZEWE: OnceLock = OnceLock::new(); static SIZENS: OnceLock = OnceLock::new(); + static SIZENWSE: OnceLock = OnceLock::new(); + static SIZENESW: OnceLock = OnceLock::new(); static NO: OnceLock = OnceLock::new(); let (lock, name) = match style { CursorStyle::IBeam | CursorStyle::IBeamCursorForVerticalLayout => (&IBEAM, IDC_IBEAM), @@ -130,6 +112,8 @@ pub(crate) fn load_cursor(style: CursorStyle) -> Option { | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown | CursorStyle::ResizeRow => (&SIZENS, IDC_SIZENS), + CursorStyle::ResizeUpLeftDownRight => (&SIZENWSE, IDC_SIZENWSE), + CursorStyle::ResizeUpRightDownLeft => (&SIZENESW, IDC_SIZENESW), CursorStyle::OperationNotAllowed => (&NO, IDC_NO), CursorStyle::None => return None, _ => (&ARROW, IDC_ARROW), @@ -191,17 +175,6 @@ fn is_color_light(color: &Color) -> bool { ((5 * color.G as u32) + (2 * color.R as u32) + color.B as u32) > (8 * 128) } -pub(crate) fn show_error(title: &str, content: String) { - let _ = unsafe { - MessageBoxW( - None, - &HSTRING::from(content), - &HSTRING::from(title), - MB_ICONERROR | MB_SYSTEMMODAL, - ) - }; -} - pub(crate) fn with_dll_library(dll_name: PCSTR, f: F) -> Result where F: FnOnce(HMODULE) -> Result, diff --git a/src/platform/windows/window.rs b/src/platform/windows/window.rs index 0cfa812b28..62e88c47df 100644 --- a/src/platform/windows/window.rs +++ b/src/platform/windows/window.rs @@ -27,6 +27,7 @@ use windows::{ }; use crate::*; +use gpui::*; pub(crate) struct WindowsWindow(pub Rc); @@ -45,11 +46,13 @@ pub struct WindowsWindowState { pub fullscreen_restore_bounds: Cell>, pub border_offset: WindowBorderOffset, pub appearance: Cell, + pub background_appearance: Cell, pub scale_factor: Cell, pub restore_from_minimized: Cell>>, pub callbacks: Callbacks, pub input_handler: Cell>, + pub ime_enabled: Cell, pub pending_surrogate: Cell>, pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, @@ -79,10 +82,10 @@ pub(crate) struct WindowsWindowInner { pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, pub(crate) executor: ForegroundExecutor, - pub(crate) windows_version: WindowsVersion, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, pub(crate) platform_window_handle: HWND, + pub(crate) parent_hwnd: Option, } impl WindowsWindowState { @@ -134,11 +137,13 @@ impl WindowsWindowState { fullscreen_restore_bounds: Cell::new(fullscreen_restore_bounds), border_offset, appearance: Cell::new(appearance), + background_appearance: Cell::new(WindowBackgroundAppearance::Opaque), scale_factor: Cell::new(scale_factor), restore_from_minimized: Cell::new(restore_from_minimized), min_size, callbacks, input_handler: Cell::new(input_handler), + ime_enabled: Cell::new(true), pending_surrogate: Cell::new(pending_surrogate), last_reported_modifiers: Cell::new(last_reported_modifiers), last_reported_capslock: Cell::new(last_reported_capslock), @@ -236,11 +241,11 @@ impl WindowsWindowInner { hide_title_bar: context.hide_title_bar, is_movable: context.is_movable, executor: context.executor.clone(), - windows_version: context.windows_version, validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), platform_window_handle: context.platform_window_handle, - system_settings: WindowsSystemSettings::new(context.display), + system_settings: WindowsSystemSettings::new(), + parent_hwnd: context.parent_hwnd, })) } @@ -289,6 +294,7 @@ impl WindowsWindowInner { } } }; + set_non_rude_hwnd(this.hwnd, !this.state.is_fullscreen()); unsafe { set_window_long(this.hwnd, GWL_STYLE, style.0 as isize) }; unsafe { SetWindowPos( @@ -339,7 +345,7 @@ impl WindowsWindowInner { #[derive(Default)] pub(crate) struct Callbacks { pub(crate) request_frame: Cell>>, - pub(crate) input: Cell DispatchEventResult>>>, + pub(crate) input: Cell DispatchEventResult>>>, pub(crate) active_status_change: Cell>>, pub(crate) hovered_status_change: Cell>>, pub(crate) resize: Cell, f32)>>>, @@ -359,7 +365,6 @@ struct WindowCreateContext { min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, - windows_version: WindowsVersion, drop_target_helper: IDropTargetHelper, validation_number: usize, main_receiver: PriorityQueueReceiver, @@ -368,6 +373,7 @@ struct WindowCreateContext { disable_direct_composition: bool, directx_devices: DirectXDevices, invalidate_devices: Arc, + parent_hwnd: Option, } impl WindowsWindow { @@ -380,7 +386,6 @@ impl WindowsWindow { icon, executor, current_cursor, - windows_version, drop_target_helper, validation_number, main_receiver, @@ -390,6 +395,20 @@ impl WindowsWindow { invalidate_devices, } = creation_info; register_window_class(icon); + let parent_hwnd = if params.kind == WindowKind::Dialog { + let parent_window = unsafe { GetActiveWindow() }; + if parent_window.is_invalid() { + None + } else { + // Disable the parent window to make this dialog modal + unsafe { + EnableWindow(parent_window, false).as_bool(); + }; + Some(parent_window) + } + } else { + None + }; let hide_title_bar = params .titlebar .as_ref() @@ -416,8 +435,14 @@ impl WindowsWindow { if params.is_minimizable { dwstyle |= WS_MINIMIZEBOX; } + let dwexstyle = if params.kind == WindowKind::Dialog { + dwstyle |= WS_POPUP | WS_CAPTION; + WS_EX_DLGMODALFRAME + } else { + WS_EX_APPWINDOW + }; - (WS_EX_APPWINDOW, dwstyle) + (dwexstyle, dwstyle) }; if !disable_direct_composition { dwexstyle |= WS_EX_NOREDIRECTIONBITMAP; @@ -440,7 +465,6 @@ impl WindowsWindow { min_size: params.window_min_size, executor, current_cursor, - windows_version, drop_target_helper, validation_number, main_receiver, @@ -449,6 +473,7 @@ impl WindowsWindow { disable_direct_composition, directx_devices, invalidate_devices, + parent_hwnd, }; let creation_result = unsafe { CreateWindowExW( @@ -460,7 +485,7 @@ impl WindowsWindow { CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, - None, + parent_hwnd, None, Some(hinstance.into()), Some(&context as *const _ as *const _), @@ -474,6 +499,7 @@ impl WindowsWindow { let this = this.unwrap(); register_drag_drop(&this)?; + set_non_rude_hwnd(hwnd, true); configure_dwm_dark_mode(hwnd, appearance); this.state.border_offset.update(hwnd)?; let placement = retrieve_window_placement( @@ -553,8 +579,7 @@ impl PlatformWindow for WindowsWindow { fn resize(&mut self, size: Size) { let hwnd = self.0.hwnd; - let bounds = - crate::bounds(self.bounds().origin, size).to_device_pixels(self.scale_factor()); + let bounds = gpui::bounds(self.bounds().origin, size).to_device_pixels(self.scale_factor()); let rect = calculate_window_rect(bounds, &self.state.border_offset); self.0 @@ -640,15 +665,15 @@ impl PlatformWindow for WindowsWindow { let title; let main_icon; match level { - crate::PromptLevel::Info => { + PromptLevel::Info => { title = windows::core::w!("Info"); main_icon = TD_INFORMATION_ICON; } - crate::PromptLevel::Warning => { + PromptLevel::Warning => { title = windows::core::w!("Warning"); main_icon = TD_WARNING_ICON; } - crate::PromptLevel::Critical => { + PromptLevel::Critical => { title = windows::core::w!("Critical"); main_icon = TD_ERROR_ICON; } @@ -716,8 +741,8 @@ impl PlatformWindow for WindowsWindow { ShowWindowAsync(hwnd, SW_RESTORE).ok().log_err(); } - SetActiveWindow(hwnd).log_err(); - SetFocus(Some(hwnd)).log_err(); + SetActiveWindow(hwnd).ok(); + SetFocus(Some(hwnd)).ok(); } // premium ragebait by windows, this is needed because the window @@ -764,6 +789,14 @@ impl PlatformWindow for WindowsWindow { self.state.hovered.get() } + fn background_appearance(&self) -> WindowBackgroundAppearance { + self.state.background_appearance.get() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + true + } + fn set_title(&mut self, title: &str) { unsafe { SetWindowTextW(self.0.hwnd, &HSTRING::from(title)) } .inspect_err(|e| log::error!("Set title failed: {e}")) @@ -771,6 +804,7 @@ impl PlatformWindow for WindowsWindow { } fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { + self.state.background_appearance.set(background_appearance); let hwnd = self.0.hwnd; // using Dwm APIs for Mica and MicaAlt backdrops. @@ -881,7 +915,11 @@ impl PlatformWindow for WindowsWindow { } fn draw(&self, scene: &Scene) { - self.state.renderer.borrow_mut().draw(scene).log_err(); + self.state + .renderer + .borrow_mut() + .draw(scene, self.state.background_appearance.get()) + .log_err(); } fn sprite_atlas(&self) -> Arc { @@ -896,8 +934,15 @@ impl PlatformWindow for WindowsWindow { self.state.renderer.borrow().gpu_specs().log_err() } - fn update_ime_position(&self, _bounds: Bounds) { - // There is no such thing on Windows. + fn update_ime_position(&self, bounds: Bounds) { + let scale_factor = self.state.scale_factor.get(); + let caret_position = POINT { + x: (bounds.origin.x.as_f32() * scale_factor) as i32, + y: (bounds.origin.y.as_f32() * scale_factor) as i32 + + ((bounds.size.height.as_f32() * scale_factor) as i32 / 2), + }; + + self.0.update_ime_position(self.0.hwnd, caret_position); } } @@ -1419,15 +1464,26 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32 } } +// When the platform title bar is hidden, Windows may think that our application is meant to appear 'fullscreen' +// and will stop the taskbar from appearing on top of our window. Prevent this. +// https://devblogs.microsoft.com/oldnewthing/20250522-00/?p=111211 +fn set_non_rude_hwnd(hwnd: HWND, non_rude: bool) { + if non_rude { + unsafe { SetPropW(hwnd, w!("NonRudeHWND"), Some(HANDLE(1 as _))) }.log_err(); + } else { + unsafe { RemovePropW(hwnd, w!("NonRudeHWND")) }.log_err(); + } +} + #[cfg(test)] mod tests { use super::ClickState; - use crate::{DevicePixels, MouseButton, point}; + use gpui::{DevicePixels, MouseButton, point}; use std::time::Duration; #[test] fn test_double_click_interval() { - let mut state = ClickState::new(); + let state = ClickState::new(); assert_eq!( state.update(MouseButton::Left, point(DevicePixels(0), DevicePixels(0))), 1 @@ -1455,7 +1511,7 @@ mod tests { #[test] fn test_double_click_spatial_tolerance() { - let mut state = ClickState::new(); + let state = ClickState::new(); assert_eq!( state.update(MouseButton::Left, point(DevicePixels(-3), DevicePixels(0))), 1 diff --git a/src/platform_scheduler.rs b/src/platform_scheduler.rs new file mode 100644 index 0000000000..5662c8670c --- /dev/null +++ b/src/platform_scheduler.rs @@ -0,0 +1,157 @@ +use crate::scheduler::Instant; +#[cfg(any(test, feature = "test-support"))] +use crate::scheduler::TestScheduler; +use crate::scheduler::{Clock, Priority, Scheduler, SessionId, Timer}; +use crate::{PlatformDispatcher, RunnableMeta}; +use async_task::Runnable; +use chrono::{DateTime, Utc}; +use futures::channel::oneshot; +#[cfg(not(target_family = "wasm"))] +use std::task::{Context, Poll}; +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, + time::Duration, +}; + +/// A production implementation of [`Scheduler`] that wraps a [`PlatformDispatcher`]. +/// +/// This allows GPUI to use the scheduler crate's executor types with the platform's +/// native dispatch mechanisms (e.g., Grand Central Dispatch on macOS). +pub struct PlatformScheduler { + dispatcher: Arc, + clock: Arc, + next_session_id: AtomicU16, +} + +impl PlatformScheduler { + pub fn new(dispatcher: Arc) -> Self { + Self { + dispatcher: dispatcher.clone(), + clock: Arc::new(PlatformClock { dispatcher }), + next_session_id: AtomicU16::new(0), + } + } + + pub fn allocate_session_id(&self) -> SessionId { + SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst)) + } +} + +impl Scheduler for PlatformScheduler { + fn block( + &self, + _session_id: Option, + #[cfg_attr(target_family = "wasm", allow(unused_mut))] mut future: Pin< + &mut dyn Future, + >, + #[cfg_attr(target_family = "wasm", allow(unused_variables))] timeout: Option, + ) -> bool { + #[cfg(target_family = "wasm")] + { + let _ = (&future, &timeout); + panic!("Cannot block on wasm") + } + #[cfg(not(target_family = "wasm"))] + { + use waker_fn::waker_fn; + let deadline = timeout.map(|t| Instant::now() + t); + let parker = parking::Parker::new(); + let unparker = parker.unparker(); + let waker = waker_fn(move || { + unparker.unpark(); + }); + let mut cx = Context::from_waker(&waker); + if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { + return true; + } + + let park_deadline = |deadline: Instant| { + // Timer expirations are only delivered every ~15.6 milliseconds by default on Windows. + // We increase the resolution during this wait so that short timeouts stay reasonably short. + let _timer_guard = self.dispatcher.increase_timer_resolution(); + parker.park_deadline(deadline) + }; + + loop { + match deadline { + Some(deadline) if !park_deadline(deadline) && deadline <= Instant::now() => { + return false; + } + Some(_) => (), + None => parker.park(), + } + if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { + break true; + } + } + } + } + + fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable) { + self.dispatcher + .dispatch_on_main_thread(runnable, Priority::default()); + } + + fn schedule_background_with_priority( + &self, + runnable: Runnable, + priority: Priority, + ) { + self.dispatcher.dispatch(runnable, priority); + } + + fn spawn_realtime(&self, f: Box) { + self.dispatcher.spawn_realtime(f); + } + + #[track_caller] + fn timer(&self, duration: Duration) -> Timer { + let (tx, rx) = oneshot::channel(); + let dispatcher = self.dispatcher.clone(); + + // Create a runnable that will send the completion signal + let location = std::panic::Location::caller(); + let (runnable, _task) = async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| async move { + let _ = tx.send(()); + }, + move |runnable| { + dispatcher.dispatch_after(duration, runnable); + }, + ); + runnable.schedule(); + + Timer::new(rx) + } + + fn clock(&self) -> Arc { + self.clock.clone() + } + + #[cfg(any(test, feature = "test-support"))] + fn as_test(&self) -> Option<&TestScheduler> { + None + } +} + +/// A production clock that uses the platform dispatcher's time. +struct PlatformClock { + dispatcher: Arc, +} + +impl Clock for PlatformClock { + fn utc_now(&self) -> DateTime { + Utc::now() + } + + fn now(&self) -> Instant { + self.dispatcher.now() + } +} diff --git a/src/prelude.rs b/src/prelude.rs index 191d0a0e6d..17b6d462d7 100644 --- a/src/prelude.rs +++ b/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, + VisualContext, local_util::FluentBuilder, }; diff --git a/src/profiler.rs b/src/profiler.rs index 73f435d7e7..610b5a7985 100644 --- a/src/profiler.rs +++ b/src/profiler.rs @@ -1,14 +1,17 @@ +use crate::scheduler::Instant; use std::{ cell::LazyCell, + collections::HashMap, hash::Hasher, hash::{DefaultHasher, Hash}, sync::Arc, thread::ThreadId, - time::Instant, }; use serde::{Deserialize, Serialize}; +use crate::SharedString; + #[doc(hidden)] #[derive(Debug, Copy, Clone)] pub struct TaskTiming { @@ -23,10 +26,12 @@ pub struct ThreadTaskTimings { pub thread_name: Option, pub thread_id: ThreadId, pub timings: Vec, + pub total_pushed: u64, } impl ThreadTaskTimings { - pub(crate) fn convert(timings: &[GlobalThreadTimings]) -> Vec { + /// Convert global thread timings into their structured format. + pub fn convert(timings: &[GlobalThreadTimings]) -> Vec { timings .iter() .filter_map(|t| match t.timings.upgrade() { @@ -36,6 +41,7 @@ impl ThreadTaskTimings { .map(|(thread_id, timings)| { let timings = timings.lock(); let thread_name = timings.thread_name.clone(); + let total_pushed = timings.total_pushed; let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); @@ -48,6 +54,7 @@ impl ThreadTaskTimings { thread_name, thread_id, timings: vec, + total_pushed, } }) .collect() @@ -55,20 +62,20 @@ impl ThreadTaskTimings { } /// Serializable variant of [`core::panic::Location`] -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] -pub struct SerializedLocation<'a> { +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializedLocation { /// Name of the source file - pub file: &'a str, + pub file: SharedString, /// Line in the source file pub line: u32, /// Column in the source file pub column: u32, } -impl<'a> From<&'a core::panic::Location<'a>> for SerializedLocation<'a> { - fn from(value: &'a core::panic::Location<'a>) -> Self { +impl From<&core::panic::Location<'static>> for SerializedLocation { + fn from(value: &core::panic::Location<'static>) -> Self { SerializedLocation { - file: value.file(), + file: value.file().into(), line: value.line(), column: value.column(), } @@ -77,23 +84,22 @@ impl<'a> From<&'a core::panic::Location<'a>> for SerializedLocation<'a> { /// Serializable variant of [`TaskTiming`] #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializedTaskTiming<'a> { +pub struct SerializedTaskTiming { /// Location of the timing - #[serde(borrow)] - pub location: SerializedLocation<'a>, + pub location: SerializedLocation, /// Time at which the measurement was reported in nanoseconds pub start: u128, /// Duration of the measurement in nanoseconds pub duration: u128, } -impl<'a> SerializedTaskTiming<'a> { +impl SerializedTaskTiming { /// Convert an array of [`TaskTiming`] into their serializable format /// /// # Params /// /// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor - pub fn convert(anchor: Instant, timings: &[TaskTiming]) -> Vec> { + pub fn convert(anchor: Instant, timings: &[TaskTiming]) -> Vec { let serialized = timings .iter() .map(|timing| { @@ -117,26 +123,22 @@ impl<'a> SerializedTaskTiming<'a> { /// Serializable variant of [`ThreadTaskTimings`] #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializedThreadTaskTimings<'a> { +pub struct SerializedThreadTaskTimings { /// Thread name pub thread_name: Option, /// Hash of the thread id pub thread_id: u64, /// Timing records for this thread - #[serde(borrow)] - pub timings: Vec>, + pub timings: Vec, } -impl<'a> SerializedThreadTaskTimings<'a> { +impl SerializedThreadTaskTimings { /// Convert [`ThreadTaskTimings`] into their serializable format /// /// # Params /// /// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor - pub fn convert( - anchor: Instant, - timings: ThreadTaskTimings, - ) -> SerializedThreadTaskTimings<'static> { + pub fn convert(anchor: Instant, timings: ThreadTaskTimings) -> SerializedThreadTaskTimings { let serialized_timings = SerializedTaskTiming::convert(anchor, &timings.timings); let mut hasher = DefaultHasher::new(); @@ -151,22 +153,117 @@ impl<'a> SerializedThreadTaskTimings<'a> { } } +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct ThreadTimingsDelta { + /// Hashed thread id + pub thread_id: u64, + /// Thread name, if known + pub thread_name: Option, + /// New timings since the last call. If the circular buffer wrapped around + /// since the previous poll, some entries may have been lost. + pub new_timings: Vec, +} + +/// Tracks which timing events have already been seen so that callers can request only unseen events. +#[doc(hidden)] +pub struct ProfilingCollector { + startup_time: Instant, + cursors: HashMap, +} + +impl ProfilingCollector { + pub fn new(startup_time: Instant) -> Self { + Self { + startup_time, + cursors: HashMap::default(), + } + } + + pub fn startup_time(&self) -> Instant { + self.startup_time + } + + pub fn collect_unseen( + &mut self, + all_timings: Vec, + ) -> Vec { + let mut deltas = Vec::with_capacity(all_timings.len()); + + for thread in all_timings { + let mut hasher = DefaultHasher::new(); + thread.thread_id.hash(&mut hasher); + let hashed_id = hasher.finish(); + + let prev_cursor = self.cursors.get(&thread.thread_id).copied().unwrap_or(0); + let buffer_len = thread.timings.len() as u64; + let buffer_start = thread.total_pushed.saturating_sub(buffer_len); + + let mut slice = if prev_cursor < buffer_start { + // Cursor fell behind the buffer — some entries were evicted. + // Return everything still in the buffer. + thread.timings.as_slice() + } else { + let skip = (prev_cursor - buffer_start) as usize; + &thread.timings[skip.min(thread.timings.len())..] + }; + + // Don't emit the last entry if it's still in-progress (end: None). + let incomplete_at_end = slice.last().is_some_and(|t| t.end.is_none()); + if incomplete_at_end { + slice = &slice[..slice.len() - 1]; + } + + let cursor_advance = if incomplete_at_end { + thread.total_pushed.saturating_sub(1) + } else { + thread.total_pushed + }; + + self.cursors.insert(thread.thread_id, cursor_advance); + + if slice.is_empty() { + continue; + } + + let new_timings = SerializedTaskTiming::convert(self.startup_time, slice); + + deltas.push(ThreadTimingsDelta { + thread_id: hashed_id, + thread_name: thread.thread_name, + new_timings, + }); + } + + deltas + } + + pub fn reset(&mut self) { + self.cursors.clear(); + } +} + // Allow 20mb of task timing entries const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::(); -pub(crate) type TaskTimings = circular_buffer::CircularBuffer; -pub(crate) type GuardedTaskTimings = spin::Mutex; +#[doc(hidden)] +pub type TaskTimings = circular_buffer::CircularBuffer; +#[doc(hidden)] +pub type GuardedTaskTimings = spin::Mutex; -pub(crate) struct GlobalThreadTimings { +#[doc(hidden)] +pub struct GlobalThreadTimings { pub thread_id: ThreadId, pub timings: std::sync::Weak, } -pub(crate) static GLOBAL_THREAD_TIMINGS: spin::Mutex> = +#[doc(hidden)] +pub static GLOBAL_THREAD_TIMINGS: spin::Mutex> = spin::Mutex::new(Vec::new()); thread_local! { - pub(crate) static THREAD_TIMINGS: LazyCell> = LazyCell::new(|| { + #[doc(hidden)] + pub static THREAD_TIMINGS: LazyCell> = LazyCell::new(|| { let current_thread = std::thread::current(); let thread_name = current_thread.name(); let thread_id = current_thread.id(); @@ -186,18 +283,21 @@ thread_local! { }); } -pub(crate) struct ThreadTimings { +#[doc(hidden)] +pub struct ThreadTimings { pub thread_name: Option, pub thread_id: ThreadId, pub timings: Box, + pub total_pushed: u64, } impl ThreadTimings { - pub(crate) fn new(thread_name: Option, thread_id: ThreadId) -> Self { + pub fn new(thread_name: Option, thread_id: ThreadId) -> Self { ThreadTimings { thread_name, thread_id, timings: TaskTimings::boxed(), + total_pushed: 0, } } } @@ -217,18 +317,20 @@ impl Drop for ThreadTimings { } } -pub(crate) fn add_task_timing(timing: TaskTiming) { +#[doc(hidden)] +#[allow(dead_code)] // Used by Linux and Windows dispatchers, not macOS +pub fn add_task_timing(timing: TaskTiming) { THREAD_TIMINGS.with(|timings| { let mut timings = timings.lock(); - let timings = &mut timings.timings; - if let Some(last_timing) = timings.iter_mut().rev().next() { - if last_timing.location == timing.location { + if let Some(last_timing) = timings.timings.back_mut() { + if last_timing.location == timing.location && last_timing.start == timing.start { last_timing.end = timing.end; return; } } - timings.push_back(timing); + timings.timings.push_back(timing); + timings.total_pushed += 1; }); } diff --git a/src/queue.rs b/src/queue.rs index 3a4ef912ff..6e7cf2445e 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,4 +1,5 @@ use std::{ + collections::VecDeque, fmt, iter::FusedIterator, sync::{Arc, atomic::AtomicUsize}, @@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng}; use crate::Priority; struct PriorityQueues { - high_priority: Vec, - medium_priority: Vec, - low_priority: Vec, + high_priority: VecDeque, + medium_priority: VecDeque, + low_priority: VecDeque, } impl PriorityQueues { @@ -40,16 +41,42 @@ impl PriorityQueueState { } let mut queues = self.queues.lock(); - match priority { - Priority::Realtime(_) => unreachable!(), - Priority::High => queues.high_priority.push(item), - Priority::Medium => queues.medium_priority.push(item), - Priority::Low => queues.low_priority.push(item), - }; + Self::push(&mut queues, priority, item); self.condvar.notify_one(); Ok(()) } + fn spin_send(&self, priority: Priority, item: T) -> Result<(), SendError> { + if self + .receiver_count + .load(std::sync::atomic::Ordering::Relaxed) + == 0 + { + return Err(SendError(item)); + } + + let mut queues = loop { + if let Some(guard) = self.queues.try_lock() { + break guard; + } + std::hint::spin_loop(); + }; + Self::push(&mut queues, priority, item); + self.condvar.notify_one(); + Ok(()) + } + + fn push(queues: &mut PriorityQueues, priority: Priority, item: T) { + match priority { + Priority::RealtimeAudio => unreachable!( + "Realtime audio priority runs on a dedicated thread and is never queued" + ), + Priority::High => queues.high_priority.push_back(item), + Priority::Medium => queues.medium_priority.push_back(item), + Priority::Low => queues.low_priority.push_back(item), + }; + } + fn recv<'a>(&'a self) -> Result>, RecvError> { let mut queues = self.queues.lock(); @@ -58,8 +85,7 @@ impl PriorityQueueState { return Err(crate::queue::RecvError); } - // parking_lot doesn't do spurious wakeups so an if is fine - if queues.is_empty() { + while queues.is_empty() { self.condvar.wait(&mut queues); } @@ -82,9 +108,32 @@ impl PriorityQueueState { Ok(Some(queues)) } } + + fn spin_try_recv<'a>( + &'a self, + ) -> Result>>, RecvError> { + let queues = loop { + if let Some(guard) = self.queues.try_lock() { + break guard; + } + std::hint::spin_loop(); + }; + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + if queues.is_empty() { + Ok(None) + } else { + Ok(Some(queues)) + } + } } -pub(crate) struct PriorityQueueSender { +#[doc(hidden)] +pub struct PriorityQueueSender { state: Arc>, } @@ -93,10 +142,15 @@ impl PriorityQueueSender { Self { state } } - pub(crate) fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + pub fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { self.state.send(priority, item)?; Ok(()) } + + pub fn spin_send(&self, priority: Priority, item: T) -> Result<(), SendError> { + self.state.spin_send(priority, item)?; + Ok(()) + } } impl Drop for PriorityQueueSender { @@ -107,7 +161,8 @@ impl Drop for PriorityQueueSender { } } -pub(crate) struct PriorityQueueReceiver { +#[doc(hidden)] +pub struct PriorityQueueReceiver { state: Arc>, rand: SmallRng, disconnected: bool, @@ -126,7 +181,8 @@ impl Clone for PriorityQueueReceiver { } } -pub(crate) struct SendError(T); +#[doc(hidden)] +pub struct SendError(pub T); impl fmt::Debug for SendError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -135,16 +191,17 @@ impl fmt::Debug for SendError { } #[derive(Debug)] -pub(crate) struct RecvError; +#[doc(hidden)] +pub struct RecvError; #[allow(dead_code)] impl PriorityQueueReceiver { - pub(crate) fn new() -> (PriorityQueueSender, Self) { + pub fn new() -> (PriorityQueueSender, Self) { let state = PriorityQueueState { queues: parking_lot::Mutex::new(PriorityQueues { - high_priority: Vec::new(), - medium_priority: Vec::new(), - low_priority: Vec::new(), + high_priority: VecDeque::new(), + medium_priority: VecDeque::new(), + low_priority: VecDeque::new(), }), condvar: parking_lot::Condvar::new(), receiver_count: AtomicUsize::new(1), @@ -173,10 +230,48 @@ impl PriorityQueueReceiver { /// # Errors /// /// If the sender was dropped - pub(crate) fn try_pop(&mut self) -> Result, RecvError> { + pub fn try_pop(&mut self) -> Result, RecvError> { self.pop_inner(false) } + pub fn spin_try_pop(&mut self) -> Result, RecvError> { + use Priority as P; + + let Some(mut queues) = self.state.spin_try_recv()? else { + return Ok(None); + }; + + let high = P::High.weight() * !queues.high_priority.is_empty() as u32; + let medium = P::Medium.weight() * !queues.medium_priority.is_empty() as u32; + let low = P::Low.weight() * !queues.low_priority.is_empty() as u32; + let mut mass = high + medium + low; + + if !queues.high_priority.is_empty() { + let flip = self.rand.random_ratio(P::High.weight(), mass); + if flip { + return Ok(queues.high_priority.pop_front()); + } + mass -= P::High.weight(); + } + + if !queues.medium_priority.is_empty() { + let flip = self.rand.random_ratio(P::Medium.weight(), mass); + if flip { + return Ok(queues.medium_priority.pop_front()); + } + mass -= P::Medium.weight(); + } + + if !queues.low_priority.is_empty() { + let flip = self.rand.random_ratio(P::Low.weight(), mass); + if flip { + return Ok(queues.low_priority.pop_front()); + } + } + + Ok(None) + } + /// Pops an element from the priority queue blocking if necessary. /// /// This method is best suited if you only intend to pop one element, for better performance @@ -185,13 +280,13 @@ impl PriorityQueueReceiver { /// # Errors /// /// If the sender was dropped - pub(crate) fn pop(&mut self) -> Result { + pub fn pop(&mut self) -> Result { self.pop_inner(true).map(|e| e.unwrap()) } /// Returns an iterator over the elements of the queue /// this iterator will end when all elements have been consumed and will not wait for new ones. - pub(crate) fn try_iter(self) -> TryIter { + pub fn try_iter(self) -> TryIter { TryIter { receiver: self, ended: false, @@ -200,7 +295,7 @@ impl PriorityQueueReceiver { /// Returns an iterator over the elements of the queue /// this iterator will wait for new elements if the queue is empty. - pub(crate) fn iter(self) -> Iter { + pub fn iter(self) -> Iter { Iter(self) } @@ -219,31 +314,31 @@ impl PriorityQueueReceiver { self.state.recv()? }; - let high = P::High.probability() * !queues.high_priority.is_empty() as u32; - let medium = P::Medium.probability() * !queues.medium_priority.is_empty() as u32; - let low = P::Low.probability() * !queues.low_priority.is_empty() as u32; + let high = P::High.weight() * !queues.high_priority.is_empty() as u32; + let medium = P::Medium.weight() * !queues.medium_priority.is_empty() as u32; + let low = P::Low.weight() * !queues.low_priority.is_empty() as u32; let mut mass = high + medium + low; //% if !queues.high_priority.is_empty() { - let flip = self.rand.random_ratio(P::High.probability(), mass); + let flip = self.rand.random_ratio(P::High.weight(), mass); if flip { - return Ok(queues.high_priority.pop()); + return Ok(queues.high_priority.pop_front()); } - mass -= P::High.probability(); + mass -= P::High.weight(); } if !queues.medium_priority.is_empty() { - let flip = self.rand.random_ratio(P::Medium.probability(), mass); + let flip = self.rand.random_ratio(P::Medium.weight(), mass); if flip { - return Ok(queues.medium_priority.pop()); + return Ok(queues.medium_priority.pop_front()); } - mass -= P::Medium.probability(); + mass -= P::Medium.weight(); } if !queues.low_priority.is_empty() { - let flip = self.rand.random_ratio(P::Low.probability(), mass); + let flip = self.rand.random_ratio(P::Low.weight(), mass); if flip { - return Ok(queues.low_priority.pop()); + return Ok(queues.low_priority.pop_front()); } } @@ -259,19 +354,19 @@ impl Drop for PriorityQueueReceiver { } } -/// If None is returned the sender disconnected -pub(crate) struct Iter(PriorityQueueReceiver); +#[doc(hidden)] +pub struct Iter(PriorityQueueReceiver); impl Iterator for Iter { type Item = T; fn next(&mut self) -> Option { - self.0.pop_inner(true).ok().flatten() + self.0.pop().ok() } } impl FusedIterator for Iter {} -/// If None is returned there are no more elements in the queue -pub(crate) struct TryIter { +#[doc(hidden)] +pub struct TryIter { receiver: PriorityQueueReceiver, ended: bool, } @@ -283,7 +378,7 @@ impl Iterator for TryIter { return None; } - let res = self.receiver.pop_inner(false); + let res = self.receiver.try_pop(); self.ended = res.is_err(); res.transpose() diff --git a/src/scene.rs b/src/scene.rs index 758d06e597..22b1bb468d 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -16,24 +16,29 @@ use std::{ }; #[allow(non_camel_case_types, unused)] -pub(crate) type PathVertex_ScaledPixels = PathVertex; +#[expect(missing_docs)] +pub type PathVertex_ScaledPixels = PathVertex; -pub(crate) type DrawOrder = u32; +#[expect(missing_docs)] +pub type DrawOrder = u32; #[derive(Default)] -pub(crate) struct Scene { +#[expect(missing_docs)] +pub struct Scene { pub(crate) paint_operations: Vec, primitive_bounds: BoundsTree, layer_stack: Vec, - pub(crate) shadows: Vec, - pub(crate) quads: Vec, - pub(crate) paths: Vec>, - pub(crate) underlines: Vec, - pub(crate) monochrome_sprites: Vec, - pub(crate) polychrome_sprites: Vec, - pub(crate) surfaces: Vec, + pub shadows: Vec, + pub quads: Vec, + pub paths: Vec>, + pub underlines: Vec, + pub monochrome_sprites: Vec, + pub subpixel_sprites: Vec, + pub polychrome_sprites: Vec, + pub surfaces: Vec, } +#[expect(missing_docs)] impl Scene { pub fn clear(&mut self) { self.paint_operations.clear(); @@ -44,6 +49,7 @@ impl Scene { self.quads.clear(); self.underlines.clear(); self.monochrome_sprites.clear(); + self.subpixel_sprites.clear(); self.polychrome_sprites.clear(); self.surfaces.clear(); } @@ -101,6 +107,10 @@ impl Scene { sprite.order = order; self.monochrome_sprites.push(sprite.clone()); } + Primitive::SubpixelSprite(sprite) => { + sprite.order = order; + self.subpixel_sprites.push(sprite.clone()); + } Primitive::PolychromeSprite(sprite) => { sprite.order = order; self.polychrome_sprites.push(sprite.clone()); @@ -131,6 +141,8 @@ impl Scene { self.underlines.sort_by_key(|underline| underline.order); self.monochrome_sprites .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); + self.subpixel_sprites + .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); self.polychrome_sprites .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); self.surfaces.sort_by_key(|surface| surface.order); @@ -143,27 +155,22 @@ impl Scene { ), allow(dead_code) )] - pub(crate) fn batches(&self) -> impl Iterator> { + pub fn batches(&self) -> impl Iterator + '_ { BatchIterator { - shadows: &self.shadows, shadows_start: 0, shadows_iter: self.shadows.iter().peekable(), - quads: &self.quads, quads_start: 0, quads_iter: self.quads.iter().peekable(), - paths: &self.paths, paths_start: 0, paths_iter: self.paths.iter().peekable(), - underlines: &self.underlines, underlines_start: 0, underlines_iter: self.underlines.iter().peekable(), - monochrome_sprites: &self.monochrome_sprites, monochrome_sprites_start: 0, monochrome_sprites_iter: self.monochrome_sprites.iter().peekable(), - polychrome_sprites: &self.polychrome_sprites, + subpixel_sprites_start: 0, + subpixel_sprites_iter: self.subpixel_sprites.iter().peekable(), polychrome_sprites_start: 0, polychrome_sprites_iter: self.polychrome_sprites.iter().peekable(), - surfaces: &self.surfaces, surfaces_start: 0, surfaces_iter: self.surfaces.iter().peekable(), } @@ -185,6 +192,7 @@ pub(crate) enum PrimitiveKind { Path, Underline, MonochromeSprite, + SubpixelSprite, PolychromeSprite, Surface, } @@ -196,16 +204,19 @@ pub(crate) enum PaintOperation { } #[derive(Clone)] -pub(crate) enum Primitive { +#[expect(missing_docs)] +pub enum Primitive { Shadow(Shadow), Quad(Quad), Path(Path), Underline(Underline), MonochromeSprite(MonochromeSprite), + SubpixelSprite(SubpixelSprite), PolychromeSprite(PolychromeSprite), Surface(PaintSurface), } +#[expect(missing_docs)] impl Primitive { pub fn bounds(&self) -> &Bounds { match self { @@ -214,6 +225,7 @@ impl Primitive { Primitive::Path(path) => &path.bounds, Primitive::Underline(underline) => &underline.bounds, Primitive::MonochromeSprite(sprite) => &sprite.bounds, + Primitive::SubpixelSprite(sprite) => &sprite.bounds, Primitive::PolychromeSprite(sprite) => &sprite.bounds, Primitive::Surface(surface) => &surface.bounds, } @@ -226,6 +238,7 @@ impl Primitive { Primitive::Path(path) => &path.content_mask, Primitive::Underline(underline) => &underline.content_mask, Primitive::MonochromeSprite(sprite) => &sprite.content_mask, + Primitive::SubpixelSprite(sprite) => &sprite.content_mask, Primitive::PolychromeSprite(sprite) => &sprite.content_mask, Primitive::Surface(surface) => &surface.content_mask, } @@ -240,31 +253,26 @@ impl Primitive { allow(dead_code) )] struct BatchIterator<'a> { - shadows: &'a [Shadow], shadows_start: usize, shadows_iter: Peekable>, - quads: &'a [Quad], quads_start: usize, quads_iter: Peekable>, - paths: &'a [Path], paths_start: usize, paths_iter: Peekable>>, - underlines: &'a [Underline], underlines_start: usize, underlines_iter: Peekable>, - monochrome_sprites: &'a [MonochromeSprite], monochrome_sprites_start: usize, monochrome_sprites_iter: Peekable>, - polychrome_sprites: &'a [PolychromeSprite], + subpixel_sprites_start: usize, + subpixel_sprites_iter: Peekable>, polychrome_sprites_start: usize, polychrome_sprites_iter: Peekable>, - surfaces: &'a [PaintSurface], surfaces_start: usize, surfaces_iter: Peekable>, } impl<'a> Iterator for BatchIterator<'a> { - type Item = PrimitiveBatch<'a>; + type Item = PrimitiveBatch; fn next(&mut self) -> Option { let mut orders_and_kinds = [ @@ -282,6 +290,10 @@ impl<'a> Iterator for BatchIterator<'a> { self.monochrome_sprites_iter.peek().map(|s| s.order), PrimitiveKind::MonochromeSprite, ), + ( + self.subpixel_sprites_iter.peek().map(|s| s.order), + PrimitiveKind::SubpixelSprite, + ), ( self.polychrome_sprites_iter.peek().map(|s| s.order), PrimitiveKind::PolychromeSprite, @@ -314,9 +326,7 @@ impl<'a> Iterator for BatchIterator<'a> { shadows_end += 1; } self.shadows_start = shadows_end; - Some(PrimitiveBatch::Shadows( - &self.shadows[shadows_start..shadows_end], - )) + Some(PrimitiveBatch::Shadows(shadows_start..shadows_end)) } PrimitiveKind::Quad => { let quads_start = self.quads_start; @@ -330,7 +340,7 @@ impl<'a> Iterator for BatchIterator<'a> { quads_end += 1; } self.quads_start = quads_end; - Some(PrimitiveBatch::Quads(&self.quads[quads_start..quads_end])) + Some(PrimitiveBatch::Quads(quads_start..quads_end)) } PrimitiveKind::Path => { let paths_start = self.paths_start; @@ -344,7 +354,7 @@ impl<'a> Iterator for BatchIterator<'a> { paths_end += 1; } self.paths_start = paths_end; - Some(PrimitiveBatch::Paths(&self.paths[paths_start..paths_end])) + Some(PrimitiveBatch::Paths(paths_start..paths_end)) } PrimitiveKind::Underline => { let underlines_start = self.underlines_start; @@ -358,9 +368,7 @@ impl<'a> Iterator for BatchIterator<'a> { underlines_end += 1; } self.underlines_start = underlines_end; - Some(PrimitiveBatch::Underlines( - &self.underlines[underlines_start..underlines_end], - )) + Some(PrimitiveBatch::Underlines(underlines_start..underlines_end)) } PrimitiveKind::MonochromeSprite => { let texture_id = self.monochrome_sprites_iter.peek().unwrap().tile.texture_id; @@ -380,13 +388,34 @@ impl<'a> Iterator for BatchIterator<'a> { self.monochrome_sprites_start = sprites_end; Some(PrimitiveBatch::MonochromeSprites { texture_id, - sprites: &self.monochrome_sprites[sprites_start..sprites_end], + range: sprites_start..sprites_end, + }) + } + PrimitiveKind::SubpixelSprite => { + let texture_id = self.subpixel_sprites_iter.peek().unwrap().tile.texture_id; + let sprites_start = self.subpixel_sprites_start; + let mut sprites_end = sprites_start + 1; + self.subpixel_sprites_iter.next(); + while self + .subpixel_sprites_iter + .next_if(|sprite| { + (sprite.order, batch_kind) < max_order_and_kind + && sprite.tile.texture_id == texture_id + }) + .is_some() + { + sprites_end += 1; + } + self.subpixel_sprites_start = sprites_end; + Some(PrimitiveBatch::SubpixelSprites { + texture_id, + range: sprites_start..sprites_end, }) } PrimitiveKind::PolychromeSprite => { let texture_id = self.polychrome_sprites_iter.peek().unwrap().tile.texture_id; let sprites_start = self.polychrome_sprites_start; - let mut sprites_end = self.polychrome_sprites_start + 1; + let mut sprites_end = sprites_start + 1; self.polychrome_sprites_iter.next(); while self .polychrome_sprites_iter @@ -401,7 +430,7 @@ impl<'a> Iterator for BatchIterator<'a> { self.polychrome_sprites_start = sprites_end; Some(PrimitiveBatch::PolychromeSprites { texture_id, - sprites: &self.polychrome_sprites[sprites_start..sprites_end], + range: sprites_start..sprites_end, }) } PrimitiveKind::Surface => { @@ -416,9 +445,7 @@ impl<'a> Iterator for BatchIterator<'a> { surfaces_end += 1; } self.surfaces_start = surfaces_end; - Some(PrimitiveBatch::Surfaces( - &self.surfaces[surfaces_start..surfaces_end], - )) + Some(PrimitiveBatch::Surfaces(surfaces_start..surfaces_end)) } } } @@ -432,25 +459,32 @@ impl<'a> Iterator for BatchIterator<'a> { ), allow(dead_code) )] -pub(crate) enum PrimitiveBatch<'a> { - Shadows(&'a [Shadow]), - Quads(&'a [Quad]), - Paths(&'a [Path]), - Underlines(&'a [Underline]), +#[allow(missing_docs)] +pub enum PrimitiveBatch { + Shadows(Range), + Quads(Range), + Paths(Range), + Underlines(Range), MonochromeSprites { texture_id: AtlasTextureId, - sprites: &'a [MonochromeSprite], + range: Range, + }, + #[cfg_attr(target_os = "macos", allow(dead_code))] + SubpixelSprites { + texture_id: AtlasTextureId, + range: Range, }, PolychromeSprites { texture_id: AtlasTextureId, - sprites: &'a [PolychromeSprite], + range: Range, }, - Surfaces(&'a [PaintSurface]), + Surfaces(Range), } #[derive(Default, Debug, Clone)] #[repr(C)] -pub(crate) struct Quad { +#[expect(missing_docs)] +pub struct Quad { pub order: DrawOrder, pub border_style: BorderStyle, pub bounds: Bounds, @@ -469,7 +503,8 @@ impl From for Primitive { #[derive(Debug, Clone)] #[repr(C)] -pub(crate) struct Underline { +#[expect(missing_docs)] +pub struct Underline { pub order: DrawOrder, pub pad: u32, // align to 8 bytes pub bounds: Bounds, @@ -487,7 +522,8 @@ impl From for Primitive { #[derive(Debug, Clone)] #[repr(C)] -pub(crate) struct Shadow { +#[expect(missing_docs)] +pub struct Shadow { pub order: DrawOrder, pub blur_radius: ScaledPixels, pub bounds: Bounds, @@ -618,9 +654,10 @@ impl Default for TransformationMatrix { #[derive(Clone, Debug)] #[repr(C)] -pub(crate) struct MonochromeSprite { +#[expect(missing_docs)] +pub struct MonochromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub bounds: Bounds, pub content_mask: ContentMask, pub color: Hsla, @@ -636,9 +673,29 @@ impl From for Primitive { #[derive(Clone, Debug)] #[repr(C)] -pub(crate) struct PolychromeSprite { +#[expect(missing_docs)] +pub struct SubpixelSprite { pub order: DrawOrder, pub pad: u32, // align to 8 bytes + pub bounds: Bounds, + pub content_mask: ContentMask, + pub color: Hsla, + pub tile: AtlasTile, + pub transformation: TransformationMatrix, +} + +impl From for Primitive { + fn from(sprite: SubpixelSprite) -> Self { + Primitive::SubpixelSprite(sprite) + } +} + +#[derive(Clone, Debug)] +#[repr(C)] +#[expect(missing_docs)] +pub struct PolychromeSprite { + pub order: DrawOrder, + pub pad: u32, pub grayscale: bool, pub opacity: f32, pub bounds: Bounds, @@ -654,7 +711,8 @@ impl From for Primitive { } #[derive(Clone, Debug)] -pub(crate) struct PaintSurface { +#[allow(missing_docs)] +pub struct PaintSurface { pub order: DrawOrder, pub bounds: Bounds, pub content_mask: ContentMask, @@ -669,17 +727,19 @@ impl From for Primitive { } #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) struct PathId(pub(crate) usize); +#[expect(missing_docs)] +pub struct PathId(pub usize); /// A line made up of a series of vertices and control points. #[derive(Clone, Debug)] +#[expect(missing_docs)] pub struct Path { - pub(crate) id: PathId, - pub(crate) order: DrawOrder, - pub(crate) bounds: Bounds

, - pub(crate) content_mask: ContentMask

, - pub(crate) vertices: Vec>, - pub(crate) color: Background, + pub id: PathId, + pub order: DrawOrder, + pub bounds: Bounds

, + pub content_mask: ContentMask

, + pub vertices: Vec>, + pub color: Background, start: Point

, current: Point

, contour_count: usize, @@ -803,7 +863,8 @@ where T: Clone + Debug + Default + PartialEq + PartialOrd + Add + Sub, { #[allow(unused)] - pub(crate) fn clipped_bounds(&self) -> Bounds { + #[expect(missing_docs)] + pub fn clipped_bounds(&self) -> Bounds { self.bounds.intersect(&self.content_mask.bounds) } } @@ -816,12 +877,14 @@ impl From> for Primitive { #[derive(Clone, Debug)] #[repr(C)] -pub(crate) struct PathVertex { - pub(crate) xy_position: Point

, - pub(crate) st_position: Point, - pub(crate) content_mask: ContentMask

, +#[expect(missing_docs)] +pub struct PathVertex { + pub xy_position: Point

, + pub st_position: Point, + pub content_mask: ContentMask

, } +#[expect(missing_docs)] impl PathVertex { pub fn scale(&self, factor: f32) -> PathVertex { PathVertex { diff --git a/src/scheduler/clock.rs b/src/scheduler/clock.rs new file mode 100644 index 0000000000..8437ab1cd7 --- /dev/null +++ b/src/scheduler/clock.rs @@ -0,0 +1,57 @@ +use chrono::{DateTime, Utc}; +use parking_lot::Mutex; +use std::time::Duration; + +pub use web_time::Instant; + +/// Interface for providing current time and monotonic instants. +pub trait Clock { + /// Returns the current UTC date and time. + fn utc_now(&self) -> DateTime; + + /// Returns the current monotonic instant. + fn now(&self) -> Instant; +} + +/// A mock clock implementation for use in tests. +pub struct TestClock(Mutex); + +struct TestClockState { + now: Instant, + utc_now: DateTime, +} + +impl TestClock { + /// Creates a new TestClock initialized to a fixed start time. + pub fn new() -> Self { + const START_TIME: &str = "2025-07-01T23:59:58-00:00"; + let utc_now = DateTime::parse_from_rfc3339(START_TIME).unwrap().to_utc(); + Self(Mutex::new(TestClockState { + now: Instant::now(), + utc_now, + })) + } + + /// Sets the current UTC time for the clock. + pub fn set_utc_now(&self, now: DateTime) { + let mut state = self.0.lock(); + state.utc_now = now; + } + + /// Advances the clock by the given duration. + pub fn advance(&self, duration: Duration) { + let mut state = self.0.lock(); + state.now += duration; + state.utc_now += duration; + } +} + +impl Clock for TestClock { + fn utc_now(&self) -> DateTime { + self.0.lock().utc_now + } + + fn now(&self) -> Instant { + self.0.lock().now + } +} diff --git a/src/scheduler/executor.rs b/src/scheduler/executor.rs new file mode 100644 index 0000000000..c973f1ab53 --- /dev/null +++ b/src/scheduler/executor.rs @@ -0,0 +1,396 @@ +use super::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer}; +use std::{ + future::Future, + marker::PhantomData, + mem::ManuallyDrop, + panic::Location, + pin::Pin, + rc::Rc, + sync::Arc, + task::{Context, Poll}, + thread::{self, ThreadId}, + time::Duration, +}; + +/// An executor for running tasks on the foreground (main/UI) thread. +#[derive(Clone)] +pub struct ForegroundExecutor { + session_id: SessionId, + scheduler: Arc, + not_send: PhantomData>, +} + +impl ForegroundExecutor { + /// Creates a new ForegroundExecutor for the given session. + pub fn new(session_id: SessionId, scheduler: Arc) -> Self { + Self { + session_id, + scheduler, + not_send: PhantomData, + } + } + + /// Returns the session ID associated with this executor. + pub fn session_id(&self) -> SessionId { + self.session_id + } + + /// Returns the underlying scheduler. + pub fn scheduler(&self) -> &Arc { + &self.scheduler + } + + /// Spawns a task to run on the foreground thread. + #[track_caller] + pub fn spawn(&self, future: F) -> Task + where + F: Future + 'static, + F::Output: 'static, + { + let session_id = self.session_id; + let scheduler = Arc::clone(&self.scheduler); + let location = Location::caller(); + let (runnable, task) = spawn_local_with_source_location( + future, + move |runnable| { + scheduler.schedule_foreground(session_id, runnable); + }, + RunnableMeta { location }, + ); + runnable.schedule(); + Task(TaskState::Spawned(task)) + } + + /// Blocks the current thread until the given future completes. + pub fn block_on(&self, future: Fut) -> Fut::Output { + use std::cell::Cell; + + let output = Cell::new(None); + + let future = async { + output.set(Some(future.await)); + }; + let mut future = std::pin::pin!(future); + + self.scheduler + .block(Some(self.session_id), future.as_mut(), None); + + output.take().expect("block_on future did not complete") + } + + /// Block until the future completes or timeout occurs. + /// Returns Ok(output) if completed, Err(future) if timed out. + pub fn block_with_timeout( + &self, + timeout: Duration, + future: Fut, + ) -> Result + use> { + use std::cell::Cell; + + let output = Cell::new(None); + let mut future = Box::pin(future); + + { + let future_ref = &mut future; + let wrapper = async { + output.set(Some(future_ref.await)); + }; + let mut wrapper = std::pin::pin!(wrapper); + + self.scheduler + .block(Some(self.session_id), wrapper.as_mut(), Some(timeout)); + } + + match output.take() { + Some(value) => Ok(value), + None => Err(future), + } + } + + /// Creates a timer that resolves after the given duration. + #[track_caller] + pub fn timer(&self, duration: Duration) -> Timer { + self.scheduler.timer(duration) + } + + /// Returns the current monotonic instant from the scheduler's clock. + pub fn now(&self) -> Instant { + self.scheduler.clock().now() + } +} + +/// An executor for running tasks in the background. +#[derive(Clone)] +pub struct BackgroundExecutor { + scheduler: Arc, +} + +impl BackgroundExecutor { + /// Creates a new BackgroundExecutor. + pub fn new(scheduler: Arc) -> Self { + Self { scheduler } + } + + /// Spawns a background task with default priority. + #[track_caller] + pub fn spawn(&self, future: F) -> Task + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + self.spawn_with_priority(Priority::default(), future) + } + + /// Spawns a background task with the given priority. + #[track_caller] + pub fn spawn_with_priority(&self, priority: Priority, future: F) -> Task + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + let scheduler = Arc::clone(&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); + }, + ); + runnable.schedule(); + Task(TaskState::Spawned(task)) + } + + /// Spawns a future on a dedicated realtime thread for audio processing. + #[track_caller] + pub fn spawn_realtime(&self, future: F) -> Task + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + let location = Location::caller(); + let (tx, rx) = flume::bounded::>(1); + + self.scheduler.spawn_realtime(Box::new(move || { + while let Ok(runnable) = rx.recv() { + runnable.run(); + } + })); + + let (runnable, task) = async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + let _ = tx.send(runnable); + }, + ); + runnable.schedule(); + Task(TaskState::Spawned(task)) + } + + /// Creates a timer that resolves after the given duration. + #[track_caller] + pub fn timer(&self, duration: Duration) -> Timer { + self.scheduler.timer(duration) + } + + /// Returns the current monotonic instant from the scheduler's clock. + pub fn now(&self) -> Instant { + self.scheduler.clock().now() + } + + /// Returns the underlying scheduler. + pub fn scheduler(&self) -> &Arc { + &self.scheduler + } +} + +/// Task is a primitive that allows work to happen in the background. +/// +/// 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(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), +} + +impl Task { + /// Creates a new task that will resolve with the value + pub fn ready(val: T) -> Self { + Task(TaskState::Ready(Some(val))) + } + + /// Creates a Task from an async_task::Task + pub fn from_async_task(task: async_task::Task) -> Self { + Task(TaskState::Spawned(task)) + } + + /// Returns true if the task has completed and its value is ready. + pub fn is_ready(&self) -> bool { + match &self.0 { + TaskState::Ready(_) => true, + TaskState::Spawned(task) => task.is_finished(), + } + } + + /// Detaching a task runs it to completion in the background + pub fn detach(self) { + match self { + Task(TaskState::Ready(_)) => {} + Task(TaskState::Spawned(task)) => task.detach(), + } + } + + /// Converts this task into a fallible task that returns `Option`. + pub fn fallible(self) -> FallibleTask { + FallibleTask(match self.0 { + TaskState::Ready(val) => FallibleTaskState::Ready(val), + TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()), + }) + } +} + +/// A task that returns `Option` instead of panicking when cancelled. +#[must_use] +pub struct FallibleTask(FallibleTaskState); + +enum FallibleTaskState { + /// A task that is ready to return a value + Ready(Option), + + /// A task that is currently running (wraps async_task::FallibleTask). + Spawned(async_task::FallibleTask), +} + +impl FallibleTask { + /// Creates a new fallible task that will resolve with the value. + pub fn ready(val: T) -> Self { + FallibleTask(FallibleTaskState::Ready(Some(val))) + } + + /// Detaching a task runs it to completion in the background. + pub fn detach(self) { + match self.0 { + FallibleTaskState::Ready(_) => {} + FallibleTaskState::Spawned(task) => task.detach(), + } + } +} + +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), + } + } +} + +impl std::fmt::Debug for FallibleTask { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + FallibleTaskState::Ready(_) => f.debug_tuple("FallibleTask::Ready").finish(), + FallibleTaskState::Spawned(task) => { + f.debug_tuple("FallibleTask::Spawned").field(task).finish() + } + } + } +} + +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), + } + } +} + +/// Variant of `async_task::spawn_local` that includes the source location of the spawn in panics. +#[track_caller] +fn spawn_local_with_source_location( + future: Fut, + schedule: S, + metadata: RunnableMeta, +) -> ( + async_task::Runnable, + async_task::Task, +) +where + Fut: Future + 'static, + Fut::Output: 'static, + S: async_task::Schedule + Send + Sync + 'static, +{ + #[inline] + fn thread_id() -> ThreadId { + std::thread_local! { + static ID: ThreadId = thread::current().id(); + } + ID.try_with(|id| *id) + .unwrap_or_else(|_| thread::current().id()) + } + + struct Checked { + id: ThreadId, + inner: ManuallyDrop, + location: &'static Location<'static>, + } + + impl Drop for Checked { + fn drop(&mut self) { + assert_eq!( + self.id, + thread_id(), + "local task dropped by a thread that didn't spawn it. Task spawned at {}", + self.location + ); + unsafe { + ManuallyDrop::drop(&mut self.inner); + } + } + } + + impl Future for Checked { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + assert!( + self.id == thread_id(), + "local task polled by a thread that didn't spawn it. Task spawned at {}", + self.location + ); + unsafe { self.map_unchecked_mut(|c| &mut *c.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, + ) + } +} diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs new file mode 100644 index 0000000000..53bb2eda66 --- /dev/null +++ b/src/scheduler/mod.rs @@ -0,0 +1,149 @@ +mod clock; +mod executor; +#[cfg(any(test, feature = "test-support"))] +mod test_scheduler; +#[cfg(test)] +mod tests; + +pub use clock::*; +pub use executor::*; +#[cfg(any(test, feature = "test-support"))] +pub use test_scheduler::*; + +use async_task::Runnable; +use futures::channel::oneshot; +use std::{ + future::Future, + panic::Location, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; + +/// Task priority for background tasks. +/// +/// Higher priority tasks are more likely to be scheduled before lower priority tasks, +/// but this is not a strict guarantee - the scheduler may interleave tasks of different +/// priorities to prevent starvation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Priority { + /// Realtime priority + /// + /// Spawning a task with this priority will spin it off on a separate thread dedicated just to that task. Only use for audio. + RealtimeAudio, + /// High priority - use for tasks critical to user experience/responsiveness. + High, + /// Medium priority - suitable for most use cases. + #[default] + Medium, + /// Low priority - use for background work that can be deprioritized. + Low, +} + +impl Priority { + /// Returns the relative probability weight for this priority level. + /// Used by schedulers to determine task selection probability. + pub const fn weight(self) -> u32 { + match self { + Priority::High => 60, + Priority::Medium => 30, + Priority::Low => 10, + // realtime priorities are not considered for probability scheduling + Priority::RealtimeAudio => 0, + } + } +} + +/// Metadata attached to runnables for debugging and profiling. +#[derive(Clone)] +pub struct RunnableMeta { + /// The source location where the task was spawned. + pub location: &'static Location<'static>, +} + +impl std::fmt::Debug for RunnableMeta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RunnableMeta") + .field("location", &self.location) + .finish() + } +} + +/// Interface for scheduling tasks on different threads or execution contexts. +pub trait Scheduler: Send + Sync { + /// Block until the given future completes or timeout occurs. + /// + /// Returns `true` if the future completed, `false` if it timed out. + /// The future is passed as a pinned mutable reference so the caller + /// retains ownership and can continue polling or return it on timeout. + fn block( + &self, + session_id: Option, + future: Pin<&mut dyn Future>, + timeout: Option, + ) -> bool; + + /// Schedules a task to run on the foreground (main/UI) thread. + fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); + + /// Schedule a background task with the given priority. + fn schedule_background_with_priority( + &self, + runnable: Runnable, + priority: Priority, + ); + + /// Spawn a closure on a dedicated realtime thread for audio processing. + fn spawn_realtime(&self, f: Box); + + /// Schedule a background task with default (medium) priority. + fn schedule_background(&self, runnable: Runnable) { + self.schedule_background_with_priority(runnable, Priority::default()); + } + + /// Creates a timer that resolves after the given duration. + #[track_caller] + fn timer(&self, timeout: Duration) -> Timer; + + /// Returns the clock used by this scheduler. + fn clock(&self) -> Arc; + + #[cfg(any(test, feature = "test-support"))] + fn as_test(&self) -> Option<&TestScheduler> { + None + } +} + +/// A unique identifier for a window or application session. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct SessionId(u16); + +impl SessionId { + /// Creates a new SessionId from the given u16. + pub fn new(id: u16) -> Self { + SessionId(id) + } +} + +/// A future that resolves after a period of time. +pub struct Timer(oneshot::Receiver<()>); + +impl Timer { + /// Creates a new timer from a oneshot receiver. + pub fn new(rx: oneshot::Receiver<()>) -> Self { + Timer(rx) + } +} + +impl Future for Timer { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<()> { + match Pin::new(&mut self.0).poll(cx) { + Poll::Ready(_) => Poll::Ready(()), + Poll::Pending => Poll::Pending, + } + } +} diff --git a/src/scheduler/test_scheduler.rs b/src/scheduler/test_scheduler.rs new file mode 100644 index 0000000000..e3f4400a7a --- /dev/null +++ b/src/scheduler/test_scheduler.rs @@ -0,0 +1,885 @@ +use super::{ + BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler, + SessionId, TestClock, Timer, +}; +use async_task::Runnable; +use backtrace::{Backtrace, BacktraceFrame}; +use futures::channel::oneshot; +use parking_lot::{Mutex, MutexGuard}; +use rand::{ + distr::{StandardUniform, uniform::SampleRange, uniform::SampleUniform}, + prelude::*, +}; +use std::{ + any::type_name_of_val, + collections::{BTreeMap, HashSet, VecDeque}, + env, + fmt::Write, + future::Future, + mem, + ops::RangeInclusive, + panic::{self, AssertUnwindSafe}, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering::SeqCst}, + }, + task::{Context, Poll, RawWaker, RawWakerVTable, Waker}, + thread::{self, Thread}, + time::Duration, +}; + +const PENDING_TRACES_VAR_NAME: &str = "PENDING_TRACES"; + +pub struct TestScheduler { + clock: Arc, + rng: Arc>, + state: Arc>, + thread: Thread, +} + +impl TestScheduler { + /// Run a test once with default configuration (seed 0) + pub fn once(f: impl AsyncFnOnce(Arc) -> R) -> R { + Self::with_seed(0, f) + } + + /// Run a test multiple times with sequential seeds (0, 1, 2, ...) + pub fn many( + default_iterations: usize, + mut f: impl AsyncFnMut(Arc) -> R, + ) -> Vec { + let num_iterations = std::env::var("ITERATIONS") + .map(|iterations| iterations.parse().unwrap()) + .unwrap_or(default_iterations); + + let seed = std::env::var("SEED") + .map(|seed| seed.parse().unwrap()) + .unwrap_or(0); + + (seed..seed + num_iterations as u64) + .map(|seed| { + let mut unwind_safe_f = AssertUnwindSafe(&mut f); + eprintln!("Running seed: {seed}"); + match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { + Ok(result) => result, + Err(error) => { + eprintln!("\x1b[31mFailing Seed: {seed}\x1b[0m"); + panic::resume_unwind(error); + } + } + }) + .collect() + } + + fn with_seed(seed: u64, f: impl AsyncFnOnce(Arc) -> R) -> R { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::with_seed(seed))); + let future = f(scheduler.clone()); + let result = scheduler.foreground().block_on(future); + scheduler.run(); // Ensure spawned tasks finish up before returning in tests + result + } + + pub fn new(config: TestSchedulerConfig) -> Self { + Self { + rng: Arc::new(Mutex::new(StdRng::seed_from_u64(config.seed))), + state: Arc::new(Mutex::new(SchedulerState { + runnables: VecDeque::new(), + timers: Vec::new(), + blocked_sessions: Vec::new(), + randomize_order: config.randomize_order, + allow_parking: config.allow_parking, + timeout_ticks: config.timeout_ticks, + next_session_id: SessionId(0), + capture_pending_traces: config.capture_pending_traces, + pending_traces: BTreeMap::new(), + next_trace_id: TraceId(0), + is_main_thread: true, + non_determinism_error: None, + finished: false, + parking_allowed_once: false, + unparked: false, + })), + clock: Arc::new(TestClock::new()), + thread: thread::current(), + } + } + + pub fn end_test(&self) { + let mut state = self.state.lock(); + if let Some((message, backtrace)) = &state.non_determinism_error { + panic!("{}\n{:?}", message, backtrace) + } + state.finished = true; + } + + pub fn clock(&self) -> Arc { + self.clock.clone() + } + + pub fn rng(&self) -> SharedRng { + SharedRng(self.rng.clone()) + } + + pub fn set_timeout_ticks(&self, timeout_ticks: RangeInclusive) { + self.state.lock().timeout_ticks = timeout_ticks; + } + + pub fn allow_parking(&self) { + let mut state = self.state.lock(); + state.allow_parking = true; + state.parking_allowed_once = true; + } + + pub fn forbid_parking(&self) { + self.state.lock().allow_parking = false; + } + + pub fn parking_allowed(&self) -> bool { + self.state.lock().allow_parking + } + + pub fn is_main_thread(&self) -> bool { + 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 { + let session_id = self.allocate_session_id(); + ForegroundExecutor::new(session_id, self.clone()) + } + + /// Create a background executor for this scheduler + pub fn background(self: &Arc) -> BackgroundExecutor { + BackgroundExecutor::new(self.clone()) + } + + pub fn yield_random(&self) -> Yield { + let rng = &mut *self.rng.lock(); + if rng.random_bool(0.1) { + Yield(rng.random_range(10..20)) + } else { + Yield(rng.random_range(0..2)) + } + } + + pub fn run(&self) { + while self.step() { + // Continue until no work remains + } + } + + pub fn run_with_clock_advancement(&self) { + while self.step() || self.advance_clock_to_next_timer() { + // Continue until no work remains + } + } + + /// Execute one tick of the scheduler, processing expired timers and running + /// at most one task. Returns true if any work was done. + /// + /// This is the public interface for GPUI's TestDispatcher to drive task execution. + pub fn tick(&self) -> bool { + self.step_filtered(false) + } + + /// Execute one tick, but only run background tasks (no foreground/session tasks). + /// Returns true if any work was done. + pub fn tick_background_only(&self) -> bool { + self.step_filtered(true) + } + + /// Check if there are any pending tasks or timers that could run. + pub fn has_pending_tasks(&self) -> bool { + let state = self.state.lock(); + !state.runnables.is_empty() || !state.timers.is_empty() + } + + /// Returns counts of (foreground_tasks, background_tasks) currently queued. + /// Foreground tasks are those with a session_id, background tasks have none. + pub fn pending_task_counts(&self) -> (usize, usize) { + let state = self.state.lock(); + let foreground = state + .runnables + .iter() + .filter(|r| r.session_id.is_some()) + .count(); + let background = state + .runnables + .iter() + .filter(|r| r.session_id.is_none()) + .count(); + (foreground, background) + } + + fn step(&self) -> bool { + self.step_filtered(false) + } + + fn step_filtered(&self, background_only: bool) -> bool { + let (elapsed_count, runnables_before) = { + let mut state = self.state.lock(); + let end_ix = state + .timers + .partition_point(|timer| timer.expiration <= self.clock.now()); + let elapsed: Vec<_> = state.timers.drain(..end_ix).collect(); + let count = elapsed.len(); + let runnables = state.runnables.len(); + drop(state); + // Dropping elapsed timers here wakes the waiting futures + drop(elapsed); + (count, runnables) + }; + + if elapsed_count > 0 { + let runnables_after = self.state.lock().runnables.len(); + if std::env::var("DEBUG_SCHEDULER").is_ok() { + eprintln!( + "[scheduler] Expired {} timers at {:?}, runnables: {} -> {}", + elapsed_count, + self.clock.now(), + runnables_before, + runnables_after + ); + } + return true; + } + + let runnable = { + let state = &mut *self.state.lock(); + + // Find candidate tasks: + // - For foreground tasks (with session_id), only the first task from each session + // is a candidate (to preserve intra-session ordering) + // - For background tasks (no session_id), all are candidates + // - Tasks from blocked sessions are excluded + // - If background_only is true, skip foreground tasks entirely + let mut seen_sessions = HashSet::new(); + let candidate_indices: Vec = state + .runnables + .iter() + .enumerate() + .filter(|(_, runnable)| { + if let Some(session_id) = runnable.session_id { + // Skip foreground tasks if background_only mode + if background_only { + return false; + } + // Exclude tasks from blocked sessions + if state.blocked_sessions.contains(&session_id) { + return false; + } + // Only include first task from each session (insert returns true if new) + seen_sessions.insert(session_id) + } else { + // Background tasks are always candidates + true + } + }) + .map(|(ix, _)| ix) + .collect(); + + if candidate_indices.is_empty() { + None + } else if state.randomize_order { + // Use priority-weighted random selection + let weights: Vec = candidate_indices + .iter() + .map(|&ix| state.runnables[ix].priority.weight()) + .collect(); + let total_weight: u32 = weights.iter().sum(); + + if total_weight == 0 { + // Fallback to uniform random if all weights are zero + let choice = self.rng.lock().random_range(0..candidate_indices.len()); + state.runnables.remove(candidate_indices[choice]) + } else { + let mut target = self.rng.lock().random_range(0..total_weight); + let mut selected_idx = 0; + for (i, &weight) in weights.iter().enumerate() { + if target < weight { + selected_idx = i; + break; + } + target -= weight; + } + state.runnables.remove(candidate_indices[selected_idx]) + } + } else { + // Non-randomized: just take the first candidate task + state.runnables.remove(candidate_indices[0]) + } + }; + + if let Some(runnable) = runnable { + let is_foreground = runnable.session_id.is_some(); + let was_main_thread = self.state.lock().is_main_thread; + self.state.lock().is_main_thread = is_foreground; + runnable.run(); + self.state.lock().is_main_thread = was_main_thread; + return true; + } + + false + } + + /// Drops all runnable tasks from the scheduler. + /// + /// This is used by the leak detector to ensure that all tasks have been dropped as tasks may keep entities alive otherwise. + /// Why do we even have tasks left when tests finish you may ask. The reason for that is simple, the scheduler itself is the executor and it retains the scheduled runnables. + /// A lot of tasks, including every foreground task contain an executor handle that keeps the test scheduler alive, causing a reference cycle, thus the need for this function right now. + pub fn drain_tasks(&self) { + // dropping runnables may reschedule tasks + // due to drop impls with executors in them + // so drop until we reach a fixpoint + loop { + let mut state = self.state.lock(); + if state.runnables.is_empty() && state.timers.is_empty() { + break; + } + let runnables = std::mem::take(&mut state.runnables); + let timers = std::mem::take(&mut state.timers); + drop(state); + drop(timers); + drop(runnables); + } + } + + pub fn advance_clock_to_next_timer(&self) -> bool { + if let Some(timer) = self.state.lock().timers.first() { + self.clock.advance(timer.expiration - self.clock.now()); + true + } else { + false + } + } + + pub fn advance_clock(&self, duration: Duration) { + let debug = std::env::var("DEBUG_SCHEDULER").is_ok(); + let start = self.clock.now(); + let next_now = start + duration; + if debug { + let timer_count = self.state.lock().timers.len(); + eprintln!( + "[scheduler] advance_clock({:?}) from {:?}, {} pending timers", + duration, start, timer_count + ); + } + loop { + self.run(); + if let Some(timer) = self.state.lock().timers.first() + && timer.expiration <= next_now + { + let advance_to = timer.expiration; + if debug { + eprintln!( + "[scheduler] Advancing clock {:?} -> {:?} for timer", + self.clock.now(), + advance_to + ); + } + self.clock.advance(advance_to - self.clock.now()); + } else { + break; + } + } + self.clock.advance(next_now - self.clock.now()); + if debug { + eprintln!( + "[scheduler] advance_clock done, now at {:?}", + self.clock.now() + ); + } + } + + fn park(&self, deadline: Option) -> bool { + if self.state.lock().allow_parking { + let start = Instant::now(); + // Enforce a hard timeout to prevent tests from hanging indefinitely + let hard_deadline = start + Duration::from_secs(15); + + // Use the earlier of the provided deadline or the hard timeout deadline + let effective_deadline = deadline + .map(|d| d.min(hard_deadline)) + .unwrap_or(hard_deadline); + + // Park in small intervals to allow checking both deadlines + const PARK_INTERVAL: Duration = Duration::from_millis(100); + loop { + let now = Instant::now(); + if now >= effective_deadline { + // Check if we hit the hard timeout + if now >= hard_deadline { + panic!( + "Test timed out after 15 seconds while parking. \ + This may indicate a deadlock or missing waker.", + ); + } + // Hit the provided deadline + return false; + } + + let remaining = effective_deadline.saturating_duration_since(now); + let park_duration = remaining.min(PARK_INTERVAL); + let before_park = Instant::now(); + thread::park_timeout(park_duration); + let elapsed = before_park.elapsed(); + + // Advance the test clock by the real elapsed time while parking + self.clock.advance(elapsed); + + // Check if any timers have expired after advancing the clock. + // If so, return so the caller can process them. + if self + .state + .lock() + .timers + .first() + .map_or(false, |t| t.expiration <= self.clock.now()) + { + return true; + } + + // Check if we were woken up by a different thread. + // We use a flag because timing-based detection is unreliable: + // OS scheduling delays can cause elapsed >= park_duration even when + // we were woken early by unpark(). + if std::mem::take(&mut self.state.lock().unparked) { + return true; + } + } + } else if deadline.is_some() { + false + } 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) { + writeln!(pending_traces, "{:?}", exclude_wakers_from_trace(trace)).unwrap(); + } + panic!("Parking forbidden. Pending traces:\n{}", pending_traces); + } else { + panic!( + "Parking forbidden. Re-run with {PENDING_TRACES_VAR_NAME}=1 to show pending traces" + ); + } + } +} + +fn assert_correct_thread(expected: &Thread, state: &Arc>) { + let current_thread = thread::current(); + let mut state = state.lock(); + if state.parking_allowed_once { + return; + } + if current_thread.id() == expected.id() { + return; + } + + let message = format!( + "Detected activity on thread {:?} {:?}, but test scheduler is running on {:?} {:?}. Your test is not deterministic.", + current_thread.name(), + current_thread.id(), + expected.name(), + expected.id(), + ); + let backtrace = Backtrace::new(); + if state.finished { + panic!("{}", message); + } else { + state.non_determinism_error = Some((message, backtrace)) + } +} + +impl Scheduler for TestScheduler { + /// Block until the given future completes, with an optional timeout. If the + /// future is unable to make progress at any moment before the timeout and + /// no other tasks or timers remain, we panic unless parking is allowed. If + /// parking is allowed, we block up to the timeout or indefinitely if none + /// is provided. This is to allow testing a mix of deterministic and + /// non-deterministic async behavior, such as when interacting with I/O in + /// an otherwise deterministic test. + fn block( + &self, + session_id: Option, + mut future: Pin<&mut dyn Future>, + timeout: Option, + ) -> bool { + if let Some(session_id) = session_id { + self.state.lock().blocked_sessions.push(session_id); + } + + let deadline = timeout.map(|timeout| Instant::now() + timeout); + let awoken = Arc::new(AtomicBool::new(false)); + let waker = Box::new(TracingWaker { + id: None, + awoken: awoken.clone(), + thread: self.thread.clone(), + state: self.state.clone(), + }); + let waker = unsafe { Waker::new(Box::into_raw(waker) as *const (), &WAKER_VTABLE) }; + let max_ticks = if timeout.is_some() { + self.rng + .lock() + .random_range(self.state.lock().timeout_ticks.clone()) + } else { + usize::MAX + }; + let mut cx = Context::from_waker(&waker); + + let mut completed = false; + for _ in 0..max_ticks { + match future.as_mut().poll(&mut cx) { + Poll::Ready(()) => { + completed = true; + break; + } + Poll::Pending => {} + } + + let mut stepped = None; + while self.rng.lock().random() { + let stepped = stepped.get_or_insert(false); + if self.step() { + *stepped = true; + } else { + break; + } + } + + let stepped = stepped.unwrap_or(true); + let awoken = awoken.swap(false, SeqCst); + if !stepped && !awoken { + let parking_allowed = self.state.lock().allow_parking; + // In deterministic mode (parking forbidden), instantly jump to the next timer. + // In non-deterministic mode (parking allowed), let real time pass instead. + let advanced_to_timer = !parking_allowed && self.advance_clock_to_next_timer(); + if !advanced_to_timer && !self.park(deadline) { + break; + } + } + } + + if session_id.is_some() { + self.state.lock().blocked_sessions.pop(); + } + + completed + } + + fn schedule_foreground(&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 { + let start_ix = state + .runnables + .iter() + .rposition(|task| task.session_id == Some(session_id)) + .map_or(0, |ix| ix + 1); + self.rng + .lock() + .random_range(start_ix..=state.runnables.len()) + } else { + state.runnables.len() + }; + state.runnables.insert( + ix, + ScheduledRunnable { + session_id: Some(session_id), + priority: Priority::default(), + runnable, + }, + ); + state.unparked = true; + drop(state); + self.thread.unpark(); + } + + fn schedule_background_with_priority( + &self, + runnable: Runnable, + priority: Priority, + ) { + assert_correct_thread(&self.thread, &self.state); + let mut state = self.state.lock(); + let ix = if state.randomize_order { + self.rng.lock().random_range(0..=state.runnables.len()) + } else { + state.runnables.len() + }; + state.runnables.insert( + ix, + ScheduledRunnable { + session_id: None, + priority, + runnable, + }, + ); + state.unparked = true; + drop(state); + self.thread.unpark(); + } + + fn spawn_realtime(&self, f: Box) { + std::thread::spawn(move || { + f(); + }); + } + + #[track_caller] + fn timer(&self, duration: Duration) -> Timer { + let (tx, rx) = oneshot::channel(); + let state = &mut *self.state.lock(); + state.timers.push(ScheduledTimer { + expiration: self.clock.now() + duration, + _notify: tx, + }); + state.timers.sort_by_key(|timer| timer.expiration); + Timer(rx) + } + + fn clock(&self) -> Arc { + self.clock.clone() + } + + fn as_test(&self) -> Option<&TestScheduler> { + Some(self) + } +} + +#[derive(Clone, Debug)] +pub struct TestSchedulerConfig { + pub seed: u64, + pub randomize_order: bool, + pub allow_parking: bool, + pub capture_pending_traces: bool, + pub timeout_ticks: RangeInclusive, +} + +impl TestSchedulerConfig { + pub fn with_seed(seed: u64) -> Self { + Self { + seed, + ..Default::default() + } + } +} + +impl Default for TestSchedulerConfig { + fn default() -> Self { + Self { + seed: 0, + randomize_order: true, + allow_parking: false, + capture_pending_traces: env::var(PENDING_TRACES_VAR_NAME) + .map_or(false, |var| var == "1" || var == "true"), + timeout_ticks: 1..=1000, + } + } +} + +struct ScheduledRunnable { + session_id: Option, + priority: Priority, + runnable: Runnable, +} + +impl ScheduledRunnable { + fn run(self) { + self.runnable.run(); + } +} + +struct ScheduledTimer { + expiration: Instant, + _notify: oneshot::Sender<()>, +} + +struct SchedulerState { + runnables: VecDeque, + timers: Vec, + blocked_sessions: Vec, + randomize_order: bool, + allow_parking: bool, + timeout_ticks: RangeInclusive, + next_session_id: SessionId, + capture_pending_traces: bool, + next_trace_id: TraceId, + pending_traces: BTreeMap, + is_main_thread: bool, + non_determinism_error: Option<(String, Backtrace)>, + parking_allowed_once: bool, + finished: bool, + unparked: bool, +} + +const WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new( + TracingWaker::clone_raw, + TracingWaker::wake_raw, + TracingWaker::wake_by_ref_raw, + TracingWaker::drop_raw, +); + +#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord)] +struct TraceId(usize); + +struct TracingWaker { + id: Option, + awoken: Arc, + thread: Thread, + state: Arc>, +} + +impl Clone for TracingWaker { + fn clone(&self) -> Self { + let mut state = self.state.lock(); + let id = if state.capture_pending_traces { + let id = state.next_trace_id; + state.next_trace_id.0 += 1; + state.pending_traces.insert(id, Backtrace::new_unresolved()); + Some(id) + } else { + None + }; + Self { + id, + awoken: self.awoken.clone(), + thread: self.thread.clone(), + state: self.state.clone(), + } + } +} + +impl Drop for TracingWaker { + fn drop(&mut self) { + assert_correct_thread(&self.thread, &self.state); + + if let Some(id) = self.id { + self.state.lock().pending_traces.remove(&id); + } + } +} + +impl TracingWaker { + fn wake(self) { + self.wake_by_ref(); + } + + fn wake_by_ref(&self) { + assert_correct_thread(&self.thread, &self.state); + + let mut state = self.state.lock(); + if let Some(id) = self.id { + state.pending_traces.remove(&id); + } + state.unparked = true; + drop(state); + self.awoken.store(true, SeqCst); + self.thread.unpark(); + } + + fn clone_raw(waker: *const ()) -> RawWaker { + let waker = waker as *const TracingWaker; + let waker = unsafe { &*waker }; + RawWaker::new( + Box::into_raw(Box::new(waker.clone())) as *const (), + &WAKER_VTABLE, + ) + } + + fn wake_raw(waker: *const ()) { + let waker = unsafe { Box::from_raw(waker as *mut TracingWaker) }; + waker.wake(); + } + + fn wake_by_ref_raw(waker: *const ()) { + let waker = waker as *const TracingWaker; + let waker = unsafe { &*waker }; + waker.wake_by_ref(); + } + + fn drop_raw(waker: *const ()) { + let waker = unsafe { Box::from_raw(waker as *mut TracingWaker) }; + drop(waker); + } +} + +pub struct Yield(usize); + +/// A wrapper around `Arc>` that provides convenient methods +/// for random number generation without requiring explicit locking. +#[derive(Clone)] +pub struct SharedRng(Arc>); + +impl SharedRng { + /// Lock the inner RNG for direct access. Use this when you need multiple + /// random operations without re-locking between each one. + pub fn lock(&self) -> MutexGuard<'_, StdRng> { + self.0.lock() + } + + /// Generate a random value in the given range. + pub fn random_range(&self, range: R) -> T + where + T: SampleUniform, + R: SampleRange, + { + self.0.lock().random_range(range) + } + + /// Generate a random boolean with the given probability of being true. + pub fn random_bool(&self, p: f64) -> bool { + self.0.lock().random_bool(p) + } + + /// Generate a random value of the given type. + pub fn random(&self) -> T + where + StandardUniform: Distribution, + { + self.0.lock().random() + } + + /// Generate a random ratio - true with probability `numerator/denominator`. + pub fn random_ratio(&self, numerator: u32, denominator: u32) -> bool { + self.0.lock().random_ratio(numerator, denominator) + } +} + +impl Future for Yield { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll { + if self.0 == 0 { + Poll::Ready(()) + } else { + self.0 -= 1; + cx.waker().wake_by_ref(); + Poll::Pending + } + } +} + +fn exclude_wakers_from_trace(mut trace: Backtrace) -> Backtrace { + trace.resolve(); + let mut frames: Vec = trace.into(); + let waker_clone_frame_ix = frames.iter().position(|frame| { + frame.symbols().iter().any(|symbol| { + symbol + .name() + .is_some_and(|name| format!("{name:#?}") == type_name_of_val(&Waker::clone)) + }) + }); + + if let Some(waker_clone_frame_ix) = waker_clone_frame_ix { + frames.drain(..waker_clone_frame_ix + 1); + } + + Backtrace::from(frames) +} diff --git a/src/scheduler/tests.rs b/src/scheduler/tests.rs new file mode 100644 index 0000000000..03fe8075f9 --- /dev/null +++ b/src/scheduler/tests.rs @@ -0,0 +1,670 @@ +use super::*; +use futures::{ + FutureExt, + channel::{mpsc, oneshot}, + executor::block_on, + future, + sink::SinkExt, + stream::{FuturesUnordered, StreamExt}, +}; +use std::{ + cell::RefCell, + collections::{BTreeSet, HashSet}, + pin::Pin, + rc::Rc, + sync::Arc, + task::{Context, Poll, Waker}, +}; + +#[test] +fn test_foreground_executor_spawn() { + let result = TestScheduler::once(async |scheduler| { + let task = scheduler.foreground().spawn(async move { 42 }); + task.await + }); + assert_eq!(result, 42); +} + +#[test] +fn test_background_executor_spawn() { + TestScheduler::once(async |scheduler| { + let task = scheduler.background().spawn(async move { 42 }); + let result = task.await; + assert_eq!(result, 42); + }); +} + +#[test] +fn test_foreground_ordering() { + let mut traces = HashSet::new(); + + TestScheduler::many(100, async |scheduler| { + #[derive(Hash, PartialEq, Eq)] + struct TraceEntry { + session: usize, + task: usize, + } + + let trace = Rc::new(RefCell::new(Vec::new())); + + let foreground_1 = scheduler.foreground(); + for task in 0..10 { + foreground_1 + .spawn({ + let trace = trace.clone(); + async move { + trace.borrow_mut().push(TraceEntry { session: 0, task }); + } + }) + .detach(); + } + + let foreground_2 = scheduler.foreground(); + for task in 0..10 { + foreground_2 + .spawn({ + let trace = trace.clone(); + async move { + trace.borrow_mut().push(TraceEntry { session: 1, task }); + } + }) + .detach(); + } + + scheduler.run(); + + assert_eq!( + trace + .borrow() + .iter() + .filter(|entry| entry.session == 0) + .map(|entry| entry.task) + .collect::>(), + (0..10).collect::>() + ); + assert_eq!( + trace + .borrow() + .iter() + .filter(|entry| entry.session == 1) + .map(|entry| entry.task) + .collect::>(), + (0..10).collect::>() + ); + + traces.insert(trace.take()); + }); + + assert!(traces.len() > 1, "Expected at least two traces"); +} + +#[test] +fn test_timer_ordering() { + TestScheduler::many(1, async |scheduler| { + let background = scheduler.background(); + let futures = FuturesUnordered::new(); + futures.push( + async { + background.timer(Duration::from_millis(100)).await; + 2 + } + .boxed(), + ); + futures.push( + async { + background.timer(Duration::from_millis(50)).await; + 1 + } + .boxed(), + ); + futures.push( + async { + background.timer(Duration::from_millis(150)).await; + 3 + } + .boxed(), + ); + assert_eq!(futures.collect::>().await, vec![1, 2, 3]); + }); +} + +#[test] +fn test_send_from_bg_to_fg() { + TestScheduler::once(async |scheduler| { + let foreground = scheduler.foreground(); + let background = scheduler.background(); + + let (sender, receiver) = oneshot::channel::(); + + background + .spawn(async move { + sender.send(42).unwrap(); + }) + .detach(); + + let task = foreground.spawn(async move { receiver.await.unwrap() }); + let result = task.await; + assert_eq!(result, 42); + }); +} + +#[test] +fn test_randomize_order() { + // Test deterministic mode: different seeds should produce same execution order + let mut deterministic_results = HashSet::new(); + for seed in 0..10 { + let config = TestSchedulerConfig { + seed, + randomize_order: false, + ..Default::default() + }; + let order = block_on(capture_execution_order(config)); + assert_eq!(order.len(), 6); + deterministic_results.insert(order); + } + + // All deterministic runs should produce the same result + assert_eq!( + deterministic_results.len(), + 1, + "Deterministic mode should always produce same execution order" + ); + + // Test randomized mode: different seeds can produce different execution orders + let mut randomized_results = HashSet::new(); + for seed in 0..20 { + let config = TestSchedulerConfig::with_seed(seed); + let order = block_on(capture_execution_order(config)); + assert_eq!(order.len(), 6); + randomized_results.insert(order); + } + + // Randomized mode should produce multiple different execution orders + assert!( + randomized_results.len() > 1, + "Randomized mode should produce multiple different orders" + ); +} + +async fn capture_execution_order(config: TestSchedulerConfig) -> Vec { + let scheduler = Arc::new(TestScheduler::new(config)); + let foreground = scheduler.foreground(); + let background = scheduler.background(); + + let (sender, receiver) = mpsc::unbounded::(); + + // Spawn foreground tasks + for i in 0..3 { + let mut sender = sender.clone(); + foreground + .spawn(async move { + sender.send(format!("fg-{}", i)).await.ok(); + }) + .detach(); + } + + // Spawn background tasks + for i in 0..3 { + let mut sender = sender.clone(); + background + .spawn(async move { + sender.send(format!("bg-{}", i)).await.ok(); + }) + .detach(); + } + + drop(sender); // Close sender to signal no more messages + scheduler.run(); + + receiver.collect().await +} + +#[test] +fn test_block() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let (tx, rx) = oneshot::channel(); + + // Spawn background task to send value + let _ = scheduler + .background() + .spawn(async move { + tx.send(42).unwrap(); + }) + .detach(); + + // Block on receiving the value + let result = scheduler.foreground().block_on(async { rx.await.unwrap() }); + assert_eq!(result, 42); +} + +#[test] +#[should_panic(expected = "Parking forbidden. Pending traces:")] +fn test_parking_panics() { + let config = TestSchedulerConfig { + capture_pending_traces: true, + ..Default::default() + }; + let scheduler = Arc::new(TestScheduler::new(config)); + scheduler.foreground().block_on(async { + let (_tx, rx) = oneshot::channel::<()>(); + rx.await.unwrap(); // This will never complete + }); +} + +#[test] +fn test_block_with_parking() { + let config = TestSchedulerConfig { + allow_parking: true, + ..Default::default() + }; + let scheduler = Arc::new(TestScheduler::new(config)); + let (tx, rx) = oneshot::channel(); + + // Spawn background task to send value + let _ = scheduler + .background() + .spawn(async move { + tx.send(42).unwrap(); + }) + .detach(); + + // Block on receiving the value (will park if needed) + let result = scheduler.foreground().block_on(async { rx.await.unwrap() }); + assert_eq!(result, 42); +} + +#[test] +fn test_helper_methods() { + // Test the once method + let result = TestScheduler::once(async |scheduler: Arc| { + let background = scheduler.background(); + background.spawn(async { 42 }).await + }); + assert_eq!(result, 42); + + // Test the many method + let results = TestScheduler::many(3, async |scheduler: Arc| { + let background = scheduler.background(); + background.spawn(async { 10 }).await + }); + assert_eq!(results, vec![10, 10, 10]); +} + +#[test] +fn test_many_with_arbitrary_seed() { + for seed in [0u64, 1, 5, 42] { + let mut seeds_seen = Vec::new(); + let iterations = 3usize; + + for current_seed in seed..seed + iterations as u64 { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::with_seed( + current_seed, + ))); + let captured_seed = current_seed; + scheduler + .foreground() + .block_on(async { seeds_seen.push(captured_seed) }); + scheduler.run(); + } + + assert_eq!( + seeds_seen, + (seed..seed + iterations as u64).collect::>(), + "Expected {iterations} iterations starting at seed {seed}" + ); + } +} + +#[test] +fn test_block_with_timeout() { + // Test case: future completes within timeout + TestScheduler::once(async |scheduler| { + let foreground = scheduler.foreground(); + let future = future::ready(42); + let output = foreground.block_with_timeout(Duration::from_millis(100), future); + assert_eq!(output.ok(), Some(42)); + }); + + // Test case: future times out + TestScheduler::once(async |scheduler| { + // Make timeout behavior deterministic by forcing the timeout tick budget to be exactly 0. + // This prevents `block_with_timeout` from making progress via extra scheduler stepping and + // accidentally completing work that we expect to time out. + scheduler.set_timeout_ticks(0..=0); + + let foreground = scheduler.foreground(); + let future = future::pending::<()>(); + let output = foreground.block_with_timeout(Duration::from_millis(50), future); + assert!(output.is_err(), "future should not have finished"); + }); + + // Test case: future makes progress via timer but still times out + let mut results = BTreeSet::new(); + TestScheduler::many(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 { + Yield { polls: 10 }.await; + 42 + }); + let output = scheduler + .foreground() + .block_with_timeout(Duration::from_millis(50), task); + results.insert(output.ok()); + }); + assert_eq!( + results.into_iter().collect::>(), + vec![None, Some(42)] + ); + + // Regression test: + // A timed-out future must not be cancelled. The returned future should still be + // pollable to completion later. We also want to ensure time only advances when we + // explicitly advance it (not by yielding). + TestScheduler::once(async |scheduler| { + // Force immediate timeout: the timeout tick budget is 0 so we will not step or + // advance timers inside `block_with_timeout`. + scheduler.set_timeout_ticks(0..=0); + + let background = scheduler.background(); + + // This task should only complete once time is explicitly advanced. + let task = background.spawn({ + let scheduler = scheduler.clone(); + async move { + scheduler.timer(Duration::from_millis(100)).await; + 123 + } + }); + + // This should time out before we advance time enough for the timer to fire. + let timed_out = scheduler + .foreground() + .block_with_timeout(Duration::from_millis(50), task); + assert!( + timed_out.is_err(), + "expected timeout before advancing the clock enough for the timer" + ); + + // Now explicitly advance time and ensure the returned future can complete. + let mut task = timed_out.err().unwrap(); + scheduler.advance_clock(Duration::from_millis(100)); + scheduler.run(); + + let output = scheduler.foreground().block_on(&mut task); + assert_eq!(output, 123); + }); +} + +// When calling block, we shouldn't make progress on foreground-spawned futures with the same session id. +#[test] +fn test_block_does_not_progress_same_session_foreground() { + let mut task2_made_progress_once = false; + TestScheduler::many(1000, async |scheduler| { + let foreground1 = scheduler.foreground(); + let foreground2 = scheduler.foreground(); + + let task1 = foreground1.spawn(async move {}); + let task2 = foreground2.spawn(async move {}); + + foreground1.block_on(async { + scheduler.yield_random().await; + assert!(!task1.is_ready()); + task2_made_progress_once |= task2.is_ready(); + }); + + task1.await; + task2.await; + }); + + assert!( + task2_made_progress_once, + "Expected task from different foreground executor to make progress (at least once)" + ); +} + +struct Yield { + polls: usize, +} + +impl Future for Yield { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.polls -= 1; + if self.polls == 0 { + Poll::Ready(()) + } else { + cx.waker().wake_by_ref(); + Poll::Pending + } + } +} + +#[test] +fn test_nondeterministic_wake_detection() { + let config = TestSchedulerConfig { + allow_parking: false, + ..Default::default() + }; + let scheduler = Arc::new(TestScheduler::new(config)); + + // A future that captures its waker and sends it to an external thread + struct SendWakerToThread { + waker_tx: Option>, + } + + impl Future for SendWakerToThread { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if let Some(tx) = self.waker_tx.take() { + tx.send(cx.waker().clone()).ok(); + } + Poll::Ready(()) + } + } + + let (waker_tx, waker_rx) = std::sync::mpsc::channel::(); + + // Get a waker by running a future that sends it + scheduler.foreground().block_on(SendWakerToThread { + waker_tx: Some(waker_tx), + }); + + // Spawn a real OS thread that will call wake() on the waker + let handle = std::thread::spawn(move || { + if let Ok(waker) = waker_rx.recv() { + // This should trigger the non-determinism detection + waker.wake(); + } + }); + + // Wait for the spawned thread to complete + handle.join().ok(); + + // The non-determinism error should be detected when end_test is called + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + scheduler.end_test(); + })); + assert!(result.is_err(), "Expected end_test to panic"); + let panic_payload = result.unwrap_err(); + let panic_message = panic_payload + .downcast_ref::() + .map(|s| s.as_str()) + .or_else(|| panic_payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!( + panic_message.contains("Your test is not deterministic"), + "Expected panic message to contain non-determinism error, got: {}", + panic_message + ); +} + +#[test] +fn test_nondeterministic_wake_allowed_with_parking() { + let config = TestSchedulerConfig { + allow_parking: true, + ..Default::default() + }; + let scheduler = Arc::new(TestScheduler::new(config)); + + // A future that captures its waker and sends it to an external thread + struct WakeFromExternalThread { + waker_sent: bool, + waker_tx: Option>, + } + + impl Future for WakeFromExternalThread { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if !self.waker_sent { + self.waker_sent = true; + if let Some(tx) = self.waker_tx.take() { + tx.send(cx.waker().clone()).ok(); + } + Poll::Pending + } else { + Poll::Ready(()) + } + } + } + + let (waker_tx, waker_rx) = std::sync::mpsc::channel::(); + + // Spawn a real OS thread that will call wake() on the waker + std::thread::spawn(move || { + if let Ok(waker) = waker_rx.recv() { + // With allow_parking, this should NOT panic + waker.wake(); + } + }); + + // This should complete without panicking + scheduler.foreground().block_on(WakeFromExternalThread { + waker_sent: false, + waker_tx: Some(waker_tx), + }); +} + +#[test] +fn test_nondeterministic_waker_drop_detection() { + let config = TestSchedulerConfig { + allow_parking: false, + ..Default::default() + }; + let scheduler = Arc::new(TestScheduler::new(config)); + + // A future that captures its waker and sends it to an external thread + struct SendWakerToThread { + waker_tx: Option>, + } + + impl Future for SendWakerToThread { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if let Some(tx) = self.waker_tx.take() { + tx.send(cx.waker().clone()).ok(); + } + Poll::Ready(()) + } + } + + let (waker_tx, waker_rx) = std::sync::mpsc::channel::(); + + // Get a waker by running a future that sends it + scheduler.foreground().block_on(SendWakerToThread { + waker_tx: Some(waker_tx), + }); + + // Spawn a real OS thread that will drop the waker without calling wake + let handle = std::thread::spawn(move || { + if let Ok(waker) = waker_rx.recv() { + // This should trigger the non-determinism detection on drop + drop(waker); + } + }); + + // Wait for the spawned thread to complete + handle.join().ok(); + + // The non-determinism error should be detected when end_test is called + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + scheduler.end_test(); + })); + assert!(result.is_err(), "Expected end_test to panic"); + let panic_payload = result.unwrap_err(); + let panic_message = panic_payload + .downcast_ref::() + .map(|s| s.as_str()) + .or_else(|| panic_payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!( + panic_message.contains("Your test is not deterministic"), + "Expected panic message to contain non-determinism error, got: {}", + panic_message + ); +} + +#[test] +fn test_background_priority_scheduling() { + use parking_lot::Mutex; + + // Run many iterations to get statistical significance + let mut high_before_low_count = 0; + let iterations = 100; + + for seed in 0..iterations { + let config = TestSchedulerConfig::with_seed(seed); + let scheduler = Arc::new(TestScheduler::new(config)); + let background = scheduler.background(); + + let execution_order = Arc::new(Mutex::new(Vec::new())); + + // Spawn low priority tasks first + for i in 0..3 { + let order = execution_order.clone(); + background + .spawn_with_priority(Priority::Low, async move { + order.lock().push(format!("low-{}", i)); + }) + .detach(); + } + + // Spawn high priority tasks second + for i in 0..3 { + let order = execution_order.clone(); + background + .spawn_with_priority(Priority::High, async move { + order.lock().push(format!("high-{}", i)); + }) + .detach(); + } + + scheduler.run(); + + // Count how many high priority tasks ran in the first half + let order = execution_order.lock(); + let high_in_first_half = order + .iter() + .take(3) + .filter(|s| s.starts_with("high")) + .count(); + + if high_in_first_half >= 2 { + high_before_low_count += 1; + } + } + + // High priority tasks should tend to run before low priority tasks + // With weights of 60 vs 10, high priority should dominate early execution + assert!( + high_before_low_count > iterations / 2, + "Expected high priority tasks to run before low priority tasks more often. \ + Got {} out of {} iterations", + high_before_low_count, + iterations + ); +} diff --git a/src/style.rs b/src/style.rs index 42f8f25e47..f7058216ee 100644 --- a/src/style.rs +++ b/src/style.rs @@ -138,6 +138,42 @@ impl ObjectFit { } } +/// The minimum size of a column or row in a grid layout +#[derive( + Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Default, JsonSchema, Serialize, Deserialize, +)] +pub enum TemplateColumnMinSize { + /// The column size may be 0 + #[default] + Zero, + /// The column size can be determined by the min content + MinContent, + /// The column size can be determined by the max content + MaxContent, +} + +/// A simplified representation of the grid-template-* value +#[derive( + Copy, + Clone, + Refineable, + PartialEq, + Eq, + PartialOrd, + Ord, + Debug, + Default, + JsonSchema, + Serialize, + Deserialize, +)] +pub struct GridTemplate { + /// How this template directive should be repeated + pub repeat: u16, + /// The minimum size in the repeat(<>, minmax(_, 1fr)) equation + pub min_size: TemplateColumnMinSize, +} + /// The CSS styling that can be applied to an element via the `Styled` trait #[derive(Clone, Refineable, Debug)] #[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -252,6 +288,7 @@ pub struct Style { pub box_shadow: Vec, /// The text style of this element + #[refineable] pub text: TextStyleRefinement, /// The mouse cursor style shown when the mouse pointer is over an element. @@ -261,12 +298,12 @@ pub struct Style { pub opacity: Option, /// The grid columns of this element - /// Equivalent to the Tailwind `grid-cols-` - pub grid_cols: Option, + /// Roughly equivalent to the Tailwind `grid-cols-` + pub grid_cols: Option, /// The row span of this element /// Equivalent to the Tailwind `grid-rows-` - pub grid_rows: Option, + pub grid_rows: Option, /// The grid location of this element pub grid_location: Option, @@ -329,9 +366,13 @@ pub enum WhiteSpace { /// How to truncate text that overflows the width of the element #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum TextOverflow { - /// Truncate the text when it doesn't fit, and represent this truncation by displaying the - /// provided string. + /// Truncate the text at the end when it doesn't fit, and represent this truncation by + /// displaying the provided string (e.g., "very long te…"). Truncate(SharedString), + /// Truncate the text at the start when it doesn't fit, and represent this truncation by + /// displaying the provided string at the beginning (e.g., "…ong text here"). + /// Typically more adequate for file paths where the end is more important than the beginning. + TruncateStart(SharedString), } /// How to align text within the element @@ -398,6 +439,9 @@ pub struct TextStyle { pub line_clamp: Option, } +/// A workaround for Refineable macro expecting a Refinement of a Refinement +pub type TextStyleRefinementRefinement = TextStyleRefinement; + impl Default for TextStyle { fn default() -> Self { TextStyle { @@ -630,13 +674,15 @@ impl Style { if background_color.is_some_and(|color| !color.is_transparent()) { let mut border_color = match background_color { Some(color) => match color.tag { - BackgroundTag::Solid => color.solid, + BackgroundTag::Solid + | BackgroundTag::PatternSlash + | BackgroundTag::Checkerboard => color.solid, + BackgroundTag::LinearGradient => color .colors .first() .map(|stop| stop.color) .unwrap_or_default(), - BackgroundTag::PatternSlash => color.solid, }, None => Hsla::default(), }; @@ -657,23 +703,31 @@ impl Style { let border_widths = self.border_widths.to_pixels(rem_size); let max_border_width = border_widths.max(); let max_corner_radius = corner_radii.max(); + let zero_size = Size { + width: Pixels::ZERO, + height: Pixels::ZERO, + }; - let top_bounds = Bounds::from_corners( + let mut top_bounds = Bounds::from_corners( bounds.origin, bounds.top_right() + point(Pixels::ZERO, max_border_width.max(max_corner_radius)), ); - let bottom_bounds = Bounds::from_corners( + top_bounds.size = top_bounds.size.max(&zero_size); + let mut bottom_bounds = Bounds::from_corners( bounds.bottom_left() - point(Pixels::ZERO, max_border_width.max(max_corner_radius)), bounds.bottom_right(), ); - let left_bounds = Bounds::from_corners( + bottom_bounds.size = bottom_bounds.size.max(&zero_size); + let mut left_bounds = Bounds::from_corners( top_bounds.bottom_left(), bottom_bounds.origin + point(max_border_width, Pixels::ZERO), ); - let right_bounds = Bounds::from_corners( + left_bounds.size = left_bounds.size.max(&zero_size); + let mut right_bounds = Bounds::from_corners( top_bounds.bottom_right() - point(max_border_width, Pixels::ZERO), bottom_bounds.top_right(), ); + right_bounds.size = right_bounds.size.max(&zero_size); let mut background = self.border_color.unwrap_or_default(); background.a = 0.; @@ -1469,4 +1523,21 @@ mod tests { ] ); } + + #[perf] + fn test_text_style_refinement() { + let mut style = Style::default(); + style.refine(&StyleRefinement::default().text_size(px(20.0))); + style.refine(&StyleRefinement::default().font_weight(FontWeight::SEMIBOLD)); + + assert_eq!( + Some(AbsoluteLength::from(px(20.0))), + style.text_style().unwrap().font_size + ); + + assert_eq!( + Some(FontWeight::SEMIBOLD), + style.text_style().unwrap().font_weight + ); + } } diff --git a/src/styled.rs b/src/styled.rs index 752038c1ed..687e71a94c 100644 --- a/src/styled.rs +++ b/src/styled.rs @@ -1,9 +1,9 @@ use crate::{ - self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle, + self as gpui, AbsoluteLength, AlignContent, AlignItems, AlignSelf, BorderStyle, CursorStyle, DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, - FontWeight, GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, - StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, - relative, rems, + FontWeight, GridPlacement, GridTemplate, Hsla, JustifyContent, Length, SharedString, + StrikethroughStyle, StyleRefinement, TemplateColumnMinSize, TextAlign, TextOverflow, + TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems, }; pub use gpui_macros::{ border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods, @@ -64,43 +64,41 @@ pub trait Styled: Sized { /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Normal); + self.text_style().white_space = Some(WhiteSpace::Normal); self } /// Sets the whitespace of the element to `nowrap`. /// [Docs](https://tailwindcss.com/docs/whitespace#nowrap) fn whitespace_nowrap(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Nowrap); + self.text_style().white_space = Some(WhiteSpace::Nowrap); self } - /// Sets the truncate overflowing text with an ellipsis (…) if needed. + /// Sets the truncate overflowing text with an ellipsis (…) at the end if needed. /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis) fn text_ellipsis(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self + } + + /// Sets the truncate overflowing text with an ellipsis (…) at the start if needed. + /// Typically more adequate for file paths where the end is more important than the beginning. + /// Note: This doesn't exist in Tailwind CSS. + fn text_ellipsis_start(mut self) -> Self { + self.text_style().text_overflow = Some(TextOverflow::TruncateStart(ELLIPSIS)); self } /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(overflow); + self.text_style().text_overflow = Some(overflow); self } /// Set the text alignment of the element. fn text_align(mut self, align: TextAlign) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_align = Some(align); + self.text_style().text_align = Some(align); self } @@ -128,7 +126,7 @@ pub trait Styled: Sized { /// Sets number of lines to show before truncating the text. /// [Docs](https://tailwindcss.com/docs/line-clamp) fn line_clamp(mut self, lines: usize) -> Self { - let mut text_style = self.text_style().get_or_insert_with(Default::default); + let mut text_style = self.text_style(); text_style.line_clamp = Some(lines); self.overflow_hidden() } @@ -273,6 +271,62 @@ pub trait Styled: Sized { self } + /// Sets the element to stretch flex items to fill the available space along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-items#stretch) + fn items_stretch(mut self) -> Self { + self.style().align_items = Some(AlignItems::Stretch); + self + } + + /// Sets how this specific element is aligned along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#start) + fn self_start(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Start); + self + } + + /// Sets this element to align against the end of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#end) + fn self_end(mut self) -> Self { + self.style().align_self = Some(AlignSelf::End); + self + } + + /// Sets this element to align against the start of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#start) + fn self_flex_start(mut self) -> Self { + self.style().align_self = Some(AlignSelf::FlexStart); + self + } + + /// Sets this element to align against the end of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#end) + fn self_flex_end(mut self) -> Self { + self.style().align_self = Some(AlignSelf::FlexEnd); + self + } + + /// Sets this element to align along the center of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#center) + fn self_center(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Center); + self + } + + /// Sets this element to align along the baseline of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#baseline) + fn self_baseline(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Baseline); + self + } + + /// Sets this element to stretch to fill the available space along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#stretch) + fn self_stretch(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Stretch); + self + } + /// Sets the element to justify flex items against the start of the container's main axis. /// [Docs](https://tailwindcss.com/docs/justify-content#start) fn justify_start(mut self) -> Self { @@ -379,6 +433,20 @@ pub trait Styled: Sized { self } + /// Sets the aspect ratio of the element. + /// [Docs](https://tailwindcss.com/docs/aspect-ratio) + fn aspect_ratio(mut self, ratio: f32) -> Self { + self.style().aspect_ratio = Some(ratio); + self + } + + /// Sets the aspect ratio of the element to 1/1 – equal width and height. + /// [Docs](https://tailwindcss.com/docs/aspect-ratio) + fn aspect_square(mut self) -> Self { + self.style().aspect_ratio = Some(1.0); + self + } + /// Sets the background color of the element. fn bg(mut self, fill: F) -> Self where @@ -396,7 +464,7 @@ pub trait Styled: Sized { } /// Returns a mutable reference to the text style that has been configured on this element. - fn text_style(&mut self) -> &mut Option { + fn text_style(&mut self) -> &mut TextStyleRefinement { let style: &mut StyleRefinement = self.style(); &mut style.text } @@ -405,7 +473,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_color(mut self, color: impl Into) -> Self { - self.text_style().get_or_insert_with(Default::default).color = Some(color.into()); + self.text_style().color = Some(color.into()); self } @@ -413,9 +481,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn font_weight(mut self, weight: FontWeight) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_weight = Some(weight); + self.text_style().font_weight = Some(weight); self } @@ -423,9 +489,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_bg(mut self, bg: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .background_color = Some(bg.into()); + self.text_style().background_color = Some(bg.into()); self } @@ -433,97 +497,77 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_size(mut self, size: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(size.into()); + self.text_style().font_size = Some(size.into()); self } /// Sets the text size to 'extra small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xs(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.75).into()); + self.text_style().font_size = Some(rems(0.75).into()); self } /// Sets the text size to 'small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_sm(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.875).into()); + self.text_style().font_size = Some(rems(0.875).into()); self } /// Sets the text size to 'base'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_base(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.0).into()); + self.text_style().font_size = Some(rems(1.0).into()); self } /// Sets the text size to 'large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_lg(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.125).into()); + self.text_style().font_size = Some(rems(1.125).into()); self } /// Sets the text size to 'extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.25).into()); + self.text_style().font_size = Some(rems(1.25).into()); self } /// Sets the text size to 'extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_2xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.5).into()); + self.text_style().font_size = Some(rems(1.5).into()); self } /// Sets the text size to 'extra extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_3xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.875).into()); + self.text_style().font_size = Some(rems(1.875).into()); self } /// Sets the font style of the element to italic. /// [Docs](https://tailwindcss.com/docs/font-style#italicizing-text) fn italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + self.text_style().font_style = Some(FontStyle::Italic); self } /// Sets the font style of the element to normal (not italic). /// [Docs](https://tailwindcss.com/docs/font-style#displaying-text-normally) fn not_italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Normal); + self.text_style().font_style = Some(FontStyle::Normal); self } /// Sets the text decoration to underline. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#underling-text) fn underline(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.underline = Some(UnderlineStyle { thickness: px(1.), ..Default::default() @@ -534,7 +578,7 @@ pub trait Styled: Sized { /// Sets the decoration of the text to have a line through it. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#adding-a-line-through-text) fn line_through(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.strikethrough = Some(StrikethroughStyle { thickness: px(1.), ..Default::default() @@ -546,15 +590,13 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_decoration_none(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .underline = None; + self.text_style().underline = None; self } /// Sets the color for the underline on this element fn text_decoration_color(mut self, color: impl Into) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.color = Some(color.into()); self @@ -563,7 +605,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a solid line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_solid(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = false; self @@ -572,7 +614,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a wavy line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_wavy(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = true; self @@ -581,7 +623,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 0px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_0(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(0.); self @@ -590,7 +632,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 1px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_1(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(1.); self @@ -599,7 +641,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 2px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_2(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(2.); self @@ -608,7 +650,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 4px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_4(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(4.); self @@ -617,7 +659,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 8px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_8(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(8.); self @@ -625,17 +667,13 @@ pub trait Styled: Sized { /// Sets the font family of this element and its children. fn font_family(mut self, family_name: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_family = Some(family_name.into()); + self.text_style().font_family = Some(family_name.into()); self } /// Sets the font features of this element and its children. fn font_features(mut self, features: FontFeatures) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_features = Some(features); + self.text_style().font_features = Some(features); self } @@ -649,7 +687,7 @@ pub trait Styled: Sized { style, } = font; - let text_style = self.text_style().get_or_insert_with(Default::default); + let text_style = self.text_style(); text_style.font_family = Some(family); text_style.font_features = Some(features); text_style.font_weight = Some(weight); @@ -661,9 +699,7 @@ pub trait Styled: Sized { /// Sets the line height of this element and its children. fn line_height(mut self, line_height: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .line_height = Some(line_height.into()); + self.text_style().line_height = Some(line_height.into()); self } @@ -675,13 +711,38 @@ pub trait Styled: Sized { /// Sets the grid columns of this element. fn grid_cols(mut self, cols: u16) -> Self { - self.style().grid_cols = Some(cols); + self.style().grid_cols = Some(GridTemplate { + repeat: cols, + min_size: TemplateColumnMinSize::Zero, + }); + self + } + + /// Sets the grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + fn grid_cols_min_content(mut self, cols: u16) -> Self { + self.style().grid_cols = Some(GridTemplate { + repeat: cols, + min_size: TemplateColumnMinSize::MinContent, + }); + self + } + + /// Sets the grid columns with max-content maximum sizing for content-based column widths. + fn grid_cols_max_content(mut self, cols: u16) -> Self { + self.style().grid_cols = Some(GridTemplate { + repeat: cols, + min_size: TemplateColumnMinSize::MaxContent, + }); self } /// Sets the grid rows of this element. fn grid_rows(mut self, rows: u16) -> Self { - self.style().grid_rows = Some(rows); + self.style().grid_rows = Some(GridTemplate { + repeat: rows, + min_size: TemplateColumnMinSize::Zero, + }); self } diff --git a/src/svg_renderer.rs b/src/svg_renderer.rs index cae1b5d423..f82530f8d1 100644 --- a/src/svg_renderer.rs +++ b/src/svg_renderer.rs @@ -14,9 +14,10 @@ use std::{ pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.; #[derive(Clone, PartialEq, Hash, Eq)] -pub(crate) struct RenderSvgParams { - pub(crate) path: SharedString, - pub(crate) size: Size, +#[expect(missing_docs)] +pub struct RenderSvgParams { + pub path: SharedString, + pub size: Size, } #[derive(Clone)] diff --git a/src/taffy.rs b/src/taffy.rs index 11cb087286..094b65553d 100644 --- a/src/taffy.rs +++ b/src/taffy.rs @@ -1,6 +1,6 @@ use crate::{ - AbsoluteLength, App, Bounds, DefiniteLength, Edges, Length, Pixels, Point, Size, Style, Window, - point, size, + AbsoluteLength, App, Bounds, DefiniteLength, Edges, GridTemplate, Length, Pixels, Point, Size, + Style, Window, point, size, }; use collections::{FxHashMap, FxHashSet}; use stacksafe::{StackSafe, stacksafe}; @@ -8,6 +8,7 @@ use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize}, + prelude::{max_content, min_content}, style::AvailableSpace as TaffyAvailableSpace, tree::NodeId, }; @@ -307,11 +308,31 @@ impl ToTaffy for Style { } fn to_grid_repeat( - unit: &Option, + unit: &Option, ) -> Vec> { - // grid-template-columns: repeat(, minmax(0, 1fr)); - unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])]) - .unwrap_or_default() + unit.map(|template| { + match template.min_size { + // grid-template-*: repeat(, minmax(0, 1fr)); + crate::TemplateColumnMinSize::Zero => { + vec![repeat(template.repeat, vec![minmax(length(0.0), fr(1.0))])] + } + // grid-template-*: repeat(, minmax(min-content, 1fr)); + crate::TemplateColumnMinSize::MinContent => { + vec![repeat( + template.repeat, + vec![minmax(min_content(), fr(1.0))], + )] + } + // grid-template-*: repeat(, minmax(0, max-content)) + crate::TemplateColumnMinSize::MaxContent => { + vec![repeat( + template.repeat, + vec![minmax(length(0.0), max_content())], + )] + } + } + }) + .unwrap_or_default() } taffy::style::Style { diff --git a/src/test.rs b/src/test.rs index 5ae72d2be1..ddcc3d27bd 100644 --- a/src/test.rs +++ b/src/test.rs @@ -27,14 +27,43 @@ //! ``` use crate::{Entity, Subscription, TestAppContext, TestDispatcher}; use futures::StreamExt as _; -use rand::prelude::*; -use smol::channel; +use proptest::prelude::{Just, Strategy, any}; use std::{ env, - panic::{self, RefUnwindSafe}, + panic::{self, RefUnwindSafe, UnwindSafe}, pin::Pin, }; +/// Strategy injected into `#[gpui::property_test]` tests to control the seed +/// given to the scheduler. Doesn't shrink, since all scheduler seeds are +/// equivalent in complexity. If `$SEED` is set, it always uses that value. +pub fn seed_strategy() -> impl Strategy { + match std::env::var("SEED") { + Ok(val) => Just(val.parse().unwrap()).boxed(), + Err(_) => any::().no_shrink().boxed(), + } +} + +/// Similar to [`run_test`], but only runs the callback once, allowing +/// [`FnOnce`] callbacks. This is intended for use with the +/// `gpui::property_test` macro and generally should not be used directly. +/// +/// Doesn't support many features of [`run_test`], since these are provided by +/// proptest. +pub fn run_test_once(seed: u64, test_fn: Box) { + let result = panic::catch_unwind(|| { + let dispatcher = TestDispatcher::new(seed); + let scheduler = dispatcher.scheduler().clone(); + test_fn(dispatcher); + scheduler.end_test(); + }); + + match result { + Ok(()) => {} + Err(e) => panic::resume_unwind(e), + } +} + /// Run the given test function with the configured parameters. /// This is intended for use with the `gpui::test` macro /// and generally should not be used directly. @@ -54,8 +83,10 @@ pub fn run_test( eprintln!("seed = {seed}"); } let result = panic::catch_unwind(|| { - let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(seed)); + let dispatcher = TestDispatcher::new(seed); + let scheduler = dispatcher.scheduler().clone(); test_fn(dispatcher, seed); + scheduler.end_test(); }); match result { @@ -69,7 +100,10 @@ pub fn run_test( std::mem::forget(error); } else { if is_multiple_runs { - eprintln!("failing seed: {}", seed); + eprintln!("failing seed: {seed}"); + eprintln!( + "You can rerun from this seed by setting the environmental variable SEED to {seed}" + ); } if let Some(on_fail_fn) = on_fail_fn { on_fail_fn() @@ -132,7 +166,7 @@ fn calculate_seeds( /// A test struct for converting an observation callback into a stream. pub struct Observation { - rx: Pin>>, + rx: Pin>>, _subscription: Subscription, } @@ -149,10 +183,10 @@ impl futures::Stream for Observation { /// observe returns a stream of the change events from the given `Entity` pub fn observe(entity: &Entity, cx: &mut TestAppContext) -> Observation<()> { - let (tx, rx) = smol::channel::unbounded(); + let (tx, rx) = async_channel::unbounded(); let _subscription = cx.update(|cx| { cx.observe(entity, move |_, _| { - let _ = smol::block_on(tx.send(())); + let _ = pollster::block_on(tx.send(())); }) }); let rx = Box::pin(rx); diff --git a/src/text_system.rs b/src/text_system.rs index 070e434dc9..b62a0ad6fd 100644 --- a/src/text_system.rs +++ b/src/text_system.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Bounds, DevicePixels, Hsla, Pixels, PlatformTextSystem, Point, Result, SharedString, Size, - StrikethroughStyle, UnderlineStyle, px, + StrikethroughStyle, TextRenderingMode, UnderlineStyle, px, }; use anyhow::{Context as _, anyhow}; use collections::FxHashMap; @@ -41,14 +41,15 @@ pub struct FontId(pub usize); #[derive(Hash, PartialEq, Eq, Clone, Copy, Debug)] pub struct FontFamilyId(pub usize); -pub(crate) const SUBPIXEL_VARIANTS_X: u8 = 4; +/// Number of subpixel glyph variants along the X axis. +pub const SUBPIXEL_VARIANTS_X: u8 = 4; -pub(crate) const SUBPIXEL_VARIANTS_Y: u8 = - if cfg!(target_os = "windows") || cfg!(target_os = "linux") { - 1 - } else { - SUBPIXEL_VARIANTS_X - }; +/// Number of subpixel glyph variants along the Y axis. +pub const SUBPIXEL_VARIANTS_Y: u8 = if cfg!(target_os = "windows") || cfg!(target_os = "linux") { + 1 +} else { + SUBPIXEL_VARIANTS_X +}; /// The GPUI text rendering sub system. pub struct TextSystem { @@ -62,7 +63,8 @@ pub struct TextSystem { } impl TextSystem { - pub(crate) fn new(platform_text_system: Arc) -> Self { + /// Create a new TextSystem with the given platform text system. + pub fn new(platform_text_system: Arc) -> Self { TextSystem { platform_text_system, font_metrics: RwLock::default(), @@ -205,6 +207,23 @@ 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. + 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.platform_text_system + .layout_line( + buffer, + font_size, + &[FontRun { + len: buffer.len(), + font_id, + }], + ) + .width + } + /// Returns the width of an `em`. /// /// Uses the width of the `m` character in the given font and size. @@ -219,6 +238,12 @@ impl TextSystem { Ok(self.advance(font_id, font_size, 'm')?.width) } + // Consider removing this? + /// Returns the shaped layout width of an `em`. + pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { + self.layout_width(font_id, font_size, 'm') + } + /// Returns the width of an `ch`. /// /// Uses the width of the `0` character in the given font and size. @@ -295,9 +320,9 @@ impl TextSystem { let wrappers = lock .entry(FontIdWithSize { font_id, font_size }) .or_default(); - let wrapper = wrappers.pop().unwrap_or_else(|| { - LineWrapper::new(font_id, font_size, self.platform_text_system.clone()) - }); + let wrapper = wrappers + .pop() + .unwrap_or_else(|| LineWrapper::new(font_id, font_size, self.clone())); LineWrapperHandle { wrapper: Some(wrapper), @@ -326,6 +351,17 @@ impl TextSystem { self.platform_text_system .rasterize_glyph(params, raster_bounds) } + + /// Returns the text rendering mode recommended by the platform for the given font and size. + /// The return value will never be [`TextRenderingMode::PlatformDefault`]. + pub(crate) fn recommended_rendering_mode( + &self, + font_id: FontId, + font_size: Pixels, + ) -> TextRenderingMode { + self.platform_text_system + .recommended_rendering_mode(font_id, font_size) + } } /// The GPUI text layout subsystem. @@ -337,7 +373,8 @@ pub struct WindowTextSystem { } impl WindowTextSystem { - pub(crate) fn new(text_system: Arc) -> Self { + /// Create a new WindowTextSystem with the given TextSystem. + pub fn new(text_system: Arc) -> Self { Self { line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()), text_system, @@ -403,6 +440,74 @@ impl WindowTextSystem { } } + /// Shape the given line using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for the text. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + /// + /// Like [`Self::shape_line`], this must be used only for single-line text (no `\n`). + pub fn shape_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> ShapedLine { + let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); + for run in runs { + if let Some(last_run) = decoration_runs.last_mut() + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + && last_run.background_color == run.background_color + { + last_run.len += run.len as u32; + continue; + } + decoration_runs.push(DecorationRun { + len: run.len as u32, + color: run.color, + background_color: run.background_color, + underline: run.underline, + strikethrough: run.strikethrough, + }); + } + + let mut used_force_width = force_width; + let layout = self.layout_line_by_hash( + text_hash, + text_len, + font_size, + runs, + used_force_width, + || { + let text = materialize_text(); + debug_assert!( + text.find('\n').is_none(), + "text argument should not contain newlines" + ); + text + }, + ); + + // We only materialize actual text on cache miss; on hit we avoid allocations. + // Since `ShapedLine` carries a `SharedString`, use an empty placeholder for hits. + // NOTE: Callers must not rely on `ShapedLine.text` for content when using this API. + let text: SharedString = SharedString::new_static(""); + + ShapedLine { + layout, + text, + decoration_runs, + } + } + /// Shape a multi line string of text, at the given font_size, for painting to the screen. /// Subsets of the text can be styled independently with the `runs` parameter. /// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width. @@ -424,7 +529,7 @@ impl WindowTextSystem { let mut process_line = |line_text: SharedString, line_start, line_end| { font_runs.clear(); - let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); + let mut decoration_runs = >::with_capacity(32); let mut run_start = line_start; while run_start < line_end { let Some(run) = runs.peek_mut() else { @@ -592,6 +697,130 @@ impl WindowTextSystem { layout } + + /// 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 + /// or the previous frame. Returns `None` if it is not cached. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn try_layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + ) -> Option> { + let mut last_run = None::<&TextRun>; + let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + + for run in runs.iter() { + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); + } + } + + let layout = self.line_layout_cache.try_layout_line_by_hash( + text_hash, + text_len, + font_size, + &font_runs, + force_width, + ); + + self.font_runs_pool.lock().push(font_runs); + + layout + } + + /// Layout the given line of text using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for the text. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> Arc { + let mut last_run = None::<&TextRun>; + let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + + for run in runs.iter() { + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); + } + } + + let layout = self.line_layout_cache.layout_line_by_hash( + text_hash, + text_len, + font_size, + &font_runs, + force_width, + materialize_text, + ); + + self.font_runs_pool.lock().push(font_runs); + + layout + } } #[derive(Hash, Eq, PartialEq)] @@ -765,16 +994,23 @@ impl TextRun { /// An identifier for a specific glyph, as returned by [`WindowTextSystem::layout_line`]. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] #[repr(C)] -pub struct GlyphId(pub(crate) u32); +pub struct GlyphId(pub u32); +/// Parameters for rendering a glyph, used as cache keys for raster bounds. +/// +/// This struct identifies a specific glyph rendering configuration including +/// font, size, subpixel positioning, and scale factor. It's used to look up +/// cached raster bounds and sprite atlas entries. #[derive(Clone, Debug, PartialEq)] -pub(crate) struct RenderGlyphParams { - pub(crate) font_id: FontId, - pub(crate) glyph_id: GlyphId, - pub(crate) font_size: Pixels, - pub(crate) subpixel_variant: Point, - pub(crate) scale_factor: f32, - pub(crate) is_emoji: bool, +#[expect(missing_docs)] +pub struct RenderGlyphParams { + pub font_id: FontId, + pub glyph_id: GlyphId, + pub font_size: Pixels, + pub subpixel_variant: Point, + pub scale_factor: f32, + pub is_emoji: bool, + pub subpixel_rendering: bool, } impl Eq for RenderGlyphParams {} @@ -787,6 +1023,7 @@ impl Hash for RenderGlyphParams { self.subpixel_variant.hash(state); self.scale_factor.to_bits().hash(state); self.is_emoji.hash(state); + self.subpixel_rendering.hash(state); } } @@ -848,32 +1085,32 @@ impl Font { pub struct FontMetrics { /// The number of font units that make up the "em square", /// a scalable grid for determining the size of a typeface. - pub(crate) units_per_em: u32, + pub units_per_em: u32, /// The vertical distance from the baseline of the font to the top of the glyph covers. - pub(crate) ascent: f32, + pub ascent: f32, /// The vertical distance from the baseline of the font to the bottom of the glyph covers. - pub(crate) descent: f32, + pub descent: f32, /// The recommended additional space to add between lines of type. - pub(crate) line_gap: f32, + pub line_gap: f32, /// The suggested position of the underline. - pub(crate) underline_position: f32, + pub underline_position: f32, /// The suggested thickness of the underline. - pub(crate) underline_thickness: f32, + pub underline_thickness: f32, /// The height of a capital letter measured from the baseline of the font. - pub(crate) cap_height: f32, + pub cap_height: f32, /// The height of a lowercase x. - pub(crate) x_height: f32, + pub x_height: f32, /// The outer limits of the area that the font covers. /// Corresponds to the xMin / xMax / yMin / yMax values in the OpenType `head` table - pub(crate) bounding_box: Bounds, + pub bounding_box: Bounds, } impl FontMetrics { @@ -918,8 +1155,9 @@ impl FontMetrics { } } +/// Maps well-known virtual font names to their concrete equivalents. #[allow(unused)] -pub(crate) fn font_name_with_fallbacks<'a>(name: &'a str, system: &'a str) -> &'a str { +pub fn font_name_with_fallbacks<'a>(name: &'a str, system: &'a str) -> &'a str { // Note: the "Zed Plex" fonts were deprecated as we are not allowed to use "Plex" // in a derived font name. They are essentially indistinguishable from IBM Plex/Lilex, // and so retained here for backward compatibility. @@ -930,3 +1168,20 @@ pub(crate) fn font_name_with_fallbacks<'a>(name: &'a str, system: &'a str) -> &' _ => name, } } + +/// Like [`font_name_with_fallbacks`] but accepts and returns [`SharedString`] references. +#[allow(unused)] +pub fn font_name_with_fallbacks_shared<'a>( + name: &'a SharedString, + system: &'a SharedString, +) -> &'a SharedString { + // Note: the "Zed Plex" fonts were deprecated as we are not allowed to use "Plex" + // in a derived font name. They are essentially indistinguishable from IBM Plex/Lilex, + // and so retained here for backward compatibility. + match name.as_str() { + ".SystemUIFont" => system, + ".ZedSans" | "Zed Plex Sans" => const { &SharedString::new_static("IBM Plex Sans") }, + ".ZedMono" | "Zed Plex Mono" => const { &SharedString::new_static("Lilex") }, + _ => name, + } +} diff --git a/src/text_system/line.rs b/src/text_system/line.rs index 84618eccc4..7b5714188f 100644 --- a/src/text_system/line.rs +++ b/src/text_system/line.rs @@ -1,12 +1,24 @@ use crate::{ - App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result, SharedString, StrikethroughStyle, - TextAlign, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px, - size, + App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result, + ShapedGlyph, ShapedRun, SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window, + WrapBoundary, WrappedLineLayout, black, fill, point, px, size, }; use derive_more::{Deref, DerefMut}; use smallvec::SmallVec; use std::sync::Arc; +/// Pre-computed glyph data for efficient painting without per-glyph cache lookups. +/// +/// This is produced by `ShapedLine::compute_glyph_raster_data` during prepaint +/// and consumed by `ShapedLine::paint_with_raster_data` during paint. +#[derive(Clone, Debug)] +pub struct GlyphRasterData { + /// The raster bounds for each glyph, in paint order. + pub bounds: Vec>, + /// The render params for each glyph (needed for sprite atlas lookup). + pub params: Vec, +} + /// Set the text decoration for a run of text. #[derive(Debug, Clone)] pub struct DecorationRun { @@ -44,6 +56,14 @@ impl ShapedLine { self.layout.len } + /// The width of the shaped line in pixels. + /// + /// This is the glyph advance width computed by the text shaping system and is useful for + /// incrementally advancing a "pen" when painting multiple fragments on the same row. + pub fn width(&self) -> Pixels { + self.layout.width + } + /// Override the len, useful if you're rendering text a /// as text b (e.g. rendering invisibles). pub fn with_len(mut self, len: usize) -> Self { @@ -64,6 +84,8 @@ impl ShapedLine { &self, origin: Point, line_height: Pixels, + align: TextAlign, + align_width: Option, window: &mut Window, cx: &mut App, ) -> Result<()> { @@ -71,8 +93,8 @@ impl ShapedLine { origin, &self.layout, line_height, - TextAlign::default(), - None, + align, + align_width, &self.decoration_runs, &[], window, @@ -87,6 +109,8 @@ impl ShapedLine { &self, origin: Point, line_height: Pixels, + align: TextAlign, + align_width: Option, window: &mut Window, cx: &mut App, ) -> Result<()> { @@ -94,8 +118,8 @@ impl ShapedLine { origin, &self.layout, line_height, - TextAlign::default(), - None, + align, + align_width, &self.decoration_runs, &[], window, @@ -104,17 +128,131 @@ impl ShapedLine { Ok(()) } + + /// Split this shaped line at a byte index, returning `(prefix, suffix)`. + /// + /// - `prefix` contains glyphs for bytes `[0, byte_index)` with original positions. + /// Its width equals the x-advance up to the split point. + /// - `suffix` contains glyphs for bytes `[byte_index, len)` with positions + /// shifted left so the first glyph starts at x=0, and byte indices rebased to 0. + /// - Decoration runs are partitioned at the boundary; a run that straddles it is + /// split into two with adjusted lengths. + /// - `font_size`, `ascent`, and `descent` are copied to both halves. + pub fn split_at(&self, byte_index: usize) -> (ShapedLine, ShapedLine) { + let x_offset = self.layout.x_for_index(byte_index); + + // Partition glyph runs. A single run may contribute glyphs to both halves. + let mut left_runs = Vec::new(); + let mut right_runs = Vec::new(); + + for run in &self.layout.runs { + let split_pos = run.glyphs.partition_point(|g| g.index < byte_index); + + if split_pos > 0 { + left_runs.push(ShapedRun { + font_id: run.font_id, + glyphs: run.glyphs[..split_pos].to_vec(), + }); + } + + if split_pos < run.glyphs.len() { + let right_glyphs = run.glyphs[split_pos..] + .iter() + .map(|g| ShapedGlyph { + id: g.id, + position: point(g.position.x - x_offset, g.position.y), + index: g.index - byte_index, + is_emoji: g.is_emoji, + }) + .collect(); + right_runs.push(ShapedRun { + font_id: run.font_id, + glyphs: right_glyphs, + }); + } + } + + // Partition decoration runs. A run straddling the boundary is split into two. + let mut left_decorations = SmallVec::new(); + let mut right_decorations = SmallVec::new(); + let mut decoration_offset = 0u32; + let split_point = byte_index as u32; + + for decoration in &self.decoration_runs { + let run_end = decoration_offset + decoration.len; + + if run_end <= split_point { + left_decorations.push(decoration.clone()); + } else if decoration_offset >= split_point { + right_decorations.push(decoration.clone()); + } else { + let left_len = split_point - decoration_offset; + let right_len = run_end - split_point; + left_decorations.push(DecorationRun { + len: left_len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + right_decorations.push(DecorationRun { + len: right_len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + } + + decoration_offset = run_end; + } + + // Split text + let left_text = SharedString::new(self.text[..byte_index].to_string()); + let right_text = SharedString::new(self.text[byte_index..].to_string()); + + let left_width = x_offset; + let right_width = self.layout.width - left_width; + + let left = ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.layout.font_size, + width: left_width, + ascent: self.layout.ascent, + descent: self.layout.descent, + runs: left_runs, + len: byte_index, + }), + text: left_text, + decoration_runs: left_decorations, + }; + + let right = ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.layout.font_size, + width: right_width, + ascent: self.layout.ascent, + descent: self.layout.descent, + runs: right_runs, + len: self.layout.len - byte_index, + }), + text: right_text, + decoration_runs: right_decorations, + }; + + (left, right) + } } /// A line of text that has been shaped, decorated, and wrapped by the text layout system. -#[derive(Clone, Default, Debug, Deref, DerefMut)] +#[derive(Default, Debug, Deref, DerefMut)] pub struct WrappedLine { #[deref] #[deref_mut] pub(crate) layout: Arc, /// The text that was shaped for this line. pub text: SharedString, - pub(crate) decoration_runs: SmallVec<[DecorationRun; 32]>, + pub(crate) decoration_runs: Vec, } impl WrappedLine { @@ -590,3 +728,268 @@ fn aligned_origin_x( TextAlign::Right => origin.x + align_width - line_width, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FontId, GlyphId}; + + /// Helper: build a ShapedLine from glyph descriptors without the platform text system. + /// Each glyph is described as (byte_index, x_position). + fn make_shaped_line( + text: &str, + glyphs: &[(usize, f32)], + width: f32, + decorations: &[DecorationRun], + ) -> ShapedLine { + let shaped_glyphs: Vec = glyphs + .iter() + .map(|&(index, x)| ShapedGlyph { + id: GlyphId(0), + position: point(px(x), px(0.0)), + index, + is_emoji: false, + }) + .collect(); + + ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(width), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ShapedRun { + font_id: FontId(0), + glyphs: shaped_glyphs, + }], + len: text.len(), + }), + text: SharedString::new(text.to_string()), + decoration_runs: SmallVec::from(decorations.to_vec()), + } + } + + #[test] + fn test_split_at_invariants() { + // Split "abcdef" at every possible byte index and verify structural invariants. + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[], + ); + + for i in 0..=6 { + let (left, right) = line.split_at(i); + + assert_eq!( + left.width() + right.width(), + line.width(), + "widths must sum at split={i}" + ); + assert_eq!( + left.len() + right.len(), + line.len(), + "lengths must sum at split={i}" + ); + assert_eq!( + format!("{}{}", left.text.as_ref(), right.text.as_ref()), + "abcdef", + "text must concatenate at split={i}" + ); + assert_eq!(left.font_size, line.font_size, "font_size at split={i}"); + assert_eq!(right.ascent, line.ascent, "ascent at split={i}"); + assert_eq!(right.descent, line.descent, "descent at split={i}"); + } + + // Edge: split at 0 produces no left runs, full content on right + let (left, right) = line.split_at(0); + assert_eq!(left.runs.len(), 0); + assert_eq!(right.runs[0].glyphs.len(), 6); + + // Edge: split at end produces full content on left, no right runs + let (left, right) = line.split_at(6); + assert_eq!(left.runs[0].glyphs.len(), 6); + assert_eq!(right.runs.len(), 0); + } + + #[test] + fn test_split_at_glyph_rebasing() { + // Two font runs (simulating a font fallback boundary at byte 3): + // run A (FontId 0): glyphs at bytes 0,1,2 positions 0,10,20 + // run B (FontId 1): glyphs at bytes 3,4,5 positions 30,40,50 + // Successive splits simulate the incremental splitting done during wrap. + let line = ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(60.0), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ + ShapedRun { + font_id: FontId(0), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(0), + position: point(px(0.0), px(0.0)), + index: 0, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(10.0), px(0.0)), + index: 1, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(20.0), px(0.0)), + index: 2, + is_emoji: false, + }, + ], + }, + ShapedRun { + font_id: FontId(1), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(0), + position: point(px(30.0), px(0.0)), + index: 3, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(40.0), px(0.0)), + index: 4, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(50.0), px(0.0)), + index: 5, + is_emoji: false, + }, + ], + }, + ], + len: 6, + }), + text: SharedString::new("abcdef".to_string()), + decoration_runs: SmallVec::new(), + }; + + // First split at byte 2 — mid-run in run A + let (first, remainder) = line.split_at(2); + assert_eq!(first.text.as_ref(), "ab"); + assert_eq!(first.runs.len(), 1); + assert_eq!(first.runs[0].font_id, FontId(0)); + + // Remainder "cdef" should have two runs: tail of A (1 glyph) + all of B (3 glyphs) + assert_eq!(remainder.text.as_ref(), "cdef"); + assert_eq!(remainder.runs.len(), 2); + assert_eq!(remainder.runs[0].font_id, FontId(0)); + assert_eq!(remainder.runs[0].glyphs.len(), 1); + assert_eq!(remainder.runs[0].glyphs[0].index, 0); + assert_eq!(remainder.runs[0].glyphs[0].position.x, px(0.0)); + assert_eq!(remainder.runs[1].font_id, FontId(1)); + assert_eq!(remainder.runs[1].glyphs[0].index, 1); + assert_eq!(remainder.runs[1].glyphs[0].position.x, px(10.0)); + + // Second split at byte 2 within remainder — crosses the run boundary + let (second, final_part) = remainder.split_at(2); + assert_eq!(second.text.as_ref(), "cd"); + assert_eq!(final_part.text.as_ref(), "ef"); + assert_eq!(final_part.runs[0].glyphs[0].index, 0); + assert_eq!(final_part.runs[0].glyphs[0].position.x, px(0.0)); + + // Widths must sum across all three pieces + assert_eq!( + first.width() + second.width() + final_part.width(), + line.width() + ); + } + + #[test] + fn test_split_at_decorations() { + // Three decoration runs: red [0..2), green [2..5), blue [5..6). + // Split at byte 3 — red goes entirely left, green straddles, blue goes entirely right. + let red = Hsla { + h: 0.0, + s: 1.0, + l: 0.5, + a: 1.0, + }; + let green = Hsla { + h: 0.3, + s: 1.0, + l: 0.5, + a: 1.0, + }; + let blue = Hsla { + h: 0.6, + s: 1.0, + l: 0.5, + a: 1.0, + }; + + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[ + DecorationRun { + len: 2, + color: red, + background_color: None, + underline: None, + strikethrough: None, + }, + DecorationRun { + len: 3, + color: green, + background_color: None, + underline: None, + strikethrough: None, + }, + DecorationRun { + len: 1, + color: blue, + background_color: None, + underline: None, + strikethrough: None, + }, + ], + ); + + let (left, right) = line.split_at(3); + + // Left: red(2) + green(1) — green straddled, left portion has len 1 + assert_eq!(left.decoration_runs.len(), 2); + assert_eq!(left.decoration_runs[0].len, 2); + assert_eq!(left.decoration_runs[0].color, red); + assert_eq!(left.decoration_runs[1].len, 1); + assert_eq!(left.decoration_runs[1].color, green); + + // Right: green(2) + blue(1) — green straddled, right portion has len 2 + assert_eq!(right.decoration_runs.len(), 2); + assert_eq!(right.decoration_runs[0].len, 2); + assert_eq!(right.decoration_runs[0].color, green); + assert_eq!(right.decoration_runs[1].len, 1); + assert_eq!(right.decoration_runs[1].color, blue); + } +} diff --git a/src/text_system/line_layout.rs b/src/text_system/line_layout.rs index 375a9bdc7b..8f3d7563d0 100644 --- a/src/text_system/line_layout.rs +++ b/src/text_system/line_layout.rs @@ -401,12 +401,25 @@ struct FrameCache { wrapped_lines: FxHashMap, Arc>, used_lines: Vec>, used_wrapped_lines: Vec>, + + // Content-addressable caches keyed by caller-provided text hash + layout params. + // These allow cache hits without materializing a contiguous `SharedString`. + // + // IMPORTANT: To support allocation-free lookups, we store these maps using a key type + // (`HashedCacheKeyRef`) that can be computed without building a contiguous `&str`/`SharedString`. + // On miss, we allocate once and store under an owned `HashedCacheKey`. + lines_by_hash: FxHashMap, Arc>, + wrapped_lines_by_hash: FxHashMap, Arc>, + used_lines_by_hash: Vec>, + used_wrapped_lines_by_hash: Vec>, } #[derive(Clone, Default)] pub(crate) struct LineLayoutIndex { lines_index: usize, wrapped_lines_index: usize, + lines_by_hash_index: usize, + wrapped_lines_by_hash_index: usize, } impl LineLayoutCache { @@ -423,6 +436,8 @@ impl LineLayoutCache { LineLayoutIndex { lines_index: frame.used_lines.len(), wrapped_lines_index: frame.used_wrapped_lines.len(), + lines_by_hash_index: frame.used_lines_by_hash.len(), + wrapped_lines_by_hash_index: frame.used_wrapped_lines_by_hash.len(), } } @@ -445,6 +460,24 @@ impl LineLayoutCache { } current_frame.used_wrapped_lines.push(key.clone()); } + + for key in &previous_frame.used_lines_by_hash + [range.start.lines_by_hash_index..range.end.lines_by_hash_index] + { + if let Some((key, line)) = previous_frame.lines_by_hash.remove_entry(key) { + current_frame.lines_by_hash.insert(key, line); + } + current_frame.used_lines_by_hash.push(key.clone()); + } + + for key in &previous_frame.used_wrapped_lines_by_hash + [range.start.wrapped_lines_by_hash_index..range.end.wrapped_lines_by_hash_index] + { + if let Some((key, line)) = previous_frame.wrapped_lines_by_hash.remove_entry(key) { + current_frame.wrapped_lines_by_hash.insert(key, line); + } + current_frame.used_wrapped_lines_by_hash.push(key.clone()); + } } pub fn truncate_layouts(&self, index: LineLayoutIndex) { @@ -453,6 +486,12 @@ impl LineLayoutCache { current_frame .used_wrapped_lines .truncate(index.wrapped_lines_index); + current_frame + .used_lines_by_hash + .truncate(index.lines_by_hash_index); + current_frame + .used_wrapped_lines_by_hash + .truncate(index.wrapped_lines_by_hash_index); } pub fn finish_frame(&self) { @@ -463,6 +502,11 @@ impl LineLayoutCache { curr_frame.wrapped_lines.clear(); curr_frame.used_lines.clear(); curr_frame.used_wrapped_lines.clear(); + + curr_frame.lines_by_hash.clear(); + curr_frame.wrapped_lines_by_hash.clear(); + curr_frame.used_lines_by_hash.clear(); + curr_frame.used_wrapped_lines_by_hash.clear(); } pub fn layout_wrapped_line( @@ -590,13 +634,173 @@ impl LineLayoutCache { layout } } + + /// Try to retrieve a previously-shaped line layout using a caller-provided content hash. + /// + /// This is a *non-allocating* cache probe: it does not materialize any text. If the layout + /// is not already cached in either the current frame or previous frame, returns `None`. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn try_layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[FontRun], + force_width: Option, + ) -> Option> { + let key_ref = HashedCacheKeyRef { + text_hash, + text_len, + font_size, + runs, + wrap_width: None, + force_width, + }; + + let current_frame = self.current_frame.read(); + if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return Some(layout.clone()); + } + + let previous_frame = self.previous_frame.lock(); + if let Some((_, layout)) = previous_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return Some(layout.clone()); + } + + None + } + + /// Layout a line of text using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for `text`. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[FontRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> Arc { + let key_ref = HashedCacheKeyRef { + text_hash, + text_len, + font_size, + runs, + wrap_width: None, + force_width, + }; + + // Fast path: already cached (no allocation). + let current_frame = self.current_frame.upgradable_read(); + if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return layout.clone(); + } + + let mut current_frame = RwLockUpgradableReadGuard::upgrade(current_frame); + + // Try to reuse from previous frame without allocating; do a linear scan to find a matching key. + // (We avoid `drain()` here because it would eagerly move all entries.) + let mut previous_frame = self.previous_frame.lock(); + if let Some(existing_key) = previous_frame + .used_lines_by_hash + .iter() + .find(|key| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) + .cloned() + { + if let Some((key, layout)) = previous_frame.lines_by_hash.remove_entry(&existing_key) { + current_frame + .lines_by_hash + .insert(key.clone(), layout.clone()); + current_frame.used_lines_by_hash.push(key); + return layout; + } + } + + let text = materialize_text(); + let mut layout = self + .platform_text_system + .layout_line(&text, font_size, runs); + + if let Some(force_width) = force_width { + let mut glyph_pos = 0; + for run in layout.runs.iter_mut() { + for glyph in run.glyphs.iter_mut() { + if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { + glyph.position.x = glyph_pos * force_width; + } + glyph_pos += 1; + } + } + } + + let key = Arc::new(HashedCacheKey { + text_hash, + text_len, + font_size, + runs: SmallVec::from(runs), + wrap_width: None, + force_width, + }); + let layout = Arc::new(layout); + current_frame + .lines_by_hash + .insert(key.clone(), layout.clone()); + current_frame.used_lines_by_hash.push(key); + layout + } } /// A run of text with a single font. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +#[expect(missing_docs)] pub struct FontRun { - pub(crate) len: usize, - pub(crate) font_id: FontId, + pub len: usize, + pub font_id: FontId, } trait AsCacheKeyRef { @@ -621,12 +825,80 @@ struct CacheKeyRef<'a> { force_width: Option, } +#[derive(Clone, Debug)] +struct HashedCacheKey { + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: SmallVec<[FontRun; 1]>, + wrap_width: Option, + force_width: Option, +} + +#[derive(Copy, Clone)] +struct HashedCacheKeyRef<'a> { + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &'a [FontRun], + wrap_width: Option, + force_width: Option, +} + impl PartialEq for dyn AsCacheKeyRef + '_ { fn eq(&self, other: &dyn AsCacheKeyRef) -> bool { self.as_cache_key_ref() == other.as_cache_key_ref() } } +impl PartialEq for HashedCacheKey { + fn eq(&self, other: &Self) -> bool { + self.text_hash == other.text_hash + && self.text_len == other.text_len + && self.font_size == other.font_size + && self.runs.as_slice() == other.runs.as_slice() + && self.wrap_width == other.wrap_width + && self.force_width == other.force_width + } +} + +impl Eq for HashedCacheKey {} + +impl Hash for HashedCacheKey { + fn hash(&self, state: &mut H) { + self.text_hash.hash(state); + self.text_len.hash(state); + self.font_size.hash(state); + self.runs.as_slice().hash(state); + self.wrap_width.hash(state); + self.force_width.hash(state); + } +} + +impl PartialEq for HashedCacheKeyRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.text_hash == other.text_hash + && self.text_len == other.text_len + && self.font_size == other.font_size + && self.runs == other.runs + && self.wrap_width == other.wrap_width + && self.force_width == other.force_width + } +} + +impl Eq for HashedCacheKeyRef<'_> {} + +impl Hash for HashedCacheKeyRef<'_> { + fn hash(&self, state: &mut H) { + self.text_hash.hash(state); + self.text_len.hash(state); + self.font_size.hash(state); + self.runs.hash(state); + self.wrap_width.hash(state); + self.force_width.hash(state); + } +} + impl Eq for dyn AsCacheKeyRef + '_ {} impl Hash for dyn AsCacheKeyRef + '_ { diff --git a/src/text_system/line_wrapper.rs b/src/text_system/line_wrapper.rs index 45159313b4..ffc433c671 100644 --- a/src/text_system/line_wrapper.rs +++ b/src/text_system/line_wrapper.rs @@ -1,10 +1,19 @@ -use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px}; +use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px}; use collections::HashMap; use std::{borrow::Cow, iter, sync::Arc}; +/// Determines whether to truncate text from the start or end. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TruncateFrom { + /// Truncate text from the start. + Start, + /// Truncate text from the end. + End, +} + /// The GPUI line wrapper, used to wrap lines of text to a given width. pub struct LineWrapper { - platform_text_system: Arc, + text_system: Arc, pub(crate) font_id: FontId, pub(crate) font_size: Pixels, cached_ascii_char_widths: [Option; 128], @@ -15,13 +24,9 @@ impl LineWrapper { /// The maximum indent that can be applied to a line. pub const MAX_INDENT: u32 = 256; - pub(crate) fn new( - font_id: FontId, - font_size: Pixels, - text_system: Arc, - ) -> Self { + pub(crate) fn new(font_id: FontId, font_size: Pixels, text_system: Arc) -> Self { Self { - platform_text_system: text_system, + text_system, font_id, font_size, cached_ascii_char_widths: [None; 128], @@ -128,40 +133,84 @@ impl LineWrapper { }) } + /// Determines if a line should be truncated based on its width. + /// + /// Returns the truncation index in `line`. + pub fn should_truncate_line( + &mut self, + line: &str, + truncate_width: Pixels, + truncation_affix: &str, + truncate_from: TruncateFrom, + ) -> Option { + let mut width = px(0.); + let suffix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |a, x| a + x); + let mut truncate_ix = 0; + + match truncate_from { + TruncateFrom::Start => { + for (ix, c) in line.char_indices().rev() { + if width + suffix_width < truncate_width { + truncate_ix = ix; + } + + let char_width = self.width_for_char(c); + width += char_width; + + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } + } + TruncateFrom::End => { + for (ix, c) in line.char_indices() { + if width + suffix_width < truncate_width { + truncate_ix = ix; + } + + let char_width = self.width_for_char(c); + width += char_width; + + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } + } + } + + None + } + /// Truncate a line of text to the given width with this wrapper's font and font size. pub fn truncate_line<'a>( &mut self, line: SharedString, truncate_width: Pixels, - truncation_suffix: &str, + truncation_affix: &str, runs: &'a [TextRun], + truncate_from: TruncateFrom, ) -> (SharedString, Cow<'a, [TextRun]>) { - let mut width = px(0.); - let mut suffix_width = truncation_suffix - .chars() - .map(|c| self.width_for_char(c)) - .fold(px(0.0), |a, x| a + x); - let mut char_indices = line.char_indices(); - let mut truncate_ix = 0; - for (ix, c) in char_indices { - if width + suffix_width < truncate_width { - truncate_ix = ix; - } - - let char_width = self.width_for_char(c); - width += char_width; - - if width.floor() > truncate_width { - let result = - SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); - let mut runs = runs.to_vec(); - update_runs_after_truncation(&result, truncation_suffix, &mut runs); - - return (result, Cow::Owned(runs)); - } + 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 mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); + (result, Cow::Owned(runs)) + } else { + (line, Cow::Borrowed(runs)) } - - (line, Cow::Borrowed(runs)) } /// Any character in this list should be treated as a word character, @@ -182,10 +231,18 @@ impl LineWrapper { // Cyrillic for Russian, Ukrainian, etc. // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode matches!(c, '\u{0400}'..='\u{04FF}') || + + // Vietnamese (https://vietunicode.sourceforge.net/charset/) + matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional + matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks + + // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block)) + matches!(c, '\u{0980}'..='\u{09FF}') || + // Some other known special characters that should be treated as word characters, - // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, + // e.g. `a-b`, `var_name`, `I'm`/`won’t`, '@mention`, `#hashtag`, `100%`, `3.1415`, // `2^3`, `a~b`, `a=1`, `Self::new`, etc. - matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':') || + matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':') || // `⋯` character is special used in Zed, to keep this at the end of the line. matches!(c, '⋯') } @@ -196,44 +253,53 @@ impl LineWrapper { if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] { cached_width } else { - let width = self.compute_width_for_char(c); + let width = self + .text_system + .layout_width(self.font_id, self.font_size, c); self.cached_ascii_char_widths[c as usize] = Some(width); width } } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) { *cached_width } else { - let width = self.compute_width_for_char(c); + let width = self + .text_system + .layout_width(self.font_id, self.font_size, c); self.cached_other_char_widths.insert(c, width); width } } - - fn compute_width_for_char(&self, c: char) -> Pixels { - let mut buffer = [0; 4]; - let buffer = c.encode_utf8(&mut buffer); - self.platform_text_system - .layout_line( - buffer, - self.font_size, - &[FontRun { - len: buffer.len(), - font_id: self.font_id, - }], - ) - .width - } } -fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec) { +fn update_runs_after_truncation( + result: &str, + ellipsis: &str, + runs: &mut Vec, + truncate_from: TruncateFrom, +) { let mut truncate_at = result.len() - ellipsis.len(); - for (run_index, run) in runs.iter_mut().enumerate() { - if run.len <= truncate_at { - truncate_at -= run.len; - } else { - run.len = truncate_at + ellipsis.len(); - runs.truncate(run_index + 1); - break; + match truncate_from { + TruncateFrom::Start => { + for (run_index, run) in runs.iter_mut().enumerate().rev() { + if run.len <= truncate_at { + truncate_at -= run.len; + } else { + run.len = truncate_at + ellipsis.len(); + runs.splice(..run_index, std::iter::empty()); + break; + } + } + } + TruncateFrom::End => { + for (run_index, run) in runs.iter_mut().enumerate() { + if run.len <= truncate_at { + truncate_at -= run.len; + } else { + run.len = truncate_at + ellipsis.len(); + runs.truncate(run_index + 1); + break; + } + } } } } @@ -318,13 +384,12 @@ mod tests { use crate::{Font, FontFeatures, FontStyle, FontWeight, TestAppContext, TestDispatcher, font}; #[cfg(target_os = "macos")] use crate::{TextRun, WindowTextSystem, WrapBoundary}; - use rand::prelude::*; fn build_wrapper() -> LineWrapper { - let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); + let dispatcher = TestDispatcher::new(0); let cx = TestAppContext::build(dispatcher, None); let id = cx.text_system().resolve_font(&font(".ZedMono")); - LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone()) + LineWrapper::new(id, px(16.), cx.text_system().clone()) } fn generate_test_runs(input_run_len: &[usize]) -> Vec { @@ -483,7 +548,7 @@ mod tests { } #[test] - fn test_truncate_line() { + fn test_truncate_line_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -494,8 +559,13 @@ mod tests { ) { 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.), ellipsis, &dummy_runs); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + px(220.), + ellipsis, + &dummy_runs, + TruncateFrom::End, + ); assert_eq!(result, expected); assert_eq!(dummy_runs.first().unwrap().len, result.len()); } @@ -518,10 +588,66 @@ mod tests { "aa bbb cccc dddd......", "......", ); + perform_test( + &mut wrapper, + "aa bbb cccc 🦀🦀🦀🦀🦀 eeee ffff gggg", + "aa bbb cccc 🦀🦀🦀🦀…", + "…", + ); } #[test] - fn test_truncate_multiple_runs() { + fn test_truncate_line_start() { + let mut wrapper = build_wrapper(); + + #[track_caller] + fn perform_test( + wrapper: &mut LineWrapper, + text: &'static str, + expected: &'static str, + ellipsis: &str, + ) { + 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.), + ellipsis, + &dummy_runs, + TruncateFrom::Start, + ); + assert_eq!(result, expected); + assert_eq!(dummy_runs.first().unwrap().len, result.len()); + } + + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "cccc ddddd eeee fff gg", + "", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "…ccc ddddd eeee fff gg", + "…", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "......dddd eeee fff gg", + "......", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc 🦀🦀🦀🦀🦀 eeee fff gg", + "…🦀🦀🦀🦀 eeee fff gg", + "…", + ); + } + + #[test] + fn test_truncate_multiple_runs_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -534,7 +660,7 @@ mod tests { ) { let dummy_runs = generate_test_runs(run_lens); let (result, dummy_runs) = - wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs); + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End); assert_eq!(result, expected); for (run, result_len) in dummy_runs.iter().zip(result_run_len) { assert_eq!(run.len, *result_len); @@ -580,10 +706,75 @@ mod tests { } #[test] - fn test_update_run_after_truncation() { + fn test_truncate_multiple_runs_start() { + let mut wrapper = build_wrapper(); + + #[track_caller] + fn perform_test( + wrapper: &mut LineWrapper, + text: &'static str, + expected: &str, + run_lens: &[usize], + result_run_len: &[usize], + line_width: Pixels, + ) { + let dummy_runs = generate_test_runs(run_lens); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + line_width, + "…", + &dummy_runs, + TruncateFrom::Start, + ); + assert_eq!(result, expected); + for (run, result_len) in dummy_runs.iter().zip(result_run_len) { + assert_eq!(run.len, *result_len); + } + } + // Case 0: Normal + // Text: abcdefghijkl + // Runs: Run0 { len: 12, ... } + // + // Truncate res: …ijkl (truncate_at = 9) + // Run res: Run0 { string: …ijkl, len: 7, ... } + perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.)); + // Case 1: Drop some runs + // Text: abcdefghijkl + // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... } + // + // Truncate res: …ghijkl (truncate_at = 7) + // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len: + // 4, ... } + perform_test( + &mut wrapper, + "abcdefghijkl", + "…ghijkl", + &[4, 4, 4], + &[5, 4], + px(70.), + ); + // Case 2: Truncate at start of some run + // Text: abcdefghijkl + // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... } + // + // Truncate res: abcdefgh… (truncate_at = 3) + // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len: + // 4, ... }, Run2 { string: ijkl, len: 4, ... } + perform_test( + &mut wrapper, + "abcdefghijkl", + "…efghijkl", + &[4, 4, 4], + &[3, 4, 4], + px(90.), + ); + } + + #[test] + fn test_update_run_after_truncation_end() { fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) { let mut dummy_runs = generate_test_runs(run_lens); - update_runs_after_truncation(result, "…", &mut dummy_runs); + update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End); for (run, result_len) in dummy_runs.iter().zip(result_run_lens) { assert_eq!(run.len, *result_len); } @@ -618,7 +809,12 @@ mod tests { #[track_caller] fn assert_word(word: &str) { for c in word.chars() { - assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c); + assert!( + LineWrapper::is_word_char(c), + "assertion failed for '{}' (unicode 0x{:x})", + c, + c as u32 + ); } } @@ -642,6 +838,8 @@ mod tests { assert_word("a=1"); assert_word("Self::is_word_char"); assert_word("more⋯"); + assert_word("won’t"); + assert_word("‘twas"); // Space assert_not_word("foo bar"); @@ -661,6 +859,12 @@ mod tests { assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ"); // Cyrillic assert_word("АБВГДЕЖЗИЙКЛМНОП"); + // Vietnamese (https://github.com/zed-industries/zed/issues/23245) + assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng"); + // Bengali + assert_word("গিয়েছিলেন"); + assert_word("ছেলে"); + assert_word("হচ্ছিল"); // non-word characters assert_not_word("你好"); diff --git a/src/view.rs b/src/view.rs index 217971792e..39b87dbb80 100644 --- a/src/view.rs +++ b/src/view.rs @@ -25,59 +25,6 @@ struct ViewCacheKey { text_style: TextStyle, } -impl Element for Entity { - type RequestLayoutState = AnyElement; - type PrepaintState = (); - - fn id(&self) -> Option { - Some(ElementId::View(self.entity_id())) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let mut element = self.update(cx, |view, cx| view.render(window, cx).into_any_element()); - let layout_id = window.with_rendered_view(self.entity_id(), |window| { - element.request_layout(window, cx) - }); - (layout_id, element) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - window.set_view_id(self.entity_id()); - window.with_rendered_view(self.entity_id(), |window| element.prepaint(window, cx)); - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - window.with_rendered_view(self.entity_id(), |window| element.paint(window, cx)); - } -} - /// A dynamically-typed handle to a view, which can be downcast to a [Entity] for a specific type. #[derive(Clone, Debug)] pub struct AnyView { @@ -294,10 +241,10 @@ impl Element for AnyView { } impl IntoElement for Entity { - type Element = Entity; + type Element = AnyView; fn into_element(self) -> Self::Element { - self + self.into() } } diff --git a/src/window.rs b/src/window.rs index 54fe99c263..c720b1b856 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,6 @@ #[cfg(any(feature = "inspector", debug_assertions))] use crate::Inspector; +use crate::scheduler::Instant; use crate::{ Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset, AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Capslock, @@ -12,12 +13,12 @@ use crate::{ 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, SubscriberSet, - Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, - TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, - transparent_black, + 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,7 +49,7 @@ use std::{ Arc, Weak, atomic::{AtomicUsize, Ordering::SeqCst}, }, - time::{Duration, Instant}, + time::Duration, }; use util::post_inc; use util::{ResultExt, measure}; @@ -56,10 +57,11 @@ use uuid::Uuid; mod prompts; -use crate::util::atomic_incr_if_not_zero; +use crate::local_util::atomic_incr_if_not_zero; pub use prompts::*; -pub(crate) const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(864.)); +/// Default window size used when no explicit size is provided. +pub const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(864.)); /// A 6:5 aspect ratio minimum window size to be used for functional, /// additional-to-main-Zed windows, like the settings and rules library windows. @@ -217,19 +219,77 @@ slotmap::new_key_type! { } thread_local! { + /// Fallback arena used when no app-specific arena is active. + /// In production, each window draw sets CURRENT_ELEMENT_ARENA to the app's arena. pub(crate) static ELEMENT_ARENA: RefCell = RefCell::new(Arena::new(1024 * 1024)); + + /// Points to the current App's element arena during draw operations. + /// This allows multiple test Apps to have isolated arenas, preventing + /// cross-session corruption when the scheduler interleaves their tasks. + static CURRENT_ELEMENT_ARENA: Cell>> = const { Cell::new(None) }; +} + +/// Allocates an element in the current arena. Uses the app-specific arena if one +/// is active (during draw), otherwise falls back to the thread-local ELEMENT_ARENA. +pub(crate) fn with_element_arena(f: impl FnOnce(&mut Arena) -> R) -> R { + CURRENT_ELEMENT_ARENA.with(|current| { + if let Some(arena_ptr) = current.get() { + // SAFETY: The pointer is valid for the duration of the draw operation + // that set it, and we're being called during that same draw. + let arena_cell = unsafe { &*arena_ptr }; + f(&mut arena_cell.borrow_mut()) + } else { + ELEMENT_ARENA.with_borrow_mut(f) + } + }) +} + +/// RAII guard that sets CURRENT_ELEMENT_ARENA for the duration of a draw operation. +/// When dropped, restores the previous arena (supporting nested draws). +pub(crate) struct ElementArenaScope { + previous: Option<*const RefCell>, +} + +impl ElementArenaScope { + /// Enter a scope where element allocations use the given arena. + pub(crate) fn enter(arena: &RefCell) -> Self { + let previous = CURRENT_ELEMENT_ARENA.with(|current| { + let prev = current.get(); + current.set(Some(arena as *const RefCell)); + prev + }); + Self { previous } + } +} + +impl Drop for ElementArenaScope { + fn drop(&mut self) { + CURRENT_ELEMENT_ARENA.with(|current| { + current.set(self.previous); + }); + } } /// Returned when the element arena has been used and so must be cleared before the next draw. #[must_use] -pub struct ArenaClearNeeded; +pub struct ArenaClearNeeded { + arena: *const RefCell, +} impl ArenaClearNeeded { + /// Create a new ArenaClearNeeded that will clear the given arena. + pub(crate) fn new(arena: &RefCell) -> Self { + Self { + arena: arena as *const RefCell, + } + } + /// Clear the element arena. pub fn clear(self) { - ELEMENT_ARENA.with_borrow_mut(|element_arena| { - element_arena.clear(); - }); + // SAFETY: The arena pointer is valid because ArenaClearNeeded is created + // at the end of draw() and must be cleared before the next draw. + let arena_cell = unsafe { &*self.arena }; + arena_cell.borrow_mut().clear(); } } @@ -345,8 +405,8 @@ impl FocusHandle { } /// Moves the focus to the element associated with this handle. - pub fn focus(&self, window: &mut Window) { - window.focus(self) + pub fn focus(&self, window: &mut Window, cx: &mut App) { + window.focus(self, cx) } /// Obtains whether the element associated with this handle is currently focused. @@ -500,12 +560,20 @@ pub enum WindowControlArea { pub struct HitboxId(u64); impl HitboxId { - /// Checks if the hitbox with this ID is currently hovered. Except when handling + /// Checks if the hitbox with this ID is currently hovered. Returns `false` during keyboard + /// input modality so that keyboard navigation suppresses hover highlights. Except when handling /// `ScrollWheelEvent`, this is typically what you want when determining whether to handle mouse /// events or paint hover styles. /// /// See [`Hitbox::is_hovered`] for details. pub fn is_hovered(self, window: &Window) -> bool { + // If this hitbox has captured the pointer, it's always considered hovered + if window.captured_hitbox == Some(self) { + return true; + } + if window.last_input_was_keyboard() { + return false; + } let hit_test = &window.mouse_hit_test; for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) { if self == *id { @@ -544,13 +612,15 @@ pub struct Hitbox { } impl Hitbox { - /// Checks if the hitbox is currently hovered. Except when handling `ScrollWheelEvent`, this is - /// typically what you want when determining whether to handle mouse events or paint hover - /// styles. + /// Checks if the hitbox is currently hovered. Returns `false` during keyboard input modality + /// so that keyboard navigation suppresses hover highlights. Except when handling + /// `ScrollWheelEvent`, this is typically what you want when determining whether to handle mouse + /// events or paint hover styles. /// /// This can return `false` even when the hitbox contains the mouse, if a hitbox in front of /// this sets `HitboxBehavior::BlockMouse` (`InteractiveElement::occlude`) or - /// `HitboxBehavior::BlockMouseExceptScroll` (`InteractiveElement::block_mouse_except_scroll`). + /// `HitboxBehavior::BlockMouseExceptScroll` (`InteractiveElement::block_mouse_except_scroll`), + /// or if the current input modality is keyboard (see [`Window::last_input_was_keyboard`]). /// /// Handling of `ScrollWheelEvent` should typically use `should_handle_scroll` instead. /// Concretely, this is due to use-cases like overlays that cause the elements under to be @@ -666,6 +736,8 @@ pub(crate) struct DeferredDraw { parent_node: DispatchNodeId, element_id_stack: SmallVec<[ElementId; 32]>, text_style_stack: Vec, + content_mask: Option>, + rem_size: Pixels, element: Option, absolute_offset: Point, prepaint_range: Range, @@ -760,6 +832,11 @@ impl Frame { self.tab_stops.clear(); self.focus = None; + #[cfg(any(test, feature = "test-support"))] + { + self.debug_bounds.clear(); + } + #[cfg(any(feature = "inspector", debug_assertions))] { self.next_inspector_instance_ids.clear(); @@ -838,6 +915,7 @@ pub struct Window { display_id: Option, sprite_atlas: Arc, text_system: Arc, + text_rendering_mode: Rc>, rem_size: Pixels, /// The stack of override values for the window's rem size. /// @@ -876,7 +954,9 @@ pub struct Window { active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, - pub(crate) last_input_timestamp: Rc>, + /// Tracks recent input event timestamps to determine if input is arriving at a high rate. + /// Used to selectively enable VRR optimization only when input rate exceeds 60fps. + pub(crate) input_rate_tracker: Rc>, last_input_modality: InputModality, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, @@ -887,6 +967,9 @@ pub struct Window { pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>, prompt: Option, pub(crate) client_inset: Option, + /// The hitbox that has captured the pointer, if any. + /// While captured, mouse events route to this hitbox regardless of hit testing. + captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, } @@ -897,6 +980,51 @@ struct ModifierState { saw_keystroke: bool, } +/// Tracks input event timestamps to determine if input is arriving at a high rate. +/// Used for selective VRR (Variable Refresh Rate) optimization. +#[derive(Clone, Debug)] +pub(crate) struct InputRateTracker { + timestamps: Vec, + window: Duration, + inputs_per_second: u32, + sustain_until: Instant, + sustain_duration: Duration, +} + +impl Default for InputRateTracker { + fn default() -> Self { + Self { + timestamps: Vec::new(), + window: Duration::from_millis(100), + inputs_per_second: 60, + sustain_until: Instant::now(), + sustain_duration: Duration::from_secs(1), + } + } +} + +impl InputRateTracker { + pub fn record_input(&mut self) { + let now = Instant::now(); + self.timestamps.push(now); + self.prune_old_timestamps(now); + + let min_events = self.inputs_per_second as u128 * self.window.as_millis() / 1000; + if self.timestamps.len() as u128 >= min_events { + self.sustain_until = now + self.sustain_duration; + } + } + + pub fn is_high_rate(&self) -> bool { + Instant::now() < self.sustain_until + } + + fn prune_old_timestamps(&mut self, now: Instant) { + self.timestamps + .retain(|&t| now.duration_since(t) <= self.window); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DrawPhase { None, @@ -1047,7 +1175,8 @@ impl Window { let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); - let last_input_timestamp = Rc::new(Cell::new(Instant::now())); + let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default())); + let last_frame_time = Rc::new(Cell::new(None)); platform_window .request_decorations(window_decorations.unwrap_or(WindowDecorations::Server)); @@ -1075,8 +1204,25 @@ impl Window { let active = active.clone(); let needs_present = needs_present.clone(); let next_frame_callbacks = next_frame_callbacks.clone(); - let last_input_timestamp = last_input_timestamp.clone(); + let input_rate_tracker = input_rate_tracker.clone(); move |request_frame_options| { + let thermal_state = handle + .update(&mut cx, |_, _, cx| cx.thermal_state()) + .log_err(); + + if thermal_state == Some(ThermalState::Serious) + || thermal_state == Some(ThermalState::Critical) + { + let now = Instant::now(); + let last_frame_time = last_frame_time.replace(Some(now)); + + if let Some(last_frame) = last_frame_time + && now.duration_since(last_frame) < Duration::from_micros(16667) + { + return; + } + } + let next_frame_callbacks = next_frame_callbacks.take(); if !next_frame_callbacks.is_empty() { handle @@ -1088,12 +1234,12 @@ impl Window { .log_err(); } - // Keep presenting the current scene for 1 extra second since the - // last input to prevent the display from underclocking the refresh rate. + // Keep presenting if input was recently arriving at a high rate (>= 60fps). + // Once high-rate input is detected, we sustain presentation for 1 second + // to prevent display underclocking during active input. let needs_present = request_frame_options.require_presentation || needs_present.get() - || (active.get() - && last_input_timestamp.get().elapsed() < Duration::from_secs(1)); + || (active.get() && input_rate_tracker.borrow_mut().is_high_rate()); if invalidator.is_dirty() || request_frame_options.force_render { measure("frame duration", || { @@ -1101,7 +1247,6 @@ impl Window { .update(&mut cx, |_, window, cx| { let arena_clear_needed = window.draw(cx); window.present(); - // drop the arena elements after present to reduce latency arena_clear_needed.clear(); }) .log_err(); @@ -1266,6 +1411,7 @@ impl Window { display_id, sprite_atlas, text_system, + text_rendering_mode: cx.text_rendering_mode.clone(), rem_size: px(16.), rem_size_override_stack: SmallVec::new(), viewport_size: content_size, @@ -1299,7 +1445,7 @@ impl Window { active, hovered, needs_present, - last_input_timestamp, + input_rate_tracker, last_input_modality: InputModality::Mouse, refreshing: false, activation_observers: SubscriberSet::new(), @@ -1311,6 +1457,7 @@ impl Window { prompt: None, client_inset: None, image_cache_stack: Vec::new(), + captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, }) @@ -1325,7 +1472,8 @@ impl Window { } #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct DispatchEventResult { +#[expect(missing_docs)] +pub struct DispatchEventResult { pub propagate: bool, pub default_prevented: bool, } @@ -1436,13 +1584,25 @@ impl Window { } /// Move focus to the element associated with the given [`FocusHandle`]. - pub fn focus(&mut self, handle: &FocusHandle) { + pub fn focus(&mut self, handle: &FocusHandle, cx: &mut App) { if !self.focus_enabled || self.focus == Some(handle.id) { return; } self.focus = Some(handle.id); self.clear_pending_keystrokes(); + + // Avoid re-entrant entity updates by deferring observer notifications to the end of the + // current effect cycle, and only for this window. + let window_handle = self.handle; + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + window.pending_input_changed(cx); + }) + .ok(); + }); + self.refresh(); } @@ -1463,24 +1623,24 @@ impl Window { } /// Move focus to next tab stop. - pub fn focus_next(&mut self) { + pub fn focus_next(&mut self, cx: &mut App) { if !self.focus_enabled { return; } if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) { - self.focus(&handle) + self.focus(&handle, cx) } } /// Move focus to previous tab stop. - pub fn focus_prev(&mut self) { + pub fn focus_prev(&mut self, cx: &mut App) { if !self.focus_enabled { return; } if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) { - self.focus(&handle) + self.focus(&handle, cx) } } @@ -1747,7 +1907,12 @@ impl Window { }) } - fn bounds_changed(&mut self, cx: &mut App) { + /// Notify the window that its bounds have changed. + /// + /// This updates internal state like `viewport_size` and `scale_factor` from + /// the platform window, then notifies observers. Normally called automatically + /// by the platform's resize callback, but exposed publicly for test infrastructure. + pub fn bounds_changed(&mut self, cx: &mut App) { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); self.display_id = self.platform_window.display().map(|display| display.id()); @@ -1764,6 +1929,15 @@ impl Window { self.platform_window.bounds() } + /// Renders the current frame's scene to a texture and returns the pixel data as an RGBA image. + /// This does not present the frame to screen - useful for visual testing where we want + /// to capture what would be rendered without displaying it or requiring the window to be visible. + #[cfg(any(test, feature = "test-support"))] + pub fn render_to_image(&self) -> anyhow::Result { + self.platform_window + .render_to_image(&self.rendered_frame.scene) + } + /// Set the content size of the window. pub fn resize(&mut self, size: Size) { self.platform_window.resize(size); @@ -1914,10 +2088,22 @@ impl Window { element_id: ElementId, f: impl FnOnce(&GlobalElementId, &mut Self) -> R, ) -> R { - self.element_id_stack.push(element_id); - let global_id = GlobalElementId(Arc::from(&*self.element_id_stack)); + self.with_id(element_id, |this| { + let global_id = GlobalElementId(Arc::from(&*this.element_id_stack)); - let result = f(&global_id, self); + f(&global_id, this) + }) + } + + /// Calls the provided closure with the element ID pushed on the stack. + #[inline] + pub fn with_id( + &mut self, + element_id: impl Into, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + self.element_id_stack.push(element_id.into()); + let result = f(self); self.element_id_stack.pop(); result } @@ -1961,7 +2147,7 @@ impl Window { } /// Determine whether the given action is available along the dispatch path to the currently focused element. - pub fn is_action_available(&self, action: &dyn Action, cx: &mut App) -> bool { + pub fn is_action_available(&self, action: &dyn Action, cx: &App) -> bool { let node_id = self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id)); self.rendered_frame @@ -1969,11 +2155,39 @@ impl Window { .is_action_available(action, node_id) } + /// Determine whether the given action is available along the dispatch path to the given focus_handle. + pub fn is_action_available_in(&self, action: &dyn Action, focus_handle: &FocusHandle) -> bool { + let node_id = self.focus_node_id_in_rendered_frame(Some(focus_handle.id)); + self.rendered_frame + .dispatch_tree + .is_action_available(action, node_id) + } + /// The position of the mouse relative to the window. pub fn mouse_position(&self) -> Point { self.mouse_position } + /// Captures the pointer for the given hitbox. While captured, all mouse move and mouse up + /// events will be routed to listeners that check this hitbox's `is_hovered` status, + /// regardless of actual hit testing. This enables drag operations that continue + /// even when the pointer moves outside the element's bounds. + /// + /// The capture is automatically released on mouse up. + pub fn capture_pointer(&mut self, hitbox_id: HitboxId) { + self.captured_hitbox = Some(hitbox_id); + } + + /// Releases any active pointer capture. + pub fn release_pointer(&mut self) { + self.captured_hitbox = None; + } + + /// Returns the hitbox that has captured the pointer, if any. + pub fn captured_hitbox(&self) -> Option { + self.captured_hitbox + } + /// The current state of the keyboard's modifiers pub fn modifiers(&self) -> Modifiers { self.modifiers @@ -1998,6 +2212,10 @@ impl Window { /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { + // Set up the per-App arena for element allocation during this draw. + // This ensures that multiple test Apps have isolated arenas. + let _arena_scope = ElementArenaScope::enter(&cx.element_arena); + self.invalidate_entities(); cx.entities.clear_accessed(); debug_assert!(self.rendered_entity_stack.is_empty()); @@ -2065,13 +2283,12 @@ impl Window { self.invalidator.set_phase(DrawPhase::None); self.needs_present.set(true); - ArenaClearNeeded + ArenaClearNeeded::new(&cx.element_arena) } fn record_entities_accessed(&mut self, cx: &mut App) { - let mut entities_ref = cx.entities.accessed_entities.borrow_mut(); + let mut entities_ref = cx.entities.accessed_entities.get_mut(); let mut entities = mem::take(entities_ref.deref_mut()); - drop(entities_ref); let handle = self.handle; cx.record_entities_accessed( handle, @@ -2079,7 +2296,7 @@ impl Window { self.invalidator.clone(), &entities, ); - let mut entities_ref = cx.entities.accessed_entities.borrow_mut(); + let mut entities_ref = cx.entities.accessed_entities.get_mut(); mem::swap(&mut entities, entities_ref.deref_mut()); } @@ -2127,10 +2344,7 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] let inspector_element = self.prepaint_inspector(_inspector_width, cx); - let mut sorted_deferred_draws = - (0..self.next_frame.deferred_draws.len()).collect::>(); - sorted_deferred_draws.sort_by_key(|ix| self.next_frame.deferred_draws[*ix].priority); - self.prepaint_deferred_draws(&sorted_deferred_draws, cx); + self.prepaint_deferred_draws(cx); let mut prompt_element = None; let mut active_drag_element = None; @@ -2159,7 +2373,7 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector(inspector_element, cx); - self.paint_deferred_draws(&sorted_deferred_draws, cx); + self.paint_deferred_draws(cx); if let Some(mut prompt_element) = prompt_element { prompt_element.paint(self, cx); @@ -2242,49 +2456,80 @@ impl Window { None } - fn prepaint_deferred_draws(&mut self, deferred_draw_indices: &[usize], cx: &mut App) { + fn prepaint_deferred_draws(&mut self, cx: &mut App) { assert_eq!(self.element_id_stack.len(), 0); - let mut deferred_draws = mem::take(&mut self.next_frame.deferred_draws); - for deferred_draw_ix in deferred_draw_indices { - let deferred_draw = &mut deferred_draws[*deferred_draw_ix]; - self.element_id_stack - .clone_from(&deferred_draw.element_id_stack); - self.text_style_stack - .clone_from(&deferred_draw.text_style_stack); - self.next_frame - .dispatch_tree - .set_active_node(deferred_draw.parent_node); + let mut completed_draws = Vec::new(); - let prepaint_start = self.prepaint_index(); - if let Some(element) = deferred_draw.element.as_mut() { - self.with_rendered_view(deferred_draw.current_view, |window| { - window.with_absolute_element_offset(deferred_draw.absolute_offset, |window| { - element.prepaint(window, cx) - }); - }) - } else { - self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + // Process deferred draws in multiple rounds to support nesting. + // Each round processes all current deferred draws, which may produce new ones. + let mut depth = 0; + loop { + // Limit maximum nesting depth to prevent infinite loops. + assert!(depth < 10, "Exceeded maximum (10) deferred depth"); + depth += 1; + let deferred_count = self.next_frame.deferred_draws.len(); + if deferred_count == 0 { + break; } - let prepaint_end = self.prepaint_index(); - deferred_draw.prepaint_range = prepaint_start..prepaint_end; + + // Sort by priority for this round + let traversal_order = self.deferred_draw_traversal_order(); + let mut deferred_draws = mem::take(&mut self.next_frame.deferred_draws); + + for deferred_draw_ix in traversal_order { + let deferred_draw = &mut deferred_draws[deferred_draw_ix]; + self.element_id_stack + .clone_from(&deferred_draw.element_id_stack); + self.text_style_stack + .clone_from(&deferred_draw.text_style_stack); + self.next_frame + .dispatch_tree + .set_active_node(deferred_draw.parent_node); + + let prepaint_start = self.prepaint_index(); + if let Some(element) = deferred_draw.element.as_mut() { + self.with_rendered_view(deferred_draw.current_view, |window| { + window.with_rem_size(Some(deferred_draw.rem_size), |window| { + window.with_absolute_element_offset( + deferred_draw.absolute_offset, + |window| { + element.prepaint(window, cx); + }, + ); + }); + }) + } else { + self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + } + let prepaint_end = self.prepaint_index(); + deferred_draw.prepaint_range = prepaint_start..prepaint_end; + } + + // Save completed draws and continue with newly added ones + completed_draws.append(&mut deferred_draws); + + self.element_id_stack.clear(); + self.text_style_stack.clear(); } - assert_eq!( - self.next_frame.deferred_draws.len(), - 0, - "cannot call defer_draw during deferred drawing" - ); - self.next_frame.deferred_draws = deferred_draws; - self.element_id_stack.clear(); - self.text_style_stack.clear(); + + // Restore all completed draws + self.next_frame.deferred_draws = completed_draws; } - fn paint_deferred_draws(&mut self, deferred_draw_indices: &[usize], cx: &mut App) { + fn paint_deferred_draws(&mut self, cx: &mut App) { assert_eq!(self.element_id_stack.len(), 0); + // Paint all deferred draws in priority order. + // Since prepaint has already processed nested deferreds, we just paint them all. + if self.next_frame.deferred_draws.len() == 0 { + return; + } + + let traversal_order = self.deferred_draw_traversal_order(); let mut deferred_draws = mem::take(&mut self.next_frame.deferred_draws); - for deferred_draw_ix in deferred_draw_indices { - let mut deferred_draw = &mut deferred_draws[*deferred_draw_ix]; + for deferred_draw_ix in traversal_order { + let mut deferred_draw = &mut deferred_draws[deferred_draw_ix]; self.element_id_stack .clone_from(&deferred_draw.element_id_stack); self.next_frame @@ -2292,9 +2537,14 @@ impl Window { .set_active_node(deferred_draw.parent_node); let paint_start = self.paint_index(); + let content_mask = deferred_draw.content_mask.clone(); if let Some(element) = deferred_draw.element.as_mut() { self.with_rendered_view(deferred_draw.current_view, |window| { - element.paint(window, cx); + window.with_content_mask(content_mask, |window| { + window.with_rem_size(Some(deferred_draw.rem_size), |window| { + element.paint(window, cx); + }); + }) }) } else { self.reuse_paint(deferred_draw.paint_range.clone()); @@ -2306,6 +2556,13 @@ impl Window { self.element_id_stack.clear(); } + fn deferred_draw_traversal_order(&mut self) -> SmallVec<[usize; 8]> { + let deferred_count = self.next_frame.deferred_draws.len(); + let mut sorted_indices = (0..deferred_count).collect::>(); + sorted_indices.sort_by_key(|ix| self.next_frame.deferred_draws[*ix].priority); + sorted_indices + } + pub(crate) fn prepaint_index(&self) -> PrepaintStateIndex { PrepaintStateIndex { hitboxes_index: self.next_frame.hitboxes.len(), @@ -2357,6 +2614,8 @@ impl Window { parent_node: reused_subtree.refresh_node_id(deferred_draw.parent_node), element_id_stack: deferred_draw.element_id_stack.clone(), text_style_stack: deferred_draw.text_style_stack.clone(), + content_mask: deferred_draw.content_mask.clone(), + rem_size: deferred_draw.rem_size, priority: deferred_draw.priority, element: None, absolute_offset: deferred_draw.absolute_offset, @@ -2692,11 +2951,6 @@ impl Window { }) } - /// Immediately push an element ID onto the stack. Useful for simplifying IDs in lists - pub fn with_id(&mut self, id: impl Into, f: impl FnOnce(&mut Self) -> R) -> R { - self.with_global_id(id.into(), |_, window| f(window)) - } - /// Use a piece of state that exists as long this element is being rendered in consecutive frames, without needing to specify a key /// /// NOTE: This method uses the location of the caller to generate an ID for this state. @@ -2844,12 +3098,16 @@ impl Window { /// at a later time. The `priority` parameter determines the drawing order relative to other deferred elements, /// with higher values being drawn on top. /// + /// When `content_mask` is provided, the deferred element will be clipped to that region during + /// both prepaint and paint. When `None`, no additional clipping is applied. + /// /// This method should only be called as part of the prepaint phase of element drawing. pub fn defer_draw( &mut self, element: AnyElement, absolute_offset: Point, priority: usize, + content_mask: Option>, ) { self.invalidator.debug_assert_prepaint(); let parent_node = self.next_frame.dispatch_tree.active_node_id().unwrap(); @@ -2858,6 +3116,8 @@ impl Window { parent_node, element_id_stack: self.element_id_stack.clone(), text_style_stack: self.text_style_stack.clone(), + content_mask, + rem_size: self.rem_size(), priority, element: Some(element), absolute_offset, @@ -3055,6 +3315,7 @@ impl Window { x: (glyph_origin.x.0.fract() * SUBPIXEL_VARIANTS_X as f32).floor() as u8, y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS_Y as f32).floor() as u8, }; + let subpixel_rendering = self.should_use_subpixel_rendering(font_id, font_size); let params = RenderGlyphParams { font_id, glyph_id, @@ -3062,6 +3323,7 @@ impl Window { subpixel_variant, scale_factor, is_emoji: false, + subpixel_rendering, }; let raster_bounds = self.text_system().raster_bounds(¶ms)?; @@ -3078,6 +3340,65 @@ impl Window { size: tile.bounds.size.map(Into::into), }; let content_mask = self.content_mask().scale(scale_factor); + + if subpixel_rendering { + self.next_frame.scene.insert_primitive(SubpixelSprite { + order: 0, + pad: 0, + bounds, + content_mask, + color: color.opacity(element_opacity), + tile, + transformation: TransformationMatrix::unit(), + }); + } else { + self.next_frame.scene.insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds, + content_mask, + color: color.opacity(element_opacity), + tile, + transformation: TransformationMatrix::unit(), + }); + } + } + Ok(()) + } + + /// Paints a monochrome glyph with pre-computed raster bounds. + /// + /// This is faster than `paint_glyph` because it skips the per-glyph cache lookup. + /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. + pub fn paint_glyph_with_raster_bounds( + &mut self, + origin: Point, + _font_id: FontId, + _glyph_id: GlyphId, + _font_size: Pixels, + color: Hsla, + raster_bounds: Bounds, + params: &RenderGlyphParams, + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + + let element_opacity = self.element_opacity(); + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + + if !raster_bounds.is_zero() { + let tile = self + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(params)?; + Ok(Some((size, Cow::Owned(bytes)))) + })? + .expect("Callback above only errors or returns Some"); + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); self.next_frame.scene.insert_primitive(MonochromeSprite { order: 0, pad: 0, @@ -3091,6 +3412,73 @@ impl Window { Ok(()) } + /// Paints an emoji glyph with pre-computed raster bounds. + /// + /// This is faster than `paint_emoji` because it skips the per-glyph cache lookup. + /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. + pub fn paint_emoji_with_raster_bounds( + &mut self, + origin: Point, + _font_id: FontId, + _glyph_id: GlyphId, + _font_size: Pixels, + raster_bounds: Bounds, + params: &RenderGlyphParams, + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + + if !raster_bounds.is_zero() { + let tile = self + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(params)?; + Ok(Some((size, Cow::Owned(bytes)))) + })? + .expect("Callback above only errors or returns Some"); + + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + let opacity = self.element_opacity(); + + self.next_frame.scene.insert_primitive(PolychromeSprite { + order: 0, + pad: 0, + grayscale: false, + bounds, + corner_radii: Default::default(), + content_mask, + tile, + opacity, + }); + } + Ok(()) + } + + fn should_use_subpixel_rendering(&self, font_id: FontId, font_size: Pixels) -> bool { + if self.platform_window.background_appearance() != WindowBackgroundAppearance::Opaque { + return false; + } + + if !self.platform_window.is_subpixel_rendering_supported() { + return false; + } + + let mode = match self.text_rendering_mode.get() { + TextRenderingMode::PlatformDefault => self + .text_system() + .recommended_rendering_mode(font_id, font_size), + mode => mode, + }; + + mode == TextRenderingMode::Subpixel + } + /// Paints an emoji glyph into the scene for the next frame at the current z-index. /// /// The y component of the origin is the baseline of the glyph. @@ -3118,6 +3506,7 @@ impl Window { subpixel_variant: Default::default(), scale_factor, is_emoji: true, + subpixel_rendering: false, }; let raster_bounds = self.text_system().raster_bounds(¶ms)?; @@ -3454,6 +3843,7 @@ impl Window { self.rendered_entity_stack.last().copied().unwrap() } + #[inline] pub(crate) fn with_rendered_view( &mut self, id: EntityId, @@ -3671,16 +4061,18 @@ impl Window { /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { - self.last_input_timestamp.set(Instant::now()); - - // Track whether this input was keyboard-based for focus-visible styling + // Track input modality for focus-visible styling and hover suppression. + // Hover is suppressed during keyboard modality so that keyboard navigation + // doesn't show hover highlights on the item under the mouse cursor. + let old_modality = self.last_input_modality; self.last_input_modality = match &event { - PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => { - InputModality::Keyboard - } - PlatformInput::MouseDown(e) if e.is_focusing() => InputModality::Mouse, + PlatformInput::KeyDown(_) => InputModality::Keyboard, + PlatformInput::MouseMove(_) | PlatformInput::MouseDown(_) => InputModality::Mouse, _ => self.last_input_modality, }; + if self.last_input_modality != old_modality { + self.refresh(); + } // Handlers may set this to false by calling `stop_propagation`. cx.propagate_event = true; @@ -3705,6 +4097,9 @@ impl Window { self.modifiers = mouse_up.modifiers; PlatformInput::MouseUp(mouse_up) } + PlatformInput::MousePressure(mouse_pressure) => { + PlatformInput::MousePressure(mouse_pressure) + } PlatformInput::MouseExited(mouse_exited) => { self.modifiers = mouse_exited.modifiers; PlatformInput::MouseExited(mouse_exited) @@ -3719,6 +4114,12 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(pinch) => { + self.mouse_position = pinch.position; + self.modifiers = pinch.modifiers; + PlatformInput::Pinch(pinch) + } // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. PlatformInput::FileDrop(file_drop) => match file_drop { @@ -3770,6 +4171,10 @@ impl Window { self.dispatch_key_event(any_key_event, cx); } + if self.invalidator.is_dirty() { + self.input_rate_tracker.borrow_mut().record_input(); + } + DispatchEventResult { propagate: cx.propagate_event, default_prevented: self.default_prevented, @@ -3827,6 +4232,11 @@ impl Window { self.refresh(); } } + + // Auto-release pointer capture on mouse up + if event.is::() && self.captured_hitbox.is_some() { + self.captured_hitbox = None; + } } fn dispatch_key_event(&mut self, event: &dyn Any, cx: &mut App) { @@ -4009,7 +4419,7 @@ impl Window { self.dispatch_keystroke_observers(event, None, context_stack, cx); } - fn pending_input_changed(&mut self, cx: &mut App) { + pub(crate) fn pending_input_changed(&mut self, cx: &mut App) { self.pending_input_observers .clone() .retain(&(), |callback| callback(self, cx)); @@ -4427,6 +4837,13 @@ impl Window { dispatch_tree.highest_precedence_binding_for_action(action, &context_stack) } + /// Find the bindings that can follow the current input sequence for the current context stack. + pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec { + self.rendered_frame + .dispatch_tree + .possible_next_bindings_for_input(input, &self.context_stack()) + } + fn context_stack_for_focus_handle( &self, focus_handle: &FocusHandle, @@ -4771,6 +5188,19 @@ impl Window { pub fn set_modifiers(&mut self, modifiers: Modifiers) { self.modifiers = modifiers; } + + /// For testing: simulate a mouse move event to the given position. + /// This dispatches the event through the normal event handling path, + /// which will trigger hover states and tooltips. + #[cfg(any(test, feature = "test-support"))] + pub fn simulate_mouse_move(&mut self, position: Point, cx: &mut App) { + let event = PlatformInput::MouseMove(MouseMoveEvent { + position, + modifiers: self.modifiers, + pressed_button: None, + }); + let _ = self.dispatch_event(event, cx); + } } // #[derive(Clone, Copy, Eq, PartialEq, Hash)] @@ -4831,11 +5261,11 @@ impl WindowHandle { where C: AppContext, { - crate::Flatten::flatten(cx.update_window(self.any_handle, |root_view, _, _| { + cx.update_window(self.any_handle, |root_view, _, _| { root_view .downcast::() .map_err(|_| anyhow!("the type of the window's root view has changed")) - })) + })? } /// Updates the root view of this window. @@ -4936,7 +5366,7 @@ impl From> for AnyWindowHandle { } /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. -#[derive(Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct AnyWindowHandle { pub(crate) id: WindowId, state_type: TypeId, diff --git a/src/window/prompts.rs b/src/window/prompts.rs index 63ad1668be..980c6f6812 100644 --- a/src/window/prompts.rs +++ b/src/window/prompts.rs @@ -44,10 +44,10 @@ impl PromptHandle { if let Some(sender) = sender.take() { sender.send(e.0).ok(); window_handle - .update(cx, |_, window, _cx| { + .update(cx, |_, window, cx| { window.prompt.take(); if let Some(previous_focus) = &previous_focus { - window.focus(previous_focus); + window.focus(previous_focus, cx); } }) .ok(); @@ -55,7 +55,7 @@ impl PromptHandle { }) .detach(); - window.focus(&view.focus_handle(cx)); + window.focus(&view.focus_handle(cx), cx); RenderablePromptHandle { view: Box::new(view), diff --git a/tooling/macros/Cargo.lock b/tooling/macros/Cargo.lock new file mode 100644 index 0000000000..7b6fdb598b --- /dev/null +++ b/tooling/macros/Cargo.lock @@ -0,0 +1,54 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "gpui-ce-macros" +version = "0.1.0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/tooling/macros/Cargo.toml b/tooling/macros/Cargo.toml new file mode 100644 index 0000000000..266fd5e61d --- /dev/null +++ b/tooling/macros/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gpui-ce-macros" +version = "0.1.0" +edition = "2024" +publish = false +license = "Apache-2.0" +description = "Macros used by gpui" + +[features] +inspector = [] + +[lib] +path = "src/gpui_macros.rs" +proc-macro = true +doctest = true + +[dependencies] +heck = "0.5.0" +proc-macro2 = "1.0.106" +quote = "1.0.45" +syn = "2.0.117" + diff --git a/tooling/macros/src/derive_action.rs b/tooling/macros/src/derive_action.rs new file mode 100644 index 0000000000..4e6c6277e4 --- /dev/null +++ b/tooling/macros/src/derive_action.rs @@ -0,0 +1,211 @@ +use crate::register_action::generate_register_action; +use proc_macro::TokenStream; +use proc_macro2::Ident; +use quote::quote; +use syn::{Data, DeriveInput, LitStr, Token, parse::ParseStream}; + +pub(crate) fn derive_action(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + + let struct_name = &input.ident; + let mut name_argument = None; + let mut deprecated_aliases = Vec::new(); + let mut no_json = false; + let mut no_register = false; + let mut namespace = None; + let mut deprecated = None; + let mut doc_str: Option = None; + + /* + * + * #[action()] + * Struct Foo { + * bar: bool // is bar considered an attribute + } + */ + for attr in &input.attrs { + if attr.path().is_ident("action") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + if name_argument.is_some() { + return Err(meta.error("'name' argument specified multiple times")); + } + meta.input.parse::()?; + let lit: LitStr = meta.input.parse()?; + name_argument = Some(lit.value()); + } else if meta.path.is_ident("namespace") { + if namespace.is_some() { + return Err(meta.error("'namespace' argument specified multiple times")); + } + meta.input.parse::()?; + let ident: Ident = meta.input.parse()?; + namespace = Some(ident.to_string()); + } else if meta.path.is_ident("no_json") { + if no_json { + return Err(meta.error("'no_json' argument specified multiple times")); + } + no_json = true; + } else if meta.path.is_ident("no_register") { + if no_register { + return Err(meta.error("'no_register' argument specified multiple times")); + } + no_register = true; + } else if meta.path.is_ident("deprecated_aliases") { + if !deprecated_aliases.is_empty() { + return Err( + meta.error("'deprecated_aliases' argument specified multiple times") + ); + } + meta.input.parse::()?; + // Parse array of string literals + let content; + syn::bracketed!(content in meta.input); + let aliases = content.parse_terminated( + |input: ParseStream| input.parse::(), + Token![,], + )?; + deprecated_aliases.extend(aliases.into_iter().map(|lit| lit.value())); + } else if meta.path.is_ident("deprecated") { + if deprecated.is_some() { + return Err(meta.error("'deprecated' argument specified multiple times")); + } + meta.input.parse::()?; + let lit: LitStr = meta.input.parse()?; + deprecated = Some(lit.value()); + } else { + return Err(meta.error(format!( + "'{:?}' argument not recognized, expected \ + 'namespace', 'no_json', 'no_register, 'deprecated_aliases', or 'deprecated'", + meta.path + ))); + } + Ok(()) + }) + .unwrap_or_else(|e| panic!("in #[action] attribute: {}", e)); + } else if attr.path().is_ident("doc") { + use syn::{Expr::Lit, ExprLit, Lit::Str, Meta, MetaNameValue}; + if let Meta::NameValue(MetaNameValue { + value: + Lit(ExprLit { + lit: Str(ref lit_str), + .. + }), + .. + }) = attr.meta + { + let doc = lit_str.value(); + let doc_str = doc_str.get_or_insert_default(); + doc_str.push_str(doc.trim()); + doc_str.push('\n'); + } + } + } + + let name = name_argument.unwrap_or_else(|| struct_name.to_string()); + + if name.contains("::") { + panic!( + "in #[action] attribute: `name = \"{name}\"` must not contain `::`, \ + also specify `namespace` instead" + ); + } + + let full_name = if let Some(namespace) = namespace { + format!("{namespace}::{name}") + } else { + name + }; + + let is_unit_struct = matches!(&input.data, Data::Struct(data) if data.fields.is_empty()); + + let build_fn_body = if no_json { + let error_msg = format!("{} cannot be built from JSON", full_name); + quote! { Err(gpui::private::anyhow::anyhow!(#error_msg)) } + } else if is_unit_struct { + quote! { Ok(Box::new(Self)) } + } else { + quote! { Ok(Box::new(gpui::private::serde_json::from_value::(_value)?)) } + }; + + let json_schema_fn_body = if no_json || is_unit_struct { + quote! { None } + } else { + quote! { Some(::json_schema(_generator)) } + }; + + let deprecated_aliases_fn_body = if deprecated_aliases.is_empty() { + quote! { &[] } + } else { + let aliases = deprecated_aliases.iter(); + quote! { &[#(#aliases),*] } + }; + + let deprecation_fn_body = if let Some(message) = deprecated { + quote! { Some(#message) } + } else { + quote! { None } + }; + + let documentation_fn_body = if let Some(doc) = doc_str { + let doc = doc.trim(); + quote! { Some(#doc) } + } else { + quote! { None } + }; + + let registration = if no_register { + quote! {} + } else { + generate_register_action(struct_name) + }; + + TokenStream::from(quote! { + #registration + + impl gpui::Action for #struct_name { + fn name(&self) -> &'static str { + #full_name + } + + fn name_for_type() -> &'static str + where + Self: Sized + { + #full_name + } + + fn partial_eq(&self, action: &dyn gpui::Action) -> bool { + action + .as_any() + .downcast_ref::() + .map_or(false, |a| self == a) + } + + fn boxed_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn build(_value: gpui::private::serde_json::Value) -> gpui::Result> { + #build_fn_body + } + + fn action_json_schema( + _generator: &mut gpui::private::schemars::SchemaGenerator, + ) -> Option { + #json_schema_fn_body + } + + fn deprecated_aliases() -> &'static [&'static str] { + #deprecated_aliases_fn_body + } + + fn deprecation_message() -> Option<&'static str> { + #deprecation_fn_body + } + + fn documentation() -> Option<&'static str> { + #documentation_fn_body + } + } + }) +} diff --git a/tooling/macros/src/derive_app_context.rs b/tooling/macros/src/derive_app_context.rs new file mode 100644 index 0000000000..46f9e58409 --- /dev/null +++ b/tooling/macros/src/derive_app_context.rs @@ -0,0 +1,110 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +use crate::get_simple_attribute_field; + +pub fn derive_app_context(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + + let Some(app_variable) = get_simple_attribute_field(&ast, "app") else { + return quote! { + compile_error!("Derive must have an #[app] attribute to detect the &mut App field"); + } + .into(); + }; + + let type_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); + + let r#gen = quote! { + impl #impl_generics gpui::AppContext for #type_name #type_generics + #where_clause + { + fn new( + &mut self, + build_entity: impl FnOnce(&mut gpui::Context<'_, T>) -> T, + ) -> gpui::Entity { + self.#app_variable.new(build_entity) + } + + fn reserve_entity(&mut self) -> gpui::Reservation { + self.#app_variable.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: gpui::Reservation, + build_entity: impl FnOnce(&mut gpui::Context<'_, T>) -> T, + ) -> gpui::Entity { + self.#app_variable.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &gpui::Entity, + update: impl FnOnce(&mut T, &mut gpui::Context<'_, T>) -> R, + ) -> R + where + T: 'static, + { + self.#app_variable.update_entity(handle, update) + } + + fn as_mut<'y, 'z, T>( + &'y mut self, + handle: &'z gpui::Entity, + ) -> gpui::GpuiBorrow<'y, T> + where + T: 'static, + { + self.#app_variable.as_mut(handle) + } + + fn read_entity( + &self, + handle: &gpui::Entity, + read: impl FnOnce(&T, &gpui::App) -> R, + ) -> R + where + T: 'static, + { + self.#app_variable.read_entity(handle, read) + } + + fn update_window(&mut self, window: gpui::AnyWindowHandle, f: F) -> gpui::Result + where + F: FnOnce(gpui::AnyView, &mut gpui::Window, &mut gpui::App) -> T, + { + self.#app_variable.update_window(window, f) + } + + fn read_window( + &self, + window: &gpui::WindowHandle, + read: impl FnOnce(gpui::Entity, &gpui::App) -> R, + ) -> gpui::Result + where + T: 'static, + { + self.#app_variable.read_window(window, read) + } + + fn background_spawn(&self, future: impl std::future::Future + Send + 'static) -> gpui::Task + where + R: Send + 'static, + { + self.#app_variable.background_spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &gpui::App) -> R) -> R + where + G: gpui::Global, + { + self.#app_variable.read_global(callback) + } + } + }; + + r#gen.into() +} diff --git a/tooling/macros/src/derive_inspector_reflection.rs b/tooling/macros/src/derive_inspector_reflection.rs new file mode 100644 index 0000000000..9c1cb503a8 --- /dev/null +++ b/tooling/macros/src/derive_inspector_reflection.rs @@ -0,0 +1,305 @@ +//! Implements `#[derive_inspector_reflection]` macro to provide runtime access to trait methods +//! that have the shape `fn method(self) -> Self`. This code was generated using Zed Agent with Claude Opus 4. + +use heck::ToSnakeCase as _; +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use syn::{ + Attribute, Expr, FnArg, Ident, Item, ItemTrait, Lit, Meta, Path, ReturnType, TraitItem, Type, + parse_macro_input, parse_quote, + visit_mut::{self, VisitMut}, +}; + +pub fn derive_inspector_reflection(_args: TokenStream, input: TokenStream) -> TokenStream { + let mut item = parse_macro_input!(input as Item); + + // First, expand any macros in the trait + match &mut item { + Item::Trait(trait_item) => { + let mut expander = MacroExpander; + expander.visit_item_trait_mut(trait_item); + } + _ => { + return syn::Error::new_spanned( + quote!(#item), + "#[derive_inspector_reflection] can only be applied to traits", + ) + .to_compile_error() + .into(); + } + } + + // Now process the expanded trait + match item { + Item::Trait(trait_item) => generate_reflected_trait(trait_item), + _ => unreachable!(), + } +} + +fn generate_reflected_trait(trait_item: ItemTrait) -> TokenStream { + let trait_name = &trait_item.ident; + let vis = &trait_item.vis; + + // Determine if we're being called from within the gpui crate + let call_site = Span::call_site(); + let inspector_reflection_path = if is_called_from_gpui_crate(call_site) { + quote! { crate::inspector_reflection } + } else { + quote! { ::gpui::inspector_reflection } + }; + + // Collect method information for methods of form fn name(self) -> Self or fn name(mut self) -> Self + let mut method_infos = Vec::new(); + + for item in &trait_item.items { + if let TraitItem::Fn(method) = item { + let method_name = &method.sig.ident; + + // Check if method has self or mut self receiver + let has_valid_self_receiver = method + .sig + .inputs + .iter() + .any(|arg| matches!(arg, FnArg::Receiver(r) if r.reference.is_none())); + + // Check if method returns Self + let returns_self = match &method.sig.output { + ReturnType::Type(_, ty) => { + matches!(**ty, Type::Path(ref path) if path.path.is_ident("Self")) + } + ReturnType::Default => false, + }; + + // Check if method has exactly one parameter (self or mut self) + let param_count = method.sig.inputs.len(); + + // Include methods of form fn name(self) -> Self or fn name(mut self) -> Self + // This includes methods with default implementations + if has_valid_self_receiver && returns_self && param_count == 1 { + // Extract documentation and cfg attributes + let doc = extract_doc_comment(&method.attrs); + let cfg_attrs = extract_cfg_attributes(&method.attrs); + method_infos.push((method_name.clone(), doc, cfg_attrs)); + } + } + } + + // Generate the reflection module name + let reflection_mod_name = Ident::new( + &format!("{}_reflection", trait_name.to_string().to_snake_case()), + trait_name.span(), + ); + + // Generate wrapper functions for each method + // These wrappers use type erasure to allow runtime invocation + let wrapper_functions = method_infos.iter().map(|(method_name, _doc, cfg_attrs)| { + let wrapper_name = Ident::new( + &format!("__wrapper_{}", method_name), + method_name.span(), + ); + quote! { + #(#cfg_attrs)* + fn #wrapper_name(value: Box) -> Box { + if let Ok(concrete) = value.downcast::() { + Box::new(concrete.#method_name()) + } else { + panic!("Type mismatch in reflection wrapper"); + } + } + } + }); + + // Generate method info entries + let method_info_entries = method_infos.iter().map(|(method_name, doc, cfg_attrs)| { + let method_name_str = method_name.to_string(); + let wrapper_name = Ident::new(&format!("__wrapper_{}", method_name), method_name.span()); + let doc_expr = match doc { + Some(doc_str) => quote! { Some(#doc_str) }, + None => quote! { None }, + }; + quote! { + #(#cfg_attrs)* + #inspector_reflection_path::FunctionReflection { + name: #method_name_str, + function: #wrapper_name::, + documentation: #doc_expr, + _type: ::std::marker::PhantomData, + } + } + }); + + // Generate the complete output + let output = quote! { + #trait_item + + /// Implements function reflection + #vis mod #reflection_mod_name { + use super::*; + + #(#wrapper_functions)* + + /// Get all reflectable methods for a concrete type implementing the trait + pub fn methods() -> Vec<#inspector_reflection_path::FunctionReflection> { + vec![ + #(#method_info_entries),* + ] + } + + /// Find a method by name for a concrete type implementing the trait + pub fn find_method(name: &str) -> Option<#inspector_reflection_path::FunctionReflection> { + methods::().into_iter().find(|m| m.name == name) + } + } + }; + + TokenStream::from(output) +} + +fn extract_doc_comment(attrs: &[Attribute]) -> Option { + let mut doc_lines = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") + && let Meta::NameValue(meta) = &attr.meta + && let Expr::Lit(expr_lit) = &meta.value + && let Lit::Str(lit_str) = &expr_lit.lit + { + let line = lit_str.value(); + let line = line.strip_prefix(' ').unwrap_or(&line); + doc_lines.push(line.to_string()); + } + } + + if doc_lines.is_empty() { + None + } else { + Some(doc_lines.join("\n")) + } +} + +fn extract_cfg_attributes(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .filter(|attr| attr.path().is_ident("cfg")) + .cloned() + .collect() +} + +fn is_called_from_gpui_crate(_span: Span) -> bool { + // Check if we're being called from within the gpui crate by examining the call site + // This is a heuristic approach - we check if the current crate name is "gpui" + std::env::var("CARGO_PKG_NAME").is_ok_and(|name| name == "gpui") +} + +struct MacroExpander; + +impl VisitMut for MacroExpander { + fn visit_item_trait_mut(&mut self, trait_item: &mut ItemTrait) { + let mut expanded_items = Vec::new(); + let mut items_to_keep = Vec::new(); + + for item in trait_item.items.drain(..) { + match item { + TraitItem::Macro(macro_item) => { + // Try to expand known macros + if let Some(expanded) = try_expand_macro(¯o_item) { + expanded_items.extend(expanded); + } else { + // Keep unknown macros as-is + items_to_keep.push(TraitItem::Macro(macro_item)); + } + } + other => { + items_to_keep.push(other); + } + } + } + + // Rebuild the items list with expanded content first, then original items + trait_item.items = expanded_items; + trait_item.items.extend(items_to_keep); + + // Continue visiting + visit_mut::visit_item_trait_mut(self, trait_item); + } +} + +fn try_expand_macro(macro_item: &syn::TraitItemMacro) -> Option> { + let path = ¯o_item.mac.path; + + // Check if this is one of our known style macros + let macro_name = path_to_string(path); + + // Handle the known macros by calling their implementations + match macro_name.as_str() { + "gpui_macros::style_helpers" | "style_helpers" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::style_helpers(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::visibility_style_methods" | "visibility_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::visibility_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::margin_style_methods" | "margin_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::margin_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::padding_style_methods" | "padding_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::padding_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::position_style_methods" | "position_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::position_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::overflow_style_methods" | "overflow_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::overflow_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::cursor_style_methods" | "cursor_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::cursor_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::border_style_methods" | "border_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::border_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + "gpui_macros::box_shadow_style_methods" | "box_shadow_style_methods" => { + let tokens = macro_item.mac.tokens.clone(); + let expanded = crate::styles::box_shadow_style_methods(TokenStream::from(tokens)); + parse_expanded_items(expanded) + } + _ => None, + } +} + +fn path_to_string(path: &Path) -> String { + path.segments + .iter() + .map(|seg| seg.ident.to_string()) + .collect::>() + .join("::") +} + +fn parse_expanded_items(expanded: TokenStream) -> Option> { + let tokens = TokenStream2::from(expanded); + + // Try to parse the expanded tokens as trait items + // We need to wrap them in a dummy trait to parse properly + let dummy_trait: ItemTrait = parse_quote! { + trait Dummy { + #tokens + } + }; + + Some(dummy_trait.items) +} diff --git a/tooling/macros/src/derive_into_element.rs b/tooling/macros/src/derive_into_element.rs new file mode 100644 index 0000000000..89d609ae65 --- /dev/null +++ b/tooling/macros/src/derive_into_element.rs @@ -0,0 +1,24 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +pub fn derive_into_element(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + let type_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); + + let r#gen = quote! { + impl #impl_generics gpui::IntoElement for #type_name #type_generics + #where_clause + { + type Element = gpui::Component; + + #[track_caller] + fn into_element(self) -> Self::Element { + gpui::Component::new(self) + } + } + }; + + r#gen.into() +} diff --git a/tooling/macros/src/derive_render.rs b/tooling/macros/src/derive_render.rs new file mode 100644 index 0000000000..3e0dcbc993 --- /dev/null +++ b/tooling/macros/src/derive_render.rs @@ -0,0 +1,21 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +pub fn derive_render(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + let type_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); + + let r#gen = quote! { + impl #impl_generics gpui::Render for #type_name #type_generics + #where_clause + { + fn render(&mut self, _window: &mut gpui::Window, _cx: &mut gpui::Context) -> impl gpui::Element { + gpui::Empty + } + } + }; + + r#gen.into() +} diff --git a/tooling/macros/src/derive_visual_context.rs b/tooling/macros/src/derive_visual_context.rs new file mode 100644 index 0000000000..a639b6d2d6 --- /dev/null +++ b/tooling/macros/src/derive_visual_context.rs @@ -0,0 +1,73 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +use super::get_simple_attribute_field; + +pub fn derive_visual_context(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + + let Some(window_variable) = get_simple_attribute_field(&ast, "window") else { + return quote! { + compile_error!("Derive must have a #[window] attribute to detect the &mut Window field"); + } + .into(); + }; + + let Some(app_variable) = get_simple_attribute_field(&ast, "app") else { + return quote! { + compile_error!("Derive must have a #[app] attribute to detect the &mut App field"); + } + .into(); + }; + + let type_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); + + let r#gen = quote! { + impl #impl_generics gpui::VisualContext for #type_name #type_generics + #where_clause + { + type Result = T; + + fn window_handle(&self) -> gpui::AnyWindowHandle { + self.#window_variable.window_handle() + } + + fn update_window_entity( + &mut self, + entity: &gpui::Entity, + update: impl FnOnce(&mut T, &mut gpui::Window, &mut gpui::Context) -> R, + ) -> R { + gpui::AppContext::update_entity(self.#app_variable, entity, |entity, cx| update(entity, self.#window_variable, cx)) + } + + fn new_window_entity( + &mut self, + build_entity: impl FnOnce(&mut gpui::Window, &mut gpui::Context<'_, T>) -> T, + ) -> gpui::Entity { + gpui::AppContext::new(self.#app_variable, |cx| build_entity(self.#window_variable, cx)) + } + + fn replace_root_view( + &mut self, + build_view: impl FnOnce(&mut gpui::Window, &mut gpui::Context) -> V, + ) -> gpui::Entity + where + V: 'static + gpui::Render, + { + self.#window_variable.replace_root(self.#app_variable, build_view) + } + + fn focus(&mut self, entity: &gpui::Entity) + where + V: gpui::Focusable, + { + let focus_handle = gpui::Focusable::focus_handle(entity, self.#app_variable); + self.#window_variable.focus(&focus_handle, self.#app_variable); + } + } + }; + + r#gen.into() +} diff --git a/tooling/macros/src/gpui_macros.rs b/tooling/macros/src/gpui_macros.rs new file mode 100644 index 0000000000..e30c85e6ed --- /dev/null +++ b/tooling/macros/src/gpui_macros.rs @@ -0,0 +1,297 @@ +mod derive_action; +mod derive_app_context; +mod derive_into_element; +mod derive_render; +mod derive_visual_context; +mod property_test; +mod register_action; +mod styles; +mod test; + +#[cfg(any(feature = "inspector", debug_assertions))] +mod derive_inspector_reflection; + +use proc_macro::TokenStream; +use syn::{DeriveInput, Ident}; + +/// `Action` derive macro - see the trait documentation for details. +#[proc_macro_derive(Action, attributes(action))] +pub fn derive_action(input: TokenStream) -> TokenStream { + derive_action::derive_action(input) +} + +/// This can be used to register an action with the GPUI runtime when you want to manually implement +/// the `Action` trait. Typically you should use the `Action` derive macro or `actions!` macro +/// instead. +#[proc_macro] +pub fn register_action(ident: TokenStream) -> TokenStream { + register_action::register_action(ident) +} + +/// #[derive(IntoElement)] is used to create a Component out of anything that implements +/// the `RenderOnce` trait. +#[proc_macro_derive(IntoElement)] +pub fn derive_into_element(input: TokenStream) -> TokenStream { + derive_into_element::derive_into_element(input) +} + +#[proc_macro_derive(Render)] +#[doc(hidden)] +pub fn derive_render(input: TokenStream) -> TokenStream { + derive_render::derive_render(input) +} + +/// #[derive(AppContext)] is used to create a context out of anything that holds a `&mut App` +/// Note that a `#[app]` attribute is required to identify the variable holding the &mut App. +/// +/// Failure to add the attribute causes a compile error: +/// +/// ```compile_fail +/// # #[macro_use] extern crate gpui_macros; +/// # #[macro_use] extern crate gpui; +/// #[derive(AppContext)] +/// struct MyContext<'a> { +/// app: &'a mut gpui::App +/// } +/// ``` +#[proc_macro_derive(AppContext, attributes(app))] +pub fn derive_app_context(input: TokenStream) -> TokenStream { + derive_app_context::derive_app_context(input) +} + +/// #[derive(VisualContext)] is used to create a visual context out of anything that holds a `&mut Window` and +/// implements `AppContext` +/// Note that a `#[app]` and a `#[window]` attribute are required to identify the variables holding the &mut App, +/// and &mut Window respectively. +/// +/// Failure to add both attributes causes a compile error: +/// +/// ```compile_fail +/// # #[macro_use] extern crate gpui_macros; +/// # #[macro_use] extern crate gpui; +/// #[derive(VisualContext)] +/// struct MyContext<'a, 'b> { +/// #[app] +/// app: &'a mut gpui::App, +/// window: &'b mut gpui::Window +/// } +/// ``` +/// +/// ```compile_fail +/// # #[macro_use] extern crate gpui_macros; +/// # #[macro_use] extern crate gpui; +/// #[derive(VisualContext)] +/// struct MyContext<'a, 'b> { +/// app: &'a mut gpui::App, +/// #[window] +/// window: &'b mut gpui::Window +/// } +/// ``` +#[proc_macro_derive(VisualContext, attributes(window, app))] +pub fn derive_visual_context(input: TokenStream) -> TokenStream { + derive_visual_context::derive_visual_context(input) +} + +/// Used by GPUI to generate the style helpers. +#[proc_macro] +#[doc(hidden)] +pub fn style_helpers(input: TokenStream) -> TokenStream { + styles::style_helpers(input) +} + +/// Generates methods for visibility styles. +#[proc_macro] +pub fn visibility_style_methods(input: TokenStream) -> TokenStream { + styles::visibility_style_methods(input) +} + +/// Generates methods for margin styles. +#[proc_macro] +pub fn margin_style_methods(input: TokenStream) -> TokenStream { + styles::margin_style_methods(input) +} + +/// Generates methods for padding styles. +#[proc_macro] +pub fn padding_style_methods(input: TokenStream) -> TokenStream { + styles::padding_style_methods(input) +} + +/// Generates methods for position styles. +#[proc_macro] +pub fn position_style_methods(input: TokenStream) -> TokenStream { + styles::position_style_methods(input) +} + +/// Generates methods for overflow styles. +#[proc_macro] +pub fn overflow_style_methods(input: TokenStream) -> TokenStream { + styles::overflow_style_methods(input) +} + +/// Generates methods for cursor styles. +#[proc_macro] +pub fn cursor_style_methods(input: TokenStream) -> TokenStream { + styles::cursor_style_methods(input) +} + +/// Generates methods for border styles. +#[proc_macro] +pub fn border_style_methods(input: TokenStream) -> TokenStream { + styles::border_style_methods(input) +} + +/// Generates methods for box shadow styles. +#[proc_macro] +pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { + styles::box_shadow_style_methods(input) +} + +/// `#[gpui::test]` can be used to annotate test functions that run with GPUI support. +/// +/// It supports both synchronous and asynchronous tests, and can provide you with +/// as many `TestAppContext` instances as you need. +/// The output contains a `#[test]` annotation so this can be used with any existing +/// test harness (`cargo test` or `cargo-nextest`). +/// +/// ``` +/// #[gpui::test] +/// async fn test_foo(mut cx: &TestAppContext) { } +/// ``` +/// +/// In addition to passing a TestAppContext, you can also ask for a `StdRnd` instance. +/// this will be seeded with the `SEED` environment variable and is used internally by +/// the ForegroundExecutor and BackgroundExecutor to run tasks deterministically in tests. +/// Using the same `StdRng` for behavior in your test will allow you to exercise a wide +/// variety of scenarios and interleavings just by changing the seed. +/// +/// # Arguments +/// +/// - `#[gpui::test]` with no arguments runs once with the seed `0` or `SEED` env var if set. +/// - `#[gpui::test(seed = 10)]` runs once with the seed `10`. +/// - `#[gpui::test(seeds(10, 20, 30))]` runs three times with seeds `10`, `20`, and `30`. +/// - `#[gpui::test(iterations = 5)]` runs five times, providing as seed the values in the range `0..5`. +/// - `#[gpui::test(retries = 3)]` runs up to four times if it fails to try and make it pass. +/// - `#[gpui::test(on_failure = "crate::test::report_failure")]` will call the specified function after the +/// tests fail so that you can write out more detail about the failure. +/// +/// You can combine `iterations = ...` with `seeds(...)`: +/// - `#[gpui::test(iterations = 5, seed = 10)]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10))]`. +/// - `#[gpui::test(iterations = 5, seeds(10, 20, 30)]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10, 20, 30))]`. +/// - `#[gpui::test(seeds(10, 20, 30), iterations = 5]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10, 20, 30))]`. +/// +/// # Environment Variables +/// +/// - `SEED`: sets a seed for the first run +/// - `ITERATIONS`: forces the value of the `iterations` argument +#[proc_macro_attribute] +pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { + test::test(args, function) +} + +/// A variant of `#[gpui::test]` that supports property-based testing. +/// +/// A property test, much like a standard GPUI randomized test, allows testing +/// claims of the form "for any possible X, Y should hold". For example: +/// ``` +/// #[gpui::property_test] +/// fn test_arithmetic(x: i32, y: i32) { +/// assert!(x == y || x < y || x > y); +/// } +/// ``` +/// Standard GPUI randomized tests provide you with an instance of `StdRng` to +/// generate random data in a controlled manner. Property-based tests have some +/// advantages, however: +/// - Shrinking - the harness also understands a notion of the "complexity" of a +/// particular value. This allows it to find the "simplest possible value that +/// causes the test to fail". +/// - Ergonomics/clarity - the property-testing harness will automatically +/// generate values, removing the need to fill the test body with generation +/// logic. +/// - Failure persistence - if a failing seed is identified, it is stored in a +/// file, which can be checked in, and future runs will check these cases before +/// future cases. +/// +/// Property tests work best when all inputs can be generated up-front and kept +/// in a simple data structure. Sometimes, this isn't possible - for example, if +/// a test needs to make a random decision based on the current state of some +/// structure. In this case, a standard GPUI randomized test may be more +/// suitable. +/// +/// ## Customizing random values +/// +/// This macro is based on the [`#[proptest::property_test]`] macro, but handles +/// some of the same GPUI-specific arguments as `#[gpui::test]`. Specifically, +/// `&{mut,} TestAppContext` and `BackgroundExecutor` work as normal. `StdRng` +/// arguments are **explicitly forbidden**, since they break shrinking, and are +/// a common footgun. +/// +/// All other arguments are forwarded to the underlying proptest macro. +/// +/// Note: much of the following is copied from the proptest docs, specifically the +/// [`#[proptest::property_test]`] macro docs. +/// +/// Random values of type `T` are generated by a `Strategy` object. +/// Some types have a canonical `Strategy` - these types also implement +/// `Arbitrary`. Parameters to a `#[gpui::property_test]`, by default, use a +/// type's `Arbitrary` implementation. If you'd like to provide a custom +/// strategy, you can use `#[strategy = ...]` on the argument: +/// ``` +/// #[gpui::property_test] +/// fn int_test(#[strategy = 1..10] x: i32, #[strategy = "[a-zA-Z0-9]{20}"] s: String) { +/// assert!(s.len() > (x as usize)); +/// } +/// ``` +/// +/// For more information on writing custom `Strategy` and `Arbitrary` +/// implementations, see [the proptest book][book], and the [`Strategy`] trait. +/// +/// ## Scheduler +/// +/// Similar to `#[gpui::test]`, this macro will choose random seeds for the test +/// scheduler. It uses `.no_shrink()` to tell proptest that all seeds are +/// roughly equivalent in terms of "complexity". If `$SEED` is set, it will +/// affect **ONLY** the seed passed to the scheduler. To control other values, +/// use custom `Strategy`s. +/// +/// [`#[proptest::property_test]`]: https://docs.rs/proptest/latest/proptest/attr.property_test.html +/// [book]: https://proptest-rs.github.io/proptest/intro.html +/// [`Strategy`]: https://docs.rs/proptest/latest/proptest/strategy/trait.Strategy.html +#[proc_macro_attribute] +pub fn property_test(args: TokenStream, function: TokenStream) -> TokenStream { + property_test::test(args.into(), function.into()).into() +} + +/// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides +/// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`. +/// This is used by the inspector so that it can use the builder methods in `Styled` and +/// `StyledExt`. +/// +/// The generated module will have the name `_reflection` and contain the +/// following functions: +/// +/// ```ignore +/// pub fn methods::() -> Vec>; +/// +/// pub fn find_method::() -> Option>; +/// ``` +/// +/// The `invoke` method on `FunctionReflection` will run the method. `FunctionReflection` also +/// provides the method's documentation. +#[cfg(any(feature = "inspector", debug_assertions))] +#[proc_macro_attribute] +pub fn derive_inspector_reflection(_args: TokenStream, input: TokenStream) -> TokenStream { + derive_inspector_reflection::derive_inspector_reflection(_args, input) +} + +pub(crate) fn get_simple_attribute_field(ast: &DeriveInput, name: &'static str) -> Option { + match &ast.data { + syn::Data::Struct(data_struct) => data_struct + .fields + .iter() + .find(|field| field.attrs.iter().any(|attr| attr.path().is_ident(name))) + .map(|field| field.ident.clone().unwrap()), + syn::Data::Enum(_) => None, + syn::Data::Union(_) => None, + } +} diff --git a/tooling/macros/src/property_test.rs b/tooling/macros/src/property_test.rs new file mode 100644 index 0000000000..6bf60eca1b --- /dev/null +++ b/tooling/macros/src/property_test.rs @@ -0,0 +1,199 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote, quote_spanned}; +use syn::{ + FnArg, Ident, ItemFn, Type, parse2, punctuated::Punctuated, spanned::Spanned, token::Comma, +}; + +pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { + let item_span = item.span(); + let Ok(func) = parse2::(item) else { + return quote_spanned! { item_span => + compile_error!("#[gpui::property_test] must be placed on a function"); + }; + }; + + let test_name = func.sig.ident.clone(); + let inner_fn_name = format_ident!("__{test_name}"); + + let parsed_args = parse_args(func.sig.inputs, &test_name); + + let inner_body = func.block; + let inner_arg_decls = parsed_args.inner_fn_decl_args; + let asyncness = func.sig.asyncness; + + let inner_fn = quote! { + let #inner_fn_name = #asyncness move |#inner_arg_decls| #inner_body; + }; + + let arg_errors = parsed_args.errors; + let proptest_args = parsed_args.proptest_args; + let inner_args = parsed_args.inner_fn_args; + let cx_vars = parsed_args.cx_vars; + let cx_teardowns = parsed_args.cx_teardowns; + + let proptest_args = quote! { + #[strategy = ::gpui::seed_strategy()] __seed: u64, + #proptest_args + }; + + let run_test_body = match &asyncness { + None => quote! { + #cx_vars + #inner_fn_name(#inner_args); + #cx_teardowns + }, + Some(_) => quote! { + let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone())); + #cx_vars + foreground_executor.block_test(#inner_fn_name(#inner_args)); + #cx_teardowns + }, + }; + + quote! { + #arg_errors + + #[::gpui::proptest::property_test(proptest_path = "::gpui::proptest", #args)] + fn #test_name(#proptest_args) { + #inner_fn + + ::gpui::run_test_once( + __seed, + Box::new(move |dispatcher| { + #run_test_body + }), + ) + } + } +} + +#[derive(Default)] +struct ParsedArgs { + cx_vars: TokenStream, + cx_teardowns: TokenStream, + proptest_args: TokenStream, + errors: TokenStream, + + // exprs passed at the call-site + inner_fn_args: TokenStream, + // args in the declaration + inner_fn_decl_args: TokenStream, +} + +fn parse_args(args: Punctuated, test_name: &Ident) -> ParsedArgs { + let mut parsed = ParsedArgs::default(); + let mut args = args.into_iter().collect(); + + remove_cxs(&mut parsed, &mut args, test_name); + remove_std_rng(&mut parsed, &mut args); + remove_background_executor(&mut parsed, &mut args); + + // all remaining args forwarded to proptest's macro + parsed.proptest_args = quote!( #(#args),* ); + + parsed +} + +fn remove_cxs(parsed: &mut ParsedArgs, args: &mut Vec, test_name: &Ident) { + let mut ix = 0; + args.retain_mut(|arg| { + if !is_test_cx(arg) { + return true; + } + + let cx_varname = format_ident!("cx_{ix}"); + ix += 1; + + parsed.cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#test_name)), + ); + )); + parsed.cx_teardowns.extend(quote!( + dispatcher.run_until_parked(); + #cx_varname.executor().forbid_parking(); + #cx_varname.quit(); + dispatcher.run_until_parked(); + )); + + parsed.inner_fn_decl_args.extend(quote!(#arg,)); + parsed.inner_fn_args.extend(quote!(&mut #cx_varname,)); + + false + }); +} + +fn remove_std_rng(parsed: &mut ParsedArgs, args: &mut Vec) { + args.retain_mut(|arg| { + if !is_std_rng(arg) { + return true; + } + + parsed.errors.extend(quote_spanned! { arg.span() => + compile_error!("`StdRng` is not allowed in a property test. Consider implementing `Arbitrary`, or implementing a custom `Strategy`. https://altsysrq.github.io/proptest-book/proptest/tutorial/strategy-basics.html"); + }); + + false + }); +} + +fn remove_background_executor(parsed: &mut ParsedArgs, args: &mut Vec) { + args.retain_mut(|arg| { + if !is_background_executor(arg) { + return true; + } + + parsed.inner_fn_decl_args.extend(quote!(#arg,)); + parsed + .inner_fn_args + .extend(quote!(gpui::BackgroundExecutor::new(std::sync::Arc::new( + dispatcher.clone() + )),)); + + false + }); +} + +// Matches `&TestAppContext` or `&foo::bar::baz::TestAppContext` +fn is_test_cx(arg: &FnArg) -> bool { + let FnArg::Typed(arg) = arg else { + return false; + }; + + let Type::Reference(ty) = &*arg.ty else { + return false; + }; + + let Type::Path(ty) = &*ty.elem else { + return false; + }; + + ty.path + .segments + .last() + .is_some_and(|seg| seg.ident == "TestAppContext") +} + +fn is_std_rng(arg: &FnArg) -> bool { + is_path_with_last_segment(arg, "StdRng") +} + +fn is_background_executor(arg: &FnArg) -> bool { + is_path_with_last_segment(arg, "BackgroundExecutor") +} + +fn is_path_with_last_segment(arg: &FnArg, last_segment: &str) -> bool { + let FnArg::Typed(arg) = arg else { + return false; + }; + + let Type::Path(ty) = &*arg.ty else { + return false; + }; + + ty.path + .segments + .last() + .is_some_and(|seg| seg.ident == last_segment) +} diff --git a/tooling/macros/src/register_action.rs b/tooling/macros/src/register_action.rs new file mode 100644 index 0000000000..ca36ce3186 --- /dev/null +++ b/tooling/macros/src/register_action.rs @@ -0,0 +1,47 @@ +use proc_macro::TokenStream; +use proc_macro2::{Ident, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::parse_macro_input; + +pub(crate) fn register_action(ident: TokenStream) -> TokenStream { + let name = parse_macro_input!(ident as Ident); + let registration = generate_register_action(&name); + + TokenStream::from(quote! { + #registration + }) +} + +pub(crate) fn generate_register_action(type_name: &Ident) -> TokenStream2 { + let action_builder_fn_name = format_ident!( + "__gpui_actions_builder_{}", + type_name.to_string().to_lowercase() + ); + + quote! { + impl #type_name { + /// This is an auto generated function, do not use. + #[automatically_derived] + #[doc(hidden)] + fn __autogenerated() { + /// This is an auto generated function, do not use. + #[doc(hidden)] + fn #action_builder_fn_name() -> gpui::MacroActionData { + gpui::MacroActionData { + name: <#type_name as gpui::Action>::name_for_type(), + type_id: ::std::any::TypeId::of::<#type_name>(), + build: <#type_name as gpui::Action>::build, + json_schema: <#type_name as gpui::Action>::action_json_schema, + deprecated_aliases: <#type_name as gpui::Action>::deprecated_aliases(), + deprecation_message: <#type_name as gpui::Action>::deprecation_message(), + documentation: <#type_name as gpui::Action>::documentation(), + } + } + + gpui::private::inventory::submit! { + gpui::MacroActionBuilder(#action_builder_fn_name) + } + } + } + } +} diff --git a/tooling/macros/src/styles.rs b/tooling/macros/src/styles.rs new file mode 100644 index 0000000000..133c9fdebe --- /dev/null +++ b/tooling/macros/src/styles.rs @@ -0,0 +1,1472 @@ +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{ + Token, Visibility, braced, + parse::{Parse, ParseStream, Result}, + parse_macro_input, +}; + +#[derive(Debug)] +struct StyleableMacroInput { + method_visibility: Visibility, +} + +impl Parse for StyleableMacroInput { + fn parse(input: ParseStream) -> Result { + if !input.peek(syn::token::Brace) { + return Ok(Self { + method_visibility: Visibility::Inherited, + }); + } + + let content; + braced!(content in input); + + let mut method_visibility = None; + + let ident: syn::Ident = content.parse()?; + if ident == "visibility" { + let _colon: Token![:] = content.parse()?; + method_visibility = Some(content.parse()?); + } + + Ok(Self { + method_visibility: method_visibility.unwrap_or(Visibility::Inherited), + }) + } +} + +pub fn style_helpers(input: TokenStream) -> TokenStream { + let _ = parse_macro_input!(input as StyleableMacroInput); + let methods = generate_methods(); + let output = quote! { + #(#methods)* + }; + + output.into() +} + +pub fn visibility_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let visibility = input.method_visibility; + let output = quote! { + /// Sets the visibility of the element to `visible`. + /// [Docs](https://tailwindcss.com/docs/visibility) + #visibility fn visible(mut self) -> Self { + self.style().visibility = Some(gpui::Visibility::Visible); + self + } + + /// Sets the visibility of the element to `hidden`. + /// [Docs](https://tailwindcss.com/docs/visibility) + #visibility fn invisible(mut self) -> Self { + self.style().visibility = Some(gpui::Visibility::Hidden); + self + } + }; + + output.into() +} + +pub fn margin_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let methods = generate_box_style_methods( + margin_box_style_prefixes(), + box_style_suffixes(), + input.method_visibility, + ); + let output = quote! { + #(#methods)* + }; + + output.into() +} + +pub fn padding_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let methods = generate_box_style_methods( + padding_box_style_prefixes(), + box_style_suffixes(), + input.method_visibility, + ); + let output = quote! { + #(#methods)* + }; + + output.into() +} + +pub fn position_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let visibility = input.method_visibility; + let methods = generate_box_style_methods( + position_box_style_prefixes(), + box_style_suffixes(), + visibility.clone(), + ); + let output = quote! { + /// Sets the position of the element to `relative`. + /// [Docs](https://tailwindcss.com/docs/position) + #visibility fn relative(mut self) -> Self { + self.style().position = Some(gpui::Position::Relative); + self + } + + /// Sets the position of the element to `absolute`. + /// [Docs](https://tailwindcss.com/docs/position) + #visibility fn absolute(mut self) -> Self { + self.style().position = Some(gpui::Position::Absolute); + self + } + + #(#methods)* + }; + + output.into() +} + +pub fn overflow_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let visibility = input.method_visibility; + let output = quote! { + /// Sets the behavior of content that overflows the container to be hidden. + /// [Docs](https://tailwindcss.com/docs/overflow#hiding-content-that-overflows) + #visibility fn overflow_hidden(mut self) -> Self { + self.style().overflow.x = Some(gpui::Overflow::Hidden); + self.style().overflow.y = Some(gpui::Overflow::Hidden); + self + } + + /// Sets the behavior of content that overflows the container on the X axis to be hidden. + /// [Docs](https://tailwindcss.com/docs/overflow#hiding-content-that-overflows) + #visibility fn overflow_x_hidden(mut self) -> Self { + self.style().overflow.x = Some(gpui::Overflow::Hidden); + self + } + + /// Sets the behavior of content that overflows the container on the Y axis to be hidden. + /// [Docs](https://tailwindcss.com/docs/overflow#hiding-content-that-overflows) + #visibility fn overflow_y_hidden(mut self) -> Self { + self.style().overflow.y = Some(gpui::Overflow::Hidden); + self + } + }; + + output.into() +} + +pub fn cursor_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let visibility = input.method_visibility; + let output = quote! { + /// Set the cursor style when hovering over this element + #visibility fn cursor(mut self, cursor: CursorStyle) -> Self { + self.style().mouse_cursor = Some(cursor); + self + } + + /// Sets the cursor style when hovering an element to `default`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_default(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::Arrow); + self + } + + /// Sets the cursor style when hovering an element to `pointer`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_pointer(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::PointingHand); + self + } + + /// Sets cursor style when hovering over an element to `text`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_text(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::IBeam); + self + } + + /// Sets cursor style when hovering over an element to `move`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_move(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ClosedHand); + self + } + + /// Sets cursor style when hovering over an element to `not-allowed`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_not_allowed(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::OperationNotAllowed); + self + } + + /// Sets cursor style when hovering over an element to `context-menu`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_context_menu(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ContextualMenu); + self + } + + /// Sets cursor style when hovering over an element to `crosshair`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_crosshair(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::Crosshair); + self + } + + /// Sets cursor style when hovering over an element to `vertical-text`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_vertical_text(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::IBeamCursorForVerticalLayout); + self + } + + /// Sets cursor style when hovering over an element to `alias`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_alias(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::DragLink); + self + } + + /// Sets cursor style when hovering over an element to `copy`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_copy(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::DragCopy); + self + } + + /// Sets cursor style when hovering over an element to `no-drop`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_no_drop(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::OperationNotAllowed); + self + } + + /// Sets cursor style when hovering over an element to `grab`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_grab(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::OpenHand); + self + } + + /// Sets cursor style when hovering over an element to `grabbing`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_grabbing(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ClosedHand); + self + } + + /// Sets cursor style when hovering over an element to `ew-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_ew_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeLeftRight); + self + } + + /// Sets cursor style when hovering over an element to `ns-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_ns_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeUpDown); + self + } + + /// Sets cursor style when hovering over an element to `nesw-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_nesw_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeUpRightDownLeft); + self + } + + /// Sets cursor style when hovering over an element to `nwse-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_nwse_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeUpLeftDownRight); + self + } + + /// Sets cursor style when hovering over an element to `col-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_col_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeColumn); + self + } + + /// Sets cursor style when hovering over an element to `row-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_row_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeRow); + self + } + + /// Sets cursor style when hovering over an element to `n-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_n_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeUp); + self + } + + /// Sets cursor style when hovering over an element to `e-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_e_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeRight); + self + } + + /// Sets cursor style when hovering over an element to `s-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_s_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeDown); + self + } + + /// Sets cursor style when hovering over an element to `w-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + #visibility fn cursor_w_resize(mut self) -> Self { + self.style().mouse_cursor = Some(gpui::CursorStyle::ResizeLeft); + self + } + + /// 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 { + self.style().mouse_cursor = Some(gpui::CursorStyle::None); + self + } + }; + + output.into() +} + +pub fn border_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let visibility = input.method_visibility; + + let mut methods = Vec::new(); + + for border_style_prefix in border_prefixes() { + methods.push(generate_custom_value_setter( + visibility.clone(), + border_style_prefix.prefix, + quote! { AbsoluteLength }, + &border_style_prefix.fields, + border_style_prefix.doc_string_prefix, + )); + + for border_style_suffix in border_suffixes() { + methods.push(generate_predefined_setter( + visibility.clone(), + border_style_prefix.prefix, + border_style_suffix.suffix, + &border_style_prefix.fields, + &border_style_suffix.width_tokens, + false, + &format!( + "{prefix}\n\n{suffix}", + prefix = border_style_prefix.doc_string_prefix, + suffix = border_style_suffix.doc_string_suffix, + ), + )); + } + } + + let output = quote! { + /// Sets the border color of the element. + #visibility fn border_color(mut self, border_color: C) -> Self + where + C: Into, + Self: Sized, + { + self.style().border_color = Some(border_color.into()); + self + } + + #(#methods)* + }; + + output.into() +} + +pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as StyleableMacroInput); + let visibility = input.method_visibility; + let output = quote! { + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow(mut self, shadows: std::vec::Vec) -> Self { + self.style().box_shadow = Some(shadows); + self + } + + /// Clears the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_none(mut self) -> Self { + self.style().box_shadow = Some(Default::default()); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_2xs(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![BoxShadow { + color: hsla(0., 0., 0., 0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(0.), + spread_radius: px(0.), + }]); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_xs(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![BoxShadow { + color: hsla(0., 0., 0., 0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + }]); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_sm(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(1.)), + blur_radius: px(3.), + spread_radius: px(0.), + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(-1.), + } + ]); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_md(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(4.)), + blur_radius: px(6.), + spread_radius: px(-1.), + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(2.)), + blur_radius: px(4.), + spread_radius: px(-2.), + } + ]); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_lg(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(10.)), + blur_radius: px(15.), + spread_radius: px(-3.), + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(4.)), + blur_radius: px(6.), + spread_radius: px(-4.), + } + ]); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_xl(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(20.)), + blur_radius: px(25.), + spread_radius: px(-5.), + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(8.)), + blur_radius: px(10.), + spread_radius: px(-6.), + } + ]); + self + } + + /// Sets the box shadow of the element. + /// [Docs](https://tailwindcss.com/docs/box-shadow) + #visibility fn shadow_2xl(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![BoxShadow { + color: hsla(0., 0., 0., 0.25), + offset: point(px(0.), px(25.)), + blur_radius: px(50.), + spread_radius: px(-12.), + }]); + self + } + }; + + output.into() +} + +struct BoxStylePrefix { + prefix: &'static str, + auto_allowed: bool, + fields: Vec, + doc_string_prefix: &'static str, +} + +struct BoxStyleSuffix { + suffix: &'static str, + length_tokens: TokenStream2, + doc_string_suffix: &'static str, +} + +struct CornerStylePrefix { + prefix: &'static str, + fields: Vec, + doc_string_prefix: &'static str, +} + +struct CornerStyleSuffix { + suffix: &'static str, + radius_tokens: TokenStream2, + doc_string_suffix: &'static str, +} + +struct BorderStylePrefix { + prefix: &'static str, + fields: Vec, + doc_string_prefix: &'static str, +} + +struct BorderStyleSuffix { + suffix: &'static str, + width_tokens: TokenStream2, + doc_string_suffix: &'static str, +} + +fn generate_box_style_methods( + prefixes: Vec, + suffixes: Vec, + visibility: Visibility, +) -> Vec { + let mut methods = Vec::new(); + + for box_style_prefix in prefixes { + methods.push(generate_custom_value_setter( + visibility.clone(), + box_style_prefix.prefix, + if box_style_prefix.auto_allowed { + quote! { Length } + } else { + quote! { DefiniteLength } + }, + &box_style_prefix.fields, + box_style_prefix.doc_string_prefix, + )); + + for box_style_suffix in &suffixes { + if box_style_suffix.suffix != "auto" || box_style_prefix.auto_allowed { + methods.push(generate_predefined_setter( + visibility.clone(), + box_style_prefix.prefix, + box_style_suffix.suffix, + &box_style_prefix.fields, + &box_style_suffix.length_tokens, + false, + &format!( + "{prefix}\n\n{suffix}", + prefix = box_style_prefix.doc_string_prefix, + suffix = box_style_suffix.doc_string_suffix, + ), + )); + } + + if box_style_suffix.suffix != "auto" { + methods.push(generate_predefined_setter( + visibility.clone(), + box_style_prefix.prefix, + box_style_suffix.suffix, + &box_style_prefix.fields, + &box_style_suffix.length_tokens, + true, + &format!( + "{prefix}\n\n{suffix}", + prefix = box_style_prefix.doc_string_prefix, + suffix = box_style_suffix.doc_string_suffix, + ), + )); + } + } + } + + methods +} + +fn generate_methods() -> Vec { + let visibility = Visibility::Inherited; + let mut methods = + generate_box_style_methods(box_prefixes(), box_style_suffixes(), visibility.clone()); + + for corner_style_prefix in corner_prefixes() { + methods.push(generate_custom_value_setter( + visibility.clone(), + corner_style_prefix.prefix, + quote! { AbsoluteLength }, + &corner_style_prefix.fields, + corner_style_prefix.doc_string_prefix, + )); + + for corner_style_suffix in corner_suffixes() { + methods.push(generate_predefined_setter( + visibility.clone(), + corner_style_prefix.prefix, + corner_style_suffix.suffix, + &corner_style_prefix.fields, + &corner_style_suffix.radius_tokens, + false, + &format!( + "{prefix}\n\n{suffix}", + prefix = corner_style_prefix.doc_string_prefix, + suffix = corner_style_suffix.doc_string_suffix, + ), + )); + } + } + + methods +} + +fn generate_predefined_setter( + visibility: Visibility, + name: &'static str, + length: &'static str, + fields: &[TokenStream2], + length_tokens: &TokenStream2, + negate: bool, + doc_string: &str, +) -> TokenStream2 { + let (negation_qualifier, negation_token) = if negate { + ("_neg", quote! { - }) + } else { + ("", quote! {}) + }; + + let method_name = if length.is_empty() { + format_ident!("{name}{negation_qualifier}") + } else { + format_ident!("{name}{negation_qualifier}_{length}") + }; + + let field_assignments = fields + .iter() + .map(|field_tokens| { + quote! { + style.#field_tokens = Some((#negation_token gpui::#length_tokens).into()); + } + }) + .collect::>(); + + let method = quote! { + #[doc = #doc_string] + #visibility fn #method_name(mut self) -> Self { + let style = self.style(); + #(#field_assignments)* + self + } + }; + + method +} + +fn generate_custom_value_setter( + visibility: Visibility, + prefix: &str, + length_type: TokenStream2, + fields: &[TokenStream2], + doc_string: &str, +) -> TokenStream2 { + let method_name = format_ident!("{}", prefix); + + let mut iter = fields.iter(); + let last = iter.next_back().unwrap(); + let field_assignments = iter + .map(|field_tokens| { + quote! { + style.#field_tokens = Some(length.clone().into()); + } + }) + .chain(std::iter::once(quote! { + style.#last = Some(length.into()); + })) + .collect::>(); + + let method = quote! { + #[doc = #doc_string] + #visibility fn #method_name(mut self, length: impl std::clone::Clone + Into) -> Self { + let style = self.style(); + #(#field_assignments)* + self + } + }; + + method +} + +fn margin_box_style_prefixes() -> Vec { + vec![ + BoxStylePrefix { + prefix: "m", + auto_allowed: true, + fields: vec![ + quote! { margin.top }, + quote! { margin.bottom }, + quote! { margin.left }, + quote! { margin.right }, + ], + doc_string_prefix: "Sets the margin of the element. [Docs](https://tailwindcss.com/docs/margin)", + }, + BoxStylePrefix { + prefix: "mt", + auto_allowed: true, + fields: vec![quote! { margin.top }], + doc_string_prefix: "Sets the top margin of the element. [Docs](https://tailwindcss.com/docs/margin#add-margin-to-a-single-side)", + }, + BoxStylePrefix { + prefix: "mb", + auto_allowed: true, + fields: vec![quote! { margin.bottom }], + doc_string_prefix: "Sets the bottom margin of the element. [Docs](https://tailwindcss.com/docs/margin#add-margin-to-a-single-side)", + }, + BoxStylePrefix { + prefix: "my", + auto_allowed: true, + fields: vec![quote! { margin.top }, quote! { margin.bottom }], + doc_string_prefix: "Sets the vertical margin of the element. [Docs](https://tailwindcss.com/docs/margin#add-vertical-margin)", + }, + BoxStylePrefix { + prefix: "mx", + auto_allowed: true, + fields: vec![quote! { margin.left }, quote! { margin.right }], + doc_string_prefix: "Sets the horizontal margin of the element. [Docs](https://tailwindcss.com/docs/margin#add-horizontal-margin)", + }, + BoxStylePrefix { + prefix: "ml", + auto_allowed: true, + fields: vec![quote! { margin.left }], + doc_string_prefix: "Sets the left margin of the element. [Docs](https://tailwindcss.com/docs/margin#add-margin-to-a-single-side)", + }, + BoxStylePrefix { + prefix: "mr", + auto_allowed: true, + fields: vec![quote! { margin.right }], + doc_string_prefix: "Sets the right margin of the element. [Docs](https://tailwindcss.com/docs/margin#add-margin-to-a-single-side)", + }, + ] +} + +fn padding_box_style_prefixes() -> Vec { + vec![ + BoxStylePrefix { + prefix: "p", + auto_allowed: false, + fields: vec![ + quote! { padding.top }, + quote! { padding.bottom }, + quote! { padding.left }, + quote! { padding.right }, + ], + doc_string_prefix: "Sets the padding of the element. [Docs](https://tailwindcss.com/docs/padding)", + }, + BoxStylePrefix { + prefix: "pt", + auto_allowed: false, + fields: vec![quote! { padding.top }], + doc_string_prefix: "Sets the top padding of the element. [Docs](https://tailwindcss.com/docs/padding#add-padding-to-a-single-side)", + }, + BoxStylePrefix { + prefix: "pb", + auto_allowed: false, + fields: vec![quote! { padding.bottom }], + doc_string_prefix: "Sets the bottom padding of the element. [Docs](https://tailwindcss.com/docs/padding#add-padding-to-a-single-side)", + }, + BoxStylePrefix { + prefix: "px", + auto_allowed: false, + fields: vec![quote! { padding.left }, quote! { padding.right }], + doc_string_prefix: "Sets the horizontal padding of the element. [Docs](https://tailwindcss.com/docs/padding#add-horizontal-padding)", + }, + BoxStylePrefix { + prefix: "py", + auto_allowed: false, + fields: vec![quote! { padding.top }, quote! { padding.bottom }], + doc_string_prefix: "Sets the vertical padding of the element. [Docs](https://tailwindcss.com/docs/padding#add-vertical-padding)", + }, + BoxStylePrefix { + prefix: "pl", + auto_allowed: false, + fields: vec![quote! { padding.left }], + doc_string_prefix: "Sets the left padding of the element. [Docs](https://tailwindcss.com/docs/padding#add-padding-to-a-single-side)", + }, + BoxStylePrefix { + prefix: "pr", + auto_allowed: false, + fields: vec![quote! { padding.right }], + doc_string_prefix: "Sets the right padding of the element. [Docs](https://tailwindcss.com/docs/padding#add-padding-to-a-single-side)", + }, + ] +} + +fn position_box_style_prefixes() -> Vec { + vec![ + BoxStylePrefix { + prefix: "inset", + auto_allowed: true, + fields: vec![ + quote! { inset.top }, + quote! { inset.right }, + quote! { inset.bottom }, + quote! { inset.left }, + ], + doc_string_prefix: "Sets the top, right, bottom, and left values of a positioned element. [Docs](https://tailwindcss.com/docs/top-right-bottom-left)", + }, + BoxStylePrefix { + prefix: "top", + auto_allowed: true, + fields: vec![quote! { inset.top }], + doc_string_prefix: "Sets the top value of a positioned element. [Docs](https://tailwindcss.com/docs/top-right-bottom-left)", + }, + BoxStylePrefix { + prefix: "bottom", + auto_allowed: true, + fields: vec![quote! { inset.bottom }], + doc_string_prefix: "Sets the bottom value of a positioned element. [Docs](https://tailwindcss.com/docs/top-right-bottom-left)", + }, + BoxStylePrefix { + prefix: "left", + auto_allowed: true, + fields: vec![quote! { inset.left }], + doc_string_prefix: "Sets the left value of a positioned element. [Docs](https://tailwindcss.com/docs/top-right-bottom-left)", + }, + BoxStylePrefix { + prefix: "right", + auto_allowed: true, + fields: vec![quote! { inset.right }], + doc_string_prefix: "Sets the right value of a positioned element. [Docs](https://tailwindcss.com/docs/top-right-bottom-left)", + }, + ] +} + +fn box_prefixes() -> Vec { + vec![ + BoxStylePrefix { + prefix: "w", + auto_allowed: true, + fields: vec![quote! { size.width }], + doc_string_prefix: "Sets the width of the element. [Docs](https://tailwindcss.com/docs/width)", + }, + BoxStylePrefix { + prefix: "h", + auto_allowed: true, + fields: vec![quote! { size.height }], + doc_string_prefix: "Sets the height of the element. [Docs](https://tailwindcss.com/docs/height)", + }, + BoxStylePrefix { + prefix: "size", + auto_allowed: true, + fields: vec![quote! {size.width}, quote! {size.height}], + doc_string_prefix: "Sets the width and height of the element.", + }, + BoxStylePrefix { + prefix: "min_size", + auto_allowed: true, + fields: vec![quote! {min_size.width}, quote! {min_size.height}], + doc_string_prefix: "Sets the minimum width and height of the element.", + }, + BoxStylePrefix { + prefix: "min_w", + auto_allowed: true, + fields: vec![quote! { min_size.width }], + doc_string_prefix: "Sets the minimum width of the element. [Docs](https://tailwindcss.com/docs/min-width)", + }, + // TODO: These don't use the same size ramp as the others + // see https://tailwindcss.com/docs/max-width + BoxStylePrefix { + prefix: "min_h", + auto_allowed: true, + fields: vec![quote! { min_size.height }], + doc_string_prefix: "Sets the minimum height of the element. [Docs](https://tailwindcss.com/docs/min-height)", + }, + BoxStylePrefix { + prefix: "max_size", + auto_allowed: true, + fields: vec![quote! {max_size.width}, quote! {max_size.height}], + doc_string_prefix: "Sets the maximum width and height of the element.", + }, + // TODO: These don't use the same size ramp as the others + // see https://tailwindcss.com/docs/max-width + BoxStylePrefix { + prefix: "max_w", + auto_allowed: true, + fields: vec![quote! { max_size.width }], + doc_string_prefix: "Sets the maximum width of the element. [Docs](https://tailwindcss.com/docs/max-width)", + }, + // TODO: These don't use the same size ramp as the others + // see https://tailwindcss.com/docs/max-width + BoxStylePrefix { + prefix: "max_h", + auto_allowed: true, + fields: vec![quote! { max_size.height }], + doc_string_prefix: "Sets the maximum height of the element. [Docs](https://tailwindcss.com/docs/max-height)", + }, + BoxStylePrefix { + prefix: "gap", + auto_allowed: false, + fields: vec![quote! { gap.width }, quote! { gap.height }], + doc_string_prefix: "Sets the gap between rows and columns in flex layouts. [Docs](https://tailwindcss.com/docs/gap)", + }, + BoxStylePrefix { + prefix: "gap_x", + auto_allowed: false, + fields: vec![quote! { gap.width }], + doc_string_prefix: "Sets the gap between columns in flex layouts. [Docs](https://tailwindcss.com/docs/gap#changing-row-and-column-gaps-independently)", + }, + BoxStylePrefix { + prefix: "gap_y", + auto_allowed: false, + fields: vec![quote! { gap.height }], + doc_string_prefix: "Sets the gap between rows in flex layouts. [Docs](https://tailwindcss.com/docs/gap#changing-row-and-column-gaps-independently)", + }, + ] +} + +fn box_style_suffixes() -> Vec { + vec![ + BoxStyleSuffix { + suffix: "0", + length_tokens: quote! { px(0.) }, + doc_string_suffix: "0px", + }, + BoxStyleSuffix { + suffix: "0p5", + length_tokens: quote! { rems(0.125) }, + doc_string_suffix: "2px (0.125rem)", + }, + BoxStyleSuffix { + suffix: "1", + length_tokens: quote! { rems(0.25) }, + doc_string_suffix: "4px (0.25rem)", + }, + BoxStyleSuffix { + suffix: "1p5", + length_tokens: quote! { rems(0.375) }, + doc_string_suffix: "6px (0.375rem)", + }, + BoxStyleSuffix { + suffix: "2", + length_tokens: quote! { rems(0.5) }, + doc_string_suffix: "8px (0.5rem)", + }, + BoxStyleSuffix { + suffix: "2p5", + length_tokens: quote! { rems(0.625) }, + doc_string_suffix: "10px (0.625rem)", + }, + BoxStyleSuffix { + suffix: "3", + length_tokens: quote! { rems(0.75) }, + doc_string_suffix: "12px (0.75rem)", + }, + BoxStyleSuffix { + suffix: "3p5", + length_tokens: quote! { rems(0.875) }, + doc_string_suffix: "14px (0.875rem)", + }, + BoxStyleSuffix { + suffix: "4", + length_tokens: quote! { rems(1.) }, + doc_string_suffix: "16px (1rem)", + }, + BoxStyleSuffix { + suffix: "5", + length_tokens: quote! { rems(1.25) }, + doc_string_suffix: "20px (1.25rem)", + }, + BoxStyleSuffix { + suffix: "6", + length_tokens: quote! { rems(1.5) }, + doc_string_suffix: "24px (1.5rem)", + }, + BoxStyleSuffix { + suffix: "7", + length_tokens: quote! { rems(1.75) }, + doc_string_suffix: "28px (1.75rem)", + }, + BoxStyleSuffix { + suffix: "8", + length_tokens: quote! { rems(2.0) }, + doc_string_suffix: "32px (2rem)", + }, + BoxStyleSuffix { + suffix: "9", + length_tokens: quote! { rems(2.25) }, + doc_string_suffix: "36px (2.25rem)", + }, + BoxStyleSuffix { + suffix: "10", + length_tokens: quote! { rems(2.5) }, + doc_string_suffix: "40px (2.5rem)", + }, + BoxStyleSuffix { + suffix: "11", + length_tokens: quote! { rems(2.75) }, + doc_string_suffix: "44px (2.75rem)", + }, + BoxStyleSuffix { + suffix: "12", + length_tokens: quote! { rems(3.) }, + doc_string_suffix: "48px (3rem)", + }, + BoxStyleSuffix { + suffix: "16", + length_tokens: quote! { rems(4.) }, + doc_string_suffix: "64px (4rem)", + }, + BoxStyleSuffix { + suffix: "20", + length_tokens: quote! { rems(5.) }, + doc_string_suffix: "80px (5rem)", + }, + BoxStyleSuffix { + suffix: "24", + length_tokens: quote! { rems(6.) }, + doc_string_suffix: "96px (6rem)", + }, + BoxStyleSuffix { + suffix: "32", + length_tokens: quote! { rems(8.) }, + doc_string_suffix: "128px (8rem)", + }, + BoxStyleSuffix { + suffix: "40", + length_tokens: quote! { rems(10.) }, + doc_string_suffix: "160px (10rem)", + }, + BoxStyleSuffix { + suffix: "48", + length_tokens: quote! { rems(12.) }, + doc_string_suffix: "192px (12rem)", + }, + BoxStyleSuffix { + suffix: "56", + length_tokens: quote! { rems(14.) }, + doc_string_suffix: "224px (14rem)", + }, + BoxStyleSuffix { + suffix: "64", + length_tokens: quote! { rems(16.) }, + doc_string_suffix: "256px (16rem)", + }, + BoxStyleSuffix { + suffix: "72", + length_tokens: quote! { rems(18.) }, + doc_string_suffix: "288px (18rem)", + }, + BoxStyleSuffix { + suffix: "80", + length_tokens: quote! { rems(20.) }, + doc_string_suffix: "320px (20rem)", + }, + BoxStyleSuffix { + suffix: "96", + length_tokens: quote! { rems(24.) }, + doc_string_suffix: "384px (24rem)", + }, + BoxStyleSuffix { + suffix: "112", + length_tokens: quote! { rems(28.) }, + doc_string_suffix: "448px (28rem)", + }, + BoxStyleSuffix { + suffix: "128", + length_tokens: quote! { rems(32.) }, + doc_string_suffix: "512px (32rem)", + }, + BoxStyleSuffix { + suffix: "auto", + length_tokens: quote! { auto() }, + doc_string_suffix: "Auto", + }, + BoxStyleSuffix { + suffix: "px", + length_tokens: quote! { px(1.) }, + doc_string_suffix: "1px", + }, + BoxStyleSuffix { + suffix: "full", + length_tokens: quote! { relative(1.) }, + doc_string_suffix: "100%", + }, + BoxStyleSuffix { + suffix: "1_2", + length_tokens: quote! { relative(0.5) }, + doc_string_suffix: "50% (1/2)", + }, + BoxStyleSuffix { + suffix: "1_3", + length_tokens: quote! { relative(1./3.) }, + doc_string_suffix: "33% (1/3)", + }, + BoxStyleSuffix { + suffix: "2_3", + length_tokens: quote! { relative(2./3.) }, + doc_string_suffix: "66% (2/3)", + }, + BoxStyleSuffix { + suffix: "1_4", + length_tokens: quote! { relative(0.25) }, + doc_string_suffix: "25% (1/4)", + }, + BoxStyleSuffix { + suffix: "2_4", + length_tokens: quote! { relative(0.5) }, + doc_string_suffix: "50% (2/4)", + }, + BoxStyleSuffix { + suffix: "3_4", + length_tokens: quote! { relative(0.75) }, + doc_string_suffix: "75% (3/4)", + }, + BoxStyleSuffix { + suffix: "1_5", + length_tokens: quote! { relative(0.2) }, + doc_string_suffix: "20% (1/5)", + }, + BoxStyleSuffix { + suffix: "2_5", + length_tokens: quote! { relative(0.4) }, + doc_string_suffix: "40% (2/5)", + }, + BoxStyleSuffix { + suffix: "3_5", + length_tokens: quote! { relative(0.6) }, + doc_string_suffix: "60% (3/5)", + }, + BoxStyleSuffix { + suffix: "4_5", + length_tokens: quote! { relative(0.8) }, + doc_string_suffix: "80% (4/5)", + }, + BoxStyleSuffix { + suffix: "1_6", + length_tokens: quote! { relative(1./6.) }, + doc_string_suffix: "16% (1/6)", + }, + BoxStyleSuffix { + suffix: "5_6", + length_tokens: quote! { relative(5./6.) }, + doc_string_suffix: "80% (5/6)", + }, + BoxStyleSuffix { + suffix: "1_12", + length_tokens: quote! { relative(1./12.) }, + doc_string_suffix: "8% (1/12)", + }, + ] +} + +fn corner_prefixes() -> Vec { + vec![ + CornerStylePrefix { + prefix: "rounded", + fields: vec![ + quote! { corner_radii.top_left }, + quote! { corner_radii.top_right }, + quote! { corner_radii.bottom_right }, + quote! { corner_radii.bottom_left }, + ], + doc_string_prefix: "Sets the border radius of the element. [Docs](https://tailwindcss.com/docs/border-radius)", + }, + CornerStylePrefix { + prefix: "rounded_t", + fields: vec![ + quote! { corner_radii.top_left }, + quote! { corner_radii.top_right }, + ], + doc_string_prefix: "Sets the border radius of the top side of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-sides-separately)", + }, + CornerStylePrefix { + prefix: "rounded_b", + fields: vec![ + quote! { corner_radii.bottom_left }, + quote! { corner_radii.bottom_right }, + ], + doc_string_prefix: "Sets the border radius of the bottom side of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-sides-separately)", + }, + CornerStylePrefix { + prefix: "rounded_r", + fields: vec![ + quote! { corner_radii.top_right }, + quote! { corner_radii.bottom_right }, + ], + doc_string_prefix: "Sets the border radius of the right side of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-sides-separately)", + }, + CornerStylePrefix { + prefix: "rounded_l", + fields: vec![ + quote! { corner_radii.top_left }, + quote! { corner_radii.bottom_left }, + ], + doc_string_prefix: "Sets the border radius of the left side of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-sides-separately)", + }, + CornerStylePrefix { + prefix: "rounded_tl", + fields: vec![quote! { corner_radii.top_left }], + doc_string_prefix: "Sets the border radius of the top left corner of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-corners-separately)", + }, + CornerStylePrefix { + prefix: "rounded_tr", + fields: vec![quote! { corner_radii.top_right }], + doc_string_prefix: "Sets the border radius of the top right corner of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-corners-separately)", + }, + CornerStylePrefix { + prefix: "rounded_bl", + fields: vec![quote! { corner_radii.bottom_left }], + doc_string_prefix: "Sets the border radius of the bottom left corner of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-corners-separately)", + }, + CornerStylePrefix { + prefix: "rounded_br", + fields: vec![quote! { corner_radii.bottom_right }], + doc_string_prefix: "Sets the border radius of the bottom right corner of the element. [Docs](https://tailwindcss.com/docs/border-radius#rounding-corners-separately)", + }, + ] +} + +fn corner_suffixes() -> Vec { + vec![ + CornerStyleSuffix { + suffix: "none", + radius_tokens: quote! { px(0.) }, + doc_string_suffix: "0px", + }, + CornerStyleSuffix { + suffix: "xs", + radius_tokens: quote! { rems(0.125) }, + doc_string_suffix: "2px (0.125rem)", + }, + CornerStyleSuffix { + suffix: "sm", + radius_tokens: quote! { rems(0.25) }, + doc_string_suffix: "4px (0.25rem)", + }, + CornerStyleSuffix { + suffix: "md", + radius_tokens: quote! { rems(0.375) }, + doc_string_suffix: "6px (0.375rem)", + }, + CornerStyleSuffix { + suffix: "lg", + radius_tokens: quote! { rems(0.5) }, + doc_string_suffix: "8px (0.5rem)", + }, + CornerStyleSuffix { + suffix: "xl", + radius_tokens: quote! { rems(0.75) }, + doc_string_suffix: "12px (0.75rem)", + }, + CornerStyleSuffix { + suffix: "2xl", + radius_tokens: quote! { rems(1.) }, + doc_string_suffix: "16px (1rem)", + }, + CornerStyleSuffix { + suffix: "3xl", + radius_tokens: quote! { rems(1.5) }, + doc_string_suffix: "24px (1.5rem)", + }, + CornerStyleSuffix { + suffix: "full", + radius_tokens: quote! { px(9999.) }, + doc_string_suffix: "9999px", + }, + ] +} + +fn border_prefixes() -> Vec { + vec![ + BorderStylePrefix { + prefix: "border", + fields: vec![ + quote! { border_widths.top }, + quote! { border_widths.right }, + quote! { border_widths.bottom }, + quote! { border_widths.left }, + ], + doc_string_prefix: "Sets the border width of the element. [Docs](https://tailwindcss.com/docs/border-width)", + }, + BorderStylePrefix { + prefix: "border_t", + fields: vec![quote! { border_widths.top }], + doc_string_prefix: "Sets the border width of the top side of the element. [Docs](https://tailwindcss.com/docs/border-width#individual-sides)", + }, + BorderStylePrefix { + prefix: "border_b", + fields: vec![quote! { border_widths.bottom }], + doc_string_prefix: "Sets the border width of the bottom side of the element. [Docs](https://tailwindcss.com/docs/border-width#individual-sides)", + }, + BorderStylePrefix { + prefix: "border_r", + fields: vec![quote! { border_widths.right }], + doc_string_prefix: "Sets the border width of the right side of the element. [Docs](https://tailwindcss.com/docs/border-width#individual-sides)", + }, + BorderStylePrefix { + prefix: "border_l", + fields: vec![quote! { border_widths.left }], + doc_string_prefix: "Sets the border width of the left side of the element. [Docs](https://tailwindcss.com/docs/border-width#individual-sides)", + }, + BorderStylePrefix { + prefix: "border_x", + fields: vec![ + quote! { border_widths.left }, + quote! { border_widths.right }, + ], + doc_string_prefix: "Sets the border width of the vertical sides of the element. [Docs](https://tailwindcss.com/docs/border-width#horizontal-and-vertical-sides)", + }, + BorderStylePrefix { + prefix: "border_y", + fields: vec![ + quote! { border_widths.top }, + quote! { border_widths.bottom }, + ], + doc_string_prefix: "Sets the border width of the horizontal sides of the element. [Docs](https://tailwindcss.com/docs/border-width#horizontal-and-vertical-sides)", + }, + ] +} + +fn border_suffixes() -> Vec { + vec![ + BorderStyleSuffix { + suffix: "0", + width_tokens: quote! { px(0.)}, + doc_string_suffix: "0px", + }, + BorderStyleSuffix { + suffix: "1", + width_tokens: quote! { px(1.) }, + doc_string_suffix: "1px", + }, + BorderStyleSuffix { + suffix: "2", + width_tokens: quote! { px(2.) }, + doc_string_suffix: "2px", + }, + BorderStyleSuffix { + suffix: "3", + width_tokens: quote! { px(3.) }, + doc_string_suffix: "3px", + }, + BorderStyleSuffix { + suffix: "4", + width_tokens: quote! { px(4.) }, + doc_string_suffix: "4px", + }, + BorderStyleSuffix { + suffix: "5", + width_tokens: quote! { px(5.) }, + doc_string_suffix: "5px", + }, + BorderStyleSuffix { + suffix: "6", + width_tokens: quote! { px(6.) }, + doc_string_suffix: "6px", + }, + BorderStyleSuffix { + suffix: "7", + width_tokens: quote! { px(7.) }, + doc_string_suffix: "7px", + }, + BorderStyleSuffix { + suffix: "8", + width_tokens: quote! { px(8.) }, + doc_string_suffix: "8px", + }, + BorderStyleSuffix { + suffix: "9", + width_tokens: quote! { px(9.) }, + doc_string_suffix: "9px", + }, + BorderStyleSuffix { + suffix: "10", + width_tokens: quote! { px(10.) }, + doc_string_suffix: "10px", + }, + BorderStyleSuffix { + suffix: "11", + width_tokens: quote! { px(11.) }, + doc_string_suffix: "11px", + }, + BorderStyleSuffix { + suffix: "12", + width_tokens: quote! { px(12.) }, + doc_string_suffix: "12px", + }, + BorderStyleSuffix { + suffix: "16", + width_tokens: quote! { px(16.) }, + doc_string_suffix: "16px", + }, + BorderStyleSuffix { + suffix: "20", + width_tokens: quote! { px(20.) }, + doc_string_suffix: "20px", + }, + BorderStyleSuffix { + suffix: "24", + width_tokens: quote! { px(24.) }, + doc_string_suffix: "24px", + }, + BorderStyleSuffix { + suffix: "32", + width_tokens: quote! { px(32.) }, + doc_string_suffix: "32px", + }, + ] +} diff --git a/tooling/macros/src/test.rs b/tooling/macros/src/test.rs new file mode 100644 index 0000000000..087e01740d --- /dev/null +++ b/tooling/macros/src/test.rs @@ -0,0 +1,347 @@ +use proc_macro::TokenStream; +use proc_macro2::Ident; +use quote::{format_ident, quote}; +use std::mem; +use syn::{ + self, Expr, ExprLit, FnArg, ItemFn, Lit, Meta, MetaList, PathSegment, Token, Type, + parse::{Parse, ParseStream}, + parse_quote, + punctuated::Punctuated, + spanned::Spanned, +}; + +struct Args { + seeds: Vec, + max_retries: usize, + max_iterations: usize, + on_failure_fn_name: proc_macro2::TokenStream, +} + +impl Parse for Args { + fn parse(input: ParseStream) -> Result { + let mut seeds = Vec::::new(); + let mut max_retries = 0; + let mut max_iterations = 1; + let mut on_failure_fn_name = quote!(None); + + let metas = Punctuated::::parse_terminated(input)?; + + for meta in metas { + let ident = { + let meta_path = match &meta { + Meta::NameValue(meta) => &meta.path, + Meta::List(list) => &list.path, + Meta::Path(path) => { + return Err(syn::Error::new(path.span(), "invalid path argument")); + } + }; + let Some(ident) = meta_path.get_ident() else { + return Err(syn::Error::new(meta_path.span(), "unexpected path")); + }; + ident.to_string() + }; + + match (&meta, ident.as_str()) { + (Meta::NameValue(meta), "retries") => { + max_retries = parse_usize_from_expr(&meta.value)? + } + (Meta::NameValue(meta), "iterations") => { + max_iterations = parse_usize_from_expr(&meta.value)? + } + (Meta::NameValue(meta), "on_failure") => { + let Expr::Lit(ExprLit { + lit: Lit::Str(name), + .. + }) = &meta.value + else { + return Err(syn::Error::new( + meta.value.span(), + "on_failure argument must be a string", + )); + }; + let segments = name + .value() + .split("::") + .map(|part| PathSegment::from(Ident::new(part, name.span()))) + .collect(); + let path = syn::Path { + leading_colon: None, + segments, + }; + on_failure_fn_name = quote!(Some(#path)); + } + (Meta::NameValue(meta), "seed") => { + seeds = vec![parse_usize_from_expr(&meta.value)? as u64] + } + (Meta::List(list), "seeds") => seeds = parse_u64_array(list)?, + (Meta::Path(_), _) => { + return Err(syn::Error::new(meta.span(), "invalid path argument")); + } + (_, _) => { + return Err(syn::Error::new(meta.span(), "invalid argument name")); + } + } + } + + Ok(Args { + seeds, + max_retries, + max_iterations, + on_failure_fn_name, + }) + } +} + +pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { + let args = syn::parse_macro_input!(args as Args); + let mut inner_fn = match syn::parse::(function) { + Ok(f) => f, + Err(err) => return error_to_stream(err), + }; + + let inner_fn_attributes = mem::take(&mut inner_fn.attrs); + let inner_fn_name = format_ident!("__{}", inner_fn.sig.ident); + let outer_fn_name = mem::replace(&mut inner_fn.sig.ident, inner_fn_name.clone()); + + let result = generate_test_function( + args, + inner_fn, + inner_fn_attributes, + inner_fn_name, + outer_fn_name, + ); + match result { + Ok(tokens) => tokens, + Err(tokens) => tokens, + } +} + +fn generate_test_function( + args: Args, + inner_fn: ItemFn, + inner_fn_attributes: Vec, + inner_fn_name: Ident, + outer_fn_name: Ident, +) -> Result { + let seeds = &args.seeds; + let max_retries = args.max_retries; + let num_iterations = args.max_iterations; + let on_failure_fn_name = &args.on_failure_fn_name; + let seeds = quote!( #(#seeds),* ); + + let mut outer_fn: ItemFn = if inner_fn.sig.asyncness.is_some() { + // Pass to the test function the number of app contexts that it needs, + // based on its parameter list. + let mut cx_vars = proc_macro2::TokenStream::new(); + let mut cx_teardowns = proc_macro2::TokenStream::new(); + let mut inner_fn_args = proc_macro2::TokenStream::new(); + for (ix, arg) in inner_fn.sig.inputs.iter().enumerate() { + if let FnArg::Typed(arg) = arg { + if let Type::Path(ty) = &*arg.ty { + let last_segment = ty.path.segments.last(); + match last_segment.map(|s| s.ident.to_string()).as_deref() { + Some("StdRng") => { + inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(_seed),)); + continue; + } + Some("BackgroundExecutor") => { + inner_fn_args.extend(quote!(gpui::BackgroundExecutor::new( + std::sync::Arc::new(dispatcher.clone()), + ),)); + continue; + } + _ => {} + } + } else if let Type::Reference(ty) = &*arg.ty + && let Type::Path(ty) = &*ty.elem + { + let last_segment = ty.path.segments.last(); + if let Some("TestAppContext") = + last_segment.map(|s| s.ident.to_string()).as_deref() + { + let cx_varname = format_ident!("cx_{}", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#outer_fn_name)), + ); + let _entity_refcounts = #cx_varname.app.borrow().ref_counts_drop_handle(); + )); + cx_teardowns.extend(quote!( + #cx_varname.run_until_parked(); + #cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); }); + #cx_varname.run_until_parked(); + drop(#cx_varname); + )); + inner_fn_args.extend(quote!(&mut #cx_varname,)); + continue; + } + } + } + + return Err(error_with_message("invalid function signature", arg)); + } + + parse_quote! { + #[test] + fn #outer_fn_name() { + #inner_fn + + gpui::run_test( + #num_iterations, + &[#seeds], + #max_retries, + &mut |dispatcher, _seed| { + let exec = std::sync::Arc::new(dispatcher.clone()); + #cx_vars + gpui::ForegroundExecutor::new(exec.clone()).block_test(#inner_fn_name(#inner_fn_args)); + drop(exec); + #cx_teardowns + // Ideally we would only drop cancelled tasks, that way we could detect leaks due to task <-> entity + // cycles as cancelled tasks will be dropped properly once the runnable gets run again + // + // async-task does not give us the power to do this just yet though + dispatcher.drain_tasks(); + drop(dispatcher); + }, + #on_failure_fn_name + ); + } + } + } else { + // Pass to the test function the number of app contexts that it needs, + // based on its parameter list. + let mut cx_vars = proc_macro2::TokenStream::new(); + let mut cx_teardowns = proc_macro2::TokenStream::new(); + let mut inner_fn_args = proc_macro2::TokenStream::new(); + for (ix, arg) in inner_fn.sig.inputs.iter().enumerate() { + if let FnArg::Typed(arg) = arg { + if let Type::Path(ty) = &*arg.ty { + let last_segment = ty.path.segments.last(); + + if let Some("StdRng") = last_segment.map(|s| s.ident.to_string()).as_deref() { + inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(_seed),)); + continue; + } + } else if let Type::Reference(ty) = &*arg.ty + && let Type::Path(ty) = &*ty.elem + { + let last_segment = ty.path.segments.last(); + match last_segment.map(|s| s.ident.to_string()).as_deref() { + Some("App") => { + let cx_varname = format_ident!("cx_{}", ix); + let cx_varname_lock = format_ident!("cx_{}_lock", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#outer_fn_name)) + ); + let mut #cx_varname_lock = #cx_varname.app.borrow_mut(); + let _entity_refcounts = #cx_varname_lock.ref_counts_drop_handle(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname_lock,)); + cx_teardowns.extend(quote!( + drop(#cx_varname_lock); + #cx_varname.run_until_parked(); + #cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); }); + #cx_varname.run_until_parked(); + drop(#cx_varname); + )); + continue; + } + Some("TestAppContext") => { + let cx_varname = format_ident!("cx_{}", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#outer_fn_name)) + ); + let _entity_refcounts = #cx_varname.app.borrow().ref_counts_drop_handle(); + )); + cx_teardowns.extend(quote!( + #cx_varname.run_until_parked(); + #cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); }); + #cx_varname.run_until_parked(); + drop(#cx_varname); + )); + inner_fn_args.extend(quote!(&mut #cx_varname,)); + continue; + } + _ => {} + } + } + } + + return Err(error_with_message("invalid function signature", arg)); + } + + parse_quote! { + #[test] + fn #outer_fn_name() { + #inner_fn + + gpui::run_test( + #num_iterations, + &[#seeds], + #max_retries, + &mut |dispatcher, _seed| { + #cx_vars + #inner_fn_name(#inner_fn_args); + #cx_teardowns + // Ideally we would only drop cancelled tasks, that way we could detect leaks due to task <-> entity + // cycles as cancelled tasks will be dropped properly once they runnable gets run again + // + // async-task does not give us the power to do this just yet though + dispatcher.drain_tasks(); + drop(dispatcher); + }, + #on_failure_fn_name, + ); + } + } + }; + outer_fn.attrs.extend(inner_fn_attributes); + + Ok(TokenStream::from(quote!(#outer_fn))) +} + +fn parse_usize_from_expr(expr: &Expr) -> Result { + let Expr::Lit(ExprLit { + lit: Lit::Int(int), .. + }) = expr + else { + return Err(syn::Error::new(expr.span(), "expected an integer")); + }; + int.base10_parse() + .map_err(|_| syn::Error::new(int.span(), "failed to parse integer")) +} + +fn parse_u64_array(meta_list: &MetaList) -> Result, syn::Error> { + let mut result = Vec::new(); + let tokens = &meta_list.tokens; + let parser = |input: ParseStream| { + let exprs = Punctuated::::parse_terminated(input)?; + for expr in exprs { + if let Expr::Lit(ExprLit { + lit: Lit::Int(int), .. + }) = expr + { + let value: usize = int.base10_parse()?; + result.push(value as u64); + } else { + return Err(syn::Error::new(expr.span(), "expected an integer")); + } + } + Ok(()) + }; + syn::parse::Parser::parse2(parser, tokens.clone())?; + Ok(result) +} + +fn error_with_message(message: &str, spanned: impl Spanned) -> TokenStream { + error_to_stream(syn::Error::new(spanned.span(), message)) +} + +fn error_to_stream(err: syn::Error) -> TokenStream { + TokenStream::from(err.into_compile_error()) +} diff --git a/tooling/macros/tests/derive_context.rs b/tooling/macros/tests/derive_context.rs new file mode 100644 index 0000000000..6c122eff25 --- /dev/null +++ b/tooling/macros/tests/derive_context.rs @@ -0,0 +1,13 @@ +#[test] +fn test_derive_context() { + use gpui::{App, Window}; + use gpui_macros::{AppContext, VisualContext}; + + #[derive(AppContext, VisualContext)] + struct _MyCustomContext<'a, 'b> { + #[app] + app: &'a mut App, + #[window] + window: &'b mut Window, + } +} diff --git a/tooling/macros/tests/derive_inspector_reflection.rs b/tooling/macros/tests/derive_inspector_reflection.rs new file mode 100644 index 0000000000..92f4e56e9c --- /dev/null +++ b/tooling/macros/tests/derive_inspector_reflection.rs @@ -0,0 +1,133 @@ +//! This code was generated using Zed Agent with Claude Opus 4. + +// gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv +#[cfg_attr(not(rust_analyzer), gpui_macros::derive_inspector_reflection)] +trait Transform: Clone { + /// Doubles the value + fn double(self) -> Self; + + /// Triples the value + fn triple(self) -> Self; + + /// Increments the value by one + /// + /// This method has a default implementation + fn increment(self) -> Self { + // Default implementation + self.add_one() + } + + /// Quadruples the value by doubling twice + fn quadruple(self) -> Self { + // Default implementation with mut self + self.double().double() + } + + // These methods will be filtered out: + #[allow(dead_code)] + fn add(&self, other: &Self) -> Self; + #[allow(dead_code)] + fn set_value(&mut self, value: i32); + #[allow(dead_code)] + fn get_value(&self) -> i32; + + /// Adds one to the value + fn add_one(self) -> Self; +} + +#[derive(Debug, Clone, PartialEq)] +struct Number(i32); + +impl Transform for Number { + fn double(self) -> Self { + Number(self.0 * 2) + } + + fn triple(self) -> Self { + Number(self.0 * 3) + } + + fn add(&self, other: &Self) -> Self { + Number(self.0 + other.0) + } + + fn set_value(&mut self, value: i32) { + self.0 = value; + } + + fn get_value(&self) -> i32 { + self.0 + } + + fn add_one(self) -> Self { + Number(self.0 + 1) + } +} + +#[test] +fn test_derive_inspector_reflection() { + use transform_reflection::*; + + // Get all methods that match the pattern fn(self) -> Self or fn(mut self) -> Self + let methods = methods::(); + + assert_eq!(methods.len(), 5); + let method_names: Vec<_> = methods.iter().map(|m| m.name).collect(); + assert!(method_names.contains(&"double")); + assert!(method_names.contains(&"triple")); + assert!(method_names.contains(&"increment")); + assert!(method_names.contains(&"quadruple")); + assert!(method_names.contains(&"add_one")); + + // Invoke methods by name + let num = Number(5); + + let doubled = find_method::("double").unwrap().invoke(num.clone()); + assert_eq!(doubled, Number(10)); + + let tripled = find_method::("triple").unwrap().invoke(num.clone()); + assert_eq!(tripled, Number(15)); + + let incremented = find_method::("increment") + .unwrap() + .invoke(num.clone()); + assert_eq!(incremented, Number(6)); + + let quadrupled = find_method::("quadruple").unwrap().invoke(num); + assert_eq!(quadrupled, Number(20)); + + // Try to invoke a non-existent method + let result = find_method::("nonexistent"); + assert!(result.is_none()); + + // Chain operations + let num = Number(10); + let result = find_method::("double") + .map(|m| m.invoke(num)) + .and_then(|n| find_method::("increment").map(|m| m.invoke(n))) + .and_then(|n| find_method::("triple").map(|m| m.invoke(n))); + + assert_eq!(result, Some(Number(63))); // (10 * 2 + 1) * 3 = 63 + + // Test documentationumentation capture + let double_method = find_method::("double").unwrap(); + assert_eq!(double_method.documentation, Some("Doubles the value")); + + let triple_method = find_method::("triple").unwrap(); + assert_eq!(triple_method.documentation, Some("Triples the value")); + + let increment_method = find_method::("increment").unwrap(); + assert_eq!( + increment_method.documentation, + Some("Increments the value by one\n\nThis method has a default implementation") + ); + + let quadruple_method = find_method::("quadruple").unwrap(); + assert_eq!( + quadruple_method.documentation, + Some("Quadruples the value by doubling twice") + ); + + let add_one_method = find_method::("add_one").unwrap(); + assert_eq!(add_one_method.documentation, Some("Adds one to the value")); +} diff --git a/tooling/macros/tests/render_test.rs b/tooling/macros/tests/render_test.rs new file mode 100644 index 0000000000..98c9062381 --- /dev/null +++ b/tooling/macros/tests/render_test.rs @@ -0,0 +1,7 @@ +#[test] +fn test_derive_render() { + use gpui_macros::Render; + + #[derive(Render)] + struct _Element; +} diff --git a/tooling/perf/Cargo.toml b/tooling/perf/Cargo.toml new file mode 100644 index 0000000000..c9018de010 --- /dev/null +++ b/tooling/perf/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "perf" +version = "0.1.0" +publish = false +edition.workspace = true +license = "Apache-2.0" +description = "A tool for measuring GPUI test performance" + +[lib] + +# Some personal lint preferences :3 +[lints.rust] +missing_docs = "warn" + +[lints.clippy] +needless_continue = "allow" # For a convenience macro +all = "warn" +pedantic = "warn" +style = "warn" +missing_docs_in_private_items = "warn" +as_underscore = "deny" +allow_attributes = "deny" +allow_attributes_without_reason = "deny" # This covers `expect` also, since we deny `allow` +let_underscore_must_use = "forbid" +undocumented_unsafe_blocks = "forbid" +missing_safety_doc = "forbid" +disallowed_methods = { level = "allow", priority = 1} + +[dependencies] +collections.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/tooling/perf/LICENSE-APACHE b/tooling/perf/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/tooling/perf/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/tooling/perf/src/implementation.rs b/tooling/perf/src/implementation.rs new file mode 100644 index 0000000000..c151dda91f --- /dev/null +++ b/tooling/perf/src/implementation.rs @@ -0,0 +1,450 @@ +//! The implementation of the this crate is kept in a separate module +//! so that it is easy to publish this crate as part of GPUI's dependencies + +use collections::HashMap; +use serde::{Deserialize, Serialize}; +use std::{num::NonZero, time::Duration}; + +pub mod consts { + //! Preset identifiers and constants so that the profiler and proc macro agree + //! on their communication protocol. + + /// The suffix on the actual test function. + pub const SUF_NORMAL: &str = "__ZED_PERF_FN"; + /// The suffix on an extra function which prints metadata about a test to stdout. + pub const SUF_MDATA: &str = "__ZED_PERF_MDATA"; + /// The env var in which we pass the iteration count to our tests. + pub const ITER_ENV_VAR: &str = "ZED_PERF_ITER"; + /// The prefix printed on all benchmark test metadata lines, to distinguish it from + /// possible output by the test harness itself. + pub const MDATA_LINE_PREF: &str = "ZED_MDATA_"; + /// The version number for the data returned from the test metadata function. + /// Increment on non-backwards-compatible changes. + pub const MDATA_VER: u32 = 0; + /// The default weight, if none is specified. + pub const WEIGHT_DEFAULT: u8 = 50; + /// How long a test must have run to be assumed to be reliable-ish. + pub const NOISE_CUTOFF: std::time::Duration = std::time::Duration::from_millis(250); + + /// Identifier for the iteration count of a test metadata. + pub const ITER_COUNT_LINE_NAME: &str = "iter_count"; + /// Identifier for the weight of a test metadata. + pub const WEIGHT_LINE_NAME: &str = "weight"; + /// Identifier for importance in test metadata. + pub const IMPORTANCE_LINE_NAME: &str = "importance"; + /// Identifier for the test metadata version. + pub const VERSION_LINE_NAME: &str = "version"; + + /// Where to save json run information. + pub const RUNS_DIR: &str = ".perf-runs"; +} + +/// How relevant a benchmark is. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Importance { + /// Regressions shouldn't be accepted without good reason. + Critical = 4, + /// Regressions should be paid extra attention. + Important = 3, + /// No extra attention should be paid to regressions, but they might still + /// be indicative of something happening. + #[default] + Average = 2, + /// Unclear if regressions are likely to be meaningful, but still worth keeping + /// an eye on. Lowest level that's checked by default by the profiler. + Iffy = 1, + /// Regressions are likely to be spurious or don't affect core functionality. + /// Only relevant if a lot of them happen, or as supplemental evidence for a + /// higher-importance benchmark regressing. Not checked by default. + Fluff = 0, +} + +impl std::fmt::Display for Importance { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Importance::Critical => f.write_str("critical"), + Importance::Important => f.write_str("important"), + Importance::Average => f.write_str("average"), + Importance::Iffy => f.write_str("iffy"), + Importance::Fluff => f.write_str("fluff"), + } + } +} + +/// Why or when did this test fail? +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum FailKind { + /// Failed while triaging it to determine the iteration count. + Triage, + /// Failed while profiling it. + Profile, + /// Failed due to an incompatible version for the test. + VersionMismatch, + /// Could not parse metadata for a test. + BadMetadata, + /// Skipped due to filters applied on the perf run. + Skipped, +} + +impl std::fmt::Display for FailKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FailKind::Triage => f.write_str("errored in triage"), + FailKind::Profile => f.write_str("errored while profiling"), + FailKind::VersionMismatch => f.write_str("test version mismatch"), + FailKind::BadMetadata => f.write_str("bad test metadata"), + FailKind::Skipped => f.write_str("skipped"), + } + } +} + +/// Information about a given perf test. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TestMdata { + /// A version number for when the test was generated. If this is greater + /// than the version this test handler expects, one of the following will + /// happen in an unspecified manner: + /// - The test is skipped silently. + /// - The handler exits with an error message indicating the version mismatch + /// or inability to parse the metadata. + /// + /// INVARIANT: If `version` <= `MDATA_VER`, this tool *must* be able to + /// correctly parse the output of this test. + pub version: u32, + /// How many iterations to pass this test if this is preset, or how many + /// iterations a test ended up running afterwards if determined at runtime. + pub iterations: Option>, + /// The importance of this particular test. See the docs on `Importance` for + /// details. + pub importance: Importance, + /// The weight of this particular test within its importance category. Used + /// when comparing across runs. + pub weight: u8, +} + +/// The actual timings of a test, as measured by Hyperfine. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Timings { + /// Mean runtime for `self.iter_total` runs of this test. + pub mean: Duration, + /// Standard deviation for the above. + pub stddev: Duration, +} + +impl Timings { + /// How many iterations does this test seem to do per second? + #[expect( + clippy::cast_precision_loss, + reason = "We only care about a couple sig figs anyways" + )] + #[must_use] + pub fn iters_per_sec(&self, total_iters: NonZero) -> f64 { + (1000. / self.mean.as_millis() as f64) * total_iters.get() as f64 + } +} + +/// Aggregate results, meant to be used for a given importance category. Each +/// test name corresponds to its benchmark results, iteration count, and weight. +type CategoryInfo = HashMap, u8)>; + +/// Aggregate output of all tests run by this handler. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Output { + /// A list of test outputs. Format is `(test_name, mdata, timings)`. + /// The latter being `Ok(_)` indicates the test succeeded. + /// + /// INVARIANT: If the test succeeded, the second field is `Some(mdata)` and + /// `mdata.iterations` is `Some(_)`. + tests: Vec<(String, Option, Result)>, +} + +impl Output { + /// Instantiates an empty "output". Useful for merging. + #[must_use] + pub fn blank() -> Self { + Output { tests: Vec::new() } + } + + /// Reports a success and adds it to this run's `Output`. + pub fn success( + &mut self, + name: impl AsRef, + mut mdata: TestMdata, + iters: NonZero, + timings: Timings, + ) { + mdata.iterations = Some(iters); + self.tests + .push((name.as_ref().to_string(), Some(mdata), Ok(timings))); + } + + /// Reports a failure and adds it to this run's `Output`. If this test was tried + /// with some number of iterations (i.e. this was not a version mismatch or skipped + /// test), it should be reported also. + /// + /// Using the `fail!()` macro is usually more convenient. + pub fn failure( + &mut self, + name: impl AsRef, + mut mdata: Option, + attempted_iters: Option>, + kind: FailKind, + ) { + if let Some(ref mut mdata) = mdata { + mdata.iterations = attempted_iters; + } + self.tests + .push((name.as_ref().to_string(), mdata, Err(kind))); + } + + /// True if no tests executed this run. + #[must_use] + pub fn is_empty(&self) -> bool { + self.tests.is_empty() + } + + /// Sorts the runs in the output in the order that we want them printed. + pub fn sort(&mut self) { + self.tests.sort_unstable_by(|a, b| match (a, b) { + // Tests where we got no metadata go at the end. + ((_, Some(_), _), (_, None, _)) => std::cmp::Ordering::Greater, + ((_, None, _), (_, Some(_), _)) => std::cmp::Ordering::Less, + // Then sort by importance, then weight. + ((_, Some(a_mdata), _), (_, Some(b_mdata), _)) => { + let c = a_mdata.importance.cmp(&b_mdata.importance); + if matches!(c, std::cmp::Ordering::Equal) { + a_mdata.weight.cmp(&b_mdata.weight) + } else { + c + } + } + // Lastly by name. + ((a_name, ..), (b_name, ..)) => a_name.cmp(b_name), + }); + } + + /// Merges the output of two runs, appending a prefix to the results of the new run. + /// To be used in conjunction with `Output::blank()`, or else only some tests will have + /// a prefix set. + pub fn merge<'a>(&mut self, other: Self, pref_other: impl Into>) { + let pref = if let Some(pref) = pref_other.into() { + "crates/".to_string() + pref + "::" + } else { + String::new() + }; + self.tests = std::mem::take(&mut self.tests) + .into_iter() + .chain( + other + .tests + .into_iter() + .map(|(name, md, tm)| (pref.clone() + &name, md, tm)), + ) + .collect(); + } + + /// Evaluates the performance of `self` against `baseline`. The latter is taken + /// as the comparison point, i.e. a positive resulting `PerfReport` means that + /// `self` performed better. + /// + /// # Panics + /// `self` and `baseline` are assumed to have the iterations field on all + /// `TestMdata`s set to `Some(_)` if the `TestMdata` is present itself. + #[must_use] + pub fn compare_perf(self, baseline: Self) -> PerfReport { + let self_categories = self.collapse(); + let mut other_categories = baseline.collapse(); + + let deltas = self_categories + .into_iter() + .filter_map(|(cat, self_data)| { + // Only compare categories where both meow + // runs have data. / + let mut other_data = other_categories.remove(&cat)?; + let mut max = f64::MIN; + let mut min = f64::MAX; + + // Running totals for averaging out tests. + let mut r_total_numerator = 0.; + let mut r_total_denominator = 0; + // Yeah this is O(n^2), but realistically it'll hardly be a bottleneck. + for (name, (s_timings, s_iters, weight)) in self_data { + // Only use the new weights if they conflict. + let Some((o_timings, o_iters, _)) = other_data.remove(&name) else { + continue; + }; + let shift = + (o_timings.iters_per_sec(o_iters) / s_timings.iters_per_sec(s_iters)) - 1.; + if shift > max { + max = shift; + } + if shift < min { + min = shift; + } + r_total_numerator += shift * f64::from(weight); + r_total_denominator += u32::from(weight); + } + // There were no runs here! + if r_total_denominator == 0 { + None + } else { + let mean = r_total_numerator / f64::from(r_total_denominator); + // TODO: also aggregate standard deviation? That's harder to keep + // meaningful, though, since we dk which tests are correlated. + Some((cat, PerfDelta { max, mean, min })) + } + }) + .collect(); + + PerfReport { deltas } + } + + /// Collapses the `PerfReport` into a `HashMap` over `Importance`, with + /// each importance category having its tests contained. + fn collapse(self) -> HashMap { + let mut categories = HashMap::>::default(); + for entry in self.tests { + if let Some(mdata) = entry.1 + && let Ok(timings) = entry.2 + { + if let Some(handle) = categories.get_mut(&mdata.importance) { + handle.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight)); + } else { + let mut new = HashMap::default(); + new.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight)); + categories.insert(mdata.importance, new); + } + } + } + + categories + } +} + +impl std::fmt::Display for Output { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Don't print the header for an empty run. + if self.tests.is_empty() { + return Ok(()); + } + + // We want to print important tests at the top, then alphabetical. + let mut sorted = self.clone(); + sorted.sort(); + // Markdown header for making a nice little table :> + writeln!( + f, + "| Command | Iter/sec | Mean [ms] | SD [ms] | Iterations | Importance (weight) |", + )?; + writeln!(f, "|:---|---:|---:|---:|---:|---:|")?; + for (name, metadata, timings) in &sorted.tests { + match metadata { + Some(metadata) => match timings { + // Happy path. + Ok(timings) => { + // If the test succeeded, then metadata.iterations is Some(_). + writeln!( + f, + "| {} | {:.2} | {} | {:.2} | {} | {} ({}) |", + name, + timings.iters_per_sec(metadata.iterations.unwrap()), + { + // Very small mean runtimes will give inaccurate + // results. Should probably also penalise weight. + let mean = timings.mean.as_secs_f64() * 1000.; + if mean < consts::NOISE_CUTOFF.as_secs_f64() * 1000. / 8. { + format!("{mean:.2} (unreliable)") + } else { + format!("{mean:.2}") + } + }, + timings.stddev.as_secs_f64() * 1000., + metadata.iterations.unwrap(), + metadata.importance, + metadata.weight, + )?; + } + // We have (some) metadata, but the test errored. + Err(err) => writeln!( + f, + "| ({}) {} | N/A | N/A | N/A | {} | {} ({}) |", + err, + name, + metadata + .iterations + .map_or_else(|| "N/A".to_owned(), |i| format!("{i}")), + metadata.importance, + metadata.weight + )?, + }, + // No metadata, couldn't even parse the test output. + None => writeln!( + f, + "| ({}) {} | N/A | N/A | N/A | N/A | N/A |", + timings.as_ref().unwrap_err(), + name + )?, + } + } + Ok(()) + } +} + +/// The difference in performance between two runs within a given importance +/// category. +struct PerfDelta { + /// The biggest improvement / least bad regression. + max: f64, + /// The weighted average change in test times. + mean: f64, + /// The worst regression / smallest improvement. + min: f64, +} + +/// Shim type for reporting all performance deltas across importance categories. +pub struct PerfReport { + /// Inner (group, diff) pairing. + deltas: HashMap, +} + +impl std::fmt::Display for PerfReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.deltas.is_empty() { + return write!(f, "(no matching tests)"); + } + let sorted = self.deltas.iter().collect::>(); + writeln!(f, "| Category | Max | Mean | Min |")?; + // We don't want to print too many newlines at the end, so handle newlines + // a little jankily like this. + write!(f, "|:---|---:|---:|---:|")?; + for (cat, delta) in sorted.into_iter().rev() { + const SIGN_POS: &str = "↑"; + const SIGN_NEG: &str = "↓"; + const SIGN_NEUTRAL_POS: &str = "±↑"; + const SIGN_NEUTRAL_NEG: &str = "±↓"; + + let prettify = |time: f64| { + let sign = if time > 0.05 { + SIGN_POS + } else if time > 0. { + SIGN_NEUTRAL_POS + } else if time > -0.05 { + SIGN_NEUTRAL_NEG + } else { + SIGN_NEG + }; + format!("{} {:.1}%", sign, time.abs() * 100.) + }; + + // Pretty-print these instead of just using the float display impl. + write!( + f, + "\n| {cat} | {} | {} | {} |", + prettify(delta.max), + prettify(delta.mean), + prettify(delta.min) + )?; + } + Ok(()) + } +} diff --git a/tooling/perf/src/lib.rs b/tooling/perf/src/lib.rs new file mode 100644 index 0000000000..7933e66e79 --- /dev/null +++ b/tooling/perf/src/lib.rs @@ -0,0 +1,7 @@ +//! Some constants and datatypes used in the Zed perf profiler. Should only be +//! consumed by the crate providing the matching macros. +//! +//! For usage documentation, see the docs on this crate's binary. + +mod implementation; +pub use implementation::*; diff --git a/tooling/perf/src/main.rs b/tooling/perf/src/main.rs new file mode 100644 index 0000000000..243658e508 --- /dev/null +++ b/tooling/perf/src/main.rs @@ -0,0 +1,582 @@ +//! Perf profiler for Zed tests. Outputs timings of tests marked with the `#[perf]` +//! attribute to stdout in Markdown. See the documentation of `util_macros::perf` +//! for usage details on the actual attribute. +//! +//! # Setup +//! Make sure `hyperfine` is installed and in the shell path. +//! +//! # Usage +//! Calling this tool rebuilds the targeted crate(s) with some cfg flags set for the +//! perf proc macro *and* enables optimisations (`release-fast` profile), so expect +//! it to take a little while. +//! +//! To test an individual crate, run: +//! ```sh +//! cargo perf-test -p $CRATE +//! ``` +//! +//! To test everything (which will be **VERY SLOW**), run: +//! ```sh +//! cargo perf-test --workspace +//! ``` +//! +//! Some command-line parameters are also recognised by this profiler. To filter +//! out all tests below a certain importance (e.g. `important`), run: +//! ```sh +//! cargo perf-test $WHATEVER -- --important +//! ``` +//! +//! Similarly, to skip outputting progress to the command line, pass `-- --quiet`. +//! These flags can be combined. +//! +//! ## Comparing runs +//! Passing `--json=ident` will save per-crate run files in `.perf-runs`, e.g. +//! `cargo perf-test -p gpui -- --json=blah` will result in `.perf-runs/blah.gpui.json` +//! being created (unless no tests were run). These results can be automatically +//! compared. To do so, run `cargo perf-compare new-ident old-ident`. +//! +//! To save the markdown output to a file instead, run `cargo perf-compare --save=$FILE +//! new-ident old-ident`. +//! +//! NB: All files matching `.perf-runs/ident.*.json` will be considered when +//! doing this comparison, so ensure there aren't leftover files in your `.perf-runs` +//! directory that might match that! +//! +//! # Notes +//! This should probably not be called manually unless you're working on the profiler +//! itself; use the `cargo perf-test` alias (after building this crate) instead. + +mod implementation; + +use implementation::{FailKind, Importance, Output, TestMdata, Timings, consts}; + +use std::{ + fs::OpenOptions, + io::{Read, Write}, + num::NonZero, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::atomic::{AtomicBool, Ordering}, + time::{Duration, Instant}, +}; + +/// How many iterations to attempt the first time a test is run. +const DEFAULT_ITER_COUNT: NonZero = NonZero::new(3).unwrap(); +/// Multiplier for the iteration count when a test doesn't pass the noise cutoff. +const ITER_COUNT_MUL: NonZero = NonZero::new(4).unwrap(); + +/// Do we keep stderr empty while running the tests? +static QUIET: AtomicBool = AtomicBool::new(false); + +/// Report a failure into the output and skip an iteration. +macro_rules! fail { + ($output:ident, $name:expr, $kind:expr) => {{ + $output.failure($name, None, None, $kind); + continue; + }}; + ($output:ident, $name:expr, $mdata:expr, $kind:expr) => {{ + $output.failure($name, Some($mdata), None, $kind); + continue; + }}; + ($output:ident, $name:expr, $mdata:expr, $count:expr, $kind:expr) => {{ + $output.failure($name, Some($mdata), Some($count), $kind); + continue; + }}; +} + +/// How does this perf run return its output? +enum OutputKind<'a> { + /// Print markdown to the terminal. + Markdown, + /// Save JSON to a file. + Json(&'a Path), +} + +impl OutputKind<'_> { + /// Logs the output of a run as per the `OutputKind`. + fn log(&self, output: &Output, t_bin: &str) { + match self { + OutputKind::Markdown => println!("{output}"), + OutputKind::Json(ident) => { + // We're going to be in tooling/perf/$whatever. + let wspace_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("..") + .join(".."); + let runs_dir = PathBuf::from(&wspace_dir).join(consts::RUNS_DIR); + std::fs::create_dir_all(&runs_dir).unwrap(); + assert!( + !ident.to_string_lossy().is_empty(), + "FATAL: Empty filename specified!" + ); + // Get the test binary's crate's name; a path like + // target/release-fast/deps/gpui-061ff76c9b7af5d7 + // would be reduced to just "gpui". + let test_bin_stripped = Path::new(t_bin) + .file_name() + .unwrap() + .to_str() + .unwrap() + .rsplit_once('-') + .unwrap() + .0; + let mut file_path = runs_dir.join(ident); + file_path + .as_mut_os_string() + .push(format!(".{test_bin_stripped}.json")); + let mut out_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&file_path) + .unwrap(); + out_file + .write_all(&serde_json::to_vec(&output).unwrap()) + .unwrap(); + if !QUIET.load(Ordering::Relaxed) { + eprintln!("JSON output written to {}", file_path.display()); + } + } + } + } +} + +/// Runs a given metadata-returning function from a test handler, parsing its +/// output into a `TestMdata`. +fn parse_mdata(t_bin: &str, mdata_fn: &str) -> Result { + let mut cmd = Command::new(t_bin); + cmd.args([mdata_fn, "--exact", "--nocapture"]); + let out = cmd + .output() + .expect("FATAL: Could not run test binary {t_bin}"); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + let mut version = None; + let mut iterations = None; + let mut importance = Importance::default(); + let mut weight = consts::WEIGHT_DEFAULT; + for line in stdout + .lines() + .filter_map(|l| l.strip_prefix(consts::MDATA_LINE_PREF)) + { + let mut items = line.split_whitespace(); + // For v0, we know the ident always comes first, then one field. + match items.next().ok_or(FailKind::BadMetadata)? { + consts::VERSION_LINE_NAME => { + let v = items + .next() + .ok_or(FailKind::BadMetadata)? + .parse::() + .map_err(|_| FailKind::BadMetadata)?; + if v > consts::MDATA_VER { + return Err(FailKind::VersionMismatch); + } + version = Some(v); + } + consts::ITER_COUNT_LINE_NAME => { + // This should never be zero! + iterations = Some( + items + .next() + .ok_or(FailKind::BadMetadata)? + .parse::() + .map_err(|_| FailKind::BadMetadata)? + .try_into() + .map_err(|_| FailKind::BadMetadata)?, + ); + } + consts::IMPORTANCE_LINE_NAME => { + importance = match items.next().ok_or(FailKind::BadMetadata)? { + "critical" => Importance::Critical, + "important" => Importance::Important, + "average" => Importance::Average, + "iffy" => Importance::Iffy, + "fluff" => Importance::Fluff, + _ => return Err(FailKind::BadMetadata), + }; + } + consts::WEIGHT_LINE_NAME => { + weight = items + .next() + .ok_or(FailKind::BadMetadata)? + .parse::() + .map_err(|_| FailKind::BadMetadata)?; + } + _ => unreachable!(), + } + } + + Ok(TestMdata { + version: version.ok_or(FailKind::BadMetadata)?, + // Iterations may be determined by us and thus left unspecified. + iterations, + // In principle this should always be set, but just for the sake of + // stability allow the potentially-breaking change of not reporting the + // importance without erroring. Maybe we want to change this. + importance, + // Same with weight. + weight, + }) +} + +/// Compares the perf results of two profiles as per the arguments passed in. +fn compare_profiles(args: &[String]) { + let mut save_to = None; + let mut ident_idx = 0; + args.first().inspect(|a| { + if a.starts_with("--save") { + save_to = Some( + a.strip_prefix("--save=") + .expect("FATAL: save param formatted incorrectly"), + ); + ident_idx = 1; + } + }); + let ident_new = args + .get(ident_idx) + .expect("FATAL: missing identifier for new run"); + let ident_old = args + .get(ident_idx + 1) + .expect("FATAL: missing identifier for old run"); + let wspace_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let runs_dir = PathBuf::from(&wspace_dir) + .join("..") + .join("..") + .join(consts::RUNS_DIR); + + // Use the blank outputs initially, so we can merge into these with prefixes. + let mut outputs_new = Output::blank(); + let mut outputs_old = Output::blank(); + + for e in runs_dir.read_dir().unwrap() { + let Ok(entry) = e else { + continue; + }; + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_file() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + + // A little helper to avoid code duplication. Reads the `output` from + // a json file, then merges it into what we have so far. + let read_into = |output: &mut Output| { + let mut elems = name.split('.').skip(1); + let prefix = elems.next().unwrap(); + assert_eq!("json", elems.next().unwrap()); + assert!(elems.next().is_none()); + let mut buffer = Vec::new(); + let _ = OpenOptions::new() + .read(true) + .open(entry.path()) + .unwrap() + .read_to_end(&mut buffer) + .unwrap(); + let o_other: Output = serde_json::from_slice(&buffer).unwrap(); + output.merge(o_other, prefix); + }; + + if name.starts_with(ident_old) { + read_into(&mut outputs_old); + } else if name.starts_with(ident_new) { + read_into(&mut outputs_new); + } + } + } + + let res = outputs_new.compare_perf(outputs_old); + if let Some(filename) = save_to { + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filename) + .expect("FATAL: couldn't save run results to file"); + file.write_all(format!("{res}").as_bytes()).unwrap(); + } else { + println!("{res}"); + } +} + +/// Runs a test binary, filtering out tests which aren't marked for perf triage +/// and giving back the list of tests we care about. +/// +/// The output of this is an iterator over `test_fn_name, test_mdata_name`. +fn get_tests(t_bin: &str) -> impl ExactSizeIterator { + let mut cmd = Command::new(t_bin); + // --format=json is nightly-only :( + cmd.args(["--list", "--format=terse"]); + let out = cmd + .output() + .expect("FATAL: Could not run test binary {t_bin}"); + assert!( + out.status.success(), + "FATAL: Cannot do perf check - test binary {t_bin} returned an error" + ); + if !QUIET.load(Ordering::Relaxed) { + eprintln!("Test binary ran successfully; starting profile..."); + } + // Parse the test harness output to look for tests we care about. + let stdout = String::from_utf8_lossy(&out.stdout); + let mut test_list: Vec<_> = stdout + .lines() + .filter_map(|line| { + // This should split only in two; e.g., + // "app::test::test_arena: test" => "app::test::test_arena:", "test" + let line: Vec<_> = line.split_whitespace().collect(); + match line[..] { + // Final byte of t_name is ":", which we need to ignore. + [t_name, kind] => (kind == "test").then(|| &t_name[..t_name.len() - 1]), + _ => None, + } + }) + // Exclude tests that aren't marked for perf triage based on suffix. + .filter(|t_name| { + t_name.ends_with(consts::SUF_NORMAL) || t_name.ends_with(consts::SUF_MDATA) + }) + .collect(); + + // Pulling itertools just for .dedup() would be quite a big dependency that's + // not used elsewhere, so do this on a vec instead. + test_list.sort_unstable(); + test_list.dedup(); + + // Tests should come in pairs with their mdata fn! + assert!( + test_list.len().is_multiple_of(2), + "Malformed tests in test binary {t_bin}" + ); + + let out = test_list + .chunks_exact_mut(2) + .map(|pair| { + // Be resilient against changes to these constants. + if consts::SUF_NORMAL < consts::SUF_MDATA { + (pair[0].to_owned(), pair[1].to_owned()) + } else { + (pair[1].to_owned(), pair[0].to_owned()) + } + }) + .collect::>(); + out.into_iter() +} + +/// Runs the specified test `count` times, returning the time taken if the test +/// succeeded. +#[inline] +fn spawn_and_iterate(t_bin: &str, t_name: &str, count: NonZero) -> Option { + let mut cmd = Command::new(t_bin); + cmd.args([t_name, "--exact"]); + cmd.env(consts::ITER_ENV_VAR, format!("{count}")); + // Don't let the child muck up our stdin/out/err. + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + let pre = Instant::now(); + // Discard the output beyond ensuring success. + let out = cmd.spawn().unwrap().wait(); + let post = Instant::now(); + out.iter().find_map(|s| s.success().then_some(post - pre)) +} + +/// Triage a test to determine the correct number of iterations that it should run. +/// Specifically, repeatedly runs the given test until its execution time exceeds +/// `thresh`, calling `step(iterations)` after every failed run to determine the new +/// iteration count. Returns `None` if the test errored or `step` returned `None`, +/// else `Some(iterations)`. +/// +/// # Panics +/// This will panic if `step(usize)` is not monotonically increasing, or if the test +/// binary is invalid. +fn triage_test( + t_bin: &str, + t_name: &str, + thresh: Duration, + mut step: impl FnMut(NonZero) -> Option>, +) -> Option> { + let mut iter_count = DEFAULT_ITER_COUNT; + // It's possible that the first loop of a test might be an outlier (e.g. it's + // doing some caching), in which case we want to skip it. + let duration_once = spawn_and_iterate(t_bin, t_name, NonZero::new(1).unwrap())?; + loop { + let duration = spawn_and_iterate(t_bin, t_name, iter_count)?; + if duration.saturating_sub(duration_once) > thresh { + break Some(iter_count); + } + let new = step(iter_count)?; + assert!( + new > iter_count, + "FATAL: step must be monotonically increasing" + ); + iter_count = new; + } +} + +/// 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)) + } +} + +/// Profiles a given test with hyperfine, returning the mean and standard deviation +/// for its runtime. If the test errors, returns `None` instead. +fn hyp_profile(t_bin: &str, t_name: &str, iterations: NonZero) -> Option { + let mut perf_cmd = hyp_binary().expect("Couldn't find the Hyperfine binary on the system"); + + // Warm up the cache and print markdown output to stdout, which we parse. + perf_cmd.args([ + "--style", + "none", + "--warmup", + "1", + "--export-markdown", + "-", + // Parse json instead... + "--time-unit", + "millisecond", + &format!("{t_bin} --exact {t_name}"), + ]); + perf_cmd.env(consts::ITER_ENV_VAR, format!("{iterations}")); + let p_out = perf_cmd.output().unwrap(); + if !p_out.status.success() { + return None; + } + + let cmd_output = String::from_utf8_lossy(&p_out.stdout); + // Can't use .last() since we have a trailing newline. Sigh. + let results_line = cmd_output.lines().nth(3).unwrap(); + // Grab the values out of the pretty-print. + // TODO: Parse json instead. + let mut res_iter = results_line.split_whitespace(); + // Durations are given in milliseconds, so account for that. + let mean = Duration::from_secs_f64(res_iter.nth(5).unwrap().parse::().unwrap() / 1000.); + let stddev = Duration::from_secs_f64(res_iter.nth(1).unwrap().parse::().unwrap() / 1000.); + + Some(Timings { mean, stddev }) +} + +fn main() { + let args = std::env::args().collect::>(); + // We get passed the test we need to run as the 1st argument after our own name. + let t_bin = args + .get(1) + .expect("FATAL: No test binary or command; this shouldn't be manually invoked!"); + + // We're being asked to compare two results, not run the profiler. + if t_bin == "compare" { + compare_profiles(&args[2..]); + return; + } + + // Minimum test importance we care about this run. + let mut thresh = Importance::Iffy; + // Where to print the output of this run. + let mut out_kind = OutputKind::Markdown; + + for arg in args.iter().skip(2) { + match arg.as_str() { + "--critical" => thresh = Importance::Critical, + "--important" => thresh = Importance::Important, + "--average" => thresh = Importance::Average, + "--iffy" => thresh = Importance::Iffy, + "--fluff" => thresh = Importance::Fluff, + "--quiet" => QUIET.store(true, Ordering::Relaxed), + s if s.starts_with("--json") => { + out_kind = OutputKind::Json(Path::new( + s.strip_prefix("--json=") + .expect("FATAL: Invalid json parameter; pass --json=ident"), + )); + } + _ => (), + } + } + if !QUIET.load(Ordering::Relaxed) { + eprintln!("Starting perf check"); + } + + let mut output = Output::default(); + + // Spawn and profile an instance of each perf-sensitive test, via hyperfine. + // Each test is a pair of (test, metadata-returning-fn), so grab both. We also + // know the list is sorted. + let i = get_tests(t_bin); + let len = i.len(); + for (idx, (ref t_name, ref t_mdata)) in i.enumerate() { + if !QUIET.load(Ordering::Relaxed) { + eprint!("\rProfiling test {}/{}", idx + 1, len); + } + // Pretty-printable stripped name for the test. + let t_name_pretty = t_name.replace(consts::SUF_NORMAL, ""); + + // Get the metadata this test reports for us. + let t_mdata = match parse_mdata(t_bin, t_mdata) { + Ok(mdata) => mdata, + Err(err) => fail!(output, t_name_pretty, err), + }; + + if t_mdata.importance < thresh { + fail!(output, t_name_pretty, t_mdata, FailKind::Skipped); + } + + // Time test execution to see how many iterations we need to do in order + // to account for random noise. This is skipped for tests with fixed + // iteration counts. + let final_iter_count = t_mdata.iterations.or_else(|| { + triage_test(t_bin, t_name, consts::NOISE_CUTOFF, |c| { + if let Some(c) = c.checked_mul(ITER_COUNT_MUL) { + Some(c) + } else { + // This should almost never happen, but maybe..? + eprintln!( + "WARNING: Ran nearly usize::MAX iterations of test {t_name_pretty}; skipping" + ); + None + } + }) + }); + + // Don't profile failing tests. + let Some(final_iter_count) = final_iter_count else { + fail!(output, t_name_pretty, t_mdata, FailKind::Triage); + }; + + // Now profile! + if let Some(timings) = hyp_profile(t_bin, t_name, final_iter_count) { + output.success(t_name_pretty, t_mdata, final_iter_count, timings); + } else { + fail!( + output, + t_name_pretty, + t_mdata, + final_iter_count, + FailKind::Profile + ); + } + } + if !QUIET.load(Ordering::Relaxed) { + if output.is_empty() { + eprintln!("Nothing to do."); + } else { + // If stdout and stderr are on the same terminal, move us after the + // output from above. + eprintln!(); + } + } + + // No need making an empty json file on every empty test bin. + if output.is_empty() { + return; + } + + out_kind.log(&output, t_bin); +} diff --git a/tests/action_macros.rs b/tooling/tests/action_macros.rs similarity index 100% rename from tests/action_macros.rs rename to tooling/tests/action_macros.rs diff --git a/tooling/xtask/Cargo.toml b/tooling/xtask/Cargo.toml new file mode 100644 index 0000000000..21090d1304 --- /dev/null +++ b/tooling/xtask/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "xtask" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[dependencies] +annotate-snippets = "0.12.1" +anyhow.workspace = true +backtrace.workspace = true +cargo_metadata.workspace = true +cargo_toml.workspace = true +clap = { workspace = true, features = ["derive"] } +toml.workspace = true +indoc.workspace = true +indexmap.workspace = true +itertools.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml = "0.9.34" +strum.workspace = true +toml_edit.workspace = true +gh-workflow.workspace = true diff --git a/tooling/xtask/LICENSE-GPL b/tooling/xtask/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/tooling/xtask/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/tooling/xtask/src/main.rs b/tooling/xtask/src/main.rs new file mode 100644 index 0000000000..05afe3c766 --- /dev/null +++ b/tooling/xtask/src/main.rs @@ -0,0 +1,43 @@ +mod tasks; +mod workspace; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "cargo xtask")] +struct Args { + #[command(subcommand)] + command: CliCommand, +} + +#[derive(Subcommand)] +enum CliCommand { + /// Runs `cargo clippy`. + Clippy(tasks::clippy::ClippyArgs), + Licenses(tasks::licenses::LicensesArgs), + /// Checks that packages conform to a set of standards. + PackageConformity(tasks::package_conformity::PackageConformityArgs), + /// Publishes GPUI and its dependencies to crates.io. + PublishGpui(tasks::publish_gpui::PublishGpuiArgs), + /// Builds GPUI web examples and serves them. + WebExamples(tasks::web_examples::WebExamplesArgs), + Workflows(tasks::workflows::GenerateWorkflowArgs), + CheckWorkflows(tasks::workflow_checks::WorkflowValidationArgs), +} + +fn main() -> Result<()> { + let args = Args::parse(); + + match args.command { + CliCommand::Clippy(args) => tasks::clippy::run_clippy(args), + CliCommand::Licenses(args) => tasks::licenses::run_licenses(args), + CliCommand::PackageConformity(args) => { + tasks::package_conformity::run_package_conformity(args) + } + CliCommand::PublishGpui(args) => tasks::publish_gpui::run_publish_gpui(args), + CliCommand::WebExamples(args) => tasks::web_examples::run_web_examples(args), + CliCommand::Workflows(args) => tasks::workflows::run_workflows(args), + CliCommand::CheckWorkflows(args) => tasks::workflow_checks::validate(args), + } +} diff --git a/tooling/xtask/src/tasks.rs b/tooling/xtask/src/tasks.rs new file mode 100644 index 0000000000..80f504fa03 --- /dev/null +++ b/tooling/xtask/src/tasks.rs @@ -0,0 +1,7 @@ +pub mod clippy; +pub mod licenses; +pub mod package_conformity; +pub mod publish_gpui; +pub mod web_examples; +pub mod workflow_checks; +pub mod workflows; diff --git a/tooling/xtask/src/tasks/clippy.rs b/tooling/xtask/src/tasks/clippy.rs new file mode 100644 index 0000000000..517223ceb8 --- /dev/null +++ b/tooling/xtask/src/tasks/clippy.rs @@ -0,0 +1,64 @@ +#![allow(clippy::disallowed_methods, reason = "tooling is exempt")] +use std::process::Command; + +use anyhow::{Context as _, Result, bail}; +use clap::Parser; + +#[derive(Parser)] +pub struct ClippyArgs { + /// Automatically apply lint suggestions (`clippy --fix`). + #[arg(long)] + fix: bool, + + /// The package to run Clippy against (`cargo -p clippy`). + #[arg(long, short)] + package: Option, +} + +pub fn run_clippy(args: ClippyArgs) -> Result<()> { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + + let mut clippy_command = Command::new(&cargo); + clippy_command.arg("clippy"); + + if let Some(package) = args.package.as_ref() { + clippy_command.args(["--package", package]); + } else { + clippy_command.arg("--workspace"); + } + + clippy_command + .arg("--release") + .arg("--all-targets") + .arg("--all-features"); + + if args.fix { + clippy_command.arg("--fix"); + } + + clippy_command.arg("--"); + + // Deny all warnings. + clippy_command.args(["--deny", "warnings"]); + + eprintln!( + "running: {cargo} {}", + clippy_command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect::>() + .join(" ") + ); + + let exit_status = clippy_command + .spawn() + .context("failed to spawn child process")? + .wait() + .context("failed to wait for child process")?; + + if !exit_status.success() { + bail!("clippy failed: {}", exit_status); + } + + Ok(()) +} diff --git a/tooling/xtask/src/tasks/licenses.rs b/tooling/xtask/src/tasks/licenses.rs new file mode 100644 index 0000000000..449c774d45 --- /dev/null +++ b/tooling/xtask/src/tasks/licenses.rs @@ -0,0 +1,45 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; +use clap::Parser; + +use crate::workspace::load_workspace; + +#[derive(Parser)] +pub struct LicensesArgs {} + +pub fn run_licenses(_args: LicensesArgs) -> Result<()> { + const LICENSE_FILES: &[&str] = &["LICENSE-APACHE", "LICENSE-GPL", "LICENSE-AGPL"]; + + let workspace = load_workspace()?; + + for package in workspace.workspace_packages() { + let crate_dir = package + .manifest_path + .parent() + .with_context(|| format!("no crate directory for {}", package.name))?; + + if let Some(license_file) = first_license_file(crate_dir, LICENSE_FILES) { + if !license_file.is_symlink() { + println!("{} is not a symlink", license_file.display()); + } + + continue; + } + + println!("Missing license: {}", package.name); + } + + Ok(()) +} + +fn first_license_file(path: impl AsRef, license_files: &[&str]) -> Option { + for license_file in license_files { + let path_to_license = path.as_ref().join(license_file); + if path_to_license.exists() { + return Some(path_to_license); + } + } + + None +} diff --git a/tooling/xtask/src/tasks/package_conformity.rs b/tooling/xtask/src/tasks/package_conformity.rs new file mode 100644 index 0000000000..e1fd15112f --- /dev/null +++ b/tooling/xtask/src/tasks/package_conformity.rs @@ -0,0 +1,75 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context as _, Result}; +use cargo_toml::{Dependency, Manifest}; +use clap::Parser; + +use crate::workspace::load_workspace; + +#[derive(Parser)] +pub struct PackageConformityArgs {} + +pub fn run_package_conformity(_args: PackageConformityArgs) -> Result<()> { + let workspace = load_workspace()?; + + let mut non_workspace_dependencies = BTreeMap::new(); + + for package in workspace.workspace_packages() { + let is_extension = package + .manifest_path + .parent() + .and_then(|parent| parent.parent()) + .is_some_and(|grandparent_dir| grandparent_dir.ends_with("extensions")); + + let cargo_toml = read_cargo_toml(&package.manifest_path)?; + + let is_using_workspace_lints = cargo_toml.lints.is_some_and(|lints| lints.workspace); + if !is_using_workspace_lints { + eprintln!( + "{package:?} is not using workspace lints", + package = package.name + ); + } + + // Extensions should not use workspace dependencies. + if is_extension || package.name == "zed_extension_api" { + continue; + } + + for dependencies in [ + &cargo_toml.dependencies, + &cargo_toml.dev_dependencies, + &cargo_toml.build_dependencies, + ] { + for (name, dependency) in dependencies { + if let Dependency::Inherited(_) = dependency { + continue; + } + + non_workspace_dependencies + .entry(name.to_owned()) + .or_insert_with(Vec::new) + .push(package.name.clone()); + } + } + } + + for (dependency, packages) in non_workspace_dependencies { + eprintln!( + "{dependency} is being used as a non-workspace dependency: {}", + packages.join(", ") + ); + } + + Ok(()) +} + +/// Returns the contents of the `Cargo.toml` file at the given path. +fn read_cargo_toml(path: impl AsRef) -> Result { + let path = path.as_ref(); + let cargo_toml_bytes = fs::read(path)?; + Manifest::from_slice(&cargo_toml_bytes) + .with_context(|| format!("reading Cargo.toml at {path:?}")) +} diff --git a/tooling/xtask/src/tasks/publish_gpui.rs b/tooling/xtask/src/tasks/publish_gpui.rs new file mode 100644 index 0000000000..2740f75a48 --- /dev/null +++ b/tooling/xtask/src/tasks/publish_gpui.rs @@ -0,0 +1,442 @@ +#![allow(clippy::disallowed_methods, reason = "tooling is exempt")] +use std::io::{self, Write}; +use std::process::{Command, Output, Stdio}; + +use anyhow::{Context as _, Result, bail}; +use clap::Parser; + +#[derive(Parser)] +pub struct PublishGpuiArgs { + /// Perform a dry-run and wait for user confirmation before each publish + #[arg(long)] + dry_run: bool, + + /// Skip to a specific package (by package name or crate name) and start from there + #[arg(long)] + skip_to: Option, +} + +pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { + println!( + "Starting GPUI publish process{}...", + if args.dry_run { " (with dry-run)" } else { "" } + ); + + let start_time = std::time::Instant::now(); + check_workspace_root()?; + + if args.skip_to.is_none() { + check_git_clean()?; + } else { + println!("Skipping git clean check due to --skip-to flag"); + } + + let version = read_gpui_version()?; + println!("Updating GPUI to version: {}", version); + publish_dependencies(&version, args.dry_run, args.skip_to.as_deref())?; + publish_gpui(&version, args.dry_run)?; + println!("GPUI published in {}s", start_time.elapsed().as_secs_f32()); + Ok(()) +} + +fn read_gpui_version() -> Result { + let gpui_cargo_toml_path = "crates/gpui/Cargo.toml"; + let contents = std::fs::read_to_string(gpui_cargo_toml_path) + .context("Failed to read crates/gpui/Cargo.toml")?; + + let cargo_toml: toml::Value = + toml::from_str(&contents).context("Failed to parse crates/gpui/Cargo.toml")?; + + let version = cargo_toml + .get("package") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + .context("Failed to find version in crates/gpui/Cargo.toml")?; + + Ok(version.to_string()) +} + +fn publish_dependencies(new_version: &str, dry_run: bool, skip_to: Option<&str>) -> Result<()> { + let gpui_dependencies = vec![ + ("collections", "gpui_collections", "crates"), + ("perf", "gpui_perf", "tooling"), + ("util_macros", "gpui_util_macros", "crates"), + ("util", "gpui_util", "crates"), + ("gpui_macros", "gpui-macros", "crates"), + ("http_client", "gpui_http_client", "crates"), + ( + "derive_refineable", + "gpui_derive_refineable", + "crates/refineable", + ), + ("refineable", "gpui_refineable", "crates"), + ("semantic_version", "gpui_semantic_version", "crates"), + ("sum_tree", "gpui_sum_tree", "crates"), + ("media", "gpui_media", "crates"), + ]; + + let mut should_skip = skip_to.is_some(); + let skip_target = skip_to.unwrap_or(""); + + for (package_name, crate_name, package_dir) in gpui_dependencies { + if should_skip { + if package_name == skip_target || crate_name == skip_target { + println!("Found skip target: {} ({})", crate_name, package_name); + should_skip = false; + } else { + println!("Skipping: {} ({})", crate_name, package_name); + continue; + } + } + + println!( + "Publishing dependency: {} (package: {})", + crate_name, package_name + ); + + update_crate_cargo_toml(package_name, crate_name, package_dir, new_version)?; + update_workspace_dependency_version(package_name, crate_name, new_version)?; + publish_crate(crate_name, dry_run)?; + } + + if should_skip { + bail!( + "Could not find package or crate named '{}' to skip to", + skip_target + ); + } + + Ok(()) +} + +fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> { + update_crate_cargo_toml("gpui", "gpui", "crates", new_version)?; + + publish_crate("gpui", dry_run)?; + + Ok(()) +} + +fn update_crate_cargo_toml( + package_name: &str, + crate_name: &str, + package_dir: &str, + new_version: &str, +) -> Result<()> { + let cargo_toml_path = format!("{}/{}/Cargo.toml", package_dir, package_name); + let contents = std::fs::read_to_string(&cargo_toml_path) + .context(format!("Failed to read {}", cargo_toml_path))?; + + let updated = update_crate_package_fields(&contents, crate_name, new_version)?; + + std::fs::write(&cargo_toml_path, updated) + .context(format!("Failed to write {}", cargo_toml_path))?; + + Ok(()) +} + +fn update_crate_package_fields( + toml_contents: &str, + crate_name: &str, + new_version: &str, +) -> Result { + let mut doc = toml_contents + .parse::() + .context("Failed to parse TOML")?; + + let package = doc + .get_mut("package") + .and_then(|p| p.as_table_like_mut()) + .context("Failed to find [package] section")?; + + package.insert("name", toml_edit::value(crate_name)); + package.insert("version", toml_edit::value(new_version)); + package.insert("publish", toml_edit::value(true)); + + Ok(doc.to_string()) +} + +fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> { + let publish_crate_impl = |crate_name, dry_run| { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + + let mut command = Command::new(&cargo); + command + .arg("publish") + .arg("--allow-dirty") + .args(["-p", crate_name]); + + if dry_run { + command.arg("--dry-run"); + } + + run_command(&mut command)?; + + anyhow::Ok(()) + }; + + if dry_run { + publish_crate_impl(crate_name, true)?; + + print!("Press Enter to publish for real (or ctrl-c to abort)..."); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + } + + publish_crate_impl(crate_name, false)?; + + Ok(()) +} + +fn update_workspace_dependency_version( + package_name: &str, + crate_name: &str, + new_version: &str, +) -> Result<()> { + let workspace_cargo_toml_path = "Cargo.toml"; + let contents = std::fs::read_to_string(workspace_cargo_toml_path) + .context("Failed to read workspace Cargo.toml")?; + + let mut doc = contents + .parse::() + .context("Failed to parse TOML")?; + + update_dependency_version_in_doc(&mut doc, package_name, crate_name, new_version)?; + update_profile_override_in_doc(&mut doc, package_name, crate_name)?; + + std::fs::write(workspace_cargo_toml_path, doc.to_string()) + .context("Failed to write workspace Cargo.toml")?; + + Ok(()) +} + +fn update_dependency_version_in_doc( + doc: &mut toml_edit::DocumentMut, + package_name: &str, + crate_name: &str, + new_version: &str, +) -> Result<()> { + let dependency = doc + .get_mut("workspace") + .and_then(|w| w.get_mut("dependencies")) + .and_then(|d| d.get_mut(package_name)) + .context(format!( + "Failed to find {} in workspace dependencies", + package_name + ))?; + + if let Some(dep_table) = dependency.as_table_like_mut() { + dep_table.insert("version", toml_edit::value(new_version)); + dep_table.insert("package", toml_edit::value(crate_name)); + } else { + bail!("{} is not a table in workspace dependencies", package_name); + } + + Ok(()) +} + +fn update_profile_override_in_doc( + doc: &mut toml_edit::DocumentMut, + package_name: &str, + crate_name: &str, +) -> Result<()> { + if let Some(profile_dev_package) = doc + .get_mut("profile") + .and_then(|p| p.get_mut("dev")) + .and_then(|d| d.get_mut("package")) + .and_then(|p| p.as_table_like_mut()) + { + if let Some(old_entry) = profile_dev_package.get(package_name) { + let old_entry_clone = old_entry.clone(); + profile_dev_package.remove(package_name); + profile_dev_package.insert(crate_name, old_entry_clone); + } + } + + Ok(()) +} + +fn check_workspace_root() -> Result<()> { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + + // Check if Cargo.toml exists in the current directory + let cargo_toml_path = cwd.join("Cargo.toml"); + if !cargo_toml_path.exists() { + bail!( + "Cargo.toml not found in current directory. Please run this command from the workspace root." + ); + } + + // Check if it's a workspace by looking for [workspace] section + let contents = + std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?; + + if !contents.contains("[workspace]") { + bail!( + "Current directory does not appear to be a workspace root. Please run this command from the workspace root." + ); + } + + Ok(()) +} + +fn check_git_clean() -> Result<()> { + let output = run_command( + Command::new("git") + .args(["status", "--porcelain"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()), + )?; + + if !output.status.success() { + bail!("git status command failed"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + bail!( + "Working directory is not clean. Please commit or stash your changes before publishing." + ); + } + + Ok(()) +} + +fn run_command(command: &mut Command) -> Result { + let command_str = { + let program = command.get_program().to_string_lossy(); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "); + + if args.is_empty() { + program.to_string() + } else { + format!("{} {}", program, args) + } + }; + eprintln!("+ {}", command_str); + + let output = command + .spawn() + .context("failed to spawn child process")? + .wait_with_output() + .context("failed to wait for child process")?; + + if !output.status.success() { + bail!("Command failed with status {}", output.status); + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use super::*; + + #[test] + fn test_update_dependency_version_in_toml() { + let input = indoc! {r#" + [workspace] + resolver = "2" + + [workspace.dependencies] + # here's a comment + collections = { path = "crates/collections" } + + util = { path = "crates/util", package = "zed-util", version = "0.1.0" } + "#}; + + let mut doc = input.parse::().unwrap(); + + update_dependency_version_in_doc(&mut doc, "collections", "gpui_collections", "0.2.0") + .unwrap(); + + let result = doc.to_string(); + + let output = indoc! {r#" + [workspace] + resolver = "2" + + [workspace.dependencies] + # here's a comment + collections = { path = "crates/collections" , version = "0.2.0", package = "gpui_collections" } + + util = { path = "crates/util", package = "zed-util", version = "0.1.0" } + "#}; + + assert_eq!(result, output); + } + + #[test] + fn test_update_crate_package_fields() { + let input = indoc! {r#" + [package] + name = "collections" + version = "0.1.0" + edition = "2021" + publish = false + # some comment about the license + license = "GPL-3.0-or-later" + + [dependencies] + serde = "1.0" + "#}; + + let result = update_crate_package_fields(input, "gpui_collections", "0.2.0").unwrap(); + + let output = indoc! {r#" + [package] + name = "gpui_collections" + version = "0.2.0" + edition = "2021" + publish = true + # some comment about the license + license = "GPL-3.0-or-later" + + [dependencies] + serde = "1.0" + "#}; + + assert_eq!(result, output); + } + + #[test] + fn test_update_profile_override_in_toml() { + let input = indoc! {r#" + [profile.dev] + split-debuginfo = "unpacked" + + [profile.dev.package] + taffy = { opt-level = 3 } + collections = { codegen-units = 256 } + refineable = { codegen-units = 256 } + util = { codegen-units = 256 } + "#}; + + let mut doc = input.parse::().unwrap(); + + update_profile_override_in_doc(&mut doc, "collections", "gpui_collections").unwrap(); + + let result = doc.to_string(); + + let output = indoc! {r#" + [profile.dev] + split-debuginfo = "unpacked" + + [profile.dev.package] + taffy = { opt-level = 3 } + refineable = { codegen-units = 256 } + util = { codegen-units = 256 } + gpui_collections = { codegen-units = 256 } + "#}; + + assert_eq!(result, output); + } +} diff --git a/tooling/xtask/src/tasks/web_examples.rs b/tooling/xtask/src/tasks/web_examples.rs new file mode 100644 index 0000000000..5b8e0fdd61 --- /dev/null +++ b/tooling/xtask/src/tasks/web_examples.rs @@ -0,0 +1,338 @@ +#![allow(clippy::disallowed_methods, reason = "tooling is exempt")] + +use std::io::Write; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context as _, Result, bail}; +use clap::Parser; + +#[derive(Parser)] +pub struct WebExamplesArgs { + #[arg(long)] + pub release: bool, + #[arg(long, default_value = "8080")] + pub port: u16, + #[arg(long)] + pub no_serve: bool, +} + +fn check_program(binary: &str, install_hint: &str) -> Result<()> { + match Command::new(binary).arg("--version").output() { + Ok(output) if output.status.success() => Ok(()), + _ => bail!("`{binary}` not found. Install with: {install_hint}"), + } +} + +fn discover_examples() -> Result> { + let examples_dir = Path::new("crates/gpui/examples"); + let mut names = Vec::new(); + + for entry in std::fs::read_dir(examples_dir).context("failed to read crates/gpui/examples")? { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) == Some("rs") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + names.push(stem.to_string()); + } + } + } + + if names.is_empty() { + bail!("no examples found in crates/gpui/examples"); + } + + names.sort(); + Ok(names) +} + +pub fn run_web_examples(args: WebExamplesArgs) -> Result<()> { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + let profile = if args.release { "release" } else { "debug" }; + let out_dir = "target/web-examples"; + + check_program("wasm-bindgen", "cargo install wasm-bindgen-cli")?; + + let examples = discover_examples()?; + eprintln!( + "Building {} example(s) for wasm32-unknown-unknown ({profile})...\n", + examples.len() + ); + + std::fs::create_dir_all(out_dir).context("failed to create output directory")?; + + eprintln!("Building all examples..."); + + let mut cmd = Command::new(&cargo); + cmd.args([ + "build", + "--target", + "wasm32-unknown-unknown", + "-p", + "gpui", + "--keep-going", + ]); + // 🙈 + cmd.env("RUSTC_BOOTSTRAP", "1"); + for name in &examples { + cmd.args(["--example", name]); + } + if args.release { + cmd.arg("--release"); + } + + let _ = cmd.status().context("failed to run cargo build")?; + + // Run wasm-bindgen on each .wasm that was produced. + let mut succeeded: Vec = Vec::new(); + let mut failed: Vec = Vec::new(); + + for name in &examples { + let wasm_path = format!("target/wasm32-unknown-unknown/{profile}/examples/{name}.wasm"); + if !Path::new(&wasm_path).exists() { + eprintln!("[{name}] SKIPPED (build failed)"); + failed.push(name.clone()); + continue; + } + + eprintln!("[{name}] Running wasm-bindgen..."); + + let example_dir = format!("{out_dir}/{name}"); + std::fs::create_dir_all(&example_dir) + .with_context(|| format!("failed to create {example_dir}"))?; + + let status = Command::new("wasm-bindgen") + .args([ + &wasm_path, + "--target", + "web", + "--no-typescript", + "--out-dir", + &example_dir, + "--out-name", + name, + ]) + // 🙈 + .env("RUSTC_BOOTSTRAP", "1") + .status() + .context("failed to run wasm-bindgen")?; + if !status.success() { + eprintln!("[{name}] SKIPPED (wasm-bindgen failed)"); + failed.push(name.clone()); + continue; + } + + // Write per-example index.html. + let html_path = format!("{example_dir}/index.html"); + std::fs::File::create(&html_path) + .and_then(|mut file| file.write_all(make_example_html(name).as_bytes())) + .with_context(|| format!("failed to write {html_path}"))?; + + eprintln!("[{name}] OK"); + succeeded.push(name.clone()); + } + + if succeeded.is_empty() { + bail!("all {} examples failed to build", examples.len()); + } + + let example_names: Vec<&str> = succeeded.iter().map(|s| s.as_str()).collect(); + let index_path = format!("{out_dir}/index.html"); + std::fs::File::create(&index_path) + .and_then(|mut file| file.write_all(make_gallery_html(&example_names).as_bytes())) + .context("failed to write index.html")?; + + if args.no_serve { + return Ok(()); + } + + // Serve with COEP/COOP headers required for WebGPU / SharedArrayBuffer. + eprintln!("Serving on http://127.0.0.1:{}...", args.port); + + let server_script = format!( + r#" +import http.server +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory="{out_dir}", **kwargs) + def end_headers(self): + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + super().end_headers() +http.server.HTTPServer(("127.0.0.1", {port}), Handler).serve_forever() +"#, + port = args.port, + ); + + let status = Command::new("python3") + .args(["-c", &server_script]) + .status() + .context("failed to run python3 http server (is python3 installed?)")?; + if !status.success() { + bail!("python3 http server exited with: {status}"); + } + + Ok(()) +} + +fn make_example_html(name: &str) -> String { + format!( + r#" + + + + + GPUI Web: {name} + + + +

Loading {name}…
+ + + +"# + ) +} + +fn make_gallery_html(examples: &[&str]) -> String { + let mut buttons = String::new(); + for name in examples { + buttons.push_str(&format!( + " \n" + )); + } + + let first = examples.first().copied().unwrap_or("hello_web"); + + format!( + r##" + + + + + GPUI Web Examples + + + +
+ +
+
+ {first} + Open in new tab ↗ +
+ +
+
+ + + +"##, + count = examples.len(), + ) +} diff --git a/tooling/xtask/src/tasks/workflow_checks.rs b/tooling/xtask/src/tasks/workflow_checks.rs new file mode 100644 index 0000000000..d6be029932 --- /dev/null +++ b/tooling/xtask/src/tasks/workflow_checks.rs @@ -0,0 +1,118 @@ +mod check_run_patterns; + +use std::{fs, path::PathBuf}; + +use annotate_snippets::Renderer; +use anyhow::{Result, anyhow}; +use clap::Parser; +use itertools::{Either, Itertools}; +use serde_yaml::Value; +use strum::IntoEnumIterator; + +use crate::tasks::{ + workflow_checks::check_run_patterns::{ + RunValidationError, WorkflowFile, WorkflowValidationError, + }, + workflows::WorkflowType, +}; + +pub use check_run_patterns::validate_run_command; + +#[derive(Default, Parser)] +pub struct WorkflowValidationArgs {} + +pub fn validate(_: WorkflowValidationArgs) -> Result<()> { + let (parsing_errors, file_errors): (Vec<_>, Vec<_>) = get_all_workflow_files() + .map(check_workflow) + .flat_map(Result::err) + .partition_map(|error| match error { + WorkflowError::ParseError(error) => Either::Left(error), + WorkflowError::ValidationError(error) => Either::Right(error), + }); + + if !parsing_errors.is_empty() { + Err(anyhow!( + "Failed to read or parse some workflow files: {}", + parsing_errors.into_iter().join("\n") + )) + } else if !file_errors.is_empty() { + let errors: Vec<_> = file_errors + .iter() + .map(|error| error.annotation_group()) + .collect(); + + let renderer = + Renderer::styled().decor_style(annotate_snippets::renderer::DecorStyle::Ascii); + println!("{}", renderer.render(errors.as_slice())); + + Err(anyhow!("Workflow checks failed!")) + } else { + Ok(()) + } +} + +enum WorkflowError { + ParseError(anyhow::Error), + ValidationError(Box), +} + +fn get_all_workflow_files() -> impl Iterator { + WorkflowType::iter() + .map(|workflow_type| workflow_type.folder_path()) + .flat_map(|folder_path| { + fs::read_dir(folder_path).into_iter().flat_map(|entries| { + entries + .flat_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext == "yaml" || ext == "yml") + }) + }) + }) +} + +fn check_workflow(workflow_file_path: PathBuf) -> Result<(), WorkflowError> { + fn collect_errors( + iter: impl Iterator>>, + ) -> Result<(), Vec> { + Some(iter.flat_map(Result::err).flatten().collect::>()) + .filter(|errors| !errors.is_empty()) + .map_or(Ok(()), Err) + } + + fn check_recursive(key: &Value, value: &Value) -> Result<(), Vec> { + match value { + Value::Mapping(mapping) => collect_errors( + mapping + .into_iter() + .map(|(key, value)| check_recursive(key, value)), + ), + Value::Sequence(sequence) => collect_errors( + sequence + .into_iter() + .map(|value| check_recursive(key, value)), + ), + Value::String(string) => check_string(key, string).map_err(|error| vec![error]), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Tagged(_) => Ok(()), + } + } + + let file_content = + WorkflowFile::load(&workflow_file_path).map_err(WorkflowError::ParseError)?; + + check_recursive(&Value::Null, &file_content.parsed_content).map_err(|errors| { + WorkflowError::ValidationError(Box::new(WorkflowValidationError::new( + errors, + file_content, + workflow_file_path, + ))) + }) +} + +fn check_string(key: &Value, value: &str) -> Result<(), RunValidationError> { + match key { + Value::String(key) if key == "run" => validate_run_command(value), + _ => Ok(()), + } +} diff --git a/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs b/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs new file mode 100644 index 0000000000..50c435d033 --- /dev/null +++ b/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs @@ -0,0 +1,124 @@ +use annotate_snippets::{AnnotationKind, Group, Level, Snippet}; +use anyhow::{Result, anyhow}; +use regex::Regex; +use serde_yaml::Value; +use std::{ + collections::HashMap, + fs, + ops::Range, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +static GITHUB_INPUT_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"\$\{\{[[:blank:]]*([[:alnum:]]|[[:punct:]])+?[[:blank:]]*\}\}"#) + .expect("Should compile") +}); + +pub struct WorkflowFile { + raw_content: String, + pub parsed_content: Value, +} + +impl WorkflowFile { + pub fn load(workflow_file_path: &Path) -> Result { + fs::read_to_string(workflow_file_path) + .map_err(|_| { + anyhow!( + "Could not read workflow file at {}", + workflow_file_path.display() + ) + }) + .and_then(|file_content| { + serde_yaml::from_str(&file_content) + .map(|parsed_content| Self { + raw_content: file_content, + parsed_content, + }) + .map_err(|e| anyhow!("Failed to parse workflow file: {e:?}")) + }) + } +} + +pub struct WorkflowValidationError { + file_path: PathBuf, + contents: WorkflowFile, + errors: Vec, +} + +impl WorkflowValidationError { + pub fn new( + errors: Vec, + contents: WorkflowFile, + file_path: PathBuf, + ) -> Self { + Self { + file_path, + contents, + errors, + } + } + + pub fn annotation_group<'a>(&'a self) -> Group<'a> { + let raw_content = &self.contents.raw_content; + let mut identical_lines = HashMap::new(); + + let ranges = self + .errors + .iter() + .flat_map(|error| error.found_injection_patterns.iter()) + .map(|(line, pattern_range)| { + let initial_offset = identical_lines + .get(&(line.as_str(), pattern_range.start)) + .copied() + .unwrap_or_default(); + + let line_start = raw_content[initial_offset..] + .find(line.as_str()) + .map(|offset| offset + initial_offset) + .unwrap_or_default(); + + let pattern_start = line_start + pattern_range.start; + let pattern_end = pattern_start + pattern_range.len(); + + identical_lines.insert((line.as_str(), pattern_range.start), pattern_end); + + pattern_start..pattern_end + }); + + Level::ERROR + .primary_title("Found GitHub input injection in run command") + .element( + Snippet::source(&self.contents.raw_content) + .path(self.file_path.display().to_string()) + .annotations(ranges.map(|range| { + AnnotationKind::Primary + .span(range) + .label("This should be passed via an environment variable") + })), + ) + } +} + +pub struct RunValidationError { + found_injection_patterns: Vec<(String, Range)>, +} + +pub fn validate_run_command(command: &str) -> Result<(), RunValidationError> { + let patterns: Vec<_> = command + .lines() + .flat_map(move |line| { + GITHUB_INPUT_PATTERN + .find_iter(line) + .map(|m| (line.to_owned(), m.range())) + }) + .collect(); + + if patterns.is_empty() { + Ok(()) + } else { + Err(RunValidationError { + found_injection_patterns: patterns, + }) + } +} diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs new file mode 100644 index 0000000000..35f053f466 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows.rs @@ -0,0 +1,224 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use gh_workflow::Workflow; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::tasks::workflow_checks::{self}; + +mod after_release; +mod autofix_pr; +mod bump_patch_version; +mod cherry_pick; +mod compare_perf; +mod danger; +mod deploy_collab; +mod extension_auto_bump; +mod extension_bump; +mod extension_tests; +mod extension_workflow_rollout; +mod extensions; +mod nix_build; +mod publish_extension_cli; +mod release_nightly; +mod run_bundling; + +mod release; +mod run_agent_evals; +mod run_tests; +mod runners; +mod steps; +mod vars; + +#[derive(Clone)] +pub(crate) struct GitSha(String); + +impl AsRef for GitSha { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[allow( + clippy::disallowed_methods, + reason = "This runs only in a CLI environment" +)] +fn parse_ref(value: &str) -> Result { + const GIT_SHA_LENGTH: usize = 40; + (value.len() == GIT_SHA_LENGTH) + .then_some(value) + .ok_or_else(|| { + format!( + "Git SHA has wrong length! \ + Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", + len = value.len() + ) + }) + .and_then(|value| { + let mut tmp = [0; 4]; + value + .chars() + .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value) + .ok_or_else(|| "Not a valid Git SHA".to_owned()) + }) + .and_then(|sha| { + std::process::Command::new("git") + .args([ + "rev-parse", + "--quiet", + "--verify", + &format!("{sha}^{{commit}}") + ]) + .output() + .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned()) + .and_then(|output| + output + .status.success() + .then_some(sha) + .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!"))) + }).map(|sha| GitSha(sha.to_owned())) +} + +#[derive(Parser)] +pub(crate) struct GenerateWorkflowArgs { + #[arg(value_parser = parse_ref)] + /// The Git SHA to use when invoking this + pub(crate) sha: Option, +} + +enum WorkflowSource { + Contextless(fn() -> Workflow), + WithContext(fn(&GenerateWorkflowArgs) -> Workflow), +} + +struct WorkflowFile { + source: WorkflowSource, + r#type: WorkflowType, +} + +impl WorkflowFile { + fn zed(f: fn() -> Workflow) -> WorkflowFile { + WorkflowFile { + source: WorkflowSource::Contextless(f), + r#type: WorkflowType::Zed, + } + } + + fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { + WorkflowFile { + source: WorkflowSource::WithContext(f), + r#type: WorkflowType::ExtensionCi, + } + } + + fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { + WorkflowFile { + source: WorkflowSource::WithContext(f), + r#type: WorkflowType::ExtensionsShared, + } + } + + fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> { + let workflow = match &self.source { + WorkflowSource::Contextless(f) => f(), + WorkflowSource::WithContext(f) => f(workflow_args), + }; + let workflow_folder = self.r#type.folder_path(); + + fs::create_dir_all(&workflow_folder).with_context(|| { + format!("Failed to create directory: {}", workflow_folder.display()) + })?; + + let workflow_name = workflow + .name + .as_ref() + .expect("Workflow must have a name at this point"); + let filename = format!( + "{}.yml", + workflow_name.rsplit("::").next().unwrap_or(workflow_name) + ); + + let workflow_path = workflow_folder.join(filename); + + let content = workflow + .to_string() + .map_err(|e| anyhow::anyhow!("{:?}: {:?}", workflow_path, e))?; + + let disclaimer = self.r#type.disclaimer(workflow_name); + + let content = [disclaimer, content].join("\n"); + fs::write(&workflow_path, content).map_err(Into::into) + } +} + +#[derive(PartialEq, Eq, strum::EnumIter)] +pub enum WorkflowType { + /// Workflows living in the Zed repository + Zed, + /// Workflows living in the `zed-extensions/workflows` repository that are + /// required workflows for PRs to the extension organization + ExtensionCi, + /// Workflows living in each of the extensions to perform checks and version + /// bumps until a better, more centralized system for that is in place. + ExtensionsShared, +} + +impl WorkflowType { + fn disclaimer(&self, workflow_name: &str) -> String { + format!( + concat!( + "# Generated from xtask::workflows::{}{}\n", + "# Rebuild with `cargo xtask workflows`.", + ), + workflow_name, + (*self != WorkflowType::Zed) + .then_some(" within the Zed repository.") + .unwrap_or_default(), + ) + } + + pub fn folder_path(&self) -> PathBuf { + match self { + WorkflowType::Zed => PathBuf::from(".github/workflows"), + WorkflowType::ExtensionCi => PathBuf::from("extensions/workflows"), + WorkflowType::ExtensionsShared => PathBuf::from("extensions/workflows/shared"), + } + } +} + +pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { + if !Path::new("crates/zed/").is_dir() { + anyhow::bail!("xtask workflows must be ran from the project root"); + } + + let workflows = [ + WorkflowFile::zed(after_release::after_release), + WorkflowFile::zed(autofix_pr::autofix_pr), + WorkflowFile::zed(bump_patch_version::bump_patch_version), + WorkflowFile::zed(cherry_pick::cherry_pick), + WorkflowFile::zed(compare_perf::compare_perf), + WorkflowFile::zed(danger::danger), + WorkflowFile::zed(deploy_collab::deploy_collab), + WorkflowFile::zed(extension_bump::extension_bump), + WorkflowFile::zed(extension_auto_bump::extension_auto_bump), + WorkflowFile::zed(extension_tests::extension_tests), + WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout), + WorkflowFile::zed(publish_extension_cli::publish_extension_cli), + WorkflowFile::zed(release::release), + WorkflowFile::zed(release_nightly::release_nightly), + WorkflowFile::zed(run_agent_evals::run_agent_evals), + WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), + WorkflowFile::zed(run_agent_evals::run_unit_evals), + WorkflowFile::zed(run_bundling::run_bundling), + WorkflowFile::zed(run_tests::run_tests), + /* workflows used for CI/CD in extension repositories */ + WorkflowFile::extension(extensions::run_tests::run_tests), + WorkflowFile::extension_shared(extensions::bump_version::bump_version), + ]; + + for workflow_file in workflows { + workflow_file.generate_file(&args)?; + } + + workflow_checks::validate(Default::default()) +} diff --git a/tooling/xtask/src/tasks/workflows/after_release.rs b/tooling/xtask/src/tasks/workflows/after_release.rs new file mode 100644 index 0000000000..07ff1fba0d --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/after_release.rs @@ -0,0 +1,185 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + release::{self, notify_on_failure}, + runners, + steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +const TAG_NAME: &str = "${{ github.event.release.tag_name || inputs.tag_name }}"; +const IS_PRERELEASE: &str = "${{ github.event.release.prerelease || inputs.prerelease }}"; +const RELEASE_BODY: &str = "${{ github.event.release.body || inputs.body }}"; + +pub fn after_release() -> Workflow { + let tag_name = WorkflowInput::string("tag_name", None); + let prerelease = WorkflowInput::bool("prerelease", None); + let body = WorkflowInput::string("body", Some(String::new())); + + let refresh_zed_dev = rebuild_releases_page(); + let post_to_discord = post_to_discord(&[&refresh_zed_dev]); + let publish_winget = publish_winget(); + let create_sentry_release = create_sentry_release(); + let notify_on_failure = notify_on_failure(&[ + &refresh_zed_dev, + &post_to_discord, + &publish_winget, + &create_sentry_release, + ]); + + named::workflow() + .on(Event::default() + .release(Release::default().types(vec![ReleaseType::Published])) + .workflow_dispatch( + WorkflowDispatch::default() + .add_input(tag_name.name, tag_name.input()) + .add_input(prerelease.name, prerelease.input()) + .add_input(body.name, body.input()), + )) + .add_job(refresh_zed_dev.name, refresh_zed_dev.job) + .add_job(post_to_discord.name, post_to_discord.job) + .add_job(publish_winget.name, publish_winget.job) + .add_job(create_sentry_release.name, create_sentry_release.job) + .add_job(notify_on_failure.name, notify_on_failure.job) +} + +fn rebuild_releases_page() -> NamedJob { + fn refresh_cloud_releases() -> Step { + named::bash(format!( + "curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag={TAG_NAME}" + )) + } + + fn redeploy_zed_dev() -> Step { + named::bash("./script/redeploy-vercel").add_env(("VERCEL_TOKEN", vars::VERCEL_TOKEN)) + } + + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .add_step(refresh_cloud_releases()) + .add_step(checkout_repo()) + .add_step(redeploy_zed_dev()), + ) +} + +fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { + fn get_release_url() -> Step { + named::bash(format!( + r#"if [ "{IS_PRERELEASE}" == "true" ]; then + URL="https://zed.dev/releases/preview" +else + URL="https://zed.dev/releases/stable" +fi + +echo "URL=$URL" >> "$GITHUB_OUTPUT" +"# + )) + .id("get-release-url") + } + + fn get_content() -> Step { + named::uses( + "2428392", + "gh-truncate-string-action", + "b3ff790d21cf42af3ca7579146eedb93c8fb0757", // v1.4.1 + ) + .id("get-content") + .add_with(( + "stringToTruncate", + format!( + "📣 Zed [{TAG_NAME}](<${{{{ steps.get-release-url.outputs.URL }}}}>) was just released!\n\n{RELEASE_BODY}\n" + ), + )) + .add_with(("maxLength", 2000)) + .add_with(("truncationSymbol", "...")) + } + + fn discord_webhook_action() -> Step { + named::uses( + "tsickert", + "discord-webhook", + "c840d45a03a323fbc3f7507ac7769dbd91bfb164", // v5.3.0 + ) + .add_with(("webhook-url", vars::DISCORD_WEBHOOK_RELEASE_NOTES)) + .add_with(("content", "${{ steps.get-content.outputs.string }}")) + } + let job = dependant_job(deps) + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .add_step(get_release_url()) + .add_step(get_content()) + .add_step(discord_webhook_action()); + named::job(job) +} + +fn publish_winget() -> NamedJob { + fn sync_winget_pkgs_fork() -> Step { + named::pwsh(indoc::indoc! {r#" + $headers = @{ + "Authorization" = "Bearer $env:WINGET_TOKEN" + "Accept" = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + } + $body = @{ branch = "master" } | ConvertTo-Json + $uri = "https://api.github.com/repos/$env:GITHUB_REPOSITORY_OWNER/winget-pkgs/merge-upstream" + try { + Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $body -ContentType "application/json" + Write-Host "Successfully synced winget-pkgs fork" + } catch { + Write-Host "Fork sync response: $_" + Write-Host "Continuing anyway - fork may already be up to date" + } + "#}) + .add_env(("WINGET_TOKEN", vars::WINGET_TOKEN)) + } + + fn set_package_name() -> (Step, StepOutput) { + let script = format!( + r#"if ("{IS_PRERELEASE}" -eq "true") {{ + $PACKAGE_NAME = "ZedIndustries.Zed.Preview" +}} else {{ + $PACKAGE_NAME = "ZedIndustries.Zed" +}} + +echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT +"# + ); + let step = named::pwsh(&script).id("set-package-name"); + + let output = StepOutput::new(&step, "PACKAGE_NAME"); + (step, output) + } + + fn winget_releaser(package_name: &StepOutput) -> Step { + named::uses( + "vedantmgoyal9", + "winget-releaser", + "19e706d4c9121098010096f9c495a70a7518b30f", // v2 + ) + .add_with(("identifier", package_name.to_string())) + .add_with(("release-tag", TAG_NAME)) + .add_with(("max-versions-to-keep", 5)) + .add_with(("token", vars::WINGET_TOKEN)) + } + + let (set_package_name, package_name) = set_package_name(); + + named::job( + Job::default() + .runs_on(runners::WINDOWS_DEFAULT) + .add_step(sync_winget_pkgs_fork()) + .add_step(set_package_name) + .add_step(winget_releaser(&package_name)), + ) +} + +fn create_sentry_release() -> NamedJob { + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .add_step(checkout_repo()) + .add_step(release::create_sentry_release()); + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs new file mode 100644 index 0000000000..2779dc2b01 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -0,0 +1,181 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, FluentBuilder, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +pub fn autofix_pr() -> Workflow { + let pr_number = WorkflowInput::string("pr_number", None); + let run_clippy = WorkflowInput::bool("run_clippy", Some(true)); + let run_autofix = run_autofix(&pr_number, &run_clippy); + let commit_changes = commit_changes(&pr_number, &run_autofix); + named::workflow() + .run_name(format!("autofix PR #{pr_number}")) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(pr_number.name, pr_number.input()) + .add_input(run_clippy.name, run_clippy.input()), + )) + .concurrency( + Concurrency::new(Expression::new(format!( + "${{{{ github.workflow }}}}-{pr_number}" + ))) + .cancel_in_progress(true), + ) + .add_job(run_autofix.name.clone(), run_autofix.job) + .add_job(commit_changes.name, commit_changes.job) +} + +const PATCH_ARTIFACT_NAME: &str = "autofix-patch"; +const PATCH_FILE_PATH: &str = "autofix.patch"; + +fn upload_patch_artifact() -> Step { + Step::new(format!("upload artifact {}", PATCH_ARTIFACT_NAME)) + .uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .add_with(("name", PATCH_ARTIFACT_NAME)) + .add_with(("path", PATCH_FILE_PATH)) + .add_with(("if-no-files-found", "ignore")) + .add_with(("retention-days", "1")) +} + +fn download_patch_artifact() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", PATCH_ARTIFACT_NAME)) +} + +fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJob { + fn checkout_pr(pr_number: &WorkflowInput) -> Step { + named::bash(r#"gh pr checkout "$PR_NUMBER""#) + .add_env(("PR_NUMBER", pr_number.to_string())) + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)) + } + + fn install_cargo_machete() -> Step { + named::uses( + "clechasseur", + "rs-cargo", + "8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2 + ) + .add_with(("command", "install")) + .add_with(("args", "cargo-machete@0.7.0")) + } + + fn run_cargo_fmt() -> Step { + named::bash("cargo fmt --all") + } + + fn run_cargo_fix() -> Step { + named::bash( + "cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged", + ) + } + + fn run_cargo_machete_fix() -> Step { + named::bash("cargo machete --fix") + } + + fn run_clippy_fix() -> Step { + named::bash( + "cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged", + ) + } + + fn run_prettier_fix() -> Step { + named::bash("./script/prettier --write") + } + + fn create_patch() -> Step { + named::bash(indoc::indoc! {r#" + if git diff --quiet; then + echo "No changes to commit" + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + git diff > autofix.patch + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + "#}) + .id("create-patch") + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .outputs([( + "has_changes".to_owned(), + "${{ steps.create-patch.outputs.has_changes }}".to_owned(), + )]) + .add_step(steps::checkout_repo()) + .add_step(checkout_pr(pr_number)) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_pnpm()) + .add_step(install_cargo_machete().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(run_cargo_fix().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(run_cargo_machete_fix().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(run_clippy_fix().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(run_prettier_fix()) + .add_step(run_cargo_fmt()) + .add_step(create_patch()) + .add_step(upload_patch_artifact()) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} + +fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob { + fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step { + named::bash(r#"gh pr checkout "$PR_NUMBER""#) + .add_env(("PR_NUMBER", pr_number.to_string())) + .add_env(("GITHUB_TOKEN", token)) + } + + fn apply_patch() -> Step { + named::bash("git apply autofix.patch") + } + + fn commit_and_push(token: &StepOutput) -> Step { + named::bash(indoc::indoc! {r#" + git commit -am "Autofix" + git push + "#}) + .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) + .add_env(( + "GIT_COMMITTER_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GIT_AUTHOR_NAME", "Zed Zippy")) + .add_env(( + "GIT_AUTHOR_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = steps::authenticate_as_zippy(); + + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .needs(vec![autofix_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.has_changes == 'true'", + autofix_job.name + ))) + .add_step(authenticate) + .add_step(steps::checkout_repo().with_token(&token)) + .add_step(checkout_pr(pr_number, &token)) + .add_step(download_patch_artifact()) + .add_step(apply_patch()) + .add_step(commit_and_push(&token)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/bump_patch_version.rs b/tooling/xtask/src/tasks/workflows/bump_patch_version.rs new file mode 100644 index 0000000000..5ef149be29 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/bump_patch_version.rs @@ -0,0 +1,78 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, CheckoutStep, named}, + vars::{StepOutput, WorkflowInput}, +}; + +pub fn bump_patch_version() -> Workflow { + let branch = WorkflowInput::string("branch", None).description("Branch name to run on"); + let bump_patch_version_job = run_bump_patch_version(&branch); + named::workflow() + .on(Event::default() + .workflow_dispatch(WorkflowDispatch::default().add_input(branch.name, branch.input()))) + .concurrency( + Concurrency::new(Expression::new(format!( + "${{{{ github.workflow }}}}-{branch}" + ))) + .cancel_in_progress(true), + ) + .add_job(bump_patch_version_job.name, bump_patch_version_job.job) +} + +fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob { + fn checkout_branch(branch: &WorkflowInput, token: &StepOutput) -> CheckoutStep { + steps::checkout_repo() + .with_token(token) + .with_ref(branch.to_string()) + } + + fn bump_patch_version(token: &StepOutput) -> Step { + named::bash(indoc::indoc! {r#" + channel="$(cat crates/zed/RELEASE_CHANNEL)" + + tag_suffix="" + case $channel in + stable) + ;; + preview) + tag_suffix="-pre" + ;; + *) + echo "this must be run on either of stable|preview release branches" >&2 + exit 1 + ;; + esac + which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version" + output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')" + git commit -am "Bump to $output for @$GITHUB_ACTOR" + git tag "v${output}${tag_suffix}" + git push origin HEAD "v${output}${tag_suffix}" + "#}) + .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) + .add_env(( + "GIT_COMMITTER_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GIT_AUTHOR_NAME", "Zed Zippy")) + .add_env(( + "GIT_AUTHOR_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = steps::authenticate_as_zippy(); + + named::job( + Job::default() + .cond(Expression::new( + "github.repository_owner == 'zed-industries'", + )) + .runs_on(runners::LINUX_XL) + .add_step(authenticate) + .add_step(checkout_branch(branch, &token)) + .add_step(bump_patch_version(&token)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/cherry_pick.rs b/tooling/xtask/src/tasks/workflows/cherry_pick.rs new file mode 100644 index 0000000000..5680bf6b23 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/cherry_pick.rs @@ -0,0 +1,56 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, NamedJob, named}, + vars::{StepOutput, WorkflowInput}, +}; + +pub fn cherry_pick() -> Workflow { + let branch = WorkflowInput::string("branch", None); + let commit = WorkflowInput::string("commit", None); + let channel = WorkflowInput::string("channel", None); + let pr_number = WorkflowInput::string("pr_number", None); + let cherry_pick = run_cherry_pick(&branch, &commit, &channel); + named::workflow() + .run_name(format!("cherry_pick to {channel} #{pr_number}")) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(commit.name, commit.input()) + .add_input(branch.name, branch.input()) + .add_input(channel.name, channel.input()) + .add_input(pr_number.name, pr_number.input()), + )) + .add_job(cherry_pick.name, cherry_pick.job) +} + +fn run_cherry_pick( + branch: &WorkflowInput, + commit: &WorkflowInput, + channel: &WorkflowInput, +) -> NamedJob { + fn cherry_pick( + branch: &WorkflowInput, + commit: &WorkflowInput, + channel: &WorkflowInput, + token: &StepOutput, + ) -> Step { + named::bash(r#"./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL""#) + .add_env(("BRANCH", branch.to_string())) + .add_env(("COMMIT", commit.to_string())) + .add_env(("CHANNEL", channel.to_string())) + .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) + .add_env(("GIT_COMMITTER_EMAIL", "hi@zed.dev")) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = steps::authenticate_as_zippy(); + + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(authenticate) + .add_step(cherry_pick(branch, commit, channel, &token)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs new file mode 100644 index 0000000000..74a1fbdc38 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/compare_perf.rs @@ -0,0 +1,69 @@ +use gh_workflow::*; + +use crate::tasks::workflows::run_bundling::upload_artifact; +use crate::tasks::workflows::steps::FluentBuilder; +use crate::tasks::workflows::{ + runners, + steps::{self, NamedJob, named}, + vars::WorkflowInput, +}; + +pub fn compare_perf() -> Workflow { + let head = WorkflowInput::string("head", None); + let base = WorkflowInput::string("base", None); + let crate_name = WorkflowInput::string("crate_name", Some("".to_owned())); + let run_perf = run_perf(&base, &head, &crate_name); + named::workflow() + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(head.name, head.input()) + .add_input(base.name, base.input()) + .add_input(crate_name.name, crate_name.input()), + )) + .add_job(run_perf.name, run_perf.job) +} + +pub fn run_perf( + base: &WorkflowInput, + head: &WorkflowInput, + crate_name: &WorkflowInput, +) -> NamedJob { + fn cargo_perf_test(ref_name: &WorkflowInput, crate_name: &WorkflowInput) -> Step { + named::bash( + r#" + if [ -n "$CRATE_NAME" ]; then + cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME"; + else + cargo perf-test -p vim -- --json="$REF_NAME"; + fi"#, + ) + .add_env(("REF_NAME", ref_name.to_string())) + .add_env(("CRATE_NAME", crate_name.to_string())) + } + + fn install_hyperfine() -> Step { + named::uses("taiki-e", "install-action", "hyperfine") + } + + fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { + named::bash(r#"cargo perf-compare --save=results.md "$BASE" "$HEAD""#) + .add_env(("BASE", base.to_string())) + .add_env(("HEAD", head.to_string())) + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .map(steps::install_linux_dependencies) + .add_step(install_hyperfine()) + .add_step(steps::git_checkout(base)) + .add_step(cargo_perf_test(base, crate_name)) + .add_step(steps::git_checkout(head)) + .add_step(cargo_perf_test(head, crate_name)) + .add_step(compare_runs(head, base)) + .add_step(upload_artifact("results.md")) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/danger.rs b/tooling/xtask/src/tasks/workflows/danger.rs new file mode 100644 index 0000000000..88e7844738 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/danger.rs @@ -0,0 +1,56 @@ +use gh_workflow::*; + +use crate::tasks::workflows::steps::{CommonJobConditions, NamedJob, named}; + +use super::{runners, steps}; + +/// Generates the danger.yml workflow +pub fn danger() -> Workflow { + let danger = danger_job(); + + named::workflow() + .on( + Event::default().pull_request(PullRequest::default().add_branch("main").types([ + PullRequestType::Opened, + PullRequestType::Synchronize, + PullRequestType::Reopened, + PullRequestType::Edited, + ])), + ) + .add_job(danger.name, danger.job) +} + +fn danger_job() -> NamedJob { + pub fn install_deps() -> Step { + named::bash("pnpm install --dir script/danger") + } + + pub fn run() -> Step { + named::bash("pnpm run --dir script/danger danger ci") + // This GitHub token is not used, but the value needs to be here to prevent + // Danger from throwing an error. + .add_env(("GITHUB_TOKEN", "not_a_real_token")) + // All requests are instead proxied through a proxy that allows Danger to securely authenticate with GitHub + // while still being able to run on PRs from forks. + .add_env(( + "DANGER_GITHUB_API_BASE_URL", + "https://danger-proxy.zed.dev/github", + )) + } + + NamedJob { + name: "danger".to_string(), + job: Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_pnpm()) + .add_step( + steps::setup_node() + .add_with(("cache", "pnpm")) + .add_with(("cache-dependency-path", "script/danger/pnpm-lock.yaml")), + ) + .add_step(install_deps()) + .add_step(run()), + } +} diff --git a/tooling/xtask/src/tasks/workflows/deploy_collab.rs b/tooling/xtask/src/tasks/workflows/deploy_collab.rs new file mode 100644 index 0000000000..c6b620bd5d --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/deploy_collab.rs @@ -0,0 +1,174 @@ +use gh_workflow::{Container, Event, Port, Push, Run, Step, Use, Workflow}; +use indoc::indoc; + +use crate::tasks::workflows::runners::{self, Platform}; +use crate::tasks::workflows::steps::{ + self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang, +}; +use crate::tasks::workflows::vars; + +pub(crate) fn deploy_collab() -> Workflow { + let style = style(); + let tests = tests(&[&style]); + let publish = publish(&[&style, &tests]); + let deploy = deploy(&[&publish]); + + named::workflow() + .on(Event::default().push(Push::default().add_tag("collab-production"))) + .add_env(("DOCKER_BUILDKIT", "1")) + .add_job(style.name, style.job) + .add_job(tests.name, tests.job) + .add_job(publish.name, publish.job) + .add_job(deploy.name, deploy.job) +} + +fn style() -> NamedJob { + named::job(use_clang( + dependant_job(&[]) + .name("Check formatting and Clippy lints") + .with_repository_owner_guard() + .runs_on(runners::LINUX_XL) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::cargo_fmt()) + .add_step(steps::clippy(Platform::Linux, None)), + )) +} + +fn tests(deps: &[&NamedJob]) -> NamedJob { + fn run_collab_tests() -> Step { + named::bash("cargo nextest run --package collab --no-fail-fast") + } + + named::job(use_clang( + dependant_job(deps) + .name("Run tests") + .runs_on(runners::LINUX_XL) + .add_service( + "postgres", + Container::new("postgres:15") + .add_env(("POSTGRES_HOST_AUTH_METHOD", "trust")) + .ports(vec![Port::Name("5432:5432".into())]) + .options( + "--health-cmd pg_isready \ + --health-interval 500ms \ + --health-timeout 5s \ + --health-retries 10", + ), + ) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::cargo_install_nextest()) + .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(run_collab_tests()), + )) +} + +fn publish(deps: &[&NamedJob]) -> NamedJob { + fn install_doctl() -> Step { + named::uses("digitalocean", "action-doctl", "v2") + .add_with(("token", vars::DIGITALOCEAN_ACCESS_TOKEN)) + } + + fn sign_into_registry() -> Step { + named::bash("doctl registry login") + } + + fn build_docker_image() -> Step { + named::bash(indoc! {r#" + docker build -f Dockerfile-collab \ + --build-arg "GITHUB_SHA=$GITHUB_SHA" \ + --tag "registry.digitalocean.com/zed/collab:$GITHUB_SHA" \ + . + "#}) + } + + fn publish_docker_image() -> Step { + named::bash(r#"docker push "registry.digitalocean.com/zed/collab:${GITHUB_SHA}""#) + } + + fn prune_docker_system() -> Step { + named::bash("docker system prune --filter 'until=72h' -f") + } + + named::job( + dependant_job(deps) + .name("Publish collab server image") + .runs_on(runners::LINUX_XL) + .add_step(install_doctl()) + .add_step(sign_into_registry()) + .add_step(steps::checkout_repo()) + .add_step(build_docker_image()) + .add_step(publish_docker_image()) + .add_step(prune_docker_system()), + ) +} + +fn deploy(deps: &[&NamedJob]) -> NamedJob { + fn install_doctl() -> Step { + named::uses("digitalocean", "action-doctl", "v2") + .add_with(("token", vars::DIGITALOCEAN_ACCESS_TOKEN)) + } + + fn sign_into_kubernetes() -> Step { + named::bash( + r#"doctl kubernetes cluster kubeconfig save --expiry-seconds 600 "$CLUSTER_NAME""#, + ) + .add_env(("CLUSTER_NAME", vars::CLUSTER_NAME)) + } + + fn start_rollout() -> Step { + named::bash(indoc! {r#" + set -eu + if [[ $GITHUB_REF_NAME = "collab-production" ]]; then + export ZED_KUBE_NAMESPACE=production + export ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT=10 + export ZED_API_LOAD_BALANCER_SIZE_UNIT=2 + elif [[ $GITHUB_REF_NAME = "collab-staging" ]]; then + export ZED_KUBE_NAMESPACE=staging + export ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT=1 + export ZED_API_LOAD_BALANCER_SIZE_UNIT=1 + else + echo "cowardly refusing to deploy from an unknown branch" + exit 1 + fi + + echo "Deploying collab:$GITHUB_SHA to $ZED_KUBE_NAMESPACE" + + source script/lib/deploy-helpers.sh + export_vars_for_environment "$ZED_KUBE_NAMESPACE" + + ZED_DO_CERTIFICATE_ID="$(doctl compute certificate list --format ID --no-header)" + export ZED_DO_CERTIFICATE_ID + export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}" + + export ZED_SERVICE_NAME=collab + export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT + export DATABASE_MAX_CONNECTIONS=850 + envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f - + kubectl -n "$ZED_KUBE_NAMESPACE" rollout status "deployment/$ZED_SERVICE_NAME" --watch + echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}" + + export ZED_SERVICE_NAME=api + export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_API_LOAD_BALANCER_SIZE_UNIT + export DATABASE_MAX_CONNECTIONS=60 + envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f - + kubectl -n "$ZED_KUBE_NAMESPACE" rollout status "deployment/$ZED_SERVICE_NAME" --watch + echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}" + "#}) + } + + named::job( + dependant_job(deps) + .name("Deploy new server image") + .runs_on(runners::LINUX_XL) + .add_step(steps::checkout_repo()) + .add_step(install_doctl()) + .add_step(sign_into_kubernetes()) + .add_step(start_rollout()), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs new file mode 100644 index 0000000000..68ed1f1acd --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs @@ -0,0 +1,115 @@ +use gh_workflow::{ + Event, Expression, Input, Job, Level, Permissions, Push, Strategy, UsesJob, Workflow, +}; +use indoc::indoc; +use serde_json::json; + +use crate::tasks::workflows::{ + extensions::WithAppSecrets, + run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT, + runners, + steps::{self, CommonJobConditions, NamedJob, named}, + vars::{StepOutput, one_workflow_per_non_main_branch}, +}; + +/// Generates a workflow that triggers on push to main, detects changed extensions +/// in the `extensions/` directory, and invokes the `extension_bump` reusable workflow +/// for each changed extension via a matrix strategy. +pub(crate) fn extension_auto_bump() -> Workflow { + let detect = detect_changed_extensions(); + let bump = bump_extension_versions(&detect); + + named::workflow() + .add_event( + Event::default().push( + Push::default() + .add_branch("main") + .add_path("extensions/**") + .add_path("!extensions/slash-commands-example/**") + .add_path("!extensions/test-extension/**") + .add_path("!extensions/workflows/**") + .add_path("!extensions/*.md"), + ), + ) + .concurrency(one_workflow_per_non_main_branch()) + .add_job(detect.name, detect.job) + .add_job(bump.name, bump.job) +} + +fn detect_changed_extensions() -> NamedJob { + let preamble = indoc! {r#" + COMPARE_REV="$(git rev-parse HEAD~1)" + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + "#}; + + let filter_new_and_removed = indoc! {r#" + # Filter out newly added or entirely removed extensions + FILTERED="[]" + for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do + if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \ + [ -f "$ext/extension.toml" ]; then + FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]') + fi + done + echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT" + "#}; + + let script = format!( + "{preamble}{detect}{filter}", + preamble = preamble, + detect = DETECT_CHANGED_EXTENSIONS_SCRIPT, + filter = filter_new_and_removed, + ); + + let step = named::bash(script).id("detect"); + + let output = StepOutput::new(&step, "changed_extensions"); + + let job = Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(5u32) + .add_step(steps::checkout_repo().with_custom_fetch_depth(2)) + .add_step(step) + .outputs([("changed_extensions".to_owned(), output.to_string())]); + + named::job(job) +} + +fn bump_extension_versions(detect_job: &NamedJob) -> NamedJob { + let job = Job::default() + .needs(vec![detect_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.changed_extensions != '[]'", + detect_job.name + ))) + .permissions( + Permissions::default() + .contents(Level::Write) + .issues(Level::Write) + .pull_requests(Level::Write) + .actions(Level::Write), + ) + .strategy( + Strategy::default() + .fail_fast(false) + // TODO: Remove the limit. We currently need this to workaround the concurrency group issue + // where different matrix jobs would be placed in the same concurrency group and thus cancelled. + .max_parallel(1u32) + .matrix(json!({ + "extension": format!( + "${{{{ fromJson(needs.{}.outputs.changed_extensions) }}}}", + detect_job.name + ) + })), + ) + .uses_local(".github/workflows/extension_bump.yml") + .with( + Input::default() + .add("working-directory", "${{ matrix.extension }}") + .add("force-bump", false), + ) + .with_app_secrets(); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs new file mode 100644 index 0000000000..38cd926ef4 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -0,0 +1,559 @@ +use gh_workflow::{ctx::Context, *}; +use indoc::{formatdoc, indoc}; + +use crate::tasks::workflows::{ + extension_tests::{self}, + runners, + steps::{ + self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, + NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named, + }, + vars::{ + JobOutput, StepOutput, WorkflowInput, WorkflowSecret, + one_workflow_per_non_main_branch_and_token, + }, +}; + +const VERSION_CHECK: &str = + r#"sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]'"#; + +// This is used by various extensions repos in the zed-extensions org to bump extension versions. +pub(crate) fn extension_bump() -> Workflow { + let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned())); + // TODO: Ideally, this would have a default of `false`, but this is currently not + // supported in gh-workflows + let force_bump = WorkflowInput::bool("force-bump", None); + let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); + + let (app_id, app_secret) = extension_workflow_secrets(); + let (check_version_changed, version_changed, current_version) = check_version_changed(); + + let version_changed = version_changed.as_job_output(&check_version_changed); + let current_version = current_version.as_job_output(&check_version_changed); + + let dependencies = [&check_version_changed]; + let bump_version = bump_extension_version( + &dependencies, + ¤t_version, + &bump_type, + &version_changed, + &force_bump, + &app_id, + &app_secret, + ); + let (create_label, tag) = create_version_label( + &dependencies, + &version_changed, + ¤t_version, + &app_id, + &app_secret, + ); + let tag = tag.as_job_output(&create_label); + let trigger_release = trigger_release( + &[&check_version_changed, &create_label], + tag, + &app_id, + &app_secret, + ); + + named::workflow() + .add_event( + Event::default().workflow_call( + WorkflowCall::default() + .add_input(bump_type.name, bump_type.call_input()) + .add_input(force_bump.name, force_bump.call_input()) + .add_input(working_directory.name, working_directory.call_input()) + .secrets([ + (app_id.name.to_owned(), app_id.secret_configuration()), + ( + app_secret.name.to_owned(), + app_secret.secret_configuration(), + ), + ]), + ), + ) + .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump")) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(( + "ZED_EXTENSION_CLI_SHA", + extension_tests::ZED_EXTENSION_CLI_SHA, + )) + .add_job(check_version_changed.name, check_version_changed.job) + .add_job(bump_version.name, bump_version.job) + .add_job(create_label.name, create_label.job) + .add_job(trigger_release.name, trigger_release.job) +} + +fn extension_job_defaults() -> Defaults { + Defaults::default().run( + RunDefaults::default() + .shell(BASH_SHELL) + .working_directory("${{ inputs.working-directory }}"), + ) +} + +fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) { + let (compare_versions, version_changed, current_version) = compare_versions(); + + let job = Job::default() + .defaults(extension_job_defaults()) + .with_repository_owner_guard() + .outputs([ + (version_changed.name.to_owned(), version_changed.to_string()), + ( + current_version.name.to_string(), + current_version.to_string(), + ), + ]) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(1u32) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(compare_versions); + + (named::job(job), version_changed, current_version) +} + +fn create_version_label( + dependencies: &[&NamedJob], + version_changed_output: &JobOutput, + current_version: &JobOutput, + app_id: &WorkflowSecret, + app_secret: &WorkflowSecret, +) -> (NamedJob, StepOutput) { + let (generate_token, generated_token) = + generate_token(&app_id.to_string(), &app_secret.to_string(), None); + let (determine_tag_step, tag) = determine_tag(current_version); + let job = steps::dependant_job(dependencies) + .defaults(extension_job_defaults()) + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \ + github.ref == 'refs/heads/main' && {version_changed} == 'true'", + version_changed = version_changed_output.expr(), + ))) + .outputs([(tag.name.to_owned(), tag.to_string())]) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(1u32) + .add_step(generate_token) + .add_step(steps::checkout_repo()) + .add_step(determine_tag_step) + .add_step(create_version_tag(&tag, generated_token)); + + (named::job(job), tag) +} + +fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { + named::uses("actions", "github-script", "v7").with( + Input::default() + .add( + "script", + formatdoc! {r#" + github.rest.git.createRef({{ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'refs/tags/{tag}', + sha: context.sha + }})"# + }, + ) + .add("github-token", generated_token.to_string()), + ) +} + +fn determine_tag(current_version: &JobOutput) -> (Step, StepOutput) { + let step = named::bash(formatdoc! {r#" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + TAG="v${{CURRENT_VERSION}}" + else + TAG="${{EXTENSION_ID}}-v${{CURRENT_VERSION}}" + fi + + echo "tag=${{TAG}}" >> "$GITHUB_OUTPUT" + "#}) + .id("determine-tag") + .add_env(("CURRENT_VERSION", current_version.to_string())) + .add_env(("WORKING_DIR", "${{ inputs.working-directory }}")); + + let tag = StepOutput::new(&step, "tag"); + (step, tag) +} + +/// Compares the current and previous commit and checks whether versions changed inbetween. +pub(crate) fn compare_versions() -> (Step, StepOutput, StepOutput) { + let check_needs_bump = named::bash(formatdoc! { + r#" + CURRENT_VERSION="$({VERSION_CHECK})" + + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + PR_FORK_POINT="$(git merge-base origin/main HEAD)" + git checkout "$PR_FORK_POINT" + else + git checkout "$(git log -1 --format=%H)"~1 + fi + + PARENT_COMMIT_VERSION="$({VERSION_CHECK})" + + [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \ + echo "version_changed=false" >> "$GITHUB_OUTPUT" || \ + echo "version_changed=true" >> "$GITHUB_OUTPUT" + + echo "current_version=${{CURRENT_VERSION}}" >> "$GITHUB_OUTPUT" + "# + }) + .id("compare-versions-check"); + + let version_changed = StepOutput::new(&check_needs_bump, "version_changed"); + let current_version = StepOutput::new(&check_needs_bump, "current_version"); + + (check_needs_bump, version_changed, current_version) +} + +fn bump_extension_version( + dependencies: &[&NamedJob], + current_version: &JobOutput, + bump_type: &WorkflowInput, + version_changed_output: &JobOutput, + force_bump_output: &WorkflowInput, + app_id: &WorkflowSecret, + app_secret: &WorkflowSecret, +) -> NamedJob { + let (generate_token, generated_token) = + generate_token(&app_id.to_string(), &app_secret.to_string(), None); + let (bump_version, _new_version, title, body, branch_name) = + bump_version(current_version, bump_type); + + let job = steps::dependant_job(dependencies) + .defaults(extension_job_defaults()) + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')", + force_bump = force_bump_output.expr(), + version_changed = version_changed_output.expr(), + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(5u32) + .add_step(generate_token) + .add_step(steps::checkout_repo()) + .add_step(cache_rust_dependencies_namespace()) + .add_step(install_bump_2_version()) + .add_step(bump_version) + .add_step(create_pull_request( + title, + body, + generated_token, + branch_name, + )); + + named::job(job) +} + +pub(crate) fn generate_token( + app_id_source: &str, + app_secret_source: &str, + repository_target: Option, +) -> (Step, StepOutput) { + let step = named::uses("actions", "create-github-app-token", "v2") + .id("generate-token") + .add_with( + Input::default() + .add("app-id", app_id_source) + .add("private-key", app_secret_source) + .when_some( + repository_target, + |input, + RepositoryTarget { + owner, + repositories, + permissions, + }| { + input + .when_some(owner, |input, owner| input.add("owner", owner)) + .when_some(repositories, |input, repositories| { + input.add("repositories", repositories) + }) + .when_some(permissions, |input, permissions| { + permissions + .into_iter() + .fold(input, |input, (permission, level)| { + input.add( + permission, + serde_json::to_value(&level).unwrap_or_default(), + ) + }) + }) + }, + ), + ); + + let generated_token = StepOutput::new(&step, "token"); + + (step, generated_token) +} + +fn install_bump_2_version() -> Step { + named::run( + runners::Platform::Linux, + "pip install bump2version --break-system-packages", + ) +} + +fn bump_version( + current_version: &JobOutput, + bump_type: &WorkflowInput, +) -> (Step, StepOutput, StepOutput, StepOutput, StepOutput) { + let step = named::bash(formatdoc! {r#" + BUMP_FILES=("extension.toml") + if [[ -f "Cargo.toml" ]]; then + BUMP_FILES+=("Cargo.toml") + fi + + bump2version \ + --search "version = \"{{current_version}}"\" \ + --replace "version = \"{{new_version}}"\" \ + --current-version "$OLD_VERSION" \ + --no-configured-files "$BUMP_TYPE" "${{BUMP_FILES[@]}}" + + if [[ -f "Cargo.toml" ]]; then + cargo +stable update --workspace + fi + + NEW_VERSION="$({VERSION_CHECK})" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + {{ + echo "title=Bump version to ${{NEW_VERSION}}"; + echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}"; + echo "branch_name=zed-zippy-autobump"; + }} >> "$GITHUB_OUTPUT" + else + {{ + echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}"; + echo "body<> "$GITHUB_OUTPUT" + fi + + echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT" + "# + }) + .id("bump-version") + .add_env(("OLD_VERSION", current_version.to_string())) + .add_env(("BUMP_TYPE", bump_type.to_string())) + .add_env(("WORKING_DIR", "${{ inputs.working-directory }}")); + + let new_version = StepOutput::new(&step, "new_version"); + let title = StepOutput::new(&step, "title"); + let body = StepOutput::new(&step, "body"); + let branch_name = StepOutput::new(&step, "branch_name"); + (step, new_version, title, body, branch_name) +} + +fn create_pull_request( + title: StepOutput, + body: StepOutput, + generated_token: StepOutput, + branch_name: StepOutput, +) -> Step { + named::uses("peter-evans", "create-pull-request", "v7").with( + Input::default() + .add("title", title.to_string()) + .add("body", body.to_string()) + .add("commit-message", title.to_string()) + .add("branch", branch_name.to_string()) + .add( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + ) + .add("base", "main") + .add("delete-branch", true) + .add("token", generated_token.to_string()) + .add("sign-commits", true) + .add("assignees", Context::github().actor().to_string()), + ) +} + +fn trigger_release( + dependencies: &[&NamedJob], + tag: JobOutput, + app_id: &WorkflowSecret, + app_secret: &WorkflowSecret, +) -> NamedJob { + let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]); + let (generate_token, generated_token) = generate_token( + &app_id.to_string(), + &app_secret.to_string(), + Some(extension_registry), + ); + let (get_extension_id, extension_id) = get_extension_id(); + let (release_action, pull_request_number) = release_action(extension_id, tag, &generated_token); + + let job = dependant_job(dependencies) + .defaults(extension_job_defaults()) + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .add_step(generate_token) + .add_step(checkout_repo()) + .add_step(get_extension_id) + .add_step(release_action) + .add_step(enable_automerge_if_staff( + pull_request_number, + generated_token, + )); + + named::job(job) +} + +fn get_extension_id() -> (Step, StepOutput) { + let step = named::bash(indoc! { + r#" + EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)" + + echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT" + "#}) + .id("get-extension-id"); + + let extension_id = StepOutput::new(&step, "extension_id"); + + (step, extension_id) +} + +fn release_action( + extension_id: StepOutput, + tag: JobOutput, + generated_token: &StepOutput, +) -> (Step, StepOutput) { + let step = named::uses( + "huacnlee", + "zed-extension-action", + "82920ff0876879f65ffbcfa3403589114a8919c6", + ) + .id("extension-update") + .add_with(("extension-name", extension_id.to_string())) + .add_with(("push-to", "zed-industries/extensions")) + .add_with(("tag", tag.to_string())) + .add_env(("COMMITTER_TOKEN", generated_token.to_string())); + + let pull_request_number = StepOutput::new(&step, "pull-request-number"); + + (step, pull_request_number) +} + +fn enable_automerge_if_staff( + pull_request_number: StepOutput, + generated_token: StepOutput, +) -> Step { + named::uses("actions", "github-script", "v7") + .add_with(("github-token", generated_token.to_string())) + .add_with(( + "script", + indoc! {r#" + const prNumber = process.env.PR_NUMBER; + if (!prNumber) { + console.log('No pull request number set, skipping automerge.'); + return; + } + + const author = process.env.GITHUB_ACTOR; + let isStaff = false; + try { + const response = await github.rest.teams.getMembershipForUserInOrg({ + org: 'zed-industries', + team_slug: 'staff', + username: author + }); + isStaff = response.data.state === 'active'; + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + if (!isStaff) { + console.log(`Actor ${author} is not a staff member, skipping automerge.`); + return; + } + + // Assign staff member responsible for the bump + const pullNumber = parseInt(prNumber); + + await github.rest.issues.addAssignees({ + owner: 'zed-industries', + repo: 'extensions', + issue_number: pullNumber, + assignees: [author] + }); + console.log(`Assigned ${author} to PR #${prNumber} in zed-industries/extensions`); + + // Get the GraphQL node ID + const { data: pr } = await github.rest.pulls.get({ + owner: 'zed-industries', + repo: 'extensions', + pull_number: pullNumber + }); + + await github.graphql(` + mutation($pullRequestId: ID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) { + pullRequest { + autoMergeRequest { + enabledAt + } + } + } + } + `, { pullRequestId: pr.node_id }); + + console.log(`Automerge enabled for PR #${prNumber} in zed-industries/extensions`); + "#}, + )) + .add_env(("PR_NUMBER", pull_request_number.to_string())) +} + +fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) { + let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR"); + let app_secret = + WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID"); + + (app_id, app_secret) +} + +pub(crate) struct RepositoryTarget { + owner: Option, + repositories: Option, + permissions: Option>, +} + +impl RepositoryTarget { + pub fn new(owner: T, repositories: &[&str]) -> Self { + Self { + owner: Some(owner.to_string()), + repositories: Some(repositories.join("\n")), + permissions: None, + } + } + + pub fn current() -> Self { + Self { + owner: None, + repositories: None, + permissions: None, + } + } + + pub fn permissions(self, permissions: impl Into>) -> Self { + Self { + permissions: Some(permissions.into()), + ..self + } + } +} diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs new file mode 100644 index 0000000000..caf57ce130 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -0,0 +1,204 @@ +use gh_workflow::*; +use indoc::indoc; + +use crate::tasks::workflows::{ + extension_bump::compare_versions, + run_tests::{fetch_ts_query_ls, orchestrate_for_extension, run_ts_query_ls, tests_pass}, + runners, + steps::{ + self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob, + cache_rust_dependencies_namespace, named, + }, + vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, +}; + +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667"; + +// This should follow the set target in crates/extension/src/extension_builder.rs +const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2"; + +// This is used by various extensions repos in the zed-extensions org to run automated tests. +pub(crate) fn extension_tests() -> Workflow { + let should_check_rust = PathCondition::new("check_rust", r"^(Cargo.lock|Cargo.toml|.*\.rs)$"); + let should_check_extension = + PathCondition::new("check_extension", r"^(extension\.toml|.*\.scm)$"); + + let orchestrate = with_extension_defaults(orchestrate_for_extension(&[ + &should_check_rust, + &should_check_extension, + ])); + + let jobs = [ + orchestrate, + should_check_rust.guard(check_rust()), + should_check_extension.guard(check_extension()), + ]; + + let tests_pass = tests_pass(&jobs, &[]); + + let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); + + named::workflow() + .add_event( + Event::default().workflow_call( + WorkflowCall::default() + .add_input(working_directory.name, working_directory.call_input()), + ), + ) + .concurrency(one_workflow_per_non_main_branch_and_token( + "extension-tests", + )) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("ZED_EXTENSION_CLI_SHA", ZED_EXTENSION_CLI_SHA)) + .add_env(("RUSTUP_TOOLCHAIN", "stable")) + .add_env(("CARGO_BUILD_TARGET", EXTENSION_RUST_TARGET)) + .map(|workflow| { + jobs.into_iter() + .chain([tests_pass]) + .fold(workflow, |workflow, job| { + workflow.add_job(job.name, job.job) + }) + }) +} + +fn install_rust_target() -> Step { + named::bash(format!("rustup target add {EXTENSION_RUST_TARGET}",)) +} + +fn get_package_name() -> (Step, StepOutput) { + let step = named::bash(indoc! {r#" + PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')" + echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT" + "#}) + .id("get-package-name"); + + let output = StepOutput::new(&step, "package_name"); + (step, output) +} + +fn cargo_fmt_package(package_name: &StepOutput) -> Step { + named::bash(r#"cargo fmt -p "$PACKAGE_NAME" -- --check"#) + .add_env(("PACKAGE_NAME", package_name.to_string())) +} + +fn run_clippy(package_name: &StepOutput) -> Step { + named::bash(r#"cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings"#) + .add_env(("PACKAGE_NAME", package_name.to_string())) +} + +fn run_nextest(package_name: &StepOutput) -> Step { + named::bash( + r#"cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n 's|host: ||p')""#, + ) + .add_env(("PACKAGE_NAME", package_name.to_string())) + .add_env(("NEXTEST_NO_TESTS", "warn")) +} + +fn extension_job_defaults() -> Defaults { + Defaults::default().run( + RunDefaults::default() + .shell(BASH_SHELL) + .working_directory("${{ inputs.working-directory }}"), + ) +} + +fn with_extension_defaults(named_job: NamedJob) -> NamedJob { + NamedJob { + name: named_job.name, + job: named_job.job.defaults(extension_job_defaults()), + } +} + +fn check_rust() -> NamedJob { + let (get_package, package_name) = get_package_name(); + + let job = Job::default() + .defaults(extension_job_defaults()) + .with_repository_owner_guard() + .runs_on(runners::LINUX_LARGE_RAM) + .timeout_minutes(6u32) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(install_rust_target()) + .add_step(get_package) + .add_step(cargo_fmt_package(&package_name)) + .add_step(run_clippy(&package_name)) + .add_step(steps::cargo_install_nextest()) + .add_step(run_nextest(&package_name)); + + named::job(job) +} + +pub(crate) fn check_extension() -> NamedJob { + let (cache_download, cache_hit) = cache_zed_extension_cli(); + let (check_version_job, version_changed, _) = compare_versions(); + + let job = Job::default() + .defaults(extension_job_defaults()) + .with_repository_owner_guard() + .runs_on(runners::LINUX_LARGE_RAM) + .timeout_minutes(6u32) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(cache_download) + .add_step(download_zed_extension_cli(cache_hit)) + .add_step(cache_rust_dependencies_namespace()) // Extensions can compile Rust, so provide the cache if needed. + .add_step(check()) + .add_step(fetch_ts_query_ls()) + .add_step(run_ts_query_ls()) + .add_step(check_version_job) + .add_step(verify_version_did_not_change(version_changed)); + + named::job(job) +} + +pub fn cache_zed_extension_cli() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "cache", + "0057852bfaa89a56745cba8c7296529d2fc39830", + ) + .id("cache-zed-extension-cli") + .with( + Input::default() + .add("path", "zed-extension") + .add("key", "zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }}"), + ); + let output = StepOutput::new(&step, "cache-hit"); + (step, output) +} + +pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step { + named::bash( + indoc! { + r#" + wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension" + chmod +x "$GITHUB_WORKSPACE/zed-extension" + "#, + } + ).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr()))) +} + +pub fn check() -> Step { + named::bash(indoc! { + r#" + mkdir -p /tmp/ext-scratch + mkdir -p /tmp/ext-output + "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output + "# + }) +} + +fn verify_version_did_not_change(version_changed: StepOutput) -> Step { + named::bash(indoc! {r#" + if [[ "$VERSION_CHANGED" == "true" && "$GITHUB_EVENT_NAME" == "pull_request" && "$PR_USER_LOGIN" != "zed-zippy[bot]" ]] ; then + echo "Version change detected in your change!" + echo "Version changes happen in separate PRs and will be performed by the zed-zippy bot" + exit 42 + fi + "# + }) + .add_env(("VERSION_CHANGED", version_changed.to_string())) + .add_env(("PR_USER_LOGIN", "${{ github.event.pull_request.user.login }}")) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs new file mode 100644 index 0000000000..a62bb107da --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -0,0 +1,394 @@ +use gh_workflow::{ + Event, Expression, Job, Level, Run, Step, Strategy, Use, Workflow, WorkflowDispatch, +}; +use indoc::formatdoc; +use indoc::indoc; +use serde_json::json; + +use crate::tasks::workflows::steps::CheckoutStep; +use crate::tasks::workflows::steps::cache_rust_dependencies_namespace; +use crate::tasks::workflows::vars::JobOutput; +use crate::tasks::workflows::{ + extension_bump::{RepositoryTarget, generate_token}, + runners, + steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +const ROLLOUT_TAG_NAME: &str = "extension-workflows"; +const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files"; + +pub(crate) fn extension_workflow_rollout() -> Workflow { + let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new())) + .description( + "Comma-separated list of repository names to rollout to. Leave empty for all repos.", + ); + let extra_context_input = WorkflowInput::string("change-description", Some(String::new())) + .description("Description for the changes to be expected with this rollout"); + + let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input); + let rollout_workflows = rollout_workflows_to_extension( + &fetch_repos, + removed_ci, + removed_shared, + &extra_context_input, + ); + let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input); + + named::workflow() + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(filter_repos_input.name, filter_repos_input.input()) + .add_input(extra_context_input.name, extra_context_input.input()), + )) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_job(fetch_repos.name, fetch_repos.job) + .add_job(rollout_workflows.name, rollout_workflows.job) + .add_job(create_tag.name, create_tag.job) +} + +fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { + fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { + let step = named::uses("actions", "github-script", "v7") + .id("list-repos") + .add_with(( + "script", + formatdoc! {r#" + const repos = await github.paginate(github.rest.repos.listForOrg, {{ + org: 'zed-extensions', + type: 'public', + per_page: 100, + }}); + + let filteredRepos = repos + .filter(repo => !repo.archived) + .map(repo => repo.name); + + const filterInput = `{filter_repos_input}`.trim(); + if (filterInput.length > 0) {{ + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`); + }} + + console.log(`Found ${{filteredRepos.length}} extension repos`); + return filteredRepos; + "#}, + )) + .add_with(("result-encoding", "json")); + + let filtered_repos = StepOutput::new(&step, "result"); + + (step, filtered_repos) + } + + fn checkout_zed_repo() -> CheckoutStep { + steps::checkout_repo() + .with_full_history() + .with_custom_name("checkout_zed_repo") + } + + fn get_previous_tag_commit() -> (Step, StepOutput) { + let step = named::bash(formatdoc! {r#" + PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "") + if [ -z "$PREV_COMMIT" ]; then + echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes." + exit 1 + fi + echo "Found previous rollout at commit: $PREV_COMMIT" + echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" + "#}) + .id("prev-tag"); + + let step_output = StepOutput::new(&step, "prev_commit"); + + (step, step_output) + } + + fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput, StepOutput) { + let step = named::bash(indoc! {r#" + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + "#}) + .id("calc-changes") + .add_env(("PREV_COMMIT", prev_commit.to_string())); + + // These are created in the for-loop above and thus do exist + let removed_ci = StepOutput::new_unchecked(&step, "removed_ci"); + let removed_shared = StepOutput::new_unchecked(&step, "removed_shared"); + + (step, removed_ci, removed_shared) + } + + fn generate_workflow_files() -> Step { + named::bash(indoc! {r#" + cargo xtask workflows "$COMMIT_SHA" + "#}) + .add_env(("COMMIT_SHA", "${{ github.sha }}")) + } + + fn upload_workflow_files() -> Step { + named::uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "extensions/workflows/**/*.yml")) + .add_with(("if-no-files-found", "error")) + } + + let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input); + let (get_prev_tag, prev_commit) = get_previous_tag_commit(); + let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit); + + let job = Job::default() + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(10u32) + .outputs([ + ("repos".to_owned(), list_repos_output.to_string()), + ("prev_commit".to_owned(), prev_commit.to_string()), + ("removed_ci".to_owned(), removed_ci.to_string()), + ("removed_shared".to_owned(), removed_shared.to_string()), + ]) + .add_step(checkout_zed_repo()) + .add_step(get_prev_tag) + .add_step(calc_changes) + .add_step(get_org_repositories) + .add_step(cache_rust_dependencies_namespace()) + .add_step(generate_workflow_files()) + .add_step(upload_workflow_files()); + + let job = named::job(job); + let (removed_ci, removed_shared) = ( + removed_ci.as_job_output(&job), + removed_shared.as_job_output(&job), + ); + + (job, removed_ci, removed_shared) +} + +fn rollout_workflows_to_extension( + fetch_repos_job: &NamedJob, + removed_ci: JobOutput, + removed_shared: JobOutput, + extra_context_input: &WorkflowInput, +) -> NamedJob { + fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { + steps::checkout_repo() + .with_custom_name("checkout_extension_repo") + .with_token(token) + .with_repository("zed-extensions/${{ matrix.repo }}") + .with_path("extension") + } + + fn download_workflow_files() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "workflow-files")) + } + + fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step { + named::bash(indoc! {r#" + mkdir -p extension/.github/workflows + + if [ "$MATRIX_REPO" = "workflows" ]; then + REMOVED_FILES="$REMOVED_CI" + else + REMOVED_FILES="$REMOVED_SHARED" + fi + + cd extension/.github/workflows + + if [ -n "$REMOVED_FILES" ]; then + for file in $REMOVED_FILES; do + if [ -f "$file" ]; then + rm -f "$file" + fi + done + fi + + cd - > /dev/null + + if [ "$MATRIX_REPO" = "workflows" ]; then + cp workflow-files/*.yml extension/.github/workflows/ + else + cp workflow-files/shared/*.yml extension/.github/workflows/ + fi + "#}) + .add_env(("REMOVED_CI", removed_ci)) + .add_env(("REMOVED_SHARED", removed_shared)) + .add_env(("MATRIX_REPO", "${{ matrix.repo }}")) + } + + fn get_short_sha() -> (Step, StepOutput) { + let step = named::bash(indoc! {r#" + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" + "#}) + .id("short-sha"); + + let step_output = StepOutput::new(&step, "sha_short"); + + (step, step_output) + } + + fn create_pull_request( + token: &StepOutput, + short_sha: &StepOutput, + context_input: &WorkflowInput, + ) -> Step { + let title = format!("Update CI workflows to `{short_sha}`"); + + let body = formatdoc! {r#" + This PR updates the CI workflow files from the main Zed repository + based on the commit zed-industries/zed@${{{{ github.sha }}}} + + {context_input} + "#, + }; + + named::uses("peter-evans", "create-pull-request", "v7") + .add_with(("path", "extension")) + .add_with(("title", title.clone())) + .add_with(("body", body)) + .add_with(("commit-message", title)) + .add_with(("branch", "update-workflows")) + .add_with(( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + )) + .add_with(( + "author", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + )) + .add_with(("base", "main")) + .add_with(("delete-branch", true)) + .add_with(("token", token.to_string())) + .add_with(("sign-commits", true)) + .id("create-pr") + } + + fn enable_auto_merge(token: &StepOutput) -> Step { + named::bash(indoc! {r#" + if [ -n "$PR_NUMBER" ]; then + gh pr merge "$PR_NUMBER" --auto --squash + fi + "#}) + .working_directory("extension") + .add_env(("GH_TOKEN", token.to_string())) + .add_env(( + "PR_NUMBER", + "${{ steps.create-pr.outputs.pull-request-number }}", + )) + } + + let (authenticate, token) = generate_token( + vars::ZED_ZIPPY_APP_ID, + vars::ZED_ZIPPY_APP_PRIVATE_KEY, + Some( + RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([ + ("permission-pull-requests".to_owned(), Level::Write), + ("permission-contents".to_owned(), Level::Write), + ("permission-workflows".to_owned(), Level::Write), + ]), + ), + ); + let (calculate_short_sha, short_sha) = get_short_sha(); + + let job = Job::default() + .needs([fetch_repos_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.repos != '[]'", + fetch_repos_job.name + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(10u32) + .strategy( + Strategy::default() + .fail_fast(false) + .max_parallel(10u32) + .matrix(json!({ + "repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name) + })), + ) + .add_step(authenticate) + .add_step(checkout_extension_repo(&token)) + .add_step(download_workflow_files()) + .add_step(sync_workflow_files(removed_ci, removed_shared)) + .add_step(calculate_short_sha) + .add_step(create_pull_request(&token, &short_sha, extra_context_input)) + .add_step(enable_auto_merge(&token)); + + named::job(job) +} + +fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob { + fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep { + steps::checkout_repo().with_full_history().with_token(token) + } + + fn update_rollout_tag() -> Step { + named::bash(formatdoc! {r#" + if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then + git tag -d "{ROLLOUT_TAG_NAME}" + git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true + fi + + echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)" + git tag "{ROLLOUT_TAG_NAME}" + git push origin "{ROLLOUT_TAG_NAME}" + "#}) + } + + fn configure_git() -> Step { + named::bash(indoc! {r#" + git config user.name "zed-zippy[bot]" + git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com" + "#}) + } + + let (authenticate, token) = generate_token( + vars::ZED_ZIPPY_APP_ID, + vars::ZED_ZIPPY_APP_PRIVATE_KEY, + Some( + RepositoryTarget::current() + .permissions([("permission-contents".to_owned(), Level::Write)]), + ), + ); + + let job = Job::default() + .needs([rollout_job.name.clone()]) + .cond(Expression::new(format!( + "{filter_repos} == ''", + filter_repos = filter_repos_input.expr(), + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(1u32) + .add_step(authenticate) + .add_step(checkout_zed_repo(&token)) + .add_step(configure_git()) + .add_step(update_rollout_tag()); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/extensions.rs b/tooling/xtask/src/tasks/workflows/extensions.rs new file mode 100644 index 0000000000..d55c091e4d --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions.rs @@ -0,0 +1,23 @@ +use gh_workflow::{Job, UsesJob}; +use indexmap::IndexMap; + +use crate::tasks::workflows::vars; + +pub(crate) mod bump_version; +pub(crate) mod run_tests; + +pub(crate) trait WithAppSecrets: Sized { + fn with_app_secrets(self) -> Self; +} + +impl WithAppSecrets for Job { + fn with_app_secrets(self) -> Self { + self.secrets(IndexMap::from([ + ("app-id".to_owned(), vars::ZED_ZIPPY_APP_ID.to_owned()), + ( + "app-secret".to_owned(), + vars::ZED_ZIPPY_APP_PRIVATE_KEY.to_owned(), + ), + ])) + } +} diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs new file mode 100644 index 0000000000..4dc2560e2b --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -0,0 +1,108 @@ +use gh_workflow::{ + Event, Expression, Input, Job, Level, Permissions, PullRequest, PullRequestType, Push, Run, + Step, UsesJob, Workflow, WorkflowDispatch, +}; +use indoc::indoc; + +use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, + extensions::WithAppSecrets, + runners, + steps::{CommonJobConditions, NamedJob, named}, + vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token}, +}; + +pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow { + let (determine_bump_type, bump_type) = determine_bump_type(); + let bump_type = bump_type.as_job_output(&determine_bump_type); + + let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type); + + named::workflow() + .on(Event::default() + .push( + Push::default() + .add_branch("main") + .add_ignored_path(".github/**"), + ) + .pull_request(PullRequest::default().add_type(PullRequestType::Labeled)) + .workflow_dispatch(WorkflowDispatch::default())) + .concurrency(one_workflow_per_non_main_branch_and_token("labels")) + .add_job(determine_bump_type.name, determine_bump_type.job) + .add_job(call_bump_version.name, call_bump_version.job) +} + +pub(crate) fn call_bump_version( + target_ref: Option<&GitSha>, + depending_job: &NamedJob, + bump_type: JobOutput, +) -> NamedJob { + let job = Job::default() + .cond(Expression::new(format!( + "github.event.action != 'labeled' || {} != 'patch'", + bump_type.expr() + ))) + .permissions( + Permissions::default() + .contents(Level::Write) + .issues(Level::Write) + .pull_requests(Level::Write) + .actions(Level::Write), + ) + .uses( + "zed-industries", + "zed", + ".github/workflows/extension_bump.yml", + target_ref.map_or("main", AsRef::as_ref), + ) + .add_need(depending_job.name.clone()) + .with( + Input::default() + .add("bump-type", bump_type.to_string()) + .add("force-bump", "${{ github.event_name != 'push' }}"), + ) + .with_app_secrets(); + + named::job(job) +} + +fn determine_bump_type() -> (NamedJob, StepOutput) { + let (get_bump_type, output) = get_bump_type(); + let job = Job::default() + .with_repository_owner_guard() + .permissions(Permissions::default()) + .runs_on(runners::LINUX_SMALL) + .add_step(get_bump_type) + .outputs([(output.name.to_owned(), output.to_string())]); + (named::job(job), output) +} + +fn get_bump_type() -> (Step, StepOutput) { + let step = named::bash( + indoc! {r#" + if [ "$HAS_MAJOR_LABEL" = "true" ]; then + bump_type="major" + elif [ "$HAS_MINOR_LABEL" = "true" ]; then + bump_type="minor" + else + bump_type="patch" + fi + echo "bump_type=$bump_type" >> $GITHUB_OUTPUT + "#}, + ) + .add_env(("HAS_MAJOR_LABEL", + indoc!{ + "${{ (github.event.action == 'labeled' && github.event.label.name == 'major') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'major')) }}" + })) + .add_env(("HAS_MINOR_LABEL", + indoc!{ + "${{ (github.event.action == 'labeled' && github.event.label.name == 'minor') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'minor')) }}" + })) + .id("get-bump-type"); + + let step_output = StepOutput::new(&step, "bump_type"); + + (step, step_output) +} diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs new file mode 100644 index 0000000000..ae8000c15c --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -0,0 +1,30 @@ +use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow}; + +use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, + steps::{NamedJob, named}, + vars::one_workflow_per_non_main_branch_and_token, +}; + +pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow { + let call_extension_tests = call_extension_tests(args.sha.as_ref()); + named::workflow() + .on(Event::default() + .pull_request(PullRequest::default().add_branch("**")) + .push(Push::default().add_branch("main"))) + .concurrency(one_workflow_per_non_main_branch_and_token("pr")) + .add_job(call_extension_tests.name, call_extension_tests.job) +} + +pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob { + let job = Job::default() + .permissions(Permissions::default().contents(Level::Read)) + .uses( + "zed-industries", + "zed", + ".github/workflows/extension_tests.yml", + target_ref.map_or("main", AsRef::as_ref), + ); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/nix_build.rs b/tooling/xtask/src/tasks/workflows/nix_build.rs new file mode 100644 index 0000000000..9e401ccac0 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/nix_build.rs @@ -0,0 +1,125 @@ +use crate::tasks::workflows::{ + runners::{Arch, Platform}, + steps::{CommonJobConditions, NamedJob}, +}; + +use super::{runners, steps, steps::named, vars}; +use gh_workflow::*; + +pub(crate) fn build_nix( + platform: Platform, + arch: Arch, + flake_output: &str, + cachix_filter: Option<&str>, + deps: &[&NamedJob], +) -> NamedJob { + pub fn install_nix() -> Step { + named::uses( + "cachix", + "install-nix-action", + "02a151ada4993995686f9ed4f1be7cfbb229e56f", // v31 + ) + .add_with(("github_access_token", vars::GITHUB_TOKEN)) + } + + pub fn cachix_action(cachix_filter: Option<&str>) -> Step { + let mut step = named::uses( + "cachix", + "cachix-action", + "0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad", // v16 + ) + .add_with(("name", "zed")) + .add_with(("authToken", vars::CACHIX_AUTH_TOKEN)) + .add_with(("cachixArgs", "-v")); + if let Some(cachix_filter) = cachix_filter { + step = step.add_with(("pushFilter", cachix_filter)); + } + step + } + + pub fn build(flake_output: &str) -> Step { + named::bash(&format!( + "nix build .#{} -L --accept-flake-config", + flake_output + )) + } + + // After install-nix, register ~/nix-cache as a local binary cache + // substituter so nix pulls from it on demand during builds (no bulk + // import). Also restart the daemon so it picks up the new config. + pub fn configure_local_nix_cache() -> Step { + named::bash(indoc::indoc! {r#" + mkdir -p ~/nix-cache + echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf + echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf + sudo launchctl kickstart -k system/org.nixos.nix-daemon + "#}) + } + + // Incrementally copy only new store paths from the build result's + // closure into the local binary cache for the next run. + pub fn export_to_local_nix_cache() -> Step { + named::bash(indoc::indoc! {r#" + if [ -L result ]; then + echo "Copying build closure to local binary cache..." + nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed" + else + echo "No build result found, skipping cache export." + fi + "#}) + .if_condition(Expression::new("always()")) + } + + let runner = match platform { + Platform::Windows => unimplemented!(), + Platform::Linux => runners::LINUX_X86_BUNDLER, + Platform::Mac => runners::MAC_DEFAULT, + }; + let mut job = Job::default() + .timeout_minutes(60u32) + .continue_on_error(true) + .with_repository_owner_guard() + .runs_on(runner) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_env(("ZED_MINIDUMP_ENDPOINT", vars::ZED_SENTRY_MINIDUMP_ENDPOINT)) + .add_env(( + "ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON", + vars::ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON, + )) + .add_env(("GIT_LFS_SKIP_SMUDGE", "1")) // breaks the livekit rust sdk examples which we don't actually depend on + .add_step(steps::checkout_repo()); + + if deps.len() > 0 { + job = job.needs(deps.iter().map(|d| d.name.clone()).collect::>()); + } + + // On Linux, `cache: nix` uses bind-mounts so the /nix store is available + // before install-nix-action runs — no extra steps needed. + // + // On macOS, `/nix` lives on a read-only root filesystem and the nscloud + // cache action cannot mount or symlink there. Instead we cache a + // user-writable directory (~/nix-cache) as a local binary cache and + // register it as a nix substituter. Nix then pulls paths from it on + // demand during builds (zero-copy at startup), and after building we + // incrementally copy new paths into the cache for the next run. + job = match platform { + Platform::Linux => job + .add_step(steps::cache_nix_dependencies_namespace()) + .add_step(install_nix()) + .add_step(cachix_action(cachix_filter)) + .add_step(build(&flake_output)), + Platform::Mac => job + .add_step(steps::cache_nix_store_macos()) + .add_step(install_nix()) + .add_step(configure_local_nix_cache()) + .add_step(cachix_action(cachix_filter)) + .add_step(build(&flake_output)) + .add_step(export_to_local_nix_cache()), + Platform::Windows => unimplemented!(), + }; + + NamedJob { + name: format!("build_nix_{platform}_{arch}"), + job, + } +} diff --git a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs new file mode 100644 index 0000000000..2269201a2d --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs @@ -0,0 +1,201 @@ +use gh_workflow::{ctx::Context, *}; +use indoc::indoc; + +use crate::tasks::workflows::{ + extension_bump::{RepositoryTarget, generate_token}, + runners, + steps::{self, CommonJobConditions, NamedJob, named}, + vars::{self, StepOutput}, +}; + +pub fn publish_extension_cli() -> Workflow { + let publish = publish_job(); + let update_sha_in_zed = update_sha_in_zed(&publish); + let update_sha_in_extensions = update_sha_in_extensions(&publish); + + named::workflow() + .on(Event::default().push(Push::default().tags(vec!["extension-cli".to_string()]))) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_job(publish.name, publish.job) + .add_job(update_sha_in_zed.name, update_sha_in_zed.job) + .add_job(update_sha_in_extensions.name, update_sha_in_extensions.job) +} + +fn publish_job() -> NamedJob { + fn build_extension_cli() -> Step { + named::bash("cargo build --release --package extension_cli") + } + + fn upload_binary() -> Step { + named::bash(r#"script/upload-extension-cli "$GITHUB_SHA""#) + .add_env(( + "DIGITALOCEAN_SPACES_ACCESS_KEY", + vars::DIGITALOCEAN_SPACES_ACCESS_KEY, + )) + .add_env(( + "DIGITALOCEAN_SPACES_SECRET_KEY", + vars::DIGITALOCEAN_SPACES_SECRET_KEY, + )) + } + + named::job( + Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(steps::setup_linux()) + .add_step(build_extension_cli()) + .add_step(upload_binary()), + ) +} + +fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob { + let (generate_token, generated_token) = generate_token( + vars::ZED_ZIPPY_APP_ID, + vars::ZED_ZIPPY_APP_PRIVATE_KEY, + Some(RepositoryTarget::current()), + ); + + fn replace_sha() -> Step { + named::bash(indoc! {r#" + sed -i "s/ZED_EXTENSION_CLI_SHA: &str = \"[a-f0-9]*\"/ZED_EXTENSION_CLI_SHA: \&str = \"$GITHUB_SHA\"/" \ + tooling/xtask/src/tasks/workflows/extension_tests.rs + "#}) + } + + fn regenerate_workflows() -> Step { + named::bash("cargo xtask workflows") + } + + let (get_short_sha_step, short_sha) = get_short_sha(); + + named::job( + Job::default() + .with_repository_owner_guard() + .needs(vec![publish_job.name.clone()]) + .runs_on(runners::LINUX_LARGE) + .add_step(generate_token) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(get_short_sha_step) + .add_step(replace_sha()) + .add_step(regenerate_workflows()) + .add_step(create_pull_request_zed(&generated_token, &short_sha)), + ) +} + +fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) -> Step { + let title = format!( + "extension_ci: Bump extension CLI version to `{}`", + short_sha + ); + + named::uses("peter-evans", "create-pull-request", "v7").with( + Input::default() + .add("title", title.clone()) + .add( + "body", + indoc! {r#" + This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`. + + Release Notes: + + - N/A + "#}, + ) + .add("commit-message", title) + .add("branch", "update-extension-cli-sha") + .add( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + ) + .add("base", "main") + .add("delete-branch", true) + .add("token", generated_token.to_string()) + .add("sign-commits", true) + .add("assignees", Context::github().actor().to_string()), + ) +} + +fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob { + let extensions_repo = RepositoryTarget::new("zed-industries", &["extensions"]); + let (generate_token, generated_token) = generate_token( + vars::ZED_ZIPPY_APP_ID, + vars::ZED_ZIPPY_APP_PRIVATE_KEY, + Some(extensions_repo), + ); + + fn checkout_extensions_repo(token: &StepOutput) -> Step { + named::uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + .add_with(("repository", "zed-industries/extensions")) + .add_with(("token", token.to_string())) + } + + fn replace_sha() -> Step { + named::bash(indoc! {r#" + sed -i "s/ZED_EXTENSION_CLI_SHA: [a-f0-9]*/ZED_EXTENSION_CLI_SHA: $GITHUB_SHA/" \ + .github/workflows/ci.yml + "#}) + } + + let (get_short_sha_step, short_sha) = get_short_sha(); + + named::job( + Job::default() + .with_repository_owner_guard() + .needs(vec![publish_job.name.clone()]) + .runs_on(runners::LINUX_SMALL) + .add_step(generate_token) + .add_step(get_short_sha_step) + .add_step(checkout_extensions_repo(&generated_token)) + .add_step(replace_sha()) + .add_step(create_pull_request_extensions(&generated_token, &short_sha)), + ) +} + +fn create_pull_request_extensions( + generated_token: &StepOutput, + short_sha: &StepOutput, +) -> Step { + let title = format!("Bump extension CLI version to `{}`", short_sha); + + named::uses("peter-evans", "create-pull-request", "v7").with( + Input::default() + .add("title", title.clone()) + .add( + "body", + indoc! {r#" + This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}. + "#}, + ) + .add("commit-message", title) + .add("branch", "update-extension-cli-sha") + .add( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + ) + .add("base", "main") + .add("delete-branch", true) + .add("token", generated_token.to_string()) + .add("sign-commits", true) + .add("labels", "allow-no-extension") + .add("assignees", Context::github().actor().to_string()), + ) +} + +fn get_short_sha() -> (Step, StepOutput) { + let step = named::bash(indoc::indoc! {r#" + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" + "#}) + .id("short-sha"); + + let step_output = vars::StepOutput::new(&step, "sha_short"); + + (step, step_output) +} diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs new file mode 100644 index 0000000000..2646005021 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -0,0 +1,446 @@ +use gh_workflow::{Event, Expression, Push, Run, Step, Use, Workflow, ctx::Context}; +use indoc::formatdoc; + +use crate::tasks::workflows::{ + run_bundling::{bundle_linux, bundle_mac, bundle_windows}, + run_tests, + runners::{self, Arch, Platform}, + steps::{self, FluentBuilder, NamedJob, dependant_job, named, release_job}, + vars::{self, StepOutput, assets}, +}; + +const CURRENT_ACTION_RUN_URL: &str = + "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"; + +pub(crate) fn release() -> Workflow { + let macos_tests = run_tests::run_platform_tests_no_filter(Platform::Mac); + let linux_tests = run_tests::run_platform_tests_no_filter(Platform::Linux); + let windows_tests = run_tests::run_platform_tests_no_filter(Platform::Windows); + let macos_clippy = run_tests::clippy(Platform::Mac, None); + let linux_clippy = run_tests::clippy(Platform::Linux, None); + let windows_clippy = run_tests::clippy(Platform::Windows, None); + let check_scripts = run_tests::check_scripts(); + + let create_draft_release = create_draft_release(); + + let bundle = ReleaseBundleJobs { + linux_aarch64: bundle_linux( + Arch::AARCH64, + None, + &[&linux_tests, &linux_clippy, &check_scripts], + ), + linux_x86_64: bundle_linux( + Arch::X86_64, + None, + &[&linux_tests, &linux_clippy, &check_scripts], + ), + mac_aarch64: bundle_mac( + Arch::AARCH64, + None, + &[&macos_tests, &macos_clippy, &check_scripts], + ), + mac_x86_64: bundle_mac( + Arch::X86_64, + None, + &[&macos_tests, &macos_clippy, &check_scripts], + ), + windows_aarch64: bundle_windows( + Arch::AARCH64, + None, + &[&windows_tests, &windows_clippy, &check_scripts], + ), + windows_x86_64: bundle_windows( + Arch::X86_64, + None, + &[&windows_tests, &windows_clippy, &check_scripts], + ), + }; + + let upload_release_assets = upload_release_assets(&[&create_draft_release], &bundle); + let validate_release_assets = validate_release_assets(&[&upload_release_assets]); + + let auto_release_preview = auto_release_preview(&[&validate_release_assets]); + + let test_jobs = [ + &macos_tests, + &linux_tests, + &windows_tests, + &macos_clippy, + &linux_clippy, + &windows_clippy, + &check_scripts, + ]; + let push_slack_notification = push_release_update_notification( + &create_draft_release, + &upload_release_assets, + &validate_release_assets, + &auto_release_preview, + &test_jobs, + &bundle, + ); + + named::workflow() + .on(Event::default().push(Push::default().tags(vec!["v*".to_string()]))) + .concurrency(vars::one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .add_job(macos_tests.name, macos_tests.job) + .add_job(linux_tests.name, linux_tests.job) + .add_job(windows_tests.name, windows_tests.job) + .add_job(macos_clippy.name, macos_clippy.job) + .add_job(linux_clippy.name, linux_clippy.job) + .add_job(windows_clippy.name, windows_clippy.job) + .add_job(check_scripts.name, check_scripts.job) + .add_job(create_draft_release.name, create_draft_release.job) + .map(|mut workflow| { + for job in bundle.into_jobs() { + workflow = workflow.add_job(job.name, job.job); + } + workflow + }) + .add_job(upload_release_assets.name, upload_release_assets.job) + .add_job(validate_release_assets.name, validate_release_assets.job) + .add_job(auto_release_preview.name, auto_release_preview.job) + .add_job(push_slack_notification.name, push_slack_notification.job) +} + +pub(crate) struct ReleaseBundleJobs { + pub linux_aarch64: NamedJob, + pub linux_x86_64: NamedJob, + pub mac_aarch64: NamedJob, + pub mac_x86_64: NamedJob, + pub windows_aarch64: NamedJob, + pub windows_x86_64: NamedJob, +} + +impl ReleaseBundleJobs { + pub fn jobs(&self) -> Vec<&NamedJob> { + vec![ + &self.linux_aarch64, + &self.linux_x86_64, + &self.mac_aarch64, + &self.mac_x86_64, + &self.windows_aarch64, + &self.windows_x86_64, + ] + } + + pub fn into_jobs(self) -> Vec { + vec![ + self.linux_aarch64, + self.linux_x86_64, + self.mac_aarch64, + self.mac_x86_64, + self.windows_aarch64, + self.windows_x86_64, + ] + } +} + +pub(crate) fn create_sentry_release() -> Step { + named::uses( + "getsentry", + "action-release", + "526942b68292201ac6bbb99b9a0747d4abee354c", // v3 + ) + .add_env(("SENTRY_ORG", "zed-dev")) + .add_env(("SENTRY_PROJECT", "zed")) + .add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN)) + .add_with(("environment", "production")) +} + +fn validate_release_assets(deps: &[&NamedJob]) -> NamedJob { + let expected_assets: Vec = assets::all().iter().map(|a| format!("\"{a}\"")).collect(); + let expected_assets_json = format!("[{}]", expected_assets.join(", ")); + + let validation_script = formatdoc! {r#" + EXPECTED_ASSETS='{expected_assets_json}' + TAG="$GITHUB_REF_NAME" + + ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]') + + MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]') + + if [ -n "$MISSING_ASSETS" ]; then + echo "Error: The following assets are missing from the release:" + echo "$MISSING_ASSETS" + exit 1 + fi + + echo "All expected assets are present in the release." + "#, + }; + + named::job( + dependant_job(deps).runs_on(runners::LINUX_SMALL).add_step( + named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)), + ), + ) +} + +fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob { + let (authenticate, token) = steps::authenticate_as_zippy(); + + named::job( + dependant_job(deps) + .runs_on(runners::LINUX_SMALL) + .cond(Expression::new(indoc::indoc!( + r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"# + ))) + .add_step(authenticate) + .add_step( + steps::script( + r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#, + ) + .add_env(("GITHUB_TOKEN", &token)), + ) + ) +} + +pub(crate) fn download_workflow_artifacts() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("path", "./artifacts/")) +} + +pub(crate) fn prep_release_artifacts() -> Step { + let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()]; + for asset in assets::all() { + let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}"); + script_lines.push(mv_command) + } + + named::bash(&script_lines.join("\n")) +} + +fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob { + let mut deps = deps.to_vec(); + deps.extend(bundle.jobs()); + + named::job( + dependant_job(&deps) + .runs_on(runners::LINUX_MEDIUM) + .add_step(download_workflow_artifacts()) + .add_step(steps::script("ls -lR ./artifacts")) + .add_step(prep_release_artifacts()) + .add_step( + steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*") + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)), + ), + ) +} + +fn create_draft_release() -> NamedJob { + fn generate_release_notes() -> Step { + named::bash( + r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#, + ) + } + + fn create_release() -> Step { + named::bash("script/create-draft-release target/release-notes.md") + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_SMALL) + // We need to fetch more than one commit so that `script/draft-release-notes` + // is able to diff between the current and previous tag. + // + // 25 was chosen arbitrarily. + .add_step( + steps::checkout_repo() + .with_custom_fetch_depth(25) + .with_ref("${{ github.ref }}"), + ) + .add_step(steps::script("script/determine-release-channel")) + .add_step(steps::script("mkdir -p target/")) + .add_step(generate_release_notes()) + .add_step(create_release()), + ) +} + +pub(crate) fn push_release_update_notification( + create_draft_release_job: &NamedJob, + upload_assets_job: &NamedJob, + validate_assets_job: &NamedJob, + auto_release_preview: &NamedJob, + test_jobs: &[&NamedJob], + bundle_jobs: &ReleaseBundleJobs, +) -> NamedJob { + fn env_name(name: &str) -> String { + format!("RESULT_{}", name.to_uppercase()) + } + + let all_job_names: Vec<&str> = test_jobs + .iter() + .map(|j| j.name.as_ref()) + .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref())) + .collect(); + + let env_entries = [ + ( + "DRAFT_RESULT".into(), + format!("${{{{ needs.{}.result }}}}", create_draft_release_job.name), + ), + ( + "UPLOAD_RESULT".into(), + format!("${{{{ needs.{}.result }}}}", upload_assets_job.name), + ), + ( + "VALIDATE_RESULT".into(), + format!("${{{{ needs.{}.result }}}}", validate_assets_job.name), + ), + ( + "AUTO_RELEASE_RESULT".into(), + format!("${{{{ needs.{}.result }}}}", auto_release_preview.name), + ), + ("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()), + ] + .into_iter() + .chain( + all_job_names + .iter() + .map(|name| (env_name(name), format!("${{{{ needs.{name}.result }}}}"))), + ); + + let failure_checks = all_job_names + .iter() + .map(|name| { + format!( + "if [ \"${env_name}\" == \"failure\" ];then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi", + env_name = env_name(name) + ) + }) + .collect::>() + .join("\n "); + + let notification_script = formatdoc! {r#" + TAG="$GITHUB_REF_NAME" + + if [ "$DRAFT_RESULT" == "failure" ]; then + echo "❌ Draft release creation failed for $TAG: $RUN_URL" + else + RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url') + if [ "$UPLOAD_RESULT" == "failure" ]; then + echo "❌ Release asset upload failed for $TAG: $RELEASE_URL" + elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then + FAILED_JOBS="" + {failure_checks} + FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs) + if [ "$UPLOAD_RESULT" == "cancelled" ]; then + if [ -n "$FAILED_JOBS" ]; then + echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL" + else + echo "❌ Release job for $TAG was cancelled: $RUN_URL" + fi + else + if [ -n "$FAILED_JOBS" ]; then + echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL" + else + echo "❌ Tests for $TAG failed: $RUN_URL" + fi + fi + elif [ "$VALIDATE_RESULT" == "failure" ]; then + echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL" + elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then + echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL" + elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then + echo "❌ Auto release failed for $TAG: $RUN_URL" + else + echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL" + fi + fi + "#, + }; + + let mut all_deps: Vec<&NamedJob> = vec![ + create_draft_release_job, + upload_assets_job, + validate_assets_job, + auto_release_preview, + ]; + all_deps.extend(test_jobs.iter().copied()); + all_deps.extend(bundle_jobs.jobs()); + + let mut job = dependant_job(&all_deps) + .runs_on(runners::LINUX_SMALL) + .cond(Expression::new("always()")); + + for step in notify_slack(MessageType::Evaluated { + script: notification_script, + env: env_entries.collect(), + }) { + job = job.add_step(step); + } + named::job(job) +} + +pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob { + let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}"); + + let mut job = dependant_job(deps) + .runs_on(runners::LINUX_SMALL) + .cond(Expression::new("failure()")); + + for step in notify_slack(MessageType::Static(failure_message)) { + job = job.add_step(step); + } + named::job(job) +} + +pub(crate) enum MessageType { + Static(String), + Evaluated { + script: String, + env: Vec<(String, String)>, + }, +} + +fn notify_slack(message: MessageType) -> Vec> { + match message { + MessageType::Static(message) => vec![send_slack_message(message)], + MessageType::Evaluated { script, env } => { + let (generate_step, generated_message) = generate_slack_message(script, env); + + vec![ + generate_step, + send_slack_message(generated_message.to_string()), + ] + } + } +} + +fn generate_slack_message( + expression: String, + env: Vec<(String, String)>, +) -> (Step, StepOutput) { + let script = formatdoc! {r#" + MESSAGE=$({expression}) + echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" + "# + }; + let mut generate_step = named::bash(&script) + .id("generate-webhook-message") + .add_env(("GH_TOKEN", Context::github().token())); + + for (name, value) in env { + generate_step = generate_step.add_env((name, value)); + } + + let output = StepOutput::new(&generate_step, "message"); + + (generate_step, output) +} + +fn send_slack_message(message: String) -> Step { + named::bash( + r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""# + ) + .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES)) + .add_env(("SLACK_MESSAGE", message)) +} diff --git a/tooling/xtask/src/tasks/workflows/release_nightly.rs b/tooling/xtask/src/tasks/workflows/release_nightly.rs new file mode 100644 index 0000000000..277db38bee --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/release_nightly.rs @@ -0,0 +1,129 @@ +use crate::tasks::workflows::{ + nix_build::build_nix, + release::{ + ReleaseBundleJobs, create_sentry_release, download_workflow_artifacts, notify_on_failure, + prep_release_artifacts, + }, + run_bundling::{bundle_linux, bundle_mac, bundle_windows}, + run_tests::{clippy, run_platform_tests_no_filter}, + runners::{Arch, Platform, ReleaseChannel}, + steps::{CommonJobConditions, FluentBuilder, NamedJob}, +}; + +use super::{runners, steps, steps::named, vars}; +use gh_workflow::*; + +/// Generates the release_nightly.yml workflow +pub fn release_nightly() -> Workflow { + let style = check_style(); + // run only on windows as that's our fastest platform right now. + let tests = run_platform_tests_no_filter(Platform::Windows); + let clippy_job = clippy(Platform::Windows, None); + let nightly = Some(ReleaseChannel::Nightly); + + let bundle = ReleaseBundleJobs { + linux_aarch64: bundle_linux(Arch::AARCH64, nightly, &[&style, &tests, &clippy_job]), + linux_x86_64: bundle_linux(Arch::X86_64, nightly, &[&style, &tests, &clippy_job]), + mac_aarch64: bundle_mac(Arch::AARCH64, nightly, &[&style, &tests, &clippy_job]), + mac_x86_64: bundle_mac(Arch::X86_64, nightly, &[&style, &tests, &clippy_job]), + windows_aarch64: bundle_windows(Arch::AARCH64, nightly, &[&style, &tests, &clippy_job]), + windows_x86_64: bundle_windows(Arch::X86_64, nightly, &[&style, &tests, &clippy_job]), + }; + + let nix_linux_x86 = build_nix( + Platform::Linux, + Arch::X86_64, + "default", + None, + &[&style, &tests], + ); + let nix_mac_arm = build_nix( + Platform::Mac, + Arch::AARCH64, + "default", + None, + &[&style, &tests], + ); + let update_nightly_tag = update_nightly_tag_job(&bundle); + let notify_on_failure = notify_on_failure(&bundle.jobs()); + + named::workflow() + .on(Event::default() + // Fire every day at 7:00am UTC (Roughly before EU workday and after US workday) + .schedule([Schedule::new("0 7 * * *")]) + .push(Push::default().add_tag("nightly"))) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .add_job(style.name, style.job) + .add_job(tests.name, tests.job) + .add_job(clippy_job.name, clippy_job.job) + .map(|mut workflow| { + for job in bundle.into_jobs() { + workflow = workflow.add_job(job.name, job.job); + } + workflow + }) + .add_job(nix_linux_x86.name, nix_linux_x86.job) + .add_job(nix_mac_arm.name, nix_mac_arm.job) + .add_job(update_nightly_tag.name, update_nightly_tag.job) + .add_job(notify_on_failure.name, notify_on_failure.job) +} + +fn check_style() -> NamedJob { + let job = release_job(&[]) + .runs_on(runners::MAC_DEFAULT) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(steps::cargo_fmt()) + .add_step(steps::script("./script/clippy")); + + named::job(job) +} + +fn release_job(deps: &[&NamedJob]) -> Job { + let job = Job::default() + .with_repository_owner_guard() + .timeout_minutes(60u32); + if deps.len() > 0 { + job.needs(deps.iter().map(|j| j.name.clone()).collect::>()) + } else { + job + } +} + +fn update_nightly_tag_job(bundle: &ReleaseBundleJobs) -> NamedJob { + fn update_nightly_tag() -> Step { + named::bash(indoc::indoc! {r#" + if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then + echo "Nightly tag already points to current commit. Skipping tagging." + exit 0 + fi + git config user.name github-actions + git config user.email github-actions@github.com + git tag -f nightly + git push origin nightly --force + "#}) + } + + NamedJob { + name: "update_nightly_tag".to_owned(), + job: steps::release_job(&bundle.jobs()) + .runs_on(runners::LINUX_MEDIUM) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(download_workflow_artifacts()) + .add_step(steps::script("ls -lR ./artifacts")) + .add_step(prep_release_artifacts()) + .add_step( + steps::script("./script/upload-nightly") + .add_env(( + "DIGITALOCEAN_SPACES_ACCESS_KEY", + vars::DIGITALOCEAN_SPACES_ACCESS_KEY, + )) + .add_env(( + "DIGITALOCEAN_SPACES_SECRET_KEY", + vars::DIGITALOCEAN_SPACES_SECRET_KEY, + )), + ) + .add_step(update_nightly_tag()) + .add_step(create_sentry_release()), + } +} diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs new file mode 100644 index 0000000000..521f419d9b --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs @@ -0,0 +1,169 @@ +use gh_workflow::{Event, Expression, Job, Run, Step, Strategy, Use, Workflow, WorkflowDispatch}; +use serde_json::json; + +use crate::tasks::workflows::{ + runners::{self, Platform}, + steps::{self, FluentBuilder as _, NamedJob, named, setup_cargo_config}, + vars::{self, WorkflowInput}, +}; + +pub(crate) fn run_agent_evals() -> Workflow { + let agent_evals = agent_evals(); + let model_name = WorkflowInput::string("model_name", None); + + named::workflow() + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default().add_input(model_name.name, model_name.input()), + )) + .concurrency(vars::one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY)) + .add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY)) + .add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY)) + .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_env(("ZED_EVAL_TELEMETRY", 1)) + .add_env(("MODEL_NAME", model_name.to_string())) + .add_job(agent_evals.name, agent_evals.job) +} + +pub(crate) fn run_unit_evals() -> Workflow { + let model_name = WorkflowInput::string("model_name", None); + let commit_sha = WorkflowInput::string("commit_sha", None); + + let unit_evals = named::job(unit_evals(Some(&commit_sha))); + + named::workflow() + .name("run_unit_evals") + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(model_name.name, model_name.input()) + .add_input(commit_sha.name, commit_sha.input()), + )) + .concurrency(vars::allow_concurrent_runs()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_env(("ZED_EVAL_TELEMETRY", 1)) + .add_env(("MODEL_NAME", model_name.to_string())) + .add_job(unit_evals.name, unit_evals.job) +} + +fn add_api_keys(step: Step) -> Step { + step.add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY)) + .add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY)) + .add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY)) + .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) +} + +fn agent_evals() -> NamedJob { + fn run_eval() -> Step { + named::bash( + "cargo run --package=eval -- --repetitions=8 --concurrency=1 --model \"${MODEL_NAME}\"", + ) + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .timeout_minutes(60_u32 * 10) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(setup_cargo_config(Platform::Linux)) + .add_step(steps::setup_sccache(Platform::Linux)) + .add_step(steps::script("cargo build --package=eval")) + .add_step(add_api_keys(run_eval())) + .add_step(steps::show_sccache_stats(Platform::Linux)) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + ) +} + +pub(crate) fn run_cron_unit_evals() -> Workflow { + let unit_evals = cron_unit_evals(); + + named::workflow() + .name("run_cron_unit_evals") + .on(Event::default() + // .schedule([ + // // GitHub might drop jobs at busy times, so we choose a random time in the middle of the night. + // Schedule::default().cron("47 1 * * 2"), + // ]) + .workflow_dispatch(WorkflowDispatch::default())) + .concurrency(vars::one_workflow_per_non_main_branch()) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("CARGO_INCREMENTAL", 0)) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) + .add_job(unit_evals.name, unit_evals.job) +} + +fn cron_unit_evals() -> NamedJob { + fn send_failure_to_slack() -> Step { + named::uses( + "slackapi", + "slack-github-action", + "b0fa283ad8fea605de13dc3f449259339835fc52", + ) + .if_condition(Expression::new("${{ failure() }}")) + .add_with(("method", "chat.postMessage")) + .add_with(("token", vars::SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN)) + .add_with(("payload", indoc::indoc!{r#" + channel: C04UDRNNJFQ + text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" + "#})) + } + + named::job(cron_unit_evals_job().add_step(send_failure_to_slack())) +} + +const UNIT_EVAL_MODELS: &[&str] = &[ + "anthropic/claude-sonnet-4-5-latest", + "anthropic/claude-opus-4-5-latest", + "google/gemini-3.1-pro", + "openai/gpt-5", +]; + +fn cron_unit_evals_job() -> Job { + let script_step = add_api_keys(steps::script("./script/run-unit-evals")) + .add_env(("ZED_AGENT_MODEL", "${{ matrix.model }}")); + + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .strategy(Strategy::default().fail_fast(false).matrix(json!({ + "model": UNIT_EVAL_MODELS + }))) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::cargo_install_nextest()) + .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(steps::setup_sccache(Platform::Linux)) + .add_step(script_step) + .add_step(steps::show_sccache_stats(Platform::Linux)) + .add_step(steps::cleanup_cargo_config(Platform::Linux)) +} + +fn unit_evals(commit: Option<&WorkflowInput>) -> Job { + let script_step = add_api_keys(steps::script("./script/run-unit-evals")); + + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::cargo_install_nextest()) + .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(steps::setup_sccache(Platform::Linux)) + .add_step(match commit { + Some(commit) => script_step.add_env(("UNIT_EVAL_COMMIT", commit)), + None => script_step, + }) + .add_step(steps::show_sccache_stats(Platform::Linux)) + .add_step(steps::cleanup_cargo_config(Platform::Linux)) +} diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs new file mode 100644 index 0000000000..6b9d3b9e36 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -0,0 +1,226 @@ +use std::path::Path; + +use crate::tasks::workflows::{ + nix_build::build_nix, + release::ReleaseBundleJobs, + runners::{Arch, Platform, ReleaseChannel}, + steps::{DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob, dependant_job, named}, + vars::{assets, bundle_envs}, +}; + +use super::{runners, steps}; +use gh_workflow::*; +use indoc::indoc; + +pub fn run_bundling() -> Workflow { + let bundle = ReleaseBundleJobs { + linux_aarch64: bundle_linux(Arch::AARCH64, None, &[]), + linux_x86_64: bundle_linux(Arch::X86_64, None, &[]), + mac_aarch64: bundle_mac(Arch::AARCH64, None, &[]), + mac_x86_64: bundle_mac(Arch::X86_64, None, &[]), + windows_aarch64: bundle_windows(Arch::AARCH64, None, &[]), + windows_x86_64: bundle_windows(Arch::X86_64, None, &[]), + }; + let nix_linux_x86_64 = nix_job(Platform::Linux, Arch::X86_64); + let nix_mac_aarch64 = nix_job(Platform::Mac, Arch::AARCH64); + named::workflow() + .on(Event::default().pull_request( + PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), + )) + .concurrency( + Concurrency::new(Expression::new( + "${{ github.workflow }}-${{ github.head_ref || github.ref }}", + )) + .cancel_in_progress(true), + ) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", "1")) + .map(|mut workflow| { + for job in bundle.into_jobs() { + workflow = workflow.add_job(job.name, job.job); + } + workflow + }) + .add_job(nix_linux_x86_64.name, nix_linux_x86_64.job) + .add_job(nix_mac_aarch64.name, nix_mac_aarch64.job) +} + +fn nix_job(platform: Platform, arch: Arch) -> NamedJob { + let mut job = build_nix( + platform, + arch, + "default", + // don't push PR builds to the cache + Some("-zed-editor-[0-9.]*"), + &[], + ); + job.job = job.job.cond(Expression::new(format!( + "{} && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || \ + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')))", + DEFAULT_REPOSITORY_OWNER_GUARD + ))); + job +} + +fn bundle_job(deps: &[&NamedJob]) -> Job { + dependant_job(deps) + .when(deps.len() == 0, |job| + job.cond(Expression::new( + indoc! { + r#"(github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))"#, + }))) + .timeout_minutes(60u32) +} + +pub(crate) fn bundle_mac( + arch: Arch, + release_channel: Option, + deps: &[&NamedJob], +) -> NamedJob { + pub fn bundle_mac(arch: Arch) -> Step { + named::bash(&format!("./script/bundle-mac {arch}-apple-darwin")) + } + let platform = Platform::Mac; + let artifact_name = match arch { + Arch::X86_64 => assets::MAC_X86_64, + Arch::AARCH64 => assets::MAC_AARCH64, + }; + let remote_server_artifact_name = match arch { + Arch::X86_64 => assets::REMOTE_SERVER_MAC_X86_64, + Arch::AARCH64 => assets::REMOTE_SERVER_MAC_AARCH64, + }; + NamedJob { + name: format!("bundle_mac_{arch}"), + job: bundle_job(deps) + .runs_on(runners::MAC_DEFAULT) + .envs(bundle_envs(platform)) + .add_step(steps::checkout_repo()) + .when_some(release_channel, |job, release_channel| { + job.add_step(set_release_channel(platform, release_channel)) + }) + .add_step(steps::setup_node()) + .add_step(steps::setup_sentry()) + .add_step(steps::clear_target_dir_if_large(runners::Platform::Mac)) + .add_step(bundle_mac(arch)) + .add_step(upload_artifact(&format!( + "target/{arch}-apple-darwin/release/{artifact_name}" + ))) + .add_step(upload_artifact(&format!( + "target/{remote_server_artifact_name}" + ))), + } +} + +pub fn upload_artifact(path: &str) -> Step { + let name = Path::new(path).file_name().unwrap().to_str().unwrap(); + Step::new(format!("@actions/upload-artifact {}", name)) + .uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + // N.B. "name" is the name for the asset. The uploaded + // file retains its filename. + .add_with(("name", name)) + .add_with(("path", path)) + .add_with(("if-no-files-found", "error")) +} + +pub(crate) fn bundle_linux( + arch: Arch, + release_channel: Option, + deps: &[&NamedJob], +) -> NamedJob { + let platform = Platform::Linux; + let artifact_name = match arch { + Arch::X86_64 => assets::LINUX_X86_64, + Arch::AARCH64 => assets::LINUX_AARCH64, + }; + let remote_server_artifact_name = match arch { + Arch::X86_64 => assets::REMOTE_SERVER_LINUX_X86_64, + Arch::AARCH64 => assets::REMOTE_SERVER_LINUX_AARCH64, + }; + NamedJob { + name: format!("bundle_linux_{arch}"), + job: bundle_job(deps) + .runs_on(arch.linux_bundler()) + .envs(bundle_envs(platform)) + .add_env(Env::new("CC", "clang-18")) + .add_env(Env::new("CXX", "clang++-18")) + .add_step(steps::checkout_repo()) + .when_some(release_channel, |job, release_channel| { + job.add_step(set_release_channel(platform, release_channel)) + }) + .add_step(steps::setup_sentry()) + .map(steps::install_linux_dependencies) + .add_step(steps::script("./script/bundle-linux")) + .add_step(upload_artifact(&format!("target/release/{artifact_name}"))) + .add_step(upload_artifact(&format!( + "target/{remote_server_artifact_name}" + ))), + } +} + +pub(crate) fn bundle_windows( + arch: Arch, + release_channel: Option, + deps: &[&NamedJob], +) -> NamedJob { + let platform = Platform::Windows; + pub fn bundle_windows(arch: Arch) -> Step { + let step = match arch { + Arch::X86_64 => named::pwsh("script/bundle-windows.ps1 -Architecture x86_64"), + Arch::AARCH64 => named::pwsh("script/bundle-windows.ps1 -Architecture aarch64"), + }; + step.working_directory("${{ env.ZED_WORKSPACE }}") + } + let artifact_name = match arch { + Arch::X86_64 => assets::WINDOWS_X86_64, + Arch::AARCH64 => assets::WINDOWS_AARCH64, + }; + let remote_server_artifact_name = match arch { + Arch::X86_64 => assets::REMOTE_SERVER_WINDOWS_X86_64, + Arch::AARCH64 => assets::REMOTE_SERVER_WINDOWS_AARCH64, + }; + NamedJob { + name: format!("bundle_windows_{arch}"), + job: bundle_job(deps) + .runs_on(runners::WINDOWS_DEFAULT) + .envs(bundle_envs(platform)) + .add_step(steps::checkout_repo()) + .when_some(release_channel, |job, release_channel| { + job.add_step(set_release_channel(platform, release_channel)) + }) + .add_step(steps::setup_sentry()) + .add_step(bundle_windows(arch)) + .add_step(upload_artifact(&format!("target/{artifact_name}"))) + .add_step(upload_artifact(&format!( + "target/{remote_server_artifact_name}" + ))), + } +} + +fn set_release_channel(platform: Platform, release_channel: ReleaseChannel) -> Step { + match release_channel { + ReleaseChannel::Nightly => set_release_channel_to_nightly(platform), + } +} + +fn set_release_channel_to_nightly(platform: Platform) -> Step { + match platform { + Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#" + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + "#}), + Platform::Windows => named::pwsh(indoc::indoc! {r#" + $ErrorActionPreference = "Stop" + $version = git rev-parse --short HEAD + Write-Host "Publishing version: $version on release channel nightly" + "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + "#}) + .working_directory("${{ env.ZED_WORKSPACE }}"), + } +} diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs new file mode 100644 index 0000000000..a43b36e975 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -0,0 +1,788 @@ +use gh_workflow::{ + Concurrency, Container, Event, Expression, Input, Job, Level, Permissions, Port, PullRequest, + Push, Run, Step, Strategy, Use, UsesJob, Workflow, +}; +use indexmap::IndexMap; +use indoc::formatdoc; +use serde_json::json; + +use crate::tasks::workflows::{ + steps::{ + CommonJobConditions, cache_rust_dependencies_namespace, repository_owner_guard_expression, + use_clang, + }, + vars::{self, PathCondition}, +}; + +use super::{ + runners::{self, Arch, Platform}, + steps::{self, FluentBuilder, NamedJob, named, release_job}, +}; + +pub(crate) fn run_tests() -> Workflow { + // Specify anything which should potentially skip full test suite in this regex: + // - docs/ + // - script/update_top_ranking_issues/ + // - .github/ISSUE_TEMPLATE/ + // - .github/workflows/ (except .github/workflows/ci.yml) + // - extensions/ (these have their own test workflow) + let should_run_tests = PathCondition::inverted( + "run_tests", + r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)", + ); + let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)"); + let should_check_scripts = PathCondition::new( + "run_action_checks", + r"^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/", + ); + let should_check_licences = + PathCondition::new("run_licenses", r"^(Cargo.lock|script/.*licenses)"); + + let orchestrate = orchestrate(&[ + &should_check_scripts, + &should_check_docs, + &should_check_licences, + &should_run_tests, + ]); + + let mut jobs = vec![ + orchestrate, + check_style(), + should_run_tests.guard(clippy(Platform::Windows, None)), + should_run_tests.guard(clippy(Platform::Linux, None)), + should_run_tests.guard(clippy(Platform::Mac, None)), + should_run_tests.guard(clippy(Platform::Mac, Some(Arch::X86_64))), + should_run_tests.guard(run_platform_tests(Platform::Windows)), + should_run_tests.guard(run_platform_tests(Platform::Linux)), + should_run_tests.guard(run_platform_tests(Platform::Mac)), + should_run_tests.guard(doctests()), + should_run_tests.guard(check_workspace_binaries()), + should_run_tests.guard(check_wasm()), + should_run_tests.guard(check_dependencies()), // could be more specific here? + should_check_docs.guard(check_docs()), + should_check_licences.guard(check_licenses()), + should_check_scripts.guard(check_scripts()), + ]; + let ext_tests = extension_tests(); + let tests_pass = tests_pass(&jobs, &[&ext_tests.name]); + + jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here? + + named::workflow() + .add_event( + Event::default() + .push( + Push::default() + .add_branch("main") + .add_branch("v[0-9]+.[0-9]+.x"), + ) + .pull_request(PullRequest::default().add_branch("**")), + ) + .concurrency( + Concurrency::default() + .group(concat!( + "${{ github.workflow }}-${{ github.ref_name }}-", + "${{ github.ref_name == 'main' && github.sha || 'anysha' }}" + )) + .cancel_in_progress(true), + ) + .add_env(("CARGO_TERM_COLOR", "always")) + .add_env(("RUST_BACKTRACE", 1)) + .add_env(("CARGO_INCREMENTAL", 0)) + .map(|mut workflow| { + for job in jobs { + workflow = workflow.add_job(job.name, job.job) + } + workflow + }) + .add_job(ext_tests.name, ext_tests.job) + .add_job(tests_pass.name, tests_pass.job) +} + +/// Controls which features `orchestrate_impl` includes in the generated script. +#[derive(PartialEq, Eq)] +enum OrchestrateTarget { + /// For the main Zed repo: includes the cargo package filter and extension + /// change detection, but no working-directory scoping. + ZedRepo, + /// For individual extension repos: scopes changed-file detection to the + /// working directory, with no package filter or extension detection. + Extension, +} + +// Generates a bash script that checks changed files against regex patterns +// and sets GitHub output variables accordingly +pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob { + orchestrate_impl(rules, OrchestrateTarget::ZedRepo) +} + +pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob { + orchestrate_impl(rules, OrchestrateTarget::Extension) +} + +fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> NamedJob { + let name = "orchestrate".to_owned(); + let step_name = "filter".to_owned(); + let mut script = String::new(); + + script.push_str(indoc::indoc! {r#" + set -euo pipefail + if [ -z "$GITHUB_BASE_REF" ]; then + echo "Not in a PR context (i.e., push to main/stable/preview)" + COMPARE_REV="$(git rev-parse HEAD~1)" + else + echo "In a PR context comparing to pull_request.base.ref" + git fetch origin "$GITHUB_BASE_REF" --depth=350 + COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)" + fi + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + + "#}); + + if target == OrchestrateTarget::Extension { + script.push_str(indoc::indoc! {r#" + # When running from a subdirectory, git diff returns repo-root-relative paths. + # Filter to only files within the current working directory and strip the prefix. + REPO_SUBDIR="$(git rev-parse --show-prefix)" + REPO_SUBDIR="${REPO_SUBDIR%/}" + if [ -n "$REPO_SUBDIR" ]; then + CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)" + fi + + "#}); + } + + script.push_str(indoc::indoc! {r#" + check_pattern() { + local output_name="$1" + local pattern="$2" + local grep_arg="$3" + + echo "$CHANGED_FILES" | grep "$grep_arg" "$pattern" && \ + echo "${output_name}=true" >> "$GITHUB_OUTPUT" || \ + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + } + + "#}); + + let mut outputs = IndexMap::new(); + + if target == OrchestrateTarget::ZedRepo { + script.push_str(indoc::indoc! {r#" + # Check for changes that require full rebuild (no filter) + # Direct pushes to main/stable/preview always run full suite + if [ -z "$GITHUB_BASE_REF" ]; then + echo "Not a PR, running full test suite" + echo "changed_packages=" >> "$GITHUB_OUTPUT" + elif echo "$CHANGED_FILES" | grep -qP '^(rust-toolchain\.toml|\.cargo/|\.github/|Cargo\.(toml|lock)$)'; then + echo "Toolchain, cargo config, or root Cargo files changed, will run all tests" + echo "changed_packages=" >> "$GITHUB_OUTPUT" + else + # Extract changed directories from file paths + CHANGED_DIRS=$(echo "$CHANGED_FILES" | \ + grep -oP '^(crates|tooling)/\K[^/]+' | \ + sort -u || true) + + # Build directory-to-package mapping using cargo metadata + DIR_TO_PKG=$(cargo metadata --format-version=1 --no-deps 2>/dev/null | \ + jq -r '.packages[] | select(.manifest_path | test("crates/|tooling/")) | "\(.manifest_path | capture("(crates|tooling)/(?[^/]+)") | .dir)=\(.name)"') + + # Map directory names to package names + FILE_CHANGED_PKGS="" + for dir in $CHANGED_DIRS; do + pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1) + if [ -n "$pkg" ]; then + FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") + else + # Fall back to directory name if no mapping found + FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$dir") + fi + done + FILE_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' | sort -u || true) + + # If assets/ changed, add crates that depend on those assets + if echo "$CHANGED_FILES" | grep -qP '^assets/'; then + FILE_CHANGED_PKGS=$(printf '%s\n%s\n%s\n%s' "$FILE_CHANGED_PKGS" "settings" "storybook" "assets" | sort -u) + fi + + # Combine all changed packages + ALL_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' || true) + + if [ -z "$ALL_CHANGED_PKGS" ]; then + echo "No package changes detected, will run all tests" + echo "changed_packages=" >> "$GITHUB_OUTPUT" + else + # Build nextest filterset with rdeps for each package + FILTERSET=$(echo "$ALL_CHANGED_PKGS" | \ + sed 's/.*/rdeps(&)/' | \ + tr '\n' '|' | \ + sed 's/|$//') + echo "Changed packages filterset: $FILTERSET" + echo "changed_packages=$FILTERSET" >> "$GITHUB_OUTPUT" + fi + fi + + "#}); + + outputs.insert( + "changed_packages".to_owned(), + format!("${{{{ steps.{}.outputs.changed_packages }}}}", step_name), + ); + } + + for rule in rules { + assert!( + rule.set_by_step + .borrow_mut() + .replace(name.clone()) + .is_none() + ); + assert!( + outputs + .insert( + rule.name.to_owned(), + format!("${{{{ steps.{}.outputs.{} }}}}", step_name, rule.name) + ) + .is_none() + ); + + let grep_arg = if rule.invert { "-qvP" } else { "-qP" }; + script.push_str(&format!( + "check_pattern \"{}\" '{}' {}\n", + rule.name, rule.pattern, grep_arg + )); + } + + if target == OrchestrateTarget::ZedRepo { + script.push_str(DETECT_CHANGED_EXTENSIONS_SCRIPT); + script.push_str("echo \"changed_extensions=$EXTENSIONS_JSON\" >> \"$GITHUB_OUTPUT\"\n"); + + outputs.insert( + "changed_extensions".to_owned(), + format!("${{{{ steps.{}.outputs.changed_extensions }}}}", step_name), + ); + } + + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .with_repository_owner_guard() + .outputs(outputs) + .add_step(steps::checkout_repo().with_deep_history_on_non_main()) + .add_step(Step::new(step_name.clone()).run(script).id(step_name)); + + NamedJob { name, job } +} + +pub fn tests_pass(jobs: &[NamedJob], extra_job_names: &[&str]) -> NamedJob { + let mut script = String::from(indoc::indoc! {r#" + set +x + EXIT_CODE=0 + + check_result() { + echo "* $1: $2" + if [[ "$2" != "skipped" && "$2" != "success" ]]; then EXIT_CODE=1; fi + } + + "#}); + + let all_names: Vec<&str> = jobs + .iter() + .map(|job| job.name.as_str()) + .chain(extra_job_names.iter().copied()) + .collect(); + + let env_entries: Vec<_> = all_names + .iter() + .map(|name| { + let env_name = format!("RESULT_{}", name.to_uppercase()); + let env_value = format!("${{{{ needs.{}.result }}}}", name); + (env_name, env_value) + }) + .collect(); + + script.push_str( + &all_names + .iter() + .zip(env_entries.iter()) + .map(|(name, (env_name, _))| format!("check_result \"{}\" \"${}\"", name, env_name)) + .collect::>() + .join("\n"), + ); + + script.push_str("\n\nexit $EXIT_CODE\n"); + + let job = Job::default() + .runs_on(runners::LINUX_SMALL) + .needs( + all_names + .iter() + .map(|name| name.to_string()) + .collect::>(), + ) + .cond(repository_owner_guard_expression(true)) + .add_step( + env_entries + .into_iter() + .fold(named::bash(&script), |step, env_item| { + step.add_env(env_item) + }), + ); + + named::job(job) +} + +/// Bash script snippet that detects changed extension directories from `$CHANGED_FILES`. +/// Assumes `$CHANGED_FILES` is already set. Sets `$EXTENSIONS_JSON` to a JSON array of +/// changed extension paths. Callers are responsible for writing the result to `$GITHUB_OUTPUT`. +pub(crate) const DETECT_CHANGED_EXTENSIONS_SCRIPT: &str = indoc::indoc! {r#" + # Detect changed extension directories (excluding extensions/workflows) + CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true) + if [ -n "$CHANGED_EXTENSIONS" ]; then + EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))') + else + EXTENSIONS_JSON="[]" + fi +"#}; + +const TS_QUERY_LS_FILE: &str = "ts_query_ls-x86_64-unknown-linux-gnu.tar.gz"; +const CI_TS_QUERY_RELEASE: &str = "tags/v3.15.1"; + +pub(crate) fn fetch_ts_query_ls() -> Step { + named::uses( + "dsaltares", + "fetch-gh-release-asset", + "aa37ae5c44d3c9820bc12fe675e8670ecd93bd1c", + ) // v1.1.1 + .add_with(("repo", "ribru17/ts_query_ls")) + .add_with(("version", CI_TS_QUERY_RELEASE)) + .add_with(("file", TS_QUERY_LS_FILE)) +} + +pub(crate) fn run_ts_query_ls() -> Step { + named::bash(formatdoc!( + r#"tar -xf "$GITHUB_WORKSPACE/{TS_QUERY_LS_FILE}" -C "$GITHUB_WORKSPACE" + "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {{ + echo "Found unformatted queries, please format them with ts_query_ls." + echo "For easy use, install the Tree-sitter query extension:" + echo "zed://extension/tree-sitter-query" + false + }}"# + )) +} + +fn check_style() -> NamedJob { + fn check_for_typos() -> Step { + named::uses( + "crate-ci", + "typos", + "2d0ce569feab1f8752f1dde43cc2f2aa53236e06", + ) // v1.40.0 + .with(("config", "./typos.toml")) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_MEDIUM) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(steps::setup_pnpm()) + .add_step(steps::prettier()) + .add_step(steps::cargo_fmt()) + .add_step(steps::script("./script/check-todos")) + .add_step(steps::script("./script/check-keymaps")) + .add_step(check_for_typos()) + .add_step(fetch_ts_query_ls()) + .add_step(run_ts_query_ls()), + ) +} + +fn check_dependencies() -> NamedJob { + fn install_cargo_machete() -> Step { + named::uses( + "clechasseur", + "rs-cargo", + "8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2 + ) + .add_with(("command", "install")) + .add_with(("args", "cargo-machete@0.7.0")) + } + + fn run_cargo_machete() -> Step { + named::uses( + "clechasseur", + "rs-cargo", + "8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2 + ) + .add_with(("command", "machete")) + } + + fn check_cargo_lock() -> Step { + named::bash("cargo update --locked --workspace") + } + + fn check_vulnerable_dependencies() -> Step { + named::uses( + "actions", + "dependency-review-action", + "67d4f4bd7a9b17a0db54d2a7519187c65e339de8", // v4 + ) + .if_condition(Expression::new("github.event_name == 'pull_request'")) + .with(("license-check", false)) + } + + named::job(use_clang( + release_job(&[]) + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(install_cargo_machete()) + .add_step(run_cargo_machete()) + .add_step(check_cargo_lock()) + .add_step(check_vulnerable_dependencies()), + )) +} + +fn check_wasm() -> NamedJob { + fn install_nightly_wasm_toolchain() -> Step { + named::bash( + "rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown", + ) + } + + fn cargo_check_wasm() -> Step { + named::bash(concat!( + "cargo +nightly -Zbuild-std=std,panic_abort ", + "check --target wasm32-unknown-unknown -p gpui_platform", + )) + .add_env(( + "CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS", + "-C target-feature=+atomics,+bulk-memory,+mutable-globals", + )) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_LARGE) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(install_nightly_wasm_toolchain()) + .add_step(steps::setup_sccache(Platform::Linux)) + .add_step(cargo_check_wasm()) + .add_step(steps::show_sccache_stats(Platform::Linux)) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + ) +} + +fn check_workspace_binaries() -> NamedJob { + named::job(use_clang( + release_job(&[]) + .runs_on(runners::LINUX_LARGE) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_sccache(Platform::Linux)) + .add_step(steps::script("cargo build -p collab")) + .add_step(steps::script("cargo build --workspace --bins --examples")) + .add_step(steps::show_sccache_stats(Platform::Linux)) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + )) +} + +pub(crate) fn clippy(platform: Platform, arch: Option) -> NamedJob { + let target = arch.map(|arch| match (platform, arch) { + (Platform::Mac, Arch::X86_64) => "x86_64-apple-darwin", + (Platform::Mac, Arch::AARCH64) => "aarch64-apple-darwin", + _ => unimplemented!("cross-arch clippy not supported for {platform}/{arch}"), + }); + let runner = match platform { + Platform::Windows => runners::WINDOWS_DEFAULT, + Platform::Linux => runners::LINUX_DEFAULT, + Platform::Mac => runners::MAC_DEFAULT, + }; + let mut job = release_job(&[]) + .runs_on(runner) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(platform)) + .when( + platform == Platform::Linux || platform == Platform::Mac, + |this| this.add_step(steps::cache_rust_dependencies_namespace()), + ) + .when( + platform == Platform::Linux, + steps::install_linux_dependencies, + ) + .when_some(target, |this, target| { + this.add_step(steps::install_rustup_target(target)) + }) + .add_step(steps::setup_sccache(platform)) + .add_step(steps::clippy(platform, target)) + .add_step(steps::show_sccache_stats(platform)); + if platform == Platform::Linux { + job = use_clang(job); + } + let name = match arch { + Some(arch) => format!("clippy_{platform}_{arch}"), + None => format!("clippy_{platform}"), + }; + NamedJob { name, job } +} + +pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { + run_platform_tests_impl(platform, true) +} + +pub(crate) fn run_platform_tests_no_filter(platform: Platform) -> NamedJob { + run_platform_tests_impl(platform, false) +} + +fn run_platform_tests_impl(platform: Platform, filter_packages: bool) -> NamedJob { + let runner = match platform { + Platform::Windows => runners::WINDOWS_DEFAULT, + Platform::Linux => runners::LINUX_DEFAULT, + Platform::Mac => runners::MAC_DEFAULT, + }; + NamedJob { + name: format!("run_tests_{platform}"), + job: release_job(&[]) + .runs_on(runner) + .when(platform == Platform::Linux, |job| { + job.add_service( + "postgres", + Container::new("postgres:15") + .add_env(("POSTGRES_HOST_AUTH_METHOD", "trust")) + .ports(vec![Port::Name("5432:5432".into())]) + .options( + "--health-cmd pg_isready \ + --health-interval 500ms \ + --health-timeout 5s \ + --health-retries 10", + ), + ) + }) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(platform)) + .when(platform == Platform::Mac, |this| { + this.add_step(steps::cache_rust_dependencies_namespace()) + }) + .when(platform == Platform::Linux, |this| { + use_clang(this.add_step(steps::cache_rust_dependencies_namespace())) + }) + .when( + platform == Platform::Linux, + steps::install_linux_dependencies, + ) + .add_step(steps::setup_node()) + .when( + platform == Platform::Linux || platform == Platform::Mac, + |job| job.add_step(steps::cargo_install_nextest()), + ) + .add_step(steps::clear_target_dir_if_large(platform)) + .add_step(steps::setup_sccache(platform)) + .when(filter_packages, |job| { + job.add_step( + steps::cargo_nextest(platform).with_changed_packages_filter("orchestrate"), + ) + }) + .when(!filter_packages, |job| { + job.add_step(steps::cargo_nextest(platform)) + }) + .add_step(steps::show_sccache_stats(platform)) + .add_step(steps::cleanup_cargo_config(platform)), + } +} + +pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { + fn ensure_fresh_merge() -> Step { + named::bash(indoc::indoc! {r#" + if [ -z "$GITHUB_BASE_REF" ]; + then + echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV" + else + git checkout -B temp + git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp" + echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV" + fi + "#}) + } + + fn bufbuild_setup_action() -> Step { + named::uses("bufbuild", "buf-setup-action", "v1") + .add_with(("version", "v1.29.0")) + .add_with(("github_token", vars::GITHUB_TOKEN)) + } + + fn bufbuild_breaking_action() -> Step { + named::uses("bufbuild", "buf-breaking-action", "v1").add_with(("input", "crates/proto/proto/")) + .add_with(("against", "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/")) + } + + fn buf_lint() -> Step { + named::bash("buf lint crates/proto/proto") + } + + fn check_protobuf_formatting() -> Step { + named::bash("buf format --diff --exit-code crates/proto/proto") + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_DEFAULT) + .add_env(("GIT_AUTHOR_NAME", "Protobuf Action")) + .add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev")) + .add_env(("GIT_COMMITTER_NAME", "Protobuf Action")) + .add_env(("GIT_COMMITTER_EMAIL", "ci@zed.dev")) + .add_step(steps::checkout_repo().with_full_history()) + .add_step(ensure_fresh_merge()) + .add_step(bufbuild_setup_action()) + .add_step(bufbuild_breaking_action()) + .add_step(buf_lint()) + .add_step(check_protobuf_formatting()), + ) +} + +fn doctests() -> NamedJob { + fn run_doctests() -> Step { + named::bash(indoc::indoc! {r#" + cargo test --workspace --doc --no-fail-fast + "#}) + .id("run_doctests") + } + + named::job(use_clang( + release_job(&[]) + .runs_on(runners::LINUX_DEFAULT) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_cargo_config(Platform::Linux)) + .add_step(steps::setup_sccache(Platform::Linux)) + .add_step(run_doctests()) + .add_step(steps::show_sccache_stats(Platform::Linux)) + .add_step(steps::cleanup_cargo_config(Platform::Linux)), + )) +} + +fn check_licenses() -> NamedJob { + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step(steps::script("./script/check-licenses")) + .add_step(steps::script("./script/generate-licenses")), + ) +} + +fn check_docs() -> NamedJob { + fn lychee_link_check(dir: &str) -> Step { + named::uses( + "lycheeverse", + "lychee-action", + "82202e5e9c2f4ef1a55a3d02563e1cb6041e5332", + ) // v2.4.1 + .add_with(("args", format!("--no-progress --exclude '^http' '{dir}'"))) + .add_with(("fail", true)) + .add_with(("jobSummary", false)) + } + + fn install_mdbook() -> Step { + named::uses( + "peaceiris", + "actions-mdbook", + "ee69d230fe19748b7abf22df32acaa93833fad08", // v2 + ) + .with(("mdbook-version", "0.4.37")) + } + + fn build_docs() -> Step { + named::bash(indoc::indoc! {r#" + mkdir -p target/deploy + mdbook build ./docs --dest-dir=../target/deploy/docs/ + "#}) + } + + named::job(use_clang( + release_job(&[]) + .runs_on(runners::LINUX_LARGE) + .add_step(steps::checkout_repo()) + .add_step(steps::setup_cargo_config(Platform::Linux)) + // todo(ci): un-inline build_docs/action.yml here + .add_step(steps::cache_rust_dependencies_namespace()) + .add_step( + lychee_link_check("./docs/src/**/*"), // check markdown links + ) + .map(steps::install_linux_dependencies) + .add_step(steps::script("./script/generate-action-metadata")) + .add_step(install_mdbook()) + .add_step(build_docs()) + .add_step( + lychee_link_check("target/deploy/docs"), // check links in generated html + ), + )) +} + +pub(crate) fn check_scripts() -> NamedJob { + fn download_actionlint() -> Step { + named::bash( + "bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)", + ) + } + + fn run_actionlint() -> Step { + named::bash(r#""$ACTIONLINT_BIN" -color"#).add_env(( + "ACTIONLINT_BIN", + "${{ steps.get_actionlint.outputs.executable }}", + )) + } + + fn run_shellcheck() -> Step { + named::bash("./script/shellcheck-scripts error") + } + + fn check_xtask_workflows() -> Step { + named::bash(indoc::indoc! {r#" + cargo xtask workflows + if ! git diff --exit-code .github; then + echo "Error: .github directory has uncommitted changes after running 'cargo xtask workflows'" + echo "Please run 'cargo xtask workflows' locally and commit the changes" + exit 1 + fi + "#}) + } + + named::job( + release_job(&[]) + .runs_on(runners::LINUX_SMALL) + .add_step(steps::checkout_repo()) + .add_step(run_shellcheck()) + .add_step(download_actionlint().id("get_actionlint")) + .add_step(run_actionlint()) + .add_step(cache_rust_dependencies_namespace()) + .add_step(check_xtask_workflows()), + ) +} + +fn extension_tests() -> NamedJob { + let job = Job::default() + .needs(vec!["orchestrate".to_owned()]) + .cond(Expression::new( + "needs.orchestrate.outputs.changed_extensions != '[]'", + )) + .permissions(Permissions::default().contents(Level::Read)) + .strategy( + Strategy::default() + .fail_fast(false) + // TODO: Remove the limit. We currently need this to workaround the concurrency group issue + // where different matrix jobs would be placed in the same concurrency group and thus cancelled. + .max_parallel(1u32) + .matrix(json!({ + "extension": "${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}" + })), + ) + .uses_local(".github/workflows/extension_tests.yml") + .with(Input::default().add("working-directory", "${{ matrix.extension }}")); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/runners.rs b/tooling/xtask/src/tasks/workflows/runners.rs new file mode 100644 index 0000000000..bc4b17aaf6 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/runners.rs @@ -0,0 +1,69 @@ +pub const LINUX_SMALL: Runner = Runner("namespace-profile-2x4-ubuntu-2404"); +pub const LINUX_DEFAULT: Runner = LINUX_XL; +pub const LINUX_XL: Runner = Runner("namespace-profile-16x32-ubuntu-2204"); +pub const LINUX_LARGE: Runner = Runner("namespace-profile-8x16-ubuntu-2204"); +pub const LINUX_MEDIUM: Runner = Runner("namespace-profile-4x8-ubuntu-2204"); + +// Using Ubuntu 20.04 for minimal glibc version +pub const LINUX_X86_BUNDLER: Runner = Runner("namespace-profile-32x64-ubuntu-2004"); +pub const LINUX_ARM_BUNDLER: Runner = Runner("namespace-profile-8x32-ubuntu-2004-arm-m4"); + +// Larger Ubuntu runner with glibc 2.39 for extension bundling +pub const LINUX_LARGE_RAM: Runner = Runner("namespace-profile-8x32-ubuntu-2404"); + +pub const MAC_DEFAULT: Runner = Runner("namespace-profile-mac-large"); +pub const WINDOWS_DEFAULT: Runner = Runner("self-32vcpu-windows-2022"); + +pub struct Runner(&'static str); + +impl Into for Runner { + fn into(self) -> gh_workflow::RunsOn { + self.0.into() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Arch { + X86_64, + AARCH64, +} + +impl std::fmt::Display for Arch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Arch::X86_64 => write!(f, "x86_64"), + Arch::AARCH64 => write!(f, "aarch64"), + } + } +} + +impl Arch { + pub fn linux_bundler(&self) -> Runner { + match self { + Arch::X86_64 => LINUX_X86_BUNDLER, + Arch::AARCH64 => LINUX_ARM_BUNDLER, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Platform { + Windows, + Linux, + Mac, +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Platform::Windows => write!(f, "windows"), + Platform::Linux => write!(f, "linux"), + Platform::Mac => write!(f, "mac"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReleaseChannel { + Nightly, +} diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs new file mode 100644 index 0000000000..2593d5dd0e --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -0,0 +1,505 @@ +use gh_workflow::*; +use serde_json::Value; + +use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; + +pub(crate) fn use_clang(job: Job) -> Job { + job.add_env(Env::new("CC", "clang")) + .add_env(Env::new("CXX", "clang++")) +} + +const SCCACHE_R2_BUCKET: &str = "sccache-zed"; + +pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}"; +// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell +pub const PWSH_SHELL: &str = "pwsh"; + +pub(crate) struct Nextest(Step); + +pub(crate) fn cargo_nextest(platform: Platform) -> Nextest { + Nextest(named::run( + platform, + "cargo nextest run --workspace --no-fail-fast --no-tests=warn", + )) +} + +impl Nextest { + #[allow(dead_code)] + pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self { + if let Some(nextest_command) = self.0.value.run.as_mut() { + nextest_command.push_str(&format!(r#" -E "{filter_expr}""#)); + } + self + } + + pub(crate) fn with_changed_packages_filter(mut self, orchestrate_job: &str) -> Self { + if let Some(nextest_command) = self.0.value.run.as_mut() { + nextest_command.push_str(&format!( + r#"${{{{ needs.{orchestrate_job}.outputs.changed_packages && format(' -E "{{0}}"', needs.{orchestrate_job}.outputs.changed_packages) || '' }}}}"# + )); + } + self + } +} + +impl From for Step { + fn from(value: Nextest) -> Self { + value.0 + } +} + +#[derive(Default)] +enum FetchDepth { + #[default] + Shallow, + Full, + Custom(serde_json::Value), +} + +#[derive(Default)] +pub(crate) struct CheckoutStep { + fetch_depth: FetchDepth, + name: Option, + token: Option, + path: Option, + repository: Option, + ref_: Option, +} + +impl CheckoutStep { + pub fn with_full_history(mut self) -> Self { + self.fetch_depth = FetchDepth::Full; + self + } + + pub fn with_custom_name(mut self, name: &str) -> Self { + self.name = Some(name.to_string()); + self + } + + pub fn with_custom_fetch_depth(mut self, fetch_depth: impl Into) -> Self { + self.fetch_depth = FetchDepth::Custom(fetch_depth.into()); + self + } + + /// Sets `fetch-depth` to `2` on the main branch and `350` on all other branches. + pub fn with_deep_history_on_non_main(self) -> Self { + self.with_custom_fetch_depth("${{ github.ref == 'refs/heads/main' && 2 || 350 }}") + } + + pub fn with_token(mut self, token: &StepOutput) -> Self { + self.token = Some(token.to_string()); + self + } + + pub fn with_path(mut self, path: &str) -> Self { + self.path = Some(path.to_string()); + self + } + + pub fn with_repository(mut self, repository: &str) -> Self { + self.repository = Some(repository.to_string()); + self + } + + pub fn with_ref(mut self, ref_: impl ToString) -> Self { + self.ref_ = Some(ref_.to_string()); + self + } +} + +impl From for Step { + fn from(value: CheckoutStep) -> Self { + Step::new(value.name.unwrap_or("steps::checkout_repo".to_string())) + .uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + // prevent checkout action from running `git clean -ffdx` which + // would delete the target directory + .add_with(("clean", false)) + .map(|step| match value.fetch_depth { + FetchDepth::Shallow => step, + FetchDepth::Full => step.add_with(("fetch-depth", 0)), + FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)), + }) + .when_some(value.path, |step, path| step.add_with(("path", path))) + .when_some(value.repository, |step, repository| { + step.add_with(("repository", repository)) + }) + .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_))) + .when_some(value.token, |step, token| step.add_with(("token", token))) + } +} + +pub fn checkout_repo() -> CheckoutStep { + CheckoutStep::default() +} + +pub fn setup_pnpm() -> Step { + named::uses( + "pnpm", + "action-setup", + "fe02b34f77f8bc703788d5817da081398fad5dd2", // v4.0.0 + ) + .add_with(("version", "9")) +} + +pub fn setup_node() -> Step { + named::uses( + "actions", + "setup-node", + "49933ea5288caeca8642d1e84afbd3f7d6820020", // v4 + ) + .add_with(("node-version", "20")) +} + +pub fn setup_sentry() -> Step { + named::uses( + "matbour", + "setup-sentry-cli", + "3e938c54b3018bdd019973689ef984e033b0454b", + ) + .add_with(("token", vars::SENTRY_AUTH_TOKEN)) +} + +pub fn prettier() -> Step { + named::bash("./script/prettier") +} + +pub fn cargo_fmt() -> Step { + named::bash("cargo fmt --all -- --check") +} + +pub fn cargo_install_nextest() -> Step { + named::uses("taiki-e", "install-action", "nextest") +} + +pub fn setup_cargo_config(platform: Platform) -> Step { + match platform { + Platform::Windows => named::pwsh(indoc::indoc! {r#" + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + "#}), + + Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#" + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + "#}), + } +} + +pub fn cleanup_cargo_config(platform: Platform) -> Step { + let step = match platform { + Platform::Windows => named::pwsh(indoc::indoc! {r#" + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + "#}), + Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#" + rm -rf ./../.cargo + "#}), + }; + + step.if_condition(Expression::new("always()")) +} + +pub fn clear_target_dir_if_large(platform: Platform) -> Step { + match platform { + Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 250"), + Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 250"), + Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 300"), + } +} + +pub fn clippy(platform: Platform, target: Option<&str>) -> Step { + match platform { + Platform::Windows => named::pwsh("./script/clippy.ps1"), + _ => match target { + Some(target) => named::bash(format!("./script/clippy --target {target}")), + None => named::bash("./script/clippy"), + }, + } +} + +pub fn install_rustup_target(target: &str) -> Step { + named::bash(format!("rustup target add {target}")) +} + +pub fn cache_rust_dependencies_namespace() -> Step { + named::uses("namespacelabs", "nscloud-cache-action", "v1") + .add_with(("cache", "rust")) + .add_with(("path", "~/.rustup")) +} + +pub fn setup_sccache(platform: Platform) -> Step { + let step = match platform { + Platform::Windows => named::pwsh("./script/setup-sccache.ps1"), + Platform::Linux | Platform::Mac => named::bash("./script/setup-sccache"), + }; + step.add_env(("R2_ACCOUNT_ID", vars::R2_ACCOUNT_ID)) + .add_env(("R2_ACCESS_KEY_ID", vars::R2_ACCESS_KEY_ID)) + .add_env(("R2_SECRET_ACCESS_KEY", vars::R2_SECRET_ACCESS_KEY)) + .add_env(("SCCACHE_BUCKET", SCCACHE_R2_BUCKET)) +} + +pub fn show_sccache_stats(platform: Platform) -> Step { + match platform { + // Use $env:RUSTC_WRAPPER (absolute path) because GITHUB_PATH changes + // don't take effect until the next step in PowerShell. + // Check if RUSTC_WRAPPER is set first (it won't be for fork PRs without secrets). + Platform::Windows => { + named::pwsh("if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0") + } + Platform::Linux | Platform::Mac => named::bash("sccache --show-stats || true"), + } +} + +pub fn cache_nix_dependencies_namespace() -> Step { + named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix")) +} + +pub fn cache_nix_store_macos() -> Step { + // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix` + // cannot mount or symlink there. Instead we cache a user-writable directory and + // use nix-store --import/--export in separate steps to transfer store paths. + named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache")) +} + +pub fn setup_linux() -> Step { + named::bash("./script/linux") +} + +fn download_wasi_sdk() -> Step { + named::bash("./script/download-wasi-sdk") +} + +pub(crate) fn install_linux_dependencies(job: Job) -> Job { + job.add_step(setup_linux()).add_step(download_wasi_sdk()) +} + +pub fn script(name: &str) -> Step { + if name.ends_with(".ps1") { + Step::new(name).run(name).shell(PWSH_SHELL) + } else { + Step::new(name).run(name) + } +} + +pub struct NamedJob { + pub name: String, + pub job: Job, +} + +// impl NamedJob { +// pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self { +// NamedJob { +// name: self.name, +// job: f(self.job), +// } +// } +// } + +pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str = + "(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')"; + +pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression { + Expression::new(format!( + "{}{}", + DEFAULT_REPOSITORY_OWNER_GUARD, + trigger_always.then_some(" && always()").unwrap_or_default() + )) +} + +pub trait CommonJobConditions: Sized { + fn with_repository_owner_guard(self) -> Self; +} + +impl CommonJobConditions for Job { + fn with_repository_owner_guard(self) -> Self { + self.cond(repository_owner_guard_expression(false)) + } +} + +pub(crate) fn release_job(deps: &[&NamedJob]) -> Job { + dependant_job(deps) + .with_repository_owner_guard() + .timeout_minutes(60u32) +} + +pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job { + let job = Job::default(); + if deps.len() > 0 { + job.needs(deps.iter().map(|j| j.name.clone()).collect::>()) + } else { + job + } +} + +impl FluentBuilder for Job {} +impl FluentBuilder for Workflow {} +impl FluentBuilder for Input {} +impl FluentBuilder for Step {} + +/// A helper trait for building complex objects with imperative conditionals in a fluent style. +/// Copied from GPUI to avoid adding GPUI as dependency +/// todo(ci) just put this in gh-workflow +#[allow(unused)] +pub trait FluentBuilder { + /// Imperatively modify self with the given closure. + fn map(self, f: impl FnOnce(Self) -> U) -> U + where + Self: Sized, + { + f(self) + } + + /// Conditionally modify self with the given closure. + fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| if condition { then(this) } else { this }) + } + + /// Conditionally modify self with the given closure. + fn when_else( + self, + condition: bool, + then: impl FnOnce(Self) -> Self, + else_fn: impl FnOnce(Self) -> Self, + ) -> Self + where + Self: Sized, + { + self.map(|this| if condition { then(this) } else { else_fn(this) }) + } + + /// Conditionally unwrap and modify self with the given closure, if the given option is Some. + fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| { + if let Some(value) = option { + then(this, value) + } else { + this + } + }) + } + /// Conditionally unwrap and modify self with the given closure, if the given option is None. + fn when_none(self, option: &Option, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| if option.is_some() { this } else { then(this) }) + } +} + +// (janky) helper to generate steps with a name that corresponds +// to the name of the calling function. +pub mod named { + use super::*; + + /// Returns a uses step with the same name as the enclosing function. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step { + Step::new(function_name(1)).uses(owner, repo, ref_) + } + + /// Returns a bash-script step with the same name as the enclosing function. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn bash(script: impl AsRef) -> Step { + Step::new(function_name(1)).run(script.as_ref()) + } + + /// Returns a pwsh-script step with the same name as the enclosing function. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn pwsh(script: &str) -> Step { + Step::new(function_name(1)).run(script).shell(PWSH_SHELL) + } + + /// Runs the command in either powershell or bash, depending on platform. + /// (You shouldn't inline this function into the workflow definition, you must + /// wrap it in a new function.) + pub fn run(platform: Platform, script: &str) -> Step { + match platform { + Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL), + Platform::Linux | Platform::Mac => Step::new(function_name(1)).run(script), + } + } + + /// Returns a Workflow with the same name as the enclosing module with default + /// set for the running shell. + pub fn workflow() -> Workflow { + Workflow::default() + .name( + named::function_name(1) + .split("::") + .collect::>() + .into_iter() + .rev() + .skip(1) + .rev() + .collect::>() + .join("::"), + ) + .defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL))) + } + + /// Returns a Job with the same name as the enclosing function. + /// (note job names may not contain `::`) + pub fn job(job: Job) -> NamedJob { + NamedJob { + name: function_name(1).split("::").last().unwrap().to_owned(), + job, + } + } + + /// Returns the function name N callers above in the stack + /// (typically 1). + /// This only works because xtask always runs debug builds. + pub fn function_name(i: usize) -> String { + let mut name = "".to_string(); + let mut count = 0; + backtrace::trace(|frame| { + if count < i + 3 { + count += 1; + return true; + } + backtrace::resolve_frame(frame, |cb| { + if let Some(s) = cb.name() { + name = s.to_string() + } + }); + false + }); + + name.split("::") + .skip_while(|s| s != &"workflows") + .skip(1) + .collect::>() + .join("::") + } +} + +pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { + named::bash(r#"git fetch origin "$REF_NAME" && git checkout "$REF_NAME""#) + .add_env(("REF_NAME", ref_name.to_string())) +} + +pub fn authenticate_as_zippy() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "create-github-app-token", + "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + ) + .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) + .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) + .id("get-app-token"); + let output = StepOutput::new(&step, "token"); + (step, output) +} diff --git a/tooling/xtask/src/tasks/workflows/vars.rs b/tooling/xtask/src/tasks/workflows/vars.rs new file mode 100644 index 0000000000..b3f8bdf56e --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/vars.rs @@ -0,0 +1,381 @@ +use std::cell::RefCell; + +use gh_workflow::{ + Concurrency, Env, Expression, Step, WorkflowCallInput, WorkflowCallSecret, + WorkflowDispatchInput, +}; + +use crate::tasks::workflows::{runners::Platform, steps::NamedJob}; + +macro_rules! secret { + ($secret_name:ident) => { + pub const $secret_name: &str = concat!("${{ secrets.", stringify!($secret_name), " }}"); + }; +} + +macro_rules! var { + ($var_name:ident) => { + pub const $var_name: &str = concat!("${{ vars.", stringify!($var_name), " }}"); + }; +} + +secret!(ANTHROPIC_API_KEY); +secret!(OPENAI_API_KEY); +secret!(GOOGLE_AI_API_KEY); +secret!(GOOGLE_CLOUD_PROJECT); +secret!(APPLE_NOTARIZATION_ISSUER_ID); +secret!(APPLE_NOTARIZATION_KEY); +secret!(APPLE_NOTARIZATION_KEY_ID); +secret!(AZURE_SIGNING_CLIENT_ID); +secret!(AZURE_SIGNING_CLIENT_SECRET); +secret!(AZURE_SIGNING_TENANT_ID); +secret!(CACHIX_AUTH_TOKEN); +secret!(CLUSTER_NAME); +secret!(DIGITALOCEAN_ACCESS_TOKEN); +secret!(DIGITALOCEAN_SPACES_ACCESS_KEY); +secret!(DIGITALOCEAN_SPACES_SECRET_KEY); +secret!(GITHUB_TOKEN); +secret!(MACOS_CERTIFICATE); +secret!(MACOS_CERTIFICATE_PASSWORD); +secret!(SENTRY_AUTH_TOKEN); +secret!(ZED_CLIENT_CHECKSUM_SEED); +secret!(ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON); +secret!(ZED_SENTRY_MINIDUMP_ENDPOINT); +secret!(SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN); +secret!(ZED_ZIPPY_APP_ID); +secret!(ZED_ZIPPY_APP_PRIVATE_KEY); +secret!(DISCORD_WEBHOOK_RELEASE_NOTES); +secret!(WINGET_TOKEN); +secret!(VERCEL_TOKEN); +secret!(SLACK_WEBHOOK_WORKFLOW_FAILURES); +secret!(R2_ACCOUNT_ID); +secret!(R2_ACCESS_KEY_ID); +secret!(R2_SECRET_ACCESS_KEY); + +// todo(ci) make these secrets too... +var!(AZURE_SIGNING_ACCOUNT_NAME); +var!(AZURE_SIGNING_CERT_PROFILE_NAME); +var!(AZURE_SIGNING_ENDPOINT); + +pub fn bundle_envs(platform: Platform) -> Env { + let env = Env::default() + .add("CARGO_INCREMENTAL", 0) + .add("ZED_CLIENT_CHECKSUM_SEED", ZED_CLIENT_CHECKSUM_SEED) + .add("ZED_MINIDUMP_ENDPOINT", ZED_SENTRY_MINIDUMP_ENDPOINT); + + match platform { + Platform::Linux => env, + Platform::Mac => env + .add("MACOS_CERTIFICATE", MACOS_CERTIFICATE) + .add("MACOS_CERTIFICATE_PASSWORD", MACOS_CERTIFICATE_PASSWORD) + .add("APPLE_NOTARIZATION_KEY", APPLE_NOTARIZATION_KEY) + .add("APPLE_NOTARIZATION_KEY_ID", APPLE_NOTARIZATION_KEY_ID) + .add("APPLE_NOTARIZATION_ISSUER_ID", APPLE_NOTARIZATION_ISSUER_ID), + Platform::Windows => env + .add("AZURE_TENANT_ID", AZURE_SIGNING_TENANT_ID) + .add("AZURE_CLIENT_ID", AZURE_SIGNING_CLIENT_ID) + .add("AZURE_CLIENT_SECRET", AZURE_SIGNING_CLIENT_SECRET) + .add("ACCOUNT_NAME", AZURE_SIGNING_ACCOUNT_NAME) + .add("CERT_PROFILE_NAME", AZURE_SIGNING_CERT_PROFILE_NAME) + .add("ENDPOINT", AZURE_SIGNING_ENDPOINT) + .add("FILE_DIGEST", "SHA256") + .add("TIMESTAMP_DIGEST", "SHA256") + .add("TIMESTAMP_SERVER", "http://timestamp.acs.microsoft.com"), + } +} + +pub fn one_workflow_per_non_main_branch() -> Concurrency { + one_workflow_per_non_main_branch_and_token("") +} + +pub fn one_workflow_per_non_main_branch_and_token>(token: T) -> Concurrency { + Concurrency::default() + .group(format!( + concat!( + "${{{{ github.workflow }}}}-${{{{ github.ref_name }}}}-", + "${{{{ github.ref_name == 'main' && github.sha || 'anysha' }}}}{}" + ), + token.as_ref() + )) + .cancel_in_progress(true) +} + +pub(crate) fn allow_concurrent_runs() -> Concurrency { + Concurrency::default() + .group("${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}") + .cancel_in_progress(true) +} + +// Represents a pattern to check for changed files and corresponding output variable +pub struct PathCondition { + pub name: &'static str, + pub pattern: &'static str, + pub invert: bool, + pub set_by_step: RefCell>, +} +impl PathCondition { + pub fn new(name: &'static str, pattern: &'static str) -> Self { + Self { + name, + pattern, + invert: false, + set_by_step: Default::default(), + } + } + pub fn inverted(name: &'static str, pattern: &'static str) -> Self { + Self { + name, + pattern, + invert: true, + set_by_step: Default::default(), + } + } + pub fn guard(&self, job: NamedJob) -> NamedJob { + let set_by_step = self + .set_by_step + .borrow() + .clone() + .unwrap_or_else(|| panic!("condition {},is never set", self.name)); + NamedJob { + name: job.name, + job: job + .job + .add_need(set_by_step.clone()) + .cond(Expression::new(format!( + "needs.{}.outputs.{} == 'true'", + &set_by_step, self.name + ))), + } + } +} + +pub(crate) struct StepOutput { + pub name: &'static str, + step_id: String, +} + +impl StepOutput { + pub fn new(step: &Step, name: &'static str) -> Self { + let step_id = step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"); + + assert!( + step.value + .run + .as_ref() + .is_none_or(|run_command| run_command.contains(name)), + "Step Output name {name} must occur at least once in run command with ID {step_id}!" + ); + + Self { name, step_id } + } + + pub fn new_unchecked(step: &Step, name: &'static str) -> Self { + let step_id = step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"); + + Self { name, step_id } + } + + pub fn expr(&self) -> String { + format!("steps.{}.outputs.{}", self.step_id, self.name) + } + + pub fn as_job_output(self, job: &NamedJob) -> JobOutput { + JobOutput { + job_name: job.name.clone(), + name: self.name, + } + } +} + +impl serde::Serialize for StepOutput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl std::fmt::Display for StepOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ {} }}}}", self.expr()) + } +} + +pub(crate) struct JobOutput { + job_name: String, + name: &'static str, +} + +impl JobOutput { + pub fn expr(&self) -> String { + format!("needs.{}.outputs.{}", self.job_name, self.name) + } +} + +impl serde::Serialize for JobOutput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl std::fmt::Display for JobOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ {} }}}}", self.expr()) + } +} + +pub struct WorkflowInput { + pub input_type: &'static str, + pub name: &'static str, + pub default: Option, + pub description: Option, +} + +impl WorkflowInput { + pub fn string(name: &'static str, default: Option) -> Self { + Self { + input_type: "string", + name, + default, + description: None, + } + } + + pub fn bool(name: &'static str, default: Option) -> Self { + Self { + input_type: "boolean", + name, + default: default.as_ref().map(ToString::to_string), + description: None, + } + } + + pub fn description(mut self, description: impl ToString) -> Self { + self.description = Some(description.to_string()); + self + } + + pub fn input(&self) -> WorkflowDispatchInput { + WorkflowDispatchInput { + description: self + .description + .clone() + .unwrap_or_else(|| self.name.to_owned()), + required: self.default.is_none(), + input_type: self.input_type.to_owned(), + default: self.default.clone(), + } + } + + pub fn call_input(&self) -> WorkflowCallInput { + WorkflowCallInput { + description: self.name.to_owned(), + required: self.default.is_none(), + input_type: self.input_type.to_owned(), + default: self.default.clone(), + } + } + + pub(crate) fn expr(&self) -> String { + format!("inputs.{}", self.name) + } +} + +impl std::fmt::Display for WorkflowInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ {} }}}}", self.expr()) + } +} + +impl serde::Serialize for WorkflowInput { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub(crate) struct WorkflowSecret { + pub name: &'static str, + description: String, + required: bool, +} + +impl WorkflowSecret { + pub fn new(name: &'static str, description: impl ToString) -> Self { + Self { + name, + description: description.to_string(), + required: true, + } + } + + pub fn secret_configuration(&self) -> WorkflowCallSecret { + WorkflowCallSecret { + description: self.description.clone(), + required: self.required, + } + } +} + +impl std::fmt::Display for WorkflowSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "${{{{ secrets.{} }}}}", self.name) + } +} + +impl serde::Serialize for WorkflowSecret { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub mod assets { + // NOTE: these asset names also exist in the zed.dev codebase. + pub const MAC_AARCH64: &str = "Zed-aarch64.dmg"; + pub const MAC_X86_64: &str = "Zed-x86_64.dmg"; + pub const LINUX_AARCH64: &str = "zed-linux-aarch64.tar.gz"; + pub const LINUX_X86_64: &str = "zed-linux-x86_64.tar.gz"; + pub const WINDOWS_X86_64: &str = "Zed-x86_64.exe"; + pub const WINDOWS_AARCH64: &str = "Zed-aarch64.exe"; + + pub const REMOTE_SERVER_MAC_AARCH64: &str = "zed-remote-server-macos-aarch64.gz"; + pub const REMOTE_SERVER_MAC_X86_64: &str = "zed-remote-server-macos-x86_64.gz"; + pub const REMOTE_SERVER_LINUX_AARCH64: &str = "zed-remote-server-linux-aarch64.gz"; + pub const REMOTE_SERVER_LINUX_X86_64: &str = "zed-remote-server-linux-x86_64.gz"; + pub const REMOTE_SERVER_WINDOWS_AARCH64: &str = "zed-remote-server-windows-aarch64.zip"; + pub const REMOTE_SERVER_WINDOWS_X86_64: &str = "zed-remote-server-windows-x86_64.zip"; + + pub fn all() -> Vec<&'static str> { + vec![ + MAC_AARCH64, + MAC_X86_64, + LINUX_AARCH64, + LINUX_X86_64, + WINDOWS_X86_64, + WINDOWS_AARCH64, + REMOTE_SERVER_MAC_AARCH64, + REMOTE_SERVER_MAC_X86_64, + REMOTE_SERVER_LINUX_AARCH64, + REMOTE_SERVER_LINUX_X86_64, + REMOTE_SERVER_WINDOWS_AARCH64, + REMOTE_SERVER_WINDOWS_X86_64, + ] + } +} diff --git a/tooling/xtask/src/workspace.rs b/tooling/xtask/src/workspace.rs new file mode 100644 index 0000000000..fd71aa6bbd --- /dev/null +++ b/tooling/xtask/src/workspace.rs @@ -0,0 +1,9 @@ +use anyhow::{Context as _, Result}; +use cargo_metadata::{Metadata, MetadataCommand}; + +/// Returns the Cargo workspace. +pub fn load_workspace() -> Result { + MetadataCommand::new() + .exec() + .context("failed to load cargo metadata") +} diff --git a/typos.toml b/typos.toml index a367402ae6..2d718e47cc 100644 --- a/typos.toml +++ b/typos.toml @@ -21,4 +21,4 @@ extend-ignore-re = [ # Stripped version of reserved keyword `type` "typ", ] -check-filename = true \ No newline at end of file +check-filename = true