feat: shape and antialias Tessera text with system fonts
Verify Tessera GUI / verify (push) Successful in 13m28s

This commit is contained in:
2026-10-20 14:25:28 +08:00
parent 1f64de48b9
commit f0fae1792f
19 changed files with 661 additions and 95 deletions
+4
View File
@@ -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.
Generated
+239 -5
View File
@@ -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"
+2 -1
View File
@@ -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]
+1 -1
View File
@@ -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
+17 -4
View File
@@ -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.
+1
View File
@@ -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.
+5
View File
@@ -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))
+5 -4
View File
@@ -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.
+1 -1
View File
@@ -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"
+7 -1
View File
@@ -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.
+4 -2
View File
@@ -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,
+14 -25
View File
@@ -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,
});
}
+2 -1
View File
@@ -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,
},
}
+1 -1
View File
@@ -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)?,
},
})
+6 -12
View File
@@ -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
));
}
+4 -2
View File
@@ -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 {
+1
View File
@@ -9,6 +9,7 @@ mod geometry;
mod layout;
mod scene;
mod software_renderer;
mod text;
mod ui_bridge;
use std::sync::{Arc, RwLock};
+37 -35
View File
@@ -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));
}
}
+310
View File
@@ -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<TextKey, PreparedText>,
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<TextEngine> {
static ENGINE: OnceLock<Mutex<TextEngine>> = 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::<usize>()
> 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), "");
}
}