Compare commits

..

1 Commits

Author SHA1 Message Date
Pengjingyu1992 e29e5ea920 M7: add Simplified Chinese, CJK composition and compact UI fixes 2026-10-07 17:56:23 +08:00
27 changed files with 3893 additions and 554 deletions

50
Cargo.lock generated
View File

@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"arboard",
"clipboard-win",
@ -4233,7 +4233,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@ -4245,7 +4245,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@ -4261,7 +4261,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"serde_json",
"vectorcraft-color",
@ -4277,7 +4277,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@ -4289,7 +4289,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@ -4303,7 +4303,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@ -4317,7 +4317,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@ -4349,7 +4349,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@ -4366,7 +4366,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"flate2",
"proptest",
@ -4380,7 +4380,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@ -4392,7 +4392,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"log",
@ -4411,7 +4411,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@ -4425,7 +4425,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@ -4437,7 +4437,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"aes",
"flate2",
@ -4464,7 +4464,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
@ -4478,7 +4478,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"flate2",
"gif",
@ -4503,7 +4503,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@ -4527,7 +4527,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@ -4544,7 +4544,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@ -4557,7 +4557,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@ -4572,7 +4572,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@ -4585,7 +4585,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"egui",
"egui_extras",
@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"eframe",
"egui",
@ -5687,7 +5687,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.4.0"
version = "0.3.1"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.4.0"
version = "0.3.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"

5
NOTICE
View File

@ -31,6 +31,7 @@ WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmp
under the Apache License 2.0 with the LLVM exception.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
web builds BIZ UDPGothic Regular only) under the SIL
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.

View File

@ -142,6 +142,12 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Quick start
Simplified Chinese (`zh-hans`) is available in the language menu and Preferences.
Legacy `locale: "zh-Hans"` preferences migrate without overriding an explicit language choice.
Horizontal Chinese text supports punctuation compression and inter-character justification.
Build desktop releases with `CRAFT_FONTS_DIR` set to include Noto Sans CJK SC from craft-fonts;
without that optional input, Chinese falls back to installed system fonts.
```sh
cargo run --release -p vectorcraft # desktop app
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft

View File

@ -84,6 +84,7 @@ When a task lands, update this section, the parity table and "Shipped so far" in
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
## Shipped so far
- **Simplified Chinese and horizontal CJK composition:** `zh-hans` catalog and legacy language preference migration; punctuation compression, inter-character justification, per-family UI font fallback and responsive dock/dialog fixes. Desktop CJK fonts use the optional craft-fonts input rather than font binaries in this repository. Ruby, configurable mojikumi and vertical font metrics remain open.
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
@ -100,14 +101,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.

View File

@ -75,16 +75,12 @@ impl LayerColor {
#[serde(tag = "shape", rename_all = "lowercase")]
pub enum LiveShape {
Rectangle {
/// Untransformed width/height. Transforms fold their scale into these (see
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
/// Untransformed width/height.
w: f64,
h: f64,
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
radii: [f64; 4],
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
/// Maps the untransformed shape (origin at its top-left) into the document.
xf: Affine,
},
Ellipse {
@ -133,44 +129,12 @@ impl LiveShape {
LiveShape::Line { a, b } => shapes::line(*a, *b),
}
}
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
/// scale in `xf` keep their exact geometry until they're next transformed).
pub fn transform(&mut self, a: Affine) -> bool {
pub fn transform(&mut self, a: Affine) {
match self {
LiveShape::Rectangle { w, h, radii, xf } => {
let m = a * *xf;
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
if moves_only || !orthogonal || !finite {
*xf = m;
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
true
}
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
*xf = a * *xf;
false
}
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
LiveShape::Line { a: p, b } => {
*p = a * *p;
*b = a * *b;
false
}
}
}
@ -649,10 +613,11 @@ impl Node {
NodeKind::Path { path, live, .. } => {
path.transform(a);
if let Some(l) = live {
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
let reshaped = l.transform(a);
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
if reshaped || kept {
l.transform(a);
if sc.keep_corners
&& let Some(k) = k
&& l.keep_corners(k)
{
*path = l.to_path();
}
}
@ -1170,113 +1135,6 @@ mod tests {
assert_eq!(l.label(), "Rectangle");
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
}
n
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn near_xf(a: Affine, b: Affine) -> bool {
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
}
/// Size and radius of a live rectangle, within rounding.
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
let (lw, lh, radii, _) = live_of(n);
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
}
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
assert_eq!(a.len(), 8, "a rounded rectangle");
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
}
}
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
#[test]
fn uneven_scales_keep_live_corners_circular() {
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
n.transform(stretch, KEEP_CORNERS);
assert_live(&n, 300.0, 100.0, 20.0);
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
let b = n.geometric_bounds().unwrap();
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
assert_round_corners(&n, Affine::IDENTITY, 20.0);
// Scale Corners on: the radius scales by the mean scale, still circular.
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
assert_live(&n, 400.0, 100.0, 40.0);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 25.0);
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
assert_round_corners(&n, Affine::IDENTITY, 40.0);
}
#[test]
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, place);
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
assert!(near_xf(live_of(&n).3, place));
assert_round_corners(&n, place.inverse(), 10.0);
// A reflection stays in `xf`; the corners stay as they were.
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
assert_live(&n, 200.0, 50.0, 10.0);
let xf = live_of(&n).3;
assert!(near(xf.determinant(), -1.0));
assert_round_corners(&n, xf.inverse(), 10.0);
}
#[test]
fn shears_and_moves_keep_the_old_transform() {
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
let rot = Affine::rotate(30f64.to_radians());
let mut n = live_rect(100.0, 50.0, 10.0, rot);
let before = n.clone();
let a = Affine::scale_non_uniform(2.0, 1.0);
n.transform(a, Scaling::default());
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
assert_eq!(*path, old.transformed(a));
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
// ...and once scaled, has document units and circular corners of its mean radius.
n.transform(Affine::scale(2.0), Scaling::default());
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
assert!(near(live_of(&n).3.determinant(), 1.0));
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());

View File

@ -153,61 +153,6 @@ fn scale_corners_keeps_or_scales_live_corner_radii() {
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
}
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a = &path.subpaths[0].anchors;
assert_eq!(a.len(), 8, "a rounded rectangle");
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
}
/// Every corner is a circular arc of radius `r`.
fn round_corners(n: &Node, r: f64) -> bool {
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
}
#[test]
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
// #291: a rounded square widened into a long rectangle keeps round corners.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
let n = node(&s, id);
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") };
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
// Undo brings each step back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
}
#[test]
fn a_bounding_box_drag_keeps_live_corners_circular() {
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
s.begin_interaction("Scale").unwrap();
for sx in [1.5, 2.5, 4.0] {
// Dragging the right-middle handle: scaled about the left edge.
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
}
s.commit_interaction().unwrap();
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
}
#[test]
fn the_journal_records_the_scale_options_used() {
let mut s = session();

View File

@ -5,7 +5,18 @@
//! hyphenation points. The breaks minimise the sum of squared (badness + penalty) demerits over
//! the whole paragraph, so loose lines are traded for evenly spaced ones.
use crate::shape::SGlyph;
use crate::shape::{SGlyph, can_break_between, is_cjk};
/// Whether justification may widen the gap between `g[i]` and `g[i + 1]`: a word space, or a CJK
/// character boundary (Chinese has no spaces, so 两端对齐 stretches the character gaps). Forbidden
/// line starts and ends are left alone so punctuation keeps its 标点挤压 spacing.
pub(crate) fn stretchable_gap(g: &[SGlyph], i: usize) -> bool {
if g[i].is_space() {
return true;
}
let Some(next) = g.get(i + 1) else { return false };
!next.is_space() && !next.continues_tcy() && (is_cjk(g[i].ch) || is_cjk(next.ch)) && can_break_between(g[i].ch, Some(next.ch))
}
const STRETCH: f64 = 0.5;
const SHRINK: f64 = 0.2;
@ -60,6 +71,12 @@ pub(crate) fn compose(
};
let mut all: Vec<Breakpoint> = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect();
all.push(Breakpoint { end: n, hyphen: 0.0 });
// Prefix sums of the gaps that justification may stretch. Between words that is the space;
// between CJK characters, which have no space to stretch, it is the gap itself (两端对齐).
let mut gap = vec![0.0; n + 1];
for i in 0..n {
gap[i + 1] = gap[i] + if !g[i].is_space() && stretchable_gap(g, i) { g[i].adv * 0.5 } else { 0.0 };
}
let mut nodes = vec![Node { pos: 0, line: 0, demerits: 0.0, hyphenated: false, prev: None }];
let mut active: Vec<usize> = vec![0];
for bp in &all {
@ -71,12 +88,14 @@ pub(crate) fn compose(
let t = trim(bp.end, a.pos);
let natural = w[t] - w[a.pos] + bp.hyphen;
let spaces = sp[t] - sp[a.pos];
// A CJK line with no spaces stretches its character gaps instead.
let stretch = if spaces > 0.0 { spaces } else { gap[t] - gap[a.pos] };
let lw = width(a.line);
let ratio = if natural < lw {
if last && !justify_last {
0.0
} else if spaces > 0.0 {
(lw - natural) / (spaces * STRETCH)
} else if stretch > 0.0 {
(lw - natural) / (stretch * STRETCH)
} else {
f64::INFINITY
}

View File

@ -932,6 +932,21 @@ impl FontDb {
self.fallback_for(c, 0)
}
/// UI fallback at the requested weight; keep the official family preference and loader.
pub fn face_covering_weighted(&self, c: char, weight: f32) -> Option<Arc<FontFace>> {
self.fallback_for_weighted(c, 0, weight)
}
pub(crate) fn fallback_for_weighted(&self, c: char, exclude: u32, weight: f32) -> Option<Arc<FontFace>> {
let fallback = self.fallback_for(c, exclude)?;
self.read_faces()
.iter()
.filter(|face| face.id() != exclude && face.family == fallback.family && face.covers(c))
.min_by_key(|face| (face.italic, (face.weight - weight).abs() as i32))
.cloned()
.or(Some(fallback))
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();

View File

@ -5,7 +5,7 @@ use std::ops::Range;
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextObject};
use crate::composer::{Breakpoint, compose};
use crate::composer::{Breakpoint, compose, stretchable_gap};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
@ -638,6 +638,20 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte {
end -= 1;
}
if !hy {
// Keep punctuation and shaped clusters together even on an emergency overset line.
let allowed = |end: usize| end == g.len() || (g[end].byte != g[end - 1].byte && kinsoku_allows(g, end - 1));
let fitted = end;
while end > i && !allowed(end) {
end -= 1;
}
if end == i {
end = fitted.max(i + 1);
while end < g.len() && !allowed(end) {
end += 1;
}
}
}
(end, hy)
}
@ -828,18 +842,27 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
Justify::JustifyAll => (0, true),
};
let justify = justify && regions.is_some();
let (mut per_space, mut per_gap) = (0.0, 0.0);
let (mut per_space, mut per_gap_base) = (0.0, 0.0);
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
let vertical = cx.vertical;
let can_stretch = |j: usize| {
sg.get(j + 1).is_some_and(|next| !next.continues_tcy())
&& (vertical || para.justify == Justify::JustifyAll || stretchable_gap(&sg, j))
};
let gaps = (i..trimmed.saturating_sub(1)).filter(|&j| can_stretch(j)).count();
let gap_em = sg.get(i).map_or(0.0, |g| g.adv);
if justify && (width - w).abs() > EPS {
if spaces > 0 {
// Composed lines may shrink word spaces (never below zero).
per_space =
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
} else if para.justify == Justify::JustifyAll && width > w {
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
if gaps > 0 {
per_gap = (width - w) / gaps as f64;
}
} else if gaps > 0 && width > w && (!cx.vertical || para.justify == Justify::JustifyAll) {
// 两端对齐 for scripts without spaces: widen the CJK character gaps. Half an em
// per gap is the most Chinese composition allows before the line looks tracked
// out, so a line that cannot reach the edge is left ragged. Justify All is the
// exception: there every line must reach the edge, so the gaps take the slack.
let per = (width - w) / gaps as f64;
per_gap_base = if para.justify == Justify::JustifyAll { per } else { per.min(gap_em * 0.5) };
}
}
let start_x = if justify {
@ -870,9 +893,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
} else if j < trimmed {
if g.is_space() {
adv += per_space;
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
// Between glyphs, never inside a tate-chu-yoko block (one cell).
adv += per_gap;
} else if j + 1 < trimmed && can_stretch(j) {
adv += per_gap_base;
}
}
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);

View File

@ -375,6 +375,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_cjk;
#[cfg(test)]
mod tests_embed;
#[cfg(test)]
mod tests_scripts;

View File

@ -107,7 +107,8 @@ pub(crate) fn no_line_start(c: char) -> bool {
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ'
| '…' | '‥' | '—' | '%' | '%')
}
/// Kinsoku: a character that can't end a line (opening brackets).
@ -137,10 +138,60 @@ pub(crate) fn no_line_end(c: char) -> bool {
)
}
fn is_cjk(c: char) -> bool {
pub(crate) fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
}
pub(crate) fn can_break_between(left: char, right: Option<char>) -> bool {
if left == SOFT_HYPHEN {
return true;
}
let Some(right) = right else { return true };
!no_line_end(left) && !no_line_start(right)
}
/// Half-width, in em, trimmed from each side of full-width punctuation (标点挤压).
///
/// Chinese composition trims an opening bracket on the left and a closing bracket on the right
/// by up to half an em; comma/full-stop forms lose the blank on the right. Proportional Latin
/// curly quotes keep their font's spacing. Actual side bearings limit the trim below.
/// The trim is applied to the glyph's offset and advance, so line breaking and justification see
/// the composed width.
pub(crate) fn cjk_punctuation_trim(c: char) -> (f64, f64) {
let (l, r) = cjk_punctuation_trim_impl(c);
(l, r)
}
fn cjk_punctuation_trim_impl(c: char) -> (f64, f64) {
// Tight punctuation: a comma or full stop hugs the character before it and takes half an em.
if matches!(c, '\u{3001}' | '\u{3002}' | '\u{FF0C}' | '\u{FF0E}' | '\u{FF1A}' | '\u{FF1B}' | '\u{FF01}' | '\u{FF1F}' | '\u{FF61}' | '\u{FF64}')
|| matches!(c, '\u{FE10}'..='\u{FE13}')
{
return (0.0, 0.5);
}
// Closing forms carry the blank half on the right.
if matches!(c, '\u{3009}' | '\u{300B}' | '\u{300D}' | '\u{300F}' | '\u{3011}' | '\u{3015}' | '\u{3017}' | '\u{3019}' | '\u{301B}')
|| matches!(c, '\u{FE18}')
|| matches!(c, '\u{FF09}' | '\u{FF3D}' | '\u{FF5D}' | '\u{FF60}' | '\u{FF63}')
|| matches!(c, '\u{301E}' | '\u{301F}')
{
return (0.0, 0.5);
}
// Opening forms carry the blank half on the left.
if matches!(c, '\u{3008}' | '\u{300A}' | '\u{300C}' | '\u{300E}' | '\u{3010}' | '\u{3014}' | '\u{3016}' | '\u{3018}' | '\u{301A}')
|| matches!(
c,
'\u{FE17}' | '\u{FE35}' | '\u{FE37}' | '\u{FE39}' | '\u{FE3B}' | '\u{FE3D}' | '\u{FE3F}' | '\u{FE41}' | '\u{FE43}' | '\u{FE47}'
)
|| matches!(c, '\u{FF08}' | '\u{FF3B}' | '\u{FF5B}' | '\u{FF5F}' | '\u{FF62}')
|| matches!(c, '\u{301D}')
{
return (0.5, 0.0);
}
// The interpunct and the ellipsis keep their full width but centre in it.
(0.0, 0.0)
}
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
let vs = st.v_scale / 100.0;
@ -187,7 +238,8 @@ pub(crate) fn shape_range(
} else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) {
f.clone()
} else {
let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone());
// Ask for the run's own weight, so a bold Chinese run gets a bold CJK face.
let f = db.fallback_for_weighted(c, primary.id(), crate::fontdb::style_weight(&st.font_style)).unwrap_or_else(|| primary.clone());
cache.push((c, f.clone()));
f
};
@ -296,6 +348,26 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
} else if last_in_cluster {
adv += tracking + manual_kern;
}
// 标点挤压, once per cluster: trim the outer blank of brackets and the right blank of
// comma/full-stop forms. Never take more than half the original cell or touch the ink.
let (tl, tr) = if last_in_cluster && !feats.vertical { cjk_punctuation_trim(ch) } else { (0.0, 0.0) };
let (mut tl, mut tr) = (tl * size * hs, tr * size * hs);
// Trim only actual side bearings: a font may already use narrow or centred forms.
if (tl > 0.0 || tr > 0.0)
&& let Some(bounds) =
face.skrifa().and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(skrifa::GlyphId::new(gid)))
{
tl = tl.min(((bounds.x_min as f64 + xo as f64) * k * hs).max(0.0));
tr = tr.min(((xa as f64 - bounds.x_max as f64 - xo as f64) * k * hs).max(0.0));
}
let room = adv.max(0.0) * 0.5;
let spend = tl + tr;
if spend > room && spend > 0.0 {
let scale = room / spend;
tl *= scale;
tr *= scale;
}
adv -= tl + tr;
out.push(SGlyph {
face: face.clone(),
gid,
@ -303,7 +375,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
len: end.saturating_sub(cl).max(1),
run,
adv,
dx: xo as f64 * k * hs,
dx: xo as f64 * k * hs - tl,
dy: -(yo as f64) * k * vs,
sx: k * hs,
sy: k * vs,

View File

@ -0,0 +1,204 @@
//! Chinese composition (中文排版): 避头尾 line breaking, 标点挤压 and 两端对齐.
//!
//! Regression coverage for shared CJK punctuation break restrictions, side-bearing-limited
//! punctuation compression, and horizontal inter-character justification. This is an initial
//! composition implementation, not a claim of full typesetting-standard conformance.
use super::*;
use crate::shape::can_break_between;
use kurbo::{Affine, Shape};
use vectorcraft_doc::{CharStyle, Justify, TextKind};
use vectorcraft_geom::PathData;
/// Deterministic on native and web, without installed system fonts.
fn db() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(Vec::new()))
}
fn has_chinese_font() -> bool {
let available = db().face("Noto Sans CJK SC", "Regular").is_some_and(|face| face.glyph_for('中') != 0);
if !available {
eprintln!("skipped: Chinese glyph metrics require the optional craft-fonts input");
}
available
}
fn style(size: f64) -> CharStyle {
CharStyle { size, font_family: "Noto Sans CJK SC".into(), ..CharStyle::default() }
}
fn cjk_area(text: &str, frame: Rect, justify: Justify) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style(12.0));
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t
}
/// The advance the face gives `c` before any 标点挤压.
fn raw_advance(family: &str, c: char, size: f64) -> f64 {
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
let f = db().face(family, "Regular").expect("face");
let gid = f.glyph_for(c);
assert_ne!(gid, 0, "{family} {c}: missing glyph");
let sk = f.skrifa().expect("skrifa");
let m = sk.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(skrifa::GlyphId::new(gid));
f64::from(m.unwrap_or(0.0)) / f.units_per_em() * size
}
/// The advance the layout gives a one-character text object of `c`.
fn laid_advance(family: &str, c: char, size: f64) -> f64 {
let st = CharStyle { font_family: family.into(), size, ..CharStyle::default() };
layout(db(), &TextObject::point(Point::ZERO, c.to_string().as_str(), st)).glyphs.first().expect("glyph").advance
}
#[test]
fn kinsoku_keeps_punctuation_with_its_text() {
// Closing punctuation and closing brackets may not begin a line.
for &c in &[',', '。', '、', ';', ':', '!', '?', ')', '】', '》', '」', '”', '’', '…'] {
assert!(!can_break_between('字', Some(c)), "{c} must not begin a line");
}
// CJK opening brackets may not end a line.
for &c in &['(', '【', '《', '「', '“', '‘', '〔', '〖'] {
assert!(!can_break_between(c, Some('字')), "{c} must not end a line");
}
// Opening brackets may start a line; closing brackets may end one.
for &c in &['(', '【', '《', '「'] {
assert!(can_break_between('字', Some(c)), "{c} may begin a line");
}
for c in [')', '】', '》', '」', '〕', '〗'] {
assert!(can_break_between(c, Some('字')), "{c} may end a line");
}
// Latin brackets keep Western behaviour: a break before them is fine.
for &c in &['(', '[', '{'] {
assert!(can_break_between('a', Some(c)), "a Latin word may break before {c}");
assert!(!can_break_between(c, Some('a')), "{c} must not end a line");
}
// Ordinary ideographs and mixed CJK/Latin boundaries may break.
assert!(can_break_between('中', Some('文')));
assert!(can_break_between('中', Some('a')));
assert!(can_break_between('a', Some('中')));
assert!(can_break_between('中', None), "the paragraph end is always a break");
}
#[test]
fn punctuation_is_squeezed_on_its_outer_side() {
if !has_chinese_font() {
return;
}
for c in ['(', '【', '《', '「'] {
let (r, l) = (raw_advance("Noto Sans CJK SC", c, 20.0), laid_advance("Noto Sans CJK SC", c, 20.0));
assert!(l < r - 1.0, "{c}: laid {l} is not tighter than raw {r}");
assert!(l >= r * 0.5 - 1e-9, "{c}: laid {l} below the half-em floor of {r}");
}
for c in [',', '。', '、'] {
let (r, l) = (raw_advance("Noto Sans CJK SC", c, 20.0), laid_advance("Noto Sans CJK SC", c, 20.0));
assert!(l < r - 1.0 && l >= r * 0.5 - 1e-9, "{c}: laid {l} against raw {r}");
}
}
#[test]
fn no_chinese_punctuation_is_squeezed_out_of_existence() {
if !has_chinese_font() {
return;
}
// The trim must never take more than half a mark's cell, so the advance stays positive and the
// glyph keeps its id even in faces that carry these marks narrower than a full em.
let marks = ",。、;:!?()【】《》「」“”‘’…·";
for family in ["Noto Sans CJK SC", "Source Sans 3"] {
for c in marks.chars() {
let st = CharStyle { font_family: family.into(), size: 20.0, ..CharStyle::default() };
let l = layout(db(), &TextObject::point(Point::ZERO, c.to_string().as_str(), st));
let Some(g) = l.glyphs.first() else { panic!("{family} {c}: no glyph") };
assert!(g.advance > 0.0, "{family} {c}: advance {} squeezed to nothing", g.advance);
assert!(g.gid != 0, "{family} {c}: missing glyph (gid 0)");
}
}
}
#[test]
fn compressed_brackets_do_not_overlap_the_enclosed_text() {
if !has_chinese_font() {
return;
}
let l = layout(db(), &TextObject::point(Point::ZERO, "甲(乙)丙《丁》戊", style(20.0)));
for pair in l.glyphs.windows(2) {
let a = pair[0].outline.bounding_box();
let b = pair[1].outline.bounding_box();
assert!(a.x1 <= b.x0 + 0.01, "overlapping ink: {a:?} and {b:?}");
}
}
#[test]
fn latin_curly_quotes_and_apostrophes_keep_the_fonts_spacing() {
for c in ['‘', '’', '“', '”'] {
let raw = raw_advance("Source Sans 3", c, 40.0);
let laid = laid_advance("Source Sans 3", c, 40.0);
assert!((raw - laid).abs() < 1e-6, "{c}: {raw} vs {laid}");
}
}
#[test]
fn narrow_frames_keep_brackets_with_the_enclosed_character() {
if !has_chinese_font() {
return;
}
let text = "甲甲《乙》丙丙丙";
for width in [12.0, 24.0, 30.0, 40.0, 50.0] {
let l = layout(db(), &cjk_area(text, Rect::new(0.0, 0.0, width, 1000.0), Justify::Left));
for line in &l.lines {
let s = &text[line.start..line.end];
assert!(!s.starts_with('》') && !s.ends_with('《'), "width {width}: {s:?}");
}
}
}
#[test]
fn justified_chinese_reaches_the_frame_edge() {
if !has_chinese_font() {
return;
}
let text: String = "中文排版测试两端对齐标点挤压".repeat(12);
let frame = Rect::new(0.0, 0.0, 200.0, 4000.0);
let l = layout(db(), &cjk_area(&text, frame, Justify::JustifyLeft));
assert!(l.lines.len() > 3, "the text wraps: {} lines", l.lines.len());
for line in &l.lines[..l.lines.len() - 1] {
let avail = line.avail.1 - line.avail.0;
assert!((line.x1 - line.x0 - avail).abs() < 1e-6, "justified line does not reach the edge: {line:?}");
}
// The last line is left ragged rather than stretched.
assert!(l.lines.last().is_some_and(|l| l.x1 - l.x0 < 200.0));
}
#[test]
fn chinese_justification_does_not_track_out_latin_words() {
let frame = Rect::new(0.0, 0.0, 70.0, 4000.0);
let mut text = cjk_area("abcdefghijklmnopqrstuvwx".repeat(4).as_str(), frame, Justify::Left);
text.runs[0].style.font_family = "Source Sans 3".into();
let left = layout(db(), &text);
text.para.justify = Justify::JustifyLeft;
let justified = layout(db(), &text);
assert!(left.lines.len() > 2);
for (a, b) in left.glyphs.iter().zip(&justified.glyphs) {
assert!((a.advance - b.advance).abs() < 1e-9);
}
}
#[test]
fn lines_never_begin_with_closing_punctuation_or_end_with_an_opening_one() {
if !has_chinese_font() {
return;
}
let text: String = "他说:「中文排版,非常讲究。」然后他走了。".repeat(20);
let l = layout(db(), &cjk_area(&text, Rect::new(0.0, 0.0, 120.0, 6000.0), Justify::Left));
assert!(l.lines.len() > 5);
for line in &l.lines {
let s = text.get(line.start..line.end).unwrap_or("");
let first = s.chars().next();
assert!(first.is_none_or(|c| !",。、;:!?)】》」”’…".contains(c)), "line begins with closing punctuation {first:?}: {s:?}");
let last = s.chars().next_back();
assert!(last.is_none_or(|c| !"(【《「“‘[{".contains(c)), "line ends with an opening bracket {last:?}: {s:?}");
}
}

View File

@ -93,15 +93,14 @@ impl CornerDrag {
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's inward diagonal (in the shape's own units), from square to fully round.
/// The start radius plus the pointer's travel along the corner's inward diagonal (in the
/// shape's own units), from square to fully round.
fn radius_at(&self, p: Point) -> f64 {
let w = &self.widgets;
let inv = w.xf.inverse();
let d = inv * p - inv * self.start;
let (_, (sx, sy)) = CORNERS[self.corner];
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
(w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0)
}
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {

View File

@ -163,7 +163,7 @@ fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui:
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
.show(ctx, add);
}
@ -193,20 +193,27 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
sync(app, &mut d);
let t = Tokens::get(ctx);
let mut b = Buttons::default();
let width = (ctx.content_rect().width() - 36.0).clamp(360.0, 1020.0);
let height = (ctx.content_rect().height() - 48.0).clamp(200.0, HEIGHT + 36.0);
window(ctx, KIND, 0, |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.horizontal_top(|ui| {
egui::Frame::NONE.inner_margin(egui::Margin { left: 22, right: 14, top: 14, bottom: 18 }).show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(640.0);
ui.set_min_height(HEIGHT - 32.0);
ui.set_width((width - DETAILS - 72.0).max(160.0));
ui.set_height(height - 32.0);
presets(app, ui, &mut d);
});
});
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(DETAILS);
ui.set_min_height(HEIGHT);
details(app, ui, &mut d, &mut b);
ui.set_height(height - 36.0);
egui::ScrollArea::vertical().id_salt("newdoc-details").max_height(height - 80.0).auto_shrink([false, false]).show(ui, |ui| {
details(app, ui, &mut d);
});
ui.add_space(12.0);
footer(ui, &mut b, "Create");
});
});
});
@ -219,7 +226,8 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let names: Vec<&str> = newdoc::category_names().collect();
let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0);
if let Some(i) = widgets::tab_bar(ui, &names, cat) {
let tab = egui::ScrollArea::horizontal().id_salt("newdoc-tabs").show(ui, |ui| widgets::tab_bar(ui, &names, cat)).inner;
if let Some(i) = tab {
d.fields.insert("category".into(), json!(names[i]));
}
let list = names.get(cat).and_then(|c| newdoc::category(&app.session, c)).unwrap_or_default();
@ -238,7 +246,7 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.label(egui::RichText::new(hint).color(t.text_dim));
return;
}
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(ui.available_height()).auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"));
@ -296,10 +304,9 @@ pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::
}
/// The Preset Details column.
fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut Buttons) {
fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0);
let top = ui.cursor().top();
ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(8.0);
ui.horizontal(|ui| {
@ -351,10 +358,11 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
if widgets::flat_button(ui, tl!("More Settings"), DETAILS).clicked() {
d.kind = MORE.into();
}
// Close and Create at the bottom right.
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
}
fn footer(ui: &mut egui::Ui, b: &mut Buttons, create: &str) {
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, tl!("Create")).clicked();
b.create = widgets::primary_button(ui, tl!(create)).clicked();
ui.add_space(8.0);
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
});
@ -491,87 +499,89 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.set_width(560.0);
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
widgets::label_row(ui, tl!("Name:"), L, |ui| {
form::text(ui, &mut d, "name", 300.0);
});
// Profile: the preset categories (the tabs); Size: the profile's presets.
let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect();
let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]");
widgets::label_row(ui, tl!("Profile:"), L, |ui| {
if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) {
d.fields.insert("category".into(), json!(p));
if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) {
apply(&mut d, &first);
}
}
});
ui.add_space(6.0);
let (lay, cols, spacing, rtl) = layout(&d);
let n = d.f64("artboards", 1.0);
let several = n > 1.0;
widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| {
artboards(ui, &mut d, 76.0);
ui.add_space(10.0);
for (l, icon) in [
(ArtboardLayout::GridByRow, "dc-grid-view"),
(ArtboardLayout::GridByColumn, "grid-3x3"),
(ArtboardLayout::Row, "arrow-left-right"),
(ArtboardLayout::Column, "arrow-up-down"),
] {
if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() {
set_layout(&mut d, "layout", json!(l.id()));
}
}
ui.add_space(6.0);
if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() {
set_layout(&mut d, "rightToLeft", json!(!rtl));
}
});
ui.add_enabled_ui(several, |ui| {
widgets::label_row(ui, tl!("Spacing:"), L, |ui| {
if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) {
set_layout(&mut d, "spacing", json!(v.max(0.0)));
}
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
ui.add_enabled_ui(grid, |ui| {
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64));
egui::ScrollArea::vertical().id_salt("newdoc-more-body").max_height((ctx.content_rect().height() - 160.0).max(100.0)).show(ui, |ui| {
widgets::label_row(ui, tl!("Name:"), L, |ui| {
form::text(ui, &mut d, "name", 300.0);
});
// Profile: the preset categories (the tabs); Size: the profile's presets.
let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect();
let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]");
widgets::label_row(ui, tl!("Profile:"), L, |ui| {
if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) {
d.fields.insert("category".into(), json!(p));
if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) {
apply(&mut d, &first);
}
}
});
ui.add_space(6.0);
let (lay, cols, spacing, rtl) = layout(&d);
let n = d.f64("artboards", 1.0);
let several = n > 1.0;
widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| {
artboards(ui, &mut d, 76.0);
ui.add_space(10.0);
for (l, icon) in [
(ArtboardLayout::GridByRow, "dc-grid-view"),
(ArtboardLayout::GridByColumn, "grid-3x3"),
(ArtboardLayout::Row, "arrow-left-right"),
(ArtboardLayout::Column, "arrow-up-down"),
] {
if widgets::icon_button_enabled(ui, icon, l.label(), lay == l, several, 26.0).clicked() {
set_layout(&mut d, "layout", json!(l.id()));
}
}
ui.add_space(6.0);
if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() {
set_layout(&mut d, "rightToLeft", json!(!rtl));
}
});
ui.add_enabled_ui(several, |ui| {
widgets::label_row(ui, tl!("Spacing:"), L, |ui| {
if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) {
set_layout(&mut d, "spacing", json!(v.max(0.0)));
}
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
ui.add_enabled_ui(grid, |ui| {
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
set_layout(&mut d, "columns", json!(v.round().clamp(1.0, n) as u64));
}
});
});
});
ui.add_space(6.0);
let sizes = newdoc::category(&app.session, profile).unwrap_or_default();
let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect();
let preset = d.str("preset");
widgets::label_row(ui, tl!("Size:"), L, |ui| {
// The profile's sizes are built in; the preset may be one the user saved.
let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) };
if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) {
apply(&mut d, s);
}
});
let unit = unit(&d);
widgets::label_row(ui, tl!("Width:"), L, |ui| {
form::length(ui, &mut d, "width", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Units:")).color(t.text));
units_dropdown(ui, &mut d, 120.0);
});
widgets::label_row(ui, tl!("Height:"), L, |ui| {
form::length(ui, &mut d, "height", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text));
orientation(ui, &mut d);
});
ui.add_space(4.0);
widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0));
ui.add_space(4.0);
widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0));
ui.add_space(8.0);
advanced(ui, &mut d, L, 220.0);
});
ui.add_space(6.0);
let sizes = newdoc::category(&app.session, profile).unwrap_or_default();
let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect();
let preset = d.str("preset");
widgets::label_row(ui, tl!("Size:"), L, |ui| {
// The profile's sizes are built in; the preset may be one the user saved.
let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) };
if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) {
apply(&mut d, s);
}
});
let unit = unit(&d);
widgets::label_row(ui, tl!("Width:"), L, |ui| {
form::length(ui, &mut d, "width", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Units:")).color(t.text));
units_dropdown(ui, &mut d, 120.0);
});
widgets::label_row(ui, tl!("Height:"), L, |ui| {
form::length(ui, &mut d, "height", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text));
orientation(ui, &mut d);
});
ui.add_space(4.0);
widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0));
ui.add_space(4.0);
widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0));
ui.add_space(8.0);
advanced(ui, &mut d, L, 220.0);
ui.add_space(16.0);
ui.horizontal(|ui| {
templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked();
@ -602,6 +612,37 @@ mod tests {
use crate::VectorcraftApp;
#[test]
fn create_controls_stay_visible_on_short_screens() {
for height in [500.0, 600.0] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.newDialog", json!({})).unwrap();
set(&mut app, "advanced", json!(true));
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, height));
for (kind, label) in [(super::KIND, "Create"), (super::MORE, "Create Document")] {
app.ui.dialog.as_mut().unwrap().kind = kind.into();
let mut found = false;
for _ in 0..4 {
let mut out =
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| crate::dialogs::show(&mut app, ui.ctx()));
for shape in &out.shapes {
if let egui::Shape::Text(text) = &shape.shape
&& text.galley.text() == label
{
assert!(screen.contains_rect(text.galley.rect.translate(text.pos.to_vec2())), "{kind}: create button outside screen");
found = true;
}
}
out.textures_delta.clear();
}
assert!(found, "{kind}: visible create button");
}
}
}
/// Cards translate the built-in presets' names only: Recent and Saved show the user's names.
#[test]
fn only_built_in_preset_names_are_translated() {

View File

@ -274,21 +274,6 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
}
#[test]
fn menu_parameter_dialogs_are_compact() {
// #292: Object › Path › Simplify (and its neighbours) spanned the whole window.
for id in ["object.path.simplify", "object.path.offsetPath", "object.move", "object.rotate", "object.path.splitIntoGrid", "path.average"] {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
crate::menus::invoke(&mut app, id, json!({}));
assert!(app.ui.dialog.is_some(), "{id} opened no dialog");
let wide = dialog_rect(&mut app, 1600.0);
eprintln!("{id}: {:.0} × {:.0} in a 1600-point window", wide.width(), wide.height());
assert!(wide.width() < 420.0, "{id} is {:.0} wide in a 1600-point window", wide.width());
}
}
/// A list mixing built-in labels with names translates only the built-in entries: names that
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
#[test]

View File

@ -86,38 +86,57 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(id) = app.ui.open_panel.clone() else { return };
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
let display = tl!(label);
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256
// points grows towards the canvas rather than over the panel icons.
let right = screen.right() - ICON_COL - 300.0 - 6.0;
let rail = egui::containers::panel::PanelState::load(ctx, egui::Id::new("icon_column"))
.map_or(screen.right() - ICON_COL - 300.0, |p| p.outer_rect.left());
let right = (rail - 2.0).max(screen.left() + 1.0);
let top = (screen.top() + 110.0).min(screen.bottom() - 100.0).max(screen.top());
let bounds = egui::Rect::from_min_max(screen.left_top(), egui::pos2(right, screen.bottom()));
let width = (bounds.width() - 2.0).clamp(1.0, 400.0);
let mut open = true;
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(256.0);
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let tab = egui::Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
if cr.clicked() {
open = false;
}
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, &id, menu_r);
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(236.0);
panels::show_icon_panel(app, ui, &id);
egui::Area::new(egui::Id::new("icon-panel"))
.order(egui::Order::Foreground)
.anchor(egui::Align2::RIGHT_TOP, vec2(0.0, top - screen.top()))
.constrain_to(bounds)
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_min_width(width.min(280.0));
ui.set_max_width(width);
ui.set_max_height((screen.bottom() - top - 2.0).max(28.0));
let (mut strip, _) = ui.allocate_exact_size(vec2(width.min(280.0), 26.0), Sense::hover());
egui::ScrollArea::both()
.id_salt(("icon-panel-body", &id))
.max_width(width)
.max_height((screen.bottom() - top - 34.0).max(1.0))
.auto_shrink([true, true])
.show(ui, |ui| {
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(width.min(280.0) - 20.0);
panels::show_icon_panel(app, ui, &id);
});
});
// Content can require more room than the preferred width. Size the entire header
// after laying it out, keeping its controls at the actual panel edge.
strip.max.x = ui.min_rect().right();
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let tab = egui::Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(display.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, display, theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
if cr.clicked() {
open = false;
}
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, &id, menu_r);
});
});
});
if !open {
app.ui.open_panel = None;
}
@ -131,6 +150,54 @@ mod tests {
use super::*;
use crate::toolbar::tests::{wheel, widget_rects};
#[test]
fn all_floating_panels_fit_beside_the_actual_dock_and_share_a_full_width_header() {
for (size, dock_width, document) in
[(vec2(1440.0, 900.0), 230.0, true), (vec2(800.0, 500.0), 520.0, true), (vec2(1440.0, 900.0), 300.0, false)]
{
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut app = VectorcraftApp::new(Session::new(), Default::default());
if document {
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
}
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
for &(id, _, _) in ICON_PANELS {
app.ui.open_panel = Some(id.into());
app.ui.status.clear();
let mut out = egui::FullOutput::default();
for k in 0..4 {
out = ctx.run_ui(egui::RawInput { time: Some(f64::from(k) + 1.0), screen_rect: Some(screen), ..Default::default() }, |ui| {
egui::Panel::right("dock").exact_size(dock_width).show(ui, |_| {});
egui::Panel::right("icon_column").exact_size(ICON_COL).show(ui, |_| {});
floating_panel(&mut app, ui.ctx());
});
out.textures_delta.clear();
}
let area = egui::AreaState::load(&ctx, egui::Id::new("icon-panel")).unwrap().rect();
let rail = egui::containers::panel::PanelState::load(&ctx, egui::Id::new("icon_column")).unwrap().outer_rect.left();
assert!(screen.contains_rect(area), "{id}: {area:?} outside {screen:?}");
assert!(app.ui.status.is_empty(), "{id}: painting a panel reported an error: {}", app.ui.status);
assert!(area.right() <= rail, "{id}: panel overlaps the icon column");
if id == "stroke" && size.y >= 900.0 {
assert!(area.height() > 400.0, "a tall panel should use available height before scrolling: {area:?}");
}
let header = out
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).panel_darker && (r.rect.height() - 26.0).abs() < 0.1 => Some(r.rect),
_ => None,
})
.expect("header");
assert!((header.width() - area.width()).abs() <= 2.0, "{id}: header {header:?}, panel {area:?}");
assert!(widget_rects(&ctx, vec2(14.0, 14.0)).iter().any(|r| header.contains_rect(*r)), "{id}: close control inside header");
}
}
}
#[test]
fn every_flyout_stays_left_of_the_icon_column() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());

View File

@ -60,7 +60,7 @@ fn plural_czech(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 4] = [
pub static LANGUAGES: [LangInfo; 5] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Japanese: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs
// not yet. `complete_menus` once the catalog covers every menu string and `tl!` literal.
@ -77,6 +77,14 @@ pub static LANGUAGES: [LangInfo; 4] = [
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "zh-hans",
name: "简体中文",
source: include_str!("zh-hans.tsv"),
plural: plural_none,
complete_menus: true,
catalog: OnceLock::new(),
},
];
impl LangInfo {

View File

@ -23,7 +23,7 @@ fn tags_map_to_languages() {
// Simplified Chinese locales never pick up the Traditional catalog (they resolve to a
// `zh-hans` catalog once one is registered, and to English until then).
for tag in ["zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh"] {
assert_ne!(lang_from_tag(tag), Some(ZH()), "{tag}");
assert_eq!(lang_from_tag(tag), Lang::from_code("zh-hans"), "{tag}");
}
assert_eq!(lang_from_tag(""), None);
assert_eq!(lang_from_tag("_"), None);
@ -51,6 +51,7 @@ fn os_language_lists_are_parsed() {
#[test]
fn preferences_resolve_with_fallback() {
assert_eq!(Lang::from_pref("zh-Hans"), Lang::from_code("zh-hans").unwrap());
assert_eq!(Lang::from_pref("zh-hant"), ZH());
assert_eq!(Lang::from_pref("ZH-Hant"), ZH());
assert_eq!(Lang::from_pref("en"), Lang::EN);

File diff suppressed because it is too large Load Diff

View File

@ -274,6 +274,7 @@ pub struct VectorcraftApp {
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
styled: bool,
font_language: Option<&'static str>,
fonts_ready: bool,
/// Installed fonts added to the UI's for characters its own fonts lack (CJK names…).
ui_fonts: ui_fonts::UiFonts,
@ -360,6 +361,7 @@ impl VectorcraftApp {
screenshot_token: 0,
synthetic: vec![],
styled: false,
font_language: None,
fonts_ready: false,
ui_fonts: Default::default(),
frame: 0,
@ -669,8 +671,11 @@ impl VectorcraftApp {
fn logic_frame(&mut self, ctx: &egui::Context) {
i18n::set_current(self.ui_language());
if !self.styled {
if !self.styled || self.font_language != Some(self.ui_language().code()) {
theme::install_fonts(ctx);
self.font_language = Some(self.ui_language().code());
self.fonts_ready = false;
self.ui_fonts = Default::default();
theme::apply(ctx, self.ui.brightness);
egui_extras::install_image_loaders(ctx);
self.styled = true;

View File

@ -2343,16 +2343,14 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let tree = menu_tree();
let end = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
};
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
})
.inner;
@ -2362,39 +2360,6 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
end
}
/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no
/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must
/// not switch to the title under it).
fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool {
title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id)
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
/// it scrolls when it is taller than the window.
fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
@ -2989,99 +2954,6 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
mod tests {
use super::*;
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
/// (rect, id of the title's popup).
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
let mut layer = None;
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
layer = Some(ui.layer_id());
menu_bar(app, ui);
});
out.textures_delta.clear();
let layer = layer.unwrap();
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(layer)
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
// `egui::Popup::default_response_id` of the title's response.
.map(|w| (w.rect, w.id.with("popup")))
.collect()
});
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
titles
}
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
}
#[test]
fn hovering_another_title_switches_the_open_menu() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
bar_frame(&mut app, &ctx, vec![]);
let titles = bar_frame(&mut app, &ctx, vec![]);
assert_eq!(titles.len(), menu_tree().len());
// 0 is the app menu, then File, Edit, Object.
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut t = bar_frame(app, &ctx, events);
for _ in 0..2 {
t = bar_frame(app, &ctx, vec![]);
}
t
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
// Nothing open: hovering a title opens nothing.
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert!(open_titles(&ctx, &t).is_empty());
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
frames(&mut app, vec![button(file, true)]);
let t = frames(&mut app, vec![button(file, false)]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
// stays open while the pointer stays still over Object.
egui::Popup::open_id(&ctx, t[1].1);
let t = frames(&mut app, vec![]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
}
#[test]
fn a_popup_covering_a_title_does_not_switch_menus() {
let ctx = egui::Context::default();
let p = egui::pos2(90.0, 12.0);
let mut reaches = (true, false);
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
for _ in 0..3 {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
let ctx = ui.ctx().clone();
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click());
let q = p + egui::vec2(0.0, 100.0);
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click());
egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
ui.allocate_space(egui::vec2(20.0, 20.0));
});
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
});
out.textures_delta.clear();
}
assert_eq!(reaches, (false, true));
}
#[test]
fn names_in_menus_are_not_interface_labels() {
for (id, label) in [

View File

@ -17,6 +17,9 @@ const KINDS: [(&str, &str); 5] =
/// (name, type) of every brush in the active document's library.
pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<String>) {
if app.session.active().is_none() {
return (vec![], None);
}
let Ok(v) = app.run("brush.list", json!({})) else { return (vec![], None) };
let list = v["brushes"]
.as_array()

View File

@ -67,11 +67,23 @@ pub fn restore(app: &mut VectorcraftApp) {
&& code != "en"
&& crate::i18n::Lang::from_code(&code).is_some()
{
p.interface_language = code;
p.interface_language = crate::i18n::Lang::from_code(&code).map_or(code.clone(), |lang| lang.code().to_string());
}
app.session.apply_prefs(p);
}
#[cfg(test)]
#[test]
fn old_chinese_locale_migrates_without_overwriting_an_explicit_new_preference() {
for (prefs, expected) in [(Value::Null, "zh-hans"), (json!({"interfaceLanguage": "ja"}), "ja")] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.ui = serde_json::from_value(json!({"locale": "zh-Hans", "engine_prefs": prefs})).unwrap();
restore(&mut app);
assert_eq!(app.session.prefs.interface_language, expected);
assert!(app.ui.legacy_language.is_none());
}
}
#[derive(Clone, Copy, PartialEq)]
struct Applied {
brightness: Brightness,

View File

@ -215,7 +215,7 @@ pub struct UiState {
/// The interface language older versions saved here (`ja`, `cs`…). It now lives in the
/// `interfaceLanguage` preference, which [`crate::prefs_dialog::restore`] carries it over to;
/// it is never written back.
#[serde(rename = "language", skip_serializing)]
#[serde(rename = "language", alias = "locale", skip_serializing)]
pub legacy_language: Option<String>,
pub brightness: Brightness,
pub dock_tab: DockTab,

View File

@ -267,12 +267,20 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
/// every family, after the app's own fonts and before the installed fonts `ui_fonts` adds: BIZ
/// UDPGothic first (bold first in the semibold family), then the Mincho faces.
fn add_craft_fonts(fonts: &mut FontDefinitions) {
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) {
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.scripts.contains(&"Hans")) {
let data = if f.scripts.contains(&"Hans") { crate::ui_fonts::craft_font_data(f) } else { FontData::from_static(f.bytes) };
fonts.font_data.insert(craft_font_name(f), Arc::new(data));
}
for (family, stack) in fonts.families.iter_mut() {
let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into());
let chinese: Vec<String> = vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans")).map(craft_font_name).collect();
if crate::i18n::current().code() == "zh-hans" {
stack.extend(chinese.iter().cloned());
}
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name));
if crate::i18n::current().code() != "zh-hans" {
stack.extend(chinese);
}
}
}

View File

@ -12,20 +12,23 @@ use std::collections::HashSet;
use std::sync::Arc;
use egui::epaint::Shape;
use egui::epaint::text::FontTweak;
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
use egui::{FontData, FontFamily, FontId, LayerId};
use vectorcraft_text::{FontDb, FontFace};
type FallbackFace = (FontFamily, Arc<FontFace>);
#[derive(Default)]
pub(crate) struct UiFonts {
/// Characters looked at already: the UI fonts have them, a fallback was added (or is being
/// looked for), or no font has them.
seen: HashSet<char>,
seen: HashSet<(FontFamily, char)>,
/// The fonts added to egui, by name.
added: HashSet<String>,
added: HashSet<(FontFamily, String)>,
/// The faces covering the characters being looked for (native: looked for on a thread).
#[cfg(not(target_arch = "wasm32"))]
pending: Option<std::sync::mpsc::Receiver<Vec<Arc<FontFace>>>>,
pending: Option<std::sync::mpsc::Receiver<Vec<FallbackFace>>>,
}
impl UiFonts {
@ -42,12 +45,14 @@ impl UiFonts {
return;
}
let find = move || {
let mut faces: Vec<Arc<FontFace>> = vec![];
for c in missing {
if let Some(f) = FontDb::global().face_covering(c)
&& !faces.iter().any(|x| x.id() == f.id())
let mut faces: Vec<FallbackFace> = vec![];
for (family, c) in missing {
// The face is looked up at the weight the character was painted in, so a semibold
// heading gets a semibold CJK face rather than the family's regular one.
if let Some(f) = FontDb::global().face_covering_weighted(c, family_weight(&family))
&& !faces.iter().any(|(fam, x)| *fam == family && x.id() == f.id())
{
faces.push(f);
faces.push((family, f));
}
}
faces
@ -74,7 +79,7 @@ impl UiFonts {
/// Characters painted this frame that the UI fonts lack and haven't been looked for, now
/// marked seen. None while a search is running.
fn missing(&mut self, ctx: &egui::Context) -> Vec<char> {
fn missing(&mut self, ctx: &egui::Context) -> Vec<(FontFamily, char)> {
#[cfg(not(target_arch = "wasm32"))]
if self.pending.is_some() {
return vec![];
@ -83,12 +88,17 @@ impl UiFonts {
if !layers.contains(&LayerId::background()) {
layers.push(LayerId::background());
}
let mut chars: Vec<char> = vec![];
let mut chars = HashSet::new();
let seen = &self.seen;
let mut note = |text: &str| {
let mut note = |text: &str, family: &FontFamily, _size: f32| {
// Most UI text is ASCII, which the UI fonts cover.
if !text.is_ascii() {
chars.extend(text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !c.is_control() && !seen.contains(c)));
chars.extend(
text.chars()
.filter(|c| !c.is_ascii() && !c.is_whitespace() && !c.is_control())
.map(|c| (family.clone(), c))
.filter(|key| !seen.contains(key)),
);
}
};
ctx.graphics(|g| {
@ -98,30 +108,25 @@ impl UiFonts {
}
}
});
if chars.is_empty() {
return chars;
}
chars.sort_unstable();
chars.dedup();
let font = FontId::new(13.0, FontFamily::Proportional);
let missing: Vec<char> = ctx.fonts_mut(|f| chars.iter().copied().filter(|c| !f.has_glyph(&font, *c)).collect());
let missing = ctx.fonts_mut(|f| chars.iter().filter(|(family, c)| !f.has_glyph(&FontId::new(13.0, family.clone()), *c)).cloned().collect());
self.seen.extend(chars);
missing
}
/// Add `faces` to every egui family, after the fonts it has.
fn install(&mut self, ctx: &egui::Context, faces: &[Arc<FontFace>]) {
let families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for face in faces {
/// Add each face only to the family that requested its weight.
fn install(&mut self, ctx: &egui::Context, faces: &[FallbackFace]) {
for (family, face) in faces {
let name = format!("{} {} #{}", face.family, face.style, face.face_index());
if !self.added.insert(name.clone()) {
if !self.added.insert((family.clone(), name.clone())) {
continue;
}
// The face's file data, shared with the process-wide font database (which keeps every
// face it loads for the session) instead of copied: one handle is leaked per font added.
let face: &'static FontFace = Box::leak(Box::new(face.clone()));
let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) };
let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect();
// Align fallback metrics with the Latin UI baseline without changing the point size.
let tweak = latin_face().map_or_else(FontTweak::default, |latin| baseline_tweak(face, &latin));
let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }.tweak(tweak);
let families = vec![InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }];
ctx.add_font(FontInsert { name, data, families });
}
if !faces.is_empty() {
@ -139,18 +144,86 @@ impl UiFonts {
}
/// Call `f` with the text of `shape` and the shapes it holds.
fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) {
fn painted_text(shape: &Shape, f: &mut impl FnMut(&str, &FontFamily, f32)) {
match shape {
Shape::Text(t) => f(t.galley.text()),
Shape::Text(t) => {
let text = t.galley.text();
for section in &t.galley.job.sections {
let Some(slice) = text.get(section.byte_range.start.0..section.byte_range.end.0) else { continue };
f(slice, &section.format.font_id.family, section.format.font_id.size);
}
}
Shape::Vec(v) => v.iter().for_each(|s| painted_text(s, f)),
_ => {}
}
}
fn baseline_tweak(face: &FontFace, family: &FontFace) -> FontTweak {
FontTweak { scale: 1.0, y_offset_factor: baseline_tweak_factor(face, family), ..Default::default() }
}
fn baseline_tweak_factor(face: &FontFace, family: &FontFace) -> f32 {
let em = |f: &FontFace| {
let (ascent, descent) = f.vertical_metrics();
let upem = f.units_per_em().max(1.0);
(ascent / upem, (ascent + descent) / upem)
};
let (face_ascent, face_height) = em(face);
let (family_ascent, family_height) = em(family);
baseline_factor(face_ascent, face_height, family_ascent, family_height)
}
fn baseline_factor(face_ascent: f64, face_height: f64, family_ascent: f64, family_height: f64) -> f32 {
// Positive `y_offset_factor` moves a glyph down, and epaint counts the tweak in the pen
// position as well as the raster offset, so the correction is twice the fallback's own
// displacement, with the sign that brings it back to the family's ink.
(2.0 * (face_ascent - family_ascent + 0.5 * (family_height - face_height))) as f32
}
fn latin_face() -> Option<Arc<FontFace>> {
static METRICS: std::sync::OnceLock<Option<Arc<FontFace>>> = std::sync::OnceLock::new();
METRICS
.get_or_init(|| {
let db = vectorcraft_text::FontDb::with_font_dirs(Vec::new());
db.add_font(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec());
db.face("Source Sans 3", "Regular")
})
.clone()
}
fn family_weight(family: &FontFamily) -> f32 {
if *family == FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into()) { 600.0 } else { 400.0 }
}
pub(crate) fn craft_font_data(font: &vectorcraft_text::CraftFont) -> FontData {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
let db = DB.get_or_init(|| FontDb::with_font_dirs(Vec::new()));
let tweak = db.face(font.family, font.style).zip(latin_face()).map_or_else(FontTweak::default, |(face, latin)| baseline_tweak(&face, &latin));
FontData::from_static(font.bytes).tweak(tweak)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_characters_are_tracked_per_family_not_font_size() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut fonts = UiFonts::default();
ctx.run_ui(Default::default(), |ui| {
ui.label(egui::RichText::new("\u{10FFFD}").font(FontId::proportional(26.0)));
ui.label(egui::RichText::new("\u{10FFFD}").font(crate::theme::semibold(12.0)));
let missing = fonts.missing(ui.ctx());
assert_eq!(missing.len(), 2);
let mut weights: Vec<_> = missing.iter().map(|(family, _)| family_weight(family) as u32).collect();
weights.sort_unstable();
assert_eq!(weights, [400, 600]);
})
.textures_delta
.clear();
}
/// Run a frame painting `text`, then the fallback search after it.
fn frame(ctx: &egui::Context, fonts: &mut UiFonts, text: &str) {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
@ -224,7 +297,9 @@ mod tests {
crate::theme::install_fonts(&ctx);
let mut fonts = UiFonts::default();
frame(&ctx, &mut fonts, text);
assert!(!has_glyph(&ctx, '标'), "the UI fonts have no CJK");
if vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Hans")) {
assert!(has_glyph(&ctx, '标'), "Chinese is available without waiting for system fonts");
}
fonts.finish(&ctx);
// The fonts arrive with the next frame.
frame(&ctx, &mut fonts, text);