From f0fae1792f22cfbd27267ac10fe8ec0ab511de20 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 20 Oct 2026 14:25:28 +0800 Subject: [PATCH] feat: shape and antialias Tessera text with system fonts --- .gitea/workflows/README.md | 4 + Cargo.lock | 244 ++++++++++++++++++++++++++++- Cargo.toml | 3 +- Dockerfile.ci | 2 +- README.md | 21 ++- examples/README.md | 1 + examples/fonts.endor | 5 + scripts/README.md | 9 +- scripts/build-macos-app.sh | 2 +- src/README.md | 8 +- src/api.rs | 6 +- src/developer_tools.rs | 39 ++--- src/display_list.rs | 3 +- src/document.rs | 2 +- src/endor_host.rs | 18 +-- src/layout.rs | 6 +- src/main.rs | 1 + src/software_renderer.rs | 72 ++++----- src/text.rs | 310 +++++++++++++++++++++++++++++++++++++ 19 files changed, 661 insertions(+), 95 deletions(-) create mode 100644 examples/fonts.endor create mode 100644 src/text.rs diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md index ae5b4af..f869a58 100644 --- a/.gitea/workflows/README.md +++ b/.gitea/workflows/README.md @@ -13,3 +13,7 @@ through Aster's Interactive interface. Function-entry compatibility, original source positions, page methods and page budgets are also checked. The workflow checks the macOS packaging script's syntax without running it on Linux. Docker is used only in this remote workflow; local compilation is native. + +The test image installs DejaVu Sans/Mono and Noto CJK for font metrics and Chinese +fallback checks. Text regression tests inspect glyph selection, measured advances, +fractional scale, multiline bounds, Unicode truncation and antialiased opacity. diff --git a/Cargo.lock b/Cargo.lock index bf4fd20..4afd4f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,6 +275,39 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.2", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash", + "self_cell", + "skrifa 0.40.0", + "smol_str 0.3.6", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -408,10 +441,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] -name = "font8x8" -version = "0.3.1" +name = "font-types" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] [[package]] name = "foreign-types" @@ -506,6 +574,19 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -635,6 +716,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.25" @@ -647,6 +734,12 @@ dependencies = [ "redox_syscall 0.9.4", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1170,12 +1263,40 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.7", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -1203,6 +1324,18 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1272,6 +1405,12 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -1343,12 +1482,41 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -1389,6 +1557,12 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" + [[package]] name = "softbuffer" version = "0.4.8" @@ -1427,6 +1601,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.119" @@ -1449,6 +1634,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tessera" version = "0.1.0" @@ -1456,12 +1650,13 @@ dependencies = [ "anyhow", "compiler", "core", - "font8x8", + "cosmic-text", "frontend", "serde", "serde_json", "softbuffer", "tiny_http", + "unicode-segmentation", "winit", ] @@ -1555,6 +1750,12 @@ dependencies = [ "log", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" @@ -1615,6 +1816,15 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" @@ -1622,6 +1832,18 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -1979,7 +2201,7 @@ dependencies = [ "rustix 0.38.44", "sctk-adwaita", "smithay-client-toolkit", - "smol_str", + "smol_str 0.2.2", "tracing", "unicode-segmentation", "wasm-bindgen", @@ -2068,6 +2290,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.59" diff --git a/Cargo.toml b/Cargo.toml index c215854..a9506a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,14 @@ license = "MIT OR Apache-2.0" [dependencies] anyhow = "1.0" compiler = { path = "vendor/aster/crates/compiler" } +cosmic-text = "0.19" endor_core = { package = "core", path = "vendor/aster/crates/core" } -font8x8 = "0.3" frontend = { path = "vendor/aster/crates/frontend" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" softbuffer = "0.4.8" tiny_http = "0.12" +unicode-segmentation = "1.12" winit = "0.30.13" [profile.release] diff --git a/Dockerfile.ci b/Dockerfile.ci index 0977ee6..146c4a1 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1,6 +1,6 @@ FROM rust:1.91.1-bookworm WORKDIR /build -RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libx11-dev libxkbcommon-dev libwayland-dev libudev-dev libegl1-mesa-dev libgbm-dev \ +RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libx11-dev libxkbcommon-dev libwayland-dev libudev-dev libegl1-mesa-dev libgbm-dev fonts-dejavu-core fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* \ && rustup component add rustfmt clippy ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 diff --git a/README.md b/README.md index 5e2dabf..ef03fad 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ cargo build --release --locked Cargo compiles the required Aster libraries together with Tessera. No separate Aster build, service or deployment is required. Local builds download dependencies -from official crates.io; do not substitute a mirror. Docker and automated tests +from official crates.io by default. A temporary dependency mirror may be used +when the official source is unavailable, without changing global configuration. +Docker and automated tests are not used locally. The command above produces a native executable. To create a Finder-launchable development app on macOS, run: @@ -113,7 +115,15 @@ more than once, its final successful render selects the visible root. Rendering does not publish partial execution: a later error rejects the entire submission. Aster keeps its exact numeric representation; conversion occurs only at the UI -boundary. Text uses the current bitmap renderer, with Chinese fonts still pending. +boundary. Text is shaped and antialiased with COSMIC Text and Swash, using +installed system fonts and per-glyph fallback (including Chinese when a CJK font +is installed). UI text uses a proportional sans-serif font; developer source uses +a monospace font. The existing size argument remains a 1..8 multiplier: size 1 +is 14 logical pixels with a 20-pixel line height; size 2 is 28/40. Row/Column layout +uses measured text advances and multiline height. Device scale remains fractional, +and glyphs are rasterized at the actual display resolution rather than enlarged. +The application does not bundle proprietary OS fonts. Linux installations need +fonts such as DejaVu Sans/Mono and Noto CJK; the remote test image installs these. ## Candidate execution and publication @@ -162,7 +172,8 @@ source budgets and VM instruction limits are not a complete untrusted-code sandb Toggle the sidebar with Command-Option-I or F12 on macOS, Control-Shift-I or F12 on Windows/Linux. It displays Aster AST functions, bindings, calls and expressions. Selection shows their ranges in the exact submitted source. Tessera does not -maintain a second language AST. +maintain a second language AST. Source lines and tree labels are fitted to the +available measured width; truncation preserves Unicode grapheme clusters. ## Validation @@ -174,4 +185,6 @@ The API regressions use actual HTTP requests, including DELETE while the worker is occupied and a queued PUT is superseded. Linux CI does not verify macOS window presentation. Aster has no standalone CI/CD; Lyra CI explicitly validates the pinned engine itself. CI uses official Cargo sources -and standard HTTPS checkout without custom SSH secrets. +and standard HTTPS checkout without custom SSH secrets. Font checks cover +proportional/monospace advances, fractional scaling, multiline height, Chinese +fallback, grapheme-safe truncation, antialiasing and alpha blending. diff --git a/examples/README.md b/examples/README.md index ceb62c1..94b4a02 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,7 @@ | [page.endor](page.endor) | Four top-level statements construct text, a rectangle and a page, then call page.render; no main wrapper. | | [main.endor](main.endor) | The earlier function-entry form, producing the same frame as page.endor. | | [hello.endor](hello.endor) | Richer function-entry example covering a helper, Row/Column and Spacer. | +| [fonts.endor](fonts.endor) | Mixed Chinese/Latin text and proportional widths, with rectangles following narrow and wide letters. | Submit a file as the UTF-8 body of a namespace PUT request. No namespace, scene, render block or imports belong in the source. diff --git a/examples/fonts.endor b/examples/fonts.endor new file mode 100644 index 0000000..3c994ca --- /dev/null +++ b/examples/fonts.endor @@ -0,0 +1,5 @@ +let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422") +let detail = ui.text("中文 English 123 — café", 1, "#606060") +let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF")) +let page = ui.page() +page.render(ui.column(24, 12, title, detail, row)) diff --git a/scripts/README.md b/scripts/README.md index 285b9c0..f4acee3 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,10 +1,11 @@ # Native packaging `build-macos-app.sh` builds the release executable with the normal Cargo lockfile -and creates `target/release/Tessera.app`. It uses the local Rust toolchain and -official Cargo sources; no mirror or Docker configuration is installed, and no -automated tests run. `CARGO_NET_OFFLINE=true` can reuse an already populated -official dependency cache. +and creates `target/release/Tessera.app`. It uses the local Rust toolchain; +no mirror or Docker configuration is installed, and no automated tests run. +Additional arguments are forwarded to Cargo, such as `--offline` to reuse an +already populated cache or temporary `--config` options for a dependency mirror +when official crates.io is unavailable. Defaults remain the official source. The local bundle has identifier `com.entroia.tessera` and can be opened from Finder. It is a development app, without a distribution signature or notarization. diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 08c2876..e07bca2 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -6,7 +6,7 @@ if [ "$(uname -s)" != Darwin ]; then exit 1 fi cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -cargo build --release --locked +cargo build --release --locked "$@" version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml) test -n "$version" bundle="$(pwd)/target/release/Tessera.app" diff --git a/src/README.md b/src/README.md index 389d3e3..1708d04 100644 --- a/src/README.md +++ b/src/README.md @@ -15,6 +15,7 @@ | display_list.rs | Rendering primitives and color representation | | geometry.rs | Native points and rectangles | | software_renderer.rs | Software rasterization of the display list | +| text.rs | Shared system font discovery, shaping, real measurements, antialiased glyphs and bounded caches | | README.md | Module map | Aster is an unmodified source submodule. Language parsing, AST definitions, @@ -37,4 +38,9 @@ Tests in api.rs execute real HTTP CRUD and a controlled queued-PUT/DELETE race without opening a window. Tests in endor_host.rs cover lifecycle, direct script and module entry, page methods, multiline inputs, original source ranges, malformed input, entry checks, budgets, failure preservation and stale-result invalidation. They -run only in remote CI. See the [project guide](../README.md) for public behavior. +run only in remote CI. Font tests in text.rs and software_renderer.rs cover real +advances, multiline height, fractional scaling, CJK fallback, grapheme-safe fitting, +antialiasing and transparency. A process-wide mutex keeps font discovery, shaping +and glyph caches shared between the worker's layout and window redraw. The +256-string/512-KiB source cache and 4096-glyph/32-MiB image cache are discarded +when their budgets are exceeded. See the [project guide](../README.md) for public behavior. diff --git a/src/api.rs b/src/api.rs index e3e4eab..7ad96dc 100644 --- a/src/api.rs +++ b/src/api.rs @@ -339,8 +339,10 @@ PUT waits for its worker result. Errors return 422 with diagnostics and preserve the last successful revision. A newer accepted PUT or DELETE supersedes older work (409); a full eight-entry queue returns 503. DELETE immediately clears the document. GET remains available while Aster executes. The window redraws from an -owned snapshot; resize does not rerun Endor. This first stage builds static UI; -events, persistent VM state, Chinese font rendering and process isolation remain +owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing +and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels +with 20-pixel line height; larger sizes multiply both. This first stage builds static UI; +events, persistent VM state and process isolation remain future work. It is not an untrusted-code sandbox. "##, name = namespace.name, diff --git a/src/developer_tools.rs b/src/developer_tools.rs index 0d7cfe4..08157db 100644 --- a/src/developer_tools.rs +++ b/src/developer_tools.rs @@ -2,6 +2,7 @@ use crate::{ display_list::{Color, DisplayList, DrawCommand}, document::{CodeNode, RenderDocument}, geometry::{Point, Rect}, + text::{self as typography, TextStyle}, }; const PANEL: Color = Color::rgb(247, 247, 245); @@ -154,14 +155,13 @@ pub fn build( if state.selected_node_id.as_deref() == Some(node.id.as_str()) { fill(&mut list, row, 5.0 * scale, SELECTED); } - text( - &mut list, - row.x + (9.0 + depth as f32 * 14.0) * scale, - row.y + 8.0 * scale, - &fit(&format!("{} {}", node.kind, node.name), 44), - scale, - TEXT, + let text_x = row.x + (9.0 + depth as f32 * 14.0) * scale; + let label = typography::fit( + &format!("{} {}", node.kind, node.name), + (width - 14.0 * scale - text_x).max(0.0), + TextStyle::code().scaled(scale), ); + text(&mut list, text_x, row.y + 3.0 * scale, &label, scale, TEXT); layout.rows.push(TreeRowHit { rect: row, node_id: node.id.clone(), @@ -235,7 +235,7 @@ fn draw_source( let first_y = area.y + 42.0 * scale; let maximum_lines = ((area.y + area.height - first_y - 10.0 * scale) / line_height).max(0.0) as usize; - let max_chars = ((area.width / (8.0 * scale)) - 9.0).max(8.0) as usize; + let source_width = (area.width - 65.0 * scale).max(0.0); for (offset, source_line) in all_lines[start..end].iter().take(maximum_lines).enumerate() { let line_number = start + offset + 1; @@ -252,7 +252,11 @@ fn draw_source( list, area.x + 55.0 * scale, y, - &fit(&source_line.replace('\t', " "), max_chars), + &typography::fit( + &source_line.replace('\t', " "), + source_width, + TextStyle::code().scaled(scale), + ), scale, TEXT, ); @@ -266,21 +270,6 @@ fn flatten<'a>(node: &'a CodeNode, depth: usize, output: &mut Vec<(usize, &'a Co } } -fn fit(value: &str, maximum: usize) -> String { - let characters: Vec<_> = value.chars().collect(); - if characters.len() <= maximum { - return value.to_owned(); - } - if maximum < 3 { - return characters.into_iter().take(maximum).collect(); - } - characters - .into_iter() - .take(maximum - 3) - .chain(['.', '.', '.']) - .collect() -} - fn fill(list: &mut DisplayList, rect: Rect, radius: f32, color: Color) { list.push(DrawCommand::FillRoundedRect { rect, @@ -293,7 +282,7 @@ fn text(list: &mut DisplayList, x: f32, y: f32, value: &str, scale: f32, color: list.push(DrawCommand::Text { origin: Point::new(x, y), value: value.to_owned(), - scale: scale.round().max(1.0) as u32, + style: TextStyle::code().scaled(scale), color, }); } diff --git a/src/display_list.rs b/src/display_list.rs index 17e3c08..4b48879 100644 --- a/src/display_list.rs +++ b/src/display_list.rs @@ -1,4 +1,5 @@ use crate::geometry::{Point, Rect}; +use crate::text::TextStyle; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Color { @@ -45,7 +46,7 @@ pub enum DrawCommand { Text { origin: Point, value: String, - scale: u32, + style: TextStyle, color: Color, }, } diff --git a/src/document.rs b/src/document.rs index b6d6ef9..2ebefb7 100644 --- a/src/document.rs +++ b/src/document.rs @@ -300,7 +300,7 @@ impl RenderCommand { } => DrawCommand::Text { origin: Point::new(x * scale, y * scale), value: value.clone(), - scale: ((*size as f32 * scale).round() as u32).max(1), + style: crate::text::TextStyle::ui(*size).scaled(scale), color: parse_color(color)?, }, }) diff --git a/src/endor_host.rs b/src/endor_host.rs index 6eb847f..0d635c9 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -378,7 +378,7 @@ mod tests { matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor") ); assert!( - matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 52.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF") + matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF") ); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 3); let wrapped = compile(include_str!("../examples/main.endor"), "app.main", 10).unwrap(); @@ -553,19 +553,16 @@ paint(ui.text("last"))"#; &document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, - y: 52.0, + y: 76.0, width: 240.0, height: 80.0, .. } )); + let first_width = crate::text::measure("Aster VM", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[3], - RenderCommand::Text { - x: 120.0, - y: 144.0, - .. - } + RenderCommand::Text { x, y: 168.0, .. } if (*x - (24.0 + first_width + 12.0 + 8.0 + 12.0)).abs() < 0.01 )); // Candidate::close already proved zero remaining VM objects. Both // redraws must still work from native data at different display scales. @@ -583,13 +580,10 @@ function main(): View { let child = ui.text("shared") return wrap(child) }"#; &document.frame.commands[0], RenderCommand::Text { x: 4.0, y: 4.0, .. } )); + let width = crate::text::measure("shared", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[1], - RenderCommand::Text { - x: 60.0, - y: 4.0, - .. - } + RenderCommand::Text { x, y: 4.0, .. } if (*x - (4.0 + width + 8.0)).abs() < 0.01 )); } diff --git a/src/layout.rs b/src/layout.rs index 69c0b6e..f169e3d 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -3,6 +3,7 @@ use anyhow::{Result, bail}; use crate::{ document::{Frame, RenderCommand}, + text::{self, TextStyle}, ui_bridge::{MAX_DIMENSION, UiKind, UiNode}, }; @@ -50,9 +51,10 @@ fn layout( size: *size, color: color.clone(), }); + let measured = text::measure(value, TextStyle::ui(*size)); Size { - width: value.chars().count() as f32 * 8.0 * *size as f32, - height: 8.0 * *size as f32, + width: measured.width, + height: measured.height, } } UiKind::Rect { diff --git a/src/main.rs b/src/main.rs index 50037f3..87b2804 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod geometry; mod layout; mod scene; mod software_renderer; +mod text; mod ui_bridge; use std::sync::{Arc, RwLock}; diff --git a/src/software_renderer.rs b/src/software_renderer.rs index 21a58c4..4e7d967 100644 --- a/src/software_renderer.rs +++ b/src/software_renderer.rs @@ -1,8 +1,7 @@ -use font8x8::{BASIC_FONTS, UnicodeFonts}; - use crate::{ display_list::{Color, DisplayList, DrawCommand}, geometry::{Point, Rect}, + text::{self, TextStyle}, }; pub struct SoftwareRenderer; @@ -32,9 +31,9 @@ impl SoftwareRenderer { DrawCommand::Text { origin, value, - scale, + style, color, - } => canvas.draw_text(*origin, value, *scale, *color), + } => canvas.draw_text(*origin, value, *style, *color), } } } @@ -88,29 +87,12 @@ impl Canvas<'_> { } } - fn draw_text(&mut self, origin: Point, value: &str, scale: u32, color: Color) { - let scale = scale.max(1) as i32; - let mut cursor_x = origin.x.round() as i32; - let baseline_y = origin.y.round() as i32; - - for character in value.chars() { - if let Some(glyph) = BASIC_FONTS.get(character) { - for (row, byte) in glyph.iter().enumerate() { - for column in 0..8 { - if byte & (1 << column) != 0 { - self.fill_pixels( - cursor_x + column * scale, - baseline_y + row as i32 * scale, - scale, - scale, - color, - ); - } - } - } - } - cursor_x += 8 * scale; - } + fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) { + let x = origin.x.round() as i32; + let y = origin.y.round() as i32; + text::draw(value, style, color, |dx, dy, pixel| { + self.blend_pixel(x + dx, y + dy, pixel) + }); } fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) { @@ -122,14 +104,6 @@ impl Canvas<'_> { ) } - fn fill_pixels(&mut self, x: i32, y: i32, width: i32, height: i32, color: Color) { - for py in y.max(0)..(y + height).min(self.height as i32) { - for px in x.max(0)..(x + width).min(self.width as i32) { - self.blend_pixel(px, py, color); - } - } - } - fn blend_pixel(&mut self, x: i32, y: i32, source: Color) { if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || source.alpha == 0 { @@ -192,4 +166,32 @@ mod tests { assert_eq!(pixels[5], 0x00ff0000); assert_eq!(pixels[0], 0); } + + #[test] + fn text_edges_are_antialiased_and_respect_opacity() { + let mut list = DisplayList::default(); + list.push(DrawCommand::Clear(Color::rgb(0, 0, 0))); + list.push(DrawCommand::Text { + origin: Point::new(2.0, 2.0), + value: "Hello 中文".into(), + style: TextStyle::ui(1).scaled(1.25), + color: Color::rgba(255, 255, 255, 128), + }); + let mut pixels = vec![0; 256 * 48]; + SoftwareRenderer::render(&list, &mut pixels, 256, 48); + assert!(pixels.iter().any(|pixel| *pixel > 0)); + assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128)); + assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255)))); + + let mut invisible = DisplayList::default(); + invisible.push(DrawCommand::Text { + origin: Point::new(2.0, 2.0), + value: "Hello 中文".into(), + style: TextStyle::ui(1), + color: Color::rgba(255, 255, 255, 0), + }); + let mut pixels = vec![0; 256 * 48]; + SoftwareRenderer::render(&invisible, &mut pixels, 256, 48); + assert!(pixels.iter().all(|pixel| *pixel == 0)); + } } diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..a54bdca --- /dev/null +++ b/src/text.rs @@ -0,0 +1,310 @@ +//! Shared font discovery, shaping, measurement and antialiased rasterization. +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, +}; + +use cosmic_text::{Align, Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap}; + +use crate::display_list::Color; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum TextFamily { + Interface, + Code, +} + +#[derive(Clone, Copy, Debug)] +pub struct TextStyle { + pub font_size: f32, + pub line_height: f32, + pub family: TextFamily, +} + +impl TextStyle { + pub fn ui(size: u32) -> Self { + Self { + font_size: 14.0 * size as f32, + line_height: 20.0 * size as f32, + family: TextFamily::Interface, + } + } + + pub fn code() -> Self { + Self { + font_size: 13.0, + line_height: 18.0, + family: TextFamily::Code, + } + } + + pub fn scaled(self, scale: f32) -> Self { + Self { + font_size: self.font_size * scale, + line_height: self.line_height * scale, + ..self + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct TextSize { + pub width: f32, + pub height: f32, +} + +#[derive(Clone, Hash, PartialEq, Eq)] +struct TextKey { + value: String, + font_size: u32, + line_height: u32, + family: TextFamily, +} + +struct PreparedText { + buffer: Buffer, + size: TextSize, +} + +struct TextEngine { + fonts: FontSystem, + swash: SwashCache, + buffers: HashMap, + source_bytes: usize, +} + +impl TextEngine { + fn new() -> Self { + let mut fonts = FontSystem::new(); + // Select installed, readable families rather than assuming an OS font + // name exists. COSMIC Text handles per-glyph fallback (including CJK). + let choose = |choices: &[&str]| { + choices.iter().find_map(|choice| { + fonts + .db() + .faces() + .any(|face| face.families.iter().any(|(name, _)| name == choice)) + .then(|| (*choice).to_owned()) + }) + }; + let sans = choose(&[ + ".AppleSystemUIFont", + "SF Pro Text", + "Helvetica Neue", + "Segoe UI", + "Noto Sans", + "DejaVu Sans", + "Liberation Sans", + ]); + let mono = choose(&["Menlo", "Consolas", "DejaVu Sans Mono", "Liberation Mono"]); + if let Some(name) = sans { + fonts.db_mut().set_sans_serif_family(name); + } + if let Some(name) = mono { + fonts.db_mut().set_monospace_family(name); + } + Self { + fonts, + swash: SwashCache::new(), + buffers: HashMap::new(), + source_bytes: 0, + } + } + + fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey { + let key = TextKey { + value: value.to_owned(), + font_size: style.font_size.to_bits(), + line_height: style.line_height.to_bits(), + family: style.family, + }; + if self.buffers.contains_key(&key) { + return key; + } + // Cache recent shaped strings, without retaining every submitted source. + if self.buffers.len() >= 256 || self.source_bytes + value.len() > 524_288 { + self.buffers.clear(); + self.source_bytes = 0; + } + let family = match style.family { + TextFamily::Interface => Family::SansSerif, + TextFamily::Code => Family::Monospace, + }; + let mut buffer = Buffer::new( + &mut self.fonts, + Metrics::new(style.font_size, style.line_height), + ); + buffer.set_wrap(Wrap::None); + buffer.set_size(None, None); + buffer.set_text( + value, + &Attrs::new().family(family), + Shaping::Advanced, + Some(Align::Left), + ); + buffer.shape_until_scroll(&mut self.fonts, false); + let mut size = TextSize { + width: 0.0, + height: style.line_height, + }; + for run in buffer.layout_runs() { + size.width = size.width.max(run.line_w); + size.height = size.height.max(run.line_top + run.line_height); + } + self.source_bytes += value.len(); + self.buffers + .insert(key.clone(), PreparedText { buffer, size }); + key + } +} + +fn engine() -> &'static Mutex { + static ENGINE: OnceLock> = OnceLock::new(); + ENGINE.get_or_init(|| Mutex::new(TextEngine::new())) +} + +pub fn measure(value: &str, style: TextStyle) -> TextSize { + let mut engine = engine().lock().expect("text engine lock"); + let key = engine.prepare(value, style); + engine.buffers[&key].size +} + +/// Draw at the actual device font size; no scaling of an already rasterized bitmap. +pub fn draw(value: &str, style: TextStyle, color: Color, mut pixel: impl FnMut(i32, i32, Color)) { + let mut engine = engine().lock().expect("text engine lock"); + let key = engine.prepare(value, style); + let TextEngine { + fonts, + swash, + buffers, + .. + } = &mut *engine; + let opacity = u16::from(color.alpha); + buffers.get_mut(&key).expect("prepared text").buffer.draw( + fonts, + swash, + cosmic_text::Color::rgb(color.red, color.green, color.blue), + |x, y, width, height, color| { + for dy in 0..height as i32 { + for dx in 0..width as i32 { + pixel( + x + dx, + y + dy, + Color::rgba( + color.r(), + color.g(), + color.b(), + (u16::from(color.a()) * opacity / 255) as u8, + ), + ); + } + } + }, + ); + // The cache is an optimization, never an archive of all submitted glyphs. + if swash.image_cache.len() > 4096 + || swash + .image_cache + .values() + .flatten() + .map(|image| image.data.len()) + .sum::() + > 33_554_432 + { + *swash = SwashCache::new(); + } +} + +/// Fit a single displayed source line without slicing a UTF-8/grapheme cluster. +pub fn fit(value: &str, width: f32, style: TextStyle) -> String { + use unicode_segmentation::UnicodeSegmentation; + + // A source line may include a very large comment. Only shape a bounded + // preview for the sidebar; the stored source and its AST spans stay intact. + let truncated = value.len() > 8192; + let value = if truncated { + let end = value + .grapheme_indices(true) + .map(|(index, _)| index) + .take_while(|index| *index <= 8192) + .last() + .unwrap_or(0); + &value[..end] + } else { + value + }; + if !truncated && measure(value, style).width <= width { + return value.to_owned(); + } + let ellipsis = "…"; + if width <= 0.0 || measure(ellipsis, style).width > width { + return String::new(); + } + let ends: Vec<_> = value + .grapheme_indices(true) + .map(|(start, part)| start + part.len()) + .collect(); + let mut low = 0; + let mut high = ends.len(); + while low < high { + let count = (low + high).div_ceil(2); + let end = ends[count - 1]; + let candidate = format!("{}{ellipsis}", &value[..end]); + if measure(&candidate, style).width <= width { + low = count; + } else { + high = count - 1; + } + } + let end = low.checked_sub(1).map_or(0, |index| ends[index]); + format!("{}{ellipsis}", &value[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_segmentation::UnicodeSegmentation; + + #[test] + fn proportional_metrics_monospace_and_multiline_height_come_from_the_fonts() { + let ui = TextStyle::ui(1); + assert!(measure("WWWW", ui).width > measure("iiii", ui).width * 2.0); + let mono = TextStyle::code(); + assert!((measure("WWWW", mono).width - measure("iiii", mono).width).abs() < 0.1); + assert_eq!(measure("one\ntwo", ui).height, ui.line_height * 2.0); + let width = measure("Hello Endor", ui).width; + assert!((measure("Hello Endor", ui.scaled(1.25)).width - width * 1.25).abs() < 0.5); + } + + #[test] + fn system_fallback_shapes_chinese_without_missing_glyphs() { + let mut engine = engine().lock().unwrap(); + for style in [TextStyle::ui(1), TextStyle::code()] { + let key = engine.prepare("中文字体", style); + let glyphs: Vec<_> = engine.buffers[&key] + .buffer + .layout_runs() + .flat_map(|run| run.glyphs) + .collect(); + assert_eq!(glyphs.len(), 4); + assert!(glyphs.iter().all(|glyph| glyph.glyph_id != 0)); + } + } + + #[test] + fn ellipsis_fits_actual_width_and_preserves_graphemes() { + let style = TextStyle::code(); + let value = "e\u{301}e\u{301}中文字体e\u{301}e\u{301}"; + let width = measure("e\u{301}e\u{301}中…", style).width + 0.01; + let fitted = fit(value, width, style); + let prefix = fitted.strip_suffix('…').unwrap(); + assert!(value.starts_with(prefix)); + assert!( + value + .grapheme_indices(true) + .any(|(index, _)| index == prefix.len()) + ); + assert!(measure(&fitted, style).width <= width); + assert_eq!(fit(value, 0.0, style), ""); + } +}