mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Merge pull request #172 from storytold/fix/issue-81
Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
This commit is contained in:
@@ -675,6 +675,13 @@ impl FontDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// A face that covers `c`, for text drawn outside the canvas (the app's own UI): the loaded
|
||||
/// fonts first, then (native) an installed one, loaded on demand. Characters no font covers
|
||||
/// are remembered, so they are looked for once.
|
||||
pub fn face_covering(&self, c: char) -> Option<Arc<FontFace>> {
|
||||
self.fallback_for(c, 0)
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod sysclip;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
mod ui_fonts;
|
||||
pub mod unsaved;
|
||||
pub mod widgets;
|
||||
pub mod workspaces;
|
||||
@@ -257,6 +258,8 @@ pub struct VectorcraftApp {
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
styled: bool,
|
||||
fonts_ready: bool,
|
||||
/// Installed fonts added to the UI's for characters its own fonts lack (CJK names…).
|
||||
ui_fonts: ui_fonts::UiFonts,
|
||||
frame: u64,
|
||||
last_time: f64,
|
||||
/// Canvas rect of the last frame (screen points), for control-channel coordinate mapping.
|
||||
@@ -336,6 +339,7 @@ impl VectorcraftApp {
|
||||
synthetic: vec![],
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
ui_fonts: Default::default(),
|
||||
frame: 0,
|
||||
last_time: 0.0,
|
||||
canvas_rect: None,
|
||||
@@ -791,6 +795,7 @@ impl VectorcraftApp {
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
self.ui_fonts.frame(&ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
let _ = json!(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Fallback fonts for the app's own text: layer and file names (and anything else the UI shows) in
|
||||
//! Chinese, Japanese, Korean or other scripts the UI fonts lack are drawn in an installed font that
|
||||
//! covers them. Nothing is bundled for them: the first time a frame paints a character the UI fonts
|
||||
//! don't have, an installed font covering it is looked for (in the background on native) and added
|
||||
//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs
|
||||
//! no font loading or memory. On the web, which has no system fonts, such characters stay
|
||||
//! missing-glyph boxes.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::epaint::Shape;
|
||||
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
|
||||
use egui::{FontData, FontFamily, FontId, LayerId};
|
||||
use vectorcraft_text::{FontDb, 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>,
|
||||
/// The fonts added to egui, by name.
|
||||
added: HashSet<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>>>>,
|
||||
}
|
||||
|
||||
impl UiFonts {
|
||||
/// After a frame is laid out: add the fallback fonts found since the last frame, and look for
|
||||
/// fonts covering the characters this frame painted that the UI fonts lack.
|
||||
pub(crate) fn frame(&mut self, ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(faces) = self.pending.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||
self.pending = None;
|
||||
self.install(ctx, &faces);
|
||||
}
|
||||
let missing = self.missing(ctx);
|
||||
if missing.is_empty() {
|
||||
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())
|
||||
{
|
||||
faces.push(f);
|
||||
}
|
||||
}
|
||||
faces
|
||||
};
|
||||
// Native: the installed fonts are read off the UI thread. Characters met meanwhile wait for
|
||||
// the next search (they aren't marked seen until then).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let repaint = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("ui-font-fallback".into()).spawn(move || {
|
||||
// The receiver is gone only when the app is.
|
||||
let _ = tx.send(find());
|
||||
repaint.request_repaint();
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
self.pending = Some(rx);
|
||||
}
|
||||
}
|
||||
// The web has only the loaded fonts to look through: quick, on this thread.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.install(ctx, &find());
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.pending.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let mut layers: Vec<LayerId> = ctx.memory(|m| m.layer_ids().collect());
|
||||
if !layers.contains(&LayerId::background()) {
|
||||
layers.push(LayerId::background());
|
||||
}
|
||||
let mut chars: Vec<char> = vec![];
|
||||
let seen = &self.seen;
|
||||
let mut note = |text: &str| {
|
||||
// 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)));
|
||||
}
|
||||
};
|
||||
ctx.graphics(|g| {
|
||||
for l in &layers {
|
||||
for s in g.get(*l).into_iter().flat_map(|list| list.all_entries()) {
|
||||
painted_text(&s.shape, &mut note);
|
||||
}
|
||||
}
|
||||
});
|
||||
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());
|
||||
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 {
|
||||
let name = format!("{} {} #{}", face.family, face.style, face.face_index());
|
||||
if !self.added.insert(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();
|
||||
ctx.add_font(FontInsert { name, data, families });
|
||||
}
|
||||
if !faces.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the font search running, if any, and add what it found (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn finish(&mut self, ctx: &egui::Context) {
|
||||
if let Some(faces) = self.pending.take().and_then(|rx| rx.recv().ok()) {
|
||||
self.install(ctx, &faces);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` with the text of `shape` and the shapes it holds.
|
||||
fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) {
|
||||
match shape {
|
||||
Shape::Text(t) => f(t.galley.text()),
|
||||
Shape::Vec(v) => v.iter().for_each(|s| painted_text(s, f)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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| {
|
||||
ui.label(text);
|
||||
fonts.frame(ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn has_glyph(ctx: &egui::Context, c: char) -> bool {
|
||||
ctx.fonts_mut(|f| f.has_glyph(&FontId::new(13.0, FontFamily::Proportional), c))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_text_loads_no_fonts() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut fonts = UiFonts::default();
|
||||
frame(&ctx, &mut fonts, "Layer 1 — Café");
|
||||
assert!(fonts.pending.is_none() && fonts.added.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_names_get_an_installed_fallback_font() {
|
||||
let text = "Logo 标志 ロゴ 로고";
|
||||
let ctx = egui::Context::default();
|
||||
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");
|
||||
fonts.finish(&ctx);
|
||||
// The fonts arrive with the next frame.
|
||||
frame(&ctx, &mut fonts, text);
|
||||
let installed: Vec<char> = text.chars().filter(|c| !c.is_ascii() && FontDb::global().face_covering(*c).is_some()).collect();
|
||||
if installed.is_empty() {
|
||||
eprintln!("no installed font covers {text}: nothing to check");
|
||||
return;
|
||||
}
|
||||
for c in installed {
|
||||
assert!(has_glyph(&ctx, c), "{c} has a glyph");
|
||||
}
|
||||
// Looked for once: a later frame starts no search.
|
||||
frame(&ctx, &mut fonts, text);
|
||||
assert!(fonts.pending.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user