gpui: Add Canvas font fallback for web text (#64110)

## Summary

Add browser-font fallback to GPUI on the web, so applications can render
emoji using the user’s local fonts without bundling an emoji font.

GPUI continues to shape text with Cosmic Text using application-provided
fonts. When those fonts cannot supply an eligible grapheme or its
requested emoji presentation, the web backend measures and rasterizes
the complete grapheme using Canvas 2D, then caches and paints the result
through GPUI’s existing glyph atlas.

### Configurable fallback

- **Emoji-only by default**, including supported variation-selector,
skin-tone, flag, keycap, and ZWJ sequences.
- Applications can opt into **CJK fallback** for a conservative subset
of horizontal Han, kana, and modern Hangul text.
- Applications can disable Canvas fallback entirely.

CJK fallback renders graphemes independently. It prioritizes readable
text over full fidelity to contextual spacing and font features. Browser
font coverage and appearance depend on the user’s environment.

### Web input and application lifecycle

- Fix text insertion that arrives without a corresponding keyboard
event, allowing emoji input through the browser’s text-input path.
- Keep the GPUI application alive after `Application::run` returns on
WebAssembly. Browser startup is asynchronous; releasing the application
after the startup callback previously caused the window to disappear.

### Browser example gallery

Make the existing GPUI examples available from a single development
server, with separate routes for hello world, styled text, text layout,
wrapping, input, and the prime sieve demo.

The gallery supports direct links and reloads, loads only the selected
example, and provides a simple way to exercise font fallback and text
input.

## Validation

- Native unit tests cover fallback eligibility and presentation, policy
defaults, glyph-cache reuse, and in-place glyph-run replacement.
- All six gallery binaries pass WASM compilation checks.
- Browser smoke coverage exercises emoji insertion, whole-grapheme
Canvas rendering, cache reuse, and deletion.
- Manually exercised the gallery and fallback rendering in Chrome.

Release Notes:

- N/A
This commit is contained in:
Max Brunsfeld authored and GitHub committed 2026-09-12 20:38:07 +00:00
1 parent f05265c624
commit abc6b3b357
18 files changed
+2419 -9

No files matched your search

Generated
+4
View File
@@ -7739,6 +7739,10 @@ dependencies = [
"parking_lot",
"raw-window-handle",
"scheduler",
"smallvec",
"unicode-properties",
"unicode-script",
"unicode-segmentation",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
+57
View File
@@ -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 <http://localhost:8080/> 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
+17
View File
@@ -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()
+6
View File
@@ -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<F>(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.
+15
View File
@@ -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"]
@@ -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"
@@ -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);
}
}
}
+80 -2
View File
@@ -3,8 +3,16 @@
<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>
<title>GPUI Web Examples</title>
<link data-trunk rel="rust" data-bin="hello_web" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
<!-- Trunk's worker pipelines build auxiliary modules without starting them.
The gallery imports these on the main thread, not in Web Workers. -->
<link data-trunk rel="rust" data-type="worker" data-bin="hello_world" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
<link data-trunk rel="rust" data-type="worker" data-bin="text" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
<link data-trunk rel="rust" data-type="worker" data-bin="text_layout" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
<link data-trunk rel="rust" data-type="worker" data-bin="text_wrapper" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
<link data-trunk rel="rust" data-type="worker" data-bin="input" data-bindgen-target="web" data-keep-debug data-wasm-opt="0" />
<script data-trunk type="module" src="gallery.js"></script>
<style>
* {
margin: 0;
@@ -16,6 +24,61 @@
margin: 0;
height: 100%;
}
body {
font-family: system-ui, sans-serif;
color: #202030;
background: #f5f5f8;
}
main {
max-width: 48rem;
margin: 0 auto;
padding: 3rem 1.5rem;
}
h1, main > p {
margin-bottom: 1.5rem;
}
ul {
list-style: none;
display: grid;
gap: 0.75rem;
}
li a {
display: block;
padding: 1rem;
border: 1px solid #d0d0dc;
border-radius: 0.5rem;
color: inherit;
background: white;
text-decoration: none;
}
li a:hover, li a:focus-visible {
border-color: #5050a0;
}
li span {
display: block;
margin-top: 0.25rem;
color: #555566;
}
#back {
position: fixed;
right: 0.75rem;
bottom: 0.75rem;
z-index: 1;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
color: #202030;
background: #ffffffee;
}
#status {
position: fixed;
top: 1rem;
left: 1rem;
right: 1rem;
z-index: 1;
padding: 1rem;
background: white;
white-space: pre-wrap;
}
canvas {
display: block;
width: 100%;
@@ -27,5 +90,20 @@
}
</style>
</head>
<body></body>
<body>
<main id="gallery">
<h1>GPUI Web Examples</h1>
<p>Each example runs in its own WebAssembly instance. Choose an example to open it.</p>
<ul>
<li><a href="/hello-world" data-module="hello_world"><strong>Hello world</strong><span>Basic GPUI layout and styling.</span></a></li>
<li><a href="/text" data-module="text"><strong>Styled text</strong><span>Typography, font sizes, and line heights.</span></a></li>
<li><a href="/text-layout" data-module="text_layout"><strong>Text layout</strong><span>Alignment, decorations, and font weights.</span></a></li>
<li><a href="/text-wrapper" data-module="text_wrapper"><strong>Text wrapping</strong><span>Wrapping and truncation, including CJK sample text.</span></a></li>
<li><a href="/input" data-module="input"><strong>Text input</strong><span>Typing, cursor movement, and selections.</span></a></li>
<li><a href="/prime-sieve" data-module="hello_web"><strong>Prime sieve</strong><span>Background tasks and progress updates.</span></a></li>
</ul>
</main>
<a id="back" href="/" hidden>All examples</a>
<p id="status" role="status" hidden></p>
</body>
</html>
@@ -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);
}
@@ -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
+463
View File
@@ -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<CanvasFallback> {
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<bool> {
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<bool> {
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::<Vec<_>>(),
["か\u{3099}", "👩🏽‍💻", "漢\u{e0100}"]
);
}
}
+197
View File
@@ -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<Option<TextCanvas>> = 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<Self> {
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::<OffscreenCanvasRenderingContext2d>()
.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<T>(operation: impl FnOnce(&mut TextCanvas) -> Result<T>) -> Result<T> {
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<CanvasTextMetrics> {
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<DevicePixels>,
subpixel_offset: (f32, f32),
color: bool,
) -> Result<Vec<u8>> {
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)
}
})
}
+133
View File
@@ -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<CanvasGlyph>,
ids_by_font: HashMap<(FontId, bool), HashMap<SharedString, GlyphId>>,
}
impl GlyphCache {
pub(crate) fn id_for(&self, font_id: FontId, text: &str, color: bool) -> Option<GlyphId> {
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<GlyphId> {
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(())
}
}
+32 -4
View File
@@ -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;
+19 -3
View File
@@ -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<dyn PlatformTextSystem> = text_system;
let active_display: Rc<dyn PlatformDisplay> =
Rc::new(WebDisplay::new(browser_window.clone()));
+592
View File
@@ -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<usize>,
pub(crate) font_id: FontId,
pub(crate) color: bool,
pub(crate) glyphs: Option<GlyphSpan>,
}
#[derive(Debug, PartialEq)]
pub(crate) struct GlyphSpan {
pub(crate) range: Range<usize>,
pub(crate) position: Point<Pixels>,
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<Candidate> {
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<usize>,
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<Replacement>) {
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<ShapedGlyph>; 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<usize>, 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<Replacement>) {
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::<Vec<_>>()
};
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<Replacement> = 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);
}
}
}
}
}
+489
View File
@@ -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<State>,
canvas_font_fallback: CanvasFontFallback,
}
#[derive(Default)]
struct State {
descriptors: HashMap<FontId, Font>,
canvas_fonts: Vec<Arc<CanvasFont>>,
canvas_font_ids: HashMap<FontId, FontId>,
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<Arc<CanvasFont>> {
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<FontId> {
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<GlyphId> {
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<CanvasGlyph> {
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<CanvasTextMetrics> {
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<Replacement> {
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<Cow<'static, [u8]>>) -> Result<()> {
self.native.add_fonts(fonts)?;
self.state.write().measurements.clear();
Ok(())
}
fn all_font_names(&self) -> Vec<String> {
self.native.all_font_names()
}
fn font_id(&self, font: &Font) -> Result<FontId> {
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<Bounds<f32>> {
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<Size<f32>> {
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<GlyphId> {
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<Bounds<DevicePixels>> {
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<DevicePixels>,
) -> Result<(Size<DevicePixels>, Vec<u8>)> {
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<String> {
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,
)
}
@@ -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(&regular)?;
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<LineLayout> {
let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?;
let runs = [FontRun {