diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index e5631a34..b1b09854 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -33,9 +33,16 @@ const FALLBACK_CANDIDATES: &[&str] = &[ "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew", + // Japanese first, so shared Han characters get Japanese forms before the SC fonts are tried. + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Yu Gothic", + "Meiryo", + "Noto Sans CJK JP", + "Noto Sans JP", + "IPAexGothic", "Noto Sans CJK SC", "PingFang SC", - "Hiragino Sans", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8d84f900..277330ba 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -476,6 +476,10 @@ pub struct TextEdit { pub created: bool, #[serde(skip)] pub dragging: bool, + /// IME composition in progress: (start, length) in characters. The preedit text lives in the + /// layer so it lays out like typed text; each IME update replaces it. + #[serde(skip)] + pub preedit: Option<(usize, usize)>, } /// View-menu overlays. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 59a0d0b0..d1bc9cb7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -290,9 +290,65 @@ pub fn install_fonts(ctx: &egui::Context) { stack.extend(fallback.iter().cloned()); fonts.families.insert(FontFamily::Name(fam.into()), stack); } + #[cfg(not(target_arch = "wasm32"))] + add_cjk_fallback(&mut fonts, &cjk_font_candidates()); ctx.set_fonts(fonts); } +/// Name under which the OS Japanese/CJK font is registered. +const CJK_FONT: &str = "system-cjk"; +/// Skip absurdly large files (a corrupt or non-font path must not eat memory). +const CJK_MAX_BYTES: u64 = 128 << 20; + +/// OS-installed fonts with Japanese coverage, most preferred first: (path, face index in a collection). +#[cfg(not(target_arch = "wasm32"))] +fn cjk_font_candidates() -> Vec<(std::path::PathBuf, u32)> { + use std::path::PathBuf; + let list: &[(&str, u32)] = if cfg!(target_os = "macos") { + &[ + ("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", 0), + ("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", 0), + ("/Library/Fonts/Arial Unicode.ttf", 0), + ("/System/Library/Fonts/Hiragino Sans GB.ttc", 0), + ] + } else if cfg!(target_os = "windows") { + &[("C:\\Windows\\Fonts\\YuGothM.ttc", 0), ("C:\\Windows\\Fonts\\YuGothR.ttc", 0), ("C:\\Windows\\Fonts\\meiryo.ttc", 0), ("C:\\Windows\\Fonts\\msgothic.ttc", 0)] + } else { + &[ + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0), + ("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 0), + ("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", 0), + ("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 0), + ("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", 0), + ("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", 0), + ("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", 0), + ] + }; + list.iter().map(|(p, i)| (PathBuf::from(p), *i)).collect() +} + +/// Appends the first readable font from `candidates` to the end of every family's stack, so +/// Latin text keeps using Inter / JetBrains Mono and only missing glyphs (Japanese) fall through. +/// Without it egui draws CJK as tofu. Returns whether a font was added; none found is not an error. +#[cfg(not(target_arch = "wasm32"))] +fn add_cjk_fallback(fonts: &mut FontDefinitions, candidates: &[(std::path::PathBuf, u32)]) -> bool { + for (path, index) in candidates { + let Ok(meta) = std::fs::metadata(path) else { continue }; + if !meta.is_file() || meta.len() == 0 || meta.len() > CJK_MAX_BYTES { + continue; + } + let Ok(bytes) = std::fs::read(path) else { continue }; + let mut data = FontData::from_owned(bytes); + data.index = *index; + fonts.font_data.insert(CJK_FONT.to_owned(), Arc::new(data)); + for stack in fonts.families.values_mut() { + stack.push(CJK_FONT.to_owned()); + } + return true; + } + false +} + pub fn medium(size: f32) -> FontId { FontId::new(size, FontFamily::Name("medium".into())) } @@ -535,3 +591,32 @@ pub mod live { } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod cjk_tests { + use super::*; + + #[test] + fn fallback_is_appended_to_every_family_and_missing_files_are_skipped() { + let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let font = dir.join("f.ttf"); + std::fs::write(&font, include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let empty = dir.join("empty.ttf"); + std::fs::write(&empty, b"").unwrap(); + + let mut fonts = FontDefinitions::default(); + let before: Vec = fonts.families.values().map(Vec::len).collect(); + assert!(add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0), (empty, 0), (dir.join("nope"), 0), (font, 0)])); + assert!(fonts.font_data.contains_key(CJK_FONT)); + for (stack, n) in fonts.families.values().zip(before) { + assert_eq!(stack.len(), n + 1); + assert_eq!(stack.last().map(String::as_str), Some(CJK_FONT), "must be last so Latin keeps its own font"); + } + // Nothing usable: untouched. + let mut fonts = FontDefinitions::default(); + assert!(!add_cjk_fallback(&mut fonts, &[(dir.join("missing.ttc"), 0)])); + assert!(!fonts.font_data.contains_key(CJK_FONT)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 9384df84..9e2ff934 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -100,7 +100,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) -> boo if let Some(id) = hit_layer(app, x, y) { let _ = app.session.select_layer(id); let off = hit_offset(app, id, x, y); - app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true }); + app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: session_key(app), created: false, dragging: true, preedit: None }); return true; } false @@ -149,7 +149,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { } // Like Photoshop: the placeholder is selected, so typing replaces it. let n = PLACEHOLDER.chars().count(); - app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, dragging: false }); + app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, dragging: false, preedit: None }); } } @@ -177,6 +177,33 @@ fn insert(app: &mut PhotocraftApp, s: &str) { } } +/// IME composition. The preedit text is written into the layer (so it lays out and reflows like +/// typed text) and replaced by every update; `commit` makes the result final. +fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; + let n = text.chars().count(); + let s = s.replace("\r\n", "\n").replace('\r', "\n"); + let (start, end) = match ed.preedit { + Some((p, l)) if p.saturating_add(l) <= n => (p, p + l), + _ => (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)), + }; + if start == end && s.is_empty() { + if let Some(e) = app.ui.text_edit.as_mut() { + e.preedit = None; + } + return; + } + let len = s.chars().count(); + if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": s}, "coalesce": ed.session})).is_ok() + && let Some(e) = app.ui.text_edit.as_mut() + { + e.caret = start + len; + e.anchor = e.caret; + e.preedit = if commit || len == 0 { None } else { Some((start, len)) }; + } +} + /// Character index of the previous / next word boundary. fn word_boundary(text: &str, from: usize, forward: bool) -> usize { let chars: Vec = text.chars().collect(); @@ -274,6 +301,11 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { handled[k] = true; match ev { egui::Event::Text(s) | egui::Event::Paste(s) => insert(app, s), + // A bare line break from the IME is the Enter key, which the key arm handles. + egui::Event::Ime(egui::ImeEvent::Preedit { text: s, .. } | egui::ImeEvent::Commit(s)) if s == "\n" || s == "\r" => handled[k] = false, + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => ime_update(app, text, false), + egui::Event::Ime(egui::ImeEvent::Commit(s)) => ime_update(app, s, true), + egui::Event::Ime(_) => {} egui::Event::Copy | egui::Event::Cut => { if a < b { ctx.copy_text(text.chars().skip(a).take(b - a).collect()); @@ -385,6 +417,15 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let p = aff.apply(Point::new(x as f64, y as f64)); xf.to_screen(p.x as f32, p.y as f32) }; + // Tell the OS where the caret is: this is what enables the IME and places its candidate window. + { + let (x, top, bot) = l.caret(byte_of(&text, ed.caret)); + let (x, top, bot) = if l.lines.is_empty() { (0.0, -(12.0 * l.px_per_pt.max(1.0)), 3.0) } else { (x, top, bot) }; + let r = egui::Rect::from_two_pos(scr(x, top), scr(x, bot)).expand2(egui::vec2(1.0, 0.0)); + painter.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false }); + }); + } // Frame: paragraph text shows its box with handles; point text an underline per line. let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape)); let frame = Stroke::new(1.0, t.accent); @@ -406,6 +447,19 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } } } + // Uncommitted IME text is underlined. + if let Some((ps, pl)) = ed.preedit { + let (a, b) = (byte_of(&text, ps), byte_of(&text, ps + pl)); + for (li, ln) in l.lines.iter().enumerate() { + let xs: Vec<(f32, f32)> = + l.clusters.iter().filter(|c| c.line == li && c.range.start >= a && c.range.end <= b).map(|c| (c.x, c.x + c.advance)).collect(); + let (x0, x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q))); + if x0 < x1 { + let y = ln.baseline + ln.descent * 0.5; + painter.line_segment([scr(x0, y), scr(x1, y)], Stroke::new(1.5, t.accent)); + } + } + } // Selection: per line, the clusters inside [a, b). let (a, b) = (byte_of(&text, ed.caret.min(ed.anchor)), byte_of(&text, ed.caret.max(ed.anchor))); if a < b { @@ -945,6 +999,33 @@ mod tests { assert!(app.ui.text_edit.is_none()); } + #[test] + fn ime_preedit_is_replaced_by_each_update_and_commit_finalises() { + let mut app = app(); + pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]); + // Each preedit update replaces the previous one (and the placeholder selection first). + ime_update(&mut app, "に", false); + assert_eq!(layer_text(&app), "に"); + ime_update(&mut app, "にほん", false); + ime_update(&mut app, "にほんご", false); + assert_eq!(layer_text(&app), "にほんご"); + assert_eq!(app.ui.text_edit.as_ref().unwrap().preedit, Some((0, 4))); + ime_update(&mut app, "日本語", true); + assert_eq!(layer_text(&app), "日本語"); + let ed = app.ui.text_edit.clone().unwrap(); + assert_eq!((ed.caret, ed.anchor, ed.preedit), (3, 3, None)); + // The next composition starts after the committed text; cancelling removes it again. + ime_update(&mut app, "あ", false); + assert_eq!(layer_text(&app), "日本語あ"); + ime_update(&mut app, "", false); + assert_eq!(layer_text(&app), "日本語"); + assert_eq!(app.ui.text_edit.as_ref().unwrap().preedit, None); + // A stale preedit range (text shortened by undo) must not panic. + app.ui.text_edit.as_mut().unwrap().preedit = Some((10, 5)); + ime_update(&mut app, "x", true); + assert_eq!(layer_text(&app), "日本語x"); + } + #[test] fn empty_new_layer_is_deleted_on_commit() { let mut app = app();