diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 07caa6007e87fcd093b40bb9a15108e18b159068..97e918aab37f3dc375eb259f416f7998b4b196fd 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -466,6 +466,45 @@ jobs: run: | rm -rf ./../.cargo timeout-minutes: 60 + check_wasm: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup + - name: run_tests::check_wasm::install_nightly_wasm_toolchain + run: rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed + - name: run_tests::check_wasm::cargo_check_wasm + run: cargo +nightly -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown -p gpui_platform + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: -C target-feature=+atomics,+bulk-memory,+mutable-globals + - name: steps::show_sccache_stats + run: sccache --show-stats || true + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + timeout-minutes: 60 check_dependencies: needs: - orchestrate @@ -641,6 +680,7 @@ jobs: - run_tests_mac - doctests - check_workspace_binaries + - check_wasm - check_dependencies - check_docs - check_licenses @@ -668,6 +708,7 @@ jobs: check_result "run_tests_mac" "${{ needs.run_tests_mac.result }}" check_result "doctests" "${{ needs.doctests.result }}" check_result "check_workspace_binaries" "${{ needs.check_workspace_binaries.result }}" + check_result "check_wasm" "${{ needs.check_wasm.result }}" check_result "check_dependencies" "${{ needs.check_dependencies.result }}" check_result "check_docs" "${{ needs.check_docs.result }}" check_result "check_licenses" "${{ needs.check_licenses.result }}" diff --git a/Cargo.lock b/Cargo.lock index 2e08fe8eed35168b13df21f1c4ab5d01ba1664c3..85cf10d661d68a535cce85904d4ae9c3aedb651f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3494,6 +3494,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -6243,6 +6253,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[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.8" @@ -6592,6 +6608,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 0.5.7", + "futures-core", + "futures-lite 2.6.1", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -7416,6 +7445,7 @@ name = "gpui" version = "0.2.2" dependencies = [ "anyhow", + "async-channel 2.5.0", "async-task", "backtrace", "bindgen 0.71.1", @@ -7439,8 +7469,11 @@ dependencies = [ "etagere", "foreign-types 0.5.0", "futures 0.3.31", + "futures-concurrency", + "getrandom 0.3.4", "gpui_macros", "gpui_platform", + "gpui_util", "http_client", "image", "inventory", @@ -7459,6 +7492,7 @@ dependencies = [ "parking_lot", "pathfinder_geometry", "pin-project", + "pollster 0.4.0", "postage", "pretty_assertions", "profiling", @@ -7474,7 +7508,6 @@ dependencies = [ "serde_json", "slotmap", "smallvec", - "smol", "spin 0.10.0", "stacksafe", "strum 0.27.2", @@ -7482,11 +7515,13 @@ dependencies = [ "taffy", "thiserror 2.0.17", "unicode-segmentation", + "url", "usvg", - "util", "util_macros", "uuid", "waker-fn", + "wasm-bindgen", + "web-time", "windows 0.61.3", "zed-font-kit", "zed-scap", @@ -7504,7 +7539,6 @@ dependencies = [ "calloop", "calloop-wayland-source", "collections", - "cosmic-text", "filedescriptor", "futures 0.3.31", "gpui", @@ -7517,6 +7551,7 @@ dependencies = [ "open", "parking_lot", "pathfinder_geometry", + "pollster 0.4.0", "profiling", "raw-window-handle", "smallvec", @@ -7535,7 +7570,6 @@ dependencies = [ "x11-clipboard", "x11rb", "xkbcommon", - "zed-font-kit", "zed-scap", "zed-xim", ] @@ -7596,9 +7630,11 @@ dependencies = [ name = "gpui_platform" version = "0.1.0" dependencies = [ + "console_error_panic_hook", "gpui", "gpui_linux", "gpui_macos", + "gpui_web", "gpui_windows", ] @@ -7612,6 +7648,36 @@ dependencies = [ "util", ] +[[package]] +name = "gpui_util" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", +] + +[[package]] +name = "gpui_web" +version = "0.1.0" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "futures 0.3.31", + "gpui", + "gpui_wgpu", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "smallvec", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + [[package]] name = "gpui_wgpu" version = "0.1.0" @@ -7619,15 +7685,24 @@ dependencies = [ "anyhow", "bytemuck", "collections", + "cosmic-text", "etagere", "gpui", + "gpui_util", + "itertools 0.14.0", + "js-sys", "log", "parking_lot", + "pollster 0.4.0", "profiling", "raw-window-handle", - "smol", - "util", + "smallvec", + "swash", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "wgpu", + "zed-font-kit", ] [[package]] @@ -12252,7 +12327,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", "indexmap", ] @@ -12574,6 +12649,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "pori" version = "0.0.0" @@ -12631,7 +12712,7 @@ dependencies = [ "log", "parking_lot", "pin-project", - "pollster", + "pollster 0.2.5", "static_assertions", "thiserror 1.0.69", ] @@ -14801,6 +14882,7 @@ dependencies = [ "futures 0.3.31", "parking_lot", "rand 0.9.2", + "web-time", ] [[package]] @@ -18603,6 +18685,7 @@ dependencies = [ "futures-lite 1.13.0", "git2", "globset", + "gpui_util", "indoc", "itertools 0.14.0", "libc", @@ -19118,6 +19201,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +dependencies = [ + "futures 0.3.31", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.201.0" diff --git a/Cargo.toml b/Cargo.toml index cb0df36a2e6d5323aa4e758a4d299bd5ffdc22c4..ac80f187e6ffc16a95753e83ae7a333c6bc9ffdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [workspace] resolver = "2" members = [ - "crates/acp_tools", "crates/acp_thread", + "crates/acp_tools", "crates/action_log", "crates/activity_indicator", "crates/agent", @@ -13,9 +13,9 @@ members = [ "crates/anthropic", "crates/askpass", "crates/assets", - "crates/assistant_text_thread", "crates/assistant_slash_command", "crates/assistant_slash_commands", + "crates/assistant_text_thread", "crates/audio", "crates/auto_update", "crates/auto_update_helper", @@ -32,6 +32,7 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", + "crates/codestral", "crates/collab", "crates/collab_ui", "crates/collections", @@ -56,9 +57,10 @@ members = [ "crates/diagnostics", "crates/docs_preprocessor", "crates/edit_prediction", + "crates/edit_prediction_cli", + "crates/edit_prediction_context", "crates/edit_prediction_types", "crates/edit_prediction_ui", - "crates/edit_prediction_context", "crates/editor", "crates/encoding_selector", "crates/etw_tracing", @@ -88,9 +90,11 @@ members = [ "crates/gpui_macos", "crates/gpui_macros", "crates/gpui_platform", + "crates/gpui_tokio", + "crates/gpui_util", + "crates/gpui_web", "crates/gpui_wgpu", "crates/gpui_windows", - "crates/gpui_tokio", "crates/html_to_markdown", "crates/http_client", "crates/http_client_tls", @@ -119,8 +123,8 @@ members = [ "crates/media", "crates/menu", "crates/migrator", - "crates/mistral", "crates/miniprofiler_ui", + "crates/mistral", "crates/multi_buffer", "crates/nc", "crates/net", @@ -136,6 +140,7 @@ members = [ "crates/panel", "crates/paths", "crates/picker", + "crates/platform_title_bar", "crates/prettier", "crates/project", "crates/project_benchmarks", @@ -147,7 +152,6 @@ members = [ "crates/refineable", "crates/refineable/derive_refineable", "crates/release_channel", - "crates/scheduler", "crates/remote", "crates/remote_connection", "crates/remote_server", @@ -157,10 +161,10 @@ members = [ "crates/rope", "crates/rpc", "crates/rules_library", + "crates/scheduler", "crates/schema_generator", "crates/search", "crates/session", - "crates/sidebar", "crates/settings", "crates/settings_content", "crates/settings_json", @@ -168,6 +172,7 @@ members = [ "crates/settings_profile_selector", "crates/settings_ui", "crates/shell_command_parser", + "crates/sidebar", "crates/snippet", "crates/snippet_provider", "crates/snippets_ui", @@ -179,7 +184,6 @@ members = [ "crates/sum_tree", "crates/supermaven", "crates/supermaven_api", - "crates/codestral", "crates/svg_preview", "crates/system_specs", "crates/tab_switcher", @@ -195,7 +199,6 @@ members = [ "crates/theme_importer", "crates/theme_selector", "crates/time_format", - "crates/platform_title_bar", "crates/title_bar", "crates/toolchain_selector", "crates/ui", @@ -207,10 +210,10 @@ members = [ "crates/vercel", "crates/vim", "crates/vim_mode_setting", - "crates/which_key", "crates/watch", "crates/web_search", "crates/web_search_providers", + "crates/which_key", "crates/workspace", "crates/worktree", "crates/worktree_benchmarks", @@ -218,7 +221,6 @@ members = [ "crates/zed", "crates/zed_actions", "crates/zed_env_vars", - "crates/edit_prediction_cli", "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", @@ -332,9 +334,11 @@ gpui_linux = { path = "crates/gpui_linux", default-features = false } gpui_macos = { path = "crates/gpui_macos", default-features = false } gpui_macros = { path = "crates/gpui_macros" } gpui_platform = { path = "crates/gpui_platform", default-features = false } +gpui_web = { path = "crates/gpui_web" } gpui_wgpu = { path = "crates/gpui_wgpu" } gpui_windows = { path = "crates/gpui_windows", default-features = false } gpui_tokio = { path = "crates/gpui_tokio" } +gpui_util = { path = "crates/gpui_util" } html_to_markdown = { path = "crates/html_to_markdown" } http_client = { path = "crates/http_client" } http_client_tls = { path = "crates/http_client_tls" } @@ -487,6 +491,7 @@ ashpd = { version = "0.13", default-features = false, features = [ "settings", "trash" ] } +async-channel = "2.5.0" async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" @@ -547,6 +552,7 @@ exec = "0.3.1" fancy-regex = "0.16.0" fork = "0.4.0" futures = "0.3" +futures-concurrency = "7.7.1" futures-lite = "1.13" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "c9eac0ed361583e1072860d96776fa52775b82ac" } git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] } @@ -637,6 +643,7 @@ profiling = "1" prost = "0.9" prost-build = "0.9" prost-types = "0.9" +pollster = "0.4.0" pulldown-cmark = { version = "0.13.0", default-features = false } quote = "1.0.9" rand = "0.9" @@ -761,6 +768,8 @@ wasmtime = { version = "33", default-features = false, features = [ wasmtime-wasi = "33" wax = "0.7" which = "6.0.0" +wasm-bindgen = "0.2.104" +web-time = "1.1.0" wgpu = "28.0" windows-core = "0.61" yawc = "0.2.5" diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index cce229a7d3c51d8cff7e6ee4f8880cc8e8d8f73c..fbc8a10571d73ff34f8e37f4591b43c0fdaaab1f 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -21,7 +21,6 @@ default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", "collections/test-support", - "util/test-support", "http_client/test-support", "wayland", "x11", @@ -37,7 +36,7 @@ x11 = [ screen-capture = [ "scap", ] -windows-manifest = [] +windows-manifest = ["dep:embed-resource"] [lib] path = "src/gpui.rs" @@ -54,8 +53,8 @@ ctor.workspace = true derive_more.workspace = true etagere = "0.2" futures.workspace = true +futures-concurrency.workspace = true gpui_macros.workspace = true -http_client.workspace = true image.workspace = true inventory.workspace = true itertools.workspace = true @@ -83,19 +82,29 @@ serde.workspace = true serde_json.workspace = true slotmap.workspace = true smallvec.workspace = true -smol.workspace = true +async-channel.workspace = true stacksafe.workspace = true strum.workspace = true sum_tree.workspace = true taffy = "=0.9.0" thiserror.workspace = true -util.workspace = true -uuid.workspace = true +gpui_util.workspace = true waker-fn = "1.2.0" lyon = "1.0" pin-project = "1.1.10" circular-buffer.workspace = true spin = "0.10.0" +pollster.workspace = true +url.workspace = true +uuid.workspace = true +web-time.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +getrandom = { version = "0.3.4", features = ["wasm_js"] } +uuid = { workspace = true, features = ["js"] } + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +http_client.workspace = true [target.'cfg(target_os = "macos")'.dependencies] block = "0.1" @@ -135,19 +144,22 @@ backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform.workspace = true -http_client = { workspace = true, features = ["test-support"] } lyon = { version = "1.0", features = ["extra"] } pretty_assertions.workspace = true rand.workspace = true -reqwest_client = { workspace = true, features = ["test-support"] } scheduler = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true -util = { workspace = true, features = ["test-support"] } +gpui_util = { workspace = true } +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies] +http_client = { workspace = true, features = ["test-support"] } +reqwest_client = { workspace = true, features = ["test-support"] } +[target.'cfg(target_family = "wasm")'.dev-dependencies] +wasm-bindgen = { workspace = true } -[target.'cfg(target_os = "windows")'.build-dependencies] -embed-resource = "3.0" +[build-dependencies] +embed-resource = { version = "3.0", optional = true } [target.'cfg(target_os = "macos")'.build-dependencies] bindgen = "0.71" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 53f78e3c416d27d73d8593ef1315c4f943712715..b1bfd2194f5059a1894ed0222c97a5869ecf9fdc 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -1,14 +1,17 @@ #![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] -#![cfg_attr(not(target_os = "macos"), allow(unused))] fn main() { println!("cargo::rustc-check-cfg=cfg(gles)"); - #[cfg(all(target_os = "windows", feature = "windows-manifest"))] - embed_resource(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + if target_os == "windows" { + #[cfg(feature = "windows-manifest")] + embed_resource(); + } } -#[cfg(all(target_os = "windows", feature = "windows-manifest"))] +#[cfg(feature = "windows-manifest")] fn embed_resource() { let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml"); let rc_file = std::path::Path::new("resources/windows/gpui.rc"); diff --git a/crates/gpui/examples/animation.rs b/crates/gpui/examples/animation.rs index 27a9a0fa35152dfdcd02df207af4fd1f78ec2b7c..6755b49ca0d183be0e47faeccb81f8266757ff43 100644 --- a/crates/gpui/examples/animation.rs +++ b/crates/gpui/examples/animation.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::time::Duration; use anyhow::Result; @@ -101,7 +103,7 @@ impl Render for AnimationExample { } } -fn main() { +fn run_example() { application().with_assets(Assets {}).run(|cx: &mut App| { let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( @@ -118,3 +120,15 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/data_table.rs b/crates/gpui/examples/data_table.rs index d32ceea0136e943f30f5150da915ed2957f90628..b3f8737ec4f03ee17eb2b143e0dbbdf230fa6356 100644 --- a/crates/gpui/examples/data_table.rs +++ b/crates/gpui/examples/data_table.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::{ops::Range, rc::Rc, time::Duration}; use gpui::{ @@ -447,7 +449,7 @@ impl Render for DataTable { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.open_window( WindowOptions { @@ -472,3 +474,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/drag_drop.rs b/crates/gpui/examples/drag_drop.rs index 734a614bd6c978b45c5dbc397e068e6e87875312..b233bc4107a51b957c0cb6d18f2b94c141b044b3 100644 --- a/crates/gpui/examples/drag_drop.rs +++ b/crates/gpui/examples/drag_drop.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, Half, Hsla, Pixels, Point, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -121,7 +123,7 @@ impl Render for DragDrop { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx); cx.open_window( @@ -136,3 +138,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs index c32ffc62a2fa3696a72f7319cbd5ee843d9308bc..02a171da216e9df7fbe9ff0bf4ad7635df0229cb 100644 --- a/crates/gpui/examples/focus_visible.rs +++ b/crates/gpui/examples/focus_visible.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, @@ -192,7 +194,7 @@ impl Render for Example { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.bind_keys([ KeyBinding::new("tab", Tab, None), @@ -213,3 +215,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/gif_viewer.rs b/crates/gpui/examples/gif_viewer.rs index 6dea19d7820876ca6334045fa5a98c63c00cf800..59fb8d3794d9289c48841a2bfa8e7ff45436beb2 100644 --- a/crates/gpui/examples/gif_viewer.rs +++ b/crates/gpui/examples/gif_viewer.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{App, Context, Render, Window, WindowOptions, div, img, prelude::*}; use gpui_platform::application; use std::path::PathBuf; @@ -23,8 +25,7 @@ impl Render for GifViewer { } } -fn main() { - env_logger::init(); +fn run_example() { application().run(|cx: &mut App| { let gif_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/image/black-cat-typing.gif"); @@ -40,3 +41,16 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + env_logger::init(); + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/gradient.rs b/crates/gpui/examples/gradient.rs index f931e6a3067b6b922a9ee29ea561d6f1eda7eb78..97321f1071947209aa6464b0c3b2f20e87c5d4ac 100644 --- a/crates/gpui/examples/gradient.rs +++ b/crates/gpui/examples/gradient.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, ColorSpace, Context, Half, Render, Window, WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, size, @@ -243,7 +245,7 @@ impl Render for GradientViewer { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.open_window( WindowOptions { @@ -256,3 +258,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/grid_layout.rs b/crates/gpui/examples/grid_layout.rs index 49119a89616758201edb3ca35eff3db364afd908..650a3e37bbc2f0740b9a13eac048ec9cae55232a 100644 --- a/crates/gpui/examples/grid_layout.rs +++ b/crates/gpui/examples/grid_layout.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, }; @@ -64,7 +66,7 @@ impl Render for HolyGrailExample { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( @@ -78,3 +80,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/hello_world.rs b/crates/gpui/examples/hello_world.rs index 634eca511269a8ad29a03cfdd104af4f081bee1c..50d56ec8df169e62f08a27567c49c886361d5ec8 100644 --- a/crates/gpui/examples/hello_world.rs +++ b/crates/gpui/examples/hello_world.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -87,7 +89,7 @@ impl Render for HelloWorld { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( @@ -105,3 +107,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index 10e40e65320c677a36f3a3027528db044b09e63e..cf879ba281e18521883222fba54451bb143fae29 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -146,9 +148,7 @@ impl Render for ImageShowcase { actions!(image, [Quit]); -fn main() { - env_logger::init(); - +fn run_example() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); application() @@ -193,3 +193,16 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + env_logger::init(); + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index 881ef5307ffebeba60daab30fe098b2f5a6cabb6..eba3fc0b6444c1b02ed8d6d2437505f1d341e605 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use futures::FutureExt; use gpui::{ App, AppContext, Asset as _, AssetLogger, Bounds, ClickEvent, Context, ElementId, Entity, @@ -245,9 +247,7 @@ impl ImageCache for SimpleLruCache { actions!(image, [Quit]); -fn main() { - env_logger::init(); - +fn run_example() { application().run(move |cx: &mut App| { let http_client = ReqwestClient::user_agent("gpui example").unwrap(); cx.set_http_client(Arc::new(http_client)); @@ -287,3 +287,16 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + env_logger::init(); + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/image_loading.rs b/crates/gpui/examples/image_loading.rs index 2de18fd7576ee91b3d54479ada909e04aa49475e..c2aab95f12a8736b28bf2fb2e6bab0d538ea27fd 100644 --- a/crates/gpui/examples/image_loading.rs +++ b/crates/gpui/examples/image_loading.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::{path::Path, sync::Arc, time::Duration}; use gpui::{ @@ -192,8 +194,7 @@ impl Render for ImageLoadingExample { } } -fn main() { - env_logger::init(); +fn run_example() { application().with_assets(Assets {}).run(|cx: &mut App| { let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( @@ -210,3 +211,16 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + env_logger::init(); + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index 1f8a9806ebee1f69973e4a54a5746aabda6f3f0c..d15d791cd008883506389cc7bb16dbad765969c0 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::ops::Range; use gpui::{ @@ -682,7 +684,7 @@ impl Render for InputExample { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.bind_keys([ @@ -752,3 +754,15 @@ fn main() { cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/layer_shell.rs b/crates/gpui/examples/layer_shell.rs index 49958711318ef70dc4e0e89dbc096f5f8761dc41..1437b05b5e91ab2db06b2f2ea4f36c8b35dd4739 100644 --- a/crates/gpui/examples/layer_shell.rs +++ b/crates/gpui/examples/layer_shell.rs @@ -1,4 +1,6 @@ -fn main() { +#![cfg_attr(target_family = "wasm", no_main)] + +fn run_example() { #[cfg(all(target_os = "linux", feature = "wayland"))] example::main(); @@ -6,6 +8,18 @@ fn main() { panic!("This example requires the `wayland` feature and a linux system."); } +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} + #[cfg(all(target_os = "linux", feature = "wayland"))] mod example { use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/crates/gpui/examples/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs index 1d0fe01b820caaed115d8b1d8baa46fa48266f64..24c3906f61e8fc8e78bd2b16b59fa4fc0db98063 100644 --- a/crates/gpui/examples/mouse_pressure.rs +++ b/crates/gpui/examples/mouse_pressure.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -44,7 +46,7 @@ impl MousePressureExample { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); @@ -65,3 +67,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/on_window_close_quit.rs b/crates/gpui/examples/on_window_close_quit.rs index 6aa0887db5efea6cf093dc2fa1c4e8f6bd4fb908..e71a142d991c87ccbccb9c078fdb50d1fa3dba49 100644 --- a/crates/gpui/examples/on_window_close_quit.rs +++ b/crates/gpui/examples/on_window_close_quit.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -35,7 +37,7 @@ impl Render for ExampleWindow { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let mut bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); @@ -81,3 +83,15 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 31094f49343074e6494250ba08b3062daea6b7f7..ba61d0f5daca2eacb382324544dff95570824368 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::{fs, path::PathBuf}; use anyhow::Result; @@ -156,7 +158,7 @@ impl Render for HelloWorld { } } -fn main() { +fn run_example() { application() .with_assets(Assets { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), @@ -174,3 +176,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/ownership_post.rs b/crates/gpui/examples/ownership_post.rs index ef9143f0c0685424738be54f56c7bd64af8a8f56..a4421b970bc8703ac97de7422e1b417b7f12ef3a 100644 --- a/crates/gpui/examples/ownership_post.rs +++ b/crates/gpui/examples/ownership_post.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{App, Context, Entity, EventEmitter, prelude::*}; use gpui_platform::application; @@ -11,7 +13,7 @@ struct Change { impl EventEmitter for Counter {} -fn main() { +fn run_example() { application().run(|cx: &mut App| { let counter: Entity = cx.new(|_cx| Counter { count: 0 }); let subscriber = cx.new(|cx: &mut Context| { @@ -34,3 +36,15 @@ fn main() { assert_eq!(subscriber.read(cx).count, 4); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index fa73a38136d287f357eb8b44bea732ad84185a25..18ef6b9fa3741297ddfebc1b5df3ea4a3594fc05 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder, PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div, linear_color_stop, @@ -445,7 +447,7 @@ impl Render for PaintingViewer { } } -fn main() { +fn run_example() { application().run(|cx| { cx.open_window( WindowOptions { @@ -462,3 +464,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/paths_bench.rs b/crates/gpui/examples/paths_bench.rs index 17f80b0ff470901af4da1213e9ed12ad1585673d..4e12f1e50ab53d69117a72a231aef1eb5c39fa2e 100644 --- a/crates/gpui/examples/paths_bench.rs +++ b/crates/gpui/examples/paths_bench.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render, TitlebarOptions, Window, WindowBounds, WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, @@ -69,7 +71,7 @@ impl Render for PaintingViewer { } } -fn main() { +fn run_example() { application().run(|cx| { cx.open_window( WindowOptions { @@ -91,3 +93,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/pattern.rs b/crates/gpui/examples/pattern.rs index bc9237268d70157a415a8819984db7d96e477e5b..3113d39d808ea01675b5cf5e8be976572aeaad8d 100644 --- a/crates/gpui/examples/pattern.rs +++ b/crates/gpui/examples/pattern.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, AppContext, Bounds, Context, Window, WindowBounds, WindowOptions, div, linear_color_stop, linear_gradient, pattern_slash, prelude::*, px, rgb, size, @@ -99,7 +101,7 @@ impl Render for PatternExample { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( @@ -114,3 +116,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/popover.rs b/crates/gpui/examples/popover.rs index 429eb17c0629938dcbd9fb21698ab887503fd51a..bd112b0e69a62c1303e9d90945e24cfb3f659b82 100644 --- a/crates/gpui/examples/popover.rs +++ b/crates/gpui/examples/popover.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Context, Corner, Div, Hsla, Stateful, Window, WindowOptions, anchored, deferred, div, prelude::*, px, @@ -161,7 +163,7 @@ impl Render for HelloWorld { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.open_window(WindowOptions::default(), |_, cx| { cx.new(|_| HelloWorld { @@ -173,3 +175,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/scrollable.rs b/crates/gpui/examples/scrollable.rs index 6e4865ee496366da69494334152b703a509780d3..39864c834aedae414191afb61bba27d98696d7dd 100644 --- a/crates/gpui/examples/scrollable.rs +++ b/crates/gpui/examples/scrollable.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, size}; use gpui_platform::application; @@ -42,7 +44,7 @@ impl Render for Scrollable { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( @@ -56,3 +58,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/set_menus.rs b/crates/gpui/examples/set_menus.rs index 30f8ef0f32a0d8c56bafeb71faa1c2435ef9fff3..683793c35fd4d356c068a3c36b041fba1dbc5ecf 100644 --- a/crates/gpui/examples/set_menus.rs +++ b/crates/gpui/examples/set_menus.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Context, Global, Menu, MenuItem, SharedString, SystemMenuType, Window, WindowOptions, actions, div, prelude::*, rgb, @@ -20,7 +22,7 @@ impl Render for SetMenus { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.set_global(AppState::new()); @@ -36,6 +38,18 @@ fn main() { }); } +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} + #[derive(PartialEq)] enum ViewMode { List, diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index 519053ae9293d51df86ba14b66e1182d718035a0..d39a2eb62ed74bfdbbef733bf5154184909911cf 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, WindowOptions, div, hsla, point, prelude::*, px, relative, rgb, size, @@ -569,7 +571,7 @@ impl Render for Shadow { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(1000.0), px(800.0)), cx); cx.open_window( @@ -584,3 +586,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/svg/svg.rs b/crates/gpui/examples/svg/svg.rs index 54e99320bd59a9d8fffeea65c7825105781d9226..e9d234167777a94784207601b01a9f1befb76ead 100644 --- a/crates/gpui/examples/svg/svg.rs +++ b/crates/gpui/examples/svg/svg.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::fs; use std::path::PathBuf; @@ -68,7 +70,7 @@ impl Render for SvgExample { } } -fn main() { +fn run_example() { application() .with_assets(Assets { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), @@ -86,3 +88,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs index 6fa0ee4929db62b2122748729686d955f96932bb..3fec59ad9e6a53b067b1aa4bf9894f986c2e9b27 100644 --- a/crates/gpui/examples/tab_stop.rs +++ b/crates/gpui/examples/tab_stop.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, @@ -178,7 +180,7 @@ impl Render for Example { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.bind_keys([ KeyBinding::new("tab", Tab, None), @@ -198,3 +200,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/testing.rs b/crates/gpui/examples/testing.rs index a3d09d1395a165635cb785a910009c8e96401f2f..f6e15791d63aa1a3dad0b74f51a2c7a9cce4b3f3 100644 --- a/crates/gpui/examples/testing.rs +++ b/crates/gpui/examples/testing.rs @@ -1,3 +1,4 @@ +#![cfg_attr(target_family = "wasm", no_main)] //! Example demonstrating GPUI's testing infrastructure. //! //! When run normally, this displays an interactive counter window. @@ -176,7 +177,7 @@ impl Render for Counter { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.bind_keys([ gpui::KeyBinding::new("up", Increment, Some("Counter")), @@ -199,6 +200,18 @@ fn main() { }); } +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index d4effbbce91cc6e8261a5ec44d196a1868a772a6..acaf4fe83a49726e0a3c641ca577bf75c54e224d 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use std::{ ops::{Deref, DerefMut}, sync::Arc, @@ -298,7 +300,7 @@ impl Render for TextExample { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.set_menus(vec![Menu { name: "GPUI Typography".into(), @@ -332,3 +334,15 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/text_layout.rs b/crates/gpui/examples/text_layout.rs index 5c9ef368b8406e0882ab159f82c3eecba52a19da..4bb930e052875b044f10f72fffc5c3656bb9645f 100644 --- a/crates/gpui/examples/text_layout.rs +++ b/crates/gpui/examples/text_layout.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, FontStyle, FontWeight, StyledText, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, @@ -81,7 +83,7 @@ impl Render for HelloWorld { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( @@ -95,3 +97,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs index cfc981e9e2b0bff6a63ecc78125292d6ff43ce48..3750c3e32b524991cf5544bdd39421961ce660bb 100644 --- a/crates/gpui/examples/text_wrapper.rs +++ b/crates/gpui/examples/text_wrapper.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, TextOverflow, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, @@ -108,7 +110,7 @@ impl Render for HelloWorld { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( @@ -122,3 +124,15 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/tree.rs b/crates/gpui/examples/tree.rs index 43607b6648f3a7894f90a3c42ab0af8d8663790c..9c4ea2cd127e169c85dac1a0957f1237c08e6817 100644 --- a/crates/gpui/examples/tree.rs +++ b/crates/gpui/examples/tree.rs @@ -1,3 +1,4 @@ +#![cfg_attr(target_family = "wasm", no_main)] //! Renders a div with deep children hierarchy. This example is useful to exemplify that Zed can //! handle deep hierarchies (even though it cannot just yet!). use std::sync::LazyLock; @@ -29,7 +30,7 @@ impl Render for Tree { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( @@ -42,3 +43,15 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/uniform_list.rs b/crates/gpui/examples/uniform_list.rs index c287cdfb45568d32d939881df5b7e289c4a41727..fabcde5c4bca50a2aae09f58b66c6e3297aab1b0 100644 --- a/crates/gpui/examples/uniform_list.rs +++ b/crates/gpui/examples/uniform_list.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, uniform_list, @@ -36,7 +38,7 @@ impl Render for UniformListExample { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( @@ -49,3 +51,15 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 80d4f46ac09d8adb483f909f00bd88fd97f1f990..c51f43fe66deff0daf3de9a8442b46b7a5d8a6e3 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -306,7 +308,7 @@ impl Render for WindowDemo { actions!(window, [Quit]); -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); @@ -333,3 +335,15 @@ fn main() { cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 45ac3fcd78fc811dcb450609c293d81900d3c67b..036a2fcdba750c0e35ea7b05fa3822f7a1c1b0db 100644 --- a/crates/gpui/examples/window_positioning.rs +++ b/crates/gpui/examples/window_positioning.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, DisplayId, Hsla, Pixels, SharedString, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowKind, WindowOptions, div, point, prelude::*, @@ -68,7 +70,7 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { // Create several new windows, positioned in the top right corner of each screen let size = Size { @@ -218,3 +220,15 @@ fn main() { } }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index c8e37b67e98c38d45608836e5752dee8a575091a..b8c052693da9f3408dd95b39dac4733abe3965c6 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + use gpui::{ App, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton, Pixels, Point, ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowDecorations, @@ -203,7 +205,7 @@ fn resize_edge(pos: Point, shadow_size: Pixels, size: Size) -> O Some(edge) } -fn main() { +fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( @@ -226,3 +228,15 @@ fn main() { .unwrap(); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 2a83045aa0f9b776eb247f40ba39312f2cd15d4a..dd81d9166d471632b62725f6ad1ce4faeca18c59 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1,3 +1,4 @@ +use scheduler::Instant; use std::{ any::{TypeId, type_name}, cell::{BorrowMutError, Cell, Ref, RefCell, RefMut}, @@ -7,7 +8,7 @@ use std::{ path::{Path, PathBuf}, rc::{Rc, Weak}, sync::{Arc, atomic::Ordering::SeqCst}, - time::{Duration, Instant}, + time::Duration, }; use anyhow::{Context as _, Result, anyhow}; @@ -25,11 +26,12 @@ pub use async_context::*; use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use context::*; pub use entity_map::*; +use gpui_util::{ResultExt, debug_panic}; +#[cfg(not(target_family = "wasm"))] use http_client::{HttpClient, Url}; use smallvec::SmallVec; #[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::*; @@ -137,6 +139,7 @@ impl Application { Self(App::new_app( platform, Arc::new(()), + #[cfg(not(target_family = "wasm"))] Arc::new(NullHttpClient), )) } @@ -152,6 +155,7 @@ impl Application { } /// Sets the HTTP client for the application. + #[cfg(not(target_family = "wasm"))] pub fn with_http_client(self, http_client: Arc) -> Self { let mut context_lock = self.0.borrow_mut(); context_lock.http_client = http_client; @@ -581,6 +585,7 @@ pub struct App { pub(crate) loading_assets: FxHashMap<(TypeId, u64), Box>, asset_source: Arc, pub(crate) svg_renderer: SvgRenderer, + #[cfg(not(target_family = "wasm"))] http_client: Arc, pub(crate) globals_by_type: FxHashMap>, pub(crate) entities: EntityMap, @@ -637,7 +642,7 @@ impl App { pub(crate) fn new_app( platform: Rc, asset_source: Arc, - http_client: Arc, + #[cfg(not(target_family = "wasm"))] http_client: Arc, ) -> Rc { let background_executor = platform.background_executor(); let foreground_executor = platform.foreground_executor(); @@ -667,6 +672,7 @@ impl App { svg_renderer: SvgRenderer::new(asset_source.clone()), loading_assets: Default::default(), asset_source, + #[cfg(not(target_family = "wasm"))] http_client, globals_by_type: FxHashMap::default(), entities, @@ -1275,11 +1281,13 @@ impl App { } /// Returns the HTTP client for the application. + #[cfg(not(target_family = "wasm"))] pub fn http_client(&self) -> Arc { self.http_client.clone() } /// Sets the HTTP client for the application. + #[cfg(not(target_family = "wasm"))] pub fn set_http_client(&mut self, new_client: Arc) { self.http_client = new_client; } @@ -2504,8 +2512,10 @@ pub struct KeystrokeEvent { pub context_stack: Vec, } +#[cfg(not(target_family = "wasm"))] struct NullHttpClient; +#[cfg(not(target_family = "wasm"))] impl HttpClient for NullHttpClient { fn send( &self, diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 6b9f572fc880b35f719b6a064f0904cfa12153d9..ccd39dda89003cf90d51fae43102a565b2136dc2 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -7,7 +7,7 @@ use crate::{ use anyhow::Context as _; use derive_more::{Deref, DerefMut}; use futures::channel::oneshot; -use smol::future::FutureExt; +use futures::future::FutureExt; use std::{future::Future, rc::Weak}; use super::{Context, WeakEntity}; @@ -241,10 +241,10 @@ impl AsyncApp { &self, entity: &WeakEntity, f: Callback, - ) -> util::Deferred> { + ) -> gpui_util::Deferred> { let entity = entity.clone(); let mut cx = self.clone(); - util::defer(move || { + gpui_util::defer(move || { entity.update(&mut cx, f).ok(); }) } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 28d30ab37e7d7b502afc3f471416f2589380ce85..c30a76bd9c8861d4d5b4d9dc4b5893ffeb2eb4b8 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -5,6 +5,7 @@ use crate::{ }; use anyhow::Result; use futures::FutureExt; +use gpui_util::Deferred; use std::{ any::{Any, TypeId}, borrow::{Borrow, BorrowMut}, @@ -12,7 +13,6 @@ use std::{ ops, sync::Arc, }; -use util::Deferred; use super::{App, AsyncWindowContext, Entity, KeystrokeEvent}; @@ -278,7 +278,7 @@ impl<'a, T: 'static> Context<'a, T> { ) -> Deferred { let this = self.weak_entity(); let mut cx = self.to_async(); - util::defer(move || { + gpui_util::defer(move || { this.update(&mut cx, f).ok(); }) } diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 2df01a1b6e05434f0b9a6822f12b0038efc5c10a..b8d9e82680eb6978d073e3e51c420cef9f1f61ec 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -904,7 +904,7 @@ impl LeakDetector { /// at the allocation site. #[track_caller] pub fn handle_created(&mut self, entity_id: EntityId) -> HandleId { - let id = util::post_inc(&mut self.next_handle_id); + let id = gpui_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( diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 8077632778e2f42af8790a0823a21d4d62efe6e5..7ce7f22e3c3cfef7beb531ce9443a172397e2e0f 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -120,10 +120,16 @@ impl TestAppContext { let foreground_executor = ForegroundExecutor::new(arc_dispatcher); let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); let asset_source = Arc::new(()); + #[cfg(not(target_family = "wasm"))] let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); - let app = App::new_app(platform.clone(), asset_source, http_client); + let app = App::new_app( + platform.clone(), + asset_source, + #[cfg(not(target_family = "wasm"))] + http_client, + ); app.borrow_mut().mode = GpuiMode::test(); Self { @@ -521,22 +527,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. diff --git a/crates/gpui/src/app/visual_test_context.rs b/crates/gpui/src/app/visual_test_context.rs index 22389b5b27566db05be7c462e87596f17def880a..f0fbf47f1f82008c592884b21d6372a9794b8a4a 100644 --- a/crates/gpui/src/app/visual_test_context.rs +++ b/crates/gpui/src/app/visual_test_context.rs @@ -356,7 +356,7 @@ impl VisualTestAppContext { predicate: impl Fn(&T) -> bool, timeout: Duration, ) -> Result<()> { - let start = std::time::Instant::now(); + let start = web_time::Instant::now(); loop { { let app = self.app.borrow(); diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index e72fb00456d14dec74ffc56e040511c189af1d18..8a42c8bd492469a8952d9dd15e410859cd4e6217 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -1,7 +1,5 @@ -use std::{ - rc::Rc, - time::{Duration, Instant}, -}; +use scheduler::Instant; +use std::{rc::Rc, time::Duration}; use crate::{ AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window, diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 4537cfc22f6ed8c4ea3d5443327723207af88620..2b4a3c84e8111796bf7ce32a4c6ad83854ded6fd 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -26,6 +26,7 @@ use crate::{ size, }; use collections::HashMap; +use gpui_util::ResultExt; use refineable::Refineable; use smallvec::SmallVec; use stacksafe::{StackSafe, stacksafe}; @@ -40,7 +41,6 @@ use std::{ sync::Arc, time::Duration, }; -use util::ResultExt; use super::ImageCacheProvider; diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index fcba6a6a4e5b3d82262129bc9f7d9bdc72c88da9..59dd9de5fdfadf66fba622da6921b468726f439c 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -4,13 +4,15 @@ 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 futures::Future; +use gpui_util::ResultExt; use image::{ AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, }; +use scheduler::Instant; use smallvec::SmallVec; use std::{ fs, @@ -19,10 +21,9 @@ use std::{ path::{Path, PathBuf}, str::FromStr, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; use thiserror::Error; -use util::ResultExt; use super::{Stateful, StatefulInteractiveElement}; @@ -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 { @@ -593,6 +594,7 @@ impl Asset for ImageAssetLoader { source: Self::Source, cx: &mut App, ) -> impl Future + Send + 'static { + #[cfg(not(target_family = "wasm"))] let client = cx.http_client(); // TODO: Can we make SVGs always rescale? // let scale_factor = cx.scale_factor(); @@ -601,7 +603,11 @@ impl Asset for ImageAssetLoader { async move { let bytes = match source.clone() { Resource::Path(uri) => fs::read(uri.as_ref())?, + #[cfg(not(target_family = "wasm"))] Resource::Uri(uri) => { + use anyhow::Context as _; + use futures::AsyncReadExt as _; + let mut response = client .get(uri.as_ref(), ().into(), true) .await @@ -620,6 +626,12 @@ impl Asset for ImageAssetLoader { } body } + #[cfg(target_family = "wasm")] + Resource::Uri(_) => { + return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!( + "Uri resources are not supported on wasm" + )))); + } Resource::Embedded(path) => { let data = asset_source.load(&path).ok().flatten(); if let Some(data) = data { @@ -710,6 +722,7 @@ pub enum ImageCacheError { #[error("IO error: {0}")] Io(Arc), /// An error that occurred while processing an image. + #[cfg(not(target_family = "wasm"))] #[error("unexpected http status for {uri}: {status}, body: {body}")] BadStatus { /// The URI of the image. diff --git a/crates/gpui/src/elements/svg.rs b/crates/gpui/src/elements/svg.rs index 57b2d712e54c501cb7eaf59f6433748ddf36d3fc..dff389fb93fe7abd2862be70731cc9e6fb613e94 100644 --- a/crates/gpui/src/elements/svg.rs +++ b/crates/gpui/src/elements/svg.rs @@ -6,7 +6,7 @@ use crate::{ StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px, radians, size, }; -use util::ResultExt; +use gpui_util::ResultExt; /// An SVG element. pub struct Svg { diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index ab861be5a29fcbfb575a48cf743407f1c6e927d6..ded0f596dcea2f6c992961906503adb6829e885f 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -6,6 +6,7 @@ use crate::{ WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window, }; use anyhow::Context as _; +use gpui_util::ResultExt; use itertools::Itertools; use smallvec::SmallVec; use std::{ @@ -16,7 +17,6 @@ use std::{ rc::Rc, sync::Arc, }; -use util::ResultExt; impl Element for &'static str { type RequestLayoutState = TextLayout; diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index d4299b73e8401faa0fc4a5aae8b7773cd920e709..d48be9dc30811cd5728fc07081c1d11d3405ec95 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,18 +1,13 @@ use crate::{App, PlatformDispatcher, PlatformScheduler}; use futures::channel::mpsc; +use futures::prelude::*; +use gpui_util::TryFutureExt; +use scheduler::Instant; use scheduler::Scheduler; -use smol::prelude::*; use std::{ - fmt::Debug, - future::Future, - marker::PhantomData, - mem, - pin::Pin, - rc::Rc, - sync::Arc, - time::{Duration, Instant}, + fmt::Debug, future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, + time::Duration, }; -use util::TryFutureExt; pub use scheduler::{FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority}; @@ -569,9 +564,15 @@ mod test { let platform = TestPlatform::new(background_executor.clone(), foreground_executor); let asset_source = Arc::new(()); + #[cfg(not(target_family = "wasm"))] let http_client = http_client::FakeHttpClient::with_404_response(); - let app = App::new_app(platform, asset_source, http_client); + let app = App::new_app( + platform, + asset_source, + #[cfg(not(target_family = "wasm"))] + http_client, + ); (dispatcher, background_executor, app) } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 7e2f0a981493cc12bca9f02e47a90ed6d6f21595..6e592655162471e5501030152a11bf67f3744578 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -35,7 +35,7 @@ mod platform; pub mod prelude; /// Profiling utilities for task timing and thread performance tracking. pub mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] #[expect(missing_docs)] pub mod queue; mod scene; @@ -87,6 +87,8 @@ pub use executor::*; pub use geometry::*; pub use global::*; pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test}; +pub use gpui_util::arc_cow::ArcCow; +#[cfg(not(target_family = "wasm"))] pub use http_client; pub use input::*; pub use inspector::*; @@ -96,7 +98,7 @@ pub use keymap::*; pub use path_builder::*; pub use platform::*; pub use profiler::*; -#[cfg(any(target_os = "windows", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] pub use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; @@ -113,7 +115,7 @@ pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; pub use text_system::*; -pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; +pub use util::{FutureExt, Timeout}; pub use view::*; pub use window::*; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 401c70ea488a8896151de047c04898f4f6b7e15a..885e031cba3020b16fc6391a52bbcf49e9022707 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -44,6 +44,7 @@ use image::RgbaImage; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use scheduler::Instant; pub use scheduler::RunnableMeta; use schemars::JsonSchema; use seahash::SeaHasher; @@ -53,7 +54,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, @@ -560,7 +561,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { pub type RunnableVariant = Runnable; #[doc(hidden)] -pub type TimerResolutionGuard = util::Deferred>; +pub type TimerResolutionGuard = gpui_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. @@ -579,7 +580,7 @@ pub trait PlatformDispatcher: Send + Sync { } fn increase_timer_resolution(&self) -> TimerResolutionGuard { - util::defer(Box::new(|| {})) + gpui_util::defer(Box::new(|| {})) } #[cfg(any(test, feature = "test-support"))] @@ -827,7 +828,7 @@ impl From for AtlasKey { } #[expect(missing_docs)] -pub trait PlatformAtlas: Send + Sync { +pub trait PlatformAtlas { fn get_or_insert_with<'a>( &self, key: &AtlasKey, @@ -1235,7 +1236,7 @@ pub struct WindowOptions { ), allow(dead_code) )] -#[expect(missing_docs)] +#[allow(missing_docs)] pub struct WindowParams { pub bounds: Bounds, diff --git a/crates/gpui/src/platform/scap_screen_capture.rs b/crates/gpui/src/platform/scap_screen_capture.rs index 2c827bb0d80b330440f050b01c024faa700329ad..797e19ba23f715c372d76fd349577dde16a66f6f 100644 --- a/crates/gpui/src/platform/scap_screen_capture.rs +++ b/crates/gpui/src/platform/scap_screen_capture.rs @@ -126,7 +126,7 @@ fn start_default_target_screen_capture( ) { // Due to use of blocking APIs, a dedicated thread is used. std::thread::spawn(|| { - let start_result = util::maybe!({ + let start_result = gpui_util::maybe!({ let mut capturer = new_scap_capturer(None)?; capturer.start_capture(); let first_frame = capturer diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index e744802ceaa507c527e8af5b0be732074ded7f10..081f5feab014b3712fa23290038f34d8ed4f5a92 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -1,11 +1,12 @@ use crate::{PlatformDispatcher, Priority, RunnableVariant}; +use scheduler::Instant; use scheduler::{Clock, Scheduler, SessionId, TestScheduler, TestSchedulerConfig, Yield}; use std::{ sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, - time::{Duration, Instant}, + time::Duration, }; /// TestDispatcher provides deterministic async execution for tests. diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 2c97b5fe087411768f3c039322be00a391e05dfe..feb3b162abe09d8cdef008aa9f794b046da22cc6 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -19,6 +19,7 @@ pub(crate) struct TestWindowState { pub(crate) title: Option, pub(crate) edited: bool, platform: Weak, + // TODO: Replace with `Rc` sprite_atlas: Arc, pub(crate) should_close_handler: Option bool>>, hit_test_window_control_callback: Option Option>>, diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 2043d2a33387ab9aa0acda48321911a1503a6da8..900cd6041d38380f4d9cb3ff9b87a3605b0ebd78 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -2,7 +2,10 @@ use crate::{PlatformDispatcher, RunnableMeta}; use async_task::Runnable; use chrono::{DateTime, Utc}; use futures::channel::oneshot; +use scheduler::Instant; use scheduler::{Clock, Priority, Scheduler, SessionId, TestScheduler, Timer}; +#[cfg(not(target_family = "wasm"))] +use std::task::{Context, Poll}; use std::{ future::Future, pin::Pin, @@ -10,10 +13,8 @@ use std::{ Arc, atomic::{AtomicU16, Ordering}, }, - task::{Context, Poll}, - time::{Duration, Instant}, + time::Duration, }; -use waker_fn::waker_fn; /// A production implementation of [`Scheduler`] that wraps a [`PlatformDispatcher`]. /// @@ -43,37 +44,48 @@ impl Scheduler for PlatformScheduler { fn block( &self, _session_id: Option, - mut future: Pin<&mut dyn Future>, - timeout: 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 { - 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; + #[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; + 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; } - Some(_) => (), - None => parker.park(), - } - if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { - break true; } } } diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 0863aa8cdaaa6bb7cbf593adea1fd4d12726acce..ccbc86e3fe35a095b2de9de159286250a24d7a05 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -1,3 +1,4 @@ +use scheduler::Instant; use std::{ cell::LazyCell, collections::HashMap, @@ -5,7 +6,6 @@ use std::{ hash::{DefaultHasher, Hash}, sync::Arc, thread::ThreadId, - time::Instant, }; use serde::{Deserialize, Serialize}; diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 45712ba27e1c022a0be18056a9df7960ecac380f..6e7cf2445e3d6d1723721325e0e145c0c55d3236 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -41,6 +41,32 @@ impl PriorityQueueState { } let mut queues = self.queues.lock(); + 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" @@ -49,8 +75,6 @@ impl PriorityQueueState { Priority::Medium => queues.medium_priority.push_back(item), Priority::Low => queues.low_priority.push_back(item), }; - self.condvar.notify_one(); - Ok(()) } fn recv<'a>(&'a self) -> Result>, RecvError> { @@ -84,6 +108,28 @@ 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)) + } + } } #[doc(hidden)] @@ -100,6 +146,11 @@ impl PriorityQueueSender { 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 { @@ -183,6 +234,44 @@ impl PriorityQueueReceiver { 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 diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 7b841da1f231f85073dd20d51769ed406d539ce8..7e0ffe017024cc7914885df9ea713a3ec3db820e 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -459,7 +459,7 @@ impl<'a> Iterator for BatchIterator<'a> { ), allow(dead_code) )] -#[expect(missing_docs)] +#[allow(missing_docs)] pub enum PrimitiveBatch { Shadows(Range), Quads(Range), @@ -711,7 +711,7 @@ impl From for Primitive { } #[derive(Clone, Debug)] -#[expect(missing_docs)] +#[allow(missing_docs)] pub struct PaintSurface { pub order: DrawOrder, pub bounds: Bounds, diff --git a/crates/gpui/src/shared_string.rs b/crates/gpui/src/shared_string.rs index 350184d350aec8c5995fe7d2f0856f1fe1cfea0f..4fd2f8c32112feb0199408825355f60d6554e19c 100644 --- a/crates/gpui/src/shared_string.rs +++ b/crates/gpui/src/shared_string.rs @@ -1,12 +1,12 @@ use derive_more::{Deref, DerefMut}; +use gpui_util::arc_cow::ArcCow; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ borrow::{Borrow, Cow}, sync::Arc, }; -use util::arc_cow::ArcCow; /// A shared string is an immutable string that can be cheaply cloned in GPUI /// tasks. Essentially an abstraction over an `Arc` and `&'static str`, diff --git a/crates/gpui/src/subscription.rs b/crates/gpui/src/subscription.rs index bd869f8d32cdfc81917fc2287b7dc62fac7d727d..cf44b68d2bcbf7ca7d02c4b9e956f15079f8bdb6 100644 --- a/crates/gpui/src/subscription.rs +++ b/crates/gpui/src/subscription.rs @@ -1,11 +1,11 @@ use collections::{BTreeMap, BTreeSet}; +use gpui_util::post_inc; use std::{ cell::{Cell, RefCell}, fmt::Debug, mem, rc::Rc, }; -use util::post_inc; pub(crate) struct SubscriberSet( Rc>>, diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 9f8fd8b1984ff72a2d773454bf78956fa91f7ef0..9e76d97e97e941121417d872e8c6f596cf658e20 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -27,7 +27,6 @@ //! ``` use crate::{Entity, Subscription, TestAppContext, TestDispatcher}; use futures::StreamExt as _; -use smol::channel; use std::{ env, panic::{self, RefUnwindSafe}, @@ -136,7 +135,7 @@ fn calculate_seeds( /// A test struct for converting an observation callback into a stream. pub struct Observation { - rx: Pin>>, + rx: Pin>>, _subscription: Subscription, } @@ -153,10 +152,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/crates/gpui/src/util.rs b/crates/gpui/src/util.rs index 1a1e3e7b5089c93d552898f2f491aaece854e8a7..8a7411e0ac29e86beff8f6a803c5f6a31518048e 100644 --- a/crates/gpui/src/util.rs +++ b/crates/gpui/src/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. diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 7301bdaa6ec2dfe78a7454482fa0aeca48f2fd90..df5948cb99e75a1f15d5b9a63cb1c3a5a29fac03 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -26,11 +26,14 @@ use core_video::pixel_buffer::CVPixelBuffer; use derive_more::{Deref, DerefMut}; use futures::FutureExt; use futures::channel::oneshot; +use gpui_util::post_inc; +use gpui_util::{ResultExt, measure}; use itertools::FoldWhile::{Continue, Done}; use itertools::Itertools; use parking_lot::RwLock; use raw_window_handle::{HandleError, HasDisplayHandle, HasWindowHandle}; use refineable::Refineable; +use scheduler::Instant; use slotmap::SlotMap; use smallvec::SmallVec; use std::{ @@ -48,10 +51,8 @@ use std::{ Arc, Weak, atomic::{AtomicUsize, Ordering::SeqCst}, }, - time::{Duration, Instant}, + time::Duration, }; -use util::post_inc; -use util::{ResultExt, measure}; use uuid::Uuid; mod prompts; diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index d1a3ef0bd6954e3527a4544ad8abe35fde0bf3d9..08c759125a7600f94867cff95035d0318f26305a 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -18,8 +18,7 @@ wayland = [ "bitflags", "gpui_wgpu", "ashpd/wayland", - "cosmic-text", - "font-kit", + "calloop-wayland-source", "wayland-backend", "wayland-client", @@ -35,8 +34,7 @@ wayland = [ x11 = [ "gpui_wgpu", "ashpd", - "cosmic-text", - "font-kit", + "as-raw-xcb-connection", "x11rb", "xkbcommon", @@ -58,13 +56,14 @@ bytemuck = "1" collections.workspace = true futures.workspace = true gpui.workspace = true -gpui_wgpu = { workspace = true, optional = true } +gpui_wgpu = { workspace = true, optional = true, features = ["font-kit"] } http_client.workspace = true itertools.workspace = true libc.workspace = true log.workspace = true parking_lot.workspace = true pathfinder_geometry = "0.5" +pollster.workspace = true profiling.workspace = true smallvec.workspace = true smol.workspace = true @@ -83,12 +82,7 @@ raw-window-handle = "0.6" # Used in both windowing options ashpd = { workspace = true, optional = true } -cosmic-text = { version = "0.17.0", optional = true } swash = { version = "0.2.6" } -# WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io -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 } bitflags = { workspace = true, optional = true } filedescriptor = { version = "0.8.2", optional = true } open = { version = "5.2.0", optional = true } diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 9253e1acaffbc8c4a0c506bb4d5a39e9cb279ff0..f044b086a580ea70ef2b959ed5e8a0931f4ce4e9 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -124,7 +124,7 @@ impl LinuxCommon { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); #[cfg(any(feature = "wayland", feature = "x11"))] - let text_system = Arc::new(crate::linux::CosmicTextSystem::new()); + let text_system = Arc::new(crate::linux::CosmicTextSystem::new("IBM Plex Sans")); #[cfg(not(any(feature = "wayland", feature = "x11")))] let text_system = Arc::new(gpui::NoopTextSystem::new()); diff --git a/crates/gpui_linux/src/linux/text_system.rs b/crates/gpui_linux/src/linux/text_system.rs index af0298e5961e500fe9e01495905ba53a85f74f37..d6571021e0647453844b7564c8cdac32926bc6a6 100644 --- a/crates/gpui_linux/src/linux/text_system.rs +++ b/crates/gpui_linux/src/linux/text_system.rs @@ -1,538 +1 @@ -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(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 { - 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>, -} - -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 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(), - })) - } -} - -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) - } - - fn recommended_rendering_mode( - &self, - _font_id: FontId, - _font_size: Pixels, - ) -> TextRenderingMode { - // Ideally, we'd use fontconfig to read the user preference. - 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> { - // TODO: Determine the proper system UI font. - let name = gpui::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, 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) -> 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, cosmic_text::Weight::NORMAL) - .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, - f32::from(font_size), - None, // We do our own wrapping - cosmic_text::Wrap::None, - None, - &mut layout_lines, - None, - cosmic_text::Hinting::Disabled, - ); - 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(), - } - } -} - -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) -} - -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(), - } -} - -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 gpui_wgpu::CosmicTextSystem; diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index cc1a41bc47d23cc42a48e7a71d0666eb86ff5da0..cfb47b1851b9e792c31fad9aca79b3671095b603 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -31,3 +31,7 @@ gpui_windows.workspace = true [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] gpui_linux.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +gpui_web.workspace = true +console_error_panic_hook = "0.1.7" diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index c8f45efd6d84f38159e4a659514c1ad7a8a8f364..86c0577f75ff4ac61ab7a4d956b7e34718fb26e5 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -18,6 +18,14 @@ pub fn headless() -> gpui::Application { gpui::Application::with_platform(current_platform(true)) } +/// 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(); + gpui_web::init_logging(); +} + /// Returns the default [`Platform`] for the current OS. pub fn current_platform(headless: bool) -> Rc { #[cfg(target_os = "macos")] @@ -33,10 +41,16 @@ pub fn current_platform(headless: bool) -> Rc { ) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { gpui_linux::current_platform(headless) } + + #[cfg(target_family = "wasm")] + { + let _ = headless; + Rc::new(gpui_web::WebPlatform::new()) + } } #[cfg(all(test, target_os = "macos"))] diff --git a/crates/gpui_util/Cargo.toml b/crates/gpui_util/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..5f2267c7f34e0d23b0a83e886ed991137932fca6 --- /dev/null +++ b/crates/gpui_util/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "gpui_util" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +log.workspace = true +anyhow.workspace = true + +[lints] +workspace = true diff --git a/crates/gpui_util/LICENSE-APACHE b/crates/gpui_util/LICENSE-APACHE new file mode 120000 index 0000000000000000000000000000000000000000..1cd601d0a3affae83854be02a0afdec3b7a9ec4d --- /dev/null +++ b/crates/gpui_util/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/util/src/arc_cow.rs b/crates/gpui_util/src/arc_cow.rs similarity index 100% rename from crates/util/src/arc_cow.rs rename to crates/gpui_util/src/arc_cow.rs diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..ae56c2ccd0ef8ff5ab339e1ddd017884abb168d4 --- /dev/null +++ b/crates/gpui_util/src/lib.rs @@ -0,0 +1,292 @@ +// FluentBuilder +// pub use gpui_util::{FutureExt, Timeout, arc_cow::ArcCow}; + +use std::{ + env, + ops::AddAssign, + panic::Location, + pin::Pin, + sync::OnceLock, + task::{Context, Poll}, + time::Instant, +}; + +pub mod arc_cow; + +pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { + let prev = *value; + *value += T::from(1); + prev +} + +pub fn measure(label: &str, f: impl FnOnce() -> R) -> R { + static ZED_MEASUREMENTS: OnceLock = OnceLock::new(); + let zed_measurements = ZED_MEASUREMENTS.get_or_init(|| { + env::var("ZED_MEASUREMENTS") + .map(|measurements| measurements == "1" || measurements == "true") + .unwrap_or(false) + }); + + if *zed_measurements { + let start = Instant::now(); + let result = f(); + let elapsed = start.elapsed(); + eprintln!("{}: {:?}", label, elapsed); + result + } else { + f() + } +} + +#[macro_export] +macro_rules! debug_panic { + ( $($fmt_arg:tt)* ) => { + if cfg!(debug_assertions) { + panic!( $($fmt_arg)* ); + } else { + let backtrace = std::backtrace::Backtrace::capture(); + log::error!("{}\n{:?}", format_args!($($fmt_arg)*), backtrace); + } + }; +} + +#[track_caller] +pub fn some_or_debug_panic(option: Option) -> Option { + #[cfg(debug_assertions)] + if option.is_none() { + panic!("Unexpected None"); + } + option +} + +/// Expands to an immediately-invoked function expression. Good for using the ? operator +/// in functions which do not return an Option or Result. +/// +/// Accepts a normal block, an async block, or an async move block. +#[macro_export] +macro_rules! maybe { + ($block:block) => { + (|| $block)() + }; + (async $block:block) => { + (async || $block)() + }; + (async move $block:block) => { + (async move || $block)() + }; +} +pub trait ResultExt { + type Ok; + + fn log_err(self) -> Option; + /// Assert that this result should never be an error in development or tests. + fn debug_assert_ok(self, reason: &str) -> Self; + fn warn_on_err(self) -> Option; + fn log_with_level(self, level: log::Level) -> Option; + fn anyhow(self) -> anyhow::Result + where + E: Into; +} + +impl ResultExt for Result +where + E: std::fmt::Debug, +{ + type Ok = T; + + #[track_caller] + fn log_err(self) -> Option { + self.log_with_level(log::Level::Error) + } + + #[track_caller] + fn debug_assert_ok(self, reason: &str) -> Self { + if let Err(error) = &self { + debug_panic!("{reason} - {error:?}"); + } + self + } + + #[track_caller] + fn warn_on_err(self) -> Option { + self.log_with_level(log::Level::Warn) + } + + #[track_caller] + fn log_with_level(self, level: log::Level) -> Option { + match self { + Ok(value) => Some(value), + Err(error) => { + log_error_with_caller(*Location::caller(), error, level); + None + } + } + } + + fn anyhow(self) -> anyhow::Result + where + E: Into, + { + self.map_err(Into::into) + } +} + +fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level) +where + E: std::fmt::Debug, +{ + #[cfg(not(windows))] + let file = caller.file(); + #[cfg(windows)] + let file = caller.file().replace('\\', "/"); + // In this codebase all crates reside in a `crates` directory, + // so discard the prefix up to that segment to find the crate name + let file = file.split_once("crates/"); + let target = file.as_ref().and_then(|(_, s)| s.split_once("/src/")); + + let module_path = target.map(|(krate, module)| { + if module.starts_with(krate) { + module.trim_end_matches(".rs").replace('/', "::") + } else { + krate.to_owned() + "::" + &module.trim_end_matches(".rs").replace('/', "::") + } + }); + let file = file.map(|(_, file)| format!("crates/{file}")); + log::logger().log( + &log::Record::builder() + .target(module_path.as_deref().unwrap_or("")) + .module_path(file.as_deref()) + .args(format_args!("{:?}", error)) + .file(Some(caller.file())) + .line(Some(caller.line())) + .level(level) + .build(), + ); +} + +pub fn log_err(error: &E) { + log_error_with_caller(*Location::caller(), error, log::Level::Error); +} + +pub trait TryFutureExt { + fn log_err(self) -> LogErrorFuture + where + Self: Sized; + + fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture + where + Self: Sized; + + fn warn_on_err(self) -> LogErrorFuture + where + Self: Sized; + fn unwrap(self) -> UnwrapFuture + where + Self: Sized; +} + +impl TryFutureExt for F +where + F: Future>, + E: std::fmt::Debug, +{ + #[track_caller] + fn log_err(self) -> LogErrorFuture + where + Self: Sized, + { + let location = Location::caller(); + LogErrorFuture(self, log::Level::Error, *location) + } + + fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture + where + Self: Sized, + { + LogErrorFuture(self, log::Level::Error, location) + } + + #[track_caller] + fn warn_on_err(self) -> LogErrorFuture + where + Self: Sized, + { + let location = Location::caller(); + LogErrorFuture(self, log::Level::Warn, *location) + } + + fn unwrap(self) -> UnwrapFuture + where + Self: Sized, + { + UnwrapFuture(self) + } +} + +#[must_use] +pub struct LogErrorFuture(F, log::Level, core::panic::Location<'static>); + +impl Future for LogErrorFuture +where + F: Future>, + E: std::fmt::Debug, +{ + type Output = Option; + + fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { + let level = self.1; + let location = self.2; + let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; + match inner.poll(cx) { + Poll::Ready(output) => Poll::Ready(match output { + Ok(output) => Some(output), + Err(error) => { + log_error_with_caller(location, error, level); + None + } + }), + Poll::Pending => Poll::Pending, + } + } +} + +pub struct UnwrapFuture(F); + +impl Future for UnwrapFuture +where + F: Future>, + E: std::fmt::Debug, +{ + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { + let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; + match inner.poll(cx) { + Poll::Ready(result) => Poll::Ready(result.unwrap()), + Poll::Pending => Poll::Pending, + } + } +} + +pub struct Deferred(Option); + +impl Deferred { + /// Drop without running the deferred function. + pub fn abort(mut self) { + self.0.take(); + } +} + +impl Drop for Deferred { + fn drop(&mut self) { + if let Some(f) = self.0.take() { + f() + } + } +} + +/// Run the given function when the returned value is dropped (unless it's cancelled). +#[must_use] +pub fn defer(f: F) -> Deferred { + Deferred(Some(f)) +} diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a2bb95a9f4bb3007a2a2feb9f7483d38dff3cf1d --- /dev/null +++ b/crates/gpui_web/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "gpui_web" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "Apache-2.0" +autoexamples = false + +[lints] +workspace = true + +[lib] +path = "src/gpui_web.rs" + +[target.'cfg(target_family = "wasm")'.dependencies] +gpui.workspace = true +parking_lot = { workspace = true, features = ["nightly"] } +gpui_wgpu.workspace = true +anyhow.workspace = true +futures.workspace = true +log.workspace = true +smallvec.workspace = true +uuid.workspace = true +wasm-bindgen.workspace = true +wasm-bindgen-futures = "0.4" +web-time.workspace = true +console_error_panic_hook = "0.1.7" +js-sys = "0.3" +raw-window-handle = "0.6" +wasm_thread = { version = "0.3", features = ["es_modules"] } +web-sys = { version = "0.3", features = [ + "console", + "CssStyleDeclaration", + "DataTransfer", + "Document", + "DomRect", + "DragEvent", + "Element", + "EventTarget", + "File", + "FileList", + "HtmlCanvasElement", + "HtmlElement", + "HtmlInputElement", + "KeyboardEvent", + "MediaQueryList", + "MediaQueryListEvent", + "MouseEvent", + "Navigator", + "PointerEvent", + "ResizeObserver", + "ResizeObserverBoxOptions", + "ResizeObserverEntry", + "ResizeObserverSize", + "ResizeObserverOptions", + "Screen", + "Storage", + "VisualViewport", + "WheelEvent", + "Window", +] } diff --git a/crates/gpui_web/LICENSE-APACHE b/crates/gpui_web/LICENSE-APACHE new file mode 120000 index 0000000000000000000000000000000000000000..1cd601d0a3affae83854be02a0afdec3b7a9ec4d --- /dev/null +++ b/crates/gpui_web/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/gpui_web/examples/hello_web/.cargo/config.toml b/crates/gpui_web/examples/hello_web/.cargo/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..de4cb1a897249f8f2f8e27025ccb6d1a54f01c34 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/.cargo/config.toml @@ -0,0 +1,14 @@ +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "target-feature=+atomics,+bulk-memory,+mutable-globals", + "-C", "link-arg=--shared-memory", + "-C", "link-arg=--max-memory=1073741824", + "-C", "link-arg=--import-memory", + "-C", "link-arg=--export=__wasm_init_tls", + "-C", "link-arg=--export=__tls_size", + "-C", "link-arg=--export=__tls_align", + "-C", "link-arg=--export=__tls_base", +] + +[unstable] +build-std = ["std,panic_abort"] diff --git a/crates/gpui_web/examples/hello_web/.gitignore b/crates/gpui_web/examples/hello_web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2b1bc8d911302b667ef2cc4e795b80eb2dd5ba28 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/.gitignore @@ -0,0 +1,3 @@ +/dist +/target +Cargo.lock diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7f8c5cf137778dc9f2d7cbab38c9dae91f2d9d15 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[package] +name = "hello_web" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "hello_web" +path = "main.rs" + +[dependencies] +gpui = { path = "../../../gpui" } +gpui_platform = { path = "../../../gpui_platform" } +web-time = "1" diff --git a/crates/gpui_web/examples/hello_web/LICENSE-APACHE b/crates/gpui_web/examples/hello_web/LICENSE-APACHE new file mode 120000 index 0000000000000000000000000000000000000000..15824831a24d7753b6e945b8190dd7d15413aed1 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/LICENSE-APACHE @@ -0,0 +1 @@ +../../../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..692f3edf06ef548e29cc6aaecc16d917f32ebdb6 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/index.html @@ -0,0 +1,31 @@ + + + + + + GPUI Web: hello_web + + + + + diff --git a/crates/gpui_web/examples/hello_web/main.rs b/crates/gpui_web/examples/hello_web/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6ff103e6475ca18ab1991403ba05089475e2e33 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/main.rs @@ -0,0 +1,422 @@ +use gpui::prelude::*; +use gpui::{ + App, Bounds, Context, ElementId, SharedString, Task, Window, WindowBounds, WindowOptions, div, + px, rgb, size, +}; + +// --------------------------------------------------------------------------- +// Prime counting (intentionally brute-force so it hammers the CPU) +// --------------------------------------------------------------------------- + +fn is_prime(n: u64) -> bool { + if n < 2 { + return false; + } + if n < 4 { + return true; + } + if n % 2 == 0 || n % 3 == 0 { + return false; + } + let mut i = 5; + while i * i <= n { + if n % i == 0 || n % (i + 2) == 0 { + return false; + } + i += 6; + } + true +} + +fn count_primes_in_range(start: u64, end: u64) -> u64 { + let mut count = 0; + for n in start..end { + if is_prime(n) { + count += 1; + } + } + count +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +const NUM_CHUNKS: u64 = 12; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Preset { + TenMillion, + FiftyMillion, + HundredMillion, +} + +impl Preset { + fn label(self) -> &'static str { + match self { + Preset::TenMillion => "10 M", + Preset::FiftyMillion => "50 M", + Preset::HundredMillion => "100 M", + } + } + + fn value(self) -> u64 { + match self { + Preset::TenMillion => 10_000_000, + Preset::FiftyMillion => 50_000_000, + Preset::HundredMillion => 100_000_000, + } + } + + const ALL: [Preset; 3] = [ + Preset::TenMillion, + Preset::FiftyMillion, + Preset::HundredMillion, + ]; +} + +struct ChunkResult { + count: u64, +} + +struct Run { + limit: u64, + chunks_done: u64, + chunk_results: Vec, + total: Option, + elapsed: Option, +} + +struct HelloWeb { + selected_preset: Preset, + current_run: Option, + history: Vec, + _tasks: Vec>, +} + +impl HelloWeb { + fn new(_cx: &mut Context) -> Self { + Self { + selected_preset: Preset::TenMillion, + current_run: None, + history: Vec::new(), + _tasks: Vec::new(), + } + } + + fn start_search(&mut self, cx: &mut Context) { + let limit = self.selected_preset.value(); + let chunk_size = limit / NUM_CHUNKS; + + self.current_run = Some(Run { + limit, + chunks_done: 0, + chunk_results: Vec::new(), + total: None, + elapsed: None, + }); + self._tasks.clear(); + cx.notify(); + + let start_time = web_time::Instant::now(); + + for i in 0..NUM_CHUNKS { + let range_start = i * chunk_size; + let range_end = if i == NUM_CHUNKS - 1 { + limit + } else { + range_start + chunk_size + }; + + let task = cx.spawn(async move |this, cx| { + let count = cx + .background_spawn(async move { count_primes_in_range(range_start, range_end) }) + .await; + + this.update(cx, |this, cx| { + if let Some(run) = &mut this.current_run { + run.chunk_results.push(ChunkResult { count }); + run.chunks_done += 1; + + if run.chunks_done == NUM_CHUNKS { + let total: u64 = run.chunk_results.iter().map(|r| r.count).sum(); + let elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0; + run.total = Some(total); + run.elapsed = Some(elapsed_ms); + this.history.push( + format!( + "π({}) = {} ({:.0} ms, {} chunks)", + format_number(run.limit), + format_number(total), + elapsed_ms, + NUM_CHUNKS, + ) + .into(), + ); + } + cx.notify(); + } + }) + .ok(); + }); + + self._tasks.push(task); + } + } +} + +fn format_number(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(ch); + } + result.chars().rev().collect() +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +const BG_BASE: u32 = 0x1e1e2e; +const BG_SURFACE: u32 = 0x313244; +const BG_OVERLAY: u32 = 0x45475a; +const TEXT_PRIMARY: u32 = 0xcdd6f4; +const TEXT_SECONDARY: u32 = 0xa6adc8; +const TEXT_DIM: u32 = 0x6c7086; +const ACCENT_YELLOW: u32 = 0xf9e2af; +const ACCENT_GREEN: u32 = 0xa6e3a1; +const ACCENT_BLUE: u32 = 0x89b4fa; +const ACCENT_MAUVE: u32 = 0xcba6f7; + +impl Render for HelloWeb { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_running = self.current_run.as_ref().is_some_and(|r| r.total.is_none()); + + // -- Preset buttons -- + let preset_row = Preset::ALL.iter().enumerate().fold( + div().flex().flex_row().gap_2(), + |row, (index, &preset)| { + let is_selected = preset == self.selected_preset; + let (bg, text_color) = if is_selected { + (ACCENT_BLUE, BG_BASE) + } else { + (BG_OVERLAY, TEXT_SECONDARY) + }; + row.child( + div() + .id(ElementId::NamedInteger("preset".into(), index as u64)) + .px_3() + .py_1() + .rounded_md() + .bg(rgb(bg)) + .text_color(rgb(text_color)) + .text_sm() + .cursor_pointer() + .when(!is_running, |this| { + this.on_click(cx.listener(move |this, _event, _window, _cx| { + this.selected_preset = preset; + })) + }) + .child(preset.label()), + ) + }, + ); + + // -- Go button -- + let (go_bg, go_text, go_label) = if is_running { + (BG_OVERLAY, TEXT_DIM, "Running…") + } else { + (ACCENT_GREEN, BG_BASE, "Count Primes") + }; + let go_button = div() + .id("go") + .px_4() + .py(px(6.)) + .rounded_md() + .bg(rgb(go_bg)) + .text_color(rgb(go_text)) + .cursor_pointer() + .when(!is_running, |this| { + this.on_click(cx.listener(|this, _event, _window, cx| { + this.start_search(cx); + })) + }) + .child(go_label); + + // -- Progress / result area -- + let status_area = if let Some(run) = &self.current_run { + let progress_fraction = run.chunks_done as f32 / NUM_CHUNKS as f32; + let progress_pct = (progress_fraction * 100.0) as u32; + + let status_text: SharedString = if let Some(total) = run.total { + format!( + "Found {} primes below {} in {:.0} ms", + format_number(total), + format_number(run.limit), + run.elapsed.unwrap_or(0.0), + ) + .into() + } else { + format!( + "Searching up to {} … {}/{} chunks ({}%)", + format_number(run.limit), + run.chunks_done, + NUM_CHUNKS, + progress_pct, + ) + .into() + }; + + let bar_color = if run.total.is_some() { + ACCENT_GREEN + } else { + ACCENT_BLUE + }; + + let chunk_dots = + (0..NUM_CHUNKS as usize).fold(div().flex().flex_row().gap_1().mt_2(), |row, i| { + let done = i < run.chunks_done as usize; + let color = if done { ACCENT_MAUVE } else { BG_OVERLAY }; + row.child(div().size(px(10.)).rounded_sm().bg(rgb(color))) + }); + + div() + .flex() + .flex_col() + .w_full() + .gap_2() + .child(div().text_color(rgb(TEXT_PRIMARY)).child(status_text)) + .child( + div() + .w_full() + .h(px(8.)) + .rounded_sm() + .bg(rgb(BG_OVERLAY)) + .child( + div() + .h_full() + .rounded_sm() + .bg(rgb(bar_color)) + .w(gpui::relative(progress_fraction)), + ), + ) + .child(chunk_dots) + } else { + div().flex().flex_col().w_full().child( + div() + .text_color(rgb(TEXT_DIM)) + .child("Select a range and press Count Primes to begin."), + ) + }; + + // -- History log -- + let history_section = if self.history.is_empty() { + div() + } else { + self.history + .iter() + .rev() + .fold(div().flex().flex_col().gap_1(), |col, entry| { + col.child( + div() + .text_sm() + .text_color(rgb(TEXT_SECONDARY)) + .child(entry.clone()), + ) + }) + }; + + // -- Layout -- + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(BG_BASE)) + .justify_center() + .items_center() + .gap_4() + .p_4() + // Title + .child( + div() + .text_xl() + .text_color(rgb(TEXT_PRIMARY)) + .child("Prime Sieve — GPUI Web"), + ) + .child(div().text_sm().text_color(rgb(TEXT_DIM)).child(format!( + "Background threads: {} · Chunks per run: {}", + std::thread::available_parallelism().map_or(2, |n| n.get().max(2)), + NUM_CHUNKS, + ))) + // Controls + .child( + div() + .flex() + .flex_col() + .items_center() + .gap_3() + .p_4() + .w(px(500.)) + .rounded_lg() + .bg(rgb(BG_SURFACE)) + .child( + div() + .text_sm() + .text_color(rgb(ACCENT_YELLOW)) + .child("Count primes below:"), + ) + .child(preset_row) + .child(go_button), + ) + // Status + .child( + div() + .flex() + .flex_col() + .w(px(500.)) + .p_4() + .rounded_lg() + .bg(rgb(BG_SURFACE)) + .child(status_area), + ) + // History + .when(!self.history.is_empty(), |this| { + this.child( + div() + .flex() + .flex_col() + .w(px(500.)) + .p_4() + .rounded_lg() + .bg(rgb(BG_SURFACE)) + .gap_2() + .child(div().text_sm().text_color(rgb(TEXT_DIM)).child("History")) + .child(history_section), + ) + }) + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +fn main() { + gpui_platform::web_init(); + gpui_platform::application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(640.), px(560.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(HelloWeb::new), + ) + .expect("failed to open window"); + cx.activate(true); + }); +} diff --git a/crates/gpui_web/examples/hello_web/rust-toolchain.toml b/crates/gpui_web/examples/hello_web/rust-toolchain.toml new file mode 100644 index 0000000000000000000000000000000000000000..8ea6df3d47dc12d2daa3d1b25431f2d636698dc4 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly" +targets = ["wasm32-unknown-unknown"] +components = ["rust-src", "rustfmt", "clippy"] diff --git a/crates/gpui_web/examples/hello_web/trunk.toml b/crates/gpui_web/examples/hello_web/trunk.toml new file mode 100644 index 0000000000000000000000000000000000000000..5ef787c270984bd9dfc4aa28daa25a4b2945126b --- /dev/null +++ b/crates/gpui_web/examples/hello_web/trunk.toml @@ -0,0 +1,7 @@ +[serve] +addresses = ["127.0.0.1"] +port = 8080 +open = true + +# Headers required for WebGPU / SharedArrayBuffer support. +headers = { "Cross-Origin-Embedder-Policy" = "require-corp", "Cross-Origin-Opener-Policy" = "same-origin" } diff --git a/crates/gpui_web/src/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca0b700a1bf0bc75e1dafd859b59a04540524f63 --- /dev/null +++ b/crates/gpui_web/src/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; + +const MIN_BACKGROUND_THREADS: usize = 2; + +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, + _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) -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + + let main_thread_mailbox = Arc::new(MainThreadMailbox::new()); + let supports_threads = shared_memory_supported(); + + if supports_threads { + main_thread_mailbox.run_waker_loop(browser_window.clone()); + } else { + log::warn!( + "SharedArrayBuffer not available; falling back to single-threaded dispatcher" + ); + } + + 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; + } + }; + + if runnable.metadata().is_closed() { + continue; + } + + 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, + _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 || { + if !runnable.metadata().is_closed() { + 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) => { + if !runnable.metadata().is_closed() { + runnable.run(); + } + } + MainThreadItem::Delayed { runnable, millis } => { + let callback = Closure::once_into_js(move || { + if !runnable.metadata().is_closed() { + 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 || { + if !runnable.metadata().is_closed() { + 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/crates/gpui_web/src/display.rs b/crates/gpui_web/src/display.rs new file mode 100644 index 0000000000000000000000000000000000000000..77dd35d92367ccc3439536db8f9bbb5ed079e7a1 --- /dev/null +++ b/crates/gpui_web/src/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/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f6d8527e70a3778a46a11e00758e822790e742f --- /dev/null +++ b/crates/gpui_web/src/events.rs @@ -0,0 +1,615 @@ +use std::rc::Rc; + +use gpui::{ + Capslock, 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_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 + } + + /// 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) { + let mut borrowed = self.callbacks.borrow_mut(); + if let Some(ref mut callback) = borrowed.input { + 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.canvas.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("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, + }; + + this.dispatch_input(PlatformInput::KeyDown(KeyDownEvent { + keystroke, + is_held, + prefer_character_input: false, + })); + }) + } + + fn register_key_up(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("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_focus(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen("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("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") +} + +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/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..966ff3b0d7d90219e8cf702a16fce598f813c835 --- /dev/null +++ b/crates/gpui_web/src/gpui_web.rs @@ -0,0 +1,16 @@ +#![cfg(target_family = "wasm")] + +mod dispatcher; +mod display; +mod events; +mod keyboard; +mod logging; +mod platform; +mod window; + +pub use dispatcher::WebDispatcher; +pub use display::WebDisplay; +pub use keyboard::WebKeyboardLayout; +pub use logging::init_logging; +pub use platform::WebPlatform; +pub use window::WebWindow; diff --git a/crates/gpui_web/src/keyboard.rs b/crates/gpui_web/src/keyboard.rs new file mode 100644 index 0000000000000000000000000000000000000000..3c1c97a01ee784af1687bfe90bedc97b091c5fba --- /dev/null +++ b/crates/gpui_web/src/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/crates/gpui_web/src/logging.rs b/crates/gpui_web/src/logging.rs new file mode 100644 index 0000000000000000000000000000000000000000..9e76201b194815a01d558c8099f552401af0fcea --- /dev/null +++ b/crates/gpui_web/src/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/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs new file mode 100644 index 0000000000000000000000000000000000000000..420b7cb3f470c683888aa76bd61236c1f1ff181e --- /dev/null +++ b/crates/gpui_web/src/platform.rs @@ -0,0 +1,341 @@ +use crate::dispatcher::WebDispatcher; +use crate::display::WebDisplay; +use crate::keyboard::WebKeyboardLayout; +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 gpui_wgpu::WgpuContext; +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() -> 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())); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + let text_system = Arc::new(gpui_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/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs new file mode 100644 index 0000000000000000000000000000000000000000..c29fa509dd206406b24069053dc71bdc4dc18e75 --- /dev/null +++ b/crates/gpui_web/src/window.rs @@ -0,0 +1,689 @@ +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 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 gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +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) 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, + mql_handle: RefCell>, +} + +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(0); + + 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:?}"))?; + + canvas.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, + 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), + mql_handle: RefCell::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.canvas.set_width(clamped_width); + inner.canvas.set_height(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; + s.renderer.update_drawable_size(Size { + width: DevicePixels(clamped_width as i32), + height: DevicePixels(clamped_height as i32), + }); + } + + 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 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) { + 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/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index a3664fe59e9c51f5b9e68f63c67d30aa502bd737..c5c078088981803712e559f0a3e19c9f1ab850d5 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -11,16 +11,36 @@ workspace = true [lib] path = "src/gpui_wgpu.rs" -[target.'cfg(not(target_os = "windows"))'.dependencies] +[features] +default = [] +font-kit = ["dep:font-kit"] + +[dependencies] gpui.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true +cosmic-text = "0.17.0" etagere = "0.2" +itertools.workspace = true log.workspace = true parking_lot.workspace = true profiling.workspace = true raw-window-handle = "0.6" -smol.workspace = true -util.workspace = true +smallvec.workspace = true +swash = "0.2.6" +gpui_util.workspace = true wgpu.workspace = true + +# Optional: only needed on platforms with multiple font sources (e.g. Linux) +# WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", optional = true } + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +pollster.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +wasm-bindgen.workspace = true +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } +js-sys = "0.3" \ No newline at end of file diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs new file mode 100644 index 0000000000000000000000000000000000000000..c664ca9449ff211b2c094556c0f896dc71cdf574 --- /dev/null +++ b/crates/gpui_wgpu/src/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/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs index 8a1eb576ed01475973e92f37b05ca3ee393daf66..a306a9d4cac2251a46cd1115462bdcbe4b368759 100644 --- a/crates/gpui_wgpu/src/gpui_wgpu.rs +++ b/crates/gpui_wgpu/src/gpui_wgpu.rs @@ -1,8 +1,9 @@ -#![cfg(not(target_os = "windows"))] +mod cosmic_text_system; mod wgpu_atlas; mod wgpu_context; mod wgpu_renderer; +pub use cosmic_text_system::*; pub use wgpu_atlas::*; pub use wgpu_context::*; pub use wgpu_renderer::*; diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index 58e9de109e6602d999433aa9b42d3b80d06ca4ad..12ce7d29b0b81603b7b051f733e905ccd9111d9d 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1,4 +1,3 @@ -enable dual_source_blending; /* Functions useful for debugging: // A heat map color for debugging (blue -> cyan -> green -> yellow -> red). @@ -501,11 +500,11 @@ fn gradient_color(background: Background, position: vec2, bounds: Bounds, // 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); } @@ -1033,7 +1032,7 @@ struct PathRasterizationVertex { 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, } @@ -1072,14 +1071,14 @@ fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4(color.rgb * color.a * alpha, color.a * alpha); } @@ -1334,57 +1333,3 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { return ycbcr_to_RGB * y_cb_cr; } - -// --- 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/crates/gpui_wgpu/src/shaders_subpixel.wgsl b/crates/gpui_wgpu/src/shaders_subpixel.wgsl new file mode 100644 index 0000000000000000000000000000000000000000..7acbd2e3d2e68ebdab349178b2918c564a35e4a3 --- /dev/null +++ b/crates/gpui_wgpu/src/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/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index 270201183c8afd33534c184b7dc597ed6ab7d9d5..bcf0b93454ea64c45d1f453c1107a23e6a9cc962 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -1,6 +1,8 @@ +#[cfg(not(target_family = "wasm"))] use anyhow::Context as _; +#[cfg(not(target_family = "wasm"))] +use gpui_util::ResultExt; use std::sync::Arc; -use util::ResultExt; pub struct WgpuContext { pub instance: wgpu::Instance, @@ -11,6 +13,7 @@ pub struct WgpuContext { } impl WgpuContext { + #[cfg(not(target_family = "wasm"))] pub fn new(instance: wgpu::Instance, surface: &wgpu::Surface<'_>) -> anyhow::Result { let device_id_filter = match std::env::var("ZED_DEVICE_ID") { Ok(val) => parse_pci_id(&val) @@ -24,7 +27,7 @@ impl WgpuContext { } }; - let adapter = smol::block_on(Self::select_adapter( + let adapter = pollster::block_on(Self::select_adapter( &instance, device_id_filter, Some(surface), @@ -60,6 +63,73 @@ impl WgpuContext { }) } + #[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(), + }); + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::None, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to request GPU adapter: {e}"))?; + Self::create_context(instance, adapter).await + } + + #[cfg(target_family = "wasm")] + async fn create_context( + instance: wgpu::Instance, + adapter: wgpu::Adapter, + ) -> anyhow::Result { + log::info!( + "Selected GPU adapter: {:?} ({:?})", + adapter.get_info().name, + adapter.get_info().backend + ); + + let dual_source_blending_available = adapter + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + + let mut required_features = wgpu::Features::empty(); + if dual_source_blending_available { + required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; + } else { + log::info!( + "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::default(), + 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(Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + dual_source_blending: dual_source_blending_available, + }) + } + + #[cfg(not(target_family = "wasm"))] pub fn instance() -> wgpu::Instance { wgpu::Instance::new(&wgpu::InstanceDescriptor { backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, @@ -84,6 +154,7 @@ impl WgpuContext { Ok(()) } + #[cfg(not(target_family = "wasm"))] fn create_device(adapter: &wgpu::Adapter) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { let dual_source_blending_available = adapter .features() @@ -99,7 +170,7 @@ impl WgpuContext { ); } - let (device, queue) = smol::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("gpui_device"), required_features, required_limits: wgpu::Limits::default(), @@ -112,6 +183,7 @@ impl WgpuContext { Ok((device, queue, dual_source_blending_available)) } + #[cfg(not(target_family = "wasm"))] async fn select_adapter( instance: &wgpu::Instance, device_id_filter: Option, @@ -182,6 +254,7 @@ impl WgpuContext { } } +#[cfg(not(target_family = "wasm"))] fn parse_pci_id(id: &str) -> anyhow::Result { let mut id = id.trim(); diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 95d64d952373f303c1015669ee90a93b5d179dd5..0f0a6c4544b8b46c82d35b5f8804accc3a943c53 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -5,6 +5,7 @@ use gpui::{ PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, SubpixelSprite, Underline, get_gamma_correction_ratios, }; +#[cfg(not(target_family = "wasm"))] use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::num::NonZeroU64; use std::sync::Arc; @@ -123,6 +124,7 @@ impl WgpuRenderer { /// # 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: &mut Option, window: &W, @@ -165,6 +167,27 @@ impl WgpuRenderer { None => gpu_context.insert(WgpuContext::new(instance, &surface)?), }; + Self::new_with_surface(context, surface, config) + } + + #[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}"))?; + Self::new_with_surface(context, surface, config) + } + + pub fn new_with_surface( + context: &WgpuContext, + surface: wgpu::Surface<'static>, + config: WgpuSurfaceConfig, + ) -> anyhow::Result { let surface_caps = surface.get_capabilities(&context.adapter); let preferred_formats = [ wgpu::TextureFormat::Bgra8Unorm, @@ -497,12 +520,25 @@ impl WgpuRenderer { path_sample_count: u32, dual_source_blending: bool, ) -> WgpuPipelines { - let shader_source = include_str!("shaders.wgsl"); + 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(shader_source.into()), + 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 @@ -523,7 +559,8 @@ impl WgpuRenderer { data_layout: &wgpu::BindGroupLayout, topology: wgpu::PrimitiveTopology, color_targets: &[Option], - sample_count: u32| { + sample_count: u32, + module: &wgpu::ShaderModule| { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some(&format!("{name}_layout")), bind_group_layouts: &[globals_layout, data_layout], @@ -534,13 +571,13 @@ impl WgpuRenderer { label: Some(name), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { - module: &shader_module, + module, entry_point: Some(vs_entry), buffers: &[], compilation_options: wgpu::PipelineCompilationOptions::default(), }, fragment: Some(wgpu::FragmentState { - module: &shader_module, + module, entry_point: Some(fs_entry), targets: color_targets, compilation_options: wgpu::PipelineCompilationOptions::default(), @@ -574,6 +611,7 @@ impl WgpuRenderer { wgpu::PrimitiveTopology::TriangleStrip, &[Some(color_target.clone())], 1, + &shader_module, ); let shadows = create_pipeline( @@ -585,6 +623,7 @@ impl WgpuRenderer { wgpu::PrimitiveTopology::TriangleStrip, &[Some(color_target.clone())], 1, + &shader_module, ); let path_rasterization = create_pipeline( @@ -600,6 +639,7 @@ impl WgpuRenderer { write_mask: wgpu::ColorWrites::ALL, })], path_sample_count, + &shader_module, ); let paths_blend = wgpu::BlendState { @@ -628,6 +668,7 @@ impl WgpuRenderer { write_mask: wgpu::ColorWrites::ALL, })], 1, + &shader_module, ); let underlines = create_pipeline( @@ -639,6 +680,7 @@ impl WgpuRenderer { wgpu::PrimitiveTopology::TriangleStrip, &[Some(color_target.clone())], 1, + &shader_module, ); let mono_sprites = create_pipeline( @@ -650,9 +692,10 @@ impl WgpuRenderer { wgpu::PrimitiveTopology::TriangleStrip, &[Some(color_target.clone())], 1, + &shader_module, ); - let subpixel_sprites = if dual_source_blending { + let subpixel_sprites = if let Some(subpixel_module) = &subpixel_shader_module { let subpixel_blend = wgpu::BlendState { color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::Src1, @@ -679,6 +722,7 @@ impl WgpuRenderer { write_mask: wgpu::ColorWrites::COLOR, })], 1, + subpixel_module, )) } else { None @@ -693,6 +737,7 @@ impl WgpuRenderer { wgpu::PrimitiveTopology::TriangleStrip, &[Some(color_target.clone())], 1, + &shader_module, ); let surfaces = create_pipeline( @@ -704,6 +749,7 @@ impl WgpuRenderer { wgpu::PrimitiveTopology::TriangleStrip, &[Some(color_target)], 1, + &shader_module, ); WgpuPipelines { @@ -837,6 +883,10 @@ impl WgpuRenderer { &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, diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 9208f9f462e515c9acbe4a71e6c0e119ea1b3966..ee729a80eaa9eff56eee7f3bcb8fe6eaf31f0c41 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -38,7 +38,7 @@ futures.workspace = true git.workspace = true git_hosting_providers.workspace = true git2 = { workspace = true, features = ["vendored-libgit2"] } -gpui = { workspace = true, features = ["windows-manifest"] } +gpui.workspace = true gpui_platform.workspace = true gpui_tokio.workspace = true http_client.workspace = true @@ -82,6 +82,7 @@ minidumper.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true +gpui = { workspace = true, features = ["windows-manifest"] } [dev-dependencies] action_log.workspace = true diff --git a/crates/scheduler/Cargo.toml b/crates/scheduler/Cargo.toml index c1c791c06736297a284dcc16396f5e5d040c7bad..bc97b17b281275ffaad2f8626d000e87fe8ec42e 100644 --- a/crates/scheduler/Cargo.toml +++ b/crates/scheduler/Cargo.toml @@ -23,3 +23,4 @@ flume = "0.11" futures.workspace = true parking_lot.workspace = true rand.workspace = true +web-time.workspace = true diff --git a/crates/scheduler/src/clock.rs b/crates/scheduler/src/clock.rs index 017643c4eb7ffe46db48b5efb43d006bf155a03c..8c989165b679746c68e6c0295e8706ab77373d29 100644 --- a/crates/scheduler/src/clock.rs +++ b/crates/scheduler/src/clock.rs @@ -1,6 +1,8 @@ use chrono::{DateTime, Utc}; use parking_lot::Mutex; -use std::time::{Duration, Instant}; +use std::time::Duration; + +pub use web_time::Instant; pub trait Clock { fn utc_now(&self) -> DateTime; diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 34e543645aba5a9a347e7d337fe0e65a23957c8c..05ea973c4ece53f996b732a7e8c3673487f3b8dc 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -1,4 +1,4 @@ -use crate::{Priority, RunnableMeta, Scheduler, SessionId, Timer}; +use crate::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer}; use std::{ future::Future, marker::PhantomData, @@ -12,7 +12,7 @@ use std::{ }, task::{Context, Poll}, thread::{self, ThreadId}, - time::{Duration, Instant}, + time::Duration, }; #[derive(Clone)] diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 7cce194f5f0427706fe531ae82c883de453c83cf..03a8c0b90c77e4c17bd8a1130e5c82ccd935e80f 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -1,6 +1,6 @@ use crate::{ - BackgroundExecutor, Clock, ForegroundExecutor, Priority, RunnableMeta, Scheduler, SessionId, - TestClock, Timer, + BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler, + SessionId, TestClock, Timer, }; use async_task::Runnable; use backtrace::{Backtrace, BacktraceFrame}; @@ -26,7 +26,7 @@ use std::{ }, task::{Context, Poll, RawWaker, RawWakerVTable, Waker}, thread::{self, Thread}, - time::{Duration, Instant}, + time::Duration, }; const PENDING_TRACES_VAR_NAME: &str = "PENDING_TRACES"; diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 55997b25344d69e090581d46008d9983bc895bca..6a9b30d463af2d9407e8f4c9e3a81133a87c1bce 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -19,14 +19,11 @@ test-support = ["git2", "rand", "util_macros"] [dependencies] anyhow.workspace = true -async-fs.workspace = true async_zip.workspace = true collections.workspace = true -dirs.workspace = true dunce = "1.0" futures-lite.workspace = true futures.workspace = true -git2 = { workspace = true, optional = true } globset.workspace = true itertools.workspace = true log.workspace = true @@ -38,15 +35,21 @@ serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true shlex.workspace = true -smol.workspace = true take-until.workspace = true tempfile.workspace = true unicase.workspace = true url.workspace = true percent-encoding.workspace = true util_macros = { workspace = true, optional = true } -walkdir.workspace = true +gpui_util.workspace = true + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +smol.workspace = true which.workspace = true +git2 = { workspace = true, optional = true } +async-fs.workspace = true +walkdir.workspace = true +dirs.workspace = true [target.'cfg(unix)'.dependencies] command-fds = "0.3.1" diff --git a/crates/util/src/archive.rs b/crates/util/src/archive.rs index 99ff0254929825292d9f4c56806e7ad8177baa9a..7fe43a25c37afa0fa2487374c89a13945e54fc6c 100644 --- a/crates/util/src/archive.rs +++ b/crates/util/src/archive.rs @@ -6,6 +6,7 @@ use async_zip::base::read; use futures::AsyncSeek; use futures::{AsyncRead, io::BufReader}; +#[cfg(any(unix, windows))] fn archive_path_is_normal(filename: &str) -> bool { Path::new(filename).components().all(|c| { matches!( @@ -64,7 +65,7 @@ pub async fn extract_zip(destination: &Path, reader: R) -> Ok(()) } -#[cfg(not(windows))] +#[cfg(unix)] pub async fn extract_zip(destination: &Path, reader: R) -> Result<()> { // Unix needs file permissions copied when extracting. // This is only possible to do when a reader impls `AsyncSeek` and `seek::ZipFileReader` is used. @@ -81,7 +82,7 @@ pub async fn extract_zip(destination: &Path, reader: R) -> extract_seekable_zip(destination, file).await } -#[cfg(not(windows))] +#[cfg(unix)] pub async fn extract_seekable_zip( destination: &Path, reader: R, diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 5cc1a0c8ebcfcf7120ef59f9305c4f4622751143..39b4064a1bd9d3c4c240abf9665b17151066e9ef 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1,4 +1,3 @@ -use anyhow::Context; use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; use itertools::Itertools; use regex::Regex; @@ -9,20 +8,19 @@ use std::error::Error; use std::fmt::{Display, Formatter}; use std::mem; use std::path::StripPrefixError; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use std::{ ffi::OsStr, path::{Path, PathBuf}, sync::LazyLock, }; +use crate::rel_path::RelPath; use crate::rel_path::RelPathBuf; -use crate::{rel_path::RelPath, shell::ShellKind}; - -static HOME_DIR: OnceLock = OnceLock::new(); /// Returns the path to the user's home directory. pub fn home_dir() -> &'static PathBuf { + static HOME_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); HOME_DIR.get_or_init(|| { if cfg!(any(test, feature = "test-support")) { if cfg!(target_os = "macos") { @@ -56,6 +54,13 @@ pub trait PathExt { where Self: From<&'a Path>, { + #[cfg(target_family = "wasm")] + { + std::str::from_utf8(bytes) + .map(Path::new) + .map(Into::into) + .map_err(Into::into) + } #[cfg(unix)] { use std::os::unix::prelude::OsStrExt; @@ -63,6 +68,7 @@ pub trait PathExt { } #[cfg(windows)] { + use anyhow::Context; use tendril::fmt::{Format, WTF8}; WTF8::validate(bytes) .then(|| { @@ -86,11 +92,17 @@ pub trait PathExt { fn multiple_extensions(&self) -> Option; /// Try to make a shell-safe representation of the path. - fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result; + #[cfg(not(target_family = "wasm"))] + fn try_shell_safe(&self, shell_kind: crate::shell::ShellKind) -> anyhow::Result; } impl> PathExt for T { fn compact(&self) -> PathBuf { + #[cfg(target_family = "wasm")] + { + self.as_ref().to_path_buf() + } + #[cfg(not(target_family = "wasm"))] if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") { match self.as_ref().strip_prefix(home_dir().as_path()) { Ok(relative_path) => { @@ -164,7 +176,9 @@ impl> PathExt for T { Some(parts.into_iter().join(".")) } - fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result { + #[cfg(not(target_family = "wasm"))] + fn try_shell_safe(&self, shell_kind: crate::shell::ShellKind) -> anyhow::Result { + use anyhow::Context; let path_str = self .as_ref() .to_str() diff --git a/crates/util/src/test.rs b/crates/util/src/test.rs index 0a251a1e2541dba1a4269f9b575401c85c308aac..717754e33375a5fee51829e2e1d35d153e493915 100644 --- a/crates/util/src/test.rs +++ b/crates/util/src/test.rs @@ -1,16 +1,14 @@ mod assertions; mod marked_text; -use git2; -use std::{ - ffi::OsStr, - path::{Path, PathBuf}, -}; -use tempfile::TempDir; - pub use assertions::*; pub use marked_text::*; +use git2; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + pub struct TempTree { _temp_dir: TempDir, path: PathBuf, @@ -45,6 +43,7 @@ fn write_tree(path: &Path, tree: serde_json::Value) { Value::Object(_) => { fs::create_dir(&path).unwrap(); + #[cfg(not(target_family = "wasm"))] if path.file_name() == Some(OsStr::new(".git")) { git2::Repository::init(path.parent().unwrap()).unwrap(); } diff --git a/crates/util/src/test/git.rs b/crates/util/src/test/git.rs new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 499ef71e1acac6f5f416e7ed313510c995a3df0a..86d26aee884da5f708fec14b5a3c09dccfa7f5f3 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1,4 +1,3 @@ -pub mod arc_cow; pub mod archive; pub mod command; pub mod fs; @@ -17,40 +16,27 @@ pub mod size; pub mod test; pub mod time; -use anyhow::{Context as _, Result}; -use futures::Future; +use anyhow::Result; use itertools::Either; -use paths::PathExt; use regex::Regex; use std::path::{Path, PathBuf}; -use std::sync::{LazyLock, OnceLock}; +use std::sync::LazyLock; use std::{ borrow::Cow, cmp::{self, Ordering}, - env, - ops::{AddAssign, Range, RangeInclusive}, - panic::Location, - pin::Pin, - task::{Context, Poll}, - time::Instant, + ops::{Range, RangeInclusive}, }; use unicase::UniCase; +pub use gpui_util::*; + pub use take_until::*; #[cfg(any(test, feature = "test-support"))] pub use util_macros::{line_endings, path, uri}; -#[macro_export] -macro_rules! debug_panic { - ( $($fmt_arg:tt)* ) => { - if cfg!(debug_assertions) { - panic!( $($fmt_arg)* ); - } else { - let backtrace = std::backtrace::Backtrace::capture(); - log::error!("{}\n{:?}", format_args!($($fmt_arg)*), backtrace); - } - }; -} +pub use self::shell::{ + get_default_system_shell, get_default_system_shell_preferring_bash, get_system_shell, +}; #[inline] pub const fn is_utf8_char_boundary(u8: u8) -> bool { @@ -174,12 +160,6 @@ fn test_truncate_lines_to_byte_limit() { ); } -pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { - let prev = *value; - *value += T::from(1); - prev -} - /// Extend a sorted vector with a sorted sequence of items, maintaining the vector's sort order and /// enforcing a maximum length. This also de-duplicates items. Sort the items according to the given callback. Before calling this, /// both `vec` and `new_items` should already be sorted according to the `cmp` comparator. @@ -287,7 +267,7 @@ fn load_shell_from_passwd() -> Result<()> { ); let shell = unsafe { std::ffi::CStr::from_ptr(entry.pw_shell).to_str().unwrap() }; - let should_set_shell = env::var("SHELL").map_or(true, |shell_env| { + let should_set_shell = std::env::var("SHELL").map_or(true, |shell_env| { shell_env != shell && !std::path::Path::new(&shell_env).exists() }); @@ -296,7 +276,7 @@ fn load_shell_from_passwd() -> Result<()> { "updating SHELL environment variable to value from passwd entry: {:?}", shell, ); - unsafe { env::set_var("SHELL", shell) }; + unsafe { std::env::set_var("SHELL", shell) }; } Ok(()) @@ -304,6 +284,8 @@ fn load_shell_from_passwd() -> Result<()> { /// Returns a shell escaped path for the current zed executable pub fn get_shell_safe_zed_path(shell_kind: shell::ShellKind) -> anyhow::Result { + use anyhow::Context as _; + use paths::PathExt; let mut zed_path = std::env::current_exe().context("Failed to determine current zed executable path.")?; if cfg!(target_os = "linux") @@ -326,6 +308,7 @@ pub fn get_shell_safe_zed_path(shell_kind: shell::ShellKind) -> anyhow::Result Result { + use anyhow::Context as _; let zed_path = std::env::current_exe().context("Failed to determine current zed executable path.")?; let parent = zed_path @@ -365,6 +348,8 @@ pub fn get_zed_cli_path() -> Result { #[cfg(unix)] pub async fn load_login_shell_environment() -> Result<()> { + use anyhow::Context as _; + load_shell_from_passwd().log_err(); // If possible, we want to `cd` in the user's `$HOME` to trigger programs @@ -383,7 +368,7 @@ pub async fn load_login_shell_environment() -> Result<()> { if name == "SHLVL" { continue; } - unsafe { env::set_var(&name, &value) }; + unsafe { std::env::set_var(&name, &value) }; } log::info!( @@ -404,7 +389,7 @@ pub fn set_pre_exec_to_start_new_session( ) -> &mut std::process::Command { // safety: code in pre_exec should be signal safe. // https://man7.org/linux/man-pages/man7/signal-safety.7.html - #[cfg(not(target_os = "windows"))] + #[cfg(unix)] unsafe { use std::os::unix::process::CommandExt; command.pre_exec(|| { @@ -485,25 +470,6 @@ pub fn merge_non_null_json_value_into(source: serde_json::Value, target: &mut se } } -pub fn measure(label: &str, f: impl FnOnce() -> R) -> R { - static ZED_MEASUREMENTS: OnceLock = OnceLock::new(); - let zed_measurements = ZED_MEASUREMENTS.get_or_init(|| { - env::var("ZED_MEASUREMENTS") - .map(|measurements| measurements == "1" || measurements == "true") - .unwrap_or(false) - }); - - if *zed_measurements { - let start = Instant::now(); - let result = f(); - let elapsed = start.elapsed(); - eprintln!("{}: {:?}", label, elapsed); - result - } else { - f() - } -} - pub fn expanded_and_wrapped_usize_range( range: Range, additional_before: usize, @@ -570,222 +536,6 @@ pub fn wrapped_usize_outward_from( }) } -pub trait ResultExt { - type Ok; - - fn log_err(self) -> Option; - /// Assert that this result should never be an error in development or tests. - fn debug_assert_ok(self, reason: &str) -> Self; - fn warn_on_err(self) -> Option; - fn log_with_level(self, level: log::Level) -> Option; - fn anyhow(self) -> anyhow::Result - where - E: Into; -} - -impl ResultExt for Result -where - E: std::fmt::Debug, -{ - type Ok = T; - - #[track_caller] - fn log_err(self) -> Option { - self.log_with_level(log::Level::Error) - } - - #[track_caller] - fn debug_assert_ok(self, reason: &str) -> Self { - if let Err(error) = &self { - debug_panic!("{reason} - {error:?}"); - } - self - } - - #[track_caller] - fn warn_on_err(self) -> Option { - self.log_with_level(log::Level::Warn) - } - - #[track_caller] - fn log_with_level(self, level: log::Level) -> Option { - match self { - Ok(value) => Some(value), - Err(error) => { - log_error_with_caller(*Location::caller(), error, level); - None - } - } - } - - fn anyhow(self) -> anyhow::Result - where - E: Into, - { - self.map_err(Into::into) - } -} - -fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level) -where - E: std::fmt::Debug, -{ - #[cfg(not(target_os = "windows"))] - let file = caller.file(); - #[cfg(target_os = "windows")] - let file = caller.file().replace('\\', "/"); - // In this codebase all crates reside in a `crates` directory, - // so discard the prefix up to that segment to find the crate name - let file = file.split_once("crates/"); - let target = file.as_ref().and_then(|(_, s)| s.split_once("/src/")); - - let module_path = target.map(|(krate, module)| { - if module.starts_with(krate) { - module.trim_end_matches(".rs").replace('/', "::") - } else { - krate.to_owned() + "::" + &module.trim_end_matches(".rs").replace('/', "::") - } - }); - let file = file.map(|(_, file)| format!("crates/{file}")); - log::logger().log( - &log::Record::builder() - .target(module_path.as_deref().unwrap_or("")) - .module_path(file.as_deref()) - .args(format_args!("{:?}", error)) - .file(Some(caller.file())) - .line(Some(caller.line())) - .level(level) - .build(), - ); -} - -pub fn log_err(error: &E) { - log_error_with_caller(*Location::caller(), error, log::Level::Error); -} - -pub trait TryFutureExt { - fn log_err(self) -> LogErrorFuture - where - Self: Sized; - - fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture - where - Self: Sized; - - fn warn_on_err(self) -> LogErrorFuture - where - Self: Sized; - fn unwrap(self) -> UnwrapFuture - where - Self: Sized; -} - -impl TryFutureExt for F -where - F: Future>, - E: std::fmt::Debug, -{ - #[track_caller] - fn log_err(self) -> LogErrorFuture - where - Self: Sized, - { - let location = Location::caller(); - LogErrorFuture(self, log::Level::Error, *location) - } - - fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture - where - Self: Sized, - { - LogErrorFuture(self, log::Level::Error, location) - } - - #[track_caller] - fn warn_on_err(self) -> LogErrorFuture - where - Self: Sized, - { - let location = Location::caller(); - LogErrorFuture(self, log::Level::Warn, *location) - } - - fn unwrap(self) -> UnwrapFuture - where - Self: Sized, - { - UnwrapFuture(self) - } -} - -#[must_use] -pub struct LogErrorFuture(F, log::Level, core::panic::Location<'static>); - -impl Future for LogErrorFuture -where - F: Future>, - E: std::fmt::Debug, -{ - type Output = Option; - - fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let level = self.1; - let location = self.2; - let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; - match inner.poll(cx) { - Poll::Ready(output) => Poll::Ready(match output { - Ok(output) => Some(output), - Err(error) => { - log_error_with_caller(location, error, level); - None - } - }), - Poll::Pending => Poll::Pending, - } - } -} - -pub struct UnwrapFuture(F); - -impl Future for UnwrapFuture -where - F: Future>, - E: std::fmt::Debug, -{ - type Output = T; - - fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - let inner = unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0) }; - match inner.poll(cx) { - Poll::Ready(result) => Poll::Ready(result.unwrap()), - Poll::Pending => Poll::Pending, - } - } -} - -pub struct Deferred(Option); - -impl Deferred { - /// Drop without running the deferred function. - pub fn abort(mut self) { - self.0.take(); - } -} - -impl Drop for Deferred { - fn drop(&mut self) { - if let Some(f) = self.0.take() { - f() - } - } -} - -/// Run the given function when the returned value is dropped (unless it's cancelled). -#[must_use] -pub fn defer(f: F) -> Deferred { - Deferred(Some(f)) -} - #[cfg(any(test, feature = "test-support"))] mod rng { use rand::prelude::*; @@ -849,23 +599,6 @@ pub fn asset_str(path: &str) -> Cow<'static, str> { } } -/// Expands to an immediately-invoked function expression. Good for using the ? operator -/// in functions which do not return an Option or Result. -/// -/// Accepts a normal block, an async block, or an async move block. -#[macro_export] -macro_rules! maybe { - ($block:block) => { - (|| $block)() - }; - (async $block:block) => { - (async || $block)() - }; - (async move $block:block) => { - (async move || $block)() - }; -} - pub trait RangeExt { fn sorted(&self) -> Self; fn to_inclusive(&self) -> RangeInclusive; @@ -1022,10 +755,6 @@ pub fn default() -> D { Default::default() } -pub use self::shell::{ - get_default_system_shell, get_default_system_shell_preferring_bash, get_system_shell, -}; - #[derive(Debug)] pub enum ConnectionResult { Timeout, @@ -1049,15 +778,6 @@ impl From> for ConnectionResult { } } -#[track_caller] -pub fn some_or_debug_panic(option: Option) -> Option { - #[cfg(debug_assertions)] - if option.is_none() { - panic!("Unexpected None"); - } - option -} - /// Normalizes a path by resolving `.` and `..` components without /// requiring the path to exist on disk (unlike `canonicalize`). pub fn normalize_path(path: &Path) -> PathBuf { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c326c1bbf5acac632dd5bc8ea2e40eb7bc1a6703..82b730ee8f1b50f6f46a7400be908a9442e115d1 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -118,11 +118,7 @@ git_hosting_providers.workspace = true git_ui.workspace = true go_to_line.workspace = true system_specs.workspace = true -gpui = { workspace = true, features = [ - "wayland", - "windows-manifest", - "x11", -] } +gpui.workspace = true gpui_platform = {workspace = true, features=["screen-capture", "font-kit", "wayland", "x11"]} image = { workspace = true, optional = true } semver = { workspace = true, optional = true } @@ -232,11 +228,18 @@ zlog_settings.workspace = true [target.'cfg(target_os = "windows")'.dependencies] etw_tracing.workspace = true windows.workspace = true +gpui = { workspace = true, features = [ + "windows-manifest", +] } [target.'cfg(target_os = "windows")'.build-dependencies] winresource = "0.1" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +gpui = { workspace = true, features = [ + "wayland", + "x11", +] } ashpd.workspace = true [dev-dependencies] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d12da67e318e37e9aff2d7eda14e1782ad6360c5..89b3c648ca2a8a9b893d1b0924697f8170047761 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -4,5 +4,6 @@ profile = "minimal" components = [ "rustfmt", "clippy", "rust-analyzer", "rust-src" ] targets = [ "wasm32-wasip2", # extensions + "wasm32-unknown-unknown", # gpui on the web "x86_64-unknown-linux-musl", # remote server ] diff --git a/tooling/xtask/src/main.rs b/tooling/xtask/src/main.rs index 6f83927d6730cb2f846d001a9bbbdd010589d998..8246b98772184276ecabc685a9b4d2e7c5346edf 100644 --- a/tooling/xtask/src/main.rs +++ b/tooling/xtask/src/main.rs @@ -20,6 +20,8 @@ enum CliCommand { 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), } @@ -33,6 +35,7 @@ fn main() -> Result<()> { 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), } } diff --git a/tooling/xtask/src/tasks.rs b/tooling/xtask/src/tasks.rs index 01b3907f0486854b1bd18a5a3d21930b16670bd4..4701b56d8dd201ad5b5f28764976b0c5397f3a3e 100644 --- a/tooling/xtask/src/tasks.rs +++ b/tooling/xtask/src/tasks.rs @@ -2,4 +2,5 @@ pub mod clippy; pub mod licenses; pub mod package_conformity; pub mod publish_gpui; +pub mod web_examples; pub mod workflows; diff --git a/tooling/xtask/src/tasks/web_examples.rs b/tooling/xtask/src/tasks/web_examples.rs new file mode 100644 index 0000000000000000000000000000000000000000..93179c92ca9a021838d48ae6a976f3c2a434f6a2 --- /dev/null +++ b/tooling/xtask/src/tasks/web_examples.rs @@ -0,0 +1,334 @@ +#![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", + ]); + 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, + ]) + .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/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index c234f46f3dd2edc4bd861d7df46f966a1e623708..8b633edab6d81ad71c31e25c5171af076402fa9d 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -50,6 +50,7 @@ pub(crate) fn run_tests() -> Workflow { 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()), @@ -335,6 +336,38 @@ fn check_dependencies() -> NamedJob { ) } +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( release_job(&[])