logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
72
crates/gpui_web/Cargo.toml
Normal file
72
crates/gpui_web/Cargo.toml
Normal 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",
|
||||
] }
|
||||
1
crates/gpui_web/LICENSE-APACHE
Symbolic link
1
crates/gpui_web/LICENSE-APACHE
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-APACHE
|
||||
14
crates/gpui_web/examples/hello_web/.cargo/config.toml
Normal file
14
crates/gpui_web/examples/hello_web/.cargo/config.toml
Normal 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"]
|
||||
3
crates/gpui_web/examples/hello_web/.gitignore
vendored
Normal file
3
crates/gpui_web/examples/hello_web/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/dist
|
||||
/target
|
||||
Cargo.lock
|
||||
16
crates/gpui_web/examples/hello_web/Cargo.toml
Normal file
16
crates/gpui_web/examples/hello_web/Cargo.toml
Normal 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"
|
||||
1
crates/gpui_web/examples/hello_web/LICENSE-APACHE
Symbolic link
1
crates/gpui_web/examples/hello_web/LICENSE-APACHE
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../LICENSE-APACHE
|
||||
31
crates/gpui_web/examples/hello_web/index.html
Normal file
31
crates/gpui_web/examples/hello_web/index.html
Normal 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>
|
||||
422
crates/gpui_web/examples/hello_web/main.rs
Normal file
422
crates/gpui_web/examples/hello_web/main.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
4
crates/gpui_web/examples/hello_web/rust-toolchain.toml
Normal file
4
crates/gpui_web/examples/hello_web/rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
components = ["rust-src", "rustfmt", "clippy"]
|
||||
7
crates/gpui_web/examples/hello_web/trunk.toml
Normal file
7
crates/gpui_web/examples/hello_web/trunk.toml
Normal 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" }
|
||||
333
crates/gpui_web/src/dispatcher.rs
Normal file
333
crates/gpui_web/src/dispatcher.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
98
crates/gpui_web/src/display.rs
Normal file
98
crates/gpui_web/src/display.rs
Normal 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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
682
crates/gpui_web/src/events.rs
Normal file
682
crates/gpui_web/src/events.rs
Normal 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
|
||||
}
|
||||
18
crates/gpui_web/src/gpui_web.rs
Normal file
18
crates/gpui_web/src/gpui_web.rs
Normal 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;
|
||||
199
crates/gpui_web/src/http_client.rs
Normal file
199
crates/gpui_web/src/http_client.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
19
crates/gpui_web/src/keyboard.rs
Normal file
19
crates/gpui_web/src/keyboard.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
37
crates/gpui_web/src/logging.rs
Normal file
37
crates/gpui_web/src/logging.rs
Normal 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);
|
||||
}
|
||||
435
crates/gpui_web/src/platform.rs
Normal file
435
crates/gpui_web/src/platform.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
731
crates/gpui_web/src/window.rs
Normal file
731
crates/gpui_web/src/window.rs
Normal 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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user