mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
craft-ui: interface fonts, text direction, IME composer, localized widgets and screen testing
craft-fonts-build gains Embed::serve (served faces for web builds) and the coverage module that craft-ui's fonts use.
This commit is contained in:
@@ -3,9 +3,9 @@ name: Shared UI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -14,6 +14,11 @@ author, source and license. Library code and original assets are under the repos
|
||||
| `crates/text/assets/hyphen/en-us.pat` | DesignCraft contributors, trained from the Moby Hyphenator II list | `crates/text/examples/hyphgen.rs` | CC0-1.0 |
|
||||
| Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, feature `slovak-gpl` only) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT |
|
||||
| Word-break dictionaries and LSTM models embedded through `icu_segmenter_data` (optional `craft-text` features `thai`, `lao`, `khmer`, `burmese`) | Unicode, Inc. and ICU4X contributors | [ICU4X](https://github.com/unicode-org/icu4x) | Unicode-3.0 |
|
||||
| `crates/ui/tests/fonts/NotoSansArabic-test.ttf` (subset to the test strings, default weight set to 100, width axis dropped) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansCJKsc-test.otf` (subset to the test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansArabic-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansCJKsc-screens.otf` (subset to printable ASCII and the screen test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansThai-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@8b0a1d0 `ofl/notosansthai`](https://github.com/google/fonts/tree/8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5/ofl/notosansthai), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansThai.txt`) |
|
||||
|
||||
Original assets are covered by the repository licence.
|
||||
|
||||
|
||||
Generated
+159
-2
@@ -97,6 +97,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -627,6 +633,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -900,7 +916,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
@@ -946,17 +962,28 @@ dependencies = [
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"epaint",
|
||||
"fontique",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa 0.44.0",
|
||||
"unicode-script",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1114,6 +1141,19 @@ version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1344,14 +1384,17 @@ name = "egui_kittest"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
@@ -1659,6 +1702,28 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontique"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts 0.41.0",
|
||||
"roxmltree 0.21.2",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1783,6 +1848,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1871,6 +1945,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"png 0.18.1",
|
||||
"skrifa 0.44.0",
|
||||
"smallvec",
|
||||
"vello_common 0.2.0",
|
||||
@@ -2572,6 +2647,25 @@ version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -2760,6 +2854,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
@@ -2867,6 +2967,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -3227,6 +3336,16 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3411,6 +3530,16 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -3481,6 +3610,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parlance"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-writer"
|
||||
version = "0.15.0"
|
||||
@@ -4203,6 +4338,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -5586,7 +5730,7 @@ dependencies = [
|
||||
"kurbo 0.11.3",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
@@ -5669,6 +5813,7 @@ dependencies = [
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
"png 0.18.1",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -5695,6 +5840,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"glifo 0.3.0",
|
||||
"hashbrown 0.17.1",
|
||||
"png 0.18.1",
|
||||
"vello_common 0.2.0",
|
||||
]
|
||||
|
||||
@@ -6583,6 +6729,17 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -121,6 +121,40 @@ manifest lacks is an `Error::UnknownFamilies` listing the manifest's families: a
|
||||
checkout that can't be used. `scripts` and `filter` narrow the families further, for example to
|
||||
their Regular faces.
|
||||
|
||||
## Serving the faces (web builds)
|
||||
|
||||
`Embed::serve(dir, url_prefix)` copies the selected faces into `dir` instead of embedding them,
|
||||
and writes a table of `ServedFace { family, style, scripts, index, url, size, integrity, coverage }`
|
||||
literals for the app to fetch at run time (craft-ui's `fonts::UiFonts::served_faces` does). Each
|
||||
font file and its licence keep their path under the checkout's `fonts/` directory, and `url` is
|
||||
`url_prefix` plus that path: `serve("web/fonts", "fonts/")` copies
|
||||
`fonts/noto-sans-arabic/NotoSansArabic.ttf` to `web/fonts/noto-sans-arabic/NotoSansArabic.ttf`, with
|
||||
the URL `fonts/noto-sans-arabic/NotoSansArabic.ttf`. Faces of one collection share a file and a
|
||||
URL. `integrity` is the manifest's SHA-256 as a Subresource Integrity value. `coverage` holds the
|
||||
code points the face's `cmap` maps, as the `coverage` module encodes them (sorted ranges as LEB128
|
||||
gaps and lengths; `coverage::contains` reads them), so an app fetches a face only for characters
|
||||
it has: about 3 KB for a CJK interface face cut to an app's catalogs, under 1 KB for a full one.
|
||||
|
||||
An interface face cut by `subset_to` is served from its cut in `OUT_DIR`, with the start of the
|
||||
cut's SHA-256 in its name (`web/fonts/ui/NotoSansTCUI-Regular.1234abcd5678ef90.ttf`) and the cut's
|
||||
own `integrity`, so changed catalogs give a new URL rather than a cached copy that fails its check.
|
||||
A relative `dir` is under the package directory. Files already there with the right size are not
|
||||
copied again, a deleted one is copied again on the next build, and files of faces no longer
|
||||
selected, and earlier cuts, stay.
|
||||
|
||||
```rust
|
||||
// CJK faces only as the interface faces, cut to the app's catalogs (see below).
|
||||
let fonts = craft_fonts_build::Embed::new().subset_to(catalogs("filmcraft"));
|
||||
let written = if craft_fonts_build::target_is_wasm() {
|
||||
fonts.serve("web/fonts", "fonts/").write("craft_fonts.rs")
|
||||
} else {
|
||||
fonts.write("craft_fonts.rs")
|
||||
};
|
||||
```
|
||||
|
||||
The crate includes the table as `&[ServedFace]` on wasm and `&[EmbeddedFace]` elsewhere. craft-ui's
|
||||
README shows a whole eframe web app, Trunk copy included.
|
||||
|
||||
## Interface faces
|
||||
|
||||
The full CJK faces take 5–17 MB each. For the interface, craft-fonts marks one Regular TrueType
|
||||
|
||||
@@ -49,6 +49,53 @@ impl<'a> Cmap<'a> {
|
||||
pub(crate) fn covers(&self, c: char) -> bool {
|
||||
self.subtables.iter().any(|t| glyph(t, u32::from(c)).is_some_and(|g| g != 0))
|
||||
}
|
||||
|
||||
/// Every character [`Self::covers`], as sorted, disjoint, non-adjacent inclusive ranges.
|
||||
pub(crate) fn ranges(&self) -> Vec<(u32, u32)> {
|
||||
let mut spans: Vec<(u32, u32)> = self.subtables.iter().flat_map(|t| spans(t)).collect();
|
||||
spans.sort_unstable();
|
||||
let mut ranges: Vec<(u32, u32)> = Vec::new();
|
||||
let mut next = 0;
|
||||
for (start, end) in spans {
|
||||
for c in start.max(next)..=end {
|
||||
if !char::from_u32(c).is_some_and(|c| self.covers(c)) {
|
||||
continue;
|
||||
}
|
||||
match ranges.last_mut() {
|
||||
Some(last) if last.1 + 1 == c => last.1 = c,
|
||||
_ => ranges.push((c, c)),
|
||||
}
|
||||
}
|
||||
next = next.max(end.saturating_add(1));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
}
|
||||
|
||||
/// The code point spans a subtable lists, mapped or not.
|
||||
fn spans(t: &[u8]) -> Vec<(u32, u32)> {
|
||||
let mut spans = Vec::new();
|
||||
match u16_at(t, 0) {
|
||||
Some(4) => {
|
||||
let seg_x2 = u16_at(t, 6).map_or(0, usize::from);
|
||||
for i in 0..seg_x2 / 2 {
|
||||
let (Some(end), Some(start)) = (u16_at(t, 14 + 2 * i), u16_at(t, 16 + seg_x2 + 2 * i)) else { break };
|
||||
if start <= end {
|
||||
spans.push((u32::from(start), u32::from(end)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(12) => {
|
||||
for i in 0..u32_at(t, 12).and_then(|n| usize::try_from(n).ok()).unwrap_or(0) {
|
||||
let (Some(start), Some(end)) = (u32_at(t, 16 + 12 * i), u32_at(t, 20 + 12 * i)) else { break };
|
||||
if start <= end {
|
||||
spans.push((start, end.min(u32::from(char::MAX))));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn glyph(subtable: &[u8], c: u32) -> Option<u32> {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The characters a face maps, as [`ServedFace::coverage`](crate::ServedFace::coverage) stores
|
||||
//! them: its sorted, disjoint code point ranges, each as two unsigned LEB128 numbers, the gap from
|
||||
//! the end of the previous range (from 0 for the first) and the range's length minus one. A
|
||||
//! Chinese interface face cut to an app's catalogs takes about 3 KB.
|
||||
|
||||
use crate::cmap::Cmap;
|
||||
|
||||
/// The coverage of face `index` of `font` (a `.ttf`/`.otf`, or a `.ttc` collection); `None` when
|
||||
/// the file isn't a font or has no Unicode `cmap` subtable in format 4 or 12.
|
||||
pub fn of_face(font: &[u8], index: u32) -> Option<Vec<u8>> {
|
||||
Some(encode(&Cmap::read(font, index)?.ranges()))
|
||||
}
|
||||
|
||||
/// The coverage of inclusive code point ranges, sorted by start. Overlaps are dropped.
|
||||
pub fn encode(ranges: &[(u32, u32)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut next = 0;
|
||||
for &(start, end) in ranges {
|
||||
let start = start.max(next);
|
||||
if end < start {
|
||||
continue;
|
||||
}
|
||||
leb128(&mut out, start - next);
|
||||
leb128(&mut out, end - start);
|
||||
let Some(after) = end.checked_add(1) else { break };
|
||||
next = after;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `coverage` holds `c`.
|
||||
pub fn contains(coverage: &[u8], c: char) -> bool {
|
||||
let c = u32::from(c);
|
||||
let mut bytes = coverage.iter().copied();
|
||||
let mut next = 0u32;
|
||||
while let (Some(gap), Some(len)) = (read(&mut bytes), read(&mut bytes)) {
|
||||
let Some(start) = next.checked_add(gap) else { return false };
|
||||
let Some(end) = start.checked_add(len) else { return false };
|
||||
if c < start {
|
||||
return false;
|
||||
}
|
||||
if c <= end {
|
||||
return true;
|
||||
}
|
||||
let Some(after) = end.checked_add(1) else { return false };
|
||||
next = after;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn leb128(out: &mut Vec<u8>, mut n: u32) {
|
||||
while n >= 0x80 {
|
||||
out.push((n & 0x7F) as u8 | 0x80);
|
||||
n >>= 7;
|
||||
}
|
||||
out.push(n as u8);
|
||||
}
|
||||
|
||||
fn read(bytes: &mut impl Iterator<Item = u8>) -> Option<u32> {
|
||||
let mut n = 0u32;
|
||||
for shift in (0..35).step_by(7) {
|
||||
let byte = bytes.next()?;
|
||||
n |= u32::from(byte & 0x7F).checked_shl(shift)?;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -34,6 +34,13 @@ pub struct Embed {
|
||||
texts: Vec<PathBuf>,
|
||||
families: Option<Vec<String>>,
|
||||
elsewhere: Vec<String>,
|
||||
serve: Option<Serve>,
|
||||
}
|
||||
|
||||
/// Where [`Embed::serve`] copies the faces, and the URL prefix they are served under.
|
||||
struct Serve {
|
||||
dir: PathBuf,
|
||||
url_prefix: String,
|
||||
}
|
||||
|
||||
/// A selected face and the file the table embeds it from.
|
||||
@@ -42,8 +49,9 @@ struct Face {
|
||||
path: PathBuf,
|
||||
index: u32,
|
||||
/// An interface face to cut to the catalogs' characters before it is embedded.
|
||||
#[cfg_attr(not(feature = "subset"), expect(dead_code))]
|
||||
cut: bool,
|
||||
/// Hex SHA-256 of the file: the manifest's, or the cut's.
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
impl Embed {
|
||||
@@ -82,8 +90,8 @@ impl Embed {
|
||||
/// CJK faces are dropped and the rest are kept. Without catalogs the interface faces are left
|
||||
/// out too, with a `cargo::warning`, so a full CJK face is never embedded for the interface.
|
||||
/// A manifest with no interface face is also a `cargo::warning` when that drops a CJK face.
|
||||
/// Combines with [`Embed::scripts`] and [`Embed::filter`]; with `CRAFT_FONTS_REQUIRED=1`, an
|
||||
/// interface face satisfies its script tags.
|
||||
/// Combines with [`Embed::scripts`], [`Embed::filter`] and [`Embed::serve`]; with
|
||||
/// `CRAFT_FONTS_REQUIRED=1`, an interface face satisfies its script tags.
|
||||
pub fn for_ui(mut self) -> Self {
|
||||
self.ui = true;
|
||||
self
|
||||
@@ -138,6 +146,22 @@ impl Embed {
|
||||
self
|
||||
}
|
||||
|
||||
/// Copy the selected faces into `dir` instead of embedding them, for a web build that serves
|
||||
/// them beside its wasm, and write a table of [`ServedFace`](crate::ServedFace)s.
|
||||
///
|
||||
/// Each font file and its licence file keep their path under the checkout's `fonts/`
|
||||
/// directory (`noto-sans-arabic/NotoSansArabic.ttf`), and a face's `url` is `url_prefix`
|
||||
/// followed by that path, so `serve("web/fonts", "fonts/")` suits a page that serves
|
||||
/// `web/fonts` as `fonts/`. An interface face cut by `Embed::subset_to` is served from its cut,
|
||||
/// with the start of the cut's SHA-256 in the file name (`ui/NotoSansTCUI-Regular.<hash>.ttf`)
|
||||
/// and the cut's own integrity value. A relative `dir` is under the package directory, where
|
||||
/// Cargo runs build scripts. Files already there with the right size are left as they are;
|
||||
/// files of faces no longer selected, and earlier cuts, are not removed.
|
||||
pub fn serve(mut self, dir: impl Into<PathBuf>, url_prefix: &str) -> Self {
|
||||
self.serve = Some(Serve { dir: dir.into(), url_prefix: url_prefix.to_owned() });
|
||||
self
|
||||
}
|
||||
|
||||
/// Write the face table to `out_file` and print the `cargo::` lines; returns the faces
|
||||
/// embedded.
|
||||
///
|
||||
@@ -183,9 +207,18 @@ impl Embed {
|
||||
self.check_coverage(faces, dir, cargo);
|
||||
}
|
||||
}
|
||||
let tabled = selected.and_then(|faces| Ok((table(&faces)?, faces.into_iter().map(|face| face.entry).collect())));
|
||||
let (table, faces) = match tabled {
|
||||
Ok(tabled) => tabled,
|
||||
let selected = match (selected, &self.serve, &dir) {
|
||||
(Ok(faces), Some(serve), Some(dir)) => match serve.copy(dir, &faces, cargo) {
|
||||
Ok(table) => Ok((table, faces)),
|
||||
Err(e) => {
|
||||
write(&out, EMPTY_TABLE)?;
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
(selected, ..) => selected.and_then(|faces| Ok((table(&faces)?, faces))),
|
||||
};
|
||||
let (table, faces) = match selected {
|
||||
Ok((table, faces)) => (table, faces.into_iter().map(|face| face.entry).collect()),
|
||||
Err(e) if required => {
|
||||
write(&out, EMPTY_TABLE)?;
|
||||
return Err(e);
|
||||
@@ -249,8 +282,8 @@ impl Embed {
|
||||
// `include_bytes!` resolves relative paths from the including source file.
|
||||
let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?;
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", path.display()));
|
||||
let index = entry.index;
|
||||
faces.push(Face { entry, path, index, cut });
|
||||
let (index, sha256) = (entry.index, entry.sha256.clone());
|
||||
faces.push(Face { entry, path, index, cut, sha256 });
|
||||
}
|
||||
if !uncut.is_empty() {
|
||||
let why = if self.catalogs.is_some() { "Embed::subset_to was given no translation catalogs" } else { "no translation catalogs" };
|
||||
@@ -303,6 +336,7 @@ impl Embed {
|
||||
}
|
||||
face.path = out;
|
||||
face.index = 0;
|
||||
face.sha256 = crate::sha256::hex(&bytes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -439,6 +473,124 @@ fn table(faces: &[Face]) -> Result<String, Error> {
|
||||
Ok(src)
|
||||
}
|
||||
|
||||
impl Serve {
|
||||
/// Copy the faces' files and licences into the served directory; returns the table.
|
||||
fn copy(&self, checkout: &Path, faces: &[Face], cargo: &mut Vec<String>) -> Result<String, Error> {
|
||||
let mut copied: Vec<String> = Vec::new();
|
||||
let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n");
|
||||
for face in faces {
|
||||
let (entry, path) = (&face.entry, &face.path);
|
||||
let served = served_file(face);
|
||||
let font = std::fs::read(path).map_err(|source| Error::Read { path: path.clone(), source })?;
|
||||
let size = font.len();
|
||||
if !copied.contains(&served) {
|
||||
copied.push(served.clone());
|
||||
copy_file(path, &self.dir.join(&served), cargo)?;
|
||||
let licence = checkout.join(&entry.licence_file);
|
||||
if entry.licence_file.is_empty() || !licence.is_file() {
|
||||
cargo.push(format!("cargo::warning=craft-fonts: no licence file beside {}", entry.file));
|
||||
} else {
|
||||
copy_file(&licence, &self.dir.join(served_path(&entry.licence_file)), cargo)?;
|
||||
}
|
||||
}
|
||||
let coverage = crate::coverage::of_face(&font, face.index).unwrap_or_else(|| {
|
||||
cargo.push(format!("cargo::warning=craft-fonts: no character map in {}; the face is never fetched for a character", entry.file));
|
||||
Vec::new()
|
||||
});
|
||||
let scripts: Vec<String> = entry.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let _ = writeln!(
|
||||
src,
|
||||
" ServedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, url: {:?}, size: {size}, integrity: {:?}, coverage: {} }},",
|
||||
entry.family,
|
||||
entry.style,
|
||||
scripts.join(", "),
|
||||
face.index,
|
||||
format!("{}{served}", self.url_prefix),
|
||||
integrity(&face.sha256),
|
||||
byte_string(&coverage),
|
||||
);
|
||||
}
|
||||
src.push_str("]\n");
|
||||
Ok(src)
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte string literal, printable ASCII as itself.
|
||||
fn byte_string(bytes: &[u8]) -> String {
|
||||
let mut out = String::from("b\"");
|
||||
for &b in bytes {
|
||||
match b {
|
||||
b'"' | b'\\' => {
|
||||
out.push('\\');
|
||||
out.push(char::from(b));
|
||||
}
|
||||
0x20..=0x7E => out.push(char::from(b)),
|
||||
_ => {
|
||||
let _ = write!(out, "\\x{b:02x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a face's file is served, under the served directory: its checkout path under `fonts/`.
|
||||
/// A cut adds the start of its SHA-256 before the extension (`ui/A.1234abcd5678ef90.ttf`), so a
|
||||
/// cut from changed catalogs gets a new URL instead of meeting a cached copy that fails its
|
||||
/// integrity check.
|
||||
fn served_file(face: &Face) -> String {
|
||||
let file = served_path(&face.entry.file);
|
||||
if !face.cut {
|
||||
return file.to_owned();
|
||||
}
|
||||
let hash = face.sha256.get(..16).unwrap_or(&face.sha256);
|
||||
match file.rsplit_once('.') {
|
||||
Some((stem, extension)) if !extension.contains('/') => format!("{stem}.{hash}.{extension}"),
|
||||
_ => format!("{file}.{hash}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkout path relative to the served directory: `fonts/a/A.ttf` → `a/A.ttf`.
|
||||
fn served_path(file: &str) -> &str {
|
||||
file.strip_prefix("fonts/").unwrap_or(file)
|
||||
}
|
||||
|
||||
/// Copy `from` unless `to` already has its size. The copy keeps `from`'s modification time and is
|
||||
/// watched, so the build copies it again once it is deleted, without running every build.
|
||||
fn copy_file(from: &Path, to: &Path, cargo: &mut Vec<String>) -> Result<(), Error> {
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", to.display()));
|
||||
let write_error = |source| Error::Write { path: to.to_path_buf(), source };
|
||||
let size = |path: &Path| std::fs::metadata(path).ok().map(|m| m.len());
|
||||
if size(to).is_some() && size(to) == size(from) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(write_error)?;
|
||||
}
|
||||
std::fs::copy(from, to).map_err(write_error)?;
|
||||
// Cargo reruns a build script whose watched files are newer than its last run.
|
||||
let modified = std::fs::metadata(from).and_then(|m| m.modified()).map_err(|source| Error::Read { path: from.to_path_buf(), source })?;
|
||||
std::fs::File::options().write(true).open(to).and_then(|f| f.set_modified(modified)).map_err(write_error)
|
||||
}
|
||||
|
||||
/// The Subresource Integrity value (`sha256-<base64>`) of a hex SHA-256, or `""` for a value that
|
||||
/// isn't one.
|
||||
pub(crate) fn integrity(sha256: &str) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let digest: Option<Vec<u8>> =
|
||||
(sha256.len() == 64).then(|| (0..32).map(|i| u8::from_str_radix(sha256.get(2 * i..2 * i + 2)?, 16).ok()).collect()).flatten();
|
||||
let Some(digest) = digest else { return String::new() };
|
||||
let mut out = String::from("sha256-");
|
||||
for chunk in digest.chunks(3) {
|
||||
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i)));
|
||||
for i in 0..4 {
|
||||
let c = if i <= chunk.len() { ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] } else { b'=' };
|
||||
out.push(char::from(c));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &Path, contents: &str) -> Result<(), Error> {
|
||||
std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source })
|
||||
}
|
||||
|
||||
@@ -46,11 +46,18 @@
|
||||
//! An app's document fonts go in a table of their own that names its families,
|
||||
//! `Embed::documents(&["Noto Sans", "Shippori Mincho"])`: nothing is embedded for documents
|
||||
//! unless the app names it.
|
||||
//!
|
||||
//! Web builds can serve the faces beside the wasm instead: [`Embed::serve`] copies the selected
|
||||
//! files into a directory and writes a table of [`ServedFace`]s, which carry a URL in place of the
|
||||
//! bytes, for the app to fetch the faces it needs at run time.
|
||||
|
||||
mod cmap;
|
||||
pub mod coverage;
|
||||
mod embed;
|
||||
mod manifest;
|
||||
#[cfg(feature = "subset")]
|
||||
mod sha256;
|
||||
#[cfg(feature = "subset")]
|
||||
mod subset;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -59,6 +66,8 @@ pub use embed::{Embed, Error, target_is_wasm};
|
||||
pub use manifest::{CJK_SCRIPTS, ManifestEntry, ParseError, UI_ROLE, parse_manifest};
|
||||
|
||||
/// A craft-fonts face embedded by [`Embed`]: one line of `fonts/manifest.txt`.
|
||||
///
|
||||
/// [`Embed::serve`] writes [`ServedFace`]s instead.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct EmbeddedFace {
|
||||
/// Family name, e.g. `"Noto Sans Arabic"`.
|
||||
@@ -112,6 +121,53 @@ impl EmbeddedFace {
|
||||
}
|
||||
}
|
||||
|
||||
/// A craft-fonts face that [`Embed::serve`] copied beside the app, to fetch at run time: one line
|
||||
/// of `fonts/manifest.txt`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ServedFace {
|
||||
/// Family name, e.g. `"Noto Sans Arabic"`.
|
||||
pub family: &'static str,
|
||||
/// Style, e.g. `"Regular"` or `"Bold"`.
|
||||
pub style: &'static str,
|
||||
/// Script tags, as in [`EmbeddedFace::scripts`].
|
||||
pub scripts: &'static [&'static str],
|
||||
/// Face index in a font collection (`.ttc`); `0` for a single font. Faces of one collection
|
||||
/// share a URL.
|
||||
pub index: u32,
|
||||
/// Where the file is served, relative to the page unless the prefix given to
|
||||
/// [`Embed::serve`] makes it absolute.
|
||||
pub url: &'static str,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Subresource Integrity value (`sha256-…`) from the manifest's hash; empty when the manifest
|
||||
/// has none.
|
||||
pub integrity: &'static str,
|
||||
/// The characters the face maps, read from its `cmap` (see [`coverage`]).
|
||||
pub coverage: &'static [u8],
|
||||
}
|
||||
|
||||
impl ServedFace {
|
||||
/// `"<family> <style>"`, e.g. `"Noto Sans Arabic Regular"`.
|
||||
pub fn name(&self) -> String {
|
||||
format!("{} {}", self.family, self.style)
|
||||
}
|
||||
|
||||
/// Whether any script tag matches `tag`: `Hant` matches `Hant` and `Hant-HK`.
|
||||
pub fn has_script(&self, tag: &str) -> bool {
|
||||
self.scripts.iter().any(|s| manifest::script_matches(s, tag))
|
||||
}
|
||||
|
||||
/// Whether the face has a CJK script tag ([`CJK_SCRIPTS`]).
|
||||
pub fn is_cjk(&self) -> bool {
|
||||
CJK_SCRIPTS.iter().any(|tag| self.has_script(tag))
|
||||
}
|
||||
|
||||
/// Whether the face maps `c`.
|
||||
pub fn covers(&self, c: char) -> bool {
|
||||
coverage::contains(self.coverage, c)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EmbeddedFace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EmbeddedFace")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! SHA-256 (FIPS 180-4) of a cut face, for its served file name and Subresource Integrity value.
|
||||
|
||||
const K: [u32; 64] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
/// The lowercase hex SHA-256 of `data`, as craft-fonts' manifest writes it.
|
||||
pub(crate) fn hex(data: &[u8]) -> String {
|
||||
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
||||
let mut message = data.to_vec();
|
||||
message.push(0x80);
|
||||
while message.len() % 64 != 56 {
|
||||
message.push(0);
|
||||
}
|
||||
message.extend_from_slice(&(u64::try_from(data.len()).unwrap_or(u64::MAX).wrapping_mul(8)).to_be_bytes());
|
||||
for block in message.as_chunks::<64>().0 {
|
||||
let mut w = [0u32; 64];
|
||||
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
|
||||
for (k, w) in K.iter().zip(w) {
|
||||
let t1 = hh
|
||||
.wrapping_add(e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25))
|
||||
.wrapping_add((e & f) ^ (!e & g))
|
||||
.wrapping_add(*k)
|
||||
.wrapping_add(w);
|
||||
let t2 = (a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22)).wrapping_add((a & b) ^ (a & c) ^ (b & c));
|
||||
(hh, g, f, e, d, c, b, a) = (g, f, e, d.wrapping_add(t1), c, b, a, t1.wrapping_add(t2));
|
||||
}
|
||||
for (h, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
|
||||
*h = h.wrapping_add(v);
|
||||
}
|
||||
}
|
||||
h.iter().map(|v| format!("{v:08x}")).collect()
|
||||
}
|
||||
@@ -191,6 +191,56 @@ fn documents_embeds_only_the_named_families() {
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("building without craft-fonts") && w.contains("no family Serif")), "{:?}", r.cargo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_copies_the_selected_faces_and_writes_urls() {
|
||||
let scratch = Scratch::new("serve");
|
||||
let served = scratch.0.join("dist/fonts");
|
||||
let env = Env { dir: Some(fixture().into()), required: Some("1".into()), out_dir: Some(scratch.0.clone().into()) };
|
||||
let embed = Embed::new().scripts(&["Jpan", "Hant"]).filter(|f| f.style == "Regular").serve(&served, "fonts/");
|
||||
let mut cargo = Vec::new();
|
||||
let names: Vec<String> =
|
||||
embed.run(&env, Path::new("craft_fonts.rs"), &mut cargo).unwrap().iter().map(|f| format!("{} {}", f.family, f.style)).collect();
|
||||
assert_eq!(names, ["Sans JP Regular", "Sans CJK TC Regular", "Sans CJK HK Regular"]);
|
||||
|
||||
let read = |path: &str| std::fs::read(served.join(path)).unwrap();
|
||||
assert_eq!(read("sans-jp/SansJP-Regular.ttf"), std::fs::read(fixture().join("fonts/sans-jp/SansJP-Regular.ttf")).unwrap());
|
||||
assert_eq!(read("sans-cjk/SansCJK.ttc").len(), 28);
|
||||
assert_eq!(read("sans-jp/OFL.txt"), b"Fixture licence text.\n");
|
||||
assert!(!served.join("sans-jp/SansJP-Bold.ttf").exists() && !served.join("sans-arabic").exists());
|
||||
// Cargo copies a deleted file again, and doesn't rerun for files it just copied.
|
||||
assert!(cargo.contains(&format!("cargo::rerun-if-changed={}", served.join("sans-jp/SansJP-Regular.ttf").display())), "{cargo:?}");
|
||||
assert!(cargo.contains(&format!("cargo::rerun-if-changed={}", served.join("sans-jp/OFL.txt").display())), "{cargo:?}");
|
||||
let modified = |path: &Path| std::fs::metadata(path).unwrap().modified().unwrap();
|
||||
assert_eq!(modified(&served.join("sans-jp/SansJP-Regular.ttf")), modified(&fixture().join("fonts/sans-jp/SansJP-Regular.ttf")));
|
||||
|
||||
let table = std::fs::read_to_string(scratch.0.join("craft_fonts.rs")).unwrap();
|
||||
assert!(!table.contains("include_bytes!"), "{table}");
|
||||
assert!(
|
||||
table.contains(
|
||||
"ServedFace { family: \"Sans JP\", style: \"Regular\", scripts: &[\"Jpan\", \"Latn\"], index: 0, url: \"fonts/sans-jp/SansJP-Regular.ttf\", size: 24, integrity: \"sha256-ydCO8fxp8V6HzlK9qKnCRxTkEl2vflCkDtsaor4Q3/E=\", coverage: b\"\" }"
|
||||
),
|
||||
"{table}"
|
||||
);
|
||||
// Both faces of the collection name the one served file.
|
||||
assert_eq!(table.matches("url: \"fonts/sans-cjk/SansCJK.ttc\"").count(), 2, "{table}");
|
||||
|
||||
// Without a checkout the table is empty and nothing is copied.
|
||||
let env = Env { dir: None, required: None, out_dir: Some(scratch.0.clone().into()) };
|
||||
let empty = scratch.0.join("empty");
|
||||
assert!(Embed::new().serve(&empty, "fonts/").run(&env, Path::new("craft_fonts.rs"), &mut cargo).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(scratch.0.join("craft_fonts.rs")).unwrap().trim(), "&[]");
|
||||
assert!(!empty.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_is_base64_of_the_hex_hash() {
|
||||
use crate::embed::integrity;
|
||||
assert_eq!(integrity("c9d08ef1fc69f15e87ce52bda8a9c24714e4125daf7e50a40edb1aa2be10dff1"), "sha256-ydCO8fxp8V6HzlK9qKnCRxTkEl2vflCkDtsaor4Q3/E=");
|
||||
for bad in ["", "00", "zz08ef1fc69f15e87ce52bda8a9c24714e4125daf7e50a40edb1aa2be10dff1"] {
|
||||
assert_eq!(integrity(bad), "", "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A font with only a `cmap` table: a Windows format 4 subtable with `bmp` segments `(first,
|
||||
/// last, glyphs)`, mapped to glyphs 1.. when `glyphs` is `None` and through the glyph array
|
||||
/// otherwise, and a format 12 subtable with `groups` `(first, last)` when there are any.
|
||||
@@ -545,6 +595,36 @@ mod subset {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_to_serves_the_cut_under_its_hash() {
|
||||
let scratch = Scratch::new("subset-serve");
|
||||
let dir = checkout(&scratch, &[("Noto Sans TC UI", "Hant,Latn", "ui", tiny("Tiny-glyf.ttf"))]);
|
||||
let catalog = scratch.0.join("zh-TW.tsv");
|
||||
std::fs::write(&catalog, "Open\t一\n").unwrap();
|
||||
let served = scratch.0.join("served");
|
||||
let r = run(Embed::new().subset_to([&catalog]).serve(&served, "fonts/"), Some(&dir), Some("1"));
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC UI Regular"]);
|
||||
let cut = std::fs::read(cut_file(&r, "Noto Sans TC UI")).unwrap();
|
||||
let sha256 = crate::sha256::hex(&cut);
|
||||
let file = format!("{}.{}.ttf", 0, &sha256[..16]);
|
||||
assert_eq!(std::fs::read(served.join(&file)).unwrap(), cut);
|
||||
assert!(!served.join("0.ttf").exists());
|
||||
let line = format!("url: \"fonts/{file}\", size: {}, integrity: {:?}, coverage: ", cut.len(), crate::embed::integrity(&sha256));
|
||||
assert!(r.table.contains(&line), "{}\n{line}", r.table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_matches_the_standard_vectors() {
|
||||
use crate::sha256::hex;
|
||||
assert_eq!(hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert_eq!(
|
||||
hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
|
||||
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
|
||||
);
|
||||
assert_eq!(hex(&vec![b'a'; 1_000_000]), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
}
|
||||
|
||||
/// With `CRAFT_FONTS_DIR` set to a craft-fonts checkout, cut its BIZ UDPGothic Regular.
|
||||
#[test]
|
||||
fn cuts_biz_udpgothic() {
|
||||
@@ -560,3 +640,43 @@ mod subset {
|
||||
assert_eq!(crate::subset::subset(&source, 0, &chars).unwrap(), cut);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_ui_serves_no_whole_cjk_face() {
|
||||
let scratch = Scratch::new("for-ui-serve");
|
||||
let dir = checkout(
|
||||
&scratch,
|
||||
&[
|
||||
("Noto Sans TC", "Hant,Latn", "", b"full".to_vec()),
|
||||
("Noto Sans TC UI", "Hant,Latn", "ui", b"source".to_vec()),
|
||||
("Noto Sans Arabic", "Arab", "", b"arabic".to_vec()),
|
||||
],
|
||||
);
|
||||
let served = scratch.0.join("served");
|
||||
let r = run(Embed::new().for_ui().serve(&served, "fonts/"), Some(&dir), None);
|
||||
assert_eq!(r.result.unwrap(), ["Noto Sans Arabic Regular"]);
|
||||
assert_eq!(std::fs::read(served.join("2.ttf")).unwrap(), b"arabic");
|
||||
assert!(!served.join("0.ttf").exists() && !served.join("1.ttf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn served_faces_carry_the_characters_their_cmap_maps() {
|
||||
let font = tiny_font(&[(0x41, 0x5A, None), (0x4E00, 0x4E02, Some(&[5, 0, 7]))], &[(0x4E03, 0x4E03), (0x2_0000, 0x2_0001)]);
|
||||
let coverage = crate::coverage::of_face(&font, 0).unwrap();
|
||||
assert_eq!(Cmap::read(&font, 0).unwrap().ranges(), [(0x41, 0x5A), (0x4E00, 0x4E00), (0x4E02, 0x4E03), (0x2_0000, 0x2_0001)]);
|
||||
let covered: Vec<char> =
|
||||
"@AZ[一丁丂七丄\u{FFFF}\u{20000}\u{20001}\u{20002}".chars().filter(|&c| crate::coverage::contains(&coverage, c)).collect();
|
||||
assert_eq!(covered, ['A', 'Z', '一', '丂', '七', '\u{20000}', '\u{20001}']);
|
||||
assert!(!crate::coverage::contains(&[], 'A') && !crate::coverage::contains(&[0x80], 'A'));
|
||||
assert_eq!(crate::coverage::encode(&[(0, 0x10_FFFF)]), [0, 0xFF, 0xFF, 0x43]);
|
||||
|
||||
let scratch = Scratch::new("serve-coverage");
|
||||
let dir = checkout(&scratch, &[("Noto Sans", "Latn", "", font), ("Broken", "Latn", "", b"not a font".to_vec())]);
|
||||
let r = run(Embed::new().serve(scratch.0.join("served"), "fonts/"), Some(&dir), None);
|
||||
assert_eq!(r.result.as_ref().unwrap().len(), 2);
|
||||
let table = &r.table;
|
||||
// Gaps and lengths 0x41 0x19, 0x4DA5 0, 1 1, 0x1_B1FC 1 as LEB128.
|
||||
assert!(table.contains(r#"coverage: b"A\x19\xa5\x9b\x01\x00\x01\x01\xfc\xe3\x06\x01" }"#), "{table}");
|
||||
assert!(table.contains(r#"family: "Broken""#) && table.ends_with("coverage: b\"\" },\n]\n"), "{table}");
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("no character map in fonts/1.ttf")), "{:?}", warnings(&r));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
//! Embeds the fixture checkout in `../fixture/craft-fonts` the way an app's build.rs embeds
|
||||
//! `CRAFT_FONTS_DIR`, so the generated table is compiled by a real `include!`.
|
||||
//! `CRAFT_FONTS_DIR`, so the generated tables are compiled by a real `include!`: once embedded and
|
||||
//! once served from `OUT_DIR/served`.
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
let embedded = craft_fonts_build::Embed::new()
|
||||
.dir(root.join("../fixture/craft-fonts"))
|
||||
.required(true)
|
||||
.scripts(&["Jpan", "Hant"])
|
||||
.filter(|face| face.style == "Regular")
|
||||
.write("craft_fonts.rs");
|
||||
if let Err(e) = embedded {
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default());
|
||||
let fonts = || {
|
||||
craft_fonts_build::Embed::new()
|
||||
.dir(root.join("../fixture/craft-fonts"))
|
||||
.required(true)
|
||||
.scripts(&["Jpan", "Hant"])
|
||||
.filter(|face| face.style == "Regular")
|
||||
};
|
||||
let embedded = fonts().write("craft_fonts.rs");
|
||||
let served = fonts().serve(out.join("served"), "fonts/").write("craft_fonts_served.rs");
|
||||
if let Err(e) = embedded.and(served) {
|
||||
println!("cargo::error={e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
//! The fixture faces as an app's crate sees them; read by `craft-fonts-build`'s tests.
|
||||
|
||||
use craft_fonts_build::EmbeddedFace;
|
||||
use craft_fonts_build::{EmbeddedFace, ServedFace};
|
||||
|
||||
pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
pub static CRAFT_FONTS_SERVED: &[ServedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts_served.rs"));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The table `craft-fonts-build-consumer`'s build.rs generated from the fixture compiles and holds
|
||||
//! The tables `craft-fonts-build-consumer`'s build.rs generated from the fixture compile and hold
|
||||
//! the selected faces' bytes.
|
||||
|
||||
use craft_fonts_build_consumer::CRAFT_FONTS;
|
||||
@@ -14,3 +14,20 @@ fn generated_table_embeds_the_selected_faces() {
|
||||
assert_eq!(CRAFT_FONTS[2].bytes, include_bytes!("fixture/craft-fonts/fonts/sans-cjk/SansCJK.ttc"));
|
||||
assert!(CRAFT_FONTS[2].has_script("Hant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn served_table_names_urls_instead_of_bytes() {
|
||||
use craft_fonts_build_consumer::CRAFT_FONTS_SERVED;
|
||||
let faces: Vec<(String, &str, u64)> = CRAFT_FONTS_SERVED.iter().map(|f| (f.name(), f.url, f.size)).collect();
|
||||
assert_eq!(
|
||||
faces,
|
||||
[
|
||||
("Sans JP Regular".to_owned(), "fonts/sans-jp/SansJP-Regular.ttf", 24),
|
||||
("Sans CJK TC Regular".to_owned(), "fonts/sans-cjk/SansCJK.ttc", 28),
|
||||
("Sans CJK HK Regular".to_owned(), "fonts/sans-cjk/SansCJK.ttc", 28),
|
||||
]
|
||||
);
|
||||
assert_eq!(CRAFT_FONTS_SERVED[2].index, 3);
|
||||
assert!(CRAFT_FONTS_SERVED[2].has_script("Hant") && CRAFT_FONTS_SERVED[2].is_cjk());
|
||||
assert!(CRAFT_FONTS_SERVED[0].integrity.starts_with("sha256-"));
|
||||
}
|
||||
|
||||
+45
-3
@@ -11,9 +11,28 @@ description = "Reusable egui controls and icon catalogs for Craft frontends"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-fonts-build = { workspace = true }
|
||||
craft-i18n = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
epaint = { version = "0.37", default-features = false }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
# Same version as epaint's, for reading a face's character map.
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Screenshot and input tests of an app's screens (the screen-testing feature).
|
||||
egui_kittest = { version = "0.37", features = ["snapshot", "wgpu"], optional = true }
|
||||
# The versions egui_system_font_provider 0.37 uses.
|
||||
fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true }
|
||||
unicode-script = { version = "0.5.8", optional = true }
|
||||
|
||||
# Fetching served fonts (`fonts::UiFonts::served_faces`); already in every eframe web build.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "RequestInit", "Response", "console"] }
|
||||
|
||||
# Fading a native window while it is dragged (`NSWindow.alphaValue`); winit already uses these.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -22,20 +41,43 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
serde = ["dep:serde"]
|
||||
# Floating windows may move to their own OS windows (desktop egui backends with viewports).
|
||||
native-windows = []
|
||||
# Draw colour glyphs (COLR, sbix, CBDT), e.g. colour emoji.
|
||||
color_fonts = ["epaint/color_fonts"]
|
||||
# Find installed fonts for characters the app's faces lack (desktop only).
|
||||
system_fonts = ["dep:fontique", "dep:unicode-script"]
|
||||
# `docking::testing`: a headless driver for application docking tests.
|
||||
testing = []
|
||||
# craft_ui::testing: per-language screenshot tests and IME smoke tests with egui_kittest
|
||||
# (desktop only).
|
||||
screen-testing = ["dep:egui_kittest"]
|
||||
|
||||
[dev-dependencies]
|
||||
egui = { workspace = true, features = ["default_fonts"] }
|
||||
# serde: the IME tests read events that only the suite's egui fork has from JSON.
|
||||
egui = { workspace = true, features = ["default_fonts", "serde"] }
|
||||
serde_json = "1"
|
||||
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
|
||||
egui_kittest = { version = "0.37", features = ["wgpu"] }
|
||||
eframe = { version = "0.37", default-features = false, features = ["wgpu", "x11", "default_fonts"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[[example]]
|
||||
name = "docking_native"
|
||||
required-features = ["native-windows"]
|
||||
|
||||
[[test]]
|
||||
name = "screens"
|
||||
required-features = ["screen-testing"]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
egui_kittest = { version = "0.37", features = ["wgpu"] }
|
||||
eframe = { version = "0.37", default-features = false, features = ["wgpu", "x11", "default_fonts"] }
|
||||
|
||||
# The browser test (tests/web_fonts.rs).
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
wasm-bindgen-test = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Response"] }
|
||||
|
||||
+278
-6
@@ -5,9 +5,11 @@ embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles
|
||||
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
|
||||
`docking` supplies transactional panel moves and an optional docking surface.
|
||||
`printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft.
|
||||
`fonts` installs the UI font stack, the same fallback order in every app.
|
||||
`localized` supplies number fields that write and read the user's region's numbers.
|
||||
The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
|
||||
records their scope and acceptance limits. Assets, complete themes, localization and command handling
|
||||
remain in each application. No icons, fonts or application engines are bundled.
|
||||
records their scope and acceptance limits. Assets (font files included), complete themes, localization
|
||||
and command handling remain in each application. No icons, fonts or application engines are bundled.
|
||||
|
||||
## SVG catalogs
|
||||
|
||||
@@ -214,10 +216,249 @@ fn style_widgets(visuals: &mut egui::Visuals, palette: WidgetPalette, text: Colo
|
||||
}
|
||||
```
|
||||
|
||||
This is a mechanism for coloring widgets, not a shared theme schema. Fonts, complete palettes,
|
||||
This is a mechanism for coloring widgets, not a shared theme schema. Complete palettes,
|
||||
selection colors, shadows, spacing, context storage and preference serialization remain in each
|
||||
application. See the [eight-app comparison](../../docs/theme-infrastructure.md) for the exact
|
||||
common operation and the differences preserved by the adapters.
|
||||
application; fonts are installed by `fonts`, below. See the
|
||||
[eight-app comparison](../../docs/theme-infrastructure.md) for the exact common operation and the
|
||||
differences preserved by the adapters.
|
||||
|
||||
## UI fonts
|
||||
|
||||
egui draws a character only if one of its installed faces has it, so an app's labels, layer names
|
||||
and file names show boxes for any script its faces miss. `fonts::UiFonts` builds every app's UI
|
||||
font stack in the same order:
|
||||
|
||||
1. the app's own faces for the family (UI face, semibold, mono), or egui's default faces for a
|
||||
family the app has none for;
|
||||
2. craft-fonts faces for the UI language;
|
||||
3. craft-fonts faces for every other script the app ships, so a Japanese interface still draws
|
||||
Arabic file names;
|
||||
4. installed system fonts (see below), set to regular weight so variable fonts don't draw Thin;
|
||||
5. emoji faces (system emoji first, then the colour, web and monochrome faces the app embeds) and,
|
||||
behind the app's own faces, egui's default faces.
|
||||
|
||||
When the faces of step 1 don't cover the UI language's main script (Arabic, Thai, Georgian), that
|
||||
script's face goes first. egui takes a row's height from a family's first face, so the language's
|
||||
own face sets the row height and its marks aren't clipped. Han, kana and Hangul sit inside the em
|
||||
square and fit a Latin row, so Chinese, Japanese and Korean UIs keep the Latin face first: Latin
|
||||
text draws in the app's face in every language, and CJK characters fall back to the language's
|
||||
face. Rows and widgets keep the same height in every one of these languages as in a Latin UI:
|
||||
CJK faces sit on the first face's baseline inside its row, scaled down only if a glyph would
|
||||
otherwise leave the row (Noto Sans CJK and BIZ UDPGothic at the same size don't need it), with
|
||||
crates.io egui and the suite's patched egui alike. `UiFonts::cjk_rows(CjkRows::Grow)` opts into
|
||||
the CJK face's taller line instead. The app's theme sets the minimum widget height for rows of its
|
||||
own face; when the UI language's script face leads the stack and has taller rows, `install` adds
|
||||
the difference to the theme's height, and takes it off again when a later install needs less. The
|
||||
theme's height and padding stay the app's: a theme applied after `install` is fitted at the start
|
||||
of the next pass, so the result doesn't depend on which comes first. On the suite's patched egui
|
||||
(`FontTweak::set_baseline_fallback`, which the other craft faces and installed text faces turn
|
||||
on), other fallback glyphs sit on the row's baseline and rows grow to fit them by themselves; emoji
|
||||
and symbol faces (colour glyphs, or "Emoji" or "Symbol" in the name) stay centred. With crates.io
|
||||
egui those fallbacks are centred. Monospace keeps the app's or egui's mono face first. Han characters take the regional face the language needs: `zh-TW` gets the
|
||||
Traditional (TC) face, then Hong Kong, then Simplified; `zh-HK` the Hong Kong face first; `ja` the
|
||||
Japanese face; `ko` the Korean one.
|
||||
|
||||
The app keeps its font files: its `build.rs` embeds the craft-fonts faces it wants from
|
||||
`fonts/manifest.txt` with `craft-fonts-build`, and craft-ui never depends on craft-fonts. A crate
|
||||
below the UI (an app's engine or font crate, without egui) holds the table as
|
||||
`craft_fonts_build::EmbeddedFace` values (storytold/craft-libs#213), and the UI converts each into
|
||||
a `fonts::CraftFace` with `From`. Apps embed with `Embed::subset_to(catalogs)`, which takes the
|
||||
CJK faces as craft-fonts' interface faces (`Noto Sans TC UI`, BIZ UDPGothic Regular) and cuts them
|
||||
at build time to the characters the translation catalogs use (about 3 MB for all five with
|
||||
FilmCraft's catalogs). A character a cut face lacks, such as one in a file name, comes from
|
||||
another region's interface face that has it, then from the installed system fonts; the build
|
||||
warns when a catalog has characters no embedded face covers. Preferences name the full family
|
||||
(`Noto Sans TC`) and also apply to its interface face (`Noto Sans TC UI`).
|
||||
|
||||
The app configures its own faces, a preferred face per script, extra named families (a semibold
|
||||
family, a family led by Arabic for the runs it shapes itself), emoji faces, and whether system
|
||||
fonts come before or after the craft faces for other scripts. `UiFonts::stack` returns a `Stack`
|
||||
whose chains the app can still edit (`insert`, `replace`, `remove`) before `Stack::install`.
|
||||
`Stack::after_system` (a `FaceList`) holds the faces asked after the system fonts, readable with
|
||||
`get` and `faces`; `Stack::flat_definitions` appends them to each chain, for a context that takes
|
||||
its fonts only through `set_fonts`.
|
||||
|
||||
`fonts::I18nScripts` reads each language's scripts and Han forms from craft-i18n; an app with its
|
||||
own table implements `fonts::LanguageScripts` instead.
|
||||
|
||||
```rust
|
||||
use craft_fonts_build::EmbeddedFace;
|
||||
use craft_ui::fonts::{CraftFace, Family, I18nScripts, UiFonts};
|
||||
use egui::{FontData, FontFamily};
|
||||
|
||||
pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
fn install_fonts(ctx: &egui::Context, ui_language: &str) {
|
||||
let craft_faces: Vec<CraftFace> = CRAFT_FONTS.iter().copied().map(CraftFace::from).collect();
|
||||
UiFonts::new(&I18nScripts)
|
||||
.app_face("ui", FontData::from_static(UI_FONT), &[FontFamily::Proportional])
|
||||
.app_face("ui-semibold", FontData::from_static(UI_SEMIBOLD), &[FontFamily::Name("semibold".into())])
|
||||
.app_face("mono", FontData::from_static(MONO_FONT), &[FontFamily::Monospace])
|
||||
.family(Family::named("semibold").weight(600))
|
||||
.craft_faces(&craft_faces)
|
||||
.languages(OFFERED_LANGUAGES)
|
||||
.ui_language(ui_language)
|
||||
.install(ctx);
|
||||
}
|
||||
```
|
||||
|
||||
Call `install` again when the UI language changes.
|
||||
|
||||
### Web builds
|
||||
|
||||
A web build serves the craft-fonts faces beside its wasm instead of embedding them, so the wasm
|
||||
stays small and an English interface never downloads Chinese. `craft-fonts-build`'s
|
||||
`Embed::serve` copies the faces into a directory and writes a table of `fonts::ServedFace`s (URL,
|
||||
size, Subresource Integrity hash and the characters the face maps, no bytes);
|
||||
`UiFonts::served_faces` takes it in place of `craft_faces`. Each face's characters are its
|
||||
`cmap`'s code point ranges, packed as LEB128 gaps and lengths: about 3 KB for a CJK interface face
|
||||
cut to an app's catalogs, a few hundred bytes for Noto Sans, the symbol faces or an emoji face. The
|
||||
stack orders served faces exactly as embedded ones, and on the web `Stack::install` fetches them:
|
||||
|
||||
- the UI language's faces at once (`Stack::ui_downloads`: the best face for each of its scripts
|
||||
the app's faces lack, per family, so a semibold family brings the bold face);
|
||||
- for a character no installed face has, the one face that draws it (`Stack::downloads_for`):
|
||||
the first face in the family's order whose file maps the character and whose scripts fit it.
|
||||
Noto Sans draws Latin, Greek and Cyrillic letters the app's faces lack (Vietnamese ẫ), the
|
||||
symbol faces (Noto Sans Symbols, Symbols 2, Math) keyboard keys, arrows and other text symbols
|
||||
(⌘ ⌥ ⇧ ⎋ ⏎), the emoji faces emoji. A Han character takes the interface face of the UI
|
||||
language's region first (Simplified, Traditional, Hong Kong, Japanese or Korean), then the
|
||||
other offered languages' and the rest in manifest order;
|
||||
- a served emoji face (`Emoji::served`) the first time text needs an emoji.
|
||||
|
||||
A character no served face maps, such as a rare Han character none of the cut interface faces
|
||||
has, fetches nothing: egui's web glyph rasterizer draws it with the browser's fonts, or it shows
|
||||
as a missing glyph. A file that
|
||||
fails to download leaves its characters to the next face that draws them and is fetched again
|
||||
after 2 seconds, then after twice as long with each further failure, up to 10 minutes. Each face is
|
||||
added when its file arrives, and the context repaints; text falls back to the faces after it until
|
||||
then. Fetched files stay in memory for later installs, such as a language switch, and the
|
||||
browser's HTTP cache keeps them across page loads. `fonts::loading(ctx)` is true while the UI
|
||||
language's files are on their way, for an app that would rather draw nothing than Latin fallbacks
|
||||
in its first frames.
|
||||
|
||||
`build.rs`:
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=build.rs");
|
||||
// CJK faces as craft-fonts' interface faces, cut to the app's catalogs (`catalogs` as in
|
||||
// craft-fonts-build's README; `subset` feature on the build-dependency).
|
||||
let fonts = craft_fonts_build::Embed::new().subset_to(catalogs("myapp"));
|
||||
let written = if craft_fonts_build::target_is_wasm() {
|
||||
// Served by the page as `fonts/`; see Trunk.toml.
|
||||
fonts.serve("web/fonts", "fonts/").write("craft_fonts.rs")
|
||||
} else {
|
||||
fonts.write("craft_fonts.rs")
|
||||
};
|
||||
if let Err(e) = written {
|
||||
println!("cargo::error={e}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The app:
|
||||
|
||||
```rust
|
||||
// The embedded table is written as `EmbeddedFace` literals, the served one as `ServedFace`s.
|
||||
use craft_ui::fonts::{CraftFace as EmbeddedFace, I18nScripts, ServedFace, UiFonts};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
static CRAFT_FONTS: &[ServedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
fn install_fonts(ctx: &egui::Context, ui_language: &str) {
|
||||
let fonts = UiFonts::new(&I18nScripts).app_face("ui", FontData::from_static(UI_FONT), &[FontFamily::Proportional]);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let fonts = fonts.craft_faces(CRAFT_FONTS);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fonts = fonts.served_faces(CRAFT_FONTS);
|
||||
fonts.languages(OFFERED_LANGUAGES).ui_language(ui_language).install(ctx);
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
if craft_ui::fonts::loading(ui.ctx()) {
|
||||
return; // The fetch repaints when it arrives.
|
||||
}
|
||||
// …
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `install_fonts` from the `eframe::App` creator, so the fetches start before the first frame.
|
||||
The served directory has to reach the site next to the wasm; with Trunk, copy it in a hook
|
||||
(`Trunk.toml`):
|
||||
|
||||
```toml
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "sh"
|
||||
command_arguments = ["-c", "cp -R web/fonts \"$TRUNK_STAGING_DIR/fonts\""]
|
||||
```
|
||||
|
||||
A relative `serve` directory is under the package whose `build.rs` runs it; a crate deeper in the
|
||||
workspace can pass `concat!(env!("CARGO_MANIFEST_DIR"), "/../../web/fonts")` instead. Keep the
|
||||
directory out of version control. A build filters faces for the web as for the desktop (`scripts`,
|
||||
`filter`), e.g. to leave out bold faces.
|
||||
|
||||
Two features, both off by default, change what every craft-ui user draws, so the app turns them on:
|
||||
|
||||
- `system_fonts` (desktop): the stack asks the installed fonts, at regular weight, and `install`
|
||||
replaces the context's font providers, including the one eframe adds. One index of the
|
||||
installed fonts serves every UI language, so a language switch doesn't scan them again.
|
||||
`install` starts building it on a background thread; until it is ready, text draws with the
|
||||
stack's own faces, and the context repaints when it arrives. Without it,
|
||||
`install` keeps the providers the context has (eframe's `system_fonts`, for one) and asks the
|
||||
stack's own faces after them. `UiFonts::system_provider` supplies a provider either way, and
|
||||
`SystemFonts::Off` drops system fonts.
|
||||
- `color_fonts`: colour glyphs (COLR, sbix, CBDT), for colour emoji.
|
||||
|
||||
egui 0.37's default faces have only a few monochrome emoji and lack symbols such as ⊞ ☰ 🔍. On
|
||||
the web there are no system fonts, so emoji draw as boxes unless the app sets an `Emoji` face.
|
||||
`UiFonts::egui_face` puts one of egui's own faces into a family, e.g. Hack in the proportional
|
||||
family for ⇧ and ▸.
|
||||
|
||||
Each app tests its coverage with `fonts::assert_covers`: it checks every shipped translation and a
|
||||
sample of content scripts against the stack without system fonts, and fails naming the language,
|
||||
message key and character that has no glyph. Build the stack with `.system_fonts(SystemFonts::Off)`
|
||||
in that test, and run it with `CRAFT_FONTS_DIR` set so it sees the faces a release embeds. For
|
||||
languages the app leaves to installed fonts, `fonts::assert_covers_with_system` asks the stack's
|
||||
system provider too; `fonts::system_font_provider(locale)` gives egui's provider (with
|
||||
`system_fonts`), shared per locale, for `UiFonts::system_provider` or such a check. At run time,
|
||||
`Stack::covers_language(language, with_system)` says whether the stack draws a language's scripts,
|
||||
for leaving a language out of the app's menu on a machine that can't show it.
|
||||
|
||||
## Testing screens in each language
|
||||
|
||||
The `screen-testing` feature adds `testing::Screens`, for an app's screenshot tests of its key screens in
|
||||
Arabic, Japanese, Traditional Chinese (Taiwan), Korean, Thai and Vietnamese (`testing::LANGUAGES`).
|
||||
Each language gets an egui_kittest harness with the app's font stack installed for that UI language
|
||||
and system fonts off, so snapshots match on every machine and a character no face draws fails the
|
||||
test. Right-to-left languages lay the screen out from the right. The app's UI closure receives the
|
||||
`Locale` (language tag and direction) and picks its strings from it.
|
||||
|
||||
```rust,ignore
|
||||
use craft_ui::testing::{KOREAN, Screens, compose};
|
||||
|
||||
let screens = Screens::new("rename").size([320.0, 150.0]).fonts(|fonts| fonts.craft_faces(FACES));
|
||||
// Compares tests/snapshots/rename-ar.png, rename-ja.png, …; UPDATE_SNAPSHOTS=1 writes them.
|
||||
screens.snapshots(|ui, locale| rename_dialog(ui, locale));
|
||||
|
||||
// IME smoke test: a scripted 2-Set Korean composition into the focused field.
|
||||
let mut harness = screens.harness_state("ko", |ui, locale, state: &mut Dialog| state.ui(ui, locale), Dialog::default());
|
||||
harness.get_by_role(egui::accesskit::Role::TextInput).focus();
|
||||
compose(&mut harness, &KOREAN);
|
||||
assert_eq!(harness.state().name, "한");
|
||||
```
|
||||
|
||||
`compose` sends the preedit and commit events an input method sends (`KOREAN`, `JAPANESE`,
|
||||
`PINYIN`, `TELEX`), which egui's `TextEdit` and canvases reading `Event::Ime` both take. The module
|
||||
documentation has the recipe for a shortcut smoke test on a non-Latin layout through craft-keymap.
|
||||
`tests/screens.rs` is a complete example. egui's default proportional face has no Vietnamese
|
||||
letters such as ổ, so an app relying on it fails the `vi` screens until its own face covers them. The per-release manual checks are in
|
||||
[language-testing.md](../../docs/language-testing.md).
|
||||
|
||||
## Print settings and feedback
|
||||
|
||||
@@ -239,6 +480,34 @@ represented by `None`, independently of explicit one-sided, long-edge or short-e
|
||||
Receipts and backend errors remain unmodified data for the app to display. These controls work
|
||||
on WASM too; browser PDF printing remains an app-owned service.
|
||||
|
||||
## Localized numbers
|
||||
|
||||
`localized::LocaleNumbers` writes numbers in the region's digits, decimal mark and grouping and
|
||||
reads back any digits. Build it from the app's `I18n` with `.with_catalog(&i18n, lang)`, so the
|
||||
error tooltips come from the catalog rows keyed by `localized::MESSAGES` (English where a row is
|
||||
missing). `LocalizedDragValue` puts it on an `egui::DragValue`.
|
||||
|
||||
`NumberField` is the panel and dialog field. It reads text on commit and keeps text it can't
|
||||
read, outlined, with the reason in a tooltip.
|
||||
|
||||
```rust
|
||||
let label = ui.label(i18n.t(lang, "Width"));
|
||||
let frame = Recessed { fill: t.input, stroke: Stroke::new(1.0, t.input_border), ..Recessed::from_style(ui.style()) };
|
||||
let r = ui.add(NumberField::new(&mut width, &numbers).range(0.0..=1e4).step(1.0).unit(Unit::translated(&i18n, lang, "{n} px"))
|
||||
.recessed(frame).scrub_label(&label).mixed(widths_differ));
|
||||
```
|
||||
|
||||
- `.recessed(Recessed)` draws the sunken box: fill, border, focus border, radius, height, margin.
|
||||
- `.scrub_label(&label)` names the field and scrubs it when the label is dragged: a step every two
|
||||
points. A click on the label focuses the field, and Escape puts the value back. The response
|
||||
carries the label's drag flags, so the app can make the drag one undo step.
|
||||
- Arrow keys, the wheel over the focused field and scrubbing step to the next multiple of `step`
|
||||
(5.1 up to 6, down to 5); Shift steps ten, Ctrl/Cmd a tenth where the decimals show it.
|
||||
- `.mixed(true)` with `.placeholder(text)` shows a blank field for values that differ between
|
||||
objects. The value stays until the user enters a number, which is `changed()` even when equal.
|
||||
- `localized::focus_first_field(ctx, true)` when a dialog opens: the first enabled field drawn
|
||||
takes the focus with its text selected.
|
||||
|
||||
## Screenshot completion
|
||||
|
||||
`capture::CaptureSlot` is a one-result slot for egui 0.37's one-shot screenshot callback. It is not a
|
||||
@@ -321,7 +590,10 @@ verify the actual pixels as well as callback routing.
|
||||
## Compatibility and checks
|
||||
|
||||
The crate uses egui 0.37 and Rust 1.95 or later. Its production dependency disables egui's default
|
||||
features so fonts and rendering features remain the application's choice. See the
|
||||
features, so egui's bundled fonts and rendering features remain the application's choice. It has
|
||||
no default features; see [UI fonts](#ui-fonts) for `color_fonts` and `system_fonts`, and
|
||||
[Testing screens in each language](#testing-screens-in-each-language) for `screen-testing`;
|
||||
`testing` adds the headless docking driver `docking::testing`. See the
|
||||
[suite audit](../../docs/ui-components-audit.md) for candidates, policies and adoption order.
|
||||
|
||||
Validation:
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Interface direction for right-to-left languages (Arabic, Hebrew, Persian, Urdu, …).
|
||||
//!
|
||||
//! In a right-to-left interface the chrome mirrors, as in right-to-left macOS and Windows apps:
|
||||
//! the panel that sits at the left moves to the right, rows and menu bars start at the right,
|
||||
//! menus open under their button's right edge, and back arrows point right. Content keeps its
|
||||
//! geometry: [`Unmirrored`] is the suite-wide list of what never mirrors.
|
||||
//!
|
||||
//! egui has no direction for a whole context. A `Ui` runs left to right unless its layout says
|
||||
//! otherwise, and panels, popups and hand-painted positions name a side. Call sites name the
|
||||
//! reading start or end through these helpers instead of left or right. Rows nested in a [`row`]
|
||||
//! follow it on their own: egui starts a `ui.horizontal*` row from the right when its parent
|
||||
//! layout runs right to left.
|
||||
//!
|
||||
//! # Setting the direction
|
||||
//!
|
||||
//! [`resolve`] picks the direction for the UI language: the `CRAFT_UI_DIRECTION` environment
|
||||
//! variable first (`rtl` or `ltr`, for checking a mirrored layout before a right-to-left catalog
|
||||
//! exists), then the app's [`Setting`], then the language's script ([`craft_i18n::direction`]).
|
||||
//! [`apply`] stores it on the context; call it at startup and whenever the language changes.
|
||||
//! Every helper reads it from a [`HasDirection`]: a `Context`, a `Ui`, or a [`Direction`] itself.
|
||||
//! Tests force a direction by calling [`apply`] on their own context.
|
||||
//!
|
||||
//! # egui's own chrome
|
||||
//!
|
||||
//! The suite's egui fork adds `Style::right_to_left`, which mirrors submenus, scroll bars,
|
||||
//! collapsing-header triangles and window title bars. When the app patches that fork in, [`apply`]
|
||||
//! sets it on every theme's style; with crates.io egui, egui's own chrome stays left to right.
|
||||
//! Installing a whole new [`egui::Style`] resets the field, so call [`apply`] after changing theme.
|
||||
|
||||
use egui::{Align, Align2, AsIdSalt, Context, Id, InnerResponse, Key, Layout, Panel, Rect, RectAlign, Ui, pos2, vec2};
|
||||
|
||||
pub use craft_i18n::Direction;
|
||||
|
||||
/// The environment variable that forces a direction whatever the language and setting.
|
||||
pub const ENV_VAR: &str = "CRAFT_UI_DIRECTION";
|
||||
|
||||
/// Where the interface direction comes from: an app setting, usually hidden in a debug section.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "lowercase"))]
|
||||
pub enum Setting {
|
||||
/// Follow the UI language.
|
||||
#[default]
|
||||
Language,
|
||||
/// Left to right whatever the language.
|
||||
Ltr,
|
||||
/// Right to left whatever the language.
|
||||
Rtl,
|
||||
}
|
||||
|
||||
impl Setting {
|
||||
/// The direction this setting gives for a UI language tag.
|
||||
pub fn direction(self, ui_language: &str) -> Direction {
|
||||
match self {
|
||||
Setting::Language => craft_i18n::direction(ui_language),
|
||||
Setting::Ltr => Direction::Ltr,
|
||||
Setting::Rtl => Direction::Rtl,
|
||||
}
|
||||
}
|
||||
|
||||
/// `language`, `ltr` or `rtl`, any case, as [`ENV_VAR`] and settings files spell them.
|
||||
pub fn parse(value: &str) -> Option<Setting> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"language" => Some(Setting::Language),
|
||||
"ltr" => Some(Setting::Ltr),
|
||||
"rtl" => Some(Setting::Rtl),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface direction for a UI language tag: [`ENV_VAR`] when set to `rtl` or `ltr`, then
|
||||
/// `setting`, then the language's script.
|
||||
pub fn resolve(ui_language: &str, setting: Setting) -> Direction {
|
||||
env_setting().filter(|s| *s != Setting::Language).unwrap_or(setting).direction(ui_language)
|
||||
}
|
||||
|
||||
fn env_setting() -> Option<Setting> {
|
||||
static ENV: std::sync::OnceLock<Option<Setting>> = std::sync::OnceLock::new();
|
||||
*ENV.get_or_init(|| Setting::parse(&std::env::var(ENV_VAR).ok()?))
|
||||
}
|
||||
|
||||
fn key() -> Id {
|
||||
Id::unique("craft_ui::direction")
|
||||
}
|
||||
|
||||
/// Draw the interface on `ctx` in `dir` from the next layout on.
|
||||
pub fn apply(ctx: &Context, dir: Direction) {
|
||||
ctx.data_mut(|d| d.insert_temp(key(), dir));
|
||||
ctx.all_styles_mut(|style| style.set_right_to_left(dir == Direction::Rtl));
|
||||
}
|
||||
|
||||
/// The suite's egui fork has an inherent `Style::set_right_to_left`; crates.io egui has neither
|
||||
/// the method nor the field. A method call picks an inherent method over a trait method of the
|
||||
/// same name, so [`apply`] reaches egui's method on the fork and this no-op everywhere else. The
|
||||
/// same source thus builds against both without a feature.
|
||||
#[allow(dead_code, reason = "unused when egui has the inherent method")]
|
||||
trait SetRightToLeft {
|
||||
fn set_right_to_left(&mut self, _right_to_left: bool) {}
|
||||
}
|
||||
|
||||
impl SetRightToLeft for egui::Style {}
|
||||
|
||||
/// Anything the interface direction can be read from.
|
||||
pub trait HasDirection {
|
||||
fn direction(&self) -> Direction;
|
||||
}
|
||||
|
||||
impl HasDirection for Direction {
|
||||
fn direction(&self) -> Direction {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl HasDirection for Context {
|
||||
/// The direction last [`apply`]-ed to this context; left to right before that.
|
||||
fn direction(&self) -> Direction {
|
||||
self.data(|d| d.get_temp(key())).unwrap_or(Direction::Ltr)
|
||||
}
|
||||
}
|
||||
|
||||
impl HasDirection for Ui {
|
||||
fn direction(&self) -> Direction {
|
||||
self.ctx().direction()
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the interface drawn right to left?
|
||||
pub fn is_rtl(d: &impl HasDirection) -> bool {
|
||||
d.direction() == Direction::Rtl
|
||||
}
|
||||
|
||||
// Panels and layouts.
|
||||
|
||||
/// A side panel at the reading start: left, or right in a right-to-left interface.
|
||||
pub fn start_panel(d: &impl HasDirection, id_salt: impl AsIdSalt) -> Panel {
|
||||
if is_rtl(d) { Panel::right(id_salt) } else { Panel::left(id_salt) }
|
||||
}
|
||||
|
||||
/// A side panel at the reading end: right, or left in a right-to-left interface.
|
||||
pub fn end_panel(d: &impl HasDirection, id_salt: impl AsIdSalt) -> Panel {
|
||||
if is_rtl(d) { Panel::left(id_salt) } else { Panel::right(id_salt) }
|
||||
}
|
||||
|
||||
/// A vertically centred row in reading order.
|
||||
pub fn row(d: &impl HasDirection) -> Layout {
|
||||
if is_rtl(d) { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) }
|
||||
}
|
||||
|
||||
/// A vertically centred row that starts from the reading end, for groups pinned to the far end
|
||||
/// of a bar: a dialog's buttons, a title bar's controls, a status bar's zoom field.
|
||||
pub fn trailing(d: &impl HasDirection) -> Layout {
|
||||
if is_rtl(d) { Layout::left_to_right(Align::Center) } else { Layout::right_to_left(Align::Center) }
|
||||
}
|
||||
|
||||
/// A top-down column with its items against the reading start.
|
||||
pub fn column(d: &impl HasDirection) -> Layout {
|
||||
Layout::top_down(start_align(d))
|
||||
}
|
||||
|
||||
/// [`Ui::horizontal`] in reading order. Use it where the parent runs top down (a panel, a
|
||||
/// column); inside a [`row`], `ui.horizontal` already follows the parent.
|
||||
pub fn horizontal<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
let size = vec2(ui.available_size_before_wrap().x, ui.spacing().interact_size.y);
|
||||
let layout = row(ui);
|
||||
ui.allocate_ui_with_layout(size, layout, add)
|
||||
}
|
||||
|
||||
/// A left-to-right row whatever the interface direction, for content on the [`Unmirrored`] list
|
||||
/// placed inside a mirrored row: a transport bar, a slider with its value, a timecode.
|
||||
pub fn unmirrored_row<R>(ui: &mut Ui, _what: Unmirrored, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
|
||||
let size = vec2(ui.available_size_before_wrap().x, ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), add)
|
||||
}
|
||||
|
||||
// Hand-painted positions.
|
||||
|
||||
/// The horizontal alignment of the reading start: [`Align::Min`], or [`Align::Max`] right to left.
|
||||
pub fn start_align(d: &impl HasDirection) -> Align {
|
||||
if is_rtl(d) { Align::Max } else { Align::Min }
|
||||
}
|
||||
|
||||
/// The x of `rect`'s reading-start edge.
|
||||
pub fn start_x(d: &impl HasDirection, rect: Rect) -> f32 {
|
||||
if is_rtl(d) { rect.right() } else { rect.left() }
|
||||
}
|
||||
|
||||
/// The x of `rect`'s reading-end edge.
|
||||
pub fn end_x(d: &impl HasDirection, rect: Rect) -> f32 {
|
||||
if is_rtl(d) { rect.left() } else { rect.right() }
|
||||
}
|
||||
|
||||
/// `1.0`, or `-1.0` right to left: multiply an x offset written for left to right by it, as in
|
||||
/// `start_x(ui, rect) + forward(ui) * padding`.
|
||||
pub fn forward(d: &impl HasDirection) -> f32 {
|
||||
if is_rtl(d) { -1.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// An anchor written for left to right, mirrored in a right-to-left interface.
|
||||
pub fn align2(d: &impl HasDirection, ltr: Align2) -> Align2 {
|
||||
if is_rtl(d) { ltr.flipped_x() } else { ltr }
|
||||
}
|
||||
|
||||
// Popups.
|
||||
|
||||
/// A popup alignment written for left to right, mirrored in a right-to-left interface.
|
||||
pub fn popup(d: &impl HasDirection, ltr: RectAlign) -> RectAlign {
|
||||
if is_rtl(d) { ltr.flipped_x() } else { ltr }
|
||||
}
|
||||
|
||||
/// A menu below its button, under the button's reading-start edge.
|
||||
pub fn below(d: &impl HasDirection) -> RectAlign {
|
||||
popup(d, RectAlign::BOTTOM_START)
|
||||
}
|
||||
|
||||
/// A menu above its button, over the button's reading-start edge.
|
||||
pub fn above(d: &impl HasDirection) -> RectAlign {
|
||||
popup(d, RectAlign::TOP_START)
|
||||
}
|
||||
|
||||
/// A flyout beside its item, towards the reading end, top edges level: a tool's flyout, a
|
||||
/// hand-made submenu.
|
||||
pub fn beside(d: &impl HasDirection) -> RectAlign {
|
||||
popup(d, RectAlign::RIGHT_START)
|
||||
}
|
||||
|
||||
// Arrow keys.
|
||||
|
||||
/// A move along a row or list in reading order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Step {
|
||||
Previous,
|
||||
Next,
|
||||
}
|
||||
|
||||
/// What the left and right arrow keys mean in a mirrored row (tabs, a toolbar, a segmented
|
||||
/// control, a breadcrumb): `ArrowRight` moves to the previous item right to left. `None` for any
|
||||
/// other key. Over [`Unmirrored`] content (nudging on a canvas, stepping a timeline or a slider)
|
||||
/// read the keys as they are.
|
||||
pub fn step(d: &impl HasDirection, key: Key) -> Option<Step> {
|
||||
let (previous, next) = if is_rtl(d) { (Key::ArrowRight, Key::ArrowLeft) } else { (Key::ArrowLeft, Key::ArrowRight) };
|
||||
if key == previous {
|
||||
Some(Step::Previous)
|
||||
} else if key == next {
|
||||
Some(Step::Next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrow key that makes `step`, as a shortcut label shows it.
|
||||
pub fn step_key(d: &impl HasDirection, step: Step) -> Key {
|
||||
match (step, is_rtl(d)) {
|
||||
(Step::Previous, false) | (Step::Next, true) => Key::ArrowLeft,
|
||||
(Step::Next, false) | (Step::Previous, true) => Key::ArrowRight,
|
||||
}
|
||||
}
|
||||
|
||||
/// The step pressed this frame on the arrow keys, if any; the keys stay in the input.
|
||||
pub fn pressed_step(ctx: &Context) -> Option<Step> {
|
||||
ctx.input(|i| [Key::ArrowLeft, Key::ArrowRight].into_iter().find(|k| i.key_pressed(*k))).and_then(|k| step(ctx, k))
|
||||
}
|
||||
|
||||
// Icons.
|
||||
|
||||
/// Icon pairs that are each other's mirror image: right to left, each name draws as the other.
|
||||
/// Names follow Lucide, which the suite's icon sets use; an app with other names maps them first.
|
||||
///
|
||||
/// They point along the reading direction: back and forward, previous and next, collapse and
|
||||
/// expand a side panel, a reply and a forward.
|
||||
pub const SWAPPED_ICONS: &[(&str, &str)] = &[
|
||||
("arrow-left", "arrow-right"),
|
||||
("arrow-big-left", "arrow-big-right"),
|
||||
("arrow-left-to-line", "arrow-right-to-line"),
|
||||
("arrow-left-from-line", "arrow-right-from-line"),
|
||||
("chevron-left", "chevron-right"),
|
||||
("chevrons-left", "chevrons-right"),
|
||||
("chevron-first", "chevron-last"),
|
||||
("circle-arrow-left", "circle-arrow-right"),
|
||||
("circle-chevron-left", "circle-chevron-right"),
|
||||
("square-chevron-left", "square-chevron-right"),
|
||||
("move-left", "move-right"),
|
||||
("panel-left", "panel-right"),
|
||||
("panel-left-close", "panel-right-close"),
|
||||
("panel-left-open", "panel-right-open"),
|
||||
("reply", "forward"),
|
||||
];
|
||||
|
||||
/// Icons with no mirrored counterpart that are drawn flipped right to left: what they show starts
|
||||
/// at the reading start. Indent and outdent (nesting in an outline, a list or a layer tree),
|
||||
/// bulleted lists, signing in and out, sending.
|
||||
pub const FLIPPED_ICONS: &[&str] =
|
||||
&["indent-increase", "indent-decrease", "indent", "outdent", "list", "list-checks", "list-tree", "log-in", "log-out", "send", "send-horizontal"];
|
||||
|
||||
/// Icons that look directional but keep their drawing right to left. [`icon`] returns every name
|
||||
/// not in [`SWAPPED_ICONS`] or [`FLIPPED_ICONS`] unchanged; this list records the decisions.
|
||||
///
|
||||
/// - Undo and redo: history runs in time, which the suite draws left to right (timelines, media
|
||||
/// transport). The buttons still sit in reading order, since their row mirrors.
|
||||
/// - Rotate, refresh and flip: they act on the canvas, which doesn't mirror, so counterclockwise
|
||||
/// stays counterclockwise.
|
||||
/// - Media transport: see [`Unmirrored::MediaTransport`].
|
||||
/// - Paragraph alignment (`align-left`, `text-align-start`, …): document text has its own
|
||||
/// direction. Pass the paragraph's [`Direction`] to [`icon`] where an icon means its start or end.
|
||||
/// - Object alignment and distribution: they name edges of the canvas.
|
||||
/// - Numbered lists: the digits would mirror.
|
||||
pub const KEPT_ICONS: &[&str] = &[
|
||||
"undo",
|
||||
"redo",
|
||||
"undo-2",
|
||||
"redo-2",
|
||||
"undo-dot",
|
||||
"redo-dot",
|
||||
"rotate-ccw",
|
||||
"rotate-cw",
|
||||
"refresh-ccw",
|
||||
"refresh-cw",
|
||||
"flip-horizontal",
|
||||
"flip-horizontal-2",
|
||||
"flip-vertical",
|
||||
"flip-vertical-2",
|
||||
"play",
|
||||
"pause",
|
||||
"skip-back",
|
||||
"skip-forward",
|
||||
"step-back",
|
||||
"step-forward",
|
||||
"rewind",
|
||||
"fast-forward",
|
||||
"align-left",
|
||||
"align-right",
|
||||
"text-align-start",
|
||||
"text-align-end",
|
||||
"align-start-vertical",
|
||||
"align-end-vertical",
|
||||
"align-horizontal-justify-start",
|
||||
"align-horizontal-justify-end",
|
||||
"align-horizontal-distribute-start",
|
||||
"align-horizontal-distribute-end",
|
||||
"list-ordered",
|
||||
];
|
||||
|
||||
/// An icon to draw for the interface direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Icon<'a> {
|
||||
/// The icon to look up in the app's set.
|
||||
pub name: &'a str,
|
||||
/// Draw it mirrored, through [`Icon::uv`].
|
||||
pub flip: bool,
|
||||
}
|
||||
|
||||
impl Icon<'_> {
|
||||
/// The texture coordinates to draw the icon with, for [`egui::Image::uv`] or
|
||||
/// [`egui::Painter::image`]: the whole texture, mirrored when [`Icon::flip`] is set.
|
||||
pub fn uv(&self) -> Rect {
|
||||
if self.flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon to draw for `name`: its mirror-image counterpart or a flipped drawing right to left
|
||||
/// ([`SWAPPED_ICONS`], [`FLIPPED_ICONS`]), and `name` as it is otherwise.
|
||||
pub fn icon<'a>(d: &impl HasDirection, name: &'a str) -> Icon<'a> {
|
||||
let plain = Icon { name, flip: false };
|
||||
if !is_rtl(d) {
|
||||
return plain;
|
||||
}
|
||||
for &(a, b) in SWAPPED_ICONS {
|
||||
if name == a {
|
||||
return Icon { name: b, flip: false };
|
||||
}
|
||||
if name == b {
|
||||
return Icon { name: a, flip: false };
|
||||
}
|
||||
}
|
||||
Icon { name, flip: FLIPPED_ICONS.contains(&name) }
|
||||
}
|
||||
|
||||
// What never mirrors.
|
||||
|
||||
/// The suite-wide list of what keeps its left-to-right geometry in a right-to-left interface.
|
||||
/// These show the content itself, or follow a convention that holds in right-to-left locales too.
|
||||
/// Their surroundings (the panel, the toolbar, the labels) still mirror. Lay such content out
|
||||
/// with [`unmirrored_row`] or a fixed [`Layout::left_to_right`], never with [`row`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Unmirrored {
|
||||
/// Document canvases and viewports, with everything drawn over them: selections, crop and
|
||||
/// mask overlays, guides, handles and transform gizmos. Arrow keys nudge in screen space.
|
||||
Canvas,
|
||||
/// Photos, previews and thumbnail contents, with marks pinned to their corners (flags,
|
||||
/// ratings, badges, Before and After labels). A grid of thumbnails follows reading order.
|
||||
Image,
|
||||
/// Rulers: units count from the document's origin.
|
||||
Ruler,
|
||||
/// Timelines, tracks, waveforms, keyframe lanes and playheads: time runs left to right.
|
||||
Timeline,
|
||||
/// Tone curves, histograms, graphs and plots: their axes are mathematical.
|
||||
Graph,
|
||||
/// Gradients, colour ramps, hue strips, colour wheels and two-dimensional colour pickers.
|
||||
ColourRamp,
|
||||
/// Sliders: the value increases to the right, and the value field sits on the same side.
|
||||
SliderValue,
|
||||
/// Play, pause, step, skip and rewind: their order and arrows, which match the timeline.
|
||||
MediaTransport,
|
||||
/// Code, expressions, file paths and URLs: left-to-right text, aligned left.
|
||||
Code,
|
||||
/// Number fields with their units, coordinates and sizes: digits run left to right, and x
|
||||
/// comes before y.
|
||||
Number,
|
||||
/// Timecodes, durations and frame counters.
|
||||
Timecode,
|
||||
}
|
||||
|
||||
impl Unmirrored {
|
||||
pub const ALL: [Unmirrored; 11] = [
|
||||
Unmirrored::Canvas,
|
||||
Unmirrored::Image,
|
||||
Unmirrored::Ruler,
|
||||
Unmirrored::Timeline,
|
||||
Unmirrored::Graph,
|
||||
Unmirrored::ColourRamp,
|
||||
Unmirrored::SliderValue,
|
||||
Unmirrored::MediaTransport,
|
||||
Unmirrored::Code,
|
||||
Unmirrored::Number,
|
||||
Unmirrored::Timecode,
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::*;
|
||||
use egui::{Pos2, RawInput, Response};
|
||||
|
||||
const WIDTH: f32 = 600.0;
|
||||
|
||||
fn context(dir: Direction) -> Context {
|
||||
let ctx = Context::default();
|
||||
apply(&ctx, dir);
|
||||
ctx
|
||||
}
|
||||
|
||||
fn frame(ctx: &Context, add: impl FnMut(&mut Ui)) {
|
||||
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(WIDTH, 400.0))), ..Default::default() };
|
||||
ctx.run_ui(raw, add).textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Centre x of a panel shown with `panel` and of a label in the space left.
|
||||
fn panel_and_rest(dir: Direction, panel: fn(&Context, &'static str) -> Panel) -> (f32, f32) {
|
||||
let ctx = context(dir);
|
||||
let (mut side, mut rest) = (0.0, 0.0);
|
||||
for _ in 0..2 {
|
||||
frame(&ctx, |ui| {
|
||||
side = panel(ui.ctx(), "side").show(ui, |ui| ui.label("side")).response.rect.center().x;
|
||||
rest = ui.label("rest").rect.center().x;
|
||||
});
|
||||
}
|
||||
(side, rest)
|
||||
}
|
||||
|
||||
/// Centre x of two labels added in order with `layout`.
|
||||
fn two_in(dir: Direction, layout: fn(&Ui) -> Layout) -> (f32, f32) {
|
||||
let ctx = context(dir);
|
||||
let mut xs = (0.0, 0.0);
|
||||
frame(&ctx, |ui| {
|
||||
let layout = layout(ui);
|
||||
ui.with_layout(layout, |ui| {
|
||||
let first: Response = ui.label("first");
|
||||
let second = ui.label("second");
|
||||
xs = (first.rect.center().x, second.rect.center().x);
|
||||
});
|
||||
});
|
||||
xs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_is_per_context_and_left_to_right_until_applied() {
|
||||
let ctx = Context::default();
|
||||
assert_eq!(ctx.direction(), Direction::Ltr);
|
||||
apply(&ctx, Direction::Rtl);
|
||||
assert!(is_rtl(&ctx));
|
||||
assert_eq!(Context::default().direction(), Direction::Ltr);
|
||||
apply(&ctx, Direction::Ltr);
|
||||
assert!(!is_rtl(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_overrides_the_language() {
|
||||
assert_eq!(Setting::Language.direction("ar"), Direction::Rtl);
|
||||
assert_eq!(Setting::Language.direction("he-IL"), Direction::Rtl);
|
||||
assert_eq!(Setting::Language.direction("en"), Direction::Ltr);
|
||||
assert_eq!(Setting::Rtl.direction("en"), Direction::Rtl);
|
||||
assert_eq!(Setting::Ltr.direction("fa"), Direction::Ltr);
|
||||
assert_eq!(Setting::parse(" RTL "), Some(Setting::Rtl));
|
||||
assert_eq!(Setting::parse("ltr"), Some(Setting::Ltr));
|
||||
assert_eq!(Setting::parse("language"), Some(Setting::Language));
|
||||
assert_eq!(Setting::parse("ar"), None);
|
||||
assert_eq!(Setting::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_panel_lands_at_the_right_in_rtl() {
|
||||
let (side, rest) = panel_and_rest(Direction::Ltr, start_panel);
|
||||
assert!(side < WIDTH / 2.0 && side < rest, "{side} {rest}");
|
||||
let (side, rest) = panel_and_rest(Direction::Rtl, start_panel);
|
||||
assert!(side > WIDTH / 2.0 && side > rest, "{side} {rest}");
|
||||
let (side, _) = panel_and_rest(Direction::Rtl, end_panel);
|
||||
assert!(side < WIDTH / 2.0, "{side}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_run_in_reading_order() {
|
||||
let (first, second) = two_in(Direction::Ltr, row);
|
||||
assert!(first < second);
|
||||
let (first, second) = two_in(Direction::Rtl, row);
|
||||
assert!(first > second);
|
||||
assert!(first > WIDTH / 2.0, "a right-to-left row starts at the right: {first}");
|
||||
let (first, _) = two_in(Direction::Rtl, trailing);
|
||||
assert!(first < WIDTH / 2.0, "a trailing group starts at the reading end: {first}");
|
||||
let (first, _) = two_in(Direction::Ltr, trailing);
|
||||
assert!(first > WIDTH / 2.0, "{first}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_rows_follow_and_unmirrored_rows_do_not() {
|
||||
let ctx = context(Direction::Rtl);
|
||||
let (mut nested, mut fixed) = ((0.0, 0.0), (0.0, 0.0));
|
||||
frame(&ctx, |ui| {
|
||||
horizontal(ui, |ui| {
|
||||
ui.horizontal(|ui| nested = (ui.label("a").rect.center().x, ui.label("b").rect.center().x));
|
||||
});
|
||||
horizontal(ui, |ui| {
|
||||
unmirrored_row(ui, Unmirrored::MediaTransport, |ui| fixed = (ui.label("a").rect.center().x, ui.label("b").rect.center().x));
|
||||
});
|
||||
});
|
||||
assert!(nested.0 > nested.1, "{nested:?}");
|
||||
assert!(fixed.0 < fixed.1, "{fixed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_and_popups_mirror() {
|
||||
let r = Rect::from_min_max(pos2(10.0, 0.0), pos2(50.0, 10.0));
|
||||
let (ltr, rtl) = (Direction::Ltr, Direction::Rtl);
|
||||
assert_eq!((start_x(<r, r), end_x(<r, r), forward(<r)), (10.0, 50.0, 1.0));
|
||||
assert_eq!((start_x(&rtl, r), end_x(&rtl, r), forward(&rtl)), (50.0, 10.0, -1.0));
|
||||
assert_eq!(start_align(&rtl), Align::Max);
|
||||
assert_eq!(column(&rtl).horizontal_align(), Align::Max);
|
||||
assert_eq!(align2(&rtl, Align2::LEFT_TOP), Align2::RIGHT_TOP);
|
||||
assert_eq!(below(<r), RectAlign::BOTTOM_START);
|
||||
assert_eq!(below(&rtl), RectAlign::BOTTOM_END);
|
||||
assert_eq!(above(&rtl), RectAlign::TOP_END);
|
||||
assert_eq!(beside(<r), RectAlign::RIGHT_START);
|
||||
assert_eq!(beside(&rtl), RectAlign::LEFT_START);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_follow_the_visual_direction() {
|
||||
let (ltr, rtl) = (Direction::Ltr, Direction::Rtl);
|
||||
assert_eq!(step(<r, Key::ArrowLeft), Some(Step::Previous));
|
||||
assert_eq!(step(<r, Key::ArrowRight), Some(Step::Next));
|
||||
assert_eq!(step(&rtl, Key::ArrowLeft), Some(Step::Next));
|
||||
assert_eq!(step(&rtl, Key::ArrowRight), Some(Step::Previous));
|
||||
assert_eq!(step(&rtl, Key::ArrowUp), None);
|
||||
for d in [ltr, rtl] {
|
||||
for s in [Step::Previous, Step::Next] {
|
||||
assert_eq!(step(&d, step_key(&d, s)), Some(s));
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = context(Direction::Rtl);
|
||||
let mut pressed = None;
|
||||
let press = egui::Event::Key { key: Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
let raw = RawInput { events: vec![press], ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| pressed = pressed_step(ui.ctx())).textures_delta.clear();
|
||||
assert_eq!(pressed, Some(Step::Previous));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_icons_flip_from_one_list() {
|
||||
let (ltr, rtl) = (Direction::Ltr, Direction::Rtl);
|
||||
assert_eq!(icon(<r, "chevron-left"), Icon { name: "chevron-left", flip: false });
|
||||
assert_eq!(icon(&rtl, "chevron-left"), Icon { name: "chevron-right", flip: false });
|
||||
assert_eq!(icon(&rtl, "panel-right-close"), Icon { name: "panel-left-close", flip: false });
|
||||
assert_eq!(icon(&rtl, "indent-increase"), Icon { name: "indent-increase", flip: true });
|
||||
assert_eq!(icon(<r, "indent-increase").uv(), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
|
||||
assert_eq!(icon(&rtl, "indent-increase").uv().left(), 1.0);
|
||||
for name in KEPT_ICONS.iter().chain(["brush", "layers"].iter()) {
|
||||
assert_eq!(icon(&rtl, name), Icon { name, flip: false }, "{name}");
|
||||
}
|
||||
|
||||
let mut names: Vec<&str> =
|
||||
SWAPPED_ICONS.iter().flat_map(|&(a, b)| [a, b]).chain(FLIPPED_ICONS.iter().copied()).chain(KEPT_ICONS.iter().copied()).collect();
|
||||
let count = names.len();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), count, "an icon is on one list only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_unmirrored_kind_is_listed_once() {
|
||||
for (i, kind) in Unmirrored::ALL.into_iter().enumerate() {
|
||||
// A new kind fails to compile here until it is placed in `ALL`.
|
||||
let position = match kind {
|
||||
Unmirrored::Canvas => 0,
|
||||
Unmirrored::Image => 1,
|
||||
Unmirrored::Ruler => 2,
|
||||
Unmirrored::Timeline => 3,
|
||||
Unmirrored::Graph => 4,
|
||||
Unmirrored::ColourRamp => 5,
|
||||
Unmirrored::SliderValue => 6,
|
||||
Unmirrored::MediaTransport => 7,
|
||||
Unmirrored::Code => 8,
|
||||
Unmirrored::Number => 9,
|
||||
Unmirrored::Timecode => 10,
|
||||
};
|
||||
assert_eq!(position, i, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Style::right_to_left` as egui's `Debug` output shows it: `None` on crates.io egui, which has
|
||||
/// no such field, so the test builds against both.
|
||||
fn style_rtl(ctx: &Context, theme: egui::Theme) -> Option<bool> {
|
||||
let debug = format!("{:?}", ctx.style_of(theme));
|
||||
debug.contains("right_to_left").then(|| debug.contains("right_to_left: true"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mirrors_egui_chrome_where_egui_can() {
|
||||
let ctx = context(Direction::Rtl);
|
||||
let patched = style_rtl(&ctx, egui::Theme::Dark).is_some();
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
assert_eq!(style_rtl(&ctx, theme), patched.then_some(true), "{theme:?}");
|
||||
}
|
||||
apply(&ctx, Direction::Ltr);
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
assert_eq!(style_rtl(&ctx, theme), patched.then_some(false), "{theme:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The coverage check each app runs over its shipped translations.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use egui::{FontFamily, FontId};
|
||||
|
||||
use super::Stack;
|
||||
|
||||
/// A string the UI shows: one translation, or a sample of content text.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Sample<'a> {
|
||||
/// The language tag the string belongs to.
|
||||
pub language: &'a str,
|
||||
/// The message key, or a name for a content sample.
|
||||
pub key: &'a str,
|
||||
pub text: &'a str,
|
||||
}
|
||||
|
||||
/// A character the stack can't draw.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Missing {
|
||||
pub language: String,
|
||||
pub key: String,
|
||||
pub text: String,
|
||||
pub character: char,
|
||||
}
|
||||
|
||||
impl fmt::Display for Missing {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} {}: U+{:04X} {:?} in {:?}", self.language, self.key, u32::from(self.character), self.character, self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every character in `samples` that no face of `family` in `stack` has. `with_system` also asks
|
||||
/// the stack's system font provider (the `system_fonts` feature, or [`super::UiFonts::system_provider`]),
|
||||
/// for an app that relies on installed fonts for some languages.
|
||||
///
|
||||
/// Whitespace, control characters and invisible format characters (joiners, direction marks,
|
||||
/// variation selectors) are skipped: they draw nothing.
|
||||
pub fn missing_glyphs<'a>(stack: &Stack, family: &FontFamily, samples: impl IntoIterator<Item = Sample<'a>>, with_system: bool) -> Vec<Missing> {
|
||||
let mut fonts = stack.fonts(with_system);
|
||||
let font_id = FontId::new(14.0, family.clone());
|
||||
let mut missing = Vec::new();
|
||||
for sample in samples {
|
||||
let mut seen = Vec::new();
|
||||
for c in sample.text.chars().filter(|c| draws(*c)) {
|
||||
if !seen.contains(&c) && !fonts.has_glyph(&font_id, c) {
|
||||
seen.push(c);
|
||||
missing.push(Missing {
|
||||
language: sample.language.to_owned(),
|
||||
key: sample.key.to_owned(),
|
||||
text: sample.text.to_owned(),
|
||||
character: c,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// Fail, naming each language, string and character, if the stack can't draw all of `samples`
|
||||
/// in the proportional family without system fonts. For an app's coverage test.
|
||||
pub fn assert_covers<'a>(stack: &Stack, samples: impl IntoIterator<Item = Sample<'a>>) {
|
||||
fail_on(&missing_glyphs(stack, &FontFamily::Proportional, samples, false));
|
||||
}
|
||||
|
||||
/// [`assert_covers`] with the stack's system font provider asked too. The result depends on the
|
||||
/// machine's fonts; use it for languages the app leaves to them.
|
||||
pub fn assert_covers_with_system<'a>(stack: &Stack, samples: impl IntoIterator<Item = Sample<'a>>) {
|
||||
fail_on(&missing_glyphs(stack, &FontFamily::Proportional, samples, true));
|
||||
}
|
||||
|
||||
#[allow(clippy::panic, reason = "a test assertion")]
|
||||
fn fail_on(missing: &[Missing]) {
|
||||
if !missing.is_empty() {
|
||||
let lines: Vec<String> = missing.iter().take(50).map(|m| format!(" {m}")).collect();
|
||||
let more = if missing.len() > lines.len() { format!("\n … and {} more", missing.len() - lines.len()) } else { String::new() };
|
||||
panic!("the UI fonts can't draw {} characters:\n{}{more}", missing.len(), lines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A few common letters of an ISO 15924 script, for [`Stack::covers_language`]. `None` for a
|
||||
/// script without a sample, which counts as covered.
|
||||
pub(super) fn script_sample(script: &str) -> Option<&'static str> {
|
||||
Some(match script {
|
||||
"Arab" => "عربي",
|
||||
"Armn" => "Հայերեն",
|
||||
"Beng" => "বাংলা",
|
||||
"Bopo" => "ㄅㄆㄇ",
|
||||
"Cyrl" => "Кирилица",
|
||||
"Deva" => "हिन्दी",
|
||||
"Ethi" => "አማርኛ",
|
||||
"Geor" => "ქართული",
|
||||
"Grek" => "Ελληνικά",
|
||||
"Gujr" => "ગુજરાતી",
|
||||
"Guru" => "ਪੰਜਾਬੀ",
|
||||
"Hang" => "한국어",
|
||||
"Hani" => "日本中文",
|
||||
"Hebr" => "עברית",
|
||||
"Hira" => "の",
|
||||
"Kana" => "ファイル",
|
||||
"Khmr" => "ខ្មែរ",
|
||||
"Knda" => "ಕನ್ನಡ",
|
||||
"Laoo" => "ລາວ",
|
||||
"Latn" => "Latin",
|
||||
"Mlym" => "മലയാളം",
|
||||
"Mong" => "ᠮᠣᠩᠭᠣᠯ",
|
||||
"Mymr" => "မြန်မာ",
|
||||
"Orya" => "ଓଡ଼ିଆ",
|
||||
"Sinh" => "සිංහල",
|
||||
"Syrc" => "ܣܘܪܝܝܐ",
|
||||
"Taml" => "தமிழ்",
|
||||
"Telu" => "తెలుగు",
|
||||
"Thaa" => "ދިވެހި",
|
||||
"Thai" => "ไทย",
|
||||
"Tibt" => "བོད་ཡིག",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `c` shows a glyph: not whitespace, a control, a format character or another invisible
|
||||
/// mark.
|
||||
pub(super) fn draws(c: char) -> bool {
|
||||
!(c.is_whitespace()
|
||||
|| c.is_control()
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}'
|
||||
)
|
||||
|| matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Row metrics for the UI language's script face.
|
||||
//!
|
||||
//! An app's theme sets `interact_size` for rows of its own face. When the UI language's script face
|
||||
//! leads the stack and has taller rows, widgets need that much more height; the theme's own height
|
||||
//! and padding stay as they are. CJK faces that follow the app's face are fitted into its rows
|
||||
//! instead ([`into_row`]).
|
||||
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use egui::epaint::text::{Fonts, TextOptions};
|
||||
use egui::{FontData, FontDefinitions, FontFamily, FontId, Style, TextStyle, Theme};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{FontRef, MetadataProvider as _};
|
||||
|
||||
const BASE: &str = "craft-ui base face";
|
||||
const SCRIPT: &str = "craft-ui script face";
|
||||
|
||||
/// The app's face and the UI language's script face, each as its own family.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RowFaces(FontDefinitions);
|
||||
|
||||
impl RowFaces {
|
||||
pub(super) fn new(base: Arc<FontData>, script: Arc<FontData>) -> Self {
|
||||
let mut definitions = FontDefinitions::empty();
|
||||
for (name, data) in [(BASE, base), (SCRIPT, script)] {
|
||||
definitions.font_data.insert(name.to_owned(), data);
|
||||
definitions.families.insert(FontFamily::Name(name.into()), vec![name.to_owned()]);
|
||||
}
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
definitions.families.entry(family).or_default();
|
||||
}
|
||||
Self(definitions)
|
||||
}
|
||||
}
|
||||
|
||||
/// How much taller a row of the script face is than a row of the app's face, at `style`'s body and
|
||||
/// button sizes; `0` when it isn't taller.
|
||||
pub(super) fn extra_height(faces: Option<&RowFaces>, style: &Style) -> f32 {
|
||||
let Some(RowFaces(definitions)) = faces else { return 0.0 };
|
||||
let mut fonts = Fonts::new(TextOptions::default(), definitions.clone());
|
||||
let mut view = fonts.with_pixels_per_point(1.0);
|
||||
let mut row = |size: f32, face: &str| view.row_height(&FontId::new(size, FontFamily::Name(face.into())));
|
||||
let extra = text_sizes(style).into_iter().map(|size| row(size, SCRIPT) - row(size, BASE)).fold(0.0_f32, f32::max);
|
||||
if extra.is_finite() { extra } else { 0.0 }
|
||||
}
|
||||
|
||||
fn text_sizes(style: &Style) -> Vec<f32> {
|
||||
[TextStyle::Body, TextStyle::Button].iter().filter_map(|s| style.text_styles.get(s)).map(|font| font.size).collect()
|
||||
}
|
||||
|
||||
/// Tall and deep ideographs, Hangul, kana, bopomofo and corner brackets: the characters a CJK face
|
||||
/// fitted into a Latin row keeps inside it.
|
||||
const ROW_SAMPLES: &str = "漢中國體鬱한뷁あぎアギㄅ「」";
|
||||
|
||||
/// Fit `face`, a CJK fallback after `primary`, into `primary`'s rows: its glyphs sit on
|
||||
/// `primary`'s baseline and the row keeps `primary`'s height. The face is scaled down only as far
|
||||
/// as keeps [`ROW_SAMPLES`] inside the row.
|
||||
pub(super) fn into_row(primary: &FontData, face: &mut FontData) {
|
||||
let (Some(p), Some(f)) = (VerticalMetrics::of(primary), VerticalMetrics::of(face)) else { return };
|
||||
let ascent = primary.tweak.scale * p.ascent;
|
||||
let height = primary.tweak.scale * p.height();
|
||||
let (above, below) = ink_extent(face, ROW_SAMPLES);
|
||||
let mut scale = 1.0_f32;
|
||||
if above > ascent {
|
||||
scale = scale.min(ascent / above);
|
||||
}
|
||||
if below > height - ascent {
|
||||
scale = scale.min((height - ascent) / below);
|
||||
}
|
||||
#[allow(unused_imports, reason = "unused when egui has the inherent method")]
|
||||
use super::SetBaselineFallback as _;
|
||||
// egui centres a fallback's row in the primary's row without growing it; the offset moves the
|
||||
// centred baseline onto the primary's. Both are in units of the font size times the scale.
|
||||
face.tweak.set_baseline_fallback(false);
|
||||
face.tweak.scale = scale;
|
||||
let centred = 0.5 * (height - scale * f.height()) + scale * f.ascent;
|
||||
face.tweak.y_offset_factor = (ascent - centred + primary.tweak.scale * primary.tweak.y_offset_factor) / scale;
|
||||
face.tweak.y_offset = primary.tweak.y_offset;
|
||||
}
|
||||
|
||||
/// A face's ascent, descent and line gap as epaint reads them, in ems.
|
||||
struct VerticalMetrics {
|
||||
ascent: f32,
|
||||
descent: f32,
|
||||
line_gap: f32,
|
||||
}
|
||||
|
||||
impl VerticalMetrics {
|
||||
fn of(data: &FontData) -> Option<Self> {
|
||||
let font = FontRef::from_index(data.bytes(), data.index).ok()?;
|
||||
let metrics = font.metrics(Size::unscaled(), LocationRef::default());
|
||||
let em = f32::from(metrics.units_per_em);
|
||||
(em > 0.0).then(|| Self { ascent: metrics.ascent / em, descent: -metrics.descent / em, line_gap: metrics.leading / em })
|
||||
}
|
||||
|
||||
fn height(&self) -> f32 {
|
||||
self.ascent + self.descent + self.line_gap
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the outlines of `text`'s characters in `data` reach above and below the baseline, in
|
||||
/// ems, from their control points.
|
||||
pub(super) fn ink_extent(data: &FontData, text: &str) -> (f32, f32) {
|
||||
let Ok(font) = FontRef::from_index(data.bytes(), data.index) else { return (0.0, 0.0) };
|
||||
let em = f32::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
let (charmap, outlines) = (font.charmap(), font.outline_glyphs());
|
||||
let mut pen = Extent::default();
|
||||
for glyph in text.chars().filter_map(|c| charmap.map(c)).filter_map(|id| outlines.get(id)) {
|
||||
let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
|
||||
}
|
||||
if em > 0.0 { (pen.top / em, -pen.bottom / em) } else { (0.0, 0.0) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Extent {
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
}
|
||||
|
||||
impl Extent {
|
||||
fn add(&mut self, y: f32) {
|
||||
self.top = self.top.max(y);
|
||||
self.bottom = self.bottom.min(y);
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for Extent {
|
||||
fn move_to(&mut self, _x: f32, y: f32) {
|
||||
self.add(y);
|
||||
}
|
||||
fn line_to(&mut self, _x: f32, y: f32) {
|
||||
self.add(y);
|
||||
}
|
||||
fn quad_to(&mut self, _cx0: f32, cy0: f32, _x: f32, y: f32) {
|
||||
self.add(cy0);
|
||||
self.add(y);
|
||||
}
|
||||
fn curve_to(&mut self, _cx0: f32, cy0: f32, _cx1: f32, cy1: f32, _x: f32, y: f32) {
|
||||
self.add(cy0);
|
||||
self.add(cy1);
|
||||
self.add(y);
|
||||
}
|
||||
fn close(&mut self) {}
|
||||
}
|
||||
|
||||
/// A context's fit, kept in its data.
|
||||
#[derive(Default)]
|
||||
struct Fitted {
|
||||
faces: Option<RowFaces>,
|
||||
/// [`extra_height`] by text sizes, for the faces installed.
|
||||
measured: Vec<(Vec<f32>, f32)>,
|
||||
/// Per theme: the style's own height and the height it was set to.
|
||||
applied: [Option<(f32, f32)>; 2],
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Fitted>>;
|
||||
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::unique("craft_ui::fonts::fitted")
|
||||
}
|
||||
|
||||
fn lock(shared: &Shared) -> MutexGuard<'_, Fitted> {
|
||||
shared.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Fit `ctx`'s styles to `faces` now, and again at the start of every pass after the app sets a
|
||||
/// style with another height or text size.
|
||||
pub(super) fn install(ctx: &egui::Context, faces: Option<RowFaces>) {
|
||||
let shared: Shared = ctx.data_mut(|d| Arc::clone(d.get_temp_mut_or_default::<Shared>(id())));
|
||||
{
|
||||
let mut fitted = lock(&shared);
|
||||
fitted.faces = faces;
|
||||
fitted.measured.clear();
|
||||
}
|
||||
ctx.add_plugin(Refit);
|
||||
refit(ctx, &shared);
|
||||
}
|
||||
|
||||
fn refit(ctx: &egui::Context, shared: &Shared) {
|
||||
let mut fitted = lock(shared);
|
||||
for (i, theme) in [Theme::Dark, Theme::Light].into_iter().enumerate() {
|
||||
let style = ctx.style_of(theme);
|
||||
let height = style.spacing.interact_size.y;
|
||||
// Any other height than the one set last is the app's, e.g. from a theme applied since.
|
||||
let base = match fitted.applied[i] {
|
||||
Some((base, set)) if set == height => base,
|
||||
_ => height,
|
||||
};
|
||||
let sizes = text_sizes(&style);
|
||||
let extra = match fitted.measured.iter().find(|(s, _)| *s == sizes) {
|
||||
Some((_, extra)) => *extra,
|
||||
None => {
|
||||
let extra = extra_height(fitted.faces.as_ref(), &style);
|
||||
// Bounded, for an app that changes text sizes often.
|
||||
if fitted.measured.len() >= 4 {
|
||||
fitted.measured.remove(0);
|
||||
}
|
||||
fitted.measured.push((sizes, extra));
|
||||
extra
|
||||
}
|
||||
};
|
||||
let target = base + extra;
|
||||
if target != height {
|
||||
ctx.style_mut_of(theme, |style| style.spacing.interact_size.y = target);
|
||||
}
|
||||
fitted.applied[i] = Some((base, target));
|
||||
}
|
||||
}
|
||||
|
||||
/// Refits a context's styles at the start of each pass.
|
||||
struct Refit;
|
||||
|
||||
impl egui::plugin::Plugin for Refit {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"craft_ui::fonts::Refit"
|
||||
}
|
||||
|
||||
fn on_begin_pass(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let shared: Option<Shared> = ctx.data(|d| d.get_temp(id()));
|
||||
if let Some(shared) = shared {
|
||||
refit(&ctx, &shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
//! Font providers: embedded faces asked after the system fonts, and the regular-weight wrapper for
|
||||
//! the system font provider.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, PoisonError, RwLock};
|
||||
|
||||
use egui::epaint::text::Tag;
|
||||
use egui::{FallbackRequest, FontData, FontFamily, FontInsert, FontPriority, FontProvider, InsertFontFamily};
|
||||
use skrifa::MetadataProvider as _;
|
||||
|
||||
/// Set a variable face's weight axis to `weight`, within the axis's range.
|
||||
///
|
||||
/// Leaves a face with no weight axis, or one whose weight is already set, as it is. Returns
|
||||
/// whether it changed the face. Variable system fonts often default to their lightest instance,
|
||||
/// e.g. Thin (100), so a face installed without this draws as hairlines.
|
||||
pub fn set_weight(data: &mut FontData, weight: u16) -> bool {
|
||||
let wght = Tag::new(b"wght");
|
||||
if data.tweak.coords.as_ref().iter().any(|(tag, _)| *tag == wght) {
|
||||
return false;
|
||||
}
|
||||
let Some(axis) = data.variation_axes().into_iter().find(|axis| axis.tag == wght) else { return false };
|
||||
let value = f32::from(weight).clamp(axis.range.min, axis.range.max);
|
||||
if value == axis.default {
|
||||
return false;
|
||||
}
|
||||
data.tweak.coords.push(*b"wght", value);
|
||||
true
|
||||
}
|
||||
|
||||
/// Wraps a font provider, usually the system font provider, so variable faces it finds draw
|
||||
/// at each family's weight (regular, 400, unless set with [`Self::with_weight`]).
|
||||
pub struct RegularWeight {
|
||||
inner: Arc<dyn FontProvider>,
|
||||
weights: Vec<(FontFamily, u16)>,
|
||||
}
|
||||
|
||||
impl RegularWeight {
|
||||
pub fn new(inner: Arc<dyn FontProvider>) -> Self {
|
||||
Self { inner, weights: Vec::new() }
|
||||
}
|
||||
|
||||
/// Draw faces found for `family` at `weight`, e.g. 600 for a semibold family.
|
||||
pub fn with_weight(mut self, family: FontFamily, weight: u16) -> Self {
|
||||
self.weights.retain(|(f, _)| *f != family);
|
||||
self.weights.push((family, weight));
|
||||
self
|
||||
}
|
||||
|
||||
fn weight(&self, family: &FontFamily) -> u16 {
|
||||
self.weights.iter().find(|(f, _)| f == family).map_or(400, |(_, w)| *w)
|
||||
}
|
||||
}
|
||||
|
||||
impl FontProvider for RegularWeight {
|
||||
fn fonts_for_family(&self, family: &FontFamily) -> Vec<FontInsert> {
|
||||
let weight = self.weight(family);
|
||||
self.inner.fonts_for_family(family).into_iter().map(|insert| weighted(insert, weight)).collect()
|
||||
}
|
||||
|
||||
fn font_for(&self, request: &FallbackRequest<'_>) -> Option<FontInsert> {
|
||||
let insert = self.inner.font_for(request)?;
|
||||
Some(weighted(insert, self.weight(request.family)))
|
||||
}
|
||||
}
|
||||
|
||||
fn weighted(mut insert: FontInsert, weight: u16) -> FontInsert {
|
||||
// egui's face store keys faces by name, so a face set to another weight needs its own.
|
||||
if set_weight(&mut insert.data, weight) && weight != 400 {
|
||||
insert.name = format!("{} @{weight}", insert.name);
|
||||
}
|
||||
insert
|
||||
}
|
||||
|
||||
/// Wraps the system font provider so the text faces it finds sit on the row's baseline as
|
||||
/// fallbacks, as craft faces do. Emoji and symbol faces stay centred in the row.
|
||||
pub(super) struct OnBaseline(pub(super) Arc<dyn FontProvider>);
|
||||
|
||||
impl FontProvider for OnBaseline {
|
||||
fn fonts_for_family(&self, family: &FontFamily) -> Vec<FontInsert> {
|
||||
self.0.fonts_for_family(family).into_iter().map(on_baseline).collect()
|
||||
}
|
||||
|
||||
fn font_for(&self, request: &FallbackRequest<'_>) -> Option<FontInsert> {
|
||||
self.0.font_for(request).map(on_baseline)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_baseline(mut insert: FontInsert) -> FontInsert {
|
||||
if is_text_face(&insert.name, &insert.data) {
|
||||
#[allow(unused_imports, reason = "unused when egui has the inherent method")]
|
||||
use super::SetBaselineFallback as _;
|
||||
insert.data.tweak.set_baseline_fallback(true);
|
||||
}
|
||||
insert
|
||||
}
|
||||
|
||||
/// Whether a face draws text rather than emoji or symbols: it has no colour glyphs, and neither
|
||||
/// its egui name nor its family name says emoji or symbols.
|
||||
pub(super) fn is_text_face(name: &str, data: &FontData) -> bool {
|
||||
let pictorial = |name: &str| {
|
||||
let name = name.to_lowercase();
|
||||
["emoji", "symbol"].iter().any(|word| name.contains(word))
|
||||
};
|
||||
if pictorial(name) {
|
||||
return false;
|
||||
}
|
||||
let Ok(font) = skrifa::FontRef::from_index(data.bytes(), data.index) else { return true };
|
||||
let colour = [b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|tag| font.table_data(skrifa::Tag::new(tag)).is_some());
|
||||
let family = font.localized_strings(skrifa::string::StringId::FAMILY_NAME).english_or_first().map(|s| s.to_string());
|
||||
!colour && !family.is_some_and(|family| pictorial(&family))
|
||||
}
|
||||
|
||||
/// Embedded faces asked in order, per family, for a character no earlier face or provider has.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FaceList {
|
||||
faces: BTreeMap<String, FontData>,
|
||||
chains: BTreeMap<FontFamily, Vec<String>>,
|
||||
}
|
||||
|
||||
impl FaceList {
|
||||
/// The faces asked for `family`, in order. A family without its own list uses the
|
||||
/// proportional one.
|
||||
pub fn chain(&self, family: &FontFamily) -> &[String] {
|
||||
self.chains.get(family).or_else(|| self.chains.get(&FontFamily::Proportional)).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Replace `family`'s list. Names without data from [`Self::add`] are skipped when asked.
|
||||
pub fn set_chain(&mut self, family: FontFamily, names: Vec<String>) {
|
||||
self.chains.insert(family, names);
|
||||
}
|
||||
|
||||
pub(super) fn add(&mut self, face: super::Face) -> String {
|
||||
self.faces.entry(face.name.clone()).or_insert(face.data);
|
||||
face.name
|
||||
}
|
||||
|
||||
/// Each family's list, for renaming entries in place.
|
||||
pub(super) fn chains_mut(&mut self) -> impl Iterator<Item = (&FontFamily, &mut Vec<String>)> {
|
||||
self.chains.iter_mut()
|
||||
}
|
||||
|
||||
/// Add or replace a face's data.
|
||||
pub fn insert(&mut self, name: &str, data: FontData) {
|
||||
self.faces.insert(name.to_owned(), data);
|
||||
}
|
||||
|
||||
/// Whether the list has data for the face called `name`.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.faces.contains_key(name)
|
||||
}
|
||||
|
||||
/// The data of the face called `name`, with its tweak.
|
||||
pub fn get(&self, name: &str) -> Option<&FontData> {
|
||||
self.faces.get(name)
|
||||
}
|
||||
|
||||
/// Every face in the list with its name, in name order.
|
||||
pub fn faces(&self) -> impl Iterator<Item = (&str, &FontData)> {
|
||||
self.faces.iter().map(|(name, data)| (name.as_str(), data))
|
||||
}
|
||||
|
||||
/// Remove the face called `name` and drop it from every chain.
|
||||
pub fn remove(&mut self, name: &str) {
|
||||
self.faces.remove(name);
|
||||
for chain in self.chains.values_mut() {
|
||||
chain.retain(|n| n != name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FontProvider for FaceList {
|
||||
/// The first face with the cluster's first character, preferring one that also has the
|
||||
/// cluster's other visible characters: egui shapes the whole cluster with this face.
|
||||
fn font_for(&self, request: &FallbackRequest<'_>) -> Option<FontInsert> {
|
||||
let mut chars = request.cluster.chars();
|
||||
let first = chars.next()?;
|
||||
let rest: Vec<char> = chars.filter(|&c| super::coverage::draws(c)).collect();
|
||||
let faces =
|
||||
self.chain(request.family).iter().filter_map(|name| Some((name, self.faces.get(name)?))).filter(|(_, data)| has_char(data, first));
|
||||
let (name, data) = faces.clone().find(|(_, data)| rest.iter().all(|&c| has_char(data, c))).or_else(|| faces.clone().next())?;
|
||||
Some(FontInsert::new(name, data.clone(), vec![InsertFontFamily { family: request.family.clone(), priority: FontPriority::Lowest }]))
|
||||
}
|
||||
}
|
||||
|
||||
/// A context's installed [`FaceList`], swapped in place when the stack is installed again, so the
|
||||
/// providers added before it keep their place, and the provider asked when the list misses too
|
||||
/// (on the web, the one that fetches served faces).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Installed(RwLock<(FaceList, Option<Arc<dyn FontProvider>>)>);
|
||||
|
||||
impl Installed {
|
||||
pub(super) fn set(&self, faces: FaceList, miss: Option<Arc<dyn FontProvider>>) {
|
||||
*self.0.write().unwrap_or_else(PoisonError::into_inner) = (faces, miss);
|
||||
}
|
||||
}
|
||||
|
||||
impl FontProvider for Installed {
|
||||
fn font_for(&self, request: &FallbackRequest<'_>) -> Option<FontInsert> {
|
||||
let (faces, miss) = &*self.0.read().unwrap_or_else(PoisonError::into_inner);
|
||||
faces.font_for(request).or_else(|| miss.as_ref()?.font_for(request))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the face's character map has `c`.
|
||||
pub(super) fn has_char(data: &FontData, c: char) -> bool {
|
||||
skrifa::FontRef::from_index(data.bytes(), data.index).is_ok_and(|font| font.charmap().map(c).is_some())
|
||||
}
|
||||
|
||||
/// The font providers last set by [`set_font_providers`], for [`reload_font_providers`].
|
||||
#[derive(Clone)]
|
||||
struct ContextProviders(Vec<Arc<dyn FontProvider>>);
|
||||
|
||||
fn providers_id() -> egui::Id {
|
||||
egui::Id::unique("craft_ui::fonts::providers")
|
||||
}
|
||||
|
||||
/// Set `ctx`'s font providers and remember them for [`reload_font_providers`].
|
||||
pub(super) fn set_font_providers(ctx: &egui::Context, providers: Vec<Arc<dyn FontProvider>>) {
|
||||
ctx.data_mut(|d| d.insert_temp(providers_id(), ContextProviders(providers.clone())));
|
||||
ctx.set_font_providers(providers);
|
||||
}
|
||||
|
||||
/// Set `ctx`'s providers again, so egui forgets the characters they had no font for and repaints.
|
||||
#[cfg_attr(
|
||||
not(all(feature = "system_fonts", not(target_arch = "wasm32"))),
|
||||
allow(dead_code, reason = "called when the shared system font index is built, and in its tests")
|
||||
)]
|
||||
pub(super) fn reload_font_providers(ctx: &egui::Context) {
|
||||
let providers: Option<ContextProviders> = ctx.data(|d| d.get_temp(providers_id()));
|
||||
if let Some(ContextProviders(providers)) = providers {
|
||||
ctx.set_font_providers(providers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Faces a web build serves beside its wasm instead of embedding them, fetched when the UI needs
|
||||
//! them.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::FontFamily;
|
||||
use egui::epaint::text::Blob;
|
||||
|
||||
use super::{CjkRows, CraftFace, EM_SQUARE_SCRIPTS, Stack, baseline_fallback, set_weight};
|
||||
|
||||
#[cfg(any(test, target_arch = "wasm32"))]
|
||||
pub(super) mod loader;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) mod web;
|
||||
|
||||
/// The script tag served emoji faces get: ISO 15924 `Zsye`, emoji.
|
||||
pub(super) const EMOJI: &str = "Zsye";
|
||||
/// ISO 15924 `Zsym`, symbols drawn as text: arrows, keyboard keys, dingbats.
|
||||
pub(super) const SYMBOLS: &str = "Zsym";
|
||||
/// ISO 15924 `Zmth`, mathematical notation.
|
||||
pub(super) const MATH: &str = "Zmth";
|
||||
|
||||
/// A craft-fonts face served beside the app, one per line of `fonts/manifest.txt`. The tables that
|
||||
/// `craft-fonts-build`'s `Embed::serve` writes are slices of these.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ServedFace {
|
||||
/// Family name, e.g. `"Noto Sans Arabic"`.
|
||||
pub family: &'static str,
|
||||
/// `"Regular"` or `"Bold"`; faces with other styles are not used for UI text.
|
||||
pub style: &'static str,
|
||||
/// The manifest's script tags, as in [`CraftFace::scripts`].
|
||||
pub scripts: &'static [&'static str],
|
||||
/// Face index in a font collection (`.ttc`); faces of one collection share a URL.
|
||||
pub index: u32,
|
||||
/// Where the file is fetched from, relative to the page or absolute.
|
||||
pub url: &'static str,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Subresource Integrity value (`sha256-…`) the browser checks the file against; empty for
|
||||
/// none.
|
||||
pub integrity: &'static str,
|
||||
/// The characters the face maps, in `craft_fonts_build::coverage`'s encoding. A character no
|
||||
/// served face maps fetches nothing.
|
||||
pub coverage: &'static [u8],
|
||||
}
|
||||
|
||||
impl ServedFace {
|
||||
/// The egui font name, e.g. `"Noto Sans Arabic Regular"`.
|
||||
pub fn name(&self) -> String {
|
||||
format!("{} {}", self.family, self.style)
|
||||
}
|
||||
|
||||
/// Whether the face maps `c`.
|
||||
pub fn covers(&self, c: char) -> bool {
|
||||
craft_fonts_build::coverage::contains(self.coverage, c)
|
||||
}
|
||||
|
||||
/// The face as the stack orders it, without bytes.
|
||||
pub(super) fn craft_face(&self) -> CraftFace {
|
||||
CraftFace { family: self.family, style: self.style, scripts: self.scripts, bytes: &[], index: self.index }
|
||||
}
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
/// The served faces in the stack's chains (see [`super::UiFonts::served_faces`]).
|
||||
pub fn served(&self) -> &[ServedFace] {
|
||||
&self.served
|
||||
}
|
||||
|
||||
/// The served faces the UI language needs, which [`Self::install`] fetches at once on the web:
|
||||
/// for each family, the best face for each of the language's scripts that the family's own
|
||||
/// faces lack. Empty for a language the app's faces cover.
|
||||
pub fn ui_downloads(&self) -> Vec<&ServedFace> {
|
||||
self.ui_downloads.iter().filter_map(|name| self.served_face(name)).collect()
|
||||
}
|
||||
|
||||
/// The served faces that draw `c` in `family`, in the order the family asks them: those
|
||||
/// whose file maps `c` ([`ServedFace::covers`]) and that are tagged with `c`'s script (see
|
||||
/// [`script_of`]). The family's order puts the UI language's faces first, so a Han character
|
||||
/// takes the face of the UI language's region (Simplified, Traditional, Hong Kong, Japanese or
|
||||
/// Korean forms), then the other offered languages' and the rest in manifest order. Letters
|
||||
/// of Latin, Greek and Cyrillic take faces such as Noto Sans, not the CJK faces, which carry
|
||||
/// those scripts at CJK proportions. Keyboard keys, arrows and other symbols take the symbol
|
||||
/// faces (`Zsym`), mathematical notation the math faces (`Zmth`) and then the symbol faces,
|
||||
/// and emoji the emoji faces. Empty for characters of no particular script and for
|
||||
/// characters no served face maps.
|
||||
pub fn downloads_for(&self, family: &FontFamily, c: char) -> Vec<&ServedFace> {
|
||||
self.downloads(family, c.encode_utf8(&mut [0; 4]))
|
||||
}
|
||||
|
||||
/// [`Self::downloads_for`] the first character of a cluster, whose variation selector
|
||||
/// chooses between emoji and symbol faces (see [`script_of_cluster`]).
|
||||
pub(super) fn downloads(&self, family: &FontFamily, cluster: &str) -> Vec<&ServedFace> {
|
||||
let (Some(c), Some(script)) = (cluster.chars().next(), script_of_cluster(cluster)) else { return Vec::new() };
|
||||
let wanted = if script == MATH { [MATH, SYMBOLS].as_slice() } else { std::slice::from_ref(&script) };
|
||||
let alphabet = matches!(script, "Latn" | "Grek" | "Cyrl");
|
||||
let configured = self.definitions.families.get(family).or_else(|| self.definitions.families.get(&FontFamily::Proportional));
|
||||
let mut faces: Vec<&ServedFace> = Vec::new();
|
||||
for want in wanted {
|
||||
for name in configured.into_iter().flatten().chain(self.after_system.chain(family)) {
|
||||
if let Some(face) = self.served_face(name)
|
||||
&& face.craft_face().covers(want)
|
||||
&& !(alphabet && face.craft_face().covers("Hani"))
|
||||
&& face.covers(c)
|
||||
&& !faces.contains(&face)
|
||||
{
|
||||
faces.push(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
/// Add the file served at `url` to every served face it holds, for [`Self::install`] to
|
||||
/// install. A face in a family of another weight is set to that weight, and renamed as
|
||||
/// [`super::UiFonts::stack`] would have. The web build does this for each file it fetches; an
|
||||
/// app that gets the files some other way can call it itself.
|
||||
pub fn provide(&mut self, url: &str, file: &Blob) {
|
||||
let faces: Vec<ServedFace> = self.served.iter().filter(|face| face.url == url).copied().collect();
|
||||
for face in faces {
|
||||
let name = face.name();
|
||||
let data = baseline_fallback(egui::FontData::from_blob(Arc::clone(file), face.index));
|
||||
let weight = |family: &FontFamily| self.weights.iter().find(|(f, _)| f == family).map_or(400, |(_, w)| *w);
|
||||
let weighted = |family: &FontFamily| {
|
||||
let mut data = data.clone();
|
||||
let weight = weight(family);
|
||||
let name = if set_weight(&mut data, weight) && weight != 400 { format!("{name} @{weight}") } else { name.clone() };
|
||||
(name, data)
|
||||
};
|
||||
let mut configured = Vec::new();
|
||||
for (family, chain) in &mut self.definitions.families {
|
||||
for entry in chain.iter_mut().filter(|entry| **entry == name) {
|
||||
let (new_name, data) = weighted(family);
|
||||
if *family == FontFamily::Proportional && self.script_face.as_ref() == Some(entry) {
|
||||
self.script_face = Some(new_name.clone());
|
||||
}
|
||||
entry.clone_from(&new_name);
|
||||
configured.push((new_name, data));
|
||||
}
|
||||
}
|
||||
let mut after = Vec::new();
|
||||
for (family, chain) in self.after_system.chains_mut() {
|
||||
for entry in chain.iter_mut().filter(|entry| **entry == name) {
|
||||
let (new_name, data) = weighted(family);
|
||||
entry.clone_from(&new_name);
|
||||
after.push((new_name, data));
|
||||
}
|
||||
}
|
||||
let mut names: Vec<String> = configured.iter().map(|(name, _)| name.clone()).collect();
|
||||
for (name, data) in configured {
|
||||
self.definitions.font_data.insert(name, Arc::new(data));
|
||||
}
|
||||
names.extend(after.iter().map(|(name, _)| name.clone()));
|
||||
for (name, data) in after {
|
||||
self.after_system.insert(&name, data);
|
||||
}
|
||||
if self.cjk_rows == CjkRows::Fit && EM_SQUARE_SCRIPTS.iter().any(|script| face.craft_face().covers(script)) {
|
||||
self.fit_into_rows(&names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn served_face(&self, name: &str) -> Option<&ServedFace> {
|
||||
self.served.iter().find(|face| face.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the UI language's served faces are still being fetched, for an app that waits for them
|
||||
/// before drawing its interface. Always false outside the web, and once every fetch has finished,
|
||||
/// failed ones included.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn loading(ctx: &egui::Context) -> bool {
|
||||
web::loading(ctx)
|
||||
}
|
||||
|
||||
/// Whether the UI language's served faces are still being fetched: never, outside the web.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn loading(_ctx: &egui::Context) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The ISO 15924 script a served face must have to draw `c`, by Unicode block. Symbols with an
|
||||
/// emoji presentation by default (Unicode's `Emoji_Presentation`) map to [`EMOJI`], other
|
||||
/// symbols, such as ⌘ ⌥ ⇧ ⌃ ⎋ ⏎ ⌫, to [`SYMBOLS`]. `None` for characters of no block listed.
|
||||
pub(super) fn script_of(c: char) -> Option<&'static str> {
|
||||
// Sorted by start; ranges don't overlap.
|
||||
const BLOCKS: &[(u32, u32, &str)] = &[
|
||||
(0x0020, 0x036F, "Latn"),
|
||||
(0x0370, 0x03FF, "Grek"),
|
||||
(0x0400, 0x052F, "Cyrl"),
|
||||
(0x0530, 0x058F, "Armn"),
|
||||
(0x0590, 0x05FF, "Hebr"),
|
||||
(0x0600, 0x06FF, "Arab"),
|
||||
(0x0700, 0x074F, "Syrc"),
|
||||
(0x0750, 0x077F, "Arab"),
|
||||
(0x0780, 0x07BF, "Thaa"),
|
||||
(0x0860, 0x086F, "Syrc"),
|
||||
(0x0870, 0x08FF, "Arab"),
|
||||
(0x0900, 0x097F, "Deva"),
|
||||
(0x0980, 0x09FF, "Beng"),
|
||||
(0x0A00, 0x0A7F, "Guru"),
|
||||
(0x0A80, 0x0AFF, "Gujr"),
|
||||
(0x0B00, 0x0B7F, "Orya"),
|
||||
(0x0B80, 0x0BFF, "Taml"),
|
||||
(0x0C00, 0x0C7F, "Telu"),
|
||||
(0x0C80, 0x0CFF, "Knda"),
|
||||
(0x0D00, 0x0D7F, "Mlym"),
|
||||
(0x0D80, 0x0DFF, "Sinh"),
|
||||
(0x0E00, 0x0E7F, "Thai"),
|
||||
(0x0E80, 0x0EFF, "Laoo"),
|
||||
(0x0F00, 0x0FFF, "Tibt"),
|
||||
(0x1000, 0x109F, "Mymr"),
|
||||
(0x10A0, 0x10FF, "Geor"),
|
||||
(0x1100, 0x11FF, "Hang"),
|
||||
(0x1200, 0x139F, "Ethi"),
|
||||
(0x1780, 0x17FF, "Khmr"),
|
||||
(0x1800, 0x18AF, "Mong"),
|
||||
(0x19E0, 0x19FF, "Khmr"),
|
||||
(0x1C80, 0x1C8F, "Cyrl"),
|
||||
(0x1C90, 0x1CBF, "Geor"),
|
||||
(0x1D00, 0x1EFF, "Latn"),
|
||||
(0x1F00, 0x1FFF, "Grek"),
|
||||
(0x2000, 0x20CF, "Latn"),
|
||||
(0x2100, 0x214F, SYMBOLS),
|
||||
(0x2150, 0x218F, "Latn"),
|
||||
(0x2190, 0x21FF, SYMBOLS),
|
||||
(0x2200, 0x22FF, MATH),
|
||||
(0x2300, 0x27BF, SYMBOLS),
|
||||
(0x27C0, 0x27EF, MATH),
|
||||
(0x27F0, 0x297F, SYMBOLS),
|
||||
(0x2980, 0x2AFF, MATH),
|
||||
(0x2B00, 0x2BFF, SYMBOLS),
|
||||
(0x2C60, 0x2C7F, "Latn"),
|
||||
(0x2D00, 0x2D2F, "Geor"),
|
||||
(0x2D80, 0x2DDF, "Ethi"),
|
||||
(0x2DE0, 0x2DFF, "Cyrl"),
|
||||
(0x2E00, 0x2E7F, "Latn"),
|
||||
(0x2E80, 0x303F, "Hani"),
|
||||
(0x3040, 0x309F, "Hira"),
|
||||
(0x30A0, 0x30FF, "Kana"),
|
||||
(0x3100, 0x312F, "Bopo"),
|
||||
(0x3130, 0x318F, "Hang"),
|
||||
(0x31A0, 0x31BF, "Bopo"),
|
||||
(0x31C0, 0x31EF, "Hani"),
|
||||
(0x31F0, 0x31FF, "Kana"),
|
||||
(0x3200, 0x9FFF, "Hani"),
|
||||
(0xA640, 0xA69F, "Cyrl"),
|
||||
(0xA720, 0xA7FF, "Latn"),
|
||||
(0xA8E0, 0xA8FF, "Deva"),
|
||||
(0xA960, 0xA97F, "Hang"),
|
||||
(0xAB00, 0xAB2F, "Ethi"),
|
||||
(0xAB30, 0xAB6F, "Latn"),
|
||||
(0xAC00, 0xD7FF, "Hang"),
|
||||
(0xF900, 0xFAFF, "Hani"),
|
||||
(0xFB00, 0xFB06, "Latn"),
|
||||
(0xFB13, 0xFB17, "Armn"),
|
||||
(0xFB1D, 0xFB4F, "Hebr"),
|
||||
(0xFB50, 0xFDFF, "Arab"),
|
||||
(0xFE30, 0xFE4F, "Hani"),
|
||||
(0xFE70, 0xFEFF, "Arab"),
|
||||
(0xFF00, 0xFF65, "Hani"),
|
||||
(0xFF66, 0xFF9F, "Kana"),
|
||||
(0xFFA0, 0xFFDC, "Hang"),
|
||||
(0x1D400, 0x1D7FF, MATH),
|
||||
(0x1F000, 0x1FAFF, EMOJI),
|
||||
(0x1FB00, 0x1FBFF, SYMBOLS),
|
||||
(0x20000, 0x3FFFF, "Hani"),
|
||||
];
|
||||
// The symbols among the BLOCKS' symbols that are emoji by default.
|
||||
const EMOJI_PRESENTATION: &[(u32, u32)] = &[
|
||||
(0x231A, 0x231B),
|
||||
(0x23E9, 0x23EC),
|
||||
(0x23F0, 0x23F0),
|
||||
(0x23F3, 0x23F3),
|
||||
(0x25FD, 0x25FE),
|
||||
(0x2614, 0x2615),
|
||||
(0x2648, 0x2653),
|
||||
(0x267F, 0x267F),
|
||||
(0x2693, 0x2693),
|
||||
(0x26A1, 0x26A1),
|
||||
(0x26AA, 0x26AB),
|
||||
(0x26BD, 0x26BE),
|
||||
(0x26C4, 0x26C5),
|
||||
(0x26CE, 0x26CE),
|
||||
(0x26D4, 0x26D4),
|
||||
(0x26EA, 0x26EA),
|
||||
(0x26F2, 0x26F3),
|
||||
(0x26F5, 0x26F5),
|
||||
(0x26FA, 0x26FA),
|
||||
(0x26FD, 0x26FD),
|
||||
(0x2705, 0x2705),
|
||||
(0x270A, 0x270B),
|
||||
(0x2728, 0x2728),
|
||||
(0x274C, 0x274C),
|
||||
(0x274E, 0x274E),
|
||||
(0x2753, 0x2755),
|
||||
(0x2757, 0x2757),
|
||||
(0x2795, 0x2797),
|
||||
(0x27B0, 0x27B0),
|
||||
(0x27BF, 0x27BF),
|
||||
(0x2B1B, 0x2B1C),
|
||||
(0x2B50, 0x2B50),
|
||||
(0x2B55, 0x2B55),
|
||||
];
|
||||
// The emoji BLOCKS' characters that are symbols, drawn as text by default: mahjong, domino
|
||||
// and playing card faces, enclosed letters, ornaments, geometric shapes, arrows, chess.
|
||||
const TEXT_PRESENTATION: &[(u32, u32)] = &[
|
||||
(0x1F000, 0x1F003),
|
||||
(0x1F005, 0x1F0CE),
|
||||
(0x1F0D0, 0x1F18D),
|
||||
(0x1F18F, 0x1F190),
|
||||
(0x1F19B, 0x1F1E5),
|
||||
(0x1F650, 0x1F67F),
|
||||
(0x1F780, 0x1F7DF),
|
||||
(0x1F800, 0x1F8FF),
|
||||
(0x1FA00, 0x1FA6F),
|
||||
];
|
||||
let c = u32::from(c);
|
||||
let script = find(BLOCKS, c, |(start, end, _)| (*start, *end)).map(|(.., script)| *script)?;
|
||||
Some(match script {
|
||||
SYMBOLS if find(EMOJI_PRESENTATION, c, |r| *r).is_some() => EMOJI,
|
||||
EMOJI if find(TEXT_PRESENTATION, c, |r| *r).is_some() => SYMBOLS,
|
||||
script => script,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`script_of`] the cluster's first character, with an emoji or text variation selector
|
||||
/// (U+FE0F, U+FE0E) choosing between [`EMOJI`] and [`SYMBOLS`].
|
||||
pub(super) fn script_of_cluster(cluster: &str) -> Option<&'static str> {
|
||||
let script = script_of(cluster.chars().next()?)?;
|
||||
Some(if cluster.contains('\u{FE0F}') {
|
||||
EMOJI
|
||||
} else if script == EMOJI && cluster.contains('\u{FE0E}') {
|
||||
SYMBOLS
|
||||
} else {
|
||||
script
|
||||
})
|
||||
}
|
||||
|
||||
/// The entry of `ranges` (sorted by start, not overlapping) that holds `c`.
|
||||
fn find<T>(ranges: &[T], c: u32, range: impl Fn(&T) -> (u32, u32)) -> Option<&T> {
|
||||
let i = ranges.partition_point(|r| range(r).0 <= c);
|
||||
let entry = ranges.get(i.checked_sub(1)?)?;
|
||||
(c <= range(entry).1).then_some(entry)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Fetching served faces: the UI language's at install, others when egui asks the context's
|
||||
//! providers for a character no installed face has. The fetching itself is a [`Fetcher`]: the
|
||||
//! browser's on the web, a fake one in tests.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
|
||||
|
||||
use egui::epaint::text::{Blob, MissingGlyphPolicy};
|
||||
use egui::{FallbackRequest, FontInsert, FontProvider};
|
||||
|
||||
use crate::fonts::Stack;
|
||||
|
||||
/// Seconds before a failed file is fetched again; each further failure doubles it, up to
|
||||
/// [`RETRY_MAX`].
|
||||
const RETRY_FIRST: f64 = 2.0;
|
||||
const RETRY_MAX: f64 = 600.0;
|
||||
|
||||
/// Called with a fetched file's bytes, or why it failed.
|
||||
pub(in crate::fonts) type Done = Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>;
|
||||
/// Called when a timer fires.
|
||||
pub(in crate::fonts) type Wake = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// Fetches files and keeps time for a [`Loader`].
|
||||
pub(in crate::fonts) trait Fetcher: Send + Sync {
|
||||
/// Start fetching `url`, checked against `integrity` when not empty, and call `done` with its
|
||||
/// bytes or why it failed.
|
||||
fn fetch(&self, url: &'static str, integrity: &'static str, done: Done);
|
||||
/// Seconds since some fixed time.
|
||||
fn now(&self) -> f64;
|
||||
/// Call `wake` once, `seconds` from now.
|
||||
fn after(&self, seconds: f64, wake: Wake);
|
||||
}
|
||||
|
||||
/// Install `stack`, add the files fetched so far, and fetch the UI language's faces.
|
||||
pub(in crate::fonts) fn install(ctx: &egui::Context, mut stack: Stack, fetcher: Arc<dyn Fetcher>) {
|
||||
let loader = Loader::of(ctx, fetcher);
|
||||
let fetch = {
|
||||
let mut state = loader.lock();
|
||||
let now = loader.fetcher.now();
|
||||
for (url, file) in &state.files {
|
||||
stack.provide(url, file);
|
||||
}
|
||||
state.ui_pending.clear();
|
||||
let mut fetch: Vec<(&'static str, &'static str)> = Vec::new();
|
||||
for face in stack.ui_downloads() {
|
||||
if state.files.contains_key(face.url) || !state.due(face.url, now) {
|
||||
continue;
|
||||
}
|
||||
state.ui_pending.insert(face.url);
|
||||
if !fetch.iter().any(|(url, _)| *url == face.url) {
|
||||
fetch.push((face.url, face.integrity));
|
||||
}
|
||||
}
|
||||
state.stack = Some(stack.clone());
|
||||
fetch
|
||||
};
|
||||
stack.install_faces(ctx, Some(Arc::clone(&loader) as Arc<dyn FontProvider>));
|
||||
for (url, integrity) in fetch {
|
||||
loader.fetch(url, integrity);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::fonts) fn loading(ctx: &egui::Context) -> bool {
|
||||
let loader: Option<Arc<Loader>> = ctx.data(|d| d.get_temp(id()));
|
||||
loader.is_some_and(|loader| !loader.lock().ui_pending.is_empty())
|
||||
}
|
||||
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::unique("craft_ui::fonts::served")
|
||||
}
|
||||
|
||||
/// Make egui ask the providers again for the characters it found no face for. egui remembers those
|
||||
/// misses until its fonts reload and has no call that only forgets them; changing the missing
|
||||
/// glyph policy reloads the fonts, and setting it back keeps the app's policy.
|
||||
fn forget_misses(ctx: &egui::Context) {
|
||||
let policy = ctx.missing_glyph_policy();
|
||||
let other = match policy {
|
||||
MissingGlyphPolicy::Tofu => MissingGlyphPolicy::Panic,
|
||||
MissingGlyphPolicy::Panic => MissingGlyphPolicy::Tofu,
|
||||
};
|
||||
ctx.set_missing_glyph_policy(other);
|
||||
ctx.set_missing_glyph_policy(policy);
|
||||
}
|
||||
|
||||
/// The context's fetched files and the stack they go into. Also the provider asked for characters
|
||||
/// no face has, which starts fetching the face that draws them.
|
||||
struct Loader {
|
||||
ctx: egui::Context,
|
||||
me: Weak<Loader>,
|
||||
fetcher: Arc<dyn Fetcher>,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// The installed stack, without the files' data.
|
||||
stack: Option<Stack>,
|
||||
/// Fetched files by URL; the browser's HTTP cache keeps them across page loads.
|
||||
files: BTreeMap<&'static str, Blob>,
|
||||
in_flight: BTreeSet<&'static str>,
|
||||
failed: BTreeMap<&'static str, Failure>,
|
||||
/// The UI language's files not fetched yet.
|
||||
ui_pending: BTreeSet<&'static str>,
|
||||
}
|
||||
|
||||
struct Failure {
|
||||
attempts: u32,
|
||||
retry_at: f64,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Whether `url` may be fetched: it never failed, or its retry time has come.
|
||||
fn due(&self, url: &str, now: f64) -> bool {
|
||||
self.failed.get(url).is_none_or(|failure| now >= failure.retry_at)
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
fn of(ctx: &egui::Context, fetcher: Arc<dyn Fetcher>) -> Arc<Self> {
|
||||
let existing: Option<Arc<Self>> = ctx.data(|d| d.get_temp(id()));
|
||||
existing.unwrap_or_else(|| {
|
||||
let loader = Arc::new_cyclic(|me| Self { ctx: ctx.clone(), me: me.clone(), fetcher, state: Mutex::default() });
|
||||
ctx.data_mut(|d| d.insert_temp(id(), Arc::clone(&loader)));
|
||||
loader
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, State> {
|
||||
self.state.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn fetch(&self, url: &'static str, integrity: &'static str) {
|
||||
if !self.lock().in_flight.insert(url) {
|
||||
return;
|
||||
}
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
self.fetcher.fetch(url, integrity, Box::new(move |result| me.arrived(url, result)));
|
||||
}
|
||||
|
||||
fn arrived(&self, url: &'static str, result: Result<Vec<u8>, String>) {
|
||||
let (stack, retry) = {
|
||||
let mut state = self.lock();
|
||||
state.in_flight.remove(url);
|
||||
state.ui_pending.remove(url);
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
state.failed.remove(url);
|
||||
let file: Blob = Arc::new(bytes);
|
||||
state.files.insert(url, Arc::clone(&file));
|
||||
let stack = state.stack.as_mut().map(|stack| {
|
||||
stack.provide(url, &file);
|
||||
stack.clone()
|
||||
});
|
||||
(stack, None)
|
||||
}
|
||||
Err(_) => {
|
||||
let attempts = state.failed.get(url).map_or(1, |failure| failure.attempts + 1);
|
||||
let delay = (RETRY_FIRST * 2f64.powi(attempts.saturating_sub(1).min(16) as i32)).min(RETRY_MAX);
|
||||
state.failed.insert(url, Failure { attempts, retry_at: self.fetcher.now() + delay });
|
||||
(None, Some(delay))
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(stack) = stack
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
stack.install_faces(&self.ctx, Some(me as Arc<dyn FontProvider>));
|
||||
}
|
||||
// The file that arrived draws characters egui found no face for; after a failure, they go
|
||||
// to the next face that draws them now and to this one again at its retry time.
|
||||
forget_misses(&self.ctx);
|
||||
if let Some(delay) = retry {
|
||||
let me = self.me.clone();
|
||||
self.fetcher.after(
|
||||
delay,
|
||||
Box::new(move || {
|
||||
if let Some(me) = me.upgrade() {
|
||||
forget_misses(&me.ctx);
|
||||
me.ctx.request_repaint();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
impl FontProvider for Loader {
|
||||
/// Start fetching the face that draws the character and find nothing for now: the face is
|
||||
/// installed when it arrives, and egui asks again. Only faces whose files map the character
|
||||
/// are candidates, the first one the family asks; a failed one leaves the character to the
|
||||
/// next until its retry time. A character no served face maps fetches nothing.
|
||||
fn font_for(&self, request: &FallbackRequest<'_>) -> Option<FontInsert> {
|
||||
let now = self.fetcher.now();
|
||||
let state = self.lock();
|
||||
let mut next = None;
|
||||
for face in state.stack.as_ref()?.downloads(request.family, request.cluster) {
|
||||
if state.in_flight.contains(face.url) || state.files.contains_key(face.url) {
|
||||
// It draws the character once it arrives, or already does.
|
||||
return None;
|
||||
}
|
||||
if state.due(face.url, now) {
|
||||
next = Some((face.url, face.integrity));
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
let (url, integrity) = next?;
|
||||
self.fetch(url, integrity);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The browser's [`Fetcher`]: `fetch` with Subresource Integrity, and `setTimeout`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::loader::{self, Done, Fetcher, Wake};
|
||||
use crate::fonts::Stack;
|
||||
|
||||
/// Install `stack`, add the files fetched so far, and fetch the UI language's faces.
|
||||
pub(in crate::fonts) fn install(ctx: &egui::Context, stack: Stack) {
|
||||
loader::install(ctx, stack, Arc::new(Browser));
|
||||
}
|
||||
|
||||
pub(in crate::fonts) fn loading(ctx: &egui::Context) -> bool {
|
||||
loader::loading(ctx)
|
||||
}
|
||||
|
||||
struct Browser;
|
||||
|
||||
impl Fetcher for Browser {
|
||||
fn fetch(&self, url: &'static str, integrity: &'static str, done: Done) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = fetch_file(url, integrity).await;
|
||||
if let Err(error) = &result {
|
||||
web_sys::console::warn_1(&format!("craft-ui: cannot fetch the font {url}: {error}").into());
|
||||
}
|
||||
done(result);
|
||||
});
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
js_sys::Date::now() / 1000.0
|
||||
}
|
||||
|
||||
fn after(&self, seconds: f64, wake: Wake) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let callback = Closure::once_into_js(wake);
|
||||
let millis = (seconds * 1000.0) as i32;
|
||||
// Without the timer, the face is retried when the next fetch finishes.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), millis);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_file(url: &str, integrity: &str) -> Result<Vec<u8>, String> {
|
||||
// An Error's message only: its Debug form carries the whole JS stack. The browser logs the
|
||||
// cause (network, integrity) itself.
|
||||
let js = |e: JsValue| match e.dyn_ref::<js_sys::Error>() {
|
||||
Some(error) => String::from(error.message()),
|
||||
None => e.as_string().unwrap_or_else(|| format!("{e:?}")),
|
||||
};
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
if !integrity.is_empty() {
|
||||
init.set_integrity(integrity);
|
||||
}
|
||||
let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?;
|
||||
if !response.ok() {
|
||||
return Err(format!("HTTP {}", response.status()));
|
||||
}
|
||||
let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;
|
||||
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! A font index built once on a background thread and shared by every locale's provider.
|
||||
//!
|
||||
//! Lookups that may not block (the UI thread's) find nothing until the index is built: egui
|
||||
//! draws with the faces it has, and every context that was turned away reloads its fonts when the
|
||||
//! index arrives, which forgets egui's cached misses and repaints. If the build panics, every
|
||||
//! lookup finds nothing from then on and egui keeps the faces it has.
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use egui::{FallbackRequest, FontInsert, FontProvider};
|
||||
|
||||
/// A font index a [`Lookup`] asks.
|
||||
pub(super) trait FontIndex: Send + 'static {
|
||||
/// The provider's preference between fonts for one script, e.g. a locale.
|
||||
type Locale: Send + Sync + 'static;
|
||||
|
||||
fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Self::Locale) -> Option<FontInsert>;
|
||||
}
|
||||
|
||||
enum State<T> {
|
||||
Idle,
|
||||
/// `turned_away`: a lookup found nothing because the index wasn't built, so `contexts` reload
|
||||
/// their fonts when it is.
|
||||
Building {
|
||||
turned_away: bool,
|
||||
contexts: Vec<egui::Context>,
|
||||
},
|
||||
Ready(Arc<Mutex<T>>),
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// An index built at most once per process, on first use.
|
||||
pub(super) struct Shared<T> {
|
||||
state: Mutex<State<T>>,
|
||||
built: Condvar,
|
||||
build: fn() -> T,
|
||||
}
|
||||
|
||||
impl<T: FontIndex> Shared<T> {
|
||||
pub(super) const fn new(build: fn() -> T) -> Self {
|
||||
Self { state: Mutex::new(State::Idle), built: Condvar::new(), build }
|
||||
}
|
||||
|
||||
/// Start building the index on a background thread unless it is built or building. `ctx`
|
||||
/// reloads its fonts when the index arrives if a lookup was turned away meanwhile.
|
||||
pub(super) fn start(&'static self, ctx: Option<&egui::Context>) {
|
||||
let idle = {
|
||||
let mut state = self.lock();
|
||||
let idle = matches!(*state, State::Idle);
|
||||
if idle {
|
||||
*state = State::Building { turned_away: false, contexts: Vec::new() };
|
||||
}
|
||||
if let (State::Building { contexts, .. }, Some(ctx)) = (&mut *state, ctx) {
|
||||
contexts.push(ctx.clone());
|
||||
}
|
||||
idle
|
||||
};
|
||||
if idle && std::thread::Builder::new().name("craft-ui system fonts".into()).spawn(|| self.finish()).is_err() {
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// The index, or `None` while it builds unless `wait`, which blocks until it is built, or
|
||||
/// `None` if its build failed.
|
||||
fn get(&'static self, wait: bool) -> Option<Arc<Mutex<T>>> {
|
||||
self.start(None);
|
||||
let mut state = self.lock();
|
||||
loop {
|
||||
match &mut *state {
|
||||
State::Ready(index) => return Some(Arc::clone(index)),
|
||||
State::Failed => return None,
|
||||
State::Building { turned_away, .. } if !wait => {
|
||||
*turned_away = true;
|
||||
return None;
|
||||
}
|
||||
_ => state = self.built.wait(state).unwrap_or_else(PoisonError::into_inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self) {
|
||||
let built = std::panic::catch_unwind(self.build).map_err(|payload| {
|
||||
let message = payload
|
||||
.downcast_ref::<String>()
|
||||
.map(String::as_str)
|
||||
.or_else(|| payload.downcast_ref::<&str>().copied())
|
||||
.unwrap_or("non-string panic payload");
|
||||
eprintln!("craft-ui: building the system font index failed, so it adds no fonts: {message}");
|
||||
});
|
||||
let next = built.map_or(State::Failed, |index| State::Ready(Arc::new(Mutex::new(index))));
|
||||
let previous = std::mem::replace(&mut *self.lock(), next);
|
||||
self.built.notify_all();
|
||||
// Outside the lock: the UI thread holds the context's fonts while it asks for the index.
|
||||
if let State::Building { turned_away: true, contexts } = previous {
|
||||
for ctx in contexts {
|
||||
super::providers::reload_font_providers(&ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, State<T>> {
|
||||
self.state.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// One locale's provider over a [`Shared`] index.
|
||||
pub(super) struct Lookup<T: FontIndex> {
|
||||
index: &'static Shared<T>,
|
||||
locale: T::Locale,
|
||||
/// Block until the index is built, for checks; the UI thread doesn't.
|
||||
wait: bool,
|
||||
}
|
||||
|
||||
impl<T: FontIndex> Lookup<T> {
|
||||
pub(super) fn new(index: &'static Shared<T>, locale: T::Locale, wait: bool) -> Self {
|
||||
Self { index, locale, wait }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: FontIndex> FontProvider for Lookup<T> {
|
||||
fn font_for(&self, request: &FallbackRequest<'_>) -> Option<FontInsert> {
|
||||
let index = self.index.get(self.wait)?;
|
||||
let mut index = index.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
index.font_for(request, &self.locale)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::{FontData, FontFamily, FontId, FontPriority, InsertFontFamily};
|
||||
|
||||
use super::*;
|
||||
|
||||
const ARABIC: &[u8] = include_bytes!("../../tests/fonts/NotoSansArabic-test.ttf");
|
||||
|
||||
struct Gated;
|
||||
|
||||
static OPEN: AtomicBool = AtomicBool::new(false);
|
||||
static BUILDS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn build_when_open() -> Gated {
|
||||
while !OPEN.load(Ordering::Acquire) {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
BUILDS.fetch_add(1, Ordering::Relaxed);
|
||||
Gated
|
||||
}
|
||||
|
||||
impl FontIndex for Gated {
|
||||
type Locale = ();
|
||||
|
||||
fn font_for(&mut self, request: &FallbackRequest<'_>, _: &()) -> Option<FontInsert> {
|
||||
Some(FontInsert::new(
|
||||
"index:Arabic",
|
||||
FontData::from_static(ARABIC),
|
||||
vec![InsertFontFamily { family: request.family.clone(), priority: FontPriority::Lowest }],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
static GATED: Shared<Gated> = Shared::new(build_when_open);
|
||||
|
||||
#[test]
|
||||
fn ui_lookups_skip_the_index_until_it_is_built_then_the_context_reloads() {
|
||||
let ctx = egui::Context::default();
|
||||
let font_id = FontId::new(14.0, FontFamily::Proportional);
|
||||
let ui: Arc<dyn FontProvider> = Arc::new(Lookup::new(&GATED, (), false));
|
||||
super::super::providers::set_font_providers(&ctx, vec![ui]);
|
||||
GATED.start(Some(&ctx));
|
||||
let draws_arabic = || {
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
ctx.fonts_mut(|f| f.has_glyph(&font_id, 'ع'))
|
||||
};
|
||||
|
||||
assert!(!draws_arabic(), "a lookup before the index is built finds nothing, without blocking");
|
||||
let other_locale = Lookup::new(&GATED, (), false);
|
||||
let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };
|
||||
assert!(other_locale.font_for(&request).is_none());
|
||||
|
||||
OPEN.store(true, Ordering::Release);
|
||||
let checks = Lookup::new(&GATED, (), true);
|
||||
assert!(checks.font_for(&request).is_some(), "a waiting lookup blocks until the index is built");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !draws_arabic() {
|
||||
assert!(Instant::now() < deadline, "the context reloads its fonts once the index is built");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(other_locale.font_for(&request).is_some());
|
||||
assert_eq!(BUILDS.load(Ordering::Relaxed), 1, "every locale shares one index");
|
||||
}
|
||||
|
||||
fn build_panics() -> Gated {
|
||||
panic!("stand-in index build failure");
|
||||
}
|
||||
|
||||
static PANICS: Shared<Gated> = Shared::new(build_panics);
|
||||
|
||||
#[test]
|
||||
fn a_panicking_build_leaves_lookups_finding_nothing() {
|
||||
let (sent, received) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };
|
||||
let found = Lookup::new(&PANICS, (), true).font_for(&request).is_some();
|
||||
let _ = sent.send(found);
|
||||
});
|
||||
let found = received.recv_timeout(Duration::from_secs(10));
|
||||
assert_eq!(found, Ok(false), "a waiting lookup returns once the build has failed");
|
||||
let request = FallbackRequest { cluster: "ع", family: &FontFamily::Proportional };
|
||||
assert!(Lookup::new(&PANICS, (), false).font_for(&request).is_none());
|
||||
assert!(Lookup::new(&PANICS, (), true).font_for(&request).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The installed fonts, indexed once with fontique for every locale. The lookup is
|
||||
//! egui_system_font_provider 0.37's, whose provider builds its own index per locale.
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use egui::{FallbackRequest, FontData, FontFamily, FontInsert, FontPriority, FontProvider, InsertFontFamily};
|
||||
use fontique::{
|
||||
Collection, CollectionOptions, FallbackKey, GenericFamily, Language, QueryFamily, QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions,
|
||||
};
|
||||
|
||||
use super::system::{FontIndex, Lookup, Shared};
|
||||
|
||||
pub(super) struct Index {
|
||||
collection: Collection,
|
||||
source_cache: SourceCache,
|
||||
}
|
||||
|
||||
static INDEX: Shared<Index> = Shared::new(build);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) static BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
fn build() -> Index {
|
||||
#[cfg(test)]
|
||||
BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Index {
|
||||
collection: Collection::new(CollectionOptions { shared: false, system_fonts: true }),
|
||||
source_cache: SourceCache::new(SourceCacheOptions::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider over the shared index that prefers `locale`'s fonts. With `wait`, a lookup before the
|
||||
/// index is built blocks until it is; without, it finds nothing (see [`start`]).
|
||||
pub(super) fn provider(locale: &str, wait: bool) -> Arc<dyn FontProvider> {
|
||||
Arc::new(Lookup::new(&INDEX, locale_language(locale), wait))
|
||||
}
|
||||
|
||||
/// The language a locale prefers fonts for: a language tag (`zh-Hant`) or a POSIX locale
|
||||
/// (`zh_TW.UTF-8`). `C`, `POSIX` and anything else without a language prefer none.
|
||||
pub(super) fn locale_language(locale: &str) -> Option<Language> {
|
||||
let tag = locale.split(['.', '@']).next().unwrap_or_default();
|
||||
if matches!(tag, "C" | "POSIX") {
|
||||
return None;
|
||||
}
|
||||
Language::parse(tag).ok()
|
||||
}
|
||||
|
||||
/// Start building the index in the background. `ctx` reloads its fonts once it is built if a
|
||||
/// lookup found nothing meanwhile.
|
||||
pub(super) fn start(ctx: &egui::Context) {
|
||||
INDEX.start(Some(ctx));
|
||||
}
|
||||
|
||||
impl FontIndex for Index {
|
||||
type Locale = Option<Language>;
|
||||
|
||||
fn font_for(&mut self, request: &FallbackRequest<'_>, locale: &Option<Language>) -> Option<FontInsert> {
|
||||
let FallbackRequest { cluster, family } = request;
|
||||
let base_char = cluster.chars().next()?;
|
||||
let Self { collection, source_cache } = self;
|
||||
let generic = match family {
|
||||
FontFamily::Monospace => GenericFamily::Monospace,
|
||||
FontFamily::Proportional | FontFamily::Name(_) => GenericFamily::SansSerif,
|
||||
};
|
||||
|
||||
let font = if let Some(script) = script_of(base_char) {
|
||||
// A script's fallback can be one locale's font, e.g. a Japanese font for Han that
|
||||
// lacks many Simplified Chinese characters, so the script's other locales follow.
|
||||
std::iter::once(locale.as_ref()).chain(other_locales(script).iter().map(Some)).find_map(|locale| {
|
||||
let mut query = collection.query(source_cache);
|
||||
query.set_families([QueryFamily::Generic(generic)]);
|
||||
query.set_fallbacks(fallback_key(script, locale));
|
||||
find_font(&mut query, base_char)
|
||||
})
|
||||
} else {
|
||||
// Punctuation, symbols and emoji belong to no script and have no fallback.
|
||||
let mut query = collection.query(source_cache);
|
||||
query.set_families(
|
||||
[generic, GenericFamily::SystemUi, GenericFamily::Emoji, GenericFamily::Math, GenericFamily::Serif, GenericFamily::Monospace]
|
||||
.map(QueryFamily::Generic),
|
||||
);
|
||||
find_font(&mut query, base_char)
|
||||
}?;
|
||||
|
||||
let family_name = collection.family_name(font.family.0).unwrap_or("unknown");
|
||||
let name = format!("system:{family_name}:{}:{}", font.family.1, font.index);
|
||||
let (blob, _) = font.blob.into_raw_parts();
|
||||
Some(FontInsert {
|
||||
name,
|
||||
data: FontData::from_blob(blob, font.index),
|
||||
families: vec![InsertFontFamily { family: (*family).clone(), priority: FontPriority::Lowest }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The first font in the query that can draw `c`.
|
||||
fn find_font(query: &mut fontique::Query<'_>, c: char) -> Option<QueryFont> {
|
||||
let mut found = None;
|
||||
query.matches_with(|font| {
|
||||
if can_render(font, c) {
|
||||
found = Some(font.clone());
|
||||
QueryStatus::Stop
|
||||
} else {
|
||||
QueryStatus::Continue
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
const HAN: Script = Script::from_bytes(*b"Hani");
|
||||
|
||||
/// The locales of the Han fonts, Simplified Chinese first. That is `zh-SG` rather than `zh-CN`:
|
||||
/// fontique's default Han locale is `zh-CN`, whose font is whatever the first locale-less Han
|
||||
/// lookup returned, often the Japanese one.
|
||||
static HAN_LOCALES: LazyLock<Vec<Language>> =
|
||||
LazyLock::new(|| ["zh-SG", "zh-TW", "ja", "ko"].iter().filter_map(|locale| Language::parse(locale).ok()).collect());
|
||||
|
||||
fn fallback_key(script: Script, locale: Option<&Language>) -> FallbackKey {
|
||||
let locale = match locale {
|
||||
Some(locale) if script == HAN && is_simplified_chinese(locale) => HAN_LOCALES.first(),
|
||||
locale => locale,
|
||||
};
|
||||
FallbackKey::new(script, locale)
|
||||
}
|
||||
|
||||
/// `zh`, `zh-CN`, `zh-Hans` and the like, as fontique tells Simplified from Traditional Chinese.
|
||||
fn is_simplified_chinese(locale: &Language) -> bool {
|
||||
locale.language() == "zh" && locale.script() != Some("Hant") && !matches!(locale.region(), Some("HK" | "TW" | "MO"))
|
||||
}
|
||||
|
||||
/// Only Han needs other locales: its fonts are per locale, each with different characters.
|
||||
fn other_locales(script: Script) -> &'static [Language] {
|
||||
if script == HAN { &HAN_LOCALES } else { &[] }
|
||||
}
|
||||
|
||||
/// The script of a character, `None` for characters shared between scripts.
|
||||
fn script_of(c: char) -> Option<Script> {
|
||||
use unicode_script::UnicodeScript as _;
|
||||
match c.script() {
|
||||
unicode_script::Script::Common | unicode_script::Script::Inherited | unicode_script::Script::Unknown => None,
|
||||
script => Script::parse(script.short_name()).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the font has an outline, bitmap or colour glyph for `c`.
|
||||
fn can_render(font: &QueryFont, c: char) -> bool {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let Some(glyph_id) = font.charmap().and_then(|charmap| charmap.map(c)) else { return false };
|
||||
let glyph_id = skrifa::GlyphId::new(glyph_id);
|
||||
if glyph_id == skrifa::GlyphId::NOTDEF {
|
||||
return false;
|
||||
}
|
||||
let Ok(font_ref) = skrifa::FontRef::from_index(font.blob.as_ref(), font.index) else { return false };
|
||||
font_ref.outline_glyphs().get(glyph_id).is_some()
|
||||
|| font_ref.color_glyphs().get(glyph_id).is_some()
|
||||
|| font_ref.bitmap_strikes().glyph_for_size(skrifa::instance::Size::unscaled(), glyph_id).is_some()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,591 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use egui::{
|
||||
Context, Event, IMEPurpose, Id, ImeEvent, Key, Modifiers, Rect,
|
||||
ime_context::{ImeContextTracker, ImeObservation, ImeTarget},
|
||||
output::IMEOutput,
|
||||
};
|
||||
|
||||
/// Seconds after a composition ends in which the IME's own confirm, cancel and edit keys
|
||||
/// (Enter, Escape, Backspace, Delete, Space) are still taken as the IME's, and after a bare
|
||||
/// commit in which the key that typed it is. Platforms that forward the key do so in the same
|
||||
/// batch of events; a person's next Enter comes much later.
|
||||
const GUARD_SECS: f64 = 0.05;
|
||||
|
||||
/// Seconds after the composer kept marked text itself (a click away, the canvas giving up the
|
||||
/// keyboard) in which the IME's own commit of that same text is a duplicate.
|
||||
const KEPT_SECS: f64 = 0.5;
|
||||
|
||||
/// One step for the canvas's text, in the order the platform sent it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Edit {
|
||||
/// The marked text changed. Empty text ends the composition; an [`Edit::Insert`] of the
|
||||
/// committed text, if any, follows. `active` is the segment being converted, in chars.
|
||||
Preedit {
|
||||
text: String,
|
||||
active: Option<Range<usize>>,
|
||||
},
|
||||
/// Text to insert at the caret: typed, committed by the IME, or marked text kept as typed.
|
||||
Insert(String),
|
||||
/// A key press for the editor (caret movement, Backspace, Enter, Escape, shortcuts). Keys the
|
||||
/// IME is using never appear here. The key's event also stays in the input for the app's
|
||||
/// other keyboard handling: an app handles each key from one of the two.
|
||||
Key {
|
||||
key: Key,
|
||||
modifiers: Modifiers,
|
||||
repeat: bool,
|
||||
},
|
||||
/// A line break sent as text or committed with nothing composing (an on-screen keyboard's
|
||||
/// Return, an IME's bare commit of `"\n"`). No Enter key press comes with it: the editor
|
||||
/// handles it as Enter. The line break a pressed Enter also types is dropped, so each Enter
|
||||
/// arrives once, as [`Edit::Key`] or as this.
|
||||
LineBreak {
|
||||
modifiers: Modifiers,
|
||||
},
|
||||
/// The clipboard events, in order with the text, held back while the IME composes. The app
|
||||
/// applies them; the event also stays in the input, so it handles each from one of the two.
|
||||
Paste(String),
|
||||
Copy,
|
||||
Cut,
|
||||
/// Delete chars around the caret (the web text agent's corrections, mobile Backspace). The app
|
||||
/// applies it; its event is removed from the input. Never sent while composing.
|
||||
DeleteSurrounding {
|
||||
before_chars: usize,
|
||||
after_chars: usize,
|
||||
},
|
||||
/// Delete the text an input method replaces (macOS accent menus and dictation, Wayland input
|
||||
/// methods), counted in UTF-8 bytes before and after the caret or selection, right before the
|
||||
/// commit that replaces it. While composing, the counts start at the caret too: the marked
|
||||
/// text isn't in the app's text. Delete only whole chars inside the counts;
|
||||
/// [`surrounding_chars`] counts them. The app applies it; its event is removed from the
|
||||
/// input. Only the suite's egui fork sends it.
|
||||
DeleteSurroundingBytes {
|
||||
before_bytes: usize,
|
||||
after_bytes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// For [`Edit::DeleteSurroundingBytes`]: how many chars of `before` (the text before the caret or
|
||||
/// selection) end inside its last `before_bytes` bytes, and how many of `after` (the text after
|
||||
/// it) lie inside its first `after_bytes` bytes. A char cut by a count stays, as in egui's
|
||||
/// `TextEdit`.
|
||||
pub fn surrounding_chars(before: &str, after: &str, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
|
||||
let from = before.len().saturating_sub(before_bytes);
|
||||
let before_chars = before.char_indices().rev().take_while(|(i, _)| *i >= from).count();
|
||||
let after_chars = after.char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count();
|
||||
(before_chars, after_chars)
|
||||
}
|
||||
|
||||
/// The marked text, for painting at the caret with an underline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Preedit<'a> {
|
||||
pub text: &'a str,
|
||||
/// The segment being converted (Japanese, Chinese phrase input), in chars. Draw it with a
|
||||
/// thicker underline. `None` when the IME doesn't say.
|
||||
pub active: Option<&'a Range<usize>>,
|
||||
}
|
||||
|
||||
/// What [`Composer::process`] made of a pass's events.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Processed {
|
||||
pub edits: Vec<Edit>,
|
||||
/// The input events with those the IME used removed, for the app's other keyboard handling.
|
||||
pub events: Vec<Event>,
|
||||
}
|
||||
|
||||
/// One composer for text typed onto a canvas, outside egui's `TextEdit`.
|
||||
///
|
||||
/// It turns a pass's input events into ordered [`Edit`]s, keeps the marked (preedit) text, and
|
||||
/// tells the platform where the caret is so the candidate window opens there.
|
||||
///
|
||||
/// A clone is a new IME context: it gets its own context generation when it next writes output.
|
||||
#[derive(Debug)]
|
||||
pub struct Composer {
|
||||
preedit: String,
|
||||
active: Option<Range<usize>>,
|
||||
/// The keys the platform may still forward, and since when, for [`GUARD_SECS`].
|
||||
guard: Option<(Guard, f64)>,
|
||||
/// Marked text the composer kept itself, and when, for [`KEPT_SECS`].
|
||||
kept: Option<(String, f64)>,
|
||||
/// The commit whose echo may still come in this pass, for [`Self::is_echo`].
|
||||
last_commit: Option<String>,
|
||||
modifiers: Modifiers,
|
||||
caret: Option<Rect>,
|
||||
purpose: IMEPurpose,
|
||||
interrupt: bool,
|
||||
/// The composer ended a composition the IME may still hold, for [`Self::interrupted`].
|
||||
interrupted: bool,
|
||||
/// The pass [`Self::interrupted`] is for, and the pass of the composer's last IME output.
|
||||
pass: Option<u64>,
|
||||
output_pass: Option<u64>,
|
||||
on: bool,
|
||||
/// The IME context's owner, as egui's `IMEOutput::owner`.
|
||||
owner: Id,
|
||||
/// The context generation egui's `IMEOutput` carries: new whenever the composer turns the
|
||||
/// IME on again, changes purpose, or skipped a pass.
|
||||
context: ImeContextTracker,
|
||||
}
|
||||
|
||||
impl Default for Composer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preedit: String::new(),
|
||||
active: None,
|
||||
guard: None,
|
||||
kept: None,
|
||||
last_commit: None,
|
||||
modifiers: Modifiers::default(),
|
||||
caret: None,
|
||||
purpose: IMEPurpose::default(),
|
||||
interrupt: false,
|
||||
interrupted: false,
|
||||
pass: None,
|
||||
output_pass: None,
|
||||
on: false,
|
||||
owner: Id::unique("craft_ui::ime::Composer"),
|
||||
context: ImeContextTracker::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Composer {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
preedit: self.preedit.clone(),
|
||||
active: self.active.clone(),
|
||||
guard: self.guard,
|
||||
kept: self.kept.clone(),
|
||||
last_commit: self.last_commit.clone(),
|
||||
modifiers: self.modifiers,
|
||||
caret: self.caret,
|
||||
purpose: self.purpose,
|
||||
interrupt: self.interrupt,
|
||||
interrupted: self.interrupted,
|
||||
pass: self.pass,
|
||||
output_pass: self.output_pass,
|
||||
on: self.on,
|
||||
owner: self.owner,
|
||||
context: ImeContextTracker::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Composer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The IME owner egui and the platform integration know this canvas's text by. Give each
|
||||
/// canvas that types text its own (the default is one id shared by every composer).
|
||||
pub fn with_owner(mut self, owner: Id) -> Self {
|
||||
self.owner = owner;
|
||||
self
|
||||
}
|
||||
|
||||
/// `IMEPurpose::Password` or `Terminal` for canvases that need them.
|
||||
pub fn with_purpose(mut self, purpose: IMEPurpose) -> Self {
|
||||
self.purpose = purpose;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_composing(&self) -> bool {
|
||||
!self.preedit.is_empty()
|
||||
}
|
||||
|
||||
pub fn preedit(&self) -> Option<Preedit<'_>> {
|
||||
self.is_composing().then(|| Preedit { text: &self.preedit, active: self.active.as_ref() })
|
||||
}
|
||||
|
||||
/// Call once per pass, before the app's other keyboard handling, with whether the canvas
|
||||
/// has a text caret. Returns the pass's edits and removes the events the IME used from
|
||||
/// `ctx`'s input, so shortcuts don't see the Space that converted a word or the Enter that
|
||||
/// confirmed it.
|
||||
///
|
||||
/// When `editing` is false, or an egui text field has the keyboard, marked text is kept as
|
||||
/// typed (an empty [`Edit::Preedit`], then an [`Edit::Insert`]) and the IME turns off.
|
||||
/// When the editing ends later in a pass (a command closes the editor), call it again with
|
||||
/// `editing` false: the IME turns off in that pass, not the next.
|
||||
pub fn update(&mut self, ctx: &Context, editing: bool) -> Vec<Edit> {
|
||||
let pass = self.start_pass(ctx);
|
||||
if !editing || ctx.text_edit_focused() {
|
||||
self.on = false;
|
||||
self.context.observe(ctx.viewport_id(), None);
|
||||
if self.output_pass == Some(pass) && !ctx.text_edit_focused() {
|
||||
ctx.output_mut(|o| o.ime = None);
|
||||
}
|
||||
let time = ctx.input(|i| i.time);
|
||||
let kept = self.end_at(time);
|
||||
// Turning the IME off drops its marked text; there is nothing to interrupt.
|
||||
self.interrupt = false;
|
||||
return kept.map(|text| vec![Edit::Preedit { text: String::new(), active: None }, Edit::Insert(text)]).unwrap_or_default();
|
||||
}
|
||||
self.on = true;
|
||||
let (events, time, modifiers) = ctx.input(|i| (i.events.clone(), i.time, i.modifiers));
|
||||
// Modifiers can change while the composer isn't updated (another view has the keyboard).
|
||||
self.modifiers = modifiers;
|
||||
let processed = self.process_pass(&events, time);
|
||||
if processed.events.len() != events.len() {
|
||||
ctx.input_mut(|i| i.events = processed.events);
|
||||
}
|
||||
self.write_output(ctx);
|
||||
processed.edits
|
||||
}
|
||||
|
||||
/// The caret in screen points (`ctx.layer_transform_to_global(layer) * rect` for a canvas
|
||||
/// on a transformed layer), after this pass's edits moved it. Put it at the start of the
|
||||
/// marked text, or of its active segment, so candidates don't cover what is being typed.
|
||||
///
|
||||
/// Call it after [`Self::update`] in every pass that edits; until the first call the
|
||||
/// candidate window has no position.
|
||||
pub fn set_caret(&mut self, ctx: &Context, caret: Rect) {
|
||||
self.caret = Some(caret_area(caret, ctx.content_rect()));
|
||||
if self.on {
|
||||
self.write_output(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the composer ended a composition the platform IME may still hold, in this pass
|
||||
/// (since the last [`Self::process`] for apps that call it directly): it kept the marked text as typed
|
||||
/// (a click, the editing ending, [`Self::end`], [`Self::discard`]) or a bare line break
|
||||
/// confirmed it. A host that talks to the platform IME itself (macOS `discardMarkedText`)
|
||||
/// tells it to drop its marked text then; egui's `should_interrupt_composition` is already
|
||||
/// set while the IME is on.
|
||||
pub fn interrupted(&self) -> bool {
|
||||
self.interrupted
|
||||
}
|
||||
|
||||
/// Ends the composition from the app, keeping the marked text as typed, for an app ending
|
||||
/// its text editing itself (a tool change, a command). Returns that text for the app to
|
||||
/// insert before the editor closes. The IME is told to drop its copy, and its late commit
|
||||
/// of the same text is dropped.
|
||||
#[must_use = "insert the marked text, or call `discard` when the editor already keeps it"]
|
||||
pub fn end(&mut self, ctx: &Context) -> Option<String> {
|
||||
self.start_pass(ctx);
|
||||
let time = ctx.input(|i| i.time);
|
||||
let kept = self.end_at(time);
|
||||
if kept.is_some() && self.on {
|
||||
self.write_output(ctx);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Ends the composition from the app without handing back the marked text: the edit was
|
||||
/// cancelled, or the editor shows marked text inline and kept it when it stopped composing
|
||||
/// on its own. Otherwise as [`Self::end`].
|
||||
pub fn discard(&mut self, ctx: &Context) {
|
||||
let _ = self.end(ctx);
|
||||
}
|
||||
|
||||
/// The state machine behind [`Self::update`], for apps that read events elsewhere and for
|
||||
/// tests. `time` is the pass's input time in seconds.
|
||||
pub fn process(&mut self, events: &[Event], time: f64) -> Processed {
|
||||
self.interrupted = false;
|
||||
self.process_pass(events, time)
|
||||
}
|
||||
|
||||
/// [`Self::process`] for [`Self::update`], which starts [`Self::interrupted`] per pass, so
|
||||
/// an [`Self::end`] before it in the same pass still counts.
|
||||
fn process_pass(&mut self, events: &[Event], time: f64) -> Processed {
|
||||
let mut out = Processed::default();
|
||||
self.last_commit = None;
|
||||
if self.kept.as_ref().is_some_and(|(_, at)| time - at > KEPT_SECS) {
|
||||
self.kept = None;
|
||||
}
|
||||
// The previous event was a pressed Enter: a line break right after it is that key's text.
|
||||
let mut after_enter = false;
|
||||
for event in events {
|
||||
let follows_enter = std::mem::take(&mut after_enter);
|
||||
let keep = match event {
|
||||
Event::Ime(ime) => {
|
||||
self.ime(ime, time, follows_enter, &mut out.edits);
|
||||
false
|
||||
}
|
||||
Event::Key { key, pressed, repeat, modifiers, .. } => {
|
||||
self.modifiers = *modifiers;
|
||||
if !*pressed {
|
||||
after_enter = follows_enter;
|
||||
true
|
||||
} else {
|
||||
after_enter = *key == Key::Enter;
|
||||
if self.is_composing() || self.take_guard(*key, time) {
|
||||
false
|
||||
} else {
|
||||
self.kept = None;
|
||||
// Text after this key is the key's own.
|
||||
self.last_commit = None;
|
||||
out.edits.push(Edit::Key { key: *key, modifiers: *modifiers, repeat: *repeat });
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Text(text) => {
|
||||
let echo = self.is_echo(text);
|
||||
if self.is_composing() || echo || is_shortcut_chord(self.modifiers) {
|
||||
false
|
||||
} else if is_line_break(text) {
|
||||
self.line_break(time, follows_enter, &mut out.edits);
|
||||
true
|
||||
} else {
|
||||
// Keys after typed text are the user's.
|
||||
self.guard = None;
|
||||
self.kept = None;
|
||||
out.edits.push(Edit::Insert(text.clone()));
|
||||
true
|
||||
}
|
||||
}
|
||||
Event::Paste(_) | Event::Copy | Event::Cut if self.is_composing() => false,
|
||||
Event::Paste(text) => {
|
||||
out.edits.push(Edit::Paste(text.clone()));
|
||||
true
|
||||
}
|
||||
Event::Copy => {
|
||||
out.edits.push(Edit::Copy);
|
||||
true
|
||||
}
|
||||
Event::Cut => {
|
||||
out.edits.push(Edit::Cut);
|
||||
true
|
||||
}
|
||||
Event::ModifiersChanged(modifiers) => {
|
||||
self.modifiers = *modifiers;
|
||||
after_enter = follows_enter;
|
||||
true
|
||||
}
|
||||
Event::PointerButton { pressed: true, .. } => {
|
||||
// A click while composing keeps the marked text where it is, then the app
|
||||
// handles the click (moving the caret or ending the edit).
|
||||
if let Some(text) = self.end_at(time) {
|
||||
out.edits.push(Edit::Preedit { text: String::new(), active: None });
|
||||
out.edits.push(Edit::Insert(text));
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if keep {
|
||||
out.events.push(event.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ime(&mut self, ime: &ImeEvent, time: f64, after_enter: bool, edits: &mut Vec<Edit>) {
|
||||
match ime {
|
||||
ImeEvent::Preedit { text, .. } if text.is_empty() => {
|
||||
// Wayland input methods send empty preedits on every state change and after
|
||||
// each commit; only one that ends a composition means anything.
|
||||
self.take_preedit(time, edits);
|
||||
}
|
||||
ImeEvent::Preedit { text, active_range_chars } => {
|
||||
self.kept = None;
|
||||
self.last_commit = None;
|
||||
self.preedit.clone_from(text);
|
||||
self.active = active_range_chars.clone().filter(|r| r.end <= text.chars().count());
|
||||
edits.push(Edit::Preedit { text: text.clone(), active: self.active.clone() });
|
||||
}
|
||||
// A bare line break confirms the composition (Enter with some IMEs): the marked text
|
||||
// stays as typed, not a new line. The platform's input context can still hold the
|
||||
// marked text (macOS), so it is kept as for a click.
|
||||
ImeEvent::Commit(text) if is_line_break(text) => {
|
||||
if let Some(kept) = self.take_preedit(time, edits) {
|
||||
self.guard = Some((Guard::Ended { committed: true }, time));
|
||||
edits.push(Edit::Insert(self.keep(kept, time)));
|
||||
} else if self.awaits_commit(time) {
|
||||
self.guard = Some((Guard::Ended { committed: true }, time));
|
||||
} else {
|
||||
self.last_commit = Some(text.clone());
|
||||
self.line_break(time, after_enter, edits);
|
||||
}
|
||||
}
|
||||
ImeEvent::Commit(text) => {
|
||||
let was_composing = self.take_preedit(time, edits).is_some();
|
||||
if self.kept.take().is_some_and(|(kept, _)| kept == *text) {
|
||||
return;
|
||||
}
|
||||
if was_composing || self.awaits_commit(time) {
|
||||
self.guard = Some((Guard::Ended { committed: true }, time));
|
||||
} else if !text.is_empty() {
|
||||
// A commit with no composition (IBus and fcitx typing plain letters, the
|
||||
// emoji picker, dictation, Apple Korean's Space with no syllable) is typed
|
||||
// text. Only Space can follow it as the key that typed it.
|
||||
self.guard = (text == " ").then_some((Guard::Key(Key::Space), time));
|
||||
}
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.last_commit = Some(text.clone());
|
||||
edits.push(Edit::Insert(text.clone()));
|
||||
}
|
||||
ImeEvent::DeleteSurrounding { before_chars, after_chars } => {
|
||||
if !self.is_composing() {
|
||||
edits.push(Edit::DeleteSurrounding { before_chars: *before_chars, after_chars: *after_chars });
|
||||
}
|
||||
}
|
||||
#[expect(deprecated)]
|
||||
ImeEvent::Enabled | ImeEvent::Disabled => {}
|
||||
#[allow(unreachable_patterns, reason = "crates.io egui has no other events")]
|
||||
other => {
|
||||
if let Some((before_bytes, after_bytes)) = delete_surrounding_bytes(other) {
|
||||
edits.push(Edit::DeleteSurroundingBytes { before_bytes, after_bytes });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `text` repeats the commit just made, as the text of the key that typed it.
|
||||
///
|
||||
/// macOS on winit 0.30 without the fix for rust-windowing/winit#4478 does this for Apple
|
||||
/// Korean's Space with no syllable: `Commit(" ")`, then that Space as `KeyboardInput`, which
|
||||
/// egui-winit turns into `Key(Space)`, `Text(" ")`, all in one pass. The key is guarded as
|
||||
/// the IME's, so only the first text after the commit, with no key handed to the editor in
|
||||
/// between, can be its echo. Text in a later pass is typed.
|
||||
fn is_echo(&mut self, text: &str) -> bool {
|
||||
self.last_commit.take().is_some_and(|c| c == text)
|
||||
}
|
||||
|
||||
/// Whether `key` is the one the platform forwards after a composition ended or a bare
|
||||
/// commit. One key per guard: a second Enter is the user's.
|
||||
fn take_guard(&mut self, key: Key, time: f64) -> bool {
|
||||
let guarded = self.guard.is_some_and(|(guard, at)| guard.covers(key) && time - at <= GUARD_SECS);
|
||||
if guarded {
|
||||
self.guard = None;
|
||||
}
|
||||
guarded
|
||||
}
|
||||
|
||||
/// The composition just ended with an empty preedit and its commit hasn't arrived yet
|
||||
/// (Wayland and Windows end it before committing).
|
||||
fn awaits_commit(&self, time: f64) -> bool {
|
||||
matches!(self.guard, Some((Guard::Ended { committed: false }, at)) if time - at <= GUARD_SECS)
|
||||
}
|
||||
|
||||
/// A line break typed or committed with nothing composing is the Enter key, unless the Enter
|
||||
/// that typed it came just before.
|
||||
fn line_break(&mut self, time: f64, after_enter: bool, edits: &mut Vec<Edit>) {
|
||||
if after_enter {
|
||||
return;
|
||||
}
|
||||
self.kept = None;
|
||||
self.guard = Some((Guard::Key(Key::Enter), time));
|
||||
edits.push(Edit::LineBreak { modifiers: self.modifiers });
|
||||
}
|
||||
|
||||
fn take_preedit(&mut self, time: f64, edits: &mut Vec<Edit>) -> Option<String> {
|
||||
if !self.is_composing() {
|
||||
return None;
|
||||
}
|
||||
self.active = None;
|
||||
self.guard = Some((Guard::Ended { committed: false }, time));
|
||||
edits.push(Edit::Preedit { text: String::new(), active: None });
|
||||
Some(std::mem::take(&mut self.preedit))
|
||||
}
|
||||
|
||||
/// Keeps marked text as typed, remembering it so the IME's late commit of it is dropped.
|
||||
fn end_at(&mut self, time: f64) -> Option<String> {
|
||||
if !self.is_composing() {
|
||||
return None;
|
||||
}
|
||||
let text = std::mem::take(&mut self.preedit);
|
||||
self.active = None;
|
||||
Some(self.keep(text, time))
|
||||
}
|
||||
|
||||
/// Starts [`Self::interrupted`] afresh in a new pass. Returns the pass number.
|
||||
fn start_pass(&mut self, ctx: &Context) -> u64 {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
if self.pass.replace(pass) != Some(pass) {
|
||||
self.interrupted = false;
|
||||
}
|
||||
pass
|
||||
}
|
||||
|
||||
/// Marked text the composer ended itself: the IME is interrupted, and a late commit of the
|
||||
/// same text is dropped.
|
||||
fn keep(&mut self, text: String, time: f64) -> String {
|
||||
self.interrupt = true;
|
||||
self.interrupted = true;
|
||||
self.kept = Some((text.clone(), time));
|
||||
text
|
||||
}
|
||||
|
||||
fn write_output(&mut self, ctx: &Context) {
|
||||
let interrupt = std::mem::take(&mut self.interrupt);
|
||||
self.output_pass = Some(ctx.cumulative_pass_nr());
|
||||
let rect = self.caret.unwrap_or(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1.0, 1.0)));
|
||||
let viewport = ctx.viewport_id();
|
||||
let observation =
|
||||
ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), ImeTarget::new(self.owner, 0))
|
||||
.purpose(self.purpose);
|
||||
// `None` only once egui's process-wide generation counter has run out.
|
||||
let Some(context) = self.context.observe(viewport, Some(observation)) else {
|
||||
return;
|
||||
};
|
||||
// egui-winit turns the IME on only in passes that set `ime`.
|
||||
ctx.output_mut(|o| {
|
||||
// A later write in the same pass (the caret after edits) keeps the interrupt.
|
||||
let interrupt = interrupt || o.ime.as_ref().is_some_and(|ime| ime.should_interrupt_composition);
|
||||
let mut ime = IMEOutput::new(context.owner, context.context_generation, context.purpose, rect, rect);
|
||||
ime.should_interrupt_composition = interrupt;
|
||||
ime.change_cause = context.change_cause;
|
||||
ime.surrounding_text = context.surrounding_text;
|
||||
o.ime = Some(ime);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys the platform may forward after the text they produced, for [`GUARD_SECS`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Guard {
|
||||
/// A composition ended: the key that confirmed, cancelled or emptied it is the IME's.
|
||||
/// `committed` once its commit, if any, has arrived.
|
||||
Ended { committed: bool },
|
||||
/// Text that arrived without a composition: only the key that typed it (Apple Korean's
|
||||
/// Space after a commit of " ", the Enter behind a line break) is a duplicate.
|
||||
Key(Key),
|
||||
}
|
||||
|
||||
impl Guard {
|
||||
fn covers(self, key: Key) -> bool {
|
||||
match self {
|
||||
Self::Ended { .. } => is_ime_key(key),
|
||||
Self::Key(guarded) => key == guarded,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys an IME uses to confirm, cancel or edit a composition.
|
||||
fn is_ime_key(key: Key) -> bool {
|
||||
matches!(key, Key::Enter | Key::Escape | Key::Backspace | Key::Delete | Key::Space)
|
||||
}
|
||||
|
||||
/// The suite's egui fork has `ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }`;
|
||||
/// crates.io egui doesn't. Reading it from the event's `Debug` output lets the same source build
|
||||
/// against both without a feature.
|
||||
fn delete_surrounding_bytes(ime: &ImeEvent) -> Option<(usize, usize)> {
|
||||
let debug = format!("{ime:?}");
|
||||
let fields = debug.strip_prefix("DeleteSurroundingBytes { before_bytes: ")?.strip_suffix(" }")?;
|
||||
let (before, after) = fields.split_once(", after_bytes: ")?;
|
||||
Some((before.parse().ok()?, after.parse().ok()?))
|
||||
}
|
||||
|
||||
fn is_line_break(text: &str) -> bool {
|
||||
text == "\n" || text == "\r" || text == "\r\n"
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl chords are shortcuts: a host that still sends their letter as text doesn't type it.
|
||||
/// Alt is left alone: Option on macOS and AltGr (Ctrl+Alt) on Windows type characters.
|
||||
fn is_shortcut_chord(m: Modifiers) -> bool {
|
||||
(m.command || m.ctrl || m.mac_cmd) && !m.alt
|
||||
}
|
||||
|
||||
/// A caret IMEs can place a window at: at least a point wide and tall (some ignore an empty
|
||||
/// rectangle), and inside the window, so a caret scrolled off the canvas doesn't put the
|
||||
/// candidates off screen.
|
||||
fn caret_area(caret: Rect, screen: Rect) -> Rect {
|
||||
if !caret.is_finite() || !screen.is_positive() {
|
||||
return caret;
|
||||
}
|
||||
let size = caret.size().max(egui::vec2(1.0, 1.0)).min(screen.size());
|
||||
let min = caret.min.clamp(screen.min, screen.max - size);
|
||||
Rect::from_min_size(min, size)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Input methods for text typed onto canvases.
|
||||
//!
|
||||
//! egui's `TextEdit` handles its own composition. A canvas that takes text (a type tool, a cell
|
||||
//! being typed into, a caption on a timeline) uses one [`Composer`] instead:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # fn insert(_: &str) {} fn press(_: egui::Key, _: egui::Modifiers) {}
|
||||
//! # fn paste(_: &str) {} fn copy() {} fn cut() {} fn delete_around(_: usize, _: usize) {}
|
||||
//! # fn delete_whole_chars_around(_: usize, _: usize) {}
|
||||
//! # fn caret_rect() -> egui::Rect { egui::Rect::ZERO }
|
||||
//! # let ctx = egui::Context::default(); let mut composer = craft_ui::ime::Composer::new();
|
||||
//! # let editing = true;
|
||||
//! use craft_ui::ime::Edit;
|
||||
//! // Every pass, before shortcuts and other keyboard handling:
|
||||
//! for edit in composer.update(&ctx, editing) {
|
||||
//! match edit {
|
||||
//! // The editor paints marked text from `composer.preedit()`.
|
||||
//! Edit::Preedit { .. } => {}
|
||||
//! Edit::Insert(text) => insert(&text),
|
||||
//! Edit::Key { key, modifiers, .. } => press(key, modifiers),
|
||||
//! Edit::LineBreak { modifiers } => press(egui::Key::Enter, modifiers),
|
||||
//! Edit::Paste(text) => paste(&text),
|
||||
//! Edit::Copy => copy(),
|
||||
//! Edit::Cut => cut(),
|
||||
//! Edit::DeleteSurrounding { before_chars, after_chars } => delete_around(before_chars, after_chars),
|
||||
//! // Whole chars inside the byte counts; `craft_ui::ime::surrounding_chars` counts them.
|
||||
//! Edit::DeleteSurroundingBytes { before_bytes, after_bytes } => delete_whole_chars_around(before_bytes, after_bytes),
|
||||
//! }
|
||||
//! }
|
||||
//! // After laying out the text, in screen points:
|
||||
//! composer.set_caret(&ctx, caret_rect());
|
||||
//! // Paint `composer.preedit()` at the caret, underlined.
|
||||
//! ```
|
||||
//!
|
||||
//! The app applies every edit; the composer changes no text. Keys and clipboard events it hands
|
||||
//! over as edits also stay in the input, so the app takes each from one of the two.
|
||||
//!
|
||||
//! When the app ends a composition itself, [`Composer::end`] returns the marked text to insert
|
||||
//! and [`Composer::discard`] drops it (the edit was cancelled, or the editor already keeps it).
|
||||
//! [`Composer::interrupted`] says when the platform IME should drop its marked text, for hosts
|
||||
//! that talk to it directly.
|
||||
//!
|
||||
//! The composer covers what the apps each handled on their own:
|
||||
//!
|
||||
//! - Events stay in the order the platform sent them, so a Telex Backspace that replaces a
|
||||
//! letter lands between the right two inserts.
|
||||
//! - Keys, text and clipboard events belong to the IME while it composes. Enter, Escape,
|
||||
//! Backspace, Delete and Space that end a composition are its too, once: Enter that confirms a
|
||||
//! word doesn't also start a line or submit, Backspace that empties the marked text doesn't
|
||||
//! delete the character before it, and Escape that cancels doesn't leave the editor.
|
||||
//! - Text committed with no composition (IBus and fcitx send plain letters this way) leaves the
|
||||
//! keys after it to the editor; only the Space that typed a committed space is dropped.
|
||||
//! - A bare line break committed by the IME confirms the marked text instead of breaking the line,
|
||||
//! and interrupts the IME as a click does. With nothing composing, a line break sent as text or
|
||||
//! committed arrives as [`Edit::LineBreak`], and the line break typed by a pressed Enter is
|
||||
//! dropped, so each Enter arrives once.
|
||||
//! - Empty preedits outside a composition (Wayland sends them on every state change) are ignored.
|
||||
//! - A click while composing keeps the marked text as typed and tells the IME to drop its copy;
|
||||
//! a late commit of the same text is dropped. Giving up the keyboard does the same.
|
||||
//! - Text that repeats the commit just made, as the text of the key the IME took (Apple Korean's
|
||||
//! Space), is typed once. A key the editor gets in between, or a new pass, ends that.
|
||||
//! - Cmd/Ctrl chords don't type their letter if the host sends it; Alt and AltGr still type.
|
||||
//! - The candidate window is placed at the caret, clamped into the window.
|
||||
//!
|
||||
//! Platform bugs below egui (lost commits on macOS, the Korean first key, X11 focus) belong in
|
||||
//! winit and are fixed there; the composer doesn't work around them.
|
||||
|
||||
mod composer;
|
||||
|
||||
pub use composer::{Composer, Edit, Preedit, Processed, surrounding_chars};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,540 @@
|
||||
use super::*;
|
||||
use egui::{Context, Event, IMEPurpose, ImeEvent, Key, Modifiers, PointerButton, RawInput, Rect, output::IMEOutput, pos2, vec2};
|
||||
|
||||
fn preedit(text: &str, active: Option<std::ops::Range<usize>>) -> Event {
|
||||
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: active })
|
||||
}
|
||||
|
||||
fn commit(text: &str) -> Event {
|
||||
Event::Ime(ImeEvent::Commit(text.into()))
|
||||
}
|
||||
|
||||
fn key_with(key: Key, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
fn key(key: Key) -> Event {
|
||||
key_with(key, Modifiers::NONE)
|
||||
}
|
||||
|
||||
fn text(t: &str) -> Event {
|
||||
Event::Text(t.into())
|
||||
}
|
||||
|
||||
fn click() -> Event {
|
||||
Event::PointerButton { pos: pos2(5.0, 5.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// Feeds passes of `(time, events)` and returns the text the editor ends with and every key it
|
||||
/// was given. Backspace deletes the last char; other keys are only recorded.
|
||||
fn type_passes(composer: &mut Composer, passes: &[(f64, Vec<Event>)]) -> (String, Vec<Key>) {
|
||||
let mut doc = String::new();
|
||||
let mut keys = Vec::new();
|
||||
for (time, events) in passes {
|
||||
for edit in composer.process(events, *time).edits {
|
||||
match edit {
|
||||
Edit::Insert(t) => doc.push_str(&t),
|
||||
Edit::Key { key: Key::Backspace, .. } => {
|
||||
doc.pop();
|
||||
keys.push(Key::Backspace);
|
||||
}
|
||||
Edit::Key { key, .. } => keys.push(key),
|
||||
Edit::LineBreak { .. } => keys.push(Key::Enter),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(doc, keys)
|
||||
}
|
||||
|
||||
fn inserts(edits: &[Edit]) -> Vec<&str> {
|
||||
edits.iter().filter_map(|e| if let Edit::Insert(t) = e { Some(t.as_str()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinyin_on_wayland_commits_once_and_ignores_trailing_empty_preedits() {
|
||||
let mut c = Composer::new();
|
||||
let (doc, keys) = type_passes(
|
||||
&mut c,
|
||||
&[
|
||||
(0.0, vec![preedit("c", Some(1..1))]),
|
||||
(0.1, vec![preedit("ce", Some(2..2))]),
|
||||
(0.2, vec![preedit("ce shi", Some(6..6))]),
|
||||
// Space picks the first candidate (fcitx5 on GNOME Wayland).
|
||||
(0.3, vec![preedit("", None), commit("测试"), preedit("", Some(0..0)), preedit("", None)]),
|
||||
(0.4, vec![preedit("", None)]),
|
||||
(1.0, vec![preedit("", None), key(Key::Enter)]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc, "测试");
|
||||
assert_eq!(keys, [Key::Enter], "an Enter long after the commit is the user's");
|
||||
assert!(!c.is_composing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinyin_on_windows_keeps_candidate_keys_for_the_ime() {
|
||||
let mut c = Composer::new();
|
||||
let (doc, keys) = type_passes(
|
||||
&mut c,
|
||||
&[
|
||||
(0.0, vec![preedit("ni", Some(2..2))]),
|
||||
// Picking candidate 2: the key and its text are the IME's.
|
||||
(0.1, vec![key(Key::Num2), text("2"), preedit("你", Some(1..1))]),
|
||||
#[expect(deprecated)]
|
||||
(0.2, vec![preedit("你", Some(1..1)), preedit("", None), commit("你"), Event::Ime(ImeEvent::Disabled)]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc, "你");
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_two_set_splits_syllables_and_types_one_space() {
|
||||
let mut c = Composer::new();
|
||||
let (doc, keys) = type_passes(
|
||||
&mut c,
|
||||
&[
|
||||
// g k s: ㅎ 하 한, the first key composing like the rest.
|
||||
(0.0, vec![preedit("ㅎ", None)]),
|
||||
(0.1, vec![preedit("하", None)]),
|
||||
(0.2, vec![preedit("한", None)]),
|
||||
// k moves ㄴ to the next syllable: 하 is committed, 나 composes.
|
||||
(0.3, vec![commit("하"), preedit("나", None)]),
|
||||
// Space after a syllable: commit, then the space itself.
|
||||
(0.4, vec![commit("나"), key(Key::Space), text(" ")]),
|
||||
// Apple Korean's Space with no syllable: committed and also sent as the key's text.
|
||||
(0.8, vec![preedit(" ", None), commit(" "), preedit("", None), key(Key::Space), text(" ")]),
|
||||
// 한 then 5: the digit after the syllable is kept.
|
||||
(1.2, vec![preedit("한", None)]),
|
||||
(1.3, vec![commit("한"), key(Key::Num5), text("5")]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc, "하나 한5");
|
||||
assert_eq!(keys, [Key::Num5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_backspace_that_empties_the_syllable_deletes_nothing_else() {
|
||||
let mut c = Composer::new();
|
||||
let (doc, keys) = type_passes(
|
||||
&mut c,
|
||||
&[
|
||||
(0.0, vec![text("a")]),
|
||||
(0.1, vec![preedit("ㅎ", None)]),
|
||||
(0.2, vec![preedit("", None), key(Key::Backspace)]),
|
||||
(0.6, vec![key(Key::Backspace)]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc, "", "only the second Backspace reaches the text");
|
||||
assert_eq!(keys, [Key::Backspace]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_first_jamo_is_typed_not_lost() {
|
||||
// Unpatched macOS sends the first key in a fresh app as a raw jamo (fixed in the shared
|
||||
// winit); it is still typed.
|
||||
let mut c = Composer::new();
|
||||
let (doc, _) = type_passes(&mut c, &[(0.0, vec![key(Key::G), text("ㅎ")]), (0.1, vec![preedit("ㅏ", None)])]);
|
||||
assert_eq!(doc, "ㅎ");
|
||||
assert_eq!(c.preedit().map(|p| p.text), Some("ㅏ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_conversion_tracks_the_segment_and_guards_the_confirming_enter() {
|
||||
let mut c = Composer::new();
|
||||
c.process(&[preedit("にほんご", Some(4..4))], 0.0);
|
||||
// Space converts: the key is the IME's.
|
||||
let p = c.process(&[key(Key::Space), preedit("日本語", Some(0..3))], 0.1);
|
||||
assert!(p.events.is_empty(), "the converting Space is removed from the app's input");
|
||||
assert_eq!(c.preedit().and_then(|p| p.active.cloned()), Some(0..3));
|
||||
// Moving to the next segment.
|
||||
c.process(&[key(Key::ArrowRight), preedit("日本語", Some(2..3))], 0.2);
|
||||
assert_eq!(c.preedit().and_then(|p| p.active.cloned()), Some(2..3));
|
||||
// An out-of-range segment is dropped rather than trusted.
|
||||
c.process(&[preedit("日本語", Some(2..9))], 0.25);
|
||||
assert_eq!(c.preedit().map(|p| p.active), Some(None));
|
||||
let p = c.process(&[commit("日本語"), key(Key::Enter)], 0.3);
|
||||
assert_eq!(inserts(&p.edits), ["日本語"]);
|
||||
assert!(!p.edits.iter().any(|e| matches!(e, Edit::Key { .. })), "the confirming Enter doesn't submit");
|
||||
// The next Enter is the user's.
|
||||
let p = c.process(&[key(Key::Enter)], 0.9);
|
||||
assert_eq!(p.edits, [Edit::Key { key: Key::Enter, modifiers: Modifiers::NONE, repeat: false }]);
|
||||
// An Enter forwarded in the pass after the commit is still the IME's.
|
||||
c.process(&[preedit("か", None)], 1.0);
|
||||
c.process(&[commit("課")], 1.1);
|
||||
let p = c.process(&[key(Key::Enter)], 1.11);
|
||||
assert!(p.edits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_line_break_commit_keeps_the_marked_text() {
|
||||
let mut c = Composer::new();
|
||||
let (doc, keys) = type_passes(&mut c, &[(0.0, vec![preedit("かな", None)]), (0.1, vec![commit("\n"), key(Key::Enter)])]);
|
||||
assert_eq!(doc, "かな");
|
||||
assert!(keys.is_empty());
|
||||
assert!(c.interrupted(), "the platform's input context may still hold the marked text");
|
||||
// Interrupting the IME can commit the same text again.
|
||||
let p = c.process(&[commit("かな")], 0.15);
|
||||
assert!(inserts(&p.edits).is_empty());
|
||||
assert!(!c.interrupted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vietnamese_telex_keeps_backspace_between_its_inserts() {
|
||||
// Unikey without preedit: "toaf" replaces the a with à by Backspace and text.
|
||||
let mut c = Composer::new();
|
||||
let p = c.process(&[text("t"), text("o"), text("a"), key(Key::Backspace), text("à")], 0.0);
|
||||
assert_eq!(
|
||||
p.edits,
|
||||
[
|
||||
Edit::Insert("t".into()),
|
||||
Edit::Insert("o".into()),
|
||||
Edit::Insert("a".into()),
|
||||
Edit::Key { key: Key::Backspace, modifiers: Modifiers::NONE, repeat: false },
|
||||
Edit::Insert("à".into()),
|
||||
]
|
||||
);
|
||||
// IBus-Unikey with preedit: the word composes and Space commits it, then types the space.
|
||||
let (doc, _) = type_passes(
|
||||
&mut c,
|
||||
&[
|
||||
(1.0, vec![preedit("t", None)]),
|
||||
(1.1, vec![preedit("to", None)]),
|
||||
(1.2, vec![preedit("toa", None)]),
|
||||
(1.3, vec![preedit("toà", None)]),
|
||||
(1.4, vec![preedit("", None), commit("toà"), key(Key::Space), text(" ")]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc, "toà ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_without_a_composition_leave_the_next_keys_to_the_user() {
|
||||
// IBus and fcitx send plain letters as commits.
|
||||
let mut c = Composer::new();
|
||||
let (doc, keys) = type_passes(&mut c, &[(0.0, vec![commit("a"), commit("b"), key(Key::Backspace)])]);
|
||||
assert_eq!(doc, "a");
|
||||
assert_eq!(keys, [Key::Backspace]);
|
||||
for k in [Key::Enter, Key::Escape, Key::Delete, Key::Space] {
|
||||
let (_, keys) = type_passes(&mut c, &[(1.0, vec![commit("x"), key(k)])]);
|
||||
assert_eq!(keys, [k], "{k:?} after a bare commit is the user's");
|
||||
}
|
||||
// Telex through IBus without preedit: Backspace replaces the a.
|
||||
let (doc, _) = type_passes(&mut c, &[(2.0, vec![commit("t"), commit("o"), commit("a"), key(Key::Backspace), commit("à")])]);
|
||||
assert_eq!(doc, "toà");
|
||||
// A bare commit of a space guards only the Space key that typed it.
|
||||
let (doc, keys) = type_passes(&mut c, &[(3.0, vec![commit(" "), key(Key::Space), text(" ")]), (3.01, vec![commit(" "), key(Key::Backspace)])]);
|
||||
assert_eq!(doc, " ");
|
||||
assert_eq!(keys, [Key::Backspace]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_text_right_after_a_commit_is_its_echo() {
|
||||
let typed = |passes: &[(f64, Vec<Event>)]| type_passes(&mut Composer::new(), passes).0;
|
||||
// A key the editor gets after a commit types its own text.
|
||||
assert_eq!(typed(&[(0.0, vec![commit("a"), key(Key::A), text("a")])]), "aa");
|
||||
assert_eq!(typed(&[(0.0, vec![commit("a"), text("a")])]), "a");
|
||||
assert_eq!(typed(&[(0.0, vec![commit("あ"), text("あ")])]), "あ");
|
||||
// The echo comes in the commit's pass; text in a later pass is typed.
|
||||
assert_eq!(typed(&[(0.0, vec![commit("a")]), (0.01, vec![text("a")])]), "aa");
|
||||
assert_eq!(typed(&[(0.0, vec![key(Key::A), text("a")])]), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_break_with_nothing_composing_is_enter_once() {
|
||||
let line_break = |modifiers| Edit::LineBreak { modifiers };
|
||||
let enter = Edit::Key { key: Key::Enter, modifiers: Modifiers::NONE, repeat: false };
|
||||
let mut c = Composer::new();
|
||||
assert_eq!(c.process(&[commit("\n")], 0.0).edits, [line_break(Modifiers::NONE)]);
|
||||
assert_eq!(c.process(&[text("\n")], 1.0).edits, [line_break(Modifiers::NONE)]);
|
||||
// The key and its text, in either order, are one Enter: the key when it came first.
|
||||
let p = c.process(&[key(Key::Enter), text("\r")], 2.0);
|
||||
assert_eq!(p.edits, [enter]);
|
||||
assert_eq!(p.events, [key(Key::Enter), text("\r")], "the pressed Enter stays in the input");
|
||||
assert_eq!(c.process(&[commit("\n"), key(Key::Enter), text("\n")], 3.0).edits, [line_break(Modifiers::NONE)]);
|
||||
// Shift+Enter stays Shift+Enter.
|
||||
let p = c.process(&[Event::ModifiersChanged(Modifiers::SHIFT), commit("\r")], 4.0);
|
||||
assert_eq!(p.edits, [line_break(Modifiers::SHIFT)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_keys_compose_or_arrive_as_text() {
|
||||
let mut c = Composer::new();
|
||||
// macOS: ⌥E marks the accent, the next key commits the letter.
|
||||
let (doc, _) = type_passes(&mut c, &[(0.0, vec![preedit("´", None)]), (0.1, vec![preedit("", None), commit("í")])]);
|
||||
assert_eq!(doc, "í");
|
||||
// Windows, X11 and Wayland compose in the keyboard layout: only the result arrives.
|
||||
let (doc, _) = type_passes(&mut c, &[(1.0, vec![text("é")])]);
|
||||
assert_eq!(doc, "é");
|
||||
// Escape cancels a marked accent without reaching the editor.
|
||||
let (doc, keys) = type_passes(&mut c, &[(2.0, vec![preedit("¨", None)]), (2.1, vec![preedit("", None), key(Key::Escape)])]);
|
||||
assert_eq!(doc, "");
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_while_composing_keeps_the_text_and_drops_the_late_commit() {
|
||||
let mut c = Composer::new();
|
||||
c.process(&[preedit("ce", None)], 0.0);
|
||||
let p = c.process(&[click()], 0.1);
|
||||
assert_eq!(p.edits, [Edit::Preedit { text: String::new(), active: None }, Edit::Insert("ce".into())]);
|
||||
assert_eq!(p.events, [click()], "the app still handles the click");
|
||||
// Windows commits the dropped composition when the IME is interrupted.
|
||||
let p = c.process(&[preedit("", Some(0..0)), commit("ce")], 0.15);
|
||||
assert!(inserts(&p.edits).is_empty());
|
||||
// The same text typed later is typed.
|
||||
let p = c.process(&[commit("ce")], 2.0);
|
||||
assert_eq!(inserts(&p.edits), ["ce"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_chords_dont_type_but_altgr_does() {
|
||||
let mut c = Composer::new();
|
||||
let p = c.process(&[key_with(Key::S, Modifiers::COMMAND), text("s")], 0.0);
|
||||
assert!(inserts(&p.edits).is_empty());
|
||||
assert!(matches!(p.edits[..], [Edit::Key { key: Key::S, .. }]));
|
||||
let altgr = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE };
|
||||
let p = c.process(&[key_with(Key::Q, altgr), text("@")], 0.1);
|
||||
assert_eq!(inserts(&p.edits), ["@"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_and_surrounding_deletes_are_the_apps_to_apply() {
|
||||
let delete = |before_chars, after_chars| Event::Ime(ImeEvent::DeleteSurrounding { before_chars, after_chars });
|
||||
let mut c = Composer::new();
|
||||
// While composing, they are the IME's: no edits, and gone from the input.
|
||||
let p = c.process(&[preedit("ni", None), Event::Paste("x".into()), Event::Copy, Event::Cut, delete(1, 0)], 0.0);
|
||||
assert_eq!(p.edits, [Edit::Preedit { text: "ni".into(), active: None }]);
|
||||
assert!(p.events.is_empty());
|
||||
// After it, in order with the text. The clipboard events stay in the input too.
|
||||
let events = [preedit("", None), Event::Copy, text("a"), Event::Cut, Event::Paste("x".into()), delete(2, 1)];
|
||||
let p = c.process(&events, 0.1);
|
||||
assert_eq!(
|
||||
p.edits,
|
||||
[
|
||||
Edit::Preedit { text: String::new(), active: None },
|
||||
Edit::Copy,
|
||||
Edit::Insert("a".into()),
|
||||
Edit::Cut,
|
||||
Edit::Paste("x".into()),
|
||||
Edit::DeleteSurrounding { before_chars: 2, after_chars: 1 },
|
||||
]
|
||||
);
|
||||
assert_eq!(p.events, [Event::Copy, text("a"), Event::Cut, Event::Paste("x".into())]);
|
||||
}
|
||||
|
||||
/// The suite's egui fork's `ImeEvent::DeleteSurroundingBytes`, read from JSON; `None` on
|
||||
/// crates.io egui, which has no such event, so the test builds against both.
|
||||
fn delete_bytes(before_bytes: usize, after_bytes: usize) -> Option<Event> {
|
||||
let known: Result<ImeEvent, _> = serde_json::from_str(r#"{"DeleteSurrounding":{"before_chars":1,"after_chars":0}}"#);
|
||||
assert!(known.is_ok(), "egui's events read from JSON: {known:?}");
|
||||
let json = format!(r#"{{"DeleteSurroundingBytes":{{"before_bytes":{before_bytes},"after_bytes":{after_bytes}}}}}"#);
|
||||
serde_json::from_str(&json).ok().map(Event::Ime)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_deletes_come_before_the_commit_that_replaces_them() {
|
||||
let (Some(delete_e), Some(delete_jamo)) = (delete_bytes(1, 0), delete_bytes(3, 2)) else {
|
||||
return;
|
||||
};
|
||||
let mut c = Composer::new();
|
||||
// macOS press-and-hold: "e" typed, then "é" from the accent menu replaces it.
|
||||
c.process(&[text("e")], 0.0);
|
||||
let p = c.process(&[delete_e, commit("é")], 1.0);
|
||||
assert_eq!(p.edits, [Edit::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }, Edit::Insert("é".into())]);
|
||||
assert!(p.events.is_empty());
|
||||
// While composing too: the counts start at the caret, where the marked text is.
|
||||
c.process(&[preedit("ㅎ", None)], 2.0);
|
||||
let p = c.process(&[delete_jamo, commit("하")], 2.1);
|
||||
assert_eq!(
|
||||
p.edits,
|
||||
[
|
||||
Edit::DeleteSurroundingBytes { before_bytes: 3, after_bytes: 2 },
|
||||
Edit::Preedit { text: String::new(), active: None },
|
||||
Edit::Insert("하".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_counts_delete_only_whole_chars() {
|
||||
// A combining acute is two bytes; "é" is two bytes, "🇧🇪" two four-byte chars.
|
||||
assert_eq!(surrounding_chars("cafe\u{301}", "", 2, 0), (1, 0));
|
||||
assert_eq!(surrounding_chars("aé", "", 1, 0), (0, 0));
|
||||
assert_eq!(surrounding_chars("aé", "", 3, 0), (2, 0));
|
||||
assert_eq!(surrounding_chars("x🇧🇪", "", 4, 0), (1, 0));
|
||||
assert_eq!(surrounding_chars("", "éb", 0, 3), (0, 2));
|
||||
assert_eq!(surrounding_chars("", "éb", 0, 1), (0, 0));
|
||||
assert_eq!(surrounding_chars("ab", "cd", 9, 9), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_ends_a_composition_keeping_or_discarding_the_marked_text() {
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
run(&ctx, 0.0, vec![preedit("かな", None)], |ctx| {
|
||||
c.update(ctx, true);
|
||||
});
|
||||
let mut kept = None;
|
||||
let out = run(&ctx, 0.1, vec![], |ctx| {
|
||||
// A command run before the composer's update ends the editing.
|
||||
kept = c.end(ctx);
|
||||
c.update(ctx, true);
|
||||
});
|
||||
assert_eq!(kept.as_deref(), Some("かな"));
|
||||
assert!(c.interrupted());
|
||||
assert!(out.platform_output.ime.is_some_and(|i| i.should_interrupt_composition));
|
||||
|
||||
// An editor that already keeps the marked text discards the composer's.
|
||||
run(&ctx, 0.2, vec![preedit("ね", None)], |ctx| {
|
||||
c.update(ctx, true);
|
||||
});
|
||||
assert!(!c.interrupted());
|
||||
let out = run(&ctx, 0.3, vec![], |ctx| {
|
||||
c.update(ctx, true);
|
||||
c.discard(ctx);
|
||||
});
|
||||
assert!(!c.is_composing() && c.interrupted());
|
||||
assert!(out.platform_output.ime.is_some_and(|i| i.should_interrupt_composition));
|
||||
let mut edits = Vec::new();
|
||||
run(&ctx, 0.35, vec![commit("ね")], |ctx| edits = c.update(ctx, true));
|
||||
assert!(inserts(&edits).is_empty(), "the IME's late commit is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ime_context_generation_is_new_only_when_the_context_starts_again() {
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
let caret = Rect::from_min_size(pos2(10.0, 10.0), vec2(1.0, 16.0));
|
||||
let mut time = 0.0;
|
||||
let mut pass = |c: &mut Composer, editing: &[bool]| {
|
||||
time += 0.1;
|
||||
let out = run(&ctx, time, vec![], |ctx| {
|
||||
for e in editing {
|
||||
c.update(ctx, *e);
|
||||
}
|
||||
if !editing.is_empty() {
|
||||
c.set_caret(ctx, caret);
|
||||
}
|
||||
});
|
||||
out.platform_output.ime.as_ref().map(|i| i.context_generation)
|
||||
};
|
||||
let first = pass(&mut c, &[true]);
|
||||
assert!(first.is_some());
|
||||
assert_eq!(pass(&mut c, &[true]), first, "consecutive editing passes keep the context");
|
||||
// The editing ends and starts again in one pass.
|
||||
let again = pass(&mut c, &[false, true]);
|
||||
assert!(again.is_some() && again != first, "{again:?} after {first:?}");
|
||||
assert_eq!(pass(&mut c, &[true]), again);
|
||||
// A pass without the composer.
|
||||
assert_eq!(pass(&mut c, &[]), None);
|
||||
let after_skip = pass(&mut c, &[true]);
|
||||
assert!(after_skip.is_some() && after_skip != again, "{after_skip:?} after {again:?}");
|
||||
}
|
||||
|
||||
fn run(ctx: &Context, time: f64, events: Vec<Event>, f: impl FnMut(&Context)) -> egui::FullOutput {
|
||||
let raw = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut f = f;
|
||||
let mut out = ctx.run_ui(raw, |ui| f(ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ime_is_on_at_the_caret_only_while_editing() {
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
let caret = Rect::from_min_size(pos2(100.0, 50.0), vec2(0.0, 16.0));
|
||||
let out = run(&ctx, 0.0, vec![preedit("に", None), key(Key::Space)], |ctx| {
|
||||
c.update(ctx, true);
|
||||
assert!(ctx.input(|i| i.events.is_empty()), "the IME's events are removed for later handlers");
|
||||
c.set_caret(ctx, caret);
|
||||
});
|
||||
let ime = out.platform_output.ime.expect("IME on while a caret is on the canvas");
|
||||
assert_eq!(ime.cursor_rect, Rect::from_min_size(pos2(100.0, 50.0), vec2(1.0, 16.0)));
|
||||
assert!(!ime.should_interrupt_composition);
|
||||
|
||||
// A click away keeps the text and interrupts the IME.
|
||||
let mut edits = Vec::new();
|
||||
let out = run(&ctx, 0.1, vec![click()], |ctx| {
|
||||
edits = c.update(ctx, true);
|
||||
c.set_caret(ctx, caret);
|
||||
});
|
||||
assert!(edits.contains(&Edit::Insert("に".into())));
|
||||
assert!(out.platform_output.ime.is_some_and(|i| i.should_interrupt_composition));
|
||||
|
||||
// Leaving the canvas keeps marked text and turns the IME off.
|
||||
run(&ctx, 0.2, vec![preedit("か", None)], |ctx| {
|
||||
c.update(ctx, true);
|
||||
});
|
||||
let out = run(&ctx, 0.3, vec![], |ctx| edits = c.update(ctx, false));
|
||||
assert_eq!(edits, [Edit::Preedit { text: String::new(), active: None }, Edit::Insert("か".into())]);
|
||||
assert!(out.platform_output.ime.is_none());
|
||||
assert!(c.interrupted(), "the IME may still hold the marked text it dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_that_ends_mid_pass_turns_the_ime_off_in_that_pass() {
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
let caret = Rect::from_min_size(pos2(100.0, 50.0), vec2(0.0, 16.0));
|
||||
run(&ctx, 0.0, vec![preedit("に", None)], |ctx| {
|
||||
c.update(ctx, true);
|
||||
c.set_caret(ctx, caret);
|
||||
});
|
||||
let mut edits = Vec::new();
|
||||
let mut interrupted = false;
|
||||
let out = run(&ctx, 0.1, vec![click()], |ctx| {
|
||||
edits = c.update(ctx, true);
|
||||
// The click closed the editor.
|
||||
edits.extend(c.update(ctx, false));
|
||||
interrupted = c.interrupted();
|
||||
c.set_caret(ctx, caret);
|
||||
});
|
||||
assert_eq!(inserts(&edits), ["に"]);
|
||||
assert!(interrupted, "the second update keeps the first one's interrupt");
|
||||
assert!(out.platform_output.ime.is_none());
|
||||
// A pass in which the composer wrote nothing leaves another widget's IME output alone.
|
||||
let other = IMEOutput::new(egui::Id::unique("other"), 1, IMEPurpose::Normal, caret, caret);
|
||||
let out = run(&ctx, 0.2, vec![], |ctx| {
|
||||
ctx.output_mut(|o| o.ime = Some(other.clone()));
|
||||
c.update(ctx, false);
|
||||
});
|
||||
assert_eq!(out.platform_output.ime, Some(other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_line_break_commit_interrupts_the_ime() {
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
run(&ctx, 0.0, vec![preedit("かな", None)], |ctx| {
|
||||
c.update(ctx, true);
|
||||
});
|
||||
let mut edits = Vec::new();
|
||||
let out = run(&ctx, 0.1, vec![commit("\n")], |ctx| edits = c.update(ctx, true));
|
||||
assert_eq!(inserts(&edits), ["かな"]);
|
||||
assert!(out.platform_output.ime.is_some_and(|i| i.should_interrupt_composition));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_starts_from_the_modifiers_held() {
|
||||
// Cmd went down while another view had the keyboard; its ModifiersChanged went there.
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
run(&ctx, 0.0, vec![Event::ModifiersChanged(Modifiers::COMMAND)], |_| {});
|
||||
let mut edits = Vec::new();
|
||||
run(&ctx, 0.1, vec![text("s")], |ctx| edits = c.update(ctx, true));
|
||||
assert!(edits.is_empty(), "Cmd+S doesn't type an s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caret_off_the_canvas_keeps_the_candidates_on_screen() {
|
||||
let ctx = Context::default();
|
||||
let mut c = Composer::new();
|
||||
let out = run(&ctx, 0.0, vec![], |ctx| {
|
||||
c.update(ctx, true);
|
||||
c.set_caret(ctx, Rect::from_min_size(pos2(-50.0, 900.0), vec2(2.0, 20.0)));
|
||||
});
|
||||
let r = out.platform_output.ime.map(|i| i.cursor_rect);
|
||||
assert_eq!(r, Some(Rect::from_min_size(pos2(0.0, 580.0), vec2(2.0, 20.0))));
|
||||
}
|
||||
@@ -7,12 +7,18 @@
|
||||
|
||||
pub mod buttons;
|
||||
pub mod capture;
|
||||
pub mod direction;
|
||||
pub mod docking;
|
||||
mod focus;
|
||||
pub mod fonts;
|
||||
pub mod icons;
|
||||
pub mod ime;
|
||||
pub mod layout;
|
||||
pub mod localized;
|
||||
pub mod panes;
|
||||
pub mod printing;
|
||||
pub mod tabs;
|
||||
#[cfg(all(feature = "screen-testing", not(target_arch = "wasm32")))]
|
||||
pub mod testing;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
//! Number widgets that write numbers the way the user's region does (its digits, decimal mark and
|
||||
//! grouping) and read back any digits with the region's separators.
|
||||
//!
|
||||
//! [`LocaleNumbers`] wraps a [`craft_i18n::Format`], which writes and reads the numbers.
|
||||
//! [`LocalizedDragValue`] puts them on an [`egui::DragValue`]; [`NumberField`] is a text field for
|
||||
//! apps that edit numbers in a [`egui::TextEdit`], with the apps' panel-field behaviour: a
|
||||
//! [`Recessed`] box, scrubbing by the label, stepping to multiples of the step, a blank field for
|
||||
//! [mixed](NumberField::mixed) values and [`focus_first_field`]. Text that isn't a number never
|
||||
//! changes the value: it is outlined in the error color with a tooltip saying why, from the
|
||||
//! catalog with [`LocaleNumbers::with_catalog`].
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::ops::RangeInclusive;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_i18n::{Format, I18n, Lang, NumberError};
|
||||
use egui::emath::{Numeric, round_to_decimals};
|
||||
use egui::response::Flags;
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
use egui::{
|
||||
Align2, AsIdSalt, Color32, Context, CornerRadius, CursorIcon, DragValue, Event, Frame, Id, IdSalt, Key, Margin, Modifiers, MouseWheelUnit, Rect,
|
||||
Response, Sense, Stroke, StrokeKind, Style, TextEdit, Ui, Vec2, Widget, vec2,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// How long a [`LocalizedDragValue`] keeps marking text it could not read after reverting, in
|
||||
/// seconds.
|
||||
const REJECTED_FOR: f64 = 2.5;
|
||||
|
||||
/// Points of horizontal drag on a [`NumberField`]'s label per step.
|
||||
const SCRUB_POINTS_PER_STEP: f32 = 2.0;
|
||||
|
||||
type Messages = Arc<dyn Fn(&'static str) -> String + Send + Sync>;
|
||||
type Fallback<'a> = Box<dyn 'a + Fn(&str) -> Option<f64>>;
|
||||
type Formatter<'a> = Box<dyn 'a + Fn(f64, RangeInclusive<usize>) -> Option<String>>;
|
||||
/// The last text egui asked the parser to read: whether it is still being typed, and the result.
|
||||
type LastRead = Rc<Cell<Option<(bool, Result<f64, NumberError>)>>>;
|
||||
|
||||
/// How the user's region writes and reads numbers. Cheap to clone; build it from
|
||||
/// [`I18n::format`] so widgets match the app's counts and sizes.
|
||||
#[derive(Clone)]
|
||||
pub struct LocaleNumbers {
|
||||
format: Arc<Format>,
|
||||
messages: Option<Messages>,
|
||||
grouping: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocaleNumbers {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LocaleNumbers").field("format", &self.format).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl LocaleNumbers {
|
||||
pub fn new(format: Arc<Format>) -> LocaleNumbers {
|
||||
LocaleNumbers { format, messages: None, grouping: true }
|
||||
}
|
||||
|
||||
/// The numbers of a locale tag (`de-CH`, `ar-EG`) with its own digits.
|
||||
pub fn from_tag(tag: &str) -> LocaleNumbers {
|
||||
LocaleNumbers::new(Arc::new(Format::from_tag(tag)))
|
||||
}
|
||||
|
||||
/// The error tooltips from `i18n`'s catalog in `lang`: the plain rows keyed by the English
|
||||
/// text ([`message`]), English where a row is missing.
|
||||
pub fn with_catalog(self, i18n: &I18n, lang: Lang) -> LocaleNumbers {
|
||||
let rows: Vec<(&'static str, String)> = MESSAGES.iter().map(|&english| (english, i18n.t(lang, english).to_owned())).collect();
|
||||
self.with_messages(move |english| rows.iter().find(|(key, _)| *key == english).map_or_else(|| english.to_owned(), |(_, row)| row.clone()))
|
||||
}
|
||||
|
||||
/// Translate the error tooltips with an app's own lookup: `translate` gets the English text
|
||||
/// ([`message`]) and returns it in the interface language.
|
||||
pub fn with_messages(mut self, translate: impl Fn(&'static str) -> String + Send + Sync + 'static) -> LocaleNumbers {
|
||||
self.messages = Some(Arc::new(translate));
|
||||
self
|
||||
}
|
||||
|
||||
/// Write numbers without grouping separators, for pixel sizes and years: `1920`, not `1,920`.
|
||||
/// Grouped text still reads back.
|
||||
pub fn ungrouped(mut self) -> LocaleNumbers {
|
||||
self.grouping = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// `value` with between `decimals.start()` and `decimals.end()` digits after the decimal mark,
|
||||
/// trailing zeros dropped down to the minimum: `1,5`, `١٬٢٣٤٫٥`.
|
||||
pub fn format(&self, value: f64, decimals: RangeInclusive<usize>) -> String {
|
||||
let (min, max) = (*decimals.start(), (*decimals.end()).max(*decimals.start()));
|
||||
let full = round_to_decimals(value, max);
|
||||
let decimals = (min..max).find(|&d| round_to_decimals(value, d) == full).unwrap_or(max);
|
||||
// `-0.001` shown with two decimals is `0,00`, not `-0,00`.
|
||||
let value = if round_to_decimals(value, decimals) == 0.0 { 0.0 } else { value };
|
||||
if self.grouping { self.format.number(value, decimals) } else { self.format.number_ungrouped(value, decimals) }
|
||||
}
|
||||
|
||||
/// The number in `text`, as [`Format::parse`] reads it: what [`LocaleNumbers::format`] writes,
|
||||
/// in any digits.
|
||||
pub fn parse(&self, text: &str) -> Result<f64, NumberError> {
|
||||
self.format.parse(text)
|
||||
}
|
||||
|
||||
fn describe(&self, error: NumberError) -> String {
|
||||
let english = message(error);
|
||||
self.messages.as_ref().map_or_else(|| english.to_owned(), |translate| translate(english))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every text [`message`] returns: the catalog rows an app adds for [`LocaleNumbers::with_catalog`].
|
||||
pub const MESSAGES: [&str; 5] =
|
||||
["Enter a number", "Not a number", "Use one kind of digits", "Check the decimal and grouping marks", "Number too large"];
|
||||
|
||||
/// The English text the widgets show for `error`; a catalog key.
|
||||
pub fn message(error: NumberError) -> &'static str {
|
||||
match error {
|
||||
NumberError::Empty => MESSAGES[0],
|
||||
NumberError::Unexpected(_) => MESSAGES[1],
|
||||
NumberError::MixedDigits => MESSAGES[2],
|
||||
NumberError::Separator => MESSAGES[3],
|
||||
NumberError::TooLarge => MESSAGES[4],
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit around a number, from a craft-i18n unit template with the number as `{n}`: `{n} px`,
|
||||
/// `{n}%`, Turkish `%{n}`. Typed text may include it; it is removed before parsing.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Unit {
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
/// The unit `template` spells; a template without `{n}` is a suffix.
|
||||
pub fn new(template: &str) -> Unit {
|
||||
match template.split_once("{n}") {
|
||||
Some((prefix, suffix)) => Unit { prefix: prefix.to_owned(), suffix: suffix.to_owned() },
|
||||
None => Unit { prefix: String::new(), suffix: template.to_owned() },
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit row `template` (context `unit`) in `lang`, the English template if untranslated.
|
||||
pub fn translated(i18n: &I18n, lang: Lang, template: &str) -> Unit {
|
||||
Unit::new(i18n.get(lang, "unit", template).unwrap_or(template))
|
||||
}
|
||||
|
||||
pub fn prefix(&self) -> &str {
|
||||
&self.prefix
|
||||
}
|
||||
|
||||
pub fn suffix(&self) -> &str {
|
||||
&self.suffix
|
||||
}
|
||||
|
||||
fn strip<'t>(&self, text: &'t str) -> &'t str {
|
||||
let text = text.trim();
|
||||
let text = text.strip_prefix(self.prefix.trim()).unwrap_or(text);
|
||||
text.strip_suffix(self.suffix.trim()).unwrap_or(text).trim()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `text` with `numbers`, then with `fallback` (an app's expression or timecode reader).
|
||||
fn read(numbers: &LocaleNumbers, unit: &Unit, fallback: Option<&Fallback<'_>>, text: &str) -> Result<f64, NumberError> {
|
||||
numbers.parse(unit.strip(text)).or_else(|error| fallback.and_then(|f| f(text)).ok_or(error))
|
||||
}
|
||||
|
||||
/// Text that may still become a number as the user types (`-`, `1,`): not marked while editing.
|
||||
fn incomplete(text: &str, error: NumberError) -> bool {
|
||||
match error {
|
||||
NumberError::Empty => true,
|
||||
NumberError::Separator => text.trim_end().ends_with(['.', ',', '٫', '٬', '\'', '’', '.', ',']),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text the widget could not read. `until` is `None` while the user is still editing it.
|
||||
#[derive(Clone)]
|
||||
struct Rejected {
|
||||
error: NumberError,
|
||||
until: Option<f64>,
|
||||
}
|
||||
|
||||
fn rejected_id(id: Id) -> Id {
|
||||
id.with("craft_ui::localized::rejected")
|
||||
}
|
||||
|
||||
/// Outlines `response` in the error color and, with `tell`, shows `text` beside it.
|
||||
fn mark_invalid(ui: &Ui, response: &Response, radius: CornerRadius, text: String, tell: bool) {
|
||||
ui.painter().rect_stroke(response.rect, radius, Stroke::new(1.5, ui.visuals().error_fg_color), StrokeKind::Inside);
|
||||
if tell {
|
||||
response.show_tooltip_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`egui::DragValue`] that writes and reads numbers with [`LocaleNumbers`]. Set range, speed,
|
||||
/// decimals and any prefix or suffix on the drag value; a [`Unit`] set here goes outside that
|
||||
/// prefix and suffix (egui adds to them and can't take them off), so set one or the other. The
|
||||
/// drag value's own `custom_formatter` and `custom_parser` are replaced: pass an app's formatter
|
||||
/// and parser to [`LocalizedDragValue::or_format`] and [`LocalizedDragValue::or_parse`] instead.
|
||||
///
|
||||
/// Typed text that isn't a number is outlined while it is being edited. Committing it (Enter,
|
||||
/// clicking away) keeps the old value, as egui does, and the field stays outlined with the reason
|
||||
/// for a moment. Escape reverts without a mark. Committing the text as the drag value shows it
|
||||
/// leaves the value as it is, with any decimals the drag value doesn't show.
|
||||
///
|
||||
/// ```
|
||||
/// # egui::__run_test_ui(|ui| {
|
||||
/// use craft_ui::localized::{LocaleNumbers, LocalizedDragValue, Unit};
|
||||
/// let numbers = LocaleNumbers::from_tag("de");
|
||||
/// let mut width = 1.5;
|
||||
/// ui.add(LocalizedDragValue::new(egui::DragValue::new(&mut width).speed(0.1), &numbers).unit(Unit::new("{n} px")));
|
||||
/// # });
|
||||
/// ```
|
||||
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
|
||||
pub struct LocalizedDragValue<'a> {
|
||||
drag: DragValue<'a>,
|
||||
numbers: LocaleNumbers,
|
||||
unit: Unit,
|
||||
formatter: Option<Formatter<'a>>,
|
||||
fallback: Option<Fallback<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> LocalizedDragValue<'a> {
|
||||
pub fn new(drag: DragValue<'a>, numbers: &LocaleNumbers) -> Self {
|
||||
LocalizedDragValue { drag, numbers: numbers.clone(), unit: Unit::default(), formatter: None, fallback: None }
|
||||
}
|
||||
|
||||
/// The unit around the number, outside a prefix and suffix set on the drag value.
|
||||
pub fn unit(mut self, unit: Unit) -> Self {
|
||||
self.unit = unit;
|
||||
self
|
||||
}
|
||||
|
||||
/// [`DragValue::range`] of the wrapped drag value.
|
||||
pub fn range<Num: Numeric>(mut self, range: RangeInclusive<Num>) -> Self {
|
||||
self.drag = self.drag.range(range);
|
||||
self
|
||||
}
|
||||
|
||||
/// [`DragValue::speed`] of the wrapped drag value.
|
||||
pub fn speed(mut self, speed: impl Into<f64>) -> Self {
|
||||
self.drag = self.drag.speed(speed);
|
||||
self
|
||||
}
|
||||
|
||||
/// Show the number without grouping separators ([`LocaleNumbers::ungrouped`]).
|
||||
pub fn ungrouped(mut self) -> Self {
|
||||
self.numbers.grouping = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// An app's own text for some values (a timecode, `Auto` for 0), tried before the locale's;
|
||||
/// `None` writes the number as the locale does. Pair it with [`LocalizedDragValue::or_parse`]
|
||||
/// so the text reads back.
|
||||
pub fn or_format(mut self, formatter: impl 'a + Fn(f64, RangeInclusive<usize>) -> Option<String>) -> Self {
|
||||
self.formatter = Some(Box::new(formatter));
|
||||
self
|
||||
}
|
||||
|
||||
/// A parser for text that isn't a plain number (an expression, a timecode), tried after the
|
||||
/// locale's.
|
||||
pub fn or_parse(mut self, parser: impl 'a + Fn(&str) -> Option<f64>) -> Self {
|
||||
self.fallback = Some(Box::new(parser));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for LocalizedDragValue<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let LocalizedDragValue { drag, numbers, unit, formatter, fallback } = self;
|
||||
let parsed: LastRead = Rc::new(Cell::new(None));
|
||||
// The value egui last had written and its text. egui reads the text back when the field
|
||||
// loses focus, edited or not; the unedited text gives the value itself, not the value
|
||||
// rounded to the decimals shown.
|
||||
let shown: Rc<RefCell<Option<(f64, String)>>> = Rc::new(RefCell::new(None));
|
||||
let mut drag = drag.custom_formatter({
|
||||
let (numbers, shown) = (numbers.clone(), Rc::clone(&shown));
|
||||
move |value, decimals| {
|
||||
let text = formatter.as_ref().and_then(|f| f(value, decimals.clone())).unwrap_or_else(|| numbers.format(value, decimals));
|
||||
*shown.borrow_mut() = Some((value, text.clone()));
|
||||
text
|
||||
}
|
||||
});
|
||||
if !unit.prefix.is_empty() {
|
||||
drag = drag.prefix(unit.prefix.clone());
|
||||
}
|
||||
if !unit.suffix.is_empty() {
|
||||
drag = drag.suffix(unit.suffix.clone());
|
||||
}
|
||||
drag = drag.custom_parser({
|
||||
let (numbers, unit, parsed) = (numbers.clone(), unit.clone(), Rc::clone(&parsed));
|
||||
let shown = Rc::clone(&shown);
|
||||
move |text| {
|
||||
let result = match &*shown.borrow() {
|
||||
Some((value, written)) if written == text => Ok(*value),
|
||||
_ => read(&numbers, &unit, fallback.as_ref(), text),
|
||||
};
|
||||
let still_typing = result.err().is_some_and(|error| incomplete(text, error));
|
||||
parsed.set(Some((still_typing, result)));
|
||||
result.ok()
|
||||
}
|
||||
});
|
||||
let response = ui.add(drag);
|
||||
|
||||
let key = rejected_id(response.id);
|
||||
let now = ui.input(|i| i.time);
|
||||
let editing = response.has_focus();
|
||||
let mut rejected = ui.data(|d| d.get_temp::<Rejected>(key));
|
||||
match parsed.take() {
|
||||
Some((_, Ok(_))) => rejected = None,
|
||||
Some((still_typing, Err(error))) if editing => rejected = (!still_typing).then_some(Rejected { error, until: None }),
|
||||
// egui reads the text once more the frame after the edit loses focus.
|
||||
Some((_, Err(error))) => rejected = Some(Rejected { error, until: Some(now + REJECTED_FOR) }),
|
||||
// Escape, or focus left without a final read.
|
||||
None if !editing && rejected.as_ref().is_some_and(|r| r.until.is_none()) => rejected = None,
|
||||
None => {}
|
||||
}
|
||||
// A drag or arrow key after a rejection sets a new value: the mark is moot.
|
||||
if response.changed() || rejected.as_ref().is_some_and(|r| r.until.is_some_and(|t| t <= now)) {
|
||||
rejected = None;
|
||||
}
|
||||
match rejected {
|
||||
Some(r) => {
|
||||
if let Some(until) = r.until {
|
||||
ui.ctx().request_repaint_after_secs((until - now) as f32);
|
||||
}
|
||||
mark_invalid(ui, &response, ui.visuals().widgets.inactive.corner_radius, numbers.describe(r.error), true);
|
||||
ui.data_mut(|d| d.insert_temp(key, r));
|
||||
}
|
||||
None => ui.data_mut(|d| d.remove::<Rejected>(key)),
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`NumberField`] keeps between frames while its text differs from the value.
|
||||
#[derive(Clone)]
|
||||
struct FieldState {
|
||||
text: String,
|
||||
/// The value and mixed state when `text` was stored; a change means the app changed them, and
|
||||
/// the text is dropped.
|
||||
value: f64,
|
||||
mixed: bool,
|
||||
error: Option<NumberError>,
|
||||
}
|
||||
|
||||
/// The sunken box of [`NumberField::recessed`], as the apps draw their own fields. Fill it from the
|
||||
/// app's tokens, or start from [`Recessed::from_style`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Recessed {
|
||||
pub fill: Color32,
|
||||
pub stroke: Stroke,
|
||||
/// The border while the field has the keyboard focus.
|
||||
pub focus_stroke: Stroke,
|
||||
pub corner_radius: CornerRadius,
|
||||
/// The box's height; the text is centred in it.
|
||||
pub height: f32,
|
||||
/// Space between the box's edge and the text.
|
||||
pub margin: Margin,
|
||||
}
|
||||
|
||||
impl Recessed {
|
||||
/// egui's text-edit fill, the noninteractive border, the selection stroke while focused, and
|
||||
/// the interact height.
|
||||
pub fn from_style(style: &Style) -> Recessed {
|
||||
let visuals = &style.visuals;
|
||||
Recessed {
|
||||
fill: visuals.text_edit_bg_color(),
|
||||
stroke: visuals.widgets.noninteractive.bg_stroke,
|
||||
focus_stroke: visuals.selection.stroke,
|
||||
corner_radius: visuals.widgets.inactive.corner_radius,
|
||||
height: style.spacing.interact_size.y,
|
||||
margin: Margin::symmetric(6, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_field_id() -> Id {
|
||||
Id::unique("craft_ui::localized::focus_first_field")
|
||||
}
|
||||
|
||||
/// While `on`, the next enabled [`NumberField`] drawn takes the keyboard focus with its text
|
||||
/// selected, so typing replaces the value and Enter applies it: a dialog opening on its first
|
||||
/// field. Raise it when the group opens; the field that takes it lowers it. A sizing pass (a new
|
||||
/// window's first frame) leaves it for the next pass.
|
||||
pub fn focus_first_field(ctx: &Context, on: bool) {
|
||||
ctx.data_mut(|d| {
|
||||
if on {
|
||||
d.insert_temp(first_field_id(), true);
|
||||
} else {
|
||||
d.remove::<bool>(first_field_id());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A drag on a [`NumberField`]'s label in progress.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Scrub {
|
||||
/// The value before the first step, which Escape puts back.
|
||||
start: Option<f64>,
|
||||
/// Points dragged and not yet stepped.
|
||||
pending: f32,
|
||||
}
|
||||
|
||||
/// A single-line text field for a number, read on commit (Enter or focus leaving) with
|
||||
/// [`LocaleNumbers`]. Arrow up and down, and the mouse wheel over the focused field, step the
|
||||
/// value; a drag on its [`NumberField::scrub_label`] scrubs it.
|
||||
///
|
||||
/// Text that isn't a number leaves the value unchanged and stays in the field, outlined, with the
|
||||
/// reason in a tooltip, until it is fixed or Escape restores the value. A number is rounded to the
|
||||
/// most decimals shown, half away from zero (`2,5` is 3 and `-0,5` is -1 in an integer field),
|
||||
/// then clamped to the range. Committing the text as the field shows it leaves the value as it is,
|
||||
/// with any decimals the field doesn't show. The response is `changed()` when the value changed.
|
||||
///
|
||||
/// ```
|
||||
/// # egui::__run_test_ui(|ui| {
|
||||
/// use craft_ui::localized::{LocaleNumbers, NumberField, Recessed, Unit};
|
||||
/// let numbers = LocaleNumbers::from_tag("fr");
|
||||
/// let mut gap = 12.0;
|
||||
/// let label = ui.label("Gap");
|
||||
/// let frame = Recessed::from_style(ui.style());
|
||||
/// ui.add(NumberField::new(&mut gap, &numbers).range(0.0..=100.0).step(0.5).unit(Unit::new("{n} mm")).recessed(frame).scrub_label(&label));
|
||||
/// # });
|
||||
/// ```
|
||||
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
|
||||
pub struct NumberField<'a> {
|
||||
get_set: Box<dyn 'a + FnMut(Option<f64>) -> f64>,
|
||||
numbers: LocaleNumbers,
|
||||
range: RangeInclusive<f64>,
|
||||
step: f64,
|
||||
decimals: RangeInclusive<usize>,
|
||||
unit: Unit,
|
||||
fallback: Option<Fallback<'a>>,
|
||||
width: Option<f32>,
|
||||
id_salt: Option<IdSalt>,
|
||||
mixed: bool,
|
||||
placeholder: Option<String>,
|
||||
recessed: Option<Recessed>,
|
||||
label: Option<(Id, Rect)>,
|
||||
}
|
||||
|
||||
impl<'a> NumberField<'a> {
|
||||
/// A field for `value`. Integer types get no decimals and their full range.
|
||||
pub fn new<Num: Numeric>(value: &'a mut Num, numbers: &LocaleNumbers) -> Self {
|
||||
let field = NumberField {
|
||||
get_set: Box::new(move |v: Option<f64>| {
|
||||
if let Some(v) = v {
|
||||
*value = Num::from_f64(v);
|
||||
}
|
||||
value.to_f64()
|
||||
}),
|
||||
numbers: numbers.clone(),
|
||||
range: f64::NEG_INFINITY..=f64::INFINITY,
|
||||
step: 1.0,
|
||||
decimals: 0..=6,
|
||||
unit: Unit::default(),
|
||||
fallback: None,
|
||||
width: None,
|
||||
id_salt: None,
|
||||
mixed: false,
|
||||
placeholder: None,
|
||||
recessed: None,
|
||||
label: None,
|
||||
};
|
||||
if Num::INTEGRAL { field.decimals(0..=0).range(Num::MIN.to_f64()..=Num::MAX.to_f64()) } else { field }
|
||||
}
|
||||
|
||||
pub fn range(mut self, range: RangeInclusive<f64>) -> Self {
|
||||
self.range = range;
|
||||
self
|
||||
}
|
||||
|
||||
/// The change per arrow key press, wheel notch or scrub step; 0 turns stepping off. Default 1.
|
||||
/// Shift steps ten, and Ctrl/Cmd a tenth where the decimals show it. A plain or Ctrl/Cmd step
|
||||
/// goes to the next multiple of its size (by 1, `5,1` goes up to 6 and down to 5); a Shift step
|
||||
/// rounds to a multiple of `step` first.
|
||||
pub fn step(mut self, step: f64) -> Self {
|
||||
self.step = step;
|
||||
self
|
||||
}
|
||||
|
||||
/// The fewest and most digits shown after the decimal mark. Default `0..=6`.
|
||||
pub fn decimals(mut self, decimals: RangeInclusive<usize>) -> Self {
|
||||
self.decimals = decimals;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unit(mut self, unit: Unit) -> Self {
|
||||
self.unit = unit;
|
||||
self
|
||||
}
|
||||
|
||||
/// A parser for text that isn't a plain number (an expression, a timecode), tried after the
|
||||
/// locale's.
|
||||
pub fn or_parse(mut self, parser: impl 'a + Fn(&str) -> Option<f64>) -> Self {
|
||||
self.fallback = Some(Box::new(parser));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn desired_width(mut self, width: f32) -> Self {
|
||||
self.width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self {
|
||||
self.id_salt = Some(IdSalt::new(id_salt));
|
||||
self
|
||||
}
|
||||
|
||||
/// The value differs between the selected objects: the field is blank (showing the
|
||||
/// [`NumberField::placeholder`]) and the value stays as it is until the user enters a number.
|
||||
/// That number is `changed()` even when it equals the value, so the app sets it on every
|
||||
/// object. Committing the field blank changes nothing, and the label doesn't scrub.
|
||||
pub fn mixed(mut self, mixed: bool) -> Self {
|
||||
self.mixed = mixed;
|
||||
self
|
||||
}
|
||||
|
||||
/// Text shown in the field while it is empty, as a [`NumberField::mixed`] field is.
|
||||
pub fn placeholder(mut self, text: impl Into<String>) -> Self {
|
||||
self.placeholder = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Draw the field in a sunken box of `frame`'s fill, border and height.
|
||||
pub fn recessed(mut self, frame: Recessed) -> Self {
|
||||
self.recessed = Some(frame);
|
||||
self
|
||||
}
|
||||
|
||||
/// The field's label, drawn before it. It names the field for accessibility, and dragging it
|
||||
/// sideways scrubs the value: a step every two points, with the arrow keys' Shift and Ctrl/Cmd
|
||||
/// steps. A click on it focuses the field; Escape during the drag puts the value back. The
|
||||
/// response reports the label's drag as its own (`drag_started`, `dragged`, `drag_stopped`),
|
||||
/// so the app can make the whole drag one undo step.
|
||||
pub fn scrub_label(mut self, label: &Response) -> Self {
|
||||
self.label = Some((label.id, label.rect));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for NumberField<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let NumberField { mut get_set, numbers, range, step, decimals, unit, fallback, width, id_salt, mixed, placeholder, recessed, label } = self;
|
||||
let id = id_salt.map_or_else(|| ui.next_auto_id(), |salt| ui.make_persistent_id(salt));
|
||||
let old = get_set(None);
|
||||
let places = *decimals.end();
|
||||
let clamp = |v: f64| if range.start() <= range.end() { v.clamp(*range.start(), *range.end()) } else { v };
|
||||
let settle = |v: f64| clamp(round_half_away(v, places));
|
||||
let shown = if mixed { String::new() } else { numbers.format(old, decimals.clone()) };
|
||||
|
||||
let mut state = ui.data(|d| d.get_temp::<FieldState>(id)).filter(|s| s.value == old && s.mixed == mixed);
|
||||
let mut select = false;
|
||||
if ui.is_enabled() && !ui.is_sizing_pass() && ui.data_mut(|d| d.remove_temp::<bool>(first_field_id())).is_some() {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
select = true;
|
||||
}
|
||||
// The shown text stands for `old` itself: reading it back would round the value to the
|
||||
// shown decimals, so leaving an untouched field would change it.
|
||||
let read_text = |text: &str| if !mixed && text == shown { Ok(old) } else { read(&numbers, &unit, fallback.as_ref(), text) };
|
||||
let mut value = old;
|
||||
// A number the user entered; in a mixed field it counts as a change even if it equals `old`.
|
||||
let mut entered = false;
|
||||
if step != 0.0 && ui.memory(|m| m.has_focus(id)) {
|
||||
let typed = state.as_ref().map_or(shown.as_str(), |s| s.text.as_str());
|
||||
// Text that isn't a number takes no steps: the wheel scrolls the panel instead.
|
||||
if let Ok(base) = read_text(typed) {
|
||||
let over = ui.ctx().read_response(id).is_some_and(|r| r.contains_pointer());
|
||||
let steps = take_steps(ui, id, over);
|
||||
if !steps.is_empty() {
|
||||
value = settle(steps.iter().fold(base, |v, &(modifiers, n)| stepped(v, step, places, modifiers, n)));
|
||||
(entered, select, state) = (true, true, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut scrubbed = None;
|
||||
if let Some((_, rect)) = label.filter(|_| !mixed && step != 0.0) {
|
||||
let (drag, to) = scrub(ui, id, rect, value, |v, modifiers, n| settle(stepped(v, step, places, modifiers, n)));
|
||||
if let Some(to) = to {
|
||||
(value, state) = (to, None);
|
||||
}
|
||||
scrubbed = Some(drag);
|
||||
}
|
||||
let mut text = match &state {
|
||||
Some(s) => s.text.clone(),
|
||||
None if value == old && !entered => shown.clone(),
|
||||
None => numbers.format(value, decimals.clone()),
|
||||
};
|
||||
if select {
|
||||
select_all(ui.ctx(), id, &text);
|
||||
}
|
||||
|
||||
let mut edit = TextEdit::singleline(&mut text).id(id);
|
||||
if let Some(placeholder) = placeholder {
|
||||
edit = edit.hint_text(placeholder);
|
||||
}
|
||||
if !unit.prefix.is_empty() {
|
||||
edit = edit.prefix(unit.prefix.clone());
|
||||
}
|
||||
if !unit.suffix.is_empty() {
|
||||
edit = edit.suffix(unit.suffix.clone());
|
||||
}
|
||||
if let Some(width) = width {
|
||||
edit = edit.desired_width(width);
|
||||
}
|
||||
let radius = match recessed {
|
||||
Some(frame) => {
|
||||
let stroke = if ui.memory(|m| m.has_focus(id)) { frame.focus_stroke } else { frame.stroke };
|
||||
let sunk = Frame::NONE.fill(frame.fill).stroke(stroke).corner_radius(frame.corner_radius).inner_margin(frame.margin);
|
||||
edit = edit.frame(sunk).min_size(vec2(0.0, frame.height)).align(Align2::LEFT_CENTER);
|
||||
frame.corner_radius
|
||||
}
|
||||
None => ui.visuals().widgets.inactive.corner_radius,
|
||||
};
|
||||
let mut response = ui.add(edit);
|
||||
// After the text edit, which gives up the focus on a click elsewhere.
|
||||
if scrubbed.as_ref().is_some_and(Response::clicked) {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
select_all(ui.ctx(), id, &text);
|
||||
}
|
||||
let editing = response.has_focus();
|
||||
|
||||
let escape = ui.input(|i| i.key_pressed(Key::Escape));
|
||||
let mut error = state.as_ref().and_then(|s| s.error);
|
||||
if response.lost_focus() && (escape || mixed && unit.strip(&text).is_empty()) {
|
||||
state = None;
|
||||
error = None;
|
||||
} else if response.lost_focus() {
|
||||
match read_text(&text) {
|
||||
Ok(parsed) => {
|
||||
value = if !mixed && text == shown { parsed } else { settle(parsed) };
|
||||
entered = true;
|
||||
state = None;
|
||||
error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
error = Some(e);
|
||||
state = Some(FieldState { text: text.clone(), value, mixed, error });
|
||||
}
|
||||
}
|
||||
} else if response.changed() {
|
||||
error = read(&numbers, &unit, fallback.as_ref(), &text).err().filter(|e| !incomplete(&text, *e));
|
||||
state = Some(FieldState { text: text.clone(), value, mixed, error });
|
||||
} else if response.has_focus() && state.is_none() {
|
||||
state = Some(FieldState { text: text.clone(), value, mixed, error: None });
|
||||
}
|
||||
|
||||
if value != old || mixed && entered {
|
||||
get_set(Some(value));
|
||||
response.mark_changed();
|
||||
} else {
|
||||
response.flags.remove(Flags::CHANGED);
|
||||
}
|
||||
if let Some(drag) = scrubbed {
|
||||
response.flags.insert(drag.flags & (Flags::DRAG_STARTED | Flags::DRAGGED | Flags::DRAG_STOPPED));
|
||||
}
|
||||
if let Some((label, _)) = label {
|
||||
response = response.labelled_by(label);
|
||||
}
|
||||
let value = get_set(None);
|
||||
match state {
|
||||
Some(mut s) => {
|
||||
s.value = value;
|
||||
ui.data_mut(|d| d.insert_temp(id, s));
|
||||
}
|
||||
None => ui.data_mut(|d| d.remove::<FieldState>(id)),
|
||||
}
|
||||
if let Some(error) = error {
|
||||
mark_invalid(ui, &response, radius, numbers.describe(error), editing);
|
||||
if !editing {
|
||||
response = response.on_hover_text(numbers.describe(error));
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` moved `n` steps of `step` (down when `n` is negative) with `modifiers` held: ten steps
|
||||
/// with Shift, a tenth of one with Ctrl/Cmd when `decimals` show it. A plain or Ctrl/Cmd step
|
||||
/// goes to the next multiple of its size in its direction, then on by whole steps; a Shift step
|
||||
/// rounds to a multiple of `step` first.
|
||||
fn stepped(value: f64, step: f64, decimals: usize, modifiers: Modifiers, n: f64) -> f64 {
|
||||
let fine = step / 10.0;
|
||||
if modifiers.command && (round_half_away(fine, decimals) - fine).abs() <= fine.abs() * 1e-9 {
|
||||
to_multiple(value, fine, n)
|
||||
} else if modifiers.shift {
|
||||
(value / step).round() * step + 10.0 * step * n
|
||||
} else {
|
||||
to_multiple(value, step, n)
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` moved to the `n`th multiple of `size` past it: by 1, 5.1 goes up to 6 and down to 5,
|
||||
/// and 5 to 6 and 4. A value within a millionth of a step of a multiple counts as on it.
|
||||
fn to_multiple(value: f64, size: f64, n: f64) -> f64 {
|
||||
let q = value / size;
|
||||
let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() };
|
||||
(from + n) * size
|
||||
}
|
||||
|
||||
/// The steps asked for this frame by the arrow keys in a focused field and, with `wheel`, by the
|
||||
/// mouse wheel over it, in order: their modifiers and how many steps up (down when negative).
|
||||
/// They are taken from the input, so the text edit doesn't move its cursor and the panel under the
|
||||
/// field doesn't scroll.
|
||||
fn take_steps(ui: &Ui, id: Id, wheel: bool) -> Vec<(Modifiers, f64)> {
|
||||
// The wheel turns in notches: a line each, or `line` points of a trackpad. What is left of a
|
||||
// notch carries over to the next turn.
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed).max(1.0);
|
||||
let notch_id = id.with("craft_ui::localized::notches");
|
||||
let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 };
|
||||
let mut steps = Vec::new();
|
||||
ui.input_mut(|i| {
|
||||
i.events.retain(|event| {
|
||||
let (modifiers, n) = match event {
|
||||
Event::Key { key: Key::ArrowUp, pressed: true, modifiers, .. } => (*modifiers, 1.0),
|
||||
Event::Key { key: Key::ArrowDown, pressed: true, modifiers, .. } => (*modifiers, -1.0),
|
||||
Event::MouseWheel { unit, delta, modifiers, .. } if wheel => {
|
||||
// Shift may turn the wheel sideways (macOS does).
|
||||
let turn = if modifiers.shift { delta.x + delta.y } else { delta.y };
|
||||
notches += match unit {
|
||||
MouseWheelUnit::Point => turn / line,
|
||||
MouseWheelUnit::Line | MouseWheelUnit::Page => turn,
|
||||
};
|
||||
let whole = notches.trunc();
|
||||
notches -= whole;
|
||||
(*modifiers, f64::from(whole))
|
||||
}
|
||||
_ => return true,
|
||||
};
|
||||
if n != 0.0 {
|
||||
steps.push((modifiers, n));
|
||||
}
|
||||
false
|
||||
});
|
||||
if wheel {
|
||||
i.smooth_scroll_delta = Vec2::ZERO;
|
||||
}
|
||||
});
|
||||
if wheel {
|
||||
ui.data_mut(|d| d.insert_temp(notch_id, notches));
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// A drag this frame on `label`, the label of field `id`: its response, and the value it scrubs
|
||||
/// `value` to with `step` (value, modifiers, steps).
|
||||
fn scrub(ui: &Ui, id: Id, label: Rect, value: f64, step: impl Fn(f64, Modifiers, f64) -> f64) -> (Response, Option<f64>) {
|
||||
// On top of the label, which may sense clicks itself (selectable text).
|
||||
let drag = ui.interact(label, id.with("craft_ui::localized::scrub"), Sense::click_and_drag());
|
||||
if drag.hovered() || drag.dragged() {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
let key = drag.id;
|
||||
if drag.drag_started() {
|
||||
// Typing in the field ends, and its text is committed, before the first step.
|
||||
ui.memory_mut(|m| m.surrender_focus(id));
|
||||
// The drag starts once the pointer has moved a few points: those count too.
|
||||
let moved = match (ui.input(|i| i.pointer.press_origin()), drag.interact_pointer_pos()) {
|
||||
(Some(from), Some(to)) => to.x - from.x,
|
||||
_ => drag.drag_delta().x,
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(key, Scrub { start: None, pending: moved }));
|
||||
return (drag, None);
|
||||
}
|
||||
let Some(mut s) = ui.data(|d| d.get_temp::<Scrub>(key)) else { return (drag, None) };
|
||||
if !drag.dragged() {
|
||||
ui.data_mut(|d| d.remove::<Scrub>(key));
|
||||
// egui ends a drag on Escape: the value goes back.
|
||||
let cancelled = drag.drag_stopped() && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape));
|
||||
return (drag, s.start.filter(|_| cancelled));
|
||||
}
|
||||
s.pending += drag.drag_delta().x;
|
||||
let n = (s.pending / SCRUB_POINTS_PER_STEP).trunc();
|
||||
let mut to = None;
|
||||
if n != 0.0 {
|
||||
s.pending -= n * SCRUB_POINTS_PER_STEP;
|
||||
s.start.get_or_insert(value);
|
||||
to = Some(step(value, ui.input(|i| i.modifiers), f64::from(n)));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, s));
|
||||
(drag, to)
|
||||
}
|
||||
|
||||
/// Selects all of `text` in text edit `id`, so typing replaces it.
|
||||
fn select_all(ctx: &Context, id: Id, text: &str) {
|
||||
let mut state = TextEdit::load_state(ctx, id).unwrap_or_default();
|
||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(0), CCursor::new(text.chars().count()))));
|
||||
state.store(ctx, id);
|
||||
}
|
||||
|
||||
/// `value` rounded to `decimals` digits after the point, ties away from zero; never `-0`.
|
||||
fn round_half_away(value: f64, decimals: usize) -> f64 {
|
||||
let scale = 10_f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX));
|
||||
let rounded = if scale.is_finite() { (value * scale).round() / scale } else { value };
|
||||
if rounded.is_finite() { rounded + 0.0 } else { value }
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{DragValue, Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_and_reads_the_regions_numbers() {
|
||||
let de = LocaleNumbers::from_tag("de");
|
||||
assert_eq!(de.format(1.5, 0..=6), "1,5");
|
||||
assert_eq!(de.format(1234567.0, 0..=2), "1.234.567");
|
||||
assert_eq!(de.format(2.0, 2..=4), "2,00");
|
||||
assert_eq!(de.format(-0.001, 0..=2), "0");
|
||||
assert_eq!(de.parse("1,5"), Ok(1.5));
|
||||
assert_eq!(de.parse("1.234.567"), Ok(1234567.0));
|
||||
assert!(de.parse("1.234.").is_err_and(|e| incomplete("1.234.", e)), "a grouping mark still to be followed by digits");
|
||||
assert_eq!(de.parse("1.5"), Ok(1.5), "a lone point that can't be grouping");
|
||||
|
||||
let en = LocaleNumbers::from_tag("en-US");
|
||||
assert_eq!(en.format(1234567.25, 0..=2), "1,234,567.25");
|
||||
assert_eq!(en.parse("1,234,567.25"), Ok(1234567.25));
|
||||
assert_eq!(en.parse("12,345.5"), Ok(12345.5), "full-width digits and marks");
|
||||
assert_eq!(en.parse("1,5,5"), Err(NumberError::Separator));
|
||||
|
||||
let ar = LocaleNumbers::from_tag("ar-EG");
|
||||
assert_eq!(ar.format(1234.5, 1..=1), "١٬٢٣٤٫٥");
|
||||
assert_eq!(ar.parse("١٬٢٣٤٫٥"), Ok(1234.5));
|
||||
assert_eq!(ar.parse(&ar.format(-12.5, 0..=2)), Ok(-12.5), "the sign and bidi marks read back");
|
||||
assert_eq!(ar.parse("12.5"), Ok(12.5));
|
||||
|
||||
let fa = LocaleNumbers::from_tag("fa-IR");
|
||||
assert_eq!(fa.format(1.5, 0..=2), "۱٫۵");
|
||||
assert_eq!(fa.parse("۱٫۵"), Ok(1.5));
|
||||
assert_eq!(fa.parse("1۵"), Err(NumberError::MixedDigits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_reads_back_its_own_decimal_mark() {
|
||||
// English in the US with a decimal comma and no grouping from the Windows settings.
|
||||
let mut region = craft_i18n::Region::from_tag("en-US");
|
||||
region.decimal = Some(',');
|
||||
let numbers = LocaleNumbers::new(Arc::new(Format::new(®ion, craft_i18n::Digits::Locale)));
|
||||
let written = numbers.format(1234.5, 1..=1);
|
||||
assert_eq!((written.as_str(), numbers.parse(&written)), ("1.234,5", Ok(1234.5)));
|
||||
assert_eq!(numbers.parse("1.234"), Ok(1234.0), "the grouping moved to the point");
|
||||
region.grouping = Some(None);
|
||||
let numbers = LocaleNumbers::new(Arc::new(Format::new(®ion, craft_i18n::Digits::Locale)));
|
||||
assert_eq!(numbers.format(1234.5, 1..=1), "1234,5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_wrap_and_strip() {
|
||||
let unit = Unit::new("%{n}");
|
||||
assert_eq!((unit.prefix(), unit.suffix()), ("%", ""));
|
||||
let px = Unit::new("{n} px");
|
||||
assert_eq!(px.strip(" 12,5 px "), "12,5");
|
||||
assert_eq!(px.strip("12,5"), "12,5");
|
||||
let (i18n, _) = I18n::new(&[("fr", "unit\t{n} px\t{n} pt-écran\n", "")], &["fr"]);
|
||||
let fr = i18n.from_code("fr").unwrap_or(Lang::EN);
|
||||
assert_eq!(Unit::translated(&i18n, fr, "{n} px").suffix(), " pt-écran");
|
||||
assert_eq!(Unit::translated(&i18n, Lang::EN, "{n} px").suffix(), " px");
|
||||
}
|
||||
|
||||
fn drag_harness(tag: &str, value: f64) -> Harness<'static, f64> {
|
||||
let numbers = LocaleNumbers::from_tag(tag);
|
||||
drag_harness_with(value, move |ui, value| LocalizedDragValue::new(DragValue::new(value).speed(0.1), &numbers).ui(ui))
|
||||
}
|
||||
|
||||
fn drag_harness_with(value: f64, add: impl FnMut(&mut Ui, &mut f64) -> Response + 'static) -> Harness<'static, f64> {
|
||||
let mut add = add;
|
||||
// The default fonts have no Arabic or Persian digits; the fonts module adds them in apps.
|
||||
let mut h = Harness::builder().allow_missing_glyphs().build_ui_state(
|
||||
move |ui, value: &mut f64| {
|
||||
let label = ui.label("Width");
|
||||
add(ui, value).labelled_by(label.id);
|
||||
},
|
||||
value,
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
/// Clicks the drag value into text editing, types `text` over its selected text and presses Enter.
|
||||
fn type_into_drag(h: &mut Harness<'_, f64>, text: &str) {
|
||||
h.get_by_role(Role::SpinButton).click();
|
||||
h.run();
|
||||
h.get_by_role(Role::SpinButton).type_text(text);
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_value_shows_and_takes_german_decimals() {
|
||||
let mut h = drag_harness("de", 1.5);
|
||||
assert_eq!(h.get_by_role(Role::SpinButton).value().as_deref(), Some("1,5"));
|
||||
type_into_drag(&mut h, "2,25");
|
||||
assert_eq!(*h.state(), 2.25);
|
||||
assert_eq!(h.get_by_role(Role::SpinButton).value().as_deref(), Some("2,25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_value_in_arabic_digits() {
|
||||
let mut h = drag_harness("ar-EG", 1.5);
|
||||
assert_eq!(h.get_by_role(Role::SpinButton).value().as_deref(), Some("١٫٥"));
|
||||
type_into_drag(&mut h, "٢٬٥٠٠٫٥");
|
||||
assert_eq!(*h.state(), 2500.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_value_keeps_the_apps_text_around_the_number() {
|
||||
let numbers = LocaleNumbers::from_tag("de");
|
||||
let mut h = drag_harness_with(0.0, move |ui, value| {
|
||||
LocalizedDragValue::new(DragValue::new(value).prefix("x: ").suffix(" °"), &numbers)
|
||||
.or_format(|v, _| (v == 0.0).then(|| "Auto".to_owned()))
|
||||
.or_parse(|t| (t.trim() == "Auto").then_some(0.0))
|
||||
.ui(ui)
|
||||
});
|
||||
assert_eq!(h.get_by_role(Role::SpinButton).value().as_deref(), Some("x: Auto °"));
|
||||
type_into_drag(&mut h, "2,5");
|
||||
assert_eq!(*h.state(), 2.5);
|
||||
assert_eq!(h.get_by_role(Role::SpinButton).value().as_deref(), Some("x: 2,5 °"));
|
||||
|
||||
let numbers = LocaleNumbers::from_tag("de");
|
||||
let h = drag_harness_with(1.5, move |ui, value| {
|
||||
LocalizedDragValue::new(DragValue::new(value).prefix("x: ").suffix(" °"), &numbers).unit(Unit::new("{n} px")).ui(ui)
|
||||
});
|
||||
let shown = h.get_by_role(Role::SpinButton).value().unwrap_or_default();
|
||||
assert!(shown.contains("x: 1,5 °") && shown.trim_end().ends_with("px"), "a unit goes outside them: {shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_value_takes_range_speed_and_no_grouping_after_wrapping() {
|
||||
let numbers = LocaleNumbers::from_tag("en-US");
|
||||
let mut h = drag_harness_with(1920.0, move |ui, value| {
|
||||
LocalizedDragValue::new(DragValue::new(value), &numbers).range(1..=4096).speed(1.0).ungrouped().unit(Unit::new("{n} px")).ui(ui)
|
||||
});
|
||||
let shown = h.get_by_role(Role::SpinButton).value().unwrap_or_default();
|
||||
assert!(shown.starts_with("1920"), "{shown:?}");
|
||||
type_into_drag(&mut h, "10,000");
|
||||
assert_eq!(*h.state(), 4096.0, "grouped text reads back and is clamped to the range");
|
||||
assert_eq!(LocaleNumbers::from_tag("de").ungrouped().format(1234567.5, 0..=1), "1234567,5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_value_marks_text_it_cannot_read() {
|
||||
let mut h = drag_harness("de", 1.5);
|
||||
h.get_by_role(Role::SpinButton).click();
|
||||
h.run();
|
||||
h.get_by_role(Role::SpinButton).type_text("1x");
|
||||
h.run();
|
||||
assert!(h.query_by_label("Not a number").is_some(), "marked while editing");
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 1.5, "the value stays");
|
||||
assert!(h.query_by_label("Not a number").is_some(), "still marked after reverting");
|
||||
assert_eq!(h.get_by_role(Role::SpinButton).value().as_deref(), Some("1,5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_value_does_not_mark_a_number_being_typed() {
|
||||
let mut h = drag_harness("de", 1.5);
|
||||
h.get_by_role(Role::SpinButton).click();
|
||||
h.run();
|
||||
h.get_by_role(Role::SpinButton).type_text("1.");
|
||||
h.run();
|
||||
assert!(h.query_by_label("Check the decimal and grouping marks").is_none());
|
||||
}
|
||||
|
||||
fn field_harness(tag: &str, value: f64) -> Harness<'static, f64> {
|
||||
let numbers = LocaleNumbers::from_tag(tag);
|
||||
let mut h = Harness::builder().allow_missing_glyphs().build_ui_state(
|
||||
move |ui, value: &mut f64| {
|
||||
let label = ui.label("Gap");
|
||||
ui.add(NumberField::new(value, &numbers).range(0.0..=10000.0).step(0.5).unit(Unit::new("{n} mm"))).labelled_by(label.id);
|
||||
},
|
||||
value,
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
fn type_into_field(h: &mut Harness<'_, f64>, text: &str) {
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text(text);
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_reads_on_commit() {
|
||||
let mut h = field_harness("de", 1.5);
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("1,5"));
|
||||
type_into_field(&mut h, "1.234,5 mm");
|
||||
assert_eq!(*h.state(), 1234.5);
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("1.234,5"));
|
||||
type_into_field(&mut h, "20000");
|
||||
assert_eq!(*h.state(), 10000.0, "clamped to the range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_takes_full_width_and_persian_digits() {
|
||||
let mut h = field_harness("en", 1.0);
|
||||
type_into_field(&mut h, "12.5");
|
||||
assert_eq!(*h.state(), 12.5);
|
||||
let mut h = field_harness("fa", 1.0);
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("۱"));
|
||||
type_into_field(&mut h, "۱۲٫۵");
|
||||
assert_eq!(*h.state(), 12.5);
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("۱۲٫۵"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_keeps_text_it_cannot_read() {
|
||||
let mut h = field_harness("de", 1.5);
|
||||
type_into_field(&mut h, "1,5,5");
|
||||
assert_eq!(*h.state(), 1.5, "the value stays");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("1,5,5"), "the text stays to be fixed");
|
||||
type_into_field(&mut h, "2,5");
|
||||
assert_eq!(*h.state(), 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_rounds_before_clamping() {
|
||||
let mut h = field_harness("de", 1.5);
|
||||
type_into_field(&mut h, "9999,99999999");
|
||||
assert_eq!(*h.state(), 10000.0, "rounded to six decimals, then clamped");
|
||||
let numbers = LocaleNumbers::from_tag("de");
|
||||
let mut h = Harness::builder().build_ui_state(
|
||||
move |ui, value: &mut i32| {
|
||||
let label = ui.label("Count");
|
||||
ui.add(NumberField::new(value, &numbers).range(-10.0..=10.0)).labelled_by(label.id);
|
||||
},
|
||||
0,
|
||||
);
|
||||
h.run();
|
||||
for (text, want) in [("2,7", 3), ("2,5", 3), ("-0,5", -1), ("-0,4", 0), ("9,6", 10), ("10,5", 10)] {
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text(text);
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), want, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_keeps_a_value_it_shows_rounded_until_it_is_edited() {
|
||||
let mut h = field_harness("en", 1.23456789);
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("1.234568"));
|
||||
type_into_field(&mut h, "1.234568");
|
||||
assert_eq!(*h.state(), 1.23456789, "the text it shows, typed back");
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text("9");
|
||||
h.run();
|
||||
h.key_press(Key::Escape);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 1.23456789, "Escape");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("1.234568"));
|
||||
type_into_field(&mut h, "1.2345678");
|
||||
assert_eq!(*h.state(), 1.234568, "an edit is rounded to the decimals shown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_go_to_multiples_of_the_step() {
|
||||
let (none, shift, cmd) = (Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND);
|
||||
assert_eq!(stepped(5.1, 1.0, 6, none, 1.0), 6.0);
|
||||
assert_eq!(stepped(5.1, 1.0, 6, none, -1.0), 5.0);
|
||||
assert_eq!(stepped(5.0, 1.0, 6, none, -2.0), 3.0);
|
||||
assert_eq!(stepped(5.4, 1.0, 6, shift, 1.0), 15.0, "Shift rounds to the step, then adds ten");
|
||||
assert_eq!(round_half_away(stepped(1.23, 1.0, 2, cmd, 1.0), 2), 1.3, "a tenth with Ctrl/Cmd");
|
||||
assert_eq!(stepped(5.1, 1.0, 0, cmd, 1.0), 6.0, "a count has no tenths");
|
||||
assert_eq!(stepped(4.9999999, 1.0, 6, none, 1.0), 6.0, "within a millionth of a multiple is on it");
|
||||
}
|
||||
|
||||
fn field_with(value: f64, add: impl FnMut(&mut Ui, &mut f64) -> Response + 'static) -> Harness<'static, f64> {
|
||||
let mut add = add;
|
||||
let mut h = Harness::builder().allow_missing_glyphs().build_ui_state(
|
||||
move |ui, value: &mut f64| {
|
||||
let label = ui.label("Gap");
|
||||
add(ui, value).labelled_by(label.id);
|
||||
},
|
||||
value,
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_steps_with_arrow_keys_and_the_wheel() {
|
||||
let mut h = field_harness("de", 1.3);
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.key_press(Key::ArrowUp);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 1.5, "to the next multiple of 0,5");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("1,5"));
|
||||
h.key_press(Key::ArrowUp);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 2.0);
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 0.0, "ten steps down, clamped");
|
||||
h.get_by_role(Role::TextInput).type_text("4");
|
||||
h.run();
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("4"), "the stepped text is selected, so typing replaces it");
|
||||
let at = h.get_by_role(Role::TextInput).rect().center();
|
||||
h.hover_at(at);
|
||||
h.run();
|
||||
let wheel = |y| egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, y),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
modifiers: Modifiers::NONE,
|
||||
};
|
||||
h.event(wheel(2.0));
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 5.0, "two notches of 0,5 up from the typed 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_field_draws_a_recessed_box() {
|
||||
let numbers = LocaleNumbers::from_tag("en");
|
||||
let mut h = field_with(1.0, move |ui, value| {
|
||||
let frame = Recessed { height: 30.0, ..Recessed::from_style(ui.style()) };
|
||||
ui.add(NumberField::new(value, &numbers).recessed(frame).desired_width(80.0))
|
||||
});
|
||||
let rect = h.get_by_role(Role::TextInput).rect();
|
||||
assert!((rect.height() - 30.0).abs() < 0.5, "{rect:?}");
|
||||
type_into_field(&mut h, "2.5");
|
||||
assert_eq!(*h.state(), 2.5);
|
||||
}
|
||||
|
||||
fn scrub_harness(value: f64) -> Harness<'static, f64> {
|
||||
let numbers = LocaleNumbers::from_tag("en");
|
||||
let mut h = Harness::builder().build_ui_state(
|
||||
move |ui, value: &mut f64| {
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label("Gap");
|
||||
ui.add(NumberField::new(value, &numbers).range(0.0..=100.0).scrub_label(&label));
|
||||
});
|
||||
},
|
||||
value,
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_label_scrubs_the_field() {
|
||||
let mut h = scrub_harness(5.4);
|
||||
let at = h.get_by_role(Role::Label).rect().center();
|
||||
let drag = |h: &mut Harness<'_, f64>, by: &[f32]| {
|
||||
h.hover_at(at);
|
||||
h.run();
|
||||
h.drag_at(at);
|
||||
h.run();
|
||||
for dx in by {
|
||||
h.hover_at(at + egui::vec2(*dx, 0.0));
|
||||
h.run();
|
||||
}
|
||||
};
|
||||
drag(&mut h, &[20.0, 40.0]);
|
||||
h.drop_at(at + egui::vec2(40.0, 0.0));
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 25.0, "20 steps of 2 points from 5,4: the first to 6");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("25"));
|
||||
|
||||
drag(&mut h, &[20.0, -20.0]);
|
||||
assert_eq!(*h.state(), 15.0);
|
||||
h.key_press(Key::Escape);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 25.0, "Escape puts the value back");
|
||||
h.drop_at(at);
|
||||
h.run();
|
||||
|
||||
h.drag_at(at);
|
||||
h.run();
|
||||
h.drop_at(at);
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text("7");
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 7.0, "a click on the label focuses the field with its text selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_label_keeps_a_value_it_shows_rounded() {
|
||||
let mut h = scrub_harness(1.23456789);
|
||||
let at = h.get_by_role(Role::Label).rect().center();
|
||||
h.drag_at(at);
|
||||
h.run();
|
||||
h.drop_at(at);
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 1.23456789);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mixed {
|
||||
value: f64,
|
||||
mixed: bool,
|
||||
changes: usize,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_field_is_blank_until_a_number_is_entered() {
|
||||
let numbers = LocaleNumbers::from_tag("en");
|
||||
let mut h = Harness::builder().build_ui_state(
|
||||
move |ui, m: &mut Mixed| {
|
||||
let label = ui.label("Gap");
|
||||
let r = ui.add(NumberField::new(&mut m.value, &numbers).mixed(m.mixed).placeholder("Mixed")).labelled_by(label.id);
|
||||
if r.changed() {
|
||||
(m.changes, m.mixed) = (m.changes + 1, false);
|
||||
}
|
||||
},
|
||||
Mixed { value: 3.0, mixed: true, changes: 0 },
|
||||
);
|
||||
h.run();
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some(""));
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.key_press(Key::ArrowUp);
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
assert_eq!((h.state().value, h.state().changes), (3.0, 0), "blank commits and arrow keys change nothing");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some(""));
|
||||
type_into_mixed(&mut h, "3");
|
||||
assert_eq!((h.state().value, h.state().changes), (3.0, 1), "the value it had is still a change: it goes to every object");
|
||||
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("3"));
|
||||
}
|
||||
|
||||
fn type_into_mixed(h: &mut Harness<'_, Mixed>, text: &str) {
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text(text);
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_field_of_a_group_takes_the_focus() {
|
||||
let numbers = LocaleNumbers::from_tag("en");
|
||||
let mut opened = false;
|
||||
let mut h = Harness::builder().build_ui_state(
|
||||
move |ui, values: &mut [f64; 2]| {
|
||||
if !opened {
|
||||
focus_first_field(ui.ctx(), true);
|
||||
opened = true;
|
||||
}
|
||||
for (value, name) in values.iter_mut().zip(["Width", "Height"]) {
|
||||
let label = ui.label(name);
|
||||
ui.add(NumberField::new(value, &numbers)).labelled_by(label.id);
|
||||
}
|
||||
},
|
||||
[12.5, 40.0],
|
||||
);
|
||||
h.run();
|
||||
h.event(egui::Event::Text("7".to_owned()));
|
||||
h.run();
|
||||
h.key_press(Key::Enter);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), [7.0, 40.0], "the first field was focused with its text selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_tooltips_come_from_the_catalog() {
|
||||
let (i18n, _) = I18n::new(&[("fr", "\tNot a number\tPas un nombre\n", "")], &["fr"]);
|
||||
let fr = i18n.from_code("fr").unwrap_or(Lang::EN);
|
||||
let numbers = LocaleNumbers::from_tag("fr").with_catalog(&i18n, fr);
|
||||
assert_eq!(numbers.describe(NumberError::Unexpected('x')), "Pas un nombre");
|
||||
assert_eq!(numbers.describe(NumberError::TooLarge), "Number too large", "English where the row is missing");
|
||||
}
|
||||
|
||||
const PRECISE: f64 = 1.234_567_89;
|
||||
|
||||
/// A harness with the widget `add` makes and a button after it to take the focus.
|
||||
fn with_other(add: impl Fn(&mut Ui, &mut f64) -> Response + 'static) -> Harness<'static, f64> {
|
||||
let mut h = Harness::builder().allow_missing_glyphs().build_ui_state(
|
||||
move |ui, value: &mut f64| {
|
||||
let label = ui.label("Width");
|
||||
add(ui, value).labelled_by(label.id);
|
||||
let _ = ui.button("Other");
|
||||
},
|
||||
PRECISE,
|
||||
);
|
||||
h.run();
|
||||
h
|
||||
}
|
||||
|
||||
/// Focuses the widget of `role` and leaves it without an edit in each way the user can: a click
|
||||
/// elsewhere, Enter and Tab. The value must stay exactly as it was.
|
||||
fn leaving_untouched_keeps_the_value(role: Role, add: impl Fn(&mut Ui, &mut f64) -> Response + Clone + 'static) {
|
||||
let mut changed = Vec::new();
|
||||
for leave in ["click elsewhere", "Enter", "Tab"] {
|
||||
let mut h = with_other(add.clone());
|
||||
h.get_by_role(role).click();
|
||||
h.run();
|
||||
assert!(h.get_by_role(role).value().is_some_and(|v| v.contains("1,234568")), "shown rounded");
|
||||
match leave {
|
||||
"Enter" => h.key_press(Key::Enter),
|
||||
"Tab" => h.key_press(Key::Tab),
|
||||
_ => h.get_by_label("Other").click(),
|
||||
}
|
||||
h.run();
|
||||
h.run();
|
||||
assert!(!h.get_by_role(role).is_focused(), "{leave} leaves the field");
|
||||
if h.state().to_bits() != PRECISE.to_bits() {
|
||||
changed.push((leave, *h.state()));
|
||||
}
|
||||
}
|
||||
assert!(changed.is_empty(), "{changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_an_unedited_number_field_keeps_its_value() {
|
||||
let numbers = LocaleNumbers::from_tag("de");
|
||||
let add = move |ui: &mut Ui, value: &mut f64| ui.add(NumberField::new(value, &numbers));
|
||||
leaving_untouched_keeps_the_value(Role::TextInput, add.clone());
|
||||
|
||||
let mut h = with_other(add.clone());
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text("2,5");
|
||||
h.run();
|
||||
h.key_press(Key::Tab);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 2.5, "an edit commits");
|
||||
|
||||
let mut h = with_other(add);
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.key_press(Key::ArrowUp);
|
||||
h.run();
|
||||
assert_eq!(*h.state(), 2.0, "a step commits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_an_unedited_drag_value_keeps_its_value() {
|
||||
let numbers = LocaleNumbers::from_tag("de");
|
||||
let add = move |ui: &mut Ui, value: &mut f64| ui.add(LocalizedDragValue::new(DragValue::new(value).speed(0.1).max_decimals(6), &numbers));
|
||||
leaving_untouched_keeps_the_value(Role::SpinButton, add.clone());
|
||||
|
||||
let mut h = with_other(add.clone());
|
||||
type_into_drag(&mut h, "2,5");
|
||||
assert_eq!(*h.state(), 2.5, "an edit commits");
|
||||
|
||||
let mut h = with_other(add);
|
||||
h.get_by_role(Role::SpinButton).click();
|
||||
h.run();
|
||||
h.key_press(Key::ArrowUp);
|
||||
h.run();
|
||||
assert_ne!(*h.state(), PRECISE, "a step commits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_scrolls_past_a_field_it_cannot_step() {
|
||||
let numbers = LocaleNumbers::from_tag("de");
|
||||
let mut h = with_other(move |ui, value| ui.add(NumberField::new(value, &numbers)));
|
||||
h.get_by_role(Role::TextInput).click();
|
||||
h.run();
|
||||
h.get_by_role(Role::TextInput).type_text("1x");
|
||||
h.run();
|
||||
let at = h.get_by_role(Role::TextInput).rect().center();
|
||||
h.hover_at(at);
|
||||
h.run();
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, 1.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
let mut wheel_left = false;
|
||||
h.step();
|
||||
h.ctx.input(|i| wheel_left = i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })));
|
||||
assert!(wheel_left, "the wheel event stays for the panel under the field");
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Screenshot and input smoke tests of an app's screens in the suite's test languages
|
||||
//! (`screen-testing` feature).
|
||||
//!
|
||||
//! [`Screens`] renders a UI closure with egui_kittest once per language in [`LANGUAGES`]: Arabic,
|
||||
//! Japanese, Traditional Chinese (Taiwan), Korean, Thai and Vietnamese. Each run installs the
|
||||
//! app's font stack for that UI language with no system fonts, so a snapshot looks the same on
|
||||
//! every machine and a character no face draws fails the test (kittest panics on a missing glyph).
|
||||
//! A right-to-left language lays the screen out from the right. [`compose`] types through an IME
|
||||
//! composition, as a Korean, Japanese or Chinese input method sends it.
|
||||
//!
|
||||
//! The app's closure gets the [`Locale`] and picks its strings and settings from it; the
|
||||
//! harness's language is not visible to the app otherwise.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use craft_ui::testing::{KOREAN, Screens, compose};
|
||||
//! use egui_kittest::kittest::Queryable as _;
|
||||
//!
|
||||
//! # const FACES: &[craft_ui::fonts::CraftFace] = &[];
|
||||
//! let screens = Screens::new("rename").size([320.0, 160.0]).fonts(|fonts| fonts.craft_faces(FACES));
|
||||
//! // Compares tests/snapshots/rename-ar.png, rename-ja.png, … (UPDATE_SNAPSHOTS=1 writes them).
|
||||
//! screens.snapshots(|ui, locale| {
|
||||
//! ui.label(locale.language);
|
||||
//! });
|
||||
//!
|
||||
//! let mut harness = screens.harness_state("ko", |ui, _, name: &mut String| {
|
||||
//! ui.text_edit_singleline(name);
|
||||
//! }, String::new());
|
||||
//! harness.get_by_role(egui::accesskit::Role::TextInput).focus();
|
||||
//! compose(&mut harness, &KOREAN);
|
||||
//! assert_eq!(harness.state(), "한");
|
||||
//! ```
|
||||
//!
|
||||
//! # Shortcut smoke test
|
||||
//!
|
||||
//! craft-ui doesn't depend on craft-keymap, so this one is a recipe. Send the key event egui
|
||||
//! reports for a press on a non-Latin layout and check the app ran the command the press means
|
||||
//! by craft-keymap's layout rule. On Hebrew, the key at the Q position types `/`; with Ctrl held
|
||||
//! egui reports `Slash` at physical `Q`, and the rule reads it as Ctrl+Q once the layout is known
|
||||
//! not to be Latin (`Layout::with_latin(false)`, or after the app has seen Hebrew typed):
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let press = egui::Event::Key {
|
||||
//! key: egui::Key::Slash,
|
||||
//! physical_key: Some(egui::Key::Q),
|
||||
//! pressed: true,
|
||||
//! repeat: false,
|
||||
//! modifiers: egui::Modifiers::COMMAND,
|
||||
//! };
|
||||
//! let mut harness = screens.harness_state("ar", app_ui, app);
|
||||
//! harness.event(press);
|
||||
//! harness.run();
|
||||
//! assert_eq!(harness.state().last_command(), Some(Command::Quit));
|
||||
//! ```
|
||||
//!
|
||||
//! On Russian, the `с` key reports `C` at physical `C` and must run Ctrl+C. The app's dispatcher
|
||||
//! (`craft_keymap::egui::Dispatcher`) turns the event into a `KeyInput` with
|
||||
//! `KeyInput::from_egui`.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_i18n::Direction;
|
||||
use egui::{Align, Context, Event, ImeEvent, Layout, Ui, Vec2};
|
||||
use egui_kittest::{Harness, SnapshotResults};
|
||||
|
||||
use crate::fonts::{I18nScripts, SystemFonts, UiFonts};
|
||||
|
||||
/// The UI languages every app's key screens are tested in: one per kind of script layout
|
||||
/// (right to left with joining, Han with kana, Traditional Han, Hangul, Thai marks without word
|
||||
/// spaces, Latin with stacked diacritics).
|
||||
pub const LANGUAGES: [&str; 6] = ["ar", "ja", "zh-TW", "ko", "th", "vi"];
|
||||
|
||||
/// The language a screen is drawn in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Locale {
|
||||
/// Language tag, as in [`LANGUAGES`].
|
||||
pub language: &'static str,
|
||||
pub direction: Direction,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
pub fn new(language: &'static str) -> Self {
|
||||
Self { language, direction: craft_i18n::direction(language) }
|
||||
}
|
||||
}
|
||||
|
||||
type Fonts = Rc<dyn Fn(UiFonts<'static>) -> UiFonts<'static>>;
|
||||
type Setup = Rc<dyn Fn(&Context, &Locale)>;
|
||||
|
||||
/// One screen's tests across [`LANGUAGES`]. Build it, then take a [`Self::harness`] per language
|
||||
/// or compare [`Self::snapshots`].
|
||||
#[derive(Clone)]
|
||||
pub struct Screens {
|
||||
name: String,
|
||||
size: Vec2,
|
||||
languages: Vec<&'static str>,
|
||||
fonts: Fonts,
|
||||
setup: Option<Setup>,
|
||||
}
|
||||
|
||||
impl Screens {
|
||||
/// `name` prefixes the snapshot files: `{name}-{language}.png`.
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self { name: name.to_owned(), size: Vec2::new(400.0, 300.0), languages: LANGUAGES.to_vec(), fonts: Rc::new(|fonts| fonts), setup: None }
|
||||
}
|
||||
|
||||
/// The harness size in points (default 400 × 300).
|
||||
pub fn size(mut self, size: impl Into<Vec2>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Languages to test instead of [`LANGUAGES`].
|
||||
pub fn languages(mut self, languages: &[&'static str]) -> Self {
|
||||
self.languages = languages.to_vec();
|
||||
self
|
||||
}
|
||||
|
||||
/// The app's font configuration: its own faces and the craft faces it embeds. The testing
|
||||
/// module offers [`LANGUAGES`] (call [`UiFonts::languages`] to offer the app's list instead),
|
||||
/// then sets the UI language and turns system fonts off after this runs.
|
||||
pub fn fonts(mut self, fonts: impl Fn(UiFonts<'static>) -> UiFonts<'static> + 'static) -> Self {
|
||||
self.fonts = Rc::new(fonts);
|
||||
self
|
||||
}
|
||||
|
||||
/// Called on the context before the first frame, after the fonts are installed: the app's
|
||||
/// theme, and its interface direction if it stores one on the context.
|
||||
pub fn setup(mut self, setup: impl Fn(&Context, &Locale) + 'static) -> Self {
|
||||
self.setup = Some(Rc::new(setup));
|
||||
self
|
||||
}
|
||||
|
||||
/// A harness drawing `app` in `language`.
|
||||
pub fn harness<'a>(&self, language: &'static str, mut app: impl FnMut(&mut Ui, &Locale) + 'a) -> Harness<'a> {
|
||||
self.harness_state(language, move |ui, locale, ()| app(ui, locale), ())
|
||||
}
|
||||
|
||||
/// A harness drawing `app` in `language`, with state the test can read and change
|
||||
/// ([`Harness::state`]).
|
||||
pub fn harness_state<'a, State>(
|
||||
&self,
|
||||
language: &'static str,
|
||||
mut app: impl FnMut(&mut Ui, &Locale, &mut State) + 'a,
|
||||
state: State,
|
||||
) -> Harness<'a, State> {
|
||||
let locale = Locale::new(language);
|
||||
let fonts = (self.fonts)(UiFonts::new(&I18nScripts).languages(&LANGUAGES[..])).ui_language(language).system_fonts(SystemFonts::Off).stack();
|
||||
let setup = self.setup.clone();
|
||||
Harness::builder()
|
||||
.with_size(self.size)
|
||||
.with_context_setup(move |ctx| {
|
||||
fonts.install(ctx);
|
||||
if let Some(setup) = setup {
|
||||
setup(ctx, &locale);
|
||||
}
|
||||
})
|
||||
.build_ui_state(
|
||||
move |ui, state| {
|
||||
// A right-to-left screen starts at the right: egui then starts each
|
||||
// `ui.horizontal` row from the right too.
|
||||
let layout = match locale.direction {
|
||||
Direction::Rtl => Layout::top_down(Align::Max),
|
||||
Direction::Ltr => Layout::top_down(Align::Min),
|
||||
};
|
||||
ui.with_layout(layout, |ui| app(ui, &locale, state));
|
||||
},
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
/// Draw `app` in each language and compare it with `tests/snapshots/{name}-{language}.png`
|
||||
/// (kittest's snapshot options and `kittest.toml` apply). Run with `UPDATE_SNAPSHOTS=1` to
|
||||
/// write new snapshots; review them before committing. The returned results panic on drop if
|
||||
/// any language differs, after every language has been compared.
|
||||
pub fn snapshots(&self, mut app: impl FnMut(&mut Ui, &Locale)) -> SnapshotResults {
|
||||
let mut results = SnapshotResults::new();
|
||||
for language in self.languages.clone() {
|
||||
let mut harness = self.harness(language, &mut app);
|
||||
harness.run();
|
||||
results.add(harness.try_snapshot(format!("{}-{language}", self.name)));
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/// One IME composition: the marked text after each keystroke, then the text committed.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Composition<'a> {
|
||||
pub preedit: &'a [&'a str],
|
||||
pub commit: &'a str,
|
||||
}
|
||||
|
||||
/// 2-Set Korean: ㅎ, ㅏ, ㄴ build one syllable.
|
||||
pub const KOREAN: Composition<'static> = Composition { preedit: &["ㅎ", "하", "한"], commit: "한" };
|
||||
|
||||
/// Japanese romaji input, converted to kanji before commit.
|
||||
pub const JAPANESE: Composition<'static> = Composition { preedit: &["に", "にほ", "にほん", "にほんご", "日本語"], commit: "日本語" };
|
||||
|
||||
/// Pinyin: Latin letters marked until a candidate is chosen.
|
||||
pub const PINYIN: Composition<'static> = Composition { preedit: &["z", "zh", "zho", "zhon", "zhong"], commit: "中" };
|
||||
|
||||
/// Vietnamese Telex (IBus, Fcitx5): `Vieejt` builds the word in place.
|
||||
pub const TELEX: Composition<'static> = Composition { preedit: &["V", "Vi", "Vie", "Viê", "Việ", "Việt"], commit: "Việt" };
|
||||
|
||||
/// The events an input method sends for `composition`: a preedit per keystroke, then the commit.
|
||||
/// Each preedit marks its whole text as the segment being converted. egui's `TextEdit` and
|
||||
/// canvases reading `Event::Ime`, such as craft-ui's IME composer, both take these.
|
||||
pub fn ime_events(composition: &Composition<'_>) -> Vec<Event> {
|
||||
let preedit = composition
|
||||
.preedit
|
||||
.iter()
|
||||
.map(|text| Event::Ime(ImeEvent::Preedit { text: (*text).to_owned(), active_range_chars: Some(0..text.chars().count()) }));
|
||||
preedit.chain([Event::Ime(ImeEvent::Commit(composition.commit.to_owned()))]).collect()
|
||||
}
|
||||
|
||||
/// Type `composition` into the focused widget, one frame per event as a platform delivers them,
|
||||
/// then run the harness until it settles. Focus the text field or canvas first.
|
||||
pub fn compose<State>(harness: &mut Harness<'_, State>, composition: &Composition<'_>) {
|
||||
for event in ime_events(composition) {
|
||||
harness.event(event);
|
||||
harness.step();
|
||||
}
|
||||
harness.run();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
|
||||
use super::{JAPANESE, KOREAN, Screens, compose, ime_events};
|
||||
use crate::fonts::CraftFace;
|
||||
|
||||
const ARABIC: CraftFace = CraftFace {
|
||||
family: "Noto Sans Arabic",
|
||||
style: "Regular",
|
||||
scripts: &["Arab"],
|
||||
bytes: include_bytes!("../../tests/fonts/NotoSansArabic-screens.ttf"),
|
||||
index: 0,
|
||||
};
|
||||
// The subset has the test strings' kana, Han and Hangul, so it stands in for every CJK face.
|
||||
const CJK: CraftFace = CraftFace {
|
||||
family: "Noto Sans CJK SC",
|
||||
style: "Regular",
|
||||
scripts: &["Jpan", "Kore", "Hans", "Hant"],
|
||||
bytes: include_bytes!("../../tests/fonts/NotoSansCJKsc-screens.otf"),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
fn screens() -> Screens {
|
||||
Screens::new("unit").size([300.0, 80.0]).fonts(|fonts| fonts.craft_faces(&[ARABIC, CJK]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_events_end_with_the_commit() {
|
||||
let events = ime_events(&KOREAN);
|
||||
assert_eq!(events.len(), 4);
|
||||
assert_eq!(events.last(), Some(&egui::Event::Ime(egui::ImeEvent::Commit("한".to_owned()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_types_into_the_focused_text_field() {
|
||||
for (language, composition) in [("ko", KOREAN), ("ja", JAPANESE)] {
|
||||
let mut harness = screens().harness_state(
|
||||
language,
|
||||
|ui, _, text: &mut String| {
|
||||
let label = ui.label("Name");
|
||||
ui.text_edit_singleline(text).labelled_by(label.id);
|
||||
},
|
||||
String::new(),
|
||||
);
|
||||
harness.get_by_role(Role::TextInput).focus();
|
||||
harness.run();
|
||||
compose(&mut harness, &composition);
|
||||
assert_eq!(harness.state(), composition.commit, "{language}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_screens_start_at_the_right() {
|
||||
for (language, at_right) in [("ar", true), ("ja", false)] {
|
||||
let harness = screens().harness(language, |ui, _| _ = ui.label("ملف"));
|
||||
let label = harness.get_by_label("ملف").rect();
|
||||
assert_eq!(label.max.x > 250.0, at_right, "{language}: {label:?}");
|
||||
assert_eq!(label.min.x < 50.0, !at_right, "{language}: {label:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Theme mechanisms for egui widgets, with palettes and theme selection owned by applications.
|
||||
//!
|
||||
//! This module does not install fonts, store a theme, or change global visuals. Applications
|
||||
//! decide their colors, borders, typography, spacing and saved preferences independently.
|
||||
//! This module does not store a theme or change global visuals; [`crate::fonts`] installs fonts.
|
||||
//! Applications decide their colors, borders, typography, spacing and saved preferences independently.
|
||||
|
||||
use egui::{Color32, CornerRadius, Stroke, style::Widgets};
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
//! A CJK and an Arabic label draw with the shared UI font stack and no system fonts.
|
||||
//!
|
||||
//! The kittest harness panics on any character no installed face can draw, so these tests fail
|
||||
//! on a replacement box ("tofu") rather than passing a snapshot nobody looks at.
|
||||
|
||||
use craft_ui::fonts::{CraftFace, I18nScripts, SystemFonts, UiFonts};
|
||||
use egui::{FontDefinitions, FontFamily};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
|
||||
const ARABIC: CraftFace =
|
||||
CraftFace { family: "Noto Sans Arabic", style: "Regular", scripts: &["Arab"], bytes: include_bytes!("fonts/NotoSansArabic-test.ttf"), index: 0 };
|
||||
const CJK: CraftFace = CraftFace {
|
||||
family: "Noto Sans CJK SC",
|
||||
style: "Regular",
|
||||
scripts: &["Hans", "Latn"],
|
||||
bytes: include_bytes!("fonts/NotoSansCJKsc-test.otf"),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const LABELS: [&str; 3] = ["Files", "日本語のファイル", "ملف عربي"];
|
||||
|
||||
fn harness(ui_language: &'static str, faces: &'static [CraftFace]) -> Harness<'static> {
|
||||
Harness::builder()
|
||||
.with_size([240.0, 120.0])
|
||||
.with_context_setup(move |ctx| {
|
||||
let defaults = FontDefinitions::default();
|
||||
let mut fonts =
|
||||
UiFonts::new(&I18nScripts).craft_faces(faces).languages(&["en", "ar", "ja"]).ui_language(ui_language).system_fonts(SystemFonts::Off);
|
||||
if let Some(ubuntu) = defaults.font_data.get("Ubuntu-Light") {
|
||||
fonts = fonts.app_face("ui", (**ubuntu).clone(), &[FontFamily::Proportional]);
|
||||
}
|
||||
fonts.install(ctx);
|
||||
})
|
||||
.build_ui(|ui| {
|
||||
for label in LABELS {
|
||||
ui.label(label);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_draw_cjk_and_arabic_labels_without_replacement_boxes() {
|
||||
for ui_language in ["en", "ar", "ja"] {
|
||||
let mut harness = harness(ui_language, &[ARABIC, CJK]);
|
||||
harness.run();
|
||||
for label in LABELS {
|
||||
assert!(harness.query_by_label(label).is_some(), "{ui_language}: {label} not shown");
|
||||
}
|
||||
let image = harness.render().expect("render");
|
||||
let background = *image.get_pixel(image.width() - 1, image.height() - 1);
|
||||
let drawn = image.pixels().filter(|p| **p != background).count();
|
||||
assert!(drawn > 500, "{ui_language}: only {drawn} pixels drawn");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without the Arabic face, an Arabic character (U+06xx) has no glyph.
|
||||
#[test]
|
||||
#[should_panic(expected = "(U+06")]
|
||||
fn fonts_without_an_arabic_face_fail() {
|
||||
harness("en", &[CJK]).run();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! A rename dialog in the six test languages: the example of `craft_ui::testing` for app tests.
|
||||
//!
|
||||
//! The snapshots are in `tests/snapshots/rename-*.png`; `UPDATE_SNAPSHOTS=1` rewrites them.
|
||||
|
||||
use craft_ui::fonts::CraftFace;
|
||||
use craft_ui::testing::{JAPANESE, KOREAN, Locale, Screens, compose};
|
||||
use egui::accesskit::Role;
|
||||
use egui::{FontData, FontFamily, Ui};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
|
||||
const ARABIC: CraftFace = CraftFace {
|
||||
family: "Noto Sans Arabic",
|
||||
style: "Regular",
|
||||
scripts: &["Arab"],
|
||||
bytes: include_bytes!("fonts/NotoSansArabic-screens.ttf"),
|
||||
index: 0,
|
||||
};
|
||||
// The subset has the dialog's kana, Han and Hangul, so it stands in for every CJK face. Its Latin
|
||||
// glyphs, Vietnamese included, are the app's UI face: egui's default face has no Vietnamese.
|
||||
const CJK: CraftFace = CraftFace {
|
||||
family: "Noto Sans CJK SC",
|
||||
style: "Regular",
|
||||
scripts: &["Jpan", "Kore", "Hans", "Hant"],
|
||||
bytes: include_bytes!("fonts/NotoSansCJKsc-screens.otf"),
|
||||
index: 0,
|
||||
};
|
||||
const THAI: CraftFace =
|
||||
CraftFace { family: "Noto Sans Thai", style: "Regular", scripts: &["Thai"], bytes: include_bytes!("fonts/NotoSansThai-screens.ttf"), index: 0 };
|
||||
|
||||
struct Strings {
|
||||
title: &'static str,
|
||||
name: &'static str,
|
||||
file: &'static str,
|
||||
keep: &'static str,
|
||||
cancel: &'static str,
|
||||
rename: &'static str,
|
||||
}
|
||||
|
||||
fn strings(language: &str) -> Strings {
|
||||
let [title, name, file, keep, cancel, rename] = match language {
|
||||
"ar" => ["إعادة تسمية الملف", "الاسم", "تقرير ٢٠٢٦.pdf", "الإبقاء على الامتداد", "إلغاء", "إعادة التسمية"],
|
||||
"ja" => ["ファイル名を変更", "名前", "報告書 2026.pdf", "拡張子を保持", "キャンセル", "変更"],
|
||||
"zh-TW" => ["重新命名檔案", "名稱", "報告 2026.pdf", "保留副檔名", "取消", "重新命名"],
|
||||
"ko" => ["파일 이름 바꾸기", "이름", "보고서 2026.pdf", "확장자 유지", "취소", "이름 바꾸기"],
|
||||
"th" => ["เปลี่ยนชื่อไฟล์", "ชื่อ", "รายงาน 2026.pdf", "เก็บนามสกุลไฟล์", "ยกเลิก", "เปลี่ยนชื่อ"],
|
||||
"vi" => ["Đổi tên tệp", "Tên", "Báo cáo 2026.pdf", "Giữ phần mở rộng", "Hủy", "Đổi tên"],
|
||||
_ => ["Rename file", "Name", "Report 2026.pdf", "Keep extension", "Cancel", "Rename"],
|
||||
};
|
||||
Strings { title, name, file, keep, cancel, rename }
|
||||
}
|
||||
|
||||
struct Dialog {
|
||||
file: String,
|
||||
keep: bool,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
fn new(locale: &Locale) -> Self {
|
||||
Self { file: strings(locale.language).file.to_owned(), keep: true }
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut Ui, locale: &Locale) {
|
||||
let text = strings(locale.language);
|
||||
ui.heading(text.title);
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(text.name);
|
||||
ui.text_edit_singleline(&mut self.file).labelled_by(label.id);
|
||||
});
|
||||
ui.checkbox(&mut self.keep, text.keep);
|
||||
ui.horizontal(|ui| {
|
||||
_ = ui.button(text.rename);
|
||||
_ = ui.button(text.cancel);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn screens() -> Screens {
|
||||
Screens::new("rename")
|
||||
.size([320.0, 150.0])
|
||||
.fonts(|fonts| fonts.app_face("ui", FontData::from_static(CJK.bytes), &[FontFamily::Proportional]).craft_faces(&[ARABIC, CJK, THAI]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testing_rename_dialog_snapshots() {
|
||||
screens().snapshots(|ui, locale| Dialog::new(locale).ui(ui, locale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testing_rename_dialog_takes_ime_input() {
|
||||
for (language, composition) in [("ko", KOREAN), ("ja", JAPANESE)] {
|
||||
let dialog = Dialog { file: String::new(), keep: true };
|
||||
let mut harness = screens().harness_state(language, |ui, locale, dialog: &mut Dialog| dialog.ui(ui, locale), dialog);
|
||||
harness.get_by_role(Role::TextInput).focus();
|
||||
harness.run();
|
||||
compose(&mut harness, &composition);
|
||||
assert_eq!(harness.state().file, composition.commit, "{language}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
*.new.png
|
||||
*.diff.png
|
||||
*.old.png
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,47 @@
|
||||
"""Serve crates/ui/tests/fonts on 127.0.0.1 for the web_fonts browser test.
|
||||
|
||||
python3 crates/ui/tests/web/serve_fonts.py [port] # default 8137
|
||||
|
||||
/fonts/<file> serves the file. /flaky/<file> answers 503 the first time and serves the file after.
|
||||
Other paths answer 404. /log lists the paths requested so far, as JSON.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
FONTS = pathlib.Path(__file__).resolve().parent.parent / "fonts"
|
||||
requested = []
|
||||
failed_once = set()
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/log":
|
||||
return self.reply(200, json.dumps(requested).encode(), "application/json")
|
||||
requested.append(self.path)
|
||||
kind, _, name = self.path.lstrip("/").partition("/")
|
||||
file = FONTS / name
|
||||
if kind == "flaky" and self.path not in failed_once:
|
||||
failed_once.add(self.path)
|
||||
return self.reply(503, b"", "text/plain")
|
||||
if kind in ("fonts", "flaky") and "/" not in name and file.is_file():
|
||||
return self.reply(200, file.read_bytes(), "font/ttf")
|
||||
self.reply(404, b"", "text/plain")
|
||||
|
||||
def reply(self, status, body, content_type):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8137
|
||||
http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Served faces in a browser: a character no installed face has fetches the one served face that
|
||||
//! has it, a character no served face has fetches nothing, and a failed face is fetched again
|
||||
//! later.
|
||||
//!
|
||||
//! Start `tests/web/serve_fonts.py` (127.0.0.1:8137; another address through
|
||||
//! `CRAFT_UI_FONT_SERVER` when building), then run
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test -p craft-ui --target wasm32-unknown-unknown --test web_fonts
|
||||
//! ```
|
||||
//!
|
||||
//! with `wasm-bindgen-test-runner` as the target's runner: headless through a WebDriver, or with
|
||||
//! `NO_HEADLESS=1`, which serves the test page for any browser to open.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use craft_ui::fonts::{I18nScripts, ServedFace, SystemFonts, UiFonts};
|
||||
use egui::{FontDefinitions, FontFamily, FontId};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
fn url(path: &str) -> &'static str {
|
||||
let server = option_env!("CRAFT_UI_FONT_SERVER").unwrap_or("http://127.0.0.1:8137");
|
||||
Box::leak(format!("{server}{path}").into_boxed_str())
|
||||
}
|
||||
|
||||
fn face(family: &'static str, scripts: &'static [&'static str], path: &str, coverage: Vec<u8>) -> ServedFace {
|
||||
ServedFace { family, style: "Regular", scripts, index: 0, url: url(path), size: 0, integrity: "", coverage: coverage.leak() }
|
||||
}
|
||||
|
||||
fn coverage_of(font: &[u8]) -> Vec<u8> {
|
||||
craft_fonts_build::coverage::of_face(font, 0).expect("a font")
|
||||
}
|
||||
|
||||
async fn sleep(millis: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let window = web_sys::window().expect("a browser window");
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, millis).expect("setTimeout");
|
||||
});
|
||||
JsFuture::from(promise).await.expect("a timer");
|
||||
}
|
||||
|
||||
/// Run passes until the context draws `c`, for up to `seconds`.
|
||||
async fn draws_within(ctx: &egui::Context, c: char, seconds: f64) -> bool {
|
||||
let start = js_sys::Date::now();
|
||||
loop {
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
if ctx.fonts_mut(|f| f.has_glyph(&FontId::proportional(14.0), c)) {
|
||||
return true;
|
||||
}
|
||||
if js_sys::Date::now() - start > seconds * 1000.0 {
|
||||
return false;
|
||||
}
|
||||
sleep(50).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths the font server was asked for, in order.
|
||||
async fn requested() -> Vec<String> {
|
||||
let window = web_sys::window().expect("a browser window");
|
||||
let response: web_sys::Response = JsFuture::from(window.fetch_with_str(url("/log"))).await.expect("the font server").unchecked_into();
|
||||
let paths = JsFuture::from(response.json().expect("JSON")).await.expect("JSON");
|
||||
js_sys::Array::from(&paths).iter().filter_map(|path| path.as_string()).collect()
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn a_missing_character_fetches_the_face_that_has_it_and_a_failed_one_is_retried() {
|
||||
let encode = craft_fonts_build::coverage::encode;
|
||||
let served = [
|
||||
face("Noto Sans", &["Latn", "Cyrl", "Grek", "Deva"], "/fonts/NotoSans-test.ttf", coverage_of(include_bytes!("fonts/NotoSans-test.ttf"))),
|
||||
face("Noto Color Emoji", &["Zsye"], "/missing/NotoColorEmoji.ttf", encode(&[(0x1F300, 0x1FAFF)])),
|
||||
// ⌃ ⎋, and ⌥ ⌫ ⏎: the keyboard keys are split between the two symbol faces.
|
||||
face("Noto Sans Symbols", &["Zsym"], "/fonts/NotoSansSymbols-test.ttf", coverage_of(include_bytes!("fonts/NotoSansSymbols-test.ttf"))),
|
||||
// Fails the first time.
|
||||
face("Noto Sans Symbols 2", &["Zsym"], "/flaky/NotoSansSymbols2-test.ttf", coverage_of(include_bytes!("fonts/NotoSansSymbols2-test.ttf"))),
|
||||
face("Noto Sans Math", &["Zmth", "Zsym"], "/missing/NotoSansMath-Regular.ttf", encode(&[(0x2200, 0x22FF)])),
|
||||
];
|
||||
let defaults = FontDefinitions::default();
|
||||
let ubuntu = (**defaults.font_data.get("Ubuntu-Light").expect("egui default_fonts")).clone();
|
||||
let ctx = egui::Context::default();
|
||||
UiFonts::new(&I18nScripts)
|
||||
.app_face("ui", ubuntu, &[FontFamily::Proportional])
|
||||
.served_faces(&served)
|
||||
.ui_language("en")
|
||||
.system_fonts(SystemFonts::Off)
|
||||
.stack()
|
||||
.install(&ctx);
|
||||
|
||||
assert!(draws_within(&ctx, 'ổ', 5.0).await, "Vietnamese from Noto Sans");
|
||||
assert!(draws_within(&ctx, '⌃', 5.0).await, "a key from Noto Sans Symbols");
|
||||
// Only Symbols 2 has ⌥: it fails, and is fetched again after two seconds.
|
||||
assert!(draws_within(&ctx, '⌥', 10.0).await, "a key from Noto Sans Symbols 2 after a failure");
|
||||
assert!(draws_within(&ctx, '⌫', 1.0).await);
|
||||
// No face has ẫ.
|
||||
assert!(!draws_within(&ctx, 'ẫ', 1.0).await);
|
||||
assert_eq!(
|
||||
requested().await,
|
||||
["/fonts/NotoSans-test.ttf", "/fonts/NotoSansSymbols-test.ttf", "/flaky/NotoSansSymbols2-test.ttf", "/flaky/NotoSansSymbols2-test.ttf"],
|
||||
"each character fetches only the face that has it",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Language testing per release
|
||||
|
||||
A manual checklist for each app release: input methods, keyboard layouts and screen readers on
|
||||
real systems. Copy it into the release issue and tick each item; for a failure, note the system,
|
||||
its version and what happened instead, and open an issue.
|
||||
|
||||
Run every input item in two places: a single-line text field (a rename or search field) and the
|
||||
app's canvas text tool, if it has one. "Commits" means the text appears in the field as ordinary
|
||||
text, once, at the caret, with the composition underline gone.
|
||||
|
||||
## What is automated
|
||||
|
||||
Leave these to the automated tests; repeat them by hand only to investigate a failure.
|
||||
|
||||
| Automated | Where |
|
||||
|---|---|
|
||||
| Linux input methods on this box: fcitx5 on Xvfb (XIM), fcitx5 on headless sway (text-input-v3) and IBus on Xvfb; Pinyin, Hangul, Telex and Anthy compositions, a click into a composing field, a pointer unplugged while composing | [IME harness](https://github.com/storytold/craft-libs/pull/212), `tools/ime-harness/` |
|
||||
| Each app's key screens in Arabic, Japanese, Traditional Chinese, Korean, Thai and Vietnamese with no system fonts; one scripted IME composition and one non-Latin shortcut per app | `craft_ui::testing` ([craft-ui README](../crates/ui/README.md#testing-screens-in-each-language)) |
|
||||
| The shortcut rule for Russian, Arabic 101, Hebrew, Greek, Thai, AZERTY, Dvorak and QWERTZ presses as egui reports them | craft-keymap's tests |
|
||||
|
||||
The manual list covers what those can't reach: the input methods and layouts of Windows and
|
||||
macOS, desktop Linux sessions, the browser on phones, and screen readers.
|
||||
|
||||
## Release
|
||||
|
||||
- App and version:
|
||||
- Tester and date:
|
||||
- Builds tested (Windows, macOS, Linux, Flatpak, FreeBSD, web):
|
||||
|
||||
## Windows
|
||||
|
||||
- [ ] **Microsoft Pinyin.** Type `nihao`, then Space. The candidate window opens at the caret;
|
||||
你好 commits. Type `nihao` and press Enter: `nihao` commits as Latin text.
|
||||
- [ ] **Microsoft Japanese IME.** Type `nihongo`, then Space. にほんご converts to 日本語 with
|
||||
the converted segment underlined; Enter commits 日本語. Type `nihongo` again and press
|
||||
Escape twice: the composition is removed, nothing is inserted, and the dialog or panel stays
|
||||
open.
|
||||
- [ ] **Microsoft Korean IME** (2-Set). Switch with the Han/Eng key or Right Alt. Type `gksrmf`:
|
||||
the syllables build in place and 한글 commits. While composing 한, Backspace leaves 하.
|
||||
Press Enter after a syllable: the syllable commits and the field does not also submit; a
|
||||
second Enter does.
|
||||
- [ ] **Sogou Pinyin.** Type `zhongguo`, then Space: 中国 commits, with Sogou's candidate bar at
|
||||
the caret. In Chinese mode, `,` types the full-width `,`.
|
||||
- [ ] **UniKey** (Telex). Type `Vieejt Nam`: Việt Nam commits with no doubled or missing letters.
|
||||
Type `tieesng` in the canvas text tool: tiếng.
|
||||
- [ ] **Emoji panel.** Press Win+. and pick 😀: it is inserted once, at the caret. Pick a second
|
||||
emoji with the panel still open: it follows the first.
|
||||
- [ ] **AltGr layouts.** On German, AltGr+Q types `@` and AltGr+E types `€`; on Polish
|
||||
(programmer's), AltGr+A types `ą`. None of these runs a Ctrl+Alt shortcut.
|
||||
- [ ] **US-International dead keys.** Type `'` then `e`: é. Type `'` then Space: `'`.
|
||||
|
||||
## macOS
|
||||
|
||||
- [ ] **Pinyin – Simplified.** Type `nihao`, then Space: the candidate window opens at the caret
|
||||
and 你好 commits.
|
||||
- [ ] **Japanese – Romaji (Kotoeri).** Type `nihongo`, then Space: 日本語 is shown with the
|
||||
active segment underlined more thickly; Return commits. Escape during a composition cancels
|
||||
it without closing the dialog.
|
||||
- [ ] **2-Set Korean.** Type `gksrmf`: 한글 builds in place and commits. Return after a syllable
|
||||
commits it without submitting the field; a second Return submits.
|
||||
- [ ] **Press and hold.** Hold `e`: the accent menu opens at the caret; press `2`: é replaces the
|
||||
e. The plain `e` is not left behind.
|
||||
- [ ] **Dead keys** (U.S. layout). Option+E then `e`: é. Option+U then `u`: ü. Option+N then `n`:
|
||||
ñ. The pending accent shows as marked text until the second key.
|
||||
- [ ] **Character Viewer.** Press Ctrl+Cmd+Space and pick 😀: it is inserted once, at the caret.
|
||||
- [ ] **Dictation.** Start dictation (press Fn twice, or the microphone key) and say a short
|
||||
sentence: interim text appears at the caret; stopping dictation leaves the sentence once.
|
||||
|
||||
## Linux
|
||||
|
||||
- [ ] **GNOME with IBus** (Wayland session). With ibus-libpinyin, `nihao` Space: 你好. With
|
||||
ibus-hangul, `gksrmf`: 한글. With ibus-mozc or ibus-anthy, `nihongo` Space Enter: 日本語.
|
||||
The candidate popup opens at the caret.
|
||||
- [ ] **KDE Plasma with Fcitx5** (Wayland session). The same three compositions through
|
||||
fcitx5-chinese-addons, fcitx5-hangul and fcitx5-mozc. The preedit shows in the field while
|
||||
composing (designcraft#1234).
|
||||
- [ ] **Hyprland with Fcitx5.** The same three compositions; the candidate popup follows the
|
||||
caret when the caret moves to a second line.
|
||||
- [ ] **X11 with Vietnamese Telex.** In an X11 session with fcitx5-unikey or ibus-unikey, type
|
||||
`tieesng Vieejt`: tiếng Việt, with no doubled letters.
|
||||
- [ ] **One Flatpak build.** Install the app's Flatpak and repeat one composition (Pinyin or
|
||||
Hangul) through the host's input method. Switch the UI to Japanese and to Arabic: every label
|
||||
draws without boxes, with only the runtime's fonts.
|
||||
|
||||
## FreeBSD (once per release cycle)
|
||||
|
||||
- [ ] Build and start the app. Under X11 with fcitx5 or IBus, compose `nihao` → 你好 and
|
||||
`gksrmf` → 한글. Switch the UI to Arabic: labels join and run right to left.
|
||||
|
||||
## Web
|
||||
|
||||
- [ ] **Android, Chrome, Gboard.** Type `hello wrold` with suggestions on: the composing word is
|
||||
underlined, and the autocorrected `world` replaces it without duplicating text. Backspace
|
||||
deletes one character at a time. Switch Gboard to Korean and type 한글: syllables build in
|
||||
place.
|
||||
- [ ] **iOS, Safari, Korean keyboard.** Type 한글: syllables build in place with no doubled jamo;
|
||||
tapping elsewhere in the text commits the syllable and moves the caret there.
|
||||
|
||||
## Keyboard shortcuts on other layouts
|
||||
|
||||
For each layout, run Copy, Paste, Undo, Select All and Save from the keyboard, and the shortcuts
|
||||
named below. Each should run the same command it runs on US QWERTY, and the menus should show the
|
||||
shortcut as listed. On macOS read Ctrl as Cmd.
|
||||
|
||||
- [ ] **Russian.** Ctrl on the C, V, Z, A and S keys (с, м, я, ф, ы) copies, pastes, undoes,
|
||||
selects all and saves. The menu shows Ctrl+[ as Ctrl+Х.
|
||||
- [ ] **Arabic 101.** Ctrl on the C, V, Z, A and S keys (ؤ, ر, ئ, ش, س) runs the same commands.
|
||||
- [ ] **Hebrew.** Ctrl on the C, V, Z, A and S keys (ב, ה, ז, ש, ד) runs the same commands. Ctrl on
|
||||
the Q key, which types `/`, runs the Ctrl+Q command, not Ctrl+/.
|
||||
- [ ] **Greek.** Ctrl on the C, V, Z, A and S keys (ψ, ω, ζ, α, σ) runs the same commands. In a
|
||||
text field, `;` then `α` types ά.
|
||||
- [ ] **Thai** (Kedmanee). Ctrl on the C, V, Z, A and S keys (แ, อ, ผ, ฟ, ห) runs the same
|
||||
commands. Ctrl on the 1 key, which types ๅ, runs Ctrl+1.
|
||||
- [ ] **AZERTY** (French). Ctrl+A and Ctrl+Z are the keys labelled A and Z. Ctrl on the 1 key
|
||||
(`&`) runs Ctrl+1. Ctrl on the 6 key (`-`) zooms out.
|
||||
- [ ] **Dvorak.** Ctrl+C, Ctrl+V and Ctrl+Z are the keys labelled C, V and Z. Ctrl on the QWERTY
|
||||
Q position, which types `'`, does not run Ctrl+Q.
|
||||
- [ ] **QWERTZ** (German). Ctrl+Z undoes and Ctrl+Y redoes, on the keys labelled Z and Y. Ctrl+⇧7
|
||||
runs Ctrl+/. The menu shows Ctrl+[ as Ctrl+Ü.
|
||||
- [ ] **Dvorak – QWERTY ⌘** (macOS). Typing gives Dvorak letters. With Cmd held, the keys follow
|
||||
QWERTY: Cmd on the QWERTY C position copies, and the menus show ⌘C.
|
||||
|
||||
## Screen readers
|
||||
|
||||
Run each with the UI in English, then in Arabic. In Arabic, use a voice that reads Arabic.
|
||||
|
||||
- [ ] **NVDA** (Windows). Tab through the main window and one dialog: each control is announced
|
||||
with its name and role, in the UI language, in the order it appears (right to left in
|
||||
Arabic). In a text field, typed characters are echoed and arrow keys read the character
|
||||
passed over. An Arabic file name in an English UI is read in Arabic.
|
||||
- [ ] **VoiceOver** (macOS). Move with VO+Right through the toolbar and a dialog: names and roles
|
||||
in the UI language, in reading order. The rotor lists the form controls. Composing Hangul in
|
||||
a text field is announced once, when it commits.
|
||||
- [ ] **Orca** (Linux, GNOME). Tab through the main window and one dialog: names and roles are
|
||||
read in the UI language. Focus moves visibly with each announcement.
|
||||
@@ -93,7 +93,9 @@ locale handling and fallback order: PhotoCraft uses its text and preference crat
|
||||
uses `vectorcraft_text`; DesignCraft and WordCraft use their font crates; PdfCraft adds script
|
||||
and system fallback; LightCraft uses its engine catalog; FilmCraft and EffectCraft use their text
|
||||
crates and local font discovery. DesignCraft also configures named Arabic shaping families.
|
||||
These dependencies and font assets stay outside `craft-ui`.
|
||||
Those dependencies and the font files stay in the apps. What they had in common, the fallback
|
||||
order and the system-font fallback, is `craft_ui::fonts` (see the
|
||||
[craft-ui README](../crates/ui/README.md#ui-fonts)); each app hands it the faces it embeds.
|
||||
|
||||
Theme references occur throughout panels, menus and tools, with local visual/spacing overrides
|
||||
in every app. A source search alone cannot turn this into a percentage of correctly themed UI:
|
||||
@@ -128,7 +130,7 @@ Examples worth preserving or reviewing separately:
|
||||
customize backgrounds and slider widths.
|
||||
|
||||
This extraction preserves those choices. It does not certify whole-app accessibility or theme
|
||||
consistency, and it does not move font installation into the shared crate.
|
||||
consistency. Font installation is separate from theme application: `craft_ui::fonts` handles it.
|
||||
|
||||
## Migration and validation
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Reference in New Issue
Block a user