diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c5ad8b9b..2a2c08e8 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -504,7 +504,8 @@ fn resolve_by_name(name: &str) -> Option { /// 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> = 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 { let order: Vec = 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 diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 897f01f8..39738530 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -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 } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5876a396..1d61ae4c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(rename_focus_id())).unwrap_or(false) { // Just started: focus the field with the whole name selected. er.request_focus(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7b13112f..74ab0b7e 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 { +/// 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 { #[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::>(); + 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"); } } diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 5f0e10ad..fc5c522e 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -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"); + } +}