diff --git a/Cargo.lock b/Cargo.lock index 940ebfa13b..4e5c2eabbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7739,6 +7739,10 @@ dependencies = [ "parking_lot", "raw-window-handle", "scheduler", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", "uuid", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index dab1c302c9..1328c255ab 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -6,6 +6,63 @@ Examples can be run from the Zed repository root: cargo run -p gpui --example hello_world ``` +## Running in a browser + +A selection of these examples is also served by the standalone web gallery: + +```sh +cd crates/gpui_web/examples/hello_web +trunk serve +``` + +Open to choose an example: + +| Path | Example | +| --- | --- | +| `/hello-world` | Hello world | +| `/text` | Styled text | +| `/text-layout` | Alignment and decorations | +| `/text-wrapper` | Wrapping and truncation | +| `/input` | Text input and selection | +| `/prime-sieve` | Background task demo | + +The server supports direct links and reloads at each path. Only the selected +example's WASM module is loaded. Follow **All examples** to return to the gallery; +navigation reloads the page so the previous app and its workers are released. + +Install [Trunk](https://trunkrs.dev/) if needed. The gallery's toolchain file selects +nightly Rust, the WASM target, and `rust-src`. Its Trunk configuration supplies the +cross-origin-isolation headers needed for shared memory and watches the GPUI +example and renderer sources for automatic rebuilds and browser reloads. Use a +browser with WebGPU or WebGL2 support. + +The gallery builds separate Cargo binaries directly from the existing example +sources. To add another browser-compatible example, register its binary in the +gallery's `Cargo.toml`, add an auxiliary Rust asset in `index.html`, and add its +gallery link with the corresponding `data-module` name. + +### Canvas font fallback + +The web text system uses browser fonts for eligible missing CJK and emoji +graphemes. It renders each complete grapheme independently, retaining the primary +font's baseline and line-spacing metrics. Contextual CJK spacing and arbitrary +OpenType features are not reproduced. Other scripts remain on Cosmic Text. +Browser font availability is assumed stable for the application's lifetime. + +With the gallery server running, a browser smoke test checks native-font +preservation, whole-grapheme rendering, emoji atlas reuse, and input/deletion: + +```sh +node crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs +``` + +This requires Node.js 22 or newer and Chrome/Chromium. On macOS it uses the +standard Google Chrome application path; elsewhere it uses `chromium`. Set +`CHROME` to override the executable, and pass an optional server URL as the first +argument. Screenshots and logs are saved under the gallery's ignored `target` +directory. The test intercepts clipboard writes rather than modifying the system +clipboard. + ## Where to start - `hello_world` shows the basic shape of a GPUI application: create an diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index a1244eba37..7fa2a15760 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -282,6 +282,8 @@ impl Render for TextExample { let tcx = cx.text_context(); let colors = cx.default_colors().clone(); + let font_size = tcx.font_size; + let line_height = tcx.line_height; let type_scale = tcx.type_scale; let step_down_2 = 1.0 / (type_scale * type_scale); @@ -322,6 +324,21 @@ impl Render for TextExample { .overflow_x_hidden() .bg(rgb(0xffffff)) .size_full() + .child( + div() + .flex() + .flex_col() + .p_3() + .text_size(px(font_size)) + .line_height(relative(line_height)) + .children([ + div().child("CJK: 中文「日本語」한글"), + div().child( + "Kana: が / か\u{3099}; Hangul: 각 / \u{1100}\u{1161}\u{11a8}", + ), + div().child("Emoji: 😀 ❤️ ❤\u{fe0e} 👍🏽 🇯🇵 1\u{fe0f}\u{20e3} 👩‍💻 👨‍👩‍👧‍👦"), + ]), + ) .child(div().child(CharacterGrid::new().scale(base))) .child( div() diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index cb4eb66edd..1d7e2f385c 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -228,6 +228,9 @@ impl Application { /// Start the application. The provided callback will be called once the /// app is fully launched. + /// + /// On WebAssembly, this returns immediately and retains the app for the lifetime + /// of the Wasm instance. Use [`Self::run_embedded`] to control its lifetime explicitly. pub fn run(self, on_finish_launching: F) where F: 'static + FnOnce(&mut App), @@ -238,6 +241,9 @@ impl Application { let cx = &mut *this.borrow_mut(); on_finish_launching(cx); })); + + #[cfg(target_family = "wasm")] + std::mem::forget(self); } /// Start the application for an embedder that drives the run loop itself. diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 610da14f0d..f1f4e36b24 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -16,6 +16,11 @@ multithreaded = ["dep:wasm_thread", "scheduler/wasm-threads"] [lib] path = "src/gpui_web.rs" +[dependencies] +unicode-properties = "=0.1.3" +unicode-script = "0.5.7" +unicode-segmentation = "1.10" + [target.'cfg(target_family = "wasm")'.dependencies] gpui.workspace = true scheduler.workspace = true @@ -32,6 +37,7 @@ web-time.workspace = true console_error_panic_hook.workspace = true js-sys.workspace = true raw-window-handle.workspace = true +smallvec.workspace = true # Forked because upstream is unmaintained and its worker bootstrap still calls # the wasm-bindgen init function with the positional arguments deprecated in 0.2.93. wasm_thread = { git = "https://github.com/zed-industries/wasm_thread", rev = "0cf96c7708dfb97ccf3da50347e25edcf75d6937", version = "0.3", features = [ @@ -60,6 +66,7 @@ web-sys = { version = "0.3", features = [ "HtmlTextAreaElement", "IdleDeadline", "IdleRequestOptions", + "ImageData", "InputEvent", "KeyboardEvent", "Location", @@ -67,6 +74,8 @@ web-sys = { version = "0.3", features = [ "MediaQueryListEvent", "MouseEvent", "Navigator", + "OffscreenCanvas", + "OffscreenCanvasRenderingContext2d", "PointerEvent", "ReadableStream", "ReadableStreamDefaultReader", @@ -78,6 +87,7 @@ web-sys = { version = "0.3", features = [ "ResizeObserverOptions", "Screen", "Storage", + "TextMetrics", "VisualViewport", "Headers", "Request", @@ -89,5 +99,10 @@ web-sys = { version = "0.3", features = [ "Window", ] } +[dev-dependencies] +anyhow.workspace = true +gpui.workspace = true +smallvec.workspace = true + [package.metadata.cargo-shear] ignored = ["scheduler"] diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml index 92a11dbd71..f6f96de0c0 100644 --- a/crates/gpui_web/examples/hello_web/Cargo.toml +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -10,8 +10,30 @@ publish = false name = "hello_web" path = "main.rs" +[[bin]] +name = "hello_world" +path = "../../../gpui/examples/hello_world.rs" + +[[bin]] +name = "text" +path = "../../../gpui/examples/text.rs" + +[[bin]] +name = "text_layout" +path = "../../../gpui/examples/text_layout.rs" + +[[bin]] +name = "text_wrapper" +path = "../../../gpui/examples/text_wrapper.rs" + +[[bin]] +name = "input" +path = "../../../gpui/examples/input.rs" + [dependencies] gpui = { path = "../../../gpui" } gpui_platform = { path = "../../../gpui_platform" } +unicode-segmentation = "1" +wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["console", "Location", "Window"] } web-time = "1" diff --git a/crates/gpui_web/examples/hello_web/gallery.js b/crates/gpui_web/examples/hello_web/gallery.js new file mode 100644 index 0000000000..57b40f491f --- /dev/null +++ b/crates/gpui_web/examples/hello_web/gallery.js @@ -0,0 +1,42 @@ +const route = window.location.pathname.replace(/\/+$/, "") || "/"; +const gallery = document.getElementById("gallery"); +const back = document.getElementById("back"); +const status = document.getElementById("status"); +const example = [...gallery.querySelectorAll("a[data-module]")].find( + (link) => new URL(link.href).pathname === route, +); + +function showError(error) { + console.error(error); + status.hidden = false; + status.textContent = `Unable to start example: ${error.message ?? error}`; + status.setAttribute("role", "alert"); +} + +if (route !== "/") { + gallery.hidden = true; + back.hidden = false; + status.hidden = false; + + if (!example) { + showError(new Error(`Unknown example path "${route}". Choose an example from the gallery.`)); + } else { + const title = example.querySelector("strong").textContent; + document.title = `${title} — GPUI Web Examples`; + status.textContent = `Loading ${title}…`; + + try { + if (!window.crossOriginIsolated) { + throw new Error("Shared-memory WASM requires cross-origin isolation. Serve this page with trunk serve."); + } + + const bindings = await import(`/${example.dataset.module}.js`); + await bindings.default(); + window.wasmBindings = bindings; + // GPUI chooses WebGPU or WebGL2 and reports initialization failures itself. + status.hidden = true; + } catch (error) { + showError(error); + } + } +} diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html index 692f3edf06..c0f9060f5b 100644 --- a/crates/gpui_web/examples/hello_web/index.html +++ b/crates/gpui_web/examples/hello_web/index.html @@ -3,8 +3,16 @@ - GPUI Web: hello_web + GPUI Web Examples + + + + + + + - + +
+

GPUI Web Examples

+

Each example runs in its own WebAssembly instance. Choose an example to open it.

+ +
+ + + diff --git a/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs b/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs new file mode 100644 index 0000000000..6d6b3fa06c --- /dev/null +++ b/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const origin = new URL(process.argv[2] ?? "http://127.0.0.1:8080").origin; +const chrome = process.env.CHROME ?? + (process.platform === "darwin" + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + : "chromium"); +const output = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "target", + "canvas-fallback-test", + String(Date.now()), +); +fs.mkdirSync(output, { recursive: true }); +const log = fs.openSync(path.join(output, "chrome.log"), "w"); +const browser = spawn(chrome, [ + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + `--user-data-dir=${path.join(output, "profile")}`, + "about:blank", +], { stdio: ["ignore", log, log] }); +let launchError; +browser.on("error", error => { launchError = error; }); +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +let socket; + +try { + let port; + for (let attempt = 0; attempt < 100; attempt++) { + if (launchError) throw launchError; + if (browser.exitCode !== null) throw new Error(`Chrome exited; see ${output}/chrome.log`); + const portFile = path.join(output, "profile", "DevToolsActivePort"); + if (fs.existsSync(portFile)) { + port = fs.readFileSync(portFile, "utf8").split("\n")[0]; + break; + } + await delay(100); + } + assert.ok(port, "Chrome did not start"); + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + socket = new WebSocket(pages.find(page => page.type === "page").webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + let nextId = 0; + const pending = new Map(); + const errors = []; + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + if (message.id) { + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new Error(JSON.stringify(message.error))); + else request.resolve(message.result); + } else if (message.method === "Runtime.exceptionThrown" || + (message.method === "Runtime.consoleAPICalled" && message.params.type === "error") || + (message.method === "Log.entryAdded" && message.params.entry.text.includes("willReadFrequently"))) { + errors.push(message.params); + } + }); + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out: ${method}`)); + }, 15000); + timer.unref(); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async expression => { + const result = await call("Runtime.evaluate", { + expression, returnByValue: true, awaitPromise: true, + }); + assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await call("Runtime.enable"); + await call("Page.enable"); + await call("Log.enable"); + await call("Emulation.setDeviceMetricsOverride", { + width: 1280, height: 900, deviceScaleFactor: 1, mobile: false, + }); + await call("Page.addScriptToEvaluateOnNewDocument", { source: ` + window.canvasCalls = []; + for (const name of ["measureText", "fillText"]) { + const original = OffscreenCanvasRenderingContext2D.prototype[name]; + OffscreenCanvasRenderingContext2D.prototype[name] = function(...args) { + canvasCalls.push({ + type: name, text: args[0], font: this.font, + willReadFrequently: this.getContextAttributes().willReadFrequently, + }); + return original.apply(this, args); + }; + } + ` }); + await call("Page.navigate", { url: `${origin}/input` }); + for (let attempt = 0; attempt < 150; attempt++) { + if (await evaluate('!!document.querySelector("textarea")')) break; + await delay(100); + } + assert.ok(await evaluate('!!document.querySelector("textarea")'), "Input example did not launch"); + await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); + // Read GPUI's buffer through its Copy action without changing the system clipboard. + await evaluate(` + window.copiedText = ""; + Object.defineProperty(navigator.clipboard, "writeText", { + value: async text => { copiedText = text; }, + }); + window.sendKey = (key, metaKey = false) => { + for (const type of ["keydown", "keyup"]) { + document.querySelector("textarea").dispatchEvent( + new KeyboardEvent(type, { key, metaKey, bubbles: true, cancelable: true }), + ); + } + }; + `); + const key = (key, meta = false) => evaluate(`sendKey(${JSON.stringify(key)}, ${meta})`); + const insert = text => call("Input.insertText", { text }); + const readText = async () => { + await key("a", true); + await evaluate('copiedText = ""'); + await key("c", true); + const text = await evaluate("copiedText"); + await key("ArrowRight"); + return text; + }; + const rasterCount = text => evaluate( + `canvasCalls.filter(call => call.type === "fillText" && call.text === ${JSON.stringify(text)}).length`, + ); + + await insert("Hello ©"); + await delay(200); + assert.equal(await evaluate("canvasCalls.length"), 0, "Bundled glyphs should not use Canvas"); + await key("a", true); + await insert("😀😀😀"); + await delay(200); + assert.equal(await rasterCount("😀"), 1, "Repeated emoji should share one atlas image"); + assert.equal(await readText(), "😀😀😀"); + await key("ArrowLeft"); + await key("ArrowRight"); + await delay(200); + assert.equal(await rasterCount("😀"), 1, "Caret movement should not rerasterize emoji"); + + const text = "A中文 が か\u3099 각 각 😀 ❤️ 👍🏽 🇯🇵 1️⃣ 👩‍💻 👨‍👩‍👧‍👦Z"; + await key("a", true); + await insert(text); + await delay(200); + assert.equal(await readText(), text); + for (const grapheme of ["❤️", "👍🏽", "🇯🇵", "1⃣", "👩‍💻", "👨‍👩‍👧‍👦"]) { + assert.ok(await rasterCount(grapheme) > 0, `Missing whole-grapheme raster: ${grapheme}`); + } + for (const grapheme of ["中", "文", "が", "か\u3099", "각", "각"]) { + assert.equal(await rasterCount(grapheme), 0, `Default policy should not rasterize CJK: ${grapheme}`); + } + assert.ok( + await evaluate("canvasCalls.every(call => call.willReadFrequently === true)"), + "Glyph canvases should be configured for frequent readback", + ); + const screenshot = await call("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(output, "input.png"), Buffer.from(screenshot.data, "base64")); + await key("Backspace"); + await key("Backspace"); + assert.equal(await readText(), text.slice(0, -"👨‍👩‍👧‍👦Z".length)); + + await key("a", true); + await evaluate("canvasCalls = []"); + await insert("ش"); + await delay(200); + assert.equal(await evaluate("canvasCalls.length"), 0, "Unsupported scripts should stay native"); + assert.deepEqual(errors, [], "Browser reported errors"); + console.log(`Canvas fallback checks passed. Screenshot and browser log: ${output}`); +} finally { + socket?.close(); + browser.kill(); + fs.closeSync(log); +} diff --git a/crates/gpui_web/examples/hello_web/trunk.toml b/crates/gpui_web/examples/hello_web/trunk.toml index 5ef787c270..1ec4ed4daf 100644 --- a/crates/gpui_web/examples/hello_web/trunk.toml +++ b/crates/gpui_web/examples/hello_web/trunk.toml @@ -1,3 +1,19 @@ +[build] +# The gallery imports only the selected example instead of starting or preloading the main binary. +pattern_script = "" +pattern_preload = "" +filehash = false + +[watch] +watch = [ + ".", + "../../../gpui/examples", + "../../../gpui/src", + "../../../gpui_platform/src", + "../../../gpui_web/src", + "../../../gpui_wgpu/src", +] + [serve] addresses = ["127.0.0.1"] port = 8080 diff --git a/crates/gpui_web/src/canvas_fallback.rs b/crates/gpui_web/src/canvas_fallback.rs new file mode 100644 index 0000000000..5aec7e74b7 --- /dev/null +++ b/crates/gpui_web/src/canvas_fallback.rs @@ -0,0 +1,463 @@ +//! Eligibility only for independent, horizontal Canvas fallback of a complete +//! extended grapheme. Callers must still check whether bundled shaping supplies +//! the requested glyph and presentation; eligibility alone does not override it. +//! +//! This is intentionally not a Unicode sequence validator or an RGI emoji database. +//! Han variation selectors are checked structurally, not against the IVD registry. +//! Emoji selectors are checked against the Emoji property, not the registered +//! variation-sequence list. Eligibility does not guarantee browser coverage. +//! Flags accept two regional indicators, not only assigned country codes. ZWJ +//! support is limited to person/profession pairs and a small explicit allowlist; +//! tags are limited to the three subdivision flags. Other sequences stay on Cosmic. + +use unicode_properties::{EmojiStatus, GeneralCategory, UnicodeEmoji, UnicodeGeneralCategory}; +use unicode_script::{Script, UnicodeScript}; +use unicode_segmentation::UnicodeSegmentation; + +/// Controls browser-font fallback when loaded fonts lack a glyph or its emoji presentation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CanvasFontFallback { + /// Use only fonts loaded into GPUI. + Disabled, + /// Use Canvas only for eligible graphemes requesting emoji presentation. + #[default] + Emoji, + /// Also allow approximate independent rendering of eligible horizontal CJK text. + EmojiAndCjk, +} + +impl CanvasFontFallback { + #[cfg(any(target_family = "wasm", test))] + pub(crate) fn allows(self, emoji_presentation: bool) -> bool { + match self { + Self::Disabled => false, + Self::Emoji => emoji_presentation, + Self::EmojiAndCjk => true, + } + } +} + +/// A supported grapheme assumed to be independently renderable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CanvasFallback { + /// Whether to request emoji presentation instead of ordinary text presentation. + pub emoji_presentation: bool, +} + +/// Returns a presentation only when `grapheme` is exactly one supported extended +/// grapheme cluster. No normalization, splitting, or font-coverage decision occurs. +pub fn classify_canvas_fallback(grapheme: &str) -> Option { + if grapheme.is_ascii() { + return None; + } + let mut graphemes = grapheme.graphemes(true); + if graphemes.next()? != grapheme || graphemes.next().is_some() { + return None; + } + + if is_cjk(grapheme) { + Some(CanvasFallback { + emoji_presentation: false, + }) + } else { + classify_emoji(grapheme).map(|emoji_presentation| CanvasFallback { emoji_presentation }) + } +} + +fn is_cjk(grapheme: &str) -> bool { + let mut characters = grapheme.chars(); + let Some(base) = characters.next() else { + return false; + }; + let suffix = characters.as_str(); + + // The script/category checks exclude unassigned holes; the ranges exclude + // Han radicals, iteration marks, and other non-ideographic Han characters. + if base.script() == Script::Han + && base.general_category() == GeneralCategory::OtherLetter + && matches!(base, '\u{3400}'..='\u{9fff}' | '\u{f900}'..='\u{faff}' + | '\u{20000}'..='\u{323af}') + { + return suffix.is_empty() + || matches!( + (characters.next(), characters.next()), + (Some('\u{fe00}'..='\u{fe02}' | '\u{e0100}'..='\u{e01ef}'), None) + ); + } + + if matches!(base, '\u{3041}'..='\u{3096}' | '\u{30a1}'..='\u{30fa}') { + return suffix.is_empty() + || match suffix { + "\u{3099}" => "うかきくけこさしすせそたちつてとはひふへほウカキクケコサシスセソタチツテトハヒフヘホワヰヱヲ".contains(base), + "\u{309a}" => "はひふへほハヒフヘホ".contains(base), + _ => false, + }; + } + + if matches!(base, '\u{ac00}'..='\u{d7a3}') { + return suffix.is_empty() + || ((base as u32 - 0xac00).is_multiple_of(28) + && is_single_modern_trailing_jamo(suffix)); + } + if matches!(base, '\u{1100}'..='\u{1112}') { + return matches!(characters.next(), Some('\u{1161}'..='\u{1175}')) + && (characters.as_str().is_empty() + || is_single_modern_trailing_jamo(characters.as_str())); + } + + suffix.is_empty() + && (matches!(base, '\u{3131}'..='\u{3163}') + || "、。〈〉《》「」『』【】〔〕()[]{},.!?:;・ー々〆".contains(base)) +} + +fn is_single_modern_trailing_jamo(text: &str) -> bool { + let mut characters = text.chars(); + matches!(characters.next(), Some('\u{11a8}'..='\u{11c2}')) && characters.next().is_none() +} + +fn classify_emoji(grapheme: &str) -> Option { + let mut characters = grapheme.chars(); + let base = characters.next()?; + let suffix = characters.as_str(); + + if matches!(base, '0'..='9' | '#' | '*') { + return match suffix { + "\u{20e3}" | "\u{fe0f}\u{20e3}" => Some(true), + _ => None, + }; + } + if unicode_properties::emoji::is_regional_indicator(base) { + return (characters + .next() + .is_some_and(unicode_properties::emoji::is_regional_indicator) + && characters.next().is_none()) + .then_some(true); + } + if matches!( + grapheme, + "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}" + | "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}" + | "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}" + ) { + return Some(true); + } + if grapheme.contains('\u{200d}') { + return is_supported_zwj_sequence(grapheme).then_some(true); + } + classify_emoji_unit(grapheme) +} + +fn classify_emoji_unit(text: &str) -> Option { + let mut characters = text.chars(); + let base = characters.next()?; + if !base.is_emoji_char() || base.is_emoji_component() { + return None; + } + let status = base.emoji_status(); + let mut emoji_presentation = matches!( + status, + EmojiStatus::EmojiPresentation | EmojiStatus::EmojiPresentationAndModifierBase + ); + let mut next = characters.next(); + let explicit_text = next == Some('\u{fe0e}'); + if matches!(next, Some('\u{fe0e}' | '\u{fe0f}')) { + emoji_presentation = !explicit_text; + next = characters.next(); + } + if let Some(modifier) = next { + if explicit_text + || !matches!( + status, + EmojiStatus::EmojiModifierBase | EmojiStatus::EmojiPresentationAndModifierBase + ) + || modifier.emoji_status() != EmojiStatus::EmojiPresentationAndModifierAndEmojiComponent + { + return None; + } + emoji_presentation = true; + } + characters.next().is_none().then_some(emoji_presentation) +} + +fn is_supported_zwj_sequence(grapheme: &str) -> bool { + if matches!( + grapheme, + "👨‍👩‍👧" | "👨‍👩‍👧‍👦" + | "👩‍👩‍👧‍👦" + | "👨‍👨‍👧‍👦" + | "🏳️‍🌈" + | "🏳️‍⚧️" + | "🏴‍☠️" + | "❤️‍🔥" + | "❤️‍🩹" + | "👁️‍🗨️" + | "🐻‍❄️" + ) { + return true; + } + let Some((person, profession)) = grapheme.split_once('\u{200d}') else { + return false; + }; + matches!(person.chars().next(), Some('👨' | '👩' | '🧑')) + && classify_emoji_unit(person) == Some(true) + && matches!( + profession, + "⚕️" | "⚖️" + | "✈️" + | "🌾" + | "🍳" + | "🎓" + | "🎤" + | "🎨" + | "🏫" + | "🏭" + | "💻" + | "💼" + | "🔧" + | "🔬" + | "🚀" + | "🚒" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_font_fallback_policy() { + assert_eq!(CanvasFontFallback::default(), CanvasFontFallback::Emoji); + for grapheme in ["😀", "❤️", "1️⃣", "👨‍👩‍👧‍👦", "中", "か\u{3099}", "각", "©", "❤︎"] + { + let fallback = classify_canvas_fallback(grapheme).expect("eligible grapheme"); + assert!(!CanvasFontFallback::Disabled.allows(fallback.emoji_presentation)); + assert_eq!( + CanvasFontFallback::Emoji.allows(fallback.emoji_presentation), + ["😀", "❤️", "1️⃣", "👨‍👩‍👧‍👦"].contains(&grapheme), + ); + assert!(CanvasFontFallback::EmojiAndCjk.allows(fallback.emoji_presentation)); + } + } + + #[test] + fn ascii_is_ineligible_but_keycaps_are_preserved() { + for byte in 0..=0x7f_u8 { + assert_eq!(classify_canvas_fallback(&(byte as char).to_string()), None); + } + for text in ["", "Hello", "0123456789#*", "\r\n"] { + assert_eq!(classify_canvas_fallback(text), None); + } + for base in "0123456789#*".chars() { + for text in [format!("{base}\u{20e3}"), format!("{base}\u{fe0f}\u{20e3}")] { + assert_eq!( + classify_canvas_fallback(&text), + Some(CanvasFallback { + emoji_presentation: true, + }), + "{text:?}" + ); + } + } + } + + #[test] + fn ordinary_cjk_clusters() { + for text in [ + "漢", + "𠀀", + "﨑", + "漢\u{fe00}", + "葛\u{e0100}", + "葛\u{e01ef}", + "あ", + "ガ", + "か\u{3099}", + "ハ\u{309a}", + "가", + "각", + "가", + "각", + "각", + "ㄱ", + "、", + "。", + "「", + "」", + "(", + "!", + "ー", + "々", + ] { + assert_eq!( + classify_canvas_fallback(text), + Some(CanvasFallback { + emoji_presentation: false, + }), + "{text:?}" + ); + } + } + + #[test] + fn emoji_presentation_is_preserved() { + for (text, emoji_presentation) in [ + ("😀", true), + ("©", false), + ("©\u{fe0e}", false), + ("©\u{fe0f}", true), + ("❤", false), + ("❤\u{fe0e}", false), + ("❤\u{fe0f}", true), + ("😀\u{fe0e}", false), + ("👍🏽", true), + ("☝🏽", true), + ("👍\u{fe0f}🏽", true), + ("🇯🇵", true), + ("1\u{20e3}", true), + ("#\u{fe0f}\u{20e3}", true), + ("*\u{fe0f}\u{20e3}", true), + ("👩🏽‍💻", true), + ("🧑‍⚕️", true), + ("👨‍👩‍👧‍👦", true), + ("🏳️‍🌈", true), + ( + "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}", + true, + ), + ( + "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}", + true, + ), + ( + "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}", + true, + ), + ] { + assert_eq!( + classify_canvas_fallback(text), + Some(CanvasFallback { emoji_presentation }), + "{text:?}" + ); + } + } + + #[test] + fn unsupported_scripts_and_cjk_forms_stay_on_cosmic() { + for text in [ + "a", + "é", + "α", + "Ж", + "ش", + "ש", + "क", + "क्ष", + "ก", + "ក", + "ᠠ", + "ཀ", + "\0", + "\n", + "\r\n", + "\u{202e}", + "\u{200d}", + "\u{fdd0}", + "\u{10ffff}", + "\u{2a6e0}", + "\u{e000}", + "\u{3099}", + "\u{fe0f}", + "\u{e0100}", + "漢\u{301}", + "漢\u{fe03}", + "漢\u{fe0f}", + "漢\u{e0100}\u{e0101}", + "あ\u{3099}", + "か\u{309a}", + "か\u{3099}\u{3099}", + "ᄀ", + "ᅡ", + "ᆨ", + "ᄓᅡ", + "ᄀᅶ", + "가ᇃ", + "각ᆨ", + "ᄀ가", + "\u{3164}", + "\u{3165}", + "⺀", + "⼀", + "㇀", + "㆐", + "ㄅ", + "㐀\u{200d}", + "\u{1b000}", + "カ", + "!\u{301}", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + } + + #[test] + fn malformed_or_out_of_policy_emoji_stay_on_cosmic() { + for text in [ + "0", + "#", + "*", + "1\u{fe0f}", + "#\u{fe0e}", + "1\u{fe0e}\u{20e3}", + "a\u{20e3}", + "🏽", + "🇯", + "😀🏽", + "👍🏽🏽", + "👍\u{fe0e}🏽", + "👍🏽\u{fe0f}", + "❤\u{fe0f}\u{fe0f}", + "😀\u{301}", + "🦰", + "😀‍😀", + "👩‍", + "👩‍💻‍🚀", + "👩\u{fe0e}‍💻", + "🏴\u{e0067}", + "🏴\u{e0061}\u{e0062}\u{e007f}", + "😀\u{e0067}\u{e007f}", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + } + + #[test] + fn accepts_only_one_whole_extended_grapheme() { + for text in [ + "", + "漢字", + "あい", + "가나", + "😀😀", + "🇯🇵🇺", + "🇯🇵🇺🇸", + " 漢", + "漢\n", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + let text = "aか\u{3099}👩🏽‍💻漢\u{e0100}"; + let eligible: Vec<_> = text + .grapheme_indices(true) + .filter_map(|(start, grapheme)| { + classify_canvas_fallback(grapheme).map(|_| start..start + grapheme.len()) + }) + .collect(); + assert_eq!( + eligible + .iter() + .map(|range| &text[range.clone()]) + .collect::>(), + ["か\u{3099}", "👩🏽‍💻", "漢\u{e0100}"] + ); + } +} diff --git a/crates/gpui_web/src/canvas_text.rs b/crates/gpui_web/src/canvas_text.rs new file mode 100644 index 0000000000..aab243b5f2 --- /dev/null +++ b/crates/gpui_web/src/canvas_text.rs @@ -0,0 +1,197 @@ +use anyhow::{Context as _, Result, anyhow, ensure}; +use gpui::{Bounds, DevicePixels}; +use std::cell::RefCell; +use wasm_bindgen::{JsCast as _, JsValue}; +use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d}; + +const MAX_RASTER_DIMENSION: u32 = 4096; +const MAX_RASTER_PIXELS: usize = 4 * 1024 * 1024; + +thread_local! { + static CANVAS: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct CanvasTextMetrics { + pub(crate) advance: f32, + pub(crate) left: f32, + pub(crate) right: f32, + pub(crate) ascent: f32, + pub(crate) descent: f32, +} + +struct TextCanvas { + canvas: OffscreenCanvas, + context: OffscreenCanvasRenderingContext2d, +} + +impl TextCanvas { + fn new() -> Result { + let canvas = OffscreenCanvas::new(1, 1) + .map_err(|error| anyhow!("creating text OffscreenCanvas: {error:?}"))?; + let options = js_sys::Object::new(); + // Every rasterized glyph is read back for upload into GPUI's atlas. + let assigned = js_sys::Reflect::set( + &options, + &JsValue::from_str("willReadFrequently"), + &JsValue::TRUE, + ) + .map_err(|error| anyhow!("setting Canvas readback option: {error:?}"))?; + ensure!(assigned, "Canvas readback option could not be set"); + let context = canvas + .get_context_with_context_options("2d", &options) + .map_err(|error| anyhow!("getting OffscreenCanvas 2D context: {error:?}"))? + .context("OffscreenCanvas 2D text rendering is unavailable")? + .dyn_into::() + .map_err(|error| anyhow!("unexpected OffscreenCanvas 2D context: {error:?}"))?; + Ok(Self { canvas, context }) + } + + fn configure(&self, css_font: &str) -> Result<()> { + ensure!(!css_font.trim().is_empty(), "Canvas text font is empty"); + // An invalid CSS font assignment is ignored by Canvas. Reset first so + // it cannot accidentally inherit the preceding request's font. + self.context.set_font("10px sans-serif"); + self.context.set_font(css_font); + self.context.set_text_align("left"); + self.context.set_text_baseline("alphabetic"); + self.context.set_fill_style_str("white"); + // web-sys does not expose direction on the offscreen 2D context. + let assigned = js_sys::Reflect::set( + self.context.as_ref(), + &JsValue::from_str("direction"), + &JsValue::from_str("ltr"), + ) + .map_err(|error| anyhow!("setting Canvas text direction: {error:?}"))?; + ensure!(assigned, "Canvas text direction could not be set"); + Ok(()) + } +} + +fn with_canvas(operation: impl FnOnce(&mut TextCanvas) -> Result) -> Result { + CANVAS.with(|canvas| { + let mut canvas = canvas + .try_borrow_mut() + .context("Canvas text renderer was called reentrantly")?; + if canvas.is_none() { + *canvas = Some(TextCanvas::new()?); + } + operation( + canvas + .as_mut() + .context("Canvas text renderer is unavailable")?, + ) + }) +} + +pub(crate) fn measure(text: &str, css_font: &str) -> Result { + with_canvas(|canvas| { + canvas.configure(css_font)?; + let metrics = canvas + .context + .measure_text(text) + .map_err(|error| anyhow!("measuring Canvas text: {error:?}"))?; + let metrics = CanvasTextMetrics { + advance: metrics.width() as f32, + left: -metrics.actual_bounding_box_left() as f32, + right: metrics.actual_bounding_box_right() as f32, + ascent: metrics.actual_bounding_box_ascent() as f32, + descent: metrics.actual_bounding_box_descent() as f32, + }; + ensure!( + [ + metrics.advance, + metrics.left, + metrics.right, + metrics.ascent, + metrics.descent + ] + .into_iter() + .all(f32::is_finite), + "Canvas returned non-finite text metrics" + ); + Ok(metrics) + }) +} + +pub(crate) fn rasterize( + text: &str, + css_font: &str, + bounds: Bounds, + subpixel_offset: (f32, f32), + color: bool, +) -> Result> { + ensure!( + subpixel_offset.0.is_finite() && subpixel_offset.1.is_finite(), + "Canvas text subpixel offset must be finite" + ); + let width = u32::try_from(bounds.size.width.0) + .context("Canvas text raster width must be nonnegative")?; + let height = u32::try_from(bounds.size.height.0) + .context("Canvas text raster height must be nonnegative")?; + if width == 0 || height == 0 { + return Ok(Vec::new()); + } + ensure!( + width <= MAX_RASTER_DIMENSION && height <= MAX_RASTER_DIMENSION, + "Canvas text raster exceeds maximum dimension {MAX_RASTER_DIMENSION}: {width}x{height}" + ); + let pixel_count = usize::try_from(width)? + .checked_mul(usize::try_from(height)?) + .context("Canvas text raster pixel count overflow")?; + ensure!( + pixel_count <= MAX_RASTER_PIXELS, + "Canvas text raster exceeds maximum pixel count {MAX_RASTER_PIXELS}: {width}x{height}" + ); + let byte_count = pixel_count + .checked_mul(4) + .context("Canvas text raster byte count overflow")?; + + with_canvas(|canvas| { + // Shrink before growing so intermediate canvas sizes obey the pixel cap. + if height < canvas.canvas.height() { + canvas.canvas.set_height(height); + } + if canvas.canvas.width() != width { + canvas.canvas.set_width(width); + } + if canvas.canvas.height() != height { + canvas.canvas.set_height(height); + } + canvas.configure(css_font)?; + canvas + .context + .clear_rect(0.0, 0.0, f64::from(width), f64::from(height)); + canvas + .context + .fill_text( + text, + -f64::from(bounds.origin.x.0) + f64::from(subpixel_offset.0), + -f64::from(bounds.origin.y.0) + f64::from(subpixel_offset.1), + ) + .map_err(|error| anyhow!("drawing Canvas text: {error:?}"))?; + let image = canvas + .context + .get_image_data(0.0, 0.0, f64::from(width), f64::from(height)) + .map_err(|error| anyhow!("reading Canvas text raster pixels: {error:?}"))?; + let mut pixels = image.data().0; + ensure!( + pixels.len() == byte_count, + "Canvas text raster returned {} bytes, expected {byte_count}", + pixels.len() + ); + if color { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok(pixels) + } else { + let mut alpha = Vec::new(); + alpha + .try_reserve_exact(pixel_count) + .context("allocating Canvas text alpha mask")?; + alpha.extend(pixels.chunks_exact(4).map(|pixel| pixel[3])); + Ok(alpha) + } + }) +} diff --git a/crates/gpui_web/src/glyph_cache.rs b/crates/gpui_web/src/glyph_cache.rs new file mode 100644 index 0000000000..8d0d3afed4 --- /dev/null +++ b/crates/gpui_web/src/glyph_cache.rs @@ -0,0 +1,133 @@ +use anyhow::Result; +use gpui::{FontId, GlyphId, SharedString}; +use std::collections::HashMap; + +#[derive(Clone)] +pub(crate) struct CanvasGlyph { + pub(crate) font_id: FontId, + pub(crate) text: SharedString, + pub(crate) color: bool, +} + +#[derive(Default)] +pub(crate) struct GlyphCache { + glyphs: Vec, + ids_by_font: HashMap<(FontId, bool), HashMap>, +} + +impl GlyphCache { + pub(crate) fn id_for(&self, font_id: FontId, text: &str, color: bool) -> Option { + self.ids_by_font.get(&(font_id, color))?.get(text).copied() + } + + pub(crate) fn get_or_insert( + &mut self, + font_id: FontId, + text: &str, + color: bool, + ) -> Result { + if let Some(glyph_id) = self.id_for(font_id, text, color) { + return Ok(glyph_id); + } + let glyph_id = GlyphId(u32::try_from(self.glyphs.len())?); + let text = SharedString::from(text.to_owned()); + self.glyphs.push(CanvasGlyph { + font_id, + text: text.clone(), + color, + }); + self.ids_by_font + .entry((font_id, color)) + .or_default() + .insert(text, glyph_id); + Ok(glyph_id) + } + + pub(crate) fn glyph(&self, font_id: FontId, glyph_id: GlyphId) -> Option<&CanvasGlyph> { + self.glyphs + .get(glyph_id.0 as usize) + .filter(|glyph| glyph.font_id == font_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context as _; + + #[test] + fn warm_lookup_borrows_text_and_preserves_storage() -> Result<()> { + let mut cache = GlyphCache::default(); + let font_id = FontId(1); + let glyph_id = cache.get_or_insert(font_id, "👩‍💻", true)?; + let glyphs_pointer = cache.glyphs.as_ptr(); + let glyphs_capacity = cache.glyphs.capacity(); + let text_pointer = cache + .glyph(font_id, glyph_id) + .context("missing glyph")? + .text + .as_ptr(); + let surrounding_text = String::from("👩‍💻 suffix"); + let borrowed_text = surrounding_text + .strip_suffix(" suffix") + .context("missing suffix")?; + + for _ in 0..100 { + assert_eq!(cache.id_for(font_id, borrowed_text, true), Some(glyph_id)); + assert_eq!(cache.get_or_insert(font_id, borrowed_text, true)?, glyph_id); + } + + assert_eq!(cache.glyphs.len(), 1); + assert_eq!(cache.glyphs.as_ptr(), glyphs_pointer); + assert_eq!(cache.glyphs.capacity(), glyphs_capacity); + assert_eq!( + cache + .glyph(font_id, glyph_id) + .context("missing glyph")? + .text + .as_ptr(), + text_pointer + ); + Ok(()) + } + + #[test] + fn font_and_presentation_have_distinct_ids() -> Result<()> { + let mut cache = GlyphCache::default(); + let first_font = FontId(1); + let second_font = FontId(2); + let text = cache.get_or_insert(first_font, "❤", false)?; + let color = cache.get_or_insert(first_font, "❤", true)?; + let other_font = cache.get_or_insert(second_font, "❤", false)?; + + assert_ne!(text, color); + assert_ne!(text, other_font); + assert_ne!(color, other_font); + assert_eq!(cache.id_for(first_font, "❤", false), Some(text)); + assert_eq!(cache.id_for(first_font, "❤", true), Some(color)); + assert_eq!(cache.id_for(second_font, "❤", false), Some(other_font)); + assert!(cache.glyph(second_font, text).is_none()); + assert!(cache.glyph(first_font, GlyphId(u32::MAX)).is_none()); + assert_eq!( + cache.glyph(first_font, text).map(|glyph| glyph.color), + Some(false) + ); + assert_eq!( + cache.glyph(first_font, color).map(|glyph| glyph.color), + Some(true) + ); + Ok(()) + } + + #[test] + fn insertion_rechecks_a_previous_miss() -> Result<()> { + let mut cache = GlyphCache::default(); + let font_id = FontId(1); + assert_eq!(cache.id_for(font_id, "中", false), None); + + let inserted = cache.get_or_insert(font_id, "中", false)?; + assert_eq!(cache.get_or_insert(font_id, "中", false)?, inserted); + assert_eq!(cache.glyphs.len(), 1); + Ok(()) + } +} diff --git a/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs index c9411f7e42..c915084325 100644 --- a/crates/gpui_web/src/gpui_web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -1,26 +1,54 @@ -#![cfg(target_family = "wasm")] - //! GPUI's browser platform uses one document-owned canvas and supports one top-level window. //! Browser WebGPU is preferred by default, with an automatic WebGL2 fallback. Applications can -//! force either backend with [`WebBackendPreference`]. Opening a second top-level window, or -//! reopening one after it closes, returns [`WebWindowError`]. +//! force either backend with `WebBackendPreference`. Opening a second top-level window, or +//! reopening one after it closes, returns `WebWindowError`. +pub mod canvas_fallback; + +pub use canvas_fallback::CanvasFontFallback; + +#[cfg(target_family = "wasm")] +mod canvas_text; +#[cfg(target_family = "wasm")] mod dispatcher; +#[cfg(target_family = "wasm")] mod display; +#[cfg(target_family = "wasm")] mod events; +#[cfg(any(target_family = "wasm", test))] +mod glyph_cache; +#[cfg(target_family = "wasm")] mod http_client; +#[cfg(target_family = "wasm")] mod ime_mirror; +#[cfg(target_family = "wasm")] mod keyboard; +#[cfg(target_family = "wasm")] mod logging; +#[cfg(target_family = "wasm")] mod platform; +#[cfg(any(target_family = "wasm", test))] +mod run_replacements; +#[cfg(target_family = "wasm")] +mod text_system; +#[cfg(target_family = "wasm")] mod viewport; +#[cfg(target_family = "wasm")] mod window; +#[cfg(target_family = "wasm")] pub use dispatcher::WebDispatcher; +#[cfg(target_family = "wasm")] pub use display::WebDisplay; +#[cfg(target_family = "wasm")] pub use gpui_wgpu::WebBackendPreference; +#[cfg(target_family = "wasm")] pub use http_client::{FetchCredentials, FetchHttpClient}; +#[cfg(target_family = "wasm")] pub use keyboard::WebKeyboardLayout; +#[cfg(target_family = "wasm")] pub use logging::init_logging; +#[cfg(target_family = "wasm")] pub use platform::{WebPlatform, WebWindowError}; +#[cfg(target_family = "wasm")] pub use window::WebWindow; diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index e3a937e105..d29dc34772 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -1,8 +1,10 @@ +use crate::CanvasFontFallback; use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; use crate::events::EventListenerHandle; use crate::http_client::FetchHttpClient; use crate::keyboard::WebKeyboardLayout; +use crate::text_system::WebTextSystem; use crate::window::WebWindow; use anyhow::Result; use futures::channel::oneshot; @@ -149,6 +151,22 @@ impl WebPlatform { pub fn new_with_backend( allow_multi_threading: bool, backend_preference: WebBackendPreference, + ) -> Self { + Self::new_with_backend_and_font_fallback( + allow_multi_threading, + backend_preference, + CanvasFontFallback::default(), + ) + } + + /// Configures browser fallback before any fonts or layouts are cached. + /// + /// Loaded fonts remain preferred, except when they cannot supply requested + /// emoji presentation. The policy cannot be changed after construction. + pub fn new_with_backend_and_font_fallback( + allow_multi_threading: bool, + backend_preference: WebBackendPreference, + canvas_font_fallback: CanvasFontFallback, ) -> Self { let browser_window = web_sys::window().expect("must be running in a browser window context"); @@ -158,9 +176,7 @@ impl WebPlatform { )); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts( - "IBM Plex Sans", - )); + let text_system = Arc::new(WebTextSystem::new("IBM Plex Sans", canvas_font_fallback)); let text_system: Arc = text_system; let active_display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); diff --git a/crates/gpui_web/src/run_replacements.rs b/crates/gpui_web/src/run_replacements.rs new file mode 100644 index 0000000000..cac43ebf41 --- /dev/null +++ b/crates/gpui_web/src/run_replacements.rs @@ -0,0 +1,592 @@ +use crate::canvas_fallback::classify_canvas_fallback; +use gpui::{FontId, FontRun, LineLayout, Pixels, Point, ShapedGlyph, ShapedRun}; +use smallvec::SmallVec; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, PartialEq)] +pub(crate) struct Candidate { + pub(crate) source: Range, + pub(crate) font_id: FontId, + pub(crate) color: bool, + pub(crate) glyphs: Option, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct GlyphSpan { + pub(crate) range: Range, + pub(crate) position: Point, + pub(crate) end_x: Pixels, + pub(crate) contiguous: bool, + pub(crate) missing: bool, + pub(crate) native_color: bool, +} + +pub(crate) fn collect_candidates( + text: &str, + font_runs: &[FontRun], + layout: &LineLayout, +) -> Vec { + let mut font_runs = font_runs.iter().peekable(); + let mut font_run = font_runs.next(); + let mut run_end = font_run.map_or(0, |run| run.len); + let mut candidates = Vec::new(); + for (start, grapheme) in text.grapheme_indices(true) { + while start >= run_end { + font_run = font_runs.next(); + let Some(run) = font_run else { break }; + run_end += run.len; + } + let Some(run) = font_run else { break }; + // Paint-only run boundaries must not split a fallback grapheme. + while let Some(next_run) = font_runs.next_if(|next| next.font_id == run.font_id) { + run_end += next_run.len; + } + let end = start + grapheme.len(); + if end > run_end { + continue; + } + let Some(fallback) = classify_canvas_fallback(grapheme) else { + continue; + }; + candidates.push(Candidate { + source: start..end, + font_id: run.font_id, + color: fallback.emoji_presentation, + glyphs: None, + }); + } + if candidates.is_empty() { + return candidates; + } + + let mut glyphs = layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter()) + .enumerate() + .peekable(); + while let Some((index, glyph)) = glyphs.next() { + let candidate_index = + candidates.partition_point(|candidate| candidate.source.end <= glyph.index); + let Some(candidate) = candidates + .get_mut(candidate_index) + .filter(|candidate| candidate.source.contains(&glyph.index)) + else { + continue; + }; + let span = candidate.glyphs.get_or_insert_with(|| GlyphSpan { + range: index..index, + position: glyph.position, + end_x: glyph.position.x, + contiguous: true, + missing: false, + native_color: true, + }); + span.contiguous &= span.range.end == index; + span.range.end = index + 1; + span.end_x = glyphs + .peek() + .map_or(layout.width, |(_, next)| next.position.x); + span.missing |= glyph.id.0 == 0; + span.native_color &= glyph.is_emoji; + } + candidates +} + +pub(crate) struct Replacement { + pub(crate) glyph_range: Range, + pub(crate) font_id: FontId, + pub(crate) glyph: ShapedGlyph, + pub(crate) width_delta: Pixels, +} + +struct Segment { + font_id: FontId, + length: usize, +} + +/// Ranges refer to the original flattened glyph stream and must be sorted, +/// non-overlapping, nonempty, and in bounds. +pub(crate) fn apply_replacements(layout: &mut LineLayout, replacements: Vec) { + if replacements.is_empty() { + return; + } + + let mut replacements = replacements.into_iter().peekable(); + let mut original_index = 0; + let mut consumed_end = 0; + let mut shift = Pixels::ZERO; + let mut segments_by_run: SmallVec<[SmallVec<[Segment; 4]>; 4]> = SmallVec::new(); + let mut spare_buffers: SmallVec<[Vec; 4]> = SmallVec::new(); + let mut final_run_count = 0; + + layout.runs.retain_mut(|run| { + let mut segments: SmallVec<[Segment; 4]> = SmallVec::new(); + run.glyphs.retain_mut(|glyph| { + let index = original_index; + original_index += 1; + if index < consumed_end { + return false; + } + + let font_id = if let Some(replacement) = + replacements.next_if(|replacement| replacement.glyph_range.start == index) + { + consumed_end = replacement.glyph_range.end; + *glyph = replacement.glyph; + glyph.position.x += shift; + shift += replacement.width_delta; + replacement.font_id + } else { + glyph.position.x += shift; + run.font_id + }; + + if let Some(segment) = segments.last_mut().filter(|last| last.font_id == font_id) { + segment.length += 1; + } else { + segments.push(Segment { font_id, length: 1 }); + } + true + }); + + if segments.is_empty() { + spare_buffers.push(std::mem::take(&mut run.glyphs)); + false + } else { + final_run_count += segments.len(); + segments_by_run.push(segments); + true + } + }); + layout.width += shift; + + let source_run_count = layout.runs.len(); + layout.runs.resize_with(final_run_count, || ShapedRun { + font_id: FontId(0), + glyphs: Vec::new(), + }); + + // Every destination is at or after its source. Expanding from the right + // therefore cannot overwrite an original run that still needs processing. + let mut destination_end = final_run_count; + for (source_index, segments) in (0..source_run_count).zip(segments_by_run).rev() { + let destination_start = destination_end - segments.len(); + let mut glyph_end = layout.runs[source_index].glyphs.len(); + for (segment_index, segment) in segments.into_iter().enumerate().rev() { + let destination_index = destination_start + segment_index; + if segment_index == 0 { + layout.runs[source_index].font_id = segment.font_id; + layout.runs.swap(source_index, destination_index); + } else { + let glyph_start = glyph_end - segment.length; + let mut glyphs = spare_buffers.pop().unwrap_or_default(); + glyphs.extend( + layout.runs[source_index] + .glyphs + .drain(glyph_start..glyph_end), + ); + layout.runs[destination_index] = ShapedRun { + font_id: segment.font_id, + glyphs, + }; + glyph_end = glyph_start; + } + } + destination_end = destination_start; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Context as _, Result}; + use gpui::{GlyphId, point, px}; + + fn source_glyph(index: usize, x: f32, id: u32, color: bool) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(id), + position: point(px(x), px(2.0)), + index, + is_emoji: color, + } + } + + #[test] + fn candidates_capture_bounds_without_a_flattened_vector() -> Result<()> { + let text = "中a文 "; + let mut layout = layout(&[2, 0, 2]); + layout.width = px(50.0); + layout.runs[0].glyphs = vec![ + source_glyph(0, 0.0, 0, false), + source_glyph(3, 10.0, 1, false), + ]; + layout.runs[2].glyphs = vec![ + source_glyph(4, 25.0, 0, false), + source_glyph(7, 40.0, 3, false), + ]; + let candidates = collect_candidates( + text, + &[FontRun { + len: text.len(), + font_id: FontId(7), + }], + &layout, + ); + assert_eq!(candidates.len(), 2); + let first = candidates.first().context("missing first candidate")?; + assert_eq!(first.source, 0..3); + assert_eq!(first.font_id, FontId(7)); + assert_eq!( + first.glyphs, + Some(GlyphSpan { + range: 0..1, + position: point(px(0.0), px(2.0)), + end_x: px(10.0), + contiguous: true, + missing: true, + native_color: false, + }) + ); + let last = candidates.last().context("missing last candidate")?; + assert_eq!(last.source, 4..7); + let span = last.glyphs.as_ref().context("missing glyph span")?; + assert_eq!(span.range, 2..3); + assert_eq!(span.position.x, px(25.0)); + assert_eq!(span.end_x, px(40.0)); + Ok(()) + } + + #[test] + fn candidates_keep_graphemes_across_same_font_boundaries() -> Result<()> { + let text = "か\u{3099}"; + let mut layout = layout(&[1, 1]); + layout.runs[0].glyphs = vec![source_glyph(0, 0.0, 0, false)]; + layout.runs[1].glyphs = vec![source_glyph(3, 10.0, 0, false)]; + let mut font_runs = [ + FontRun { + len: 3, + font_id: FontId(7), + }, + FontRun { + len: 3, + font_id: FontId(7), + }, + ]; + let candidates = collect_candidates(text, &font_runs, &layout); + assert_eq!(candidates.len(), 1); + let span = candidates + .first() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing glyph span")?; + assert_eq!(span.range, 0..2); + assert_eq!(span.position.x, px(0.0)); + assert_eq!(span.end_x, layout.width); + assert!(span.contiguous); + + font_runs[1].font_id = FontId(8); + assert!(collect_candidates(text, &font_runs, &layout).is_empty()); + Ok(()) + } + + #[test] + fn candidates_handle_nonmonotonic_and_disjoint_source_indices() -> Result<()> { + let mut layout = layout(&[3]); + layout.width = px(50.0); + layout.runs[0].glyphs = vec![ + source_glyph(3, 0.0, 0, false), + source_glyph(0, 20.0, 0, false), + source_glyph(3, 30.0, 0, false), + ]; + let candidates = collect_candidates( + "中文", + &[FontRun { + len: 6, + font_id: FontId(7), + }], + &layout, + ); + let first = candidates + .first() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing first span")?; + assert_eq!(first.range, 1..2); + assert_eq!(first.position.x, px(20.0)); + assert_eq!(first.end_x, px(30.0)); + assert!(first.contiguous); + let last = candidates + .last() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing last span")?; + assert_eq!(last.range, 0..3); + assert_eq!(last.position.x, px(0.0)); + assert_eq!(last.end_x, layout.width); + assert!(!last.contiguous); + Ok(()) + } + + #[test] + fn candidates_preserve_native_coverage_and_color() -> Result<()> { + let mut layout = layout(&[1]); + let font_runs = [FontRun { + len: "😀".len(), + font_id: FontId(7), + }]; + for (id, color) in [(0, false), (1, false), (0, true), (1, true)] { + layout.runs[0].glyphs = vec![source_glyph(0, 0.0, id, color)]; + let candidates = collect_candidates("😀", &font_runs, &layout); + let candidate = candidates.first().context("missing candidate")?; + assert!(candidate.color); + let span = candidate.glyphs.as_ref().context("missing span")?; + assert_eq!(span.missing, id == 0); + assert_eq!(span.native_color, color); + } + layout.runs[0].glyphs.clear(); + let candidates = collect_candidates("😀", &font_runs, &layout); + assert!( + candidates + .first() + .context("missing candidate")? + .glyphs + .is_none() + ); + Ok(()) + } + + fn glyph(index: usize) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(index as u32), + position: point(px(index as f32 * 10.0), px(2.0)), + index: index * 2, + is_emoji: false, + } + } + + fn layout(run_lengths: &[usize]) -> LineLayout { + let mut index = 0; + let runs = run_lengths + .iter() + .enumerate() + .map(|(font, length)| { + let glyphs = (index..index + length).map(glyph).collect(); + index += length; + ShapedRun { + font_id: FontId(font), + glyphs, + } + }) + .collect(); + LineLayout { + runs, + width: px(index as f32 * 10.0), + font_size: px(14.0), + ascent: px(11.0), + descent: px(3.0), + len: index * 2, + } + } + + fn replacement(range: Range, font_id: usize, delta: f32) -> Replacement { + let mut glyph = glyph(range.start); + glyph.id = GlyphId(999); + glyph.position.x += px(1.0); + glyph.position.y = px(7.0); + glyph.is_emoji = true; + Replacement { + glyph_range: range, + font_id: FontId(font_id), + glyph, + width_delta: px(delta), + } + } + + fn reference(layout: &mut LineLayout, replacements: Vec) { + let original = std::mem::take(&mut layout.runs); + let mut replacements = replacements.into_iter().peekable(); + let mut shift = Pixels::ZERO; + let mut consumed_end = 0; + for (index, (font_id, mut glyph)) in original + .into_iter() + .flat_map(|run| { + run.glyphs + .into_iter() + .map(move |glyph| (run.font_id, glyph)) + }) + .enumerate() + { + if index < consumed_end { + continue; + } + let font_id = if replacements + .peek() + .is_some_and(|replacement| replacement.glyph_range.start == index) + { + let replacement = replacements.next().expect("peeked replacement"); + glyph = replacement.glyph; + glyph.position.x += shift; + shift += replacement.width_delta; + consumed_end = replacement.glyph_range.end; + replacement.font_id + } else { + glyph.position.x += shift; + font_id + }; + if let Some(run) = layout.runs.last_mut().filter(|run| run.font_id == font_id) { + run.glyphs.push(glyph); + } else { + layout.runs.push(ShapedRun { + font_id, + glyphs: vec![glyph], + }); + } + } + layout.width += shift; + } + + fn assert_equal(actual: &LineLayout, expected: &LineLayout) { + let flatten = |layout: &LineLayout| { + layout + .runs + .iter() + .flat_map(|run| { + run.glyphs.iter().map(move |glyph| { + ( + run.font_id, + glyph.id, + glyph.position, + glyph.index, + glyph.is_emoji, + ) + }) + }) + .collect::>() + }; + assert_eq!(flatten(actual), flatten(expected)); + assert_eq!(actual.width, expected.width); + assert_eq!(actual.font_size, expected.font_size); + assert_eq!(actual.ascent, expected.ascent); + assert_eq!(actual.descent, expected.descent); + assert_eq!(actual.len, expected.len); + } + + #[test] + fn no_replacements_preserves_even_empty_runs() { + let mut actual = layout(&[0, 3, 0]); + let pointer = actual.runs.as_ptr(); + let glyph_pointer = actual.runs[1].glyphs.as_ptr(); + apply_replacements(&mut actual, vec![]); + assert_eq!(actual.runs.len(), 3); + assert_eq!(actual.runs.as_ptr(), pointer); + assert_eq!(actual.runs[1].glyphs.as_ptr(), glyph_pointer); + assert_equal(&actual, &layout(&[0, 3, 0])); + apply_replacements(&mut layout(&[]), vec![]); + apply_replacements(&mut layout(&[0, 0]), vec![]); + } + + #[test] + fn whole_run_reuses_both_allocations() { + let mut actual = layout(&[8]); + actual.runs.reserve(4); + actual.runs[0].glyphs.reserve(10); + let runs = (actual.runs.as_ptr(), actual.runs.capacity()); + let glyphs = ( + actual.runs[0].glyphs.as_ptr(), + actual.runs[0].glyphs.capacity(), + ); + apply_replacements(&mut actual, vec![replacement(0..8, 9, -20.0)]); + assert_eq!((actual.runs.as_ptr(), actual.runs.capacity()), runs); + assert_eq!( + ( + actual.runs[0].glyphs.as_ptr(), + actual.runs[0].glyphs.capacity() + ), + glyphs + ); + assert_eq!(actual.runs[0].font_id, FontId(9)); + assert_eq!(actual.runs[0].glyphs.len(), 1); + } + + #[test] + fn splits_reuse_outer_prefix_unaffected_and_emptied_buffers() { + let mut actual = layout(&[4, 4, 3]); + actual.runs.reserve(8); + let outer = (actual.runs.as_ptr(), actual.runs.capacity()); + let prefix = actual.runs[0].glyphs.as_ptr(); + let emptied = ( + actual.runs[1].glyphs.as_ptr(), + actual.runs[1].glyphs.capacity(), + ); + let unaffected = actual.runs[2].glyphs.as_ptr(); + apply_replacements(&mut actual, vec![replacement(2..8, 9, -3.0)]); + assert_eq!((actual.runs.as_ptr(), actual.runs.capacity()), outer); + assert_eq!(actual.runs[0].glyphs.as_ptr(), prefix); + assert_eq!( + ( + actual.runs[1].glyphs.as_ptr(), + actual.runs[1].glyphs.capacity() + ), + emptied + ); + assert_eq!(actual.runs[2].glyphs.as_ptr(), unaffected); + let mut expected = layout(&[4, 4, 3]); + reference(&mut expected, vec![replacement(2..8, 9, -3.0)]); + assert_equal(&actual, &expected); + } + + #[test] + fn multiple_splits_and_shifts() { + let mut actual = layout(&[7, 2, 3]); + actual.runs.reserve(12); + let outer = actual.runs.as_ptr(); + let mut expected = layout(&[7, 2, 3]); + let replacements = || { + vec![ + replacement(1..2, 9, 3.0), + replacement(3..5, 8, -7.0), + replacement(6..10, 9, 2.0), + ] + }; + apply_replacements(&mut actual, replacements()); + reference(&mut expected, replacements()); + assert_equal(&actual, &expected); + assert_eq!(actual.runs.as_ptr(), outer); + } + + #[test] + fn exhaustive_small_streams_match_reference() { + // Base-four choices at each original glyph: keep, start a native-font + // replacement, start a fallback replacement, or extend the previous one. + for first in 0..=4 { + for second in 0..=4 - first { + let lengths = [first, 0, second, 4 - first - second]; + for choices in 0usize..4usize.pow(4) { + let replacements = || { + let mut replacements: Vec = Vec::new(); + for index in 0..4 { + match (choices >> (2 * index)) & 3 { + 0 => {} + 1 => replacements.push(replacement(index..index + 1, 0, 2.0)), + 2 => replacements.push(replacement(index..index + 1, 9, -3.0)), + _ => { + if let Some(previous) = replacements + .last_mut() + .filter(|previous| previous.glyph_range.end == index) + { + previous.glyph_range.end += 1; + } + } + } + } + replacements + }; + let mut actual = layout(&lengths); + let mut expected = layout(&lengths); + apply_replacements(&mut actual, replacements()); + reference(&mut expected, replacements()); + assert_equal(&actual, &expected); + } + } + } + } +} diff --git a/crates/gpui_web/src/text_system.rs b/crates/gpui_web/src/text_system.rs new file mode 100644 index 0000000000..71ad4c71e0 --- /dev/null +++ b/crates/gpui_web/src/text_system.rs @@ -0,0 +1,489 @@ +use crate::canvas_fallback::{CanvasFontFallback, classify_canvas_fallback}; +use crate::canvas_text::{self, CanvasTextMetrics}; +use crate::glyph_cache::{CanvasGlyph, GlyphCache}; +use crate::run_replacements::{Replacement, apply_replacements, collect_candidates}; +use anyhow::{Context as _, Result, ensure}; +use gpui::{ + Bounds, DevicePixels, Font, FontId, FontMetrics, FontRun, FontStyle, GlyphId, Hsla, LineLayout, + Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, + ShapedGlyph, Size, TextRenderingMode, point, px, size, +}; +use gpui_wgpu::CosmicTextSystem; +use parking_lot::RwLock; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +// Cosmic's font IDs index its loaded-font vector. Keep browser IDs in a disjoint namespace. +const CANVAS_FONT_BIT: usize = 1 << (usize::BITS - 1); +const MAX_MEASUREMENTS: usize = 4096; + +pub(crate) struct WebTextSystem { + native: CosmicTextSystem, + state: RwLock, + canvas_font_fallback: CanvasFontFallback, +} + +#[derive(Default)] +struct State { + descriptors: HashMap, + canvas_fonts: Vec>, + canvas_font_ids: HashMap, + glyphs: GlyphCache, + measurements: HashMap<(FontId, GlyphId, Pixels), CanvasTextMetrics>, +} + +struct CanvasFont { + native_id: FontId, + descriptor: Font, + monospace: bool, +} + +impl State { + fn cached_fallback( + &self, + native_id: FontId, + text: &str, + color: bool, + font_size: Pixels, + ) -> Option<(FontId, GlyphId, CanvasTextMetrics)> { + let font_id = *self.canvas_font_ids.get(&native_id)?; + let glyph_id = self.glyphs.id_for(font_id, text, color)?; + let metrics = *self.measurements.get(&(font_id, glyph_id, font_size))?; + Some((font_id, glyph_id, metrics)) + } +} + +impl WebTextSystem { + pub(crate) fn new( + system_font_fallback: &str, + canvas_font_fallback: CanvasFontFallback, + ) -> Self { + Self { + native: CosmicTextSystem::new_without_system_fonts(system_font_fallback), + state: RwLock::new(State::default()), + canvas_font_fallback, + } + } + + fn canvas_font(&self, font_id: FontId) -> Option> { + if font_id.0 & CANVAS_FONT_BIT == 0 { + return None; + } + self.state + .read() + .canvas_fonts + .get(font_id.0 & !CANVAS_FONT_BIT) + .cloned() + } + + fn native_font_id(&self, font_id: FontId) -> FontId { + self.canvas_font(font_id) + .map_or(font_id, |font| font.native_id) + } + + fn canvas_font_id(&self, native_id: FontId) -> Result { + let descriptor = { + let state = self.state.read(); + if let Some(font_id) = state.canvas_font_ids.get(&native_id) { + return Ok(*font_id); + } + state + .descriptors + .get(&native_id) + .cloned() + .context("missing source font descriptor for Canvas fallback")? + }; + let monospace = match ( + self.native.glyph_for_char(native_id, 'i'), + self.native.glyph_for_char(native_id, 'm'), + ) { + (Some(narrow), Some(wide)) => { + self.native.advance(native_id, narrow)?.width + == self.native.advance(native_id, wide)?.width + } + _ => false, + }; + let mut state = self.state.write(); + if let Some(font_id) = state.canvas_font_ids.get(&native_id) { + return Ok(*font_id); + } + ensure!( + state.canvas_fonts.len() < CANVAS_FONT_BIT, + "Canvas font ID space exhausted" + ); + let font_id = FontId(CANVAS_FONT_BIT | state.canvas_fonts.len()); + state.canvas_fonts.push(Arc::new(CanvasFont { + native_id, + descriptor, + monospace, + })); + state.canvas_font_ids.insert(native_id, font_id); + Ok(font_id) + } + + fn register_glyph(&self, font_id: FontId, text: &str, color: bool) -> Result { + if let Some(glyph_id) = self.state.read().glyphs.id_for(font_id, text, color) { + return Ok(glyph_id); + } + // Another thread may register the same glyph between the read and write locks. + self.state + .write() + .glyphs + .get_or_insert(font_id, text, color) + } + + fn canvas_glyph(&self, font_id: FontId, glyph_id: GlyphId) -> Result { + self.state + .read() + .glyphs + .glyph(font_id, glyph_id) + .cloned() + .context("invalid Canvas glyph ID") + } + + fn measure( + &self, + font_id: FontId, + glyph_id: GlyphId, + font_size: Pixels, + ) -> Result { + let key = (font_id, glyph_id, font_size); + if let Some(metrics) = self.state.read().measurements.get(&key) { + return Ok(*metrics); + } + let font = self + .canvas_font(font_id) + .context("invalid Canvas font ID")?; + let glyph = self.canvas_glyph(font_id, glyph_id)?; + let metrics = canvas_text::measure(&glyph.text, &font.css_font(font_size, glyph.color)?)?; + ensure!(metrics.advance >= 0.0, "Canvas returned a negative advance"); + let mut state = self.state.write(); + if state.measurements.len() >= MAX_MEASUREMENTS { + state.measurements.clear(); + } + state.measurements.insert(key, metrics); + Ok(metrics) + } + + fn fallback_glyph( + &self, + native_id: FontId, + text: &str, + color: bool, + font_size: Pixels, + ) -> Result<(FontId, GlyphId, CanvasTextMetrics)> { + if let Some(cached) = self + .state + .read() + .cached_fallback(native_id, text, color, font_size) + { + return Ok(cached); + } + let font_id = self.canvas_font_id(native_id)?; + let glyph_id = self.register_glyph(font_id, text, color)?; + let metrics = self.measure(font_id, glyph_id, font_size)?; + Ok((font_id, glyph_id, metrics)) + } + + fn apply_fallback(&self, text: &str, font_runs: &[FontRun], layout: &mut LineLayout) { + if self.canvas_font_fallback == CanvasFontFallback::Disabled + || text.is_ascii() + || !f32::from(layout.font_size).is_finite() + || layout.font_size <= px(0.) + { + return; + } + let mut replacements = Vec::new(); + for candidate in collect_candidates(text, font_runs, layout) { + if !self.canvas_font_fallback.allows(candidate.color) { + continue; + } + let Some(glyphs) = candidate.glyphs else { + continue; + }; + if !glyphs.contiguous || !(glyphs.missing || (candidate.color && !glyphs.native_color)) + { + continue; + } + let old_width = glyphs.end_x - glyphs.position.x; + if old_width < px(0.) { + continue; + } + let replacement = (|| -> Result { + let text = text + .get(candidate.source.clone()) + .context("invalid Canvas fallback source range")?; + let (font_id, glyph_id, metrics) = self.fallback_glyph( + candidate.font_id, + text, + candidate.color, + layout.font_size, + )?; + Ok(Replacement { + glyph_range: glyphs.range, + font_id, + glyph: ShapedGlyph { + id: glyph_id, + position: glyphs.position, + index: candidate.source.start, + is_emoji: candidate.color, + }, + width_delta: px(metrics.advance) - old_width, + }) + })(); + match replacement { + Ok(replacement) => replacements.push(replacement), + Err(error) => log::warn!("Canvas font fallback failed: {error:#}"), + } + } + if replacements.is_empty() { + return; + } + replacements.sort_unstable_by_key(|replacement| replacement.glyph_range.start); + // Keep the primary font's baseline and line spacing stable. Browser ink + // extents affect raster bounds, not the surrounding editor's line metrics. + apply_replacements(layout, replacements); + } +} + +impl PlatformTextSystem for WebTextSystem { + fn add_fonts(&self, fonts: Vec>) -> Result<()> { + self.native.add_fonts(fonts)?; + self.state.write().measurements.clear(); + Ok(()) + } + + fn all_font_names(&self) -> Vec { + self.native.all_font_names() + } + + fn font_id(&self, font: &Font) -> Result { + let font_id = self.native.font_id(font)?; + ensure!( + font_id.0 & CANVAS_FONT_BIT == 0, + "native font ID space exhausted" + ); + let mut descriptor = font.clone(); + (descriptor.weight, descriptor.style) = self.native.font_weight_and_style(font_id)?; + self.state + .write() + .descriptors + .entry(font_id) + .or_insert(descriptor); + Ok(font_id) + } + + fn prewarm_fonts(&self, font_ids: &[FontId]) { + if font_ids + .iter() + .all(|font_id| font_id.0 & CANVAS_FONT_BIT == 0) + { + return self.native.prewarm_fonts(font_ids); + } + let native_ids: Vec<_> = font_ids + .iter() + .map(|font_id| self.native_font_id(*font_id)) + .collect(); + self.native.prewarm_fonts(&native_ids); + } + + fn font_metrics(&self, font_id: FontId) -> FontMetrics { + self.native.font_metrics(self.native_font_id(font_id)) + } + + fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let Some(font) = self.canvas_font(font_id) else { + return self.native.typographic_bounds(font_id, glyph_id); + }; + let font_size = px(self.native.font_metrics(font.native_id).units_per_em as f32); + let metrics = self.measure(font_id, glyph_id, font_size)?; + Ok(Bounds { + origin: point(metrics.left, -metrics.ascent), + size: size( + metrics.right - metrics.left, + metrics.ascent + metrics.descent, + ), + }) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let Some(font) = self.canvas_font(font_id) else { + return self.native.advance(font_id, glyph_id); + }; + let font_size = px(self.native.font_metrics(font.native_id).units_per_em as f32); + Ok(size( + self.measure(font_id, glyph_id, font_size)?.advance, + 0., + )) + } + + fn glyph_for_char(&self, font_id: FontId, character: char) -> Option { + if self.canvas_font(font_id).is_none() { + return self.native.glyph_for_char(font_id, character); + } + let mut buffer = [0; 4]; + let text = character.encode_utf8(&mut buffer); + let color = + classify_canvas_fallback(text).is_some_and(|fallback| fallback.emoji_presentation); + match self.register_glyph(font_id, text, color) { + Ok(glyph_id) => Some(glyph_id), + Err(error) => { + log::warn!("registering Canvas glyph failed: {error:#}"); + None + } + } + } + + fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { + if self.canvas_font(params.font_id).is_none() { + return self.native.glyph_raster_bounds(params); + } + let metrics = self.measure( + params.font_id, + params.glyph_id, + params.font_size * params.scale_factor, + )?; + if metrics.right <= metrics.left || metrics.ascent + metrics.descent <= 0. { + return Ok(Bounds::default()); + } + let (offset_x, offset_y) = subpixel_offset(params); + let left = (f64::from(metrics.left) + f64::from(offset_x)).floor() - 1.; + let top = (-f64::from(metrics.ascent) + f64::from(offset_y)).floor() - 1.; + let right = (f64::from(metrics.right) + f64::from(offset_x)).ceil() + 1.; + let bottom = (f64::from(metrics.descent) + f64::from(offset_y)).ceil() + 1.; + ensure!( + [left, top, right, bottom] + .into_iter() + .all(|value| value >= f64::from(i32::MIN) && value <= f64::from(i32::MAX)), + "Canvas text bounds exceed device coordinates" + ); + let width = (right as i32) + .checked_sub(left as i32) + .context("Canvas text width overflow")?; + let height = (bottom as i32) + .checked_sub(top as i32) + .context("Canvas text height overflow")?; + Ok(Bounds { + origin: point((left as i32).into(), (top as i32).into()), + size: size(width.into(), height.into()), + }) + } + + fn rasterize_glyph( + &self, + params: &RenderGlyphParams, + bounds: Bounds, + ) -> Result<(Size, Vec)> { + let Some(font) = self.canvas_font(params.font_id) else { + return self.native.rasterize_glyph(params, bounds); + }; + let glyph = self.canvas_glyph(params.font_id, params.glyph_id)?; + let css_font = font.css_font(params.font_size * params.scale_factor, glyph.color)?; + let mut pixels = canvas_text::rasterize( + &glyph.text, + &css_font, + bounds, + subpixel_offset(params), + glyph.color, + )?; + if params.subpixel_rendering && !glyph.color { + pixels = pixels.into_iter().flat_map(|alpha| [alpha; 4]).collect(); + } + Ok((bounds.size, pixels)) + } + + fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { + let native_runs: Cow<'_, [FontRun]> = + if runs.iter().any(|run| run.font_id.0 & CANVAS_FONT_BIT != 0) { + Cow::Owned( + runs.iter() + .map(|run| FontRun { + len: run.len, + font_id: self.native_font_id(run.font_id), + }) + .collect(), + ) + } else { + Cow::Borrowed(runs) + }; + let mut layout = self.native.layout_line(text, font_size, &native_runs); + self.apply_fallback(text, &native_runs, &mut layout); + layout + } + + fn recommended_rendering_mode(&self, font_id: FontId, font_size: Pixels) -> TextRenderingMode { + if self.canvas_font(font_id).is_some() { + TextRenderingMode::Grayscale + } else { + self.native.recommended_rendering_mode(font_id, font_size) + } + } + + fn glyph_dilation_for_color(&self, color: Hsla) -> u8 { + self.native.glyph_dilation_for_color(color) + } +} + +impl CanvasFont { + fn css_font(&self, font_size: Pixels, color: bool) -> Result { + let font_size = f32::from(font_size); + ensure!( + font_size.is_finite() && font_size > 0., + "invalid Canvas font size" + ); + let style = match self.descriptor.style { + FontStyle::Normal => "normal", + FontStyle::Italic => "italic", + FontStyle::Oblique => "oblique", + }; + let mut families = Vec::new(); + if !color && !self.descriptor.family.starts_with('.') { + families.push(quote_family(&self.descriptor.family)); + } + if let Some(fallbacks) = &self.descriptor.fallbacks { + families.extend( + fallbacks + .fallback_list() + .iter() + .map(|family| quote_family(family)), + ); + } + families.push( + if color { + "emoji" + } else if self.monospace { + "monospace" + } else { + "sans-serif" + } + .to_owned(), + ); + Ok(format!( + "{style} {} {font_size}px {}", + self.descriptor.weight.0, + families.join(", ") + )) + } +} + +fn quote_family(family: &str) -> String { + let mut quoted = String::from("\""); + for character in family.chars() { + match character { + '\\' | '"' => { + quoted.push('\\'); + quoted.push(character); + } + character if character.is_control() => quoted.push(' '), + character => quoted.push(character), + } + } + quoted.push('"'); + quoted +} + +fn subpixel_offset(params: &RenderGlyphParams) -> (f32, f32) { + ( + f32::from(params.subpixel_variant.x) / SUBPIXEL_VARIANTS_X as f32, + f32::from(params.subpixel_variant.y) / SUBPIXEL_VARIANTS_Y as f32, + ) +} diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index c871b11fbc..e31ef48b21 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -84,6 +84,29 @@ impl FontMatchProperties { } impl CosmicTextSystem { + /// Returns the selected face's weight and style, which may differ from the request. + pub fn font_weight_and_style( + &self, + font_id: FontId, + ) -> Result<(gpui::FontWeight, gpui::FontStyle)> { + let state = self.0.read(); + let font = state + .loaded_fonts + .get(font_id.0) + .context("invalid font ID")?; + let face = state + .font_system + .db() + .face(font.font.id()) + .context("font face not found")?; + let style = match face.style { + cosmic_text::Style::Normal => gpui::FontStyle::Normal, + cosmic_text::Style::Italic => gpui::FontStyle::Italic, + cosmic_text::Style::Oblique => gpui::FontStyle::Oblique, + }; + Ok((gpui::FontWeight(face.weight.0 as f32), style)) + } + pub fn new(system_font_fallback: &str) -> Self { let font_system = FontSystem::new(); @@ -1095,6 +1118,31 @@ mod tests { Ok(text_system) } + #[test] + fn font_properties_describe_the_selected_face() -> Result<()> { + let text_system = text_system()?; + let regular = gpui::font("IBM Plex Sans"); + let regular_id = text_system.font_id(®ular)?; + for (weight, style) in [ + (gpui::FontWeight::MEDIUM, gpui::FontStyle::Normal), + (gpui::FontWeight::BOLD, gpui::FontStyle::Italic), + (gpui::FontWeight::NORMAL, gpui::FontStyle::Oblique), + ] { + let requested = Font { + weight, + style, + ..regular.clone() + }; + let font_id = text_system.font_id(&requested)?; + assert_eq!(font_id, regular_id); + assert_eq!( + text_system.font_weight_and_style(font_id)?, + (gpui::FontWeight::NORMAL, gpui::FontStyle::Normal) + ); + } + Ok(()) + } + fn layout_text(text_system: &CosmicTextSystem, text: &str) -> Result { let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; let runs = [FontRun {