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:
bflatasticandClaude Opus 5.5 committed 2026-10-10 00:53:21 +07:00
1 parent 921f0dbdbe
commit 98a6a3a041
5 files changed
+275 -47

No files matched your search

+85 -3
View File
@@ -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
+9 -5
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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");
}
}
+52
View File
@@ -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");
}
}