GPUI on the web (#50228)

Lukas Wirth and John Tur created

Implements a basic web platform for the wasm32-unknown-unknown target
for gpui

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: John Tur <john-tur@outlook.com>

Change summary

.github/workflows/run_tests.yml                        |  41 
Cargo.lock                                             | 111 +
Cargo.toml                                             |  31 
crates/gpui/Cargo.toml                                 |  34 
crates/gpui/build.rs                                   |  11 
crates/gpui/examples/animation.rs                      |  16 
crates/gpui/examples/data_table.rs                     |  16 
crates/gpui/examples/drag_drop.rs                      |  16 
crates/gpui/examples/focus_visible.rs                  |  16 
crates/gpui/examples/gif_viewer.rs                     |  18 
crates/gpui/examples/gradient.rs                       |  16 
crates/gpui/examples/grid_layout.rs                    |  16 
crates/gpui/examples/hello_world.rs                    |  16 
crates/gpui/examples/image/image.rs                    |  19 
crates/gpui/examples/image_gallery.rs                  |  19 
crates/gpui/examples/image_loading.rs                  |  18 
crates/gpui/examples/input.rs                          |  16 
crates/gpui/examples/layer_shell.rs                    |  16 
crates/gpui/examples/mouse_pressure.rs                 |  16 
crates/gpui/examples/on_window_close_quit.rs           |  16 
crates/gpui/examples/opacity.rs                        |  16 
crates/gpui/examples/ownership_post.rs                 |  16 
crates/gpui/examples/painting.rs                       |  16 
crates/gpui/examples/paths_bench.rs                    |  16 
crates/gpui/examples/pattern.rs                        |  16 
crates/gpui/examples/popover.rs                        |  16 
crates/gpui/examples/scrollable.rs                     |  16 
crates/gpui/examples/set_menus.rs                      |  16 
crates/gpui/examples/shadow.rs                         |  16 
crates/gpui/examples/svg/svg.rs                        |  16 
crates/gpui/examples/tab_stop.rs                       |  16 
crates/gpui/examples/testing.rs                        |  15 
crates/gpui/examples/text.rs                           |  16 
crates/gpui/examples/text_layout.rs                    |  16 
crates/gpui/examples/text_wrapper.rs                   |  16 
crates/gpui/examples/tree.rs                           |  15 
crates/gpui/examples/uniform_list.rs                   |  16 
crates/gpui/examples/window.rs                         |  16 
crates/gpui/examples/window_positioning.rs             |  16 
crates/gpui/examples/window_shadow.rs                  |  16 
crates/gpui/src/app.rs                                 |  16 
crates/gpui/src/app/async_context.rs                   |   6 
crates/gpui/src/app/context.rs                         |   4 
crates/gpui/src/app/entity_map.rs                      |   2 
crates/gpui/src/app/test_context.rs                    |  37 
crates/gpui/src/app/visual_test_context.rs             |   2 
crates/gpui/src/elements/animation.rs                  |   6 
crates/gpui/src/elements/div.rs                        |   2 
crates/gpui/src/elements/img.rs                        |  23 
crates/gpui/src/elements/svg.rs                        |   2 
crates/gpui/src/elements/text.rs                       |   2 
crates/gpui/src/executor.rs                            |  23 
crates/gpui/src/gpui.rs                                |   8 
crates/gpui/src/platform.rs                            |  11 
crates/gpui/src/platform/scap_screen_capture.rs        |   2 
crates/gpui/src/platform/test/dispatcher.rs            |   3 
crates/gpui/src/platform/test/window.rs                |   1 
crates/gpui/src/platform_scheduler.rs                  |  72 
crates/gpui/src/profiler.rs                            |   2 
crates/gpui/src/queue.rs                               |  93 +
crates/gpui/src/scene.rs                               |   4 
crates/gpui/src/shared_string.rs                       |   2 
crates/gpui/src/subscription.rs                        |   2 
crates/gpui/src/test.rs                                |   7 
crates/gpui/src/util.rs                                |   2 
crates/gpui/src/window.rs                              |   7 
crates/gpui_linux/Cargo.toml                           |  14 
crates/gpui_linux/src/linux/platform.rs                |   2 
crates/gpui_linux/src/linux/text_system.rs             | 539 ---------
crates/gpui_platform/Cargo.toml                        |   4 
crates/gpui_platform/src/gpui_platform.rs              |  16 
crates/gpui_util/Cargo.toml                            |  12 
crates/gpui_util/LICENSE-APACHE                        |   1 
crates/gpui_util/src/arc_cow.rs                        |   0 
crates/gpui_util/src/lib.rs                            | 292 +++++
crates/gpui_web/Cargo.toml                             |  61 +
crates/gpui_web/LICENSE-APACHE                         |   1 
crates/gpui_web/examples/hello_web/.cargo/config.toml  |  14 
crates/gpui_web/examples/hello_web/.gitignore          |   3 
crates/gpui_web/examples/hello_web/Cargo.toml          |  16 
crates/gpui_web/examples/hello_web/LICENSE-APACHE      |   1 
crates/gpui_web/examples/hello_web/index.html          |  31 
crates/gpui_web/examples/hello_web/main.rs             | 422 +++++++
crates/gpui_web/examples/hello_web/rust-toolchain.toml |   4 
crates/gpui_web/examples/hello_web/trunk.toml          |   7 
crates/gpui_web/src/dispatcher.rs                      | 333 +++++
crates/gpui_web/src/display.rs                         |  98 +
crates/gpui_web/src/events.rs                          | 615 ++++++++++
crates/gpui_web/src/gpui_web.rs                        |  16 
crates/gpui_web/src/keyboard.rs                        |  19 
crates/gpui_web/src/logging.rs                         |  37 
crates/gpui_web/src/platform.rs                        | 341 +++++
crates/gpui_web/src/window.rs                          | 689 ++++++++++++
crates/gpui_wgpu/Cargo.toml                            |  26 
crates/gpui_wgpu/src/cosmic_text_system.rs             | 645 +++++++++++
crates/gpui_wgpu/src/gpui_wgpu.rs                      |   3 
crates/gpui_wgpu/src/shaders.wgsl                      |  65 -
crates/gpui_wgpu/src/shaders_subpixel.wgsl             |  53 
crates/gpui_wgpu/src/wgpu_context.rs                   |  79 +
crates/gpui_wgpu/src/wgpu_renderer.rs                  |  62 
crates/remote_server/Cargo.toml                        |   3 
crates/scheduler/Cargo.toml                            |   1 
crates/scheduler/src/clock.rs                          |   4 
crates/scheduler/src/executor.rs                       |   4 
crates/scheduler/src/test_scheduler.rs                 |   6 
crates/util/Cargo.toml                                 |  13 
crates/util/src/archive.rs                             |   5 
crates/util/src/paths.rs                               |  28 
crates/util/src/test.rs                                |  13 
crates/util/src/test/git.rs                            |   0 
crates/util/src/util.rs                                | 314 -----
crates/zed/Cargo.toml                                  |  13 
rust-toolchain.toml                                    |   1 
tooling/xtask/src/main.rs                              |   3 
tooling/xtask/src/tasks.rs                             |   1 
tooling/xtask/src/tasks/web_examples.rs                | 334 +++++
tooling/xtask/src/tasks/workflows/run_tests.rs         |  33 
117 files changed, 5,303 insertions(+), 1,131 deletions(-)

Detailed changes

.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 }}"

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"

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"

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"

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");

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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};

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();
+}

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();
+}

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();
+}

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<Change> for Counter {}
 
-fn main() {
+fn run_example() {
     application().run(|cx: &mut App| {
         let counter: Entity<Counter> = cx.new(|_cx| Counter { count: 0 });
         let subscriber = cx.new(|cx: &mut Context<Counter>| {
@@ -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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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,

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();
+}

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();
+}

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();
+}

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::*;

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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();
+}

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<Pixels>) -> 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();
+}

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<Pixels>, shadow_size: Pixels, size: Size<Pixels>) -> 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();
+}

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<dyn HttpClient>) -> 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<dyn Any>>,
     asset_source: Arc<dyn AssetSource>,
     pub(crate) svg_renderer: SvgRenderer,
+    #[cfg(not(target_family = "wasm"))]
     http_client: Arc<dyn HttpClient>,
     pub(crate) globals_by_type: FxHashMap<TypeId, Box<dyn Any>>,
     pub(crate) entities: EntityMap,
@@ -637,7 +642,7 @@ impl App {
     pub(crate) fn new_app(
         platform: Rc<dyn Platform>,
         asset_source: Arc<dyn AssetSource>,
-        http_client: Arc<dyn HttpClient>,
+        #[cfg(not(target_family = "wasm"))] http_client: Arc<dyn HttpClient>,
     ) -> Rc<AppCell> {
         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<dyn HttpClient> {
         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<dyn HttpClient>) {
         self.http_client = new_client;
     }
@@ -2504,8 +2512,10 @@ pub struct KeystrokeEvent {
     pub context_stack: Vec<KeyContext>,
 }
 
+#[cfg(not(target_family = "wasm"))]
 struct NullHttpClient;
 
+#[cfg(not(target_family = "wasm"))]
 impl HttpClient for NullHttpClient {
     fn send(
         &self,

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<T>,
         f: Callback,
-    ) -> util::Deferred<impl FnOnce() + use<T, Callback>> {
+    ) -> gpui_util::Deferred<impl FnOnce() + use<T, Callback>> {
         let entity = entity.clone();
         let mut cx = self.clone();
-        util::defer(move || {
+        gpui_util::defer(move || {
             entity.update(&mut cx, f).ok();
         })
     }

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<impl FnOnce()> {
         let this = self.weak_entity();
         let mut cx = self.to_async();
-        util::defer(move || {
+        gpui_util::defer(move || {
             this.update(&mut cx, f).ok();
         })
     }

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(

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.

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();

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,

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;
 

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<SharedUri> for ImageSource {
@@ -593,6 +594,7 @@ impl Asset for ImageAssetLoader {
         source: Self::Source,
         cx: &mut App,
     ) -> impl Future<Output = Self::Output> + 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<std::io::Error>),
     /// 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.

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 {

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;

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)
     }
 

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::*;
 

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<RunnableMeta>;
 
 #[doc(hidden)]
-pub type TimerResolutionGuard = util::Deferred<Box<dyn FnOnce() + Send>>;
+pub type TimerResolutionGuard = gpui_util::Deferred<Box<dyn FnOnce() + Send>>;
 
 /// 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<RenderImageParams> 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<Pixels>,
 

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

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.

crates/gpui/src/platform/test/window.rs 🔗

@@ -19,6 +19,7 @@ pub(crate) struct TestWindowState {
     pub(crate) title: Option<String>,
     pub(crate) edited: bool,
     platform: Weak<TestPlatform>,
+    // TODO: Replace with `Rc`
     sprite_atlas: Arc<dyn PlatformAtlas>,
     pub(crate) should_close_handler: Option<Box<dyn FnMut() -> bool>>,
     hit_test_window_control_callback: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,

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<SessionId>,
-        mut future: Pin<&mut dyn Future<Output = ()>>,
-        timeout: Option<Duration>,
+        #[cfg_attr(target_family = "wasm", allow(unused_mut))] mut future: Pin<
+            &mut dyn Future<Output = ()>,
+        >,
+        #[cfg_attr(target_family = "wasm", allow(unused_variables))] timeout: Option<Duration>,
     ) -> 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;
             }
         }
     }

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};

crates/gpui/src/queue.rs 🔗

@@ -41,6 +41,32 @@ impl<T> PriorityQueueState<T> {
         }
 
         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<T>> {
+        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<T>, 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<T> PriorityQueueState<T> {
             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<parking_lot::MutexGuard<'a, PriorityQueues<T>>, RecvError> {
@@ -84,6 +108,28 @@ impl<T> PriorityQueueState<T> {
             Ok(Some(queues))
         }
     }
+
+    fn spin_try_recv<'a>(
+        &'a self,
+    ) -> Result<Option<parking_lot::MutexGuard<'a, PriorityQueues<T>>>, 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<T> PriorityQueueSender<T> {
         self.state.send(priority, item)?;
         Ok(())
     }
+
+    pub fn spin_send(&self, priority: Priority, item: T) -> Result<(), SendError<T>> {
+        self.state.spin_send(priority, item)?;
+        Ok(())
+    }
 }
 
 impl<T> Drop for PriorityQueueSender<T> {
@@ -183,6 +234,44 @@ impl<T> PriorityQueueReceiver<T> {
         self.pop_inner(false)
     }
 
+    pub fn spin_try_pop(&mut self) -> Result<Option<T>, 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

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<usize>),
     Quads(Range<usize>),
@@ -711,7 +711,7 @@ impl From<PolychromeSprite> for Primitive {
 }
 
 #[derive(Clone, Debug)]
-#[expect(missing_docs)]
+#[allow(missing_docs)]
 pub struct PaintSurface {
     pub order: DrawOrder,
     pub bounds: Bounds<ScaledPixels>,

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<str>` and `&'static str`,

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<EmitterKey, Callback>(
     Rc<RefCell<SubscriberSetState<EmitterKey, Callback>>>,

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<T> {
-    rx: Pin<Box<channel::Receiver<T>>>,
+    rx: Pin<Box<async_channel::Receiver<T>>>,
     _subscription: Subscription,
 }
 
@@ -153,10 +152,10 @@ impl<T: 'static> futures::Stream for Observation<T> {
 
 /// observe returns a stream of the change events from the given `Entity`
 pub fn observe<T: 'static>(entity: &Entity<T>, 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);

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.

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;

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 }

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());
 

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<CosmicTextSystemState>);
-
-#[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<LoadedFont>,
-    /// 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<FontKey, SmallVec<[FontId; 4]>>,
-}
-
-struct LoadedFont {
-    font: Arc<CosmicTextFont>,
-    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<Cow<'static, [u8]>>) -> Result<()> {
-        self.0.write().add_fonts(fonts)
-    }
-
-    fn all_font_names(&self) -> Vec<String> {
-        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<FontId> {
-        // 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::<SmallVec<[_; 4]>>();
-
-        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<Bounds<f32>> {
-        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<Size<f32>> {
-        self.0.read().advance(font_id, glyph_id)
-    }
-
-    fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId> {
-        self.0.read().glyph_for_char(font_id, ch)
-    }
-
-    fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
-        self.0.write().raster_bounds(params)
-    }
-
-    fn rasterize_glyph(
-        &self,
-        params: &RenderGlyphParams,
-        raster_bounds: Bounds<DevicePixels>,
-    ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
-        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<Cow<'static, [u8]>>) -> 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<SmallVec<[FontId; 4]>> {
-        // 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::<SmallVec<[_; 4]>>();
-
-        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<Size<f32>> {
-        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<GlyphId> {
-        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<Bounds<DevicePixels>> {
-        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<DevicePixels>,
-    ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
-        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<swash::scale::image::Image> {
-        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<ShapedRun> = 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<CosmicFontFeatures> {
-    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;

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"

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<dyn Platform> {
     #[cfg(target_os = "macos")]
@@ -33,10 +41,16 @@ pub fn current_platform(headless: bool) -> Rc<dyn Platform> {
         )
     }
 
-    #[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"))]

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

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<T: From<u8> + AddAssign<T> + Copy>(value: &mut T) -> T {
+    let prev = *value;
+    *value += T::from(1);
+    prev
+}
+
+pub fn measure<R>(label: &str, f: impl FnOnce() -> R) -> R {
+    static ZED_MEASUREMENTS: OnceLock<bool> = 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<T>(option: Option<T>) -> Option<T> {
+    #[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<E> {
+    type Ok;
+
+    fn log_err(self) -> Option<Self::Ok>;
+    /// 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<Self::Ok>;
+    fn log_with_level(self, level: log::Level) -> Option<Self::Ok>;
+    fn anyhow(self) -> anyhow::Result<Self::Ok>
+    where
+        E: Into<anyhow::Error>;
+}
+
+impl<T, E> ResultExt<E> for Result<T, E>
+where
+    E: std::fmt::Debug,
+{
+    type Ok = T;
+
+    #[track_caller]
+    fn log_err(self) -> Option<T> {
+        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<T> {
+        self.log_with_level(log::Level::Warn)
+    }
+
+    #[track_caller]
+    fn log_with_level(self, level: log::Level) -> Option<T> {
+        match self {
+            Ok(value) => Some(value),
+            Err(error) => {
+                log_error_with_caller(*Location::caller(), error, level);
+                None
+            }
+        }
+    }
+
+    fn anyhow(self) -> anyhow::Result<T>
+    where
+        E: Into<anyhow::Error>,
+    {
+        self.map_err(Into::into)
+    }
+}
+
+fn log_error_with_caller<E>(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<E: std::fmt::Debug>(error: &E) {
+    log_error_with_caller(*Location::caller(), error, log::Level::Error);
+}
+
+pub trait TryFutureExt {
+    fn log_err(self) -> LogErrorFuture<Self>
+    where
+        Self: Sized;
+
+    fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture<Self>
+    where
+        Self: Sized;
+
+    fn warn_on_err(self) -> LogErrorFuture<Self>
+    where
+        Self: Sized;
+    fn unwrap(self) -> UnwrapFuture<Self>
+    where
+        Self: Sized;
+}
+
+impl<F, T, E> TryFutureExt for F
+where
+    F: Future<Output = Result<T, E>>,
+    E: std::fmt::Debug,
+{
+    #[track_caller]
+    fn log_err(self) -> LogErrorFuture<Self>
+    where
+        Self: Sized,
+    {
+        let location = Location::caller();
+        LogErrorFuture(self, log::Level::Error, *location)
+    }
+
+    fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture<Self>
+    where
+        Self: Sized,
+    {
+        LogErrorFuture(self, log::Level::Error, location)
+    }
+
+    #[track_caller]
+    fn warn_on_err(self) -> LogErrorFuture<Self>
+    where
+        Self: Sized,
+    {
+        let location = Location::caller();
+        LogErrorFuture(self, log::Level::Warn, *location)
+    }
+
+    fn unwrap(self) -> UnwrapFuture<Self>
+    where
+        Self: Sized,
+    {
+        UnwrapFuture(self)
+    }
+}
+
+#[must_use]
+pub struct LogErrorFuture<F>(F, log::Level, core::panic::Location<'static>);
+
+impl<F, T, E> Future for LogErrorFuture<F>
+where
+    F: Future<Output = Result<T, E>>,
+    E: std::fmt::Debug,
+{
+    type Output = Option<T>;
+
+    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
+        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>(F);
+
+impl<F, T, E> Future for UnwrapFuture<F>
+where
+    F: Future<Output = Result<T, E>>,
+    E: std::fmt::Debug,
+{
+    type Output = T;
+
+    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
+        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<F: FnOnce()>(Option<F>);
+
+impl<F: FnOnce()> Deferred<F> {
+    /// Drop without running the deferred function.
+    pub fn abort(mut self) {
+        self.0.take();
+    }
+}
+
+impl<F: FnOnce()> Drop for Deferred<F> {
+    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: FnOnce()>(f: F) -> Deferred<F> {
+    Deferred(Some(f))
+}

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",
+] }

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"]

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"

crates/gpui_web/examples/hello_web/index.html 🔗

@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8" />
+        <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, user-scalable=0" />
+        <title>GPUI Web: hello_web</title>
+        <link data-trunk rel="rust" data-bin="hello_web" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
+        <style>
+            * {
+                margin: 0;
+                padding: 0;
+                box-sizing: border-box;
+            }
+            html,
+            body {
+                margin: 0;
+                height: 100%;
+            }
+            canvas {
+                display: block;
+                width: 100%;
+                height: 100%;
+                touch-action: none;
+                outline: none;
+                -webkit-user-select: none;
+                user-select: none;
+            }
+        </style>
+    </head>
+    <body></body>
+</html>

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<ChunkResult>,
+    total: Option<u64>,
+    elapsed: Option<f64>,
+}
+
+struct HelloWeb {
+    selected_preset: Preset,
+    current_run: Option<Run>,
+    history: Vec<SharedString>,
+    _tasks: Vec<Task<()>>,
+}
+
+impl HelloWeb {
+    fn new(_cx: &mut Context<Self>) -> Self {
+        Self {
+            selected_preset: Preset::TenMillion,
+            current_run: None,
+            history: Vec::new(),
+            _tasks: Vec::new(),
+        }
+    }
+
+    fn start_search(&mut self, cx: &mut Context<Self>) {
+        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<Self>) -> 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);
+    });
+}

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" }

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::<js_sys::SharedArrayBuffer>();

+    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<dyn FnOnce() + Send>),

+}

+

+struct MainThreadMailbox {

+    sender: PriorityQueueSender<MainThreadItem>,

+    receiver: parking_lot::Mutex<PriorityQueueReceiver<MainThreadItem>>,

+    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<Self>, 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<RunnableVariant>,

+    main_thread_mailbox: Arc<MainThreadMailbox>,

+    supports_threads: bool,

+    _background_threads: Vec<wasm_thread::JoinHandle<()>>,

+}

+

+// 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::<Vec<_>>()

+        } 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<ThreadTaskTimings> {

+        // 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<dyn FnOnce() + Send>) {

+        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();

+        }

+    }

+}

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<Pixels> {

+        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<Pixels> {

+        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<uuid::Uuid> {

+        Ok(self.uuid)

+    }

+

+    fn bounds(&self) -> Bounds<Pixels> {

+        let size = self.screen_size();

+        Bounds {

+            origin: Point::default(),

+            size,

+        }

+    }

+

+    fn visible_bounds(&self) -> Bounds<Pixels> {

+        let size = self.viewport_size();

+        Bounds {

+            origin: Point::default(),

+            size,

+        }

+    }

+

+    fn default_bounds(&self) -> Bounds<Pixels> {

+        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 },

+        }

+    }

+}

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<Closure<dyn FnMut(JsValue)>>,

+}

+

+pub(crate) struct ClickState {

+    last_position: Point<Pixels>,

+    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<Pixels>, 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<Self>) -> 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<Self>,

+        event_name: &str,

+        handler: impl FnMut(JsValue) + 'static,

+    ) -> Closure<dyn FnMut(JsValue)> {

+        let closure = Closure::<dyn FnMut(JsValue)>::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<Self>,

+        event_name: &str,

+        handler: impl FnMut(JsValue) + 'static,

+    ) -> Closure<dyn FnMut(JsValue)> {

+        let closure = Closure::<dyn FnMut(JsValue)>::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::<js_sys::Function>() {

+                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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        self.listen("contextmenu", move |event: JsValue| {

+            let event: web_sys::Event = event.unchecked_into();

+            event.prevent_default();

+        })

+    }

+

+    fn register_dragover(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        let this = Rc::clone(self);

+        self.listen("dragleave", move |_event: JsValue| {

+            this.dispatch_input(PlatformInput::FileDrop(FileDropEvent::Exited));

+        })

+    }

+

+    fn register_key_down(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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::<u8>() {

+                    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<String> {

+    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<Pixels> {

+    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<Pixels> {

+    // 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

+}

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;

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"

+    }

+}

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);

+}

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<dyn PlatformTextSystem>,

+    active_window: RefCell<Option<AnyWindowHandle>>,

+    active_display: Rc<dyn PlatformDisplay>,

+    callbacks: RefCell<WebPlatformCallbacks>,

+    wgpu_context: Rc<RefCell<Option<WgpuContext>>>,

+}

+

+#[derive(Default)]

+struct WebPlatformCallbacks {

+    open_urls: Option<Box<dyn FnMut(Vec<String>)>>,

+    quit: Option<Box<dyn FnMut()>>,

+    reopen: Option<Box<dyn FnMut()>>,

+    app_menu_action: Option<Box<dyn FnMut(&dyn Action)>>,

+    will_open_app_menu: Option<Box<dyn FnMut()>>,

+    validate_app_menu_command: Option<Box<dyn FnMut(&dyn Action) -> bool>>,

+    keyboard_layout_change: Option<Box<dyn FnMut()>>,

+    thermal_state_change: Option<Box<dyn FnMut()>>,

+}

+

+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<dyn PlatformTextSystem> = text_system;

+        let active_display: Rc<dyn PlatformDisplay> =

+            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<dyn PlatformTextSystem> {

+        self.text_system.clone()

+    }

+

+    fn run(&self, on_finish_launching: Box<dyn 'static + FnOnce()>) {

+        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<PathBuf>) {}

+

+    fn activate(&self, _ignoring_other_apps: bool) {}

+

+    fn hide(&self) {}

+

+    fn hide_other_apps(&self) {}

+

+    fn unhide_other_apps(&self) {}

+

+    fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {

+        vec![self.active_display.clone()]

+    }

+

+    fn primary_display(&self) -> Option<Rc<dyn PlatformDisplay>> {

+        Some(self.active_display.clone())

+    }

+

+    fn active_window(&self) -> Option<AnyWindowHandle> {

+        *self.active_window.borrow()

+    }

+

+    fn open_window(

+        &self,

+        handle: AnyWindowHandle,

+        params: WindowParams,

+    ) -> anyhow::Result<Box<dyn PlatformWindow>> {

+        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<dyn FnMut(Vec<String>)>) {

+        self.callbacks.borrow_mut().open_urls = Some(callback);

+    }

+

+    fn register_url_scheme(&self, _url: &str) -> Task<Result<()>> {

+        Task::ready(Ok(()))

+    }

+

+    fn prompt_for_paths(

+        &self,

+        _options: PathPromptOptions,

+    ) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>> {

+        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<Result<Option<PathBuf>>> {

+        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<dyn FnMut()>) {

+        self.callbacks.borrow_mut().quit = Some(callback);

+    }

+

+    fn on_reopen(&self, callback: Box<dyn FnMut()>) {

+        self.callbacks.borrow_mut().reopen = Some(callback);

+    }

+

+    fn set_menus(&self, _menus: Vec<Menu>, _keymap: &Keymap) {}

+

+    fn set_dock_menu(&self, _menu: Vec<MenuItem>, _keymap: &Keymap) {}

+

+    fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>) {

+        self.callbacks.borrow_mut().app_menu_action = Some(callback);

+    }

+

+    fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>) {

+        self.callbacks.borrow_mut().will_open_app_menu = Some(callback);

+    }

+

+    fn on_validate_app_menu_command(&self, callback: Box<dyn FnMut(&dyn Action) -> 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<dyn FnMut()>) {

+        self.callbacks.borrow_mut().thermal_state_change = Some(callback);

+    }

+

+    fn compositor_name(&self) -> &'static str {

+        "Web"

+    }

+

+    fn app_path(&self) -> Result<PathBuf> {

+        Err(anyhow::anyhow!("app_path is not available on the web"))

+    }

+

+    fn path_for_auxiliary_executable(&self, _name: &str) -> Result<PathBuf> {

+        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<ClipboardItem> {

+        None

+    }

+

+    fn write_to_clipboard(&self, _item: ClipboardItem) {}

+

+    fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {

+        Task::ready(Err(anyhow::anyhow!(

+            "credential storage is not available on the web"

+        )))

+    }

+

+    fn read_credentials(&self, _url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {

+        Task::ready(Ok(None))

+    }

+

+    fn delete_credentials(&self, _url: &str) -> Task<Result<()>> {

+        Task::ready(Err(anyhow::anyhow!(

+            "credential storage is not available on the web"

+        )))

+    }

+

+    fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {

+        Box::new(WebKeyboardLayout)

+    }

+

+    fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {

+        Rc::new(DummyKeyboardMapper)

+    }

+

+    fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {

+        self.callbacks.borrow_mut().keyboard_layout_change = Some(callback);

+    }

+}

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<Box<dyn FnMut(RequestFrameOptions)>>,

+    pub(crate) input: Option<Box<dyn FnMut(PlatformInput) -> DispatchEventResult>>,

+    pub(crate) active_status_change: Option<Box<dyn FnMut(bool)>>,

+    pub(crate) hover_status_change: Option<Box<dyn FnMut(bool)>>,

+    pub(crate) resize: Option<Box<dyn FnMut(Size<Pixels>, f32)>>,

+    pub(crate) moved: Option<Box<dyn FnMut()>>,

+    pub(crate) should_close: Option<Box<dyn FnMut() -> bool>>,

+    pub(crate) close: Option<Box<dyn FnOnce()>>,

+    pub(crate) appearance_changed: Option<Box<dyn FnMut()>>,

+    pub(crate) hit_test_window_control: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,

+}

+

+pub(crate) struct WebWindowMutableState {

+    pub(crate) renderer: WgpuRenderer,

+    pub(crate) bounds: Bounds<Pixels>,

+    pub(crate) scale_factor: f32,

+    pub(crate) max_texture_dimension: u32,

+    pub(crate) title: String,

+    pub(crate) input_handler: Option<PlatformInputHandler>,

+    pub(crate) is_fullscreen: bool,

+    pub(crate) is_active: bool,

+    pub(crate) is_hovered: bool,

+    pub(crate) mouse_position: Point<Pixels>,

+    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<WebWindowMutableState>,

+    pub(crate) callbacks: RefCell<WebWindowCallbacks>,

+    pub(crate) click_state: RefCell<ClickState>,

+    pub(crate) pressed_button: Cell<Option<MouseButton>>,

+    pub(crate) last_physical_size: Cell<(u32, u32)>,

+    pub(crate) notify_scale: Cell<bool>,

+    mql_handle: RefCell<Option<MqlHandle>>,

+}

+

+pub struct WebWindow {

+    inner: Rc<WebWindowInner>,

+    display: Rc<dyn PlatformDisplay>,

+    #[allow(dead_code)]

+    handle: AnyWindowHandle,

+    _raf_closure: Closure<dyn FnMut()>,

+    _resize_observer: Option<web_sys::ResizeObserver>,

+    _resize_observer_closure: Closure<dyn FnMut(js_sys::Array)>,

+    _event_listeners: WebEventListeners,

+}

+

+impl WebWindow {

+    pub fn new(

+        handle: AnyWindowHandle,

+        _params: WindowParams,

+        context: &WgpuContext,

+        browser_window: web_sys::Window,

+    ) -> anyhow::Result<Self> {

+        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<dyn PlatformDisplay> = 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<WebWindowInner>,

+    ) -> Closure<dyn FnMut(js_sys::Array)> {

+        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<Self>) -> Closure<dyn FnMut()> {

+        let raf_handle: Rc<RefCell<Option<js_sys::Function>>> = 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::<js_sys::Function>().clone();

+        *raf_handle.borrow_mut() = Some(js_func);

+

+        closure

+    }

+

+    fn schedule_raf(&self, closure: &Closure<dyn FnMut()>) {

+        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<Self>, 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::<dyn FnMut(JsValue)>::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<Self>,

+    ) -> Option<Closure<dyn FnMut(JsValue)>> {

+        let document = self.browser_window.document()?;

+        let this = Rc::clone(self);

+

+        let closure = Closure::<dyn FnMut(JsValue)>::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<Self>,

+    ) -> Option<Closure<dyn FnMut(JsValue)>> {

+        let mql = self

+            .browser_window

+            .match_media("(prefers-color-scheme: dark)")

+            .ok()??;

+

+        let this = Rc::clone(self);

+        let closure = Closure::<dyn FnMut(JsValue)>::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<dyn FnMut(JsValue)>,

+}

+

+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::<js_sys::Object>(),

+        &"devicePixelContentBoxSize".into(),

+    );

+    !descriptor.is_undefined()

+}

+

+impl raw_window_handle::HasWindowHandle for WebWindow {

+    fn window_handle(

+        &self,

+    ) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {

+        let canvas_ref: &JsValue = self.inner.canvas.as_ref();

+        let obj = std::ptr::NonNull::from(canvas_ref).cast::<std::ffi::c_void>();

+        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::DisplayHandle<'_>, raw_window_handle::HandleError> {

+        Ok(raw_window_handle::DisplayHandle::web())

+    }

+}

+

+impl PlatformWindow for WebWindow {

+    fn bounds(&self) -> Bounds<Pixels> {

+        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<Pixels> {

+        self.inner.state.borrow().bounds.size

+    }

+

+    fn resize(&mut self, size: Size<Pixels>) {

+        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<Rc<dyn PlatformDisplay>> {

+        Some(self.display.clone())

+    }

+

+    fn mouse_position(&self) -> Point<Pixels> {

+        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<PlatformInputHandler> {

+        self.inner.state.borrow_mut().input_handler.take()

+    }

+

+    fn prompt(

+        &self,

+        _level: PromptLevel,

+        _msg: &str,

+        _detail: Option<&str>,

+        _answers: &[PromptButton],

+    ) -> Option<futures::channel::oneshot::Receiver<usize>> {

+        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<dyn FnMut(RequestFrameOptions)>) {

+        self.inner.callbacks.borrow_mut().request_frame = Some(callback);

+    }

+

+    fn on_input(&self, callback: Box<dyn FnMut(PlatformInput) -> DispatchEventResult>) {

+        self.inner.callbacks.borrow_mut().input = Some(callback);

+    }

+

+    fn on_active_status_change(&self, callback: Box<dyn FnMut(bool)>) {

+        self.inner.callbacks.borrow_mut().active_status_change = Some(callback);

+    }

+

+    fn on_hover_status_change(&self, callback: Box<dyn FnMut(bool)>) {

+        self.inner.callbacks.borrow_mut().hover_status_change = Some(callback);

+    }

+

+    fn on_resize(&self, callback: Box<dyn FnMut(Size<Pixels>, f32)>) {

+        self.inner.callbacks.borrow_mut().resize = Some(callback);

+    }

+

+    fn on_moved(&self, callback: Box<dyn FnMut()>) {

+        self.inner.callbacks.borrow_mut().moved = Some(callback);

+    }

+

+    fn on_should_close(&self, callback: Box<dyn FnMut() -> bool>) {

+        self.inner.callbacks.borrow_mut().should_close = Some(callback);

+    }

+

+    fn on_close(&self, callback: Box<dyn FnOnce()>) {

+        self.inner.callbacks.borrow_mut().close = Some(callback);

+    }

+

+    fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {

+        self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback);

+    }

+

+    fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {

+        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<dyn PlatformAtlas> {

+        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<GpuSpecs> {

+        Some(self.inner.state.borrow().renderer.gpu_specs())

+    }

+

+    fn update_ime_position(&self, _bounds: Bounds<Pixels>) {}

+

+    fn request_decorations(&self, _decorations: WindowDecorations) {}

+

+    fn show_window_menu(&self, _position: Point<Pixels>) {}

+

+    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) {}

+}

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"

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<CosmicTextSystemState>);

+

+#[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<LoadedFont>,

+    /// 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<FontKey, SmallVec<[FontId; 4]>>,

+    system_font_fallback: String,

+}

+

+struct LoadedFont {

+    font: Arc<CosmicTextFont>,

+    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<Cow<'static, [u8]>>) -> Result<()> {

+        self.0.write().add_fonts(fonts)

+    }

+

+    fn all_font_names(&self) -> Vec<String> {

+        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<FontId> {

+        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<Bounds<f32>> {

+        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<Size<f32>> {

+        self.0.read().advance(font_id, glyph_id)

+    }

+

+    fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId> {

+        self.0.read().glyph_for_char(font_id, ch)

+    }

+

+    fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {

+        self.0.write().raster_bounds(params)

+    }

+

+    fn rasterize_glyph(

+        &self,

+        params: &RenderGlyphParams,

+        raster_bounds: Bounds<DevicePixels>,

+    ) -> Result<(Size<DevicePixels>, Vec<u8>)> {

+        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<Cow<'static, [u8]>>) -> 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<SmallVec<[FontId; 4]>> {

+        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::<SmallVec<[_; 4]>>();

+

+        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<Size<f32>> {

+        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<GlyphId> {

+        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<Bounds<DevicePixels>> {

+        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<DevicePixels>,

+    ) -> Result<(Size<DevicePixels>, Vec<u8>)> {

+        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<swash::scale::image::Image> {

+        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<FontId> {

+        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<ShapedRun> = 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<usize> {

+    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::<Result<SmallVec<[_; 4]>>>()?;

+

+    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<usize> {

+    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<CosmicFontFeatures> {

+    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"

+}

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::*;

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<f32>, 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<f32>,
     @location(0) st_position: vec2<f32>,
-    @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<f32>,
 }
@@ -1072,14 +1071,14 @@ fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4<f
         let distance = f / length(gradient);
         alpha = saturate(0.5 - distance);
     }
-    let gradient_color = prepare_gradient_color(
+    let prepared_gradient = prepare_gradient_color(
         background.tag,
         background.color_space,
         background.solid,
         background.colors,
     );
     let color = gradient_color(background, input.position.xy, bounds,
-        gradient_color.solid, gradient_color.color0, gradient_color.color1);
+        prepared_gradient.solid, prepared_gradient.color0, prepared_gradient.color1);
     return vec4<f32>(color.rgb * color.a * alpha, color.a * alpha);
 }
 
@@ -1334,57 +1333,3 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4<f32> {
 
     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<storage, read> b_subpixel_sprites: array<SubpixelSprite>;
-
-struct SubpixelSpriteOutput {
-    @builtin(position) position: vec4<f32>,
-    @location(0) tile_position: vec2<f32>,
-    @location(1) @interpolate(flat) color: vec4<f32>,
-    @location(3) clip_distances: vec4<f32>,
-}
-
-struct SubpixelSpriteFragmentOutput {
-    @location(0) @blend_src(0) foreground: vec4<f32>,
-    @location(0) @blend_src(1) alpha: vec4<f32>,
-}
-
-@vertex
-fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> SubpixelSpriteOutput {
-    let unit_vertex = vec2<f32>(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<f32>(0.0))) {
-        return SubpixelSpriteFragmentOutput(vec4<f32>(0.0), vec4<f32>(0.0));
-    }
-
-    var out = SubpixelSpriteFragmentOutput();
-    out.foreground = vec4<f32>(input.color.rgb, 1.0);
-    out.alpha = vec4<f32>(input.color.a * alpha_corrected, 1.0);
-    return out;
-}

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<storage, read> b_subpixel_sprites: array<SubpixelSprite>;

+

+struct SubpixelSpriteOutput {

+    @builtin(position) position: vec4<f32>,

+    @location(0) tile_position: vec2<f32>,

+    @location(1) @interpolate(flat) color: vec4<f32>,

+    @location(3) clip_distances: vec4<f32>,

+}

+

+struct SubpixelSpriteFragmentOutput {

+    @location(0) @blend_src(0) foreground: vec4<f32>,

+    @location(0) @blend_src(1) alpha: vec4<f32>,

+}

+

+@vertex

+fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> SubpixelSpriteOutput {

+    let unit_vertex = vec2<f32>(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<f32>(0.0))) {

+        return SubpixelSpriteFragmentOutput(vec4<f32>(0.0), vec4<f32>(0.0));

+    }

+

+    var out = SubpixelSpriteFragmentOutput();

+    out.foreground = vec4<f32>(input.color.rgb, 1.0);

+    out.alpha = vec4<f32>(input.color.a * alpha_corrected, 1.0);

+    return out;

+}

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<Self> {
         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<Self> {
+        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<Self> {
+        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<u32>,
@@ -182,6 +254,7 @@ impl WgpuContext {
     }
 }
 
+#[cfg(not(target_family = "wasm"))]
 fn parse_pci_id(id: &str) -> anyhow::Result<u32> {
     let mut id = id.trim();
 

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<W: HasWindowHandle + HasDisplayHandle>(
         gpu_context: &mut Option<WgpuContext>,
         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<Self> {
+        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<Self> {
         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<wgpu::ColorTargetState>],
-                               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,

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

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

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<Utc>;

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)]

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";

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"

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<R: AsyncRead + Unpin>(destination: &Path, reader: R) ->
     Ok(())
 }
 
-#[cfg(not(windows))]
+#[cfg(unix)]
 pub async fn extract_zip<R: AsyncRead + Unpin>(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<R: AsyncRead + Unpin>(destination: &Path, reader: R) ->
     extract_seekable_zip(destination, file).await
 }
 
-#[cfg(not(windows))]
+#[cfg(unix)]
 pub async fn extract_seekable_zip<R: AsyncRead + AsyncSeek + Unpin>(
     destination: &Path,
     reader: R,

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<PathBuf> = OnceLock::new();
 
 /// Returns the path to the user's home directory.
 pub fn home_dir() -> &'static PathBuf {
+    static HOME_DIR: std::sync::OnceLock<PathBuf> = 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<String>;
 
     /// Try to make a shell-safe representation of the path.
-    fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result<String>;
+    #[cfg(not(target_family = "wasm"))]
+    fn try_shell_safe(&self, shell_kind: crate::shell::ShellKind) -> anyhow::Result<String>;
 }
 
 impl<T: AsRef<Path>> 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<T: AsRef<Path>> PathExt for T {
         Some(parts.into_iter().join("."))
     }
 
-    fn try_shell_safe(&self, shell_kind: ShellKind) -> anyhow::Result<String> {
+    #[cfg(not(target_family = "wasm"))]
+    fn try_shell_safe(&self, shell_kind: crate::shell::ShellKind) -> anyhow::Result<String> {
+        use anyhow::Context;
         let path_str = self
             .as_ref()
             .to_str()

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();
                     }

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<T: From<u8> + AddAssign<T> + 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<String> {
+    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<S
 /// Returns a path for the zed cli executable, this function
 /// should be called from the zed executable, not zed-cli.
 pub fn get_zed_cli_path() -> Result<PathBuf> {
+    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<PathBuf> {
 
 #[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<R>(label: &str, f: impl FnOnce() -> R) -> R {
-    static ZED_MEASUREMENTS: OnceLock<bool> = 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<usize>,
     additional_before: usize,
@@ -570,222 +536,6 @@ pub fn wrapped_usize_outward_from(
     })
 }
 
-pub trait ResultExt<E> {
-    type Ok;
-
-    fn log_err(self) -> Option<Self::Ok>;
-    /// 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<Self::Ok>;
-    fn log_with_level(self, level: log::Level) -> Option<Self::Ok>;
-    fn anyhow(self) -> anyhow::Result<Self::Ok>
-    where
-        E: Into<anyhow::Error>;
-}
-
-impl<T, E> ResultExt<E> for Result<T, E>
-where
-    E: std::fmt::Debug,
-{
-    type Ok = T;
-
-    #[track_caller]
-    fn log_err(self) -> Option<T> {
-        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<T> {
-        self.log_with_level(log::Level::Warn)
-    }
-
-    #[track_caller]
-    fn log_with_level(self, level: log::Level) -> Option<T> {
-        match self {
-            Ok(value) => Some(value),
-            Err(error) => {
-                log_error_with_caller(*Location::caller(), error, level);
-                None
-            }
-        }
-    }
-
-    fn anyhow(self) -> anyhow::Result<T>
-    where
-        E: Into<anyhow::Error>,
-    {
-        self.map_err(Into::into)
-    }
-}
-
-fn log_error_with_caller<E>(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<E: std::fmt::Debug>(error: &E) {
-    log_error_with_caller(*Location::caller(), error, log::Level::Error);
-}
-
-pub trait TryFutureExt {
-    fn log_err(self) -> LogErrorFuture<Self>
-    where
-        Self: Sized;
-
-    fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture<Self>
-    where
-        Self: Sized;
-
-    fn warn_on_err(self) -> LogErrorFuture<Self>
-    where
-        Self: Sized;
-    fn unwrap(self) -> UnwrapFuture<Self>
-    where
-        Self: Sized;
-}
-
-impl<F, T, E> TryFutureExt for F
-where
-    F: Future<Output = Result<T, E>>,
-    E: std::fmt::Debug,
-{
-    #[track_caller]
-    fn log_err(self) -> LogErrorFuture<Self>
-    where
-        Self: Sized,
-    {
-        let location = Location::caller();
-        LogErrorFuture(self, log::Level::Error, *location)
-    }
-
-    fn log_tracked_err(self, location: core::panic::Location<'static>) -> LogErrorFuture<Self>
-    where
-        Self: Sized,
-    {
-        LogErrorFuture(self, log::Level::Error, location)
-    }
-
-    #[track_caller]
-    fn warn_on_err(self) -> LogErrorFuture<Self>
-    where
-        Self: Sized,
-    {
-        let location = Location::caller();
-        LogErrorFuture(self, log::Level::Warn, *location)
-    }
-
-    fn unwrap(self) -> UnwrapFuture<Self>
-    where
-        Self: Sized,
-    {
-        UnwrapFuture(self)
-    }
-}
-
-#[must_use]
-pub struct LogErrorFuture<F>(F, log::Level, core::panic::Location<'static>);
-
-impl<F, T, E> Future for LogErrorFuture<F>
-where
-    F: Future<Output = Result<T, E>>,
-    E: std::fmt::Debug,
-{
-    type Output = Option<T>;
-
-    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
-        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>(F);
-
-impl<F, T, E> Future for UnwrapFuture<F>
-where
-    F: Future<Output = Result<T, E>>,
-    E: std::fmt::Debug,
-{
-    type Output = T;
-
-    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
-        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<F: FnOnce()>(Option<F>);
-
-impl<F: FnOnce()> Deferred<F> {
-    /// Drop without running the deferred function.
-    pub fn abort(mut self) {
-        self.0.take();
-    }
-}
-
-impl<F: FnOnce()> Drop for Deferred<F> {
-    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: FnOnce()>(f: F) -> Deferred<F> {
-    Deferred(Some(f))
-}
-
 #[cfg(any(test, feature = "test-support"))]
 mod rng {
     use rand::prelude::*;
@@ -849,23 +599,6 @@ pub fn asset_str<A: rust_embed::RustEmbed>(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<T> {
     fn sorted(&self) -> Self;
     fn to_inclusive(&self) -> RangeInclusive<T>;
@@ -1022,10 +755,6 @@ pub fn default<D: 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<O> {
     Timeout,
@@ -1049,15 +778,6 @@ impl<O> From<anyhow::Result<O>> for ConnectionResult<O> {
     }
 }
 
-#[track_caller]
-pub fn some_or_debug_panic<T>(option: Option<T>) -> Option<T> {
-    #[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 {

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]

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
 ]

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),
     }
 }

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;

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<Vec<String>> {
+    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<String> = Vec::new();
+    let mut failed: Vec<String> = 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#"<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>GPUI Web: {name}</title>
+    <style>
+        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
+        html, body {{
+            width: 100%; height: 100%; overflow: hidden;
+            background: #1e1e2e; color: #cdd6f4;
+            font-family: system-ui, -apple-system, sans-serif;
+        }}
+        canvas {{ display: block; width: 100%; height: 100%; }}
+        #loading {{
+            position: fixed; inset: 0;
+            display: flex; align-items: center; justify-content: center;
+            font-size: 1.25rem; opacity: 0.6;
+        }}
+        #loading.hidden {{ display: none; }}
+    </style>
+</head>
+<body>
+    <div id="loading">Loading {name}…</div>
+    <script type="module">
+        import init from './{name}.js';
+        await init();
+        document.getElementById('loading').classList.add('hidden');
+    </script>
+</body>
+</html>
+"#
+    )
+}
+
+fn make_gallery_html(examples: &[&str]) -> String {
+    let mut buttons = String::new();
+    for name in examples {
+        buttons.push_str(&format!(
+            "                <button class=\"example-btn\" data-name=\"{name}\">{name}</button>\n"
+        ));
+    }
+
+    let first = examples.first().copied().unwrap_or("hello_web");
+
+    format!(
+        r##"<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>GPUI Web Examples</title>
+    <style>
+        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
+        html, body {{
+            width: 100%; height: 100%; overflow: hidden;
+            background: #1e1e2e; color: #cdd6f4;
+            font-family: system-ui, -apple-system, sans-serif;
+        }}
+        #app {{ display: flex; width: 100%; height: 100%; }}
+
+        #sidebar {{
+            width: 240px; min-width: 240px;
+            background: #181825;
+            border-right: 1px solid #313244;
+            display: flex; flex-direction: column;
+        }}
+        #sidebar-header {{
+            padding: 16px 14px 12px;
+            font-size: 0.8rem; font-weight: 700;
+            text-transform: uppercase; letter-spacing: 0.08em;
+            color: #a6adc8; border-bottom: 1px solid #313244;
+        }}
+        #sidebar-header span {{
+            font-size: 1rem; text-transform: none; letter-spacing: normal;
+            color: #cdd6f4; display: block; margin-top: 2px;
+        }}
+        #example-list {{
+            flex: 1; overflow-y: auto; padding: 8px 0;
+        }}
+        .example-btn {{
+            display: block; width: 100%;
+            padding: 8px 14px; border: none;
+            background: transparent; color: #bac2de;
+            font-size: 0.85rem; text-align: left;
+            cursor: pointer;
+            font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+        }}
+        .example-btn:hover {{ background: #313244; color: #cdd6f4; }}
+        .example-btn.active {{ background: #45475a; color: #f5e0dc; font-weight: 600; }}
+
+        #main {{ flex: 1; display: flex; flex-direction: column; min-width: 0; }}
+        #toolbar {{
+            height: 40px; display: flex; align-items: center;
+            padding: 0 16px; gap: 12px;
+            background: #1e1e2e; border-bottom: 1px solid #313244;
+            font-size: 0.8rem; color: #a6adc8;
+        }}
+        #current-name {{
+            font-weight: 600; color: #cdd6f4;
+            font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+        }}
+        #open-tab {{
+            margin-left: auto; padding: 4px 10px;
+            border: 1px solid #585b70; border-radius: 4px;
+            background: transparent; color: #a6adc8;
+            font-size: 0.75rem; cursor: pointer;
+            text-decoration: none;
+        }}
+        #open-tab:hover {{ background: #313244; color: #cdd6f4; }}
+        #viewer {{ flex: 1; border: none; width: 100%; background: #11111b; }}
+    </style>
+</head>
+<body>
+    <div id="app">
+        <div id="sidebar">
+            <div id="sidebar-header">
+                GPUI Examples
+                <span>{count} available</span>
+            </div>
+            <div id="example-list">
+{buttons}            </div>
+        </div>
+        <div id="main">
+            <div id="toolbar">
+                <span id="current-name">{first}</span>
+                <a id="open-tab" href="./{first}/" target="_blank">Open in new tab ↗</a>
+            </div>
+            <iframe id="viewer" src="./{first}/"></iframe>
+        </div>
+    </div>
+    <script>
+        const buttons = document.querySelectorAll('.example-btn');
+        const viewer  = document.getElementById('viewer');
+        const nameEl  = document.getElementById('current-name');
+        const openEl  = document.getElementById('open-tab');
+
+        function select(name) {{
+            buttons.forEach(b => b.classList.toggle('active', b.dataset.name === name));
+            viewer.src = './' + name + '/';
+            nameEl.textContent = name;
+            openEl.href = './' + name + '/';
+            history.replaceState(null, '', '#' + name);
+        }}
+
+        buttons.forEach(b => b.addEventListener('click', () => select(b.dataset.name)));
+
+        const hash = location.hash.slice(1);
+        if (hash && [...buttons].some(b => b.dataset.name === hash)) {{
+            select(hash);
+        }} else {{
+            select('{first}');
+        }}
+    </script>
+</body>
+</html>
+"##,
+        count = examples.len(),
+    )
+}

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<Run> {
+        named::bash(
+            "rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown",
+        )
+    }
+
+    fn cargo_check_wasm() -> Step<Run> {
+        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(&[])