logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
[package]
name = "gpui_web"
version = "0.1.0"
publish.workspace = true
edition.workspace = true
license = "Apache-2.0"
autoexamples = false
[lints]
workspace = true
[features]
default = ["multithreaded"]
multithreaded = ["dep:wasm_thread"]
[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
http_client.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"], optional = true }
web-sys = { version = "0.3", features = [
"console",
"CompositionEvent",
"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",
"Headers",
"Request",
"RequestInit",
"RequestRedirect",
"Response",
"WheelEvent",
"Window",
] }

View File

@@ -0,0 +1 @@
../../LICENSE-APACHE

View File

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

View File

@@ -0,0 +1,3 @@
/dist
/target
Cargo.lock

View File

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

View File

@@ -0,0 +1 @@
../../../../LICENSE-APACHE

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly"
targets = ["wasm32-unknown-unknown"]
components = ["rust-src", "rustfmt", "clippy"]

View File

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

View File

@@ -0,0 +1,333 @@
use gpui::{
PlatformDispatcher, Priority, PriorityQueueReceiver, PriorityQueueSender, RunnableVariant,
ThreadTaskTimings,
};
use std::sync::Arc;
use std::sync::atomic::AtomicI32;
use std::time::Duration;
use wasm_bindgen::prelude::*;
use web_time::Instant;
#[cfg(feature = "multithreaded")]
const MIN_BACKGROUND_THREADS: usize = 2;
#[cfg(feature = "multithreaded")]
fn shared_memory_supported() -> bool {
let global = js_sys::global();
let has_shared_array_buffer =
js_sys::Reflect::has(&global, &JsValue::from_str("SharedArrayBuffer")).unwrap_or(false);
let has_atomics = js_sys::Reflect::has(&global, &JsValue::from_str("Atomics")).unwrap_or(false);
let memory = js_sys::WebAssembly::Memory::from(wasm_bindgen::memory());
let buffer = memory.buffer();
let is_shared_buffer = buffer.is_instance_of::<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,
#[cfg(feature = "multithreaded")]
_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, allow_threads: bool) -> Self {
#[cfg(feature = "multithreaded")]
let (background_sender, background_receiver) = PriorityQueueReceiver::new();
#[cfg(not(feature = "multithreaded"))]
let (background_sender, _) = PriorityQueueReceiver::new();
let main_thread_mailbox = Arc::new(MainThreadMailbox::new());
#[cfg(feature = "multithreaded")]
let supports_threads = allow_threads && shared_memory_supported();
#[cfg(not(feature = "multithreaded"))]
let supports_threads = false;
if supports_threads {
main_thread_mailbox.run_waker_loop(browser_window.clone());
} else {
log::warn!(
"SharedArrayBuffer not available; falling back to single-threaded dispatcher"
);
}
#[cfg(feature = "multithreaded")]
let background_threads = if supports_threads {
let thread_count = browser_window
.navigator()
.hardware_concurrency()
.max(MIN_BACKGROUND_THREADS as f64) as usize;
// TODO-Wasm: Is it bad to have web workers blocking for a long time like this?
(0..thread_count)
.map(|i| {
let mut receiver = background_receiver.clone();
wasm_thread::Builder::new()
.name(format!("background-worker-{i}"))
.spawn(move || {
loop {
let runnable: RunnableVariant = match receiver.pop() {
Ok(runnable) => runnable,
Err(_) => {
log::info!(
"background-worker-{i}: channel disconnected, exiting"
);
break;
}
};
runnable.run();
}
})
.expect("failed to spawn background worker thread")
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
Self {
main_thread_id: std::thread::current().id(),
browser_window,
background_sender,
main_thread_mailbox,
supports_threads,
#[cfg(feature = "multithreaded")]
_background_threads: background_threads,
}
}
fn on_main_thread(&self) -> bool {
std::thread::current().id() == self.main_thread_id
}
}
impl PlatformDispatcher for WebDispatcher {
fn get_all_timings(&self) -> Vec<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 || {
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) => {
runnable.run();
}
MainThreadItem::Delayed { runnable, millis } => {
let callback = Closure::once_into_js(move || {
runnable.run();
});
window
.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.unchecked_ref(),
millis,
)
.ok();
}
MainThreadItem::RealtimeFunction(function) => {
function();
}
}
}
fn schedule_runnable(window: &web_sys::Window, runnable: RunnableVariant, priority: Priority) {
let callback = Closure::once_into_js(move || {
runnable.run();
});
let callback: &js_sys::Function = callback.unchecked_ref();
match priority {
Priority::RealtimeAudio => {
window.queue_microtask(callback);
}
_ => {
// TODO-Wasm: this ought to enqueue so we can dequeue with proper priority
window
.set_timeout_with_callback_and_timeout_and_arguments_0(callback, 0)
.ok();
}
}
}

View File

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

View File

@@ -0,0 +1,682 @@
use std::rc::Rc;
use gpui::{
Capslock, DispatchEventResult, ExternalPaths, FileDropEvent, KeyDownEvent, KeyUpEvent,
Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent,
MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformInput, Point, ScrollDelta,
ScrollWheelEvent, TouchPhase, point, px,
};
use smallvec::smallvec;
use wasm_bindgen::prelude::*;
use crate::window::WebWindowInner;
pub struct WebEventListeners {
#[allow(dead_code)]
closures: Vec<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_composition_start(),
self.register_composition_update(),
self.register_composition_end(),
self.register_focus(),
self.register_blur(),
self.register_pointer_enter(),
self.register_pointer_leave_hover(),
];
closures.extend(self.register_visibility_change());
closures.extend(self.register_appearance_change());
WebEventListeners { closures }
}
fn listen(
self: &Rc<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
}
fn listen_input(
self: &Rc<Self>,
event_name: &str,
handler: impl FnMut(JsValue) + 'static,
) -> Closure<dyn FnMut(JsValue)> {
let closure = Closure::<dyn FnMut(JsValue)>::new(handler);
self.input_element
.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())
.ok();
closure
}
/// Registers a listener with `{passive: false}` so that `preventDefault()` works.
/// Needed for events like `wheel` which are passive by default in modern browsers.
fn listen_non_passive(
self: &Rc<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) -> Option<DispatchEventResult> {
let mut borrowed = self.callbacks.borrow_mut();
borrowed.input.as_mut().map(|callback| 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.input_element.focus().ok();
let button = dom_mouse_button_to_gpui(event.button());
let position = pointer_position_in_element(&event);
let modifiers = modifiers_from_mouse_event(&event, this.is_mac);
let time = js_sys::Date::now();
this.pressed_button.set(Some(button));
let click_count = this.click_state.borrow_mut().register_click(position, time);
{
let mut current_state = this.state.borrow_mut();
current_state.mouse_position = position;
current_state.modifiers = modifiers;
}
this.dispatch_input(PlatformInput::MouseDown(MouseDownEvent {
button,
position,
modifiers,
click_count,
first_mouse: false,
}));
})
}
fn register_pointer_up(self: &Rc<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_input("keydown", move |event: JsValue| {
let event: web_sys::KeyboardEvent = event.unchecked_into();
let modifiers = modifiers_from_keyboard_event(&event, this.is_mac);
let capslock = capslock_from_keyboard_event(&event);
{
let mut current_state = this.state.borrow_mut();
current_state.modifiers = modifiers;
current_state.capslock = capslock;
}
this.dispatch_input(PlatformInput::ModifiersChanged(ModifiersChangedEvent {
modifiers,
capslock,
}));
let key = dom_key_to_gpui_key(&event);
if is_modifier_only_key(&key) {
return;
}
event.prevent_default();
let is_held = event.repeat();
let key_char = compute_key_char(&event, &key, &modifiers);
let keystroke = Keystroke {
modifiers,
key,
key_char: key_char.clone(),
};
let result = this.dispatch_input(PlatformInput::KeyDown(KeyDownEvent {
keystroke,
is_held,
prefer_character_input: false,
}));
if let Some(result) = result {
if !result.propagate {
return;
}
}
if this.is_composing.get() || event.is_composing() {
return;
}
if modifiers.is_subset_of(&Modifiers::shift()) {
if let Some(text) = key_char {
this.with_input_handler(|handler| {
handler.replace_text_in_range(None, &text);
});
}
}
})
}
fn register_key_up(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("keyup", move |event: JsValue| {
let event: web_sys::KeyboardEvent = event.unchecked_into();
let modifiers = modifiers_from_keyboard_event(&event, this.is_mac);
let capslock = capslock_from_keyboard_event(&event);
{
let mut current_state = this.state.borrow_mut();
current_state.modifiers = modifiers;
current_state.capslock = capslock;
}
this.dispatch_input(PlatformInput::ModifiersChanged(ModifiersChangedEvent {
modifiers,
capslock,
}));
let key = dom_key_to_gpui_key(&event);
if is_modifier_only_key(&key) {
return;
}
event.prevent_default();
let key_char = compute_key_char(&event, &key, &modifiers);
let keystroke = Keystroke {
modifiers,
key,
key_char,
};
this.dispatch_input(PlatformInput::KeyUp(KeyUpEvent { keystroke }));
})
}
fn register_composition_start(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("compositionstart", move |_event: JsValue| {
this.is_composing.set(true);
})
}
fn register_composition_update(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("compositionupdate", move |event: JsValue| {
let event: web_sys::CompositionEvent = event.unchecked_into();
let data = event.data().unwrap_or_default();
this.is_composing.set(true);
this.with_input_handler(|handler| {
handler.replace_and_mark_text_in_range(None, &data, None);
});
})
}
fn register_composition_end(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("compositionend", move |event: JsValue| {
let event: web_sys::CompositionEvent = event.unchecked_into();
let data = event.data().unwrap_or_default();
this.is_composing.set(false);
this.with_input_handler(|handler| {
handler.replace_text_in_range(None, &data);
handler.unmark_text();
});
this.input_element.set_value("");
})
}
fn register_focus(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("focus", move |_event: JsValue| {
{
let mut state = this.state.borrow_mut();
state.is_active = true;
}
let mut callbacks = this.callbacks.borrow_mut();
if let Some(ref mut callback) = callbacks.active_status_change {
callback(true);
}
})
}
fn register_blur(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {
let this = Rc::clone(self);
self.listen_input("blur", move |_event: JsValue| {
{
let mut state = this.state.borrow_mut();
state.is_active = false;
}
let mut callbacks = this.callbacks.borrow_mut();
if let Some(ref mut callback) = callbacks.active_status_change {
callback(false);
}
})
}
fn register_pointer_enter(self: &Rc<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" | "compose" | "process"
)
}
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
}

View File

@@ -0,0 +1,18 @@
#![cfg(target_family = "wasm")]
mod dispatcher;
mod display;
mod events;
mod http_client;
mod keyboard;
mod logging;
mod platform;
mod window;
pub use dispatcher::WebDispatcher;
pub use display::WebDisplay;
pub use http_client::FetchHttpClient;
pub use keyboard::WebKeyboardLayout;
pub use logging::init_logging;
pub use platform::WebPlatform;
pub use window::WebWindow;

View File

@@ -0,0 +1,199 @@
use anyhow::anyhow;
use futures::AsyncReadExt as _;
use http_client::{AsyncBody, HttpClient, RedirectPolicy};
use std::future::Future;
use std::pin::Pin;
use std::task::Poll;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(catch, js_name = "fetch")]
fn global_fetch(input: &web_sys::Request) -> Result<js_sys::Promise, JsValue>;
}
pub struct FetchHttpClient {
user_agent: Option<http_client::http::header::HeaderValue>,
}
impl Default for FetchHttpClient {
fn default() -> Self {
Self { user_agent: None }
}
}
#[cfg(feature = "multithreaded")]
impl FetchHttpClient {
/// # Safety
///
/// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment.
pub unsafe fn new() -> Self {
Self::default()
}
/// # Safety
///
/// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment.
pub unsafe fn with_user_agent(user_agent: &str) -> anyhow::Result<Self> {
Ok(Self {
user_agent: Some(http_client::http::header::HeaderValue::from_str(
user_agent,
)?),
})
}
}
#[cfg(not(feature = "multithreaded"))]
impl FetchHttpClient {
pub fn new() -> Self {
Self::default()
}
pub fn with_user_agent(user_agent: &str) -> anyhow::Result<Self> {
Ok(Self {
user_agent: Some(http_client::http::header::HeaderValue::from_str(
user_agent,
)?),
})
}
}
/// Wraps a `!Send` future to satisfy the `Send` bound on `BoxFuture`.
///
/// Safety: only valid in WASM contexts where the `FetchHttpClient` is
/// confined to a single thread (guaranteed by the caller via unsafe
/// constructors when `multithreaded` is enabled, or by the absence of
/// threads when it is not).
struct AssertSend<F>(F);
unsafe impl<F> Send for AssertSend<F> {}
impl<F: Future> Future for AssertSend<F> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
// Safety: pin projection for a single-field newtype wrapper.
let inner = unsafe { self.map_unchecked_mut(|this| &mut this.0) };
inner.poll(cx)
}
}
impl HttpClient for FetchHttpClient {
fn user_agent(&self) -> Option<&http_client::http::header::HeaderValue> {
self.user_agent.as_ref()
}
fn proxy(&self) -> Option<&http_client::Url> {
None
}
fn send(
&self,
req: http_client::http::Request<AsyncBody>,
) -> futures::future::BoxFuture<'static, anyhow::Result<http_client::http::Response<AsyncBody>>>
{
let (parts, body) = req.into_parts();
Box::pin(AssertSend(async move {
let body_bytes = read_body_to_bytes(body).await?;
let init = web_sys::RequestInit::new();
init.set_method(parts.method.as_str());
if let Some(redirect_policy) = parts.extensions.get::<RedirectPolicy>() {
match redirect_policy {
RedirectPolicy::NoFollow => {
init.set_redirect(web_sys::RequestRedirect::Manual);
}
RedirectPolicy::FollowLimit(_) | RedirectPolicy::FollowAll => {
init.set_redirect(web_sys::RequestRedirect::Follow);
}
}
}
if let Some(ref bytes) = body_bytes {
let uint8array = js_sys::Uint8Array::from(bytes.as_slice());
init.set_body(uint8array.as_ref());
}
let url = parts.uri.to_string();
let request = web_sys::Request::new_with_str_and_init(&url, &init)
.map_err(|error| anyhow!("failed to create fetch Request: {error:?}"))?;
let request_headers = request.headers();
for (name, value) in &parts.headers {
let value_str = value
.to_str()
.map_err(|_| anyhow!("non-ASCII header value for {name}"))?;
request_headers
.set(name.as_str(), value_str)
.map_err(|error| anyhow!("failed to set header {name}: {error:?}"))?;
}
let promise = global_fetch(&request)
.map_err(|error| anyhow!("fetch threw an error: {error:?}"))?;
let response_value = wasm_bindgen_futures::JsFuture::from(promise)
.await
.map_err(|error| anyhow!("fetch failed: {error:?}"))?;
let web_response: web_sys::Response = response_value
.dyn_into()
.map_err(|error| anyhow!("fetch result is not a Response: {error:?}"))?;
let status = web_response.status();
let mut builder = http_client::http::Response::builder().status(status);
// `Headers` is a JS iterable yielding `[name, value]` pairs.
// `js_sys::Array::from` calls `Array.from()` which accepts any iterable.
let header_pairs = js_sys::Array::from(&web_response.headers());
for index in 0..header_pairs.length() {
match header_pairs.get(index).dyn_into::<js_sys::Array>() {
Ok(pair) => match (pair.get(0).as_string(), pair.get(1).as_string()) {
(Some(name), Some(value)) => {
builder = builder.header(name, value);
}
(name, value) => {
log::warn!(
"skipping response header at index {index}: \
name={name:?}, value={value:?}"
);
}
},
Err(entry) => {
log::warn!("skipping non-array header entry at index {index}: {entry:?}");
}
}
}
// The entire response body is eagerly buffered into memory via
// `arrayBuffer()`. The Fetch API does not expose a synchronous
// streaming interface; streaming would require `ReadableStream`
// interop which is significantly more complex.
let body_promise = web_response
.array_buffer()
.map_err(|error| anyhow!("failed to initiate response body read: {error:?}"))?;
let body_value = wasm_bindgen_futures::JsFuture::from(body_promise)
.await
.map_err(|error| anyhow!("failed to read response body: {error:?}"))?;
let array_buffer: js_sys::ArrayBuffer = body_value
.dyn_into()
.map_err(|error| anyhow!("response body is not an ArrayBuffer: {error:?}"))?;
let response_bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
builder
.body(AsyncBody::from(response_bytes))
.map_err(|error| anyhow!(error))
}))
}
}
async fn read_body_to_bytes(mut body: AsyncBody) -> anyhow::Result<Option<Vec<u8>>> {
let mut buffer = Vec::new();
body.read_to_end(&mut buffer).await?;
if buffer.is_empty() {
Ok(None)
} else {
Ok(Some(buffer))
}
}

View File

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

View File

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

View File

@@ -0,0 +1,435 @@
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::{Cell, RefCell},
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use wasm_bindgen::prelude::*;
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>>>,
cursor_visible: Rc<Cell<bool>>,
last_cursor_css: Rc<Cell<&'static str>>,
_cursor_restore_listeners: Vec<EventListenerHandle>,
}
#[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(allow_multi_threading: bool) -> Self {
let browser_window =
web_sys::window().expect("must be running in a browser window context");
let dispatcher = Arc::new(WebDispatcher::new(
browser_window.clone(),
allow_multi_threading,
));
let background_executor = BackgroundExecutor::new(dispatcher.clone());
let foreground_executor = ForegroundExecutor::new(dispatcher);
let text_system = Arc::new(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()));
let cursor_visible = Rc::new(Cell::new(true));
let last_cursor_css = Rc::new(Cell::new("default"));
let cursor_restore_listeners = cursor_restore_listeners(
&browser_window,
cursor_visible.clone(),
last_cursor_css.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)),
cursor_visible,
last_cursor_css,
_cursor_restore_listeners: cursor_restore_listeners,
}
}
}
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",
};
self.last_cursor_css.set(css_cursor);
if self.cursor_visible.get() {
set_body_cursor(&self.browser_window, css_cursor);
}
}
fn hide_cursor_until_mouse_moves(&self) {
if !self.cursor_visible.replace(false) {
return;
}
set_body_cursor(&self.browser_window, "none");
}
fn is_cursor_visible(&self) -> bool {
self.cursor_visible.get()
}
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);
}
}
struct EventListenerHandle {
target: web_sys::EventTarget,
event_name: &'static str,
closure: Closure<dyn FnMut(JsValue)>,
}
impl Drop for EventListenerHandle {
fn drop(&mut self) {
self.target
.remove_event_listener_with_callback(
self.event_name,
self.closure.as_ref().unchecked_ref(),
)
.ok();
}
}
fn cursor_restore_listeners(
browser_window: &web_sys::Window,
cursor_visible: Rc<Cell<bool>>,
last_cursor_css: Rc<Cell<&'static str>>,
) -> Vec<EventListenerHandle> {
let mut handles = Vec::new();
let Some(document) = browser_window.document() else {
return handles;
};
let make_restore_handler = |browser_window: web_sys::Window| {
let cursor_visible = cursor_visible.clone();
let last_cursor_css = last_cursor_css.clone();
Closure::<dyn FnMut(JsValue)>::new(move |_event: JsValue| {
if !cursor_visible.replace(true) {
set_body_cursor(&browser_window, last_cursor_css.get());
}
})
};
let mut add_listener = |target: &web_sys::EventTarget, event_name: &'static str| {
let closure = make_restore_handler(browser_window.clone());
target
.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())
.ok();
handles.push(EventListenerHandle {
target: target.clone(),
event_name,
closure,
});
};
let document_target: &web_sys::EventTarget = document.as_ref();
let window_target: &web_sys::EventTarget = browser_window.as_ref();
add_listener(document_target, "mousemove");
add_listener(document_target, "mouseenter");
add_listener(window_target, "blur");
add_listener(document_target, "visibilitychange");
handles
}
fn set_body_cursor(browser_window: &web_sys::Window, css_cursor: &str) {
if let Some(document) = browser_window.document()
&& let Some(body) = document.body()
&& let Err(error) = body.style().set_property("cursor", css_cursor)
{
log::warn!("Failed to set cursor style: {error:?}");
}
}

View File

@@ -0,0 +1,731 @@
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) input_element: web_sys::HtmlInputElement,
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>,
pub(crate) is_composing: Cell<bool>,
mql_handle: RefCell<Option<MqlHandle>>,
pending_physical_size: Cell<Option<(u32, u32)>>,
}
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(-1);
let style = canvas.style();
style
.set_property("width", "100%")
.map_err(|e| anyhow::anyhow!("Failed to set canvas width style: {e:?}"))?;
style
.set_property("height", "100%")
.map_err(|e| anyhow::anyhow!("Failed to set canvas height style: {e:?}"))?;
style
.set_property("display", "block")
.map_err(|e| anyhow::anyhow!("Failed to set canvas display style: {e:?}"))?;
style
.set_property("outline", "none")
.map_err(|e| anyhow::anyhow!("Failed to set canvas outline style: {e:?}"))?;
style
.set_property("touch-action", "none")
.map_err(|e| anyhow::anyhow!("Failed to set touch-action style: {e:?}"))?;
let body = document
.body()
.ok_or_else(|| anyhow::anyhow!("No `body` found on document"))?;
body.append_child(&canvas)
.map_err(|e| anyhow::anyhow!("Failed to append canvas to body: {e:?}"))?;
let input_element: web_sys::HtmlInputElement = document
.create_element("input")
.map_err(|e| anyhow::anyhow!("Failed to create input element: {e:?}"))?
.dyn_into()
.map_err(|e| anyhow::anyhow!("Created element is not an input: {e:?}"))?;
let input_style = input_element.style();
input_style.set_property("position", "fixed").ok();
input_style.set_property("top", "0").ok();
input_style.set_property("left", "0").ok();
input_style.set_property("width", "1px").ok();
input_style.set_property("height", "1px").ok();
input_style.set_property("opacity", "0").ok();
body.append_child(&input_element)
.map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?;
input_element.focus().ok();
let device_size = Size {
width: DevicePixels(0),
height: DevicePixels(0),
};
let renderer_config = WgpuSurfaceConfig {
size: device_size,
transparent: false,
preferred_present_mode: None,
};
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,
input_element,
has_device_pixel_support,
is_mac,
state: RefCell::new(mutable_state),
callbacks: RefCell::new(WebWindowCallbacks::default()),
click_state: RefCell::new(ClickState::default()),
pressed_button: Cell::new(None),
last_physical_size: Cell::new((0, 0)),
notify_scale: Cell::new(false),
is_composing: Cell::new(false),
mql_handle: RefCell::new(None),
pending_physical_size: Cell::new(None),
});
let raf_closure = inner.create_raf_closure();
inner.schedule_raf(&raf_closure);
let resize_observer_closure = Self::create_resize_observer_closure(Rc::clone(&inner));
let resize_observer =
web_sys::ResizeObserver::new(resize_observer_closure.as_ref().unchecked_ref()).ok();
if let Some(ref observer) = resize_observer {
inner.observe_canvas(observer);
inner.watch_dpr_changes(observer);
}
let event_listeners = inner.register_event_listeners();
Ok(Self {
inner,
display,
handle,
_raf_closure: raf_closure,
_resize_observer: resize_observer,
_resize_observer_closure: resize_observer_closure,
_event_listeners: event_listeners,
})
}
fn create_resize_observer_closure(
inner: Rc<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
.pending_physical_size
.set(Some((clamped_width, clamped_height)));
{
let mut s = inner.state.borrow_mut();
s.bounds.size = Size {
width: px(logical_width),
height: px(logical_height),
};
s.scale_factor = dpr_f32;
}
let new_size = Size {
width: px(logical_width),
height: px(logical_height),
};
let mut cbs = inner.callbacks.borrow_mut();
if let Some(ref mut callback) = cbs.resize {
callback(new_size, dpr_f32);
}
})
}
}
impl WebWindowInner {
fn create_raf_closure(self: &Rc<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 with_input_handler<R>(
&self,
f: impl FnOnce(&mut PlatformInputHandler) -> R,
) -> Option<R> {
let mut handler = self.state.borrow_mut().input_handler.take()?;
let result = f(&mut handler);
self.state.borrow_mut().input_handler = Some(handler);
Some(result)
}
pub(crate) fn register_appearance_change(
self: &Rc<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) {
if let Some((width, height)) = self.inner.pending_physical_size.take() {
if self.inner.canvas.width() != width || self.inner.canvas.height() != height {
self.inner.canvas.set_width(width);
self.inner.canvas.set_height(height);
}
let mut state = self.inner.state.borrow_mut();
state.renderer.update_drawable_size(Size {
width: DevicePixels(width as i32),
height: DevicePixels(height as i32),
});
drop(state);
}
self.inner.state.borrow_mut().renderer.draw(scene);
}
fn completed_frame(&self) {
// On web, presentation happens automatically via wgpu surface present
}
fn sprite_atlas(&self) -> Arc<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) {}
}