mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
UI: Arabic, Hebrew, Indic and Thai names can be typed and are drawn (#394)
Cause: two things kept Arabic out of a layer name on Windows. - Windows switches the keyboard layout with Alt+Shift. Releasing Shift before Alt left Alt held alone, and the in-window menu bar (#279) counted the release as an Alt tap: it took the keyboard focus, the rename field closed, and the letters typed next were swallowed by the menu bar. The Project panel's rename field lost them the same way. - The UI fonts are Inter plus the CJK system fallbacks, so Arabic, Hebrew, Indic and Thai names that did arrive (pasted, or from a project) showed as missing-glyph boxes. Fix: - An Alt tap only counts when Alt went down with no other modifier held, so Alt+Shift in either release order is no tap; a plain Alt tap still focuses the menu bar. - The UI registers installed system faces for Arabic, Hebrew, Devanagari, Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam and Thai after the CJK ones, the same way (native only; the web build has no system fonts). A face covering several scripts is registered once (Segoe UI: Arabic and Hebrew; Nirmala UI: every Indic script). - The text engine's fallback tries each script's plain platform families (Segoe UI, Nirmala UI, Leelawadee UI, Geeza Pro, Kohinoor, Noto…) before any covering face, so neither the UI nor a text layer gets a calligraphic face that merely sorts first. - The Timeline's rename field registers `timeline.layer.<id>.nameEdit`. egui 0.36 shapes each run of one face, so Arabic letters join and a word reads right to left; it has no bidirectional layout, so the words of a multi-word Arabic name (and mixed Arabic/Latin) still follow each other left to right. Tests: ui_inline_edit a_layout_switch_keeps_the_name_field_and_arabic_ names_are_kept (Alt+Shift both ways in the layer rename, Arabic typed and stored; Hebrew/Devanagari/Thai in the Project panel; a plain Alt tap still focuses File; failed before: the field closed), theme installed_script_fallbacks_draw_arabic_hebrew_indic_and_thai_names (failed before: replacement glyph for Arabic) and arabic_words_are_joined_and_read_right_to_left, text other_scripts_fall_back_to_a_plain_system_family and the script classification. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
921f0dbdbe
commit
98a6a3a041
5 files changed
+275
-47
No files matched your search
@@ -504,7 +504,8 @@ fn resolve_by_name(name: &str) -> Option<Resolved> {
|
||||
|
||||
/// The writing systems that need a dedicated fallback family: the bundled fonts cover none of
|
||||
/// them, and picking "any face with the glyph" gives Chinese glyph shapes to Japanese text (or the
|
||||
/// other way round), since Han ideographs are shared.
|
||||
/// other way round), since Han ideographs are shared, and a calligraphic display face to Arabic,
|
||||
/// Hebrew, Indic or Thai text when that is the first installed face that covers it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum FallbackScript {
|
||||
/// Kana: unambiguously Japanese.
|
||||
@@ -514,9 +515,23 @@ enum FallbackScript {
|
||||
/// Han ideographs, CJK punctuation and full-width forms: shared by Japanese, Chinese and
|
||||
/// Korean; the locale decides which family to try first.
|
||||
Han,
|
||||
Arabic,
|
||||
Hebrew,
|
||||
/// Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada, Malayalam and
|
||||
/// Sinhala (one family covers them all on Windows; elsewhere each has its own).
|
||||
Indic,
|
||||
Thai,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FallbackScript {
|
||||
/// Kana, Hangul or Han: these share the Han ideographs, so each tries the others' families
|
||||
/// next.
|
||||
fn is_cjk(self) -> bool {
|
||||
matches!(self, FallbackScript::Japanese | FallbackScript::Korean | FallbackScript::Han)
|
||||
}
|
||||
}
|
||||
|
||||
fn script_of(c: char) -> FallbackScript {
|
||||
match c as u32 {
|
||||
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => FallbackScript::Japanese,
|
||||
@@ -530,6 +545,10 @@ fn script_of(c: char) -> FallbackScript {
|
||||
| 0xFE30..=0xFE4F
|
||||
| 0xFF00..=0xFFEF
|
||||
| 0x20000..=0x3FFFF => FallbackScript::Han,
|
||||
0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFE => FallbackScript::Arabic,
|
||||
0x0590..=0x05FF | 0xFB1D..=0xFB4F => FallbackScript::Hebrew,
|
||||
0x0900..=0x0DFF | 0xA8E0..=0xA8FF => FallbackScript::Indic,
|
||||
0x0E00..=0x0E7F => FallbackScript::Thai,
|
||||
_ => FallbackScript::Other,
|
||||
}
|
||||
}
|
||||
@@ -568,6 +587,38 @@ const CHINESE_FAMILIES: &[&str] = &[
|
||||
"WenQuanYi Micro Hei",
|
||||
];
|
||||
const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"];
|
||||
/// The platforms' plain text families for the other scripts the bundled fonts lack, in the same
|
||||
/// order (Windows, macOS, then the Linux/Noto names).
|
||||
const ARABIC_FAMILIES: &[&str] = &["Segoe UI", "Tahoma", "Geeza Pro", "Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans", "Arial"];
|
||||
const HEBREW_FAMILIES: &[&str] = &["Segoe UI", "Tahoma", "Arial Hebrew", "Noto Sans Hebrew", "DejaVu Sans", "Arial"];
|
||||
const INDIC_FAMILIES: &[&str] = &[
|
||||
"Nirmala UI",
|
||||
"Kohinoor Devanagari",
|
||||
"Kohinoor Bangla",
|
||||
"Kohinoor Gujarati",
|
||||
"Kohinoor Telugu",
|
||||
"Devanagari Sangam MN",
|
||||
"Bangla Sangam MN",
|
||||
"Gurmukhi Sangam MN",
|
||||
"Gujarati Sangam MN",
|
||||
"Oriya Sangam MN",
|
||||
"Tamil Sangam MN",
|
||||
"Telugu Sangam MN",
|
||||
"Kannada Sangam MN",
|
||||
"Malayalam Sangam MN",
|
||||
"Sinhala Sangam MN",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Bengali",
|
||||
"Noto Sans Gurmukhi",
|
||||
"Noto Sans Gujarati",
|
||||
"Noto Sans Oriya",
|
||||
"Noto Sans Tamil",
|
||||
"Noto Sans Telugu",
|
||||
"Noto Sans Kannada",
|
||||
"Noto Sans Malayalam",
|
||||
"Noto Sans Sinhala",
|
||||
];
|
||||
const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sans Thai", "Noto Sans Thai UI", "Loma", "Garuda"];
|
||||
|
||||
/// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]).
|
||||
static REQUESTED_CJK_LOCALE: RwLock<Option<String>> = RwLock::new(None);
|
||||
@@ -628,6 +679,10 @@ fn families_for(s: FallbackScript) -> &'static [&'static str] {
|
||||
FallbackScript::Japanese => JAPANESE_FAMILIES,
|
||||
FallbackScript::Han => CHINESE_FAMILIES,
|
||||
FallbackScript::Korean => KOREAN_FAMILIES,
|
||||
FallbackScript::Arabic => ARABIC_FAMILIES,
|
||||
FallbackScript::Hebrew => HEBREW_FAMILIES,
|
||||
FallbackScript::Indic => INDIC_FAMILIES,
|
||||
FallbackScript::Thai => THAI_FAMILIES,
|
||||
FallbackScript::Other => &[],
|
||||
}
|
||||
}
|
||||
@@ -741,11 +796,12 @@ fn system_fallback(c: char, style: &str) -> Option<FaceId> {
|
||||
let order: Vec<FallbackScript> = match script {
|
||||
FallbackScript::Other => vec![],
|
||||
FallbackScript::Han => han_order().to_vec(),
|
||||
s => {
|
||||
s if s.is_cjk() => {
|
||||
let mut v = vec![s];
|
||||
v.extend(han_order().iter().copied().filter(|o| *o != s));
|
||||
v
|
||||
}
|
||||
s => vec![s],
|
||||
};
|
||||
for s in order {
|
||||
for fam in families_for(s) {
|
||||
@@ -851,7 +907,33 @@ mod tests {
|
||||
assert_eq!(script_of('A'), FallbackScript::Han);
|
||||
assert_eq!(script_of('\u{20000}'), FallbackScript::Han);
|
||||
assert_eq!(script_of('A'), FallbackScript::Other);
|
||||
assert_eq!(script_of('\u{5d0}'), FallbackScript::Other);
|
||||
assert_eq!(script_of('م'), FallbackScript::Arabic);
|
||||
assert_eq!(script_of('\u{fefb}'), FallbackScript::Arabic);
|
||||
assert_eq!(script_of('\u{5d0}'), FallbackScript::Hebrew);
|
||||
assert_eq!(script_of('क'), FallbackScript::Indic);
|
||||
assert_eq!(script_of('த'), FallbackScript::Indic);
|
||||
assert_eq!(script_of('ก'), FallbackScript::Thai);
|
||||
assert_eq!(script_of('\u{feff}'), FallbackScript::Other, "the byte order mark is no Arabic letter");
|
||||
}
|
||||
|
||||
/// Native: Arabic, Hebrew, Indic and Thai text in a Latin-only family falls back to the
|
||||
/// platform's plain text family for its script (#394), not to the first installed face that
|
||||
/// covers it (on Windows a calligraphic Arabic face sorts first). Skipped per script where no
|
||||
/// family from its list is installed.
|
||||
#[test]
|
||||
fn other_scripts_fall_back_to_a_plain_system_family() {
|
||||
scan_system();
|
||||
let inter = resolve("Inter", "Regular").face;
|
||||
for (c, families) in [('م', ARABIC_FAMILIES), ('א', HEBREW_FAMILIES), ('क', INDIC_FAMILIES), ('த', INDIC_FAMILIES), ('ก', THAI_FAMILIES)] {
|
||||
let covering = families.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c)));
|
||||
if !covering {
|
||||
eprintln!("{c}: no family from the list is installed; skipping");
|
||||
continue;
|
||||
}
|
||||
let f = face(fallback_for(c, inter));
|
||||
assert!(f.has_char(c), "{c}: {} lacks it", f.info.family);
|
||||
assert!(families.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
|
||||
}
|
||||
}
|
||||
|
||||
/// Native: Japanese text in a Latin-only family falls back to an installed Japanese family
|
||||
|
||||
@@ -114,27 +114,31 @@ pub fn active(ctx: &Context) -> bool {
|
||||
ctx.data(|d| d.get_temp(active_id())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Alt pressed and released with nothing else in between (no key, click or wheel), off macOS.
|
||||
/// Alt pressed and released with nothing else in between (no other modifier, key, click or wheel),
|
||||
/// off macOS. Alt only counts when it went down with no other modifier held: Windows switches the
|
||||
/// keyboard layout with Alt+Shift, and releasing Shift first leaves Alt held alone, which must not
|
||||
/// take the keyboard from the field being typed in (#394).
|
||||
fn alt_tapped(ctx: &Context) -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
return false;
|
||||
}
|
||||
let mut armed: bool = ctx.data(|d| d.get_temp(alt_id())).unwrap_or(false);
|
||||
// (armed, the modifiers held after the last change)
|
||||
let (mut armed, mut held): (bool, Modifiers) = ctx.data(|d| d.get_temp(alt_id())).unwrap_or_default();
|
||||
let mut tapped = false;
|
||||
ctx.input(|i| {
|
||||
for e in &i.events {
|
||||
match e {
|
||||
Event::ModifiersChanged(m) if *m == Modifiers::ALT => armed = true,
|
||||
Event::ModifiersChanged(m) => {
|
||||
tapped |= armed && *m == Modifiers::NONE;
|
||||
armed = false;
|
||||
armed = *m == Modifiers::ALT && held == Modifiers::NONE;
|
||||
held = *m;
|
||||
}
|
||||
Event::PointerMoved(_) | Event::MouseMoved(_) | Event::Zoom(_) => {}
|
||||
_ => armed = false,
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), armed));
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), (armed, held)));
|
||||
tapped
|
||||
}
|
||||
|
||||
|
||||
@@ -1747,6 +1747,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
|
||||
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
|
||||
app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_focus_id())).unwrap_or(false) {
|
||||
// Just started: focus the field with the whole name selected.
|
||||
er.request_focus();
|
||||
|
||||
+128
-39
@@ -290,22 +290,44 @@ pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) {
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into());
|
||||
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]);
|
||||
fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]);
|
||||
install_cjk_fallbacks(&mut fonts, language);
|
||||
install_script_fallbacks(&mut fonts, language);
|
||||
ctx.set_fonts(fonts);
|
||||
apply_visuals(ctx, t);
|
||||
}
|
||||
|
||||
/// Register the CJK system fallbacks for `language` in every font family, and return their names in
|
||||
/// registration order (empty when nothing installed covers a probe, so a machine without CJK fonts
|
||||
/// simply keeps the bundled ones).
|
||||
/// Scripts the bundled fonts lack beyond CJK, each probed with one of its letters: names typed in
|
||||
/// them (layers, items, comments) are drawn by an installed face instead of missing-glyph boxes
|
||||
/// (#394). egui shapes each run of one face, so Arabic letters join and a word reads right to
|
||||
/// left; it has no bidirectional layout, so the words of a name still follow each other left to
|
||||
/// right.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SCRIPT_PROBES: &[(char, &str)] = &[
|
||||
('م', "arabic-system"),
|
||||
('א', "hebrew-system"),
|
||||
('क', "devanagari-system"),
|
||||
('ক', "bengali-system"),
|
||||
('ਕ', "gurmukhi-system"),
|
||||
('ક', "gujarati-system"),
|
||||
('க', "tamil-system"),
|
||||
('క', "telugu-system"),
|
||||
('ಕ', "kannada-system"),
|
||||
('ക', "malayalam-system"),
|
||||
('ก', "thai-system"),
|
||||
];
|
||||
|
||||
/// Register the system fallbacks for scripts the bundled fonts lack in every font family, and
|
||||
/// return their names in registration order (empty when nothing installed covers a probe, so a
|
||||
/// machine without those fonts simply keeps the bundled ones). A face that covers several probes
|
||||
/// (Segoe UI draws Arabic and Hebrew, Nirmala UI every Indic script) is registered once.
|
||||
///
|
||||
/// The text engine has its own script-aware fallback (#84), but which face it picks for Han
|
||||
/// ideographs depends on the locale environment: a Chinese interface on a Japanese-locale machine
|
||||
/// would be drawn with the Japanese face. The interface language is the better signal, so the probe
|
||||
/// for its script comes first — kana for Japanese, a Han character for the Chinese catalogs. Both
|
||||
/// scripts are registered, so file, layer and template names in the other one still render. The web
|
||||
/// build has no system fonts and installs nothing.
|
||||
fn install_cjk_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec<String> {
|
||||
/// CJK comes first. The text engine has its own script-aware fallback (#84), but which face it
|
||||
/// picks for Han ideographs depends on the locale environment: a Chinese interface on a
|
||||
/// Japanese-locale machine would be drawn with the Japanese face. The interface language is the
|
||||
/// better signal, so the probe for its script comes first — kana for Japanese, a Han character for
|
||||
/// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the
|
||||
/// other one still render; then [`SCRIPT_PROBES`]. The web build has no system fonts and installs
|
||||
/// nothing.
|
||||
fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec<String> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (family_fonts, language);
|
||||
@@ -321,10 +343,10 @@ fn install_cjk_fallbacks(family_fonts: &mut FontDefinitions, language: &str) ->
|
||||
// Han text follows the interface language, not the operating system's locale.
|
||||
fonts::set_cjk_locale(cjk_locale(language));
|
||||
let base = fonts::resolve("Inter", "Regular").face;
|
||||
let probes = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] };
|
||||
let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] };
|
||||
let mut names = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
for (probe, name) in probes {
|
||||
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) {
|
||||
let id = fonts::fallback_for(probe, base);
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
@@ -418,17 +440,20 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod japanese_font_tests {
|
||||
#[test]
|
||||
fn installed_japanese_fallback_is_available_in_all_ui_families() {
|
||||
use effectcraft_text::fonts;
|
||||
mod script_font_tests {
|
||||
use effectcraft_text::fonts;
|
||||
|
||||
/// Whether an installed face covers `probe` (else the glyph check is skipped).
|
||||
fn installed(probe: char) -> bool {
|
||||
let base = fonts::resolve("Inter", "Regular").face;
|
||||
if !fonts::face(fonts::fallback_for('あ', base)).has_char('あ') {
|
||||
eprintln!("no Japanese system font installed; skipping glyph coverage");
|
||||
return;
|
||||
}
|
||||
fonts::face(fonts::fallback_for(probe, base)).has_char(probe)
|
||||
}
|
||||
|
||||
/// Every character of `text` draws a real glyph in every UI font family with the `language`
|
||||
/// interface's fonts installed.
|
||||
fn assert_drawn(language: &str, text: &str) {
|
||||
let ctx = egui::Context::default();
|
||||
super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "ja");
|
||||
super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), language);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
// No renderer here: drop the frame's texture uploads (egui asserts on unhandled ones in debug).
|
||||
out.textures_delta.clear();
|
||||
@@ -441,11 +466,11 @@ mod japanese_font_tests {
|
||||
] {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
// epaint 0.36's has_glyph compares face keys, so it reports a false
|
||||
// negative when a real Japanese glyph shares the replacement face.
|
||||
// negative when a real glyph shares the replacement face.
|
||||
// Check the rendered atlas glyph instead of that face-level predicate.
|
||||
let missing = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE);
|
||||
let missing_uv = missing.rows[0].glyphs[0].uv_rect;
|
||||
for ch in "日本語コンポジションレイヤーエフェクト設定".chars() {
|
||||
for ch in text.chars() {
|
||||
let rendered = fonts.layout_no_wrap(ch.to_string(), font.clone(), egui::Color32::WHITE);
|
||||
let uv = rendered.rows[0].glyphs[0].uv_rect;
|
||||
assert!(uv.size.x > 0.0 && uv.size.y > 0.0, "empty {ch} in {font:?}");
|
||||
@@ -454,6 +479,61 @@ mod japanese_font_tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_japanese_fallback_is_available_in_all_ui_families() {
|
||||
if !installed('あ') {
|
||||
eprintln!("no Japanese system font installed; skipping glyph coverage");
|
||||
return;
|
||||
}
|
||||
assert_drawn("ja", "日本語コンポジションレイヤーエフェクト設定");
|
||||
}
|
||||
|
||||
/// #394: Arabic, Hebrew, Indic and Thai names are drawn by an installed face in an English
|
||||
/// interface: the UI fonts were Inter and the CJK fallbacks only, so a layer named in Arabic
|
||||
/// showed as missing-glyph boxes.
|
||||
#[test]
|
||||
fn installed_script_fallbacks_draw_arabic_hebrew_indic_and_thai_names() {
|
||||
// Letters only: a vowel sign or virama on its own is drawn on a dotted circle.
|
||||
for text in ["طبقةمرحبا", "שכבה", "परत", "কলম", "ਕਲਮ", "કલમ", "கடல", "కలమ", "ಕಲಮ", "കലമ", "กขค"]
|
||||
{
|
||||
let Some(probe) = text.chars().next() else { continue };
|
||||
if !installed(probe) {
|
||||
eprintln!("no system font covers {text}; skipping its glyph coverage");
|
||||
continue;
|
||||
}
|
||||
assert_drawn("en", text);
|
||||
}
|
||||
}
|
||||
|
||||
/// egui shapes each run of one face: Arabic letters take their joined forms, and an Arabic
|
||||
/// word reads right to left (its first letter drawn rightmost).
|
||||
#[test]
|
||||
fn arabic_words_are_joined_and_read_right_to_left() {
|
||||
if !installed('م') {
|
||||
eprintln!("no Arabic system font installed; skipping");
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "en");
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
|
||||
let missing = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect;
|
||||
let alone = fonts.layout_no_wrap("م".into(), font.clone(), egui::Color32::WHITE);
|
||||
let word = fonts.layout_no_wrap("مرحبا".into(), font, egui::Color32::WHITE);
|
||||
let glyphs = &word.rows[0].glyphs;
|
||||
let meem = glyphs.iter().find(|g| g.chr == 'م').expect("the meem is laid out");
|
||||
let alif = glyphs.iter().find(|g| g.chr == 'ا').expect("the alif is laid out");
|
||||
assert!(meem.pos.x > alif.pos.x, "the first letter is drawn rightmost: {} vs {}", meem.pos.x, alif.pos.x);
|
||||
let isolated = alone.rows[0].glyphs[0].uv_rect;
|
||||
for uv in [meem.uv_rect, isolated] {
|
||||
assert_ne!((uv.min, uv.max), (missing.min, missing.max), "a real glyph, not the replacement");
|
||||
}
|
||||
assert_ne!((meem.uv_rect.min, meem.uv_rect.max), (isolated.min, isolated.max), "the meem takes its initial form");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -475,40 +555,49 @@ mod tests {
|
||||
assert_eq!(cjk_locale("en"), "");
|
||||
assert_eq!(cjk_locale("system"), "");
|
||||
let mut chinese = FontDefinitions::default();
|
||||
let installed = install_cjk_fallbacks(&mut chinese, "zh-hans");
|
||||
if installed.is_empty() {
|
||||
let installed = install_script_fallbacks(&mut chinese, "zh-hans");
|
||||
let cjk = |names: &[String]| names.iter().filter(|n| matches!(n.as_str(), "chinese-system" | "japanese-system")).cloned().collect::<Vec<_>>();
|
||||
if cjk(&installed).is_empty() {
|
||||
eprintln!("no CJK font is installed: only the language plumbing can be checked here");
|
||||
} else {
|
||||
assert_eq!(installed.first().map(String::as_str), Some("chinese-system"), "the Han probe comes first");
|
||||
assert!(chinese.font_data.contains_key("chinese-system"));
|
||||
let expected: Vec<&str> = installed.iter().map(String::as_str).collect();
|
||||
for (family, stack) in &chinese.families {
|
||||
assert_eq!(tail(stack, installed.len()), expected, "{family:?}");
|
||||
}
|
||||
}
|
||||
let expected: Vec<&str> = installed.iter().map(String::as_str).collect();
|
||||
for (family, stack) in &chinese.families {
|
||||
assert_eq!(tail(stack, installed.len()), expected, "{family:?}");
|
||||
}
|
||||
// A Japanese interface keeps the kana probe first, as it was before the language existed.
|
||||
let mut japanese = FontDefinitions::default();
|
||||
let installed = install_cjk_fallbacks(&mut japanese, "ja");
|
||||
if let Some(first) = installed.first() {
|
||||
let installed = install_script_fallbacks(&mut japanese, "ja");
|
||||
if let Some(first) = cjk(&installed).first() {
|
||||
assert_eq!(installed.first(), Some(first));
|
||||
assert_eq!(first, "japanese-system", "the kana probe comes first");
|
||||
}
|
||||
// And an English interface leaves the operating system's locale in charge.
|
||||
let mut english = FontDefinitions::default();
|
||||
install_cjk_fallbacks(&mut english, "en");
|
||||
install_script_fallbacks(&mut english, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cjk_face_is_registered_at_most_once() {
|
||||
fn a_fallback_face_is_registered_at_most_once() {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let installed = install_cjk_fallbacks(&mut fonts, "zh-hant");
|
||||
let installed = install_script_fallbacks(&mut fonts, "zh-hant");
|
||||
let mut unique = installed.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique, installed, "no face may be registered twice");
|
||||
for name in &installed {
|
||||
assert!(fonts.font_data.contains_key(name), "{name} is missing its font data");
|
||||
assert_eq!(unique.len(), installed.len(), "no name may be registered twice: {installed:?}");
|
||||
let faces: Vec<_> = installed
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let data = fonts.font_data.get(name).unwrap_or_else(|| panic!("{name} is missing its font data"));
|
||||
(data.font.as_ref(), data.index)
|
||||
})
|
||||
.collect();
|
||||
for (i, a) in faces.iter().enumerate() {
|
||||
assert!(!faces[i + 1..].contains(a), "{} shares its face with a later fallback: {installed:?}", installed[i]);
|
||||
}
|
||||
// Leave the operating system's locale behind for whatever runs next.
|
||||
install_cjk_fallbacks(&mut fonts, "system");
|
||||
install_script_fallbacks(&mut fonts, "system");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
@@ -422,3 +423,54 @@ fn ctrl_click_on_the_time_display_toggles_timecode_and_frames() {
|
||||
click_with(&mut h, at, Modifiers::COMMAND);
|
||||
assert_eq!(style(&h), TimeDisplayStyle::Timecode);
|
||||
}
|
||||
|
||||
/// Hold Alt and Shift and let go of them, as Windows' keyboard-layout hotkey does; `shift_first`
|
||||
/// releases Shift before Alt.
|
||||
fn switch_layout(h: &mut Harness<'_, EffectcraftApp>, shift_first: bool) {
|
||||
let held = if shift_first { Modifiers::ALT } else { Modifiers::SHIFT };
|
||||
for m in [Modifiers::ALT, Modifiers::ALT | Modifiers::SHIFT, held, Modifiers::NONE] {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(m));
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// #394: switching the keyboard layout to Arabic with Alt+Shift while a name is being typed
|
||||
/// leaves the field with the keyboard: releasing Shift before Alt left Alt held alone, which
|
||||
/// counted as an Alt tap, so the menu bar took the keyboard, the rename ended and the Arabic
|
||||
/// letters went nowhere. Layer and Project names keep what is typed in any script.
|
||||
#[test]
|
||||
fn a_layout_switch_keeps_the_name_field_and_arabic_names_are_kept() {
|
||||
let (mut h, comp) = harness();
|
||||
let layer = h.state_mut().session.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
h.run_steps(3);
|
||||
let layer_name =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone();
|
||||
let p = center(&h, &format!("timeline.layer.{layer}.row"));
|
||||
click(&mut h, p, 2);
|
||||
assert!(h.state().auto.find(&format!("timeline.layer.{layer}.nameEdit")).is_some(), "double-click opened the rename field");
|
||||
for shift_first in [true, false] {
|
||||
switch_layout(&mut h, shift_first);
|
||||
assert!(h.state().auto.find(&format!("timeline.layer.{layer}.nameEdit")).is_some(), "the rename field stays open");
|
||||
type_text(&mut h, if shift_first { "طبقة " } else { "مرحبا" });
|
||||
}
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(layer_name(&h), "طبقة مرحبا");
|
||||
// The Project panel's rename field, with Hebrew, Devanagari and Thai.
|
||||
let p = center(&h, &format!("project.item.{comp}.name"));
|
||||
click(&mut h, p, 1);
|
||||
key(&mut h, Key::Enter);
|
||||
switch_layout(&mut h, true);
|
||||
type_text(&mut h, "שכבה नाम ชื่อ");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name, "שכבה नाम ชื่อ");
|
||||
// A plain Alt tap still takes the keyboard to the menu bar (#279).
|
||||
if !cfg!(target_os = "macos") {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
let focused = h.query_by(|n| n.is_focused()).and_then(|n| n.accesskit_node().label()).unwrap_or_default();
|
||||
assert_eq!(focused.trim(), "File", "Alt focused the menu bar");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user