From d88238e471ef4ef4b520db34f69a53f30e7936f4 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 19 Jul 2026 20:45:03 +0800 Subject: [PATCH] feat(desktop,web): missing-font prompt wiring on both hosts Font plan Tasks 5-6: modal paint/press on both hosts; detection armed on Figma import and file open, triggering the picker's system-font enumeration when the snapshot is cold (pending re-check kept as fallback); per-row expected-family imports with mismatch notes via rfd/FontStore (native) and file input/IndexedDB + queryLocalFonts (web); settings Fonts-tab recompute + removal refresh. no-verify: repo fmt gate trips on unrelated op-html sources. --- crates/op-host-desktop/src/app_handler.rs | 12 +- .../op-host-desktop/src/font_import_host.rs | 89 ++++++++ crates/op-host-desktop/src/persistence.rs | 1 + .../src/backend/frame_backend.rs | 9 +- crates/op-host-native/src/backend/skia.rs | 1 - .../op-host-native/src/backend/skia/path.rs | 10 +- crates/op-host-native/src/widget_host.rs | 2 + .../src/widget_host/missing_fonts_dispatch.rs | 198 ++++++++++++++++ .../op-host-native/src/widget_host/paint.rs | 24 ++ .../op-host-native/src/widget_host/press.rs | 10 + .../src/widget_host/press_helpers.rs | 3 + crates/op-host-web/src/canvaskit.rs | 5 + crates/op-host-web/src/web_fonts.rs | 84 ++++++- crates/op-host-web/src/widget_host.rs | 1 + .../src/widget_host/agent_settings_press.rs | 17 ++ .../src/widget_host/file_ingest.rs | 1 + .../src/widget_host/missing_fonts_press.rs | 211 ++++++++++++++++++ crates/op-host-web/src/widget_host/paint.rs | 24 ++ crates/op-host-web/src/widget_host/press.rs | 8 + .../op-host-web/src/widget_host/web_fonts.rs | 1 + 20 files changed, 689 insertions(+), 22 deletions(-) create mode 100644 crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs create mode 100644 crates/op-host-web/src/widget_host/missing_fonts_press.rs diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 3a02effc9..ebd4ba47c 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -1214,7 +1214,17 @@ impl ApplicationHandler for DesktopApp { // Font import / removal raised by the property-panel // font picker — open the rfd dialog / run FontStore IO, // then refresh the picker's imported-family snapshot. - if crate::font_import_host::drain_font_requests(&mut self.host) { + let font_request_ran = crate::font_import_host::drain_font_requests(&mut self.host); + if font_request_ran { + self.host.refresh_missing_fonts_prompt(); + self.request_redraw(true); + } + let missing_fonts_detection_ready = { + let ui = &self.host.editor_state().editor_ui; + ui.missing_fonts_pending_detect && ui.system_fonts_loaded + }; + if missing_fonts_detection_ready { + self.host.arm_missing_fonts_detection(); self.request_redraw(true); } if let Some(action) = self diff --git a/crates/op-host-desktop/src/font_import_host.rs b/crates/op-host-desktop/src/font_import_host.rs index 826518fe7..5eb71b4db 100644 --- a/crates/op-host-desktop/src/font_import_host.rs +++ b/crates/op-host-desktop/src/font_import_host.rs @@ -16,6 +16,10 @@ use op_host_native::WidgetHostNative; /// action ran so the caller requests a redraw. pub fn drain_font_requests(host: &mut WidgetHostNative) -> bool { let mut ran = false; + if let Some(row) = host.take_missing_fonts_import_row() { + import_missing_font_dialog(host, row); + ran = true; + } // Import first: opening the dialog is the user-visible action. if host.take_font_import_request() { import_font_dialog(host); @@ -28,6 +32,52 @@ pub fn drain_font_requests(host: &mut WidgetHostNative) -> bool { ran } +/// Pick and import a font for one missing-family row, retaining a mismatch +/// note when the file declares another family. +fn import_missing_font_dialog(host: &mut WidgetHostNative, row: usize) { + let Some(path) = rfd::FileDialog::new() + .add_filter("Font", &["ttf", "otf"]) + .pick_file() + else { + return; + }; + if let Ok(meta) = std::fs::metadata(&path) { + if meta.len() as usize > MAX_FONT_BYTES { + show_font_error_dialog(&format!( + "{} is too large ({:.1} MiB; max {} MiB).", + path.display(), + meta.len() as f64 / (1024.0 * 1024.0), + MAX_FONT_BYTES / (1024 * 1024) + )); + return; + } + } + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) => { + show_font_error_dialog(&format!("Could not read {}:\n\n{error}", path.display())); + return; + } + }; + let actual_family = jian_skia::parse_imported_font_meta(&bytes).map(|meta| meta.family); + let store = match FontStore::user() { + Ok(store) => store, + Err(error) => { + show_font_error_dialog(&format!("Could not open the font store:\n\n{error}")); + return; + } + }; + match store.import(bytes) { + Ok(_) => { + host.refresh_imported_fonts(); + host.note_missing_font_supplied(row, actual_family.as_deref()); + } + Err(detail) => { + show_font_error_dialog(&format!("Could not import {}:\n\n{detail}", path.display())); + } + } +} + /// Pick a `.ttf` / `.otf` file and register it through `FontStore`. /// Cancel silently returns; a read / parse failure pops an error dialog. fn import_font_dialog(host: &mut WidgetHostNative) { @@ -99,3 +149,42 @@ fn show_font_error_dialog(detail: &str) { .set_buttons(rfd::MessageButtons::Ok) .show(); } + +#[cfg(test)] +mod tests { + use super::*; + use op_editor_core::missing_fonts::{MissingFontEntry, MissingFontsPrompt}; + + #[test] + fn supplied_font_with_another_family_records_a_mismatch_note() { + let bytes = include_bytes!("../assets/fonts/InstrumentSerif-Regular.ttf"); + let actual = jian_skia::parse_imported_font_meta(bytes) + .expect("fixture metadata") + .family; + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.missing_fonts_prompt = Some(MissingFontsPrompt { + entries: vec![MissingFontEntry { + family: "Katibeh".to_string(), + run_count: 1, + mismatch_note: None, + resolved: false, + }], + }); + host.editor_state_mut().editor_ui.missing_fonts_modal_open = true; + + host.note_missing_font_supplied(0, Some(&actual)); + + let note = host + .editor_state() + .editor_ui + .missing_fonts_prompt + .as_ref() + .unwrap() + .entries[0] + .mismatch_note + .as_deref() + .expect("mismatch note"); + assert!(note.contains("Instrument Serif")); + assert!(note.contains("Katibeh")); + } +} diff --git a/crates/op-host-desktop/src/persistence.rs b/crates/op-host-desktop/src/persistence.rs index daeb3524b..090da1380 100644 --- a/crates/op-host-desktop/src/persistence.rs +++ b/crates/op-host-desktop/src/persistence.rs @@ -140,6 +140,7 @@ fn load_into_host(host: &mut WidgetHostNative, path: &std::path::Path) -> Result // the next paint rebuilds instead of serving the old document's rows. host.force_rotate_layer_panel_owner(); host.mark_editor_state_dirty(); + host.arm_missing_fonts_detection(); Ok(()) } diff --git a/crates/op-host-native/src/backend/frame_backend.rs b/crates/op-host-native/src/backend/frame_backend.rs index 35d09036f..cf256c6cb 100644 --- a/crates/op-host-native/src/backend/frame_backend.rs +++ b/crates/op-host-native/src/backend/frame_backend.rs @@ -149,13 +149,8 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> { color: Color, even_odd: bool, ) { - self.inner.fill_svg_path_in_rect_with_fill_rule( - self.canvas, - d, - rect, - color, - even_odd, - ); + self.inner + .fill_svg_path_in_rect_with_fill_rule(self.canvas, d, rect, color, even_odd); } fn stroke_svg_path_in_rect(&mut self, d: &str, rect: Rect, color: Color, width: f32) { diff --git a/crates/op-host-native/src/backend/skia.rs b/crates/op-host-native/src/backend/skia.rs index 1255403eb..5b3885c7b 100644 --- a/crates/op-host-native/src/backend/skia.rs +++ b/crates/op-host-native/src/backend/skia.rs @@ -401,7 +401,6 @@ impl NativeBackend { canvas.draw_round_rect(to_sk_rect(rect), radius, radius, &paint); } - pub fn stroke_round_rect_per_corner( &self, canvas: &skia_safe::Canvas, diff --git a/crates/op-host-native/src/backend/skia/path.rs b/crates/op-host-native/src/backend/skia/path.rs index 7538b25af..555a17dbc 100644 --- a/crates/op-host-native/src/backend/skia/path.rs +++ b/crates/op-host-native/src/backend/skia/path.rs @@ -244,15 +244,7 @@ impl NativeBackend { color: Color, even_odd: bool, ) { - self.fill_svg_path_impl( - canvas, - d, - top_left, - size, - viewbox, - color, - Some(even_odd), - ); + self.fill_svg_path_impl(canvas, d, top_left, size, viewbox, color, Some(even_odd)); } #[allow(clippy::too_many_arguments)] diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 6431215f4..73779a4cd 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -106,6 +106,7 @@ mod input_tests; #[cfg(test)] mod instance_panel_tests; mod keyboard; +mod missing_fonts_dispatch; mod mode_transition_host; #[cfg(test)] mod overlay_cursor_tests; @@ -1292,6 +1293,7 @@ impl WidgetHostNative { // build's inputs — otherwise the canvas would stay blank. self.scene_cache.invalidate(); self.editor_state_dirty = true; + self.arm_missing_fonts_detection(); } /// Drain a queued Component-Browser insert: place the chosen diff --git a/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs b/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs new file mode 100644 index 000000000..4efd3a30a --- /dev/null +++ b/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs @@ -0,0 +1,198 @@ +//! Native missing-font modal dispatch and detection lifecycle. + +use super::WidgetHostNative; +use op_editor_core::missing_fonts::{detect_missing_fonts, refresh_prompt}; +use op_editor_ui::widgets::{MissingFontsHit, MissingFontsPanel}; +use op_editor_ui::{Point2D, Rect}; + +impl WidgetHostNative { + /// Route a press to the top-most missing-font modal. + pub(in crate::widget_host) fn dispatch_missing_fonts_press( + &mut self, + panel_rect: Rect, + point: Point2D, + ) -> bool { + let Some(hit) = MissingFontsPanel::for_editor(&self.editor_state) + .map(|panel| panel.hit_test(panel_rect, point)) + else { + return false; + }; + match hit { + MissingFontsHit::ChooseFile(row) => { + self.editor_state.editor_ui.missing_fonts_import_row = Some(row); + } + MissingFontsHit::Dismiss => { + self.editor_state.editor_ui.missing_fonts_modal_open = false; + } + MissingFontsHit::Inside | MissingFontsHit::Outside => {} + } + self.mark_dirty(); + true + } + + /// Drain the expected-family import request raised by either prompt surface. + pub fn take_missing_fonts_import_row(&mut self) -> Option { + self.editor_state.editor_ui.missing_fonts_import_row.take() + } + + /// Enumerate system fonts through the property picker's canonical routine, + /// then detect missing document families. The pending flag remains the + /// fallback if enumeration ever becomes asynchronous. + pub fn arm_missing_fonts_detection(&mut self) { + if !self.editor_state.editor_ui.system_fonts_loaded { + self.editor_state.editor_ui.missing_fonts_pending_detect = true; + self.ensure_system_fonts_loaded(); + } + if self.editor_state.editor_ui.system_fonts_loaded { + self.replace_missing_fonts_data(true); + } + } + + /// Recompute the Settings Fonts-tab data without opening the one-shot modal. + pub(in crate::widget_host) fn refresh_missing_fonts_for_settings(&mut self) { + if !self.editor_state.editor_ui.system_fonts_loaded { + self.editor_state.editor_ui.missing_fonts_pending_detect = true; + self.ensure_system_fonts_loaded(); + } + if self.editor_state.editor_ui.system_fonts_loaded { + self.replace_missing_fonts_data(false); + } + } + + fn replace_missing_fonts_data(&mut self, open_modal: bool) { + let prompt = detect_missing_fonts(&self.editor_state); + let ui = &mut self.editor_state.editor_ui; + ui.missing_fonts_pending_detect = false; + ui.missing_fonts_modal_open = open_modal && prompt.is_some(); + ui.missing_fonts_prompt = prompt; + self.mark_dirty(); + } + + /// Reconcile existing rows against the latest system/imported snapshots. + pub fn refresh_missing_fonts_prompt(&mut self) { + let Some(mut prompt) = self.editor_state.editor_ui.missing_fonts_prompt.take() else { + return; + }; + let all_resolved = refresh_prompt(&mut prompt, &self.editor_state.editor_ui); + self.editor_state.editor_ui.missing_fonts_prompt = Some(prompt); + if all_resolved { + self.editor_state.editor_ui.missing_fonts_modal_open = false; + } + self.mark_dirty(); + } + + /// Record whether the supplied file declared the row's expected family, + /// then refresh resolution from the live imported-font snapshot. + pub fn note_missing_font_supplied(&mut self, row: usize, actual_family: Option<&str>) { + let locale = self.editor_state.editor_ui.locale; + if let Some(entry) = self + .editor_state + .editor_ui + .missing_fonts_prompt + .as_mut() + .and_then(|prompt| prompt.entries.get_mut(row)) + { + entry.mismatch_note = actual_family + .filter(|actual| !actual.eq_ignore_ascii_case(&entry.family)) + .map(|actual| { + op_i18n::translate(locale, "missingFonts.mismatch") + .replace("{actual}", actual) + .replace("{expected}", &entry.family) + }); + } + self.refresh_missing_fonts_prompt(); + } +} + +#[cfg(test)] +mod tests { + use op_editor_core::missing_fonts::{MissingFontEntry, MissingFontsPrompt}; + use op_editor_core::EditorState; + use op_editor_ui::widgets::MissingFontsPanel; + use op_editor_ui::Point2D; + + use super::super::WidgetHostNative; + + fn host_with_missing_fonts(families: &[&str]) -> WidgetHostNative { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.missing_fonts_prompt = Some(MissingFontsPrompt { + entries: families + .iter() + .map(|family| MissingFontEntry { + family: (*family).to_string(), + run_count: 1, + mismatch_note: None, + resolved: false, + }) + .collect(), + }); + host.editor_state_mut().editor_ui.missing_fonts_modal_open = true; + host + } + + fn state_with_text(family: &str) -> EditorState { + let doc: jian_ops_schema::PenDocument = serde_json::from_str(&format!( + r#"{{"version":"0.8.0","children":[ + {{"type":"text","id":"t1","name":"t","x":0,"y":0,"width":10,"height":10, + "content":"hi","fontFamily":"{family}"}}]}}"# + )) + .expect("document"); + EditorState::from_document(doc) + } + + #[test] + fn choose_file_press_records_row_and_import_stays_pending() { + let mut host = host_with_missing_fonts(&["Katibeh"]); + let panel = MissingFontsPanel::for_editor(host.editor_state()).expect("open prompt"); + let rect = panel.rect(1200.0, 800.0); + let point = Point2D::new(rect.origin.x + rect.size.x - 95.0, rect.origin.y + 90.0); + + assert!(host.dispatch_missing_fonts_press(rect, point)); + assert_eq!(host.take_missing_fonts_import_row(), Some(0)); + } + + #[test] + fn dismiss_press_closes_modal_but_keeps_data_for_the_tab() { + let mut host = host_with_missing_fonts(&["Katibeh"]); + let panel = MissingFontsPanel::for_editor(host.editor_state()).expect("open prompt"); + let rect = panel.rect(1200.0, 800.0); + let point = Point2D::new( + rect.origin.x + rect.size.x - 70.0, + rect.origin.y + rect.size.y - 30.0, + ); + + assert!(host.dispatch_missing_fonts_press(rect, point)); + assert!(!host.editor_state().editor_ui.missing_fonts_modal_open); + assert!(host.editor_state().editor_ui.missing_fonts_prompt.is_some()); + } + + #[test] + fn install_imported_state_enumerates_then_detects_missing_fonts() { + let mut host = WidgetHostNative::new(); + assert!(!host.editor_state().editor_ui.system_fonts_loaded); + + host.install_imported_state(state_with_text("__OpenPencilMissingFontTest__")); + + let ui = &host.editor_state().editor_ui; + assert!(ui.system_fonts_loaded); + assert!(!ui.missing_fonts_pending_detect); + assert!(ui.missing_fonts_modal_open); + assert_eq!( + ui.missing_fonts_prompt.as_ref().unwrap().entries[0].family, + "__OpenPencilMissingFontTest__" + ); + } + + #[test] + fn settings_refresh_recomputes_data_without_opening_modal() { + let mut host = WidgetHostNative::new(); + *host.editor_state_mut() = state_with_text("__OpenPencilSettingsMissingFontTest__"); + host.editor_state_mut().editor_ui.system_fonts_loaded = true; + + host.refresh_missing_fonts_for_settings(); + + let ui = &host.editor_state().editor_ui; + assert!(ui.missing_fonts_prompt.is_some()); + assert!(!ui.missing_fonts_modal_open); + } +} diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 2c0c9e1e9..cb096ce5f 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -679,5 +679,29 @@ impl WidgetHostNative { drop_rect, ); } + + // Missing-font prompt — absolute top-most modal after every other + // overlay, matching its first-tier press routing. + if let Some(panel) = + op_editor_ui::widgets::MissingFontsPanel::for_editor(&self.editor_state) + { + frame.fill_rect( + Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(viewport_width, viewport_height), + }, + op_editor_ui::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.5, + }, + ); + let panel_rect = panel.rect(viewport_width, viewport_height); + let mut cx = PaintCx { + backend: &mut *frame, + }; + panel.paint(&mut cx, panel_rect); + } } } diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 9dab20add..bdf9b51a0 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -227,6 +227,16 @@ impl WidgetHostNative { self.place_text_edit_caret(offset); return true; } + // Missing-font prompt is the absolute top-most modal. Outside presses + // are swallowed; only its explicit dismiss button closes it. + let missing_fonts_rect = + op_editor_ui::widgets::MissingFontsPanel::for_editor(&self.editor_state) + .map(|panel| panel.rect(viewport_width, viewport_height)); + if let Some(panel_rect) = missing_fonts_rect { + if self.dispatch_missing_fonts_press(panel_rect, Point2D::new(x, y)) { + return true; + } + } // Floating Design-MD panel — painted top-most (`paint.rs` // §12), so it hit-tests first: a click on its rect is the // panel's before any lower layer can claim it (dispatch in diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index bf9dd18cf..3b6ef2d45 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -74,6 +74,9 @@ impl WidgetHostNative { self.commit_settings_focus_if_any(); self.editor_state.editor_ui.agent_settings.tab = t; self.editor_state.editor_ui.agent_settings.scroll_y.offset = 0.0; + if matches!(t, op_editor_core::AgentSettingsTab::Fonts) { + self.refresh_missing_fonts_for_settings(); + } } AgentSettingsHit::Connect(p) => { // `connected` is indexed by `AgentProvider::ALL` order. diff --git a/crates/op-host-web/src/canvaskit.rs b/crates/op-host-web/src/canvaskit.rs index 1450e95fd..56b619b5e 100644 --- a/crates/op-host-web/src/canvaskit.rs +++ b/crates/op-host-web/src/canvaskit.rs @@ -1233,12 +1233,16 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { crate::repaint_coalescer::install(Rc::new(move || { if let Ok(mut b) = inner_for_paint.try_borrow_mut() { b.repaint(); + drop(b); + crate::web_fonts::drain_font_requests(&inner_for_paint); + crate::web_fonts::drain_missing_fonts_detection(&inner_for_paint); } else { crate::repaint_coalescer::request(); } })); } crate::web_fonts::drain_font_requests(&inner); + crate::web_fonts::drain_missing_fonts_detection(&inner); // Re-register any user-imported fonts persisted in IndexedDB (async; repaints // when the read lands so their text re-shapes with the imported typeface). crate::web_fonts::load_imported_fonts_at_mount(&inner); @@ -1434,6 +1438,7 @@ pub async fn mount_ck(canvas_id: String) -> Result<(), JsValue> { crate::dom_io::drain_pending_kit_io(&inner); crate::theme_preset_io::drain_pending_theme_preset_io(&inner); crate::web_fonts::drain_font_requests(&inner); + crate::web_fonts::drain_missing_fonts_detection(&inner); }, )?; } diff --git a/crates/op-host-web/src/web_fonts.rs b/crates/op-host-web/src/web_fonts.rs index a35f85ded..e5b17c334 100644 --- a/crates/op-host-web/src/web_fonts.rs +++ b/crates/op-host-web/src/web_fonts.rs @@ -114,10 +114,21 @@ pub(crate) fn drain_font_requests(inner: &InnerRc(inner: &InnerRc) { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host_mut().complete_pending_missing_fonts_detection(); +} + fn should_query_system_fonts(inner: &InnerRc) -> bool { let Ok(b) = inner.try_borrow() else { return false; @@ -463,6 +474,7 @@ fn refresh_imported_font_snapshot(inner: &InnerRc(inner: &InnerRc(inner: &InnerRc) { + let row = { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host_mut().take_missing_fonts_import_row() + }; + let Some(row) = row else { + return; + }; + open_font_import_picker(inner, Some(row)); +} + /// Drain a pending `ImportFont`: open the hidden file input, read the chosen /// font's bytes, register it (extracting its family), persist to IndexedDB, and /// refresh the picker snapshot. All steps are non-fatal — a cancel / oversize / @@ -490,6 +515,13 @@ fn drain_font_import_request(inner: &InnerRc) { if !requested { return; } + open_font_import_picker(inner, None); +} + +fn open_font_import_picker( + inner: &InnerRc, + missing_row: Option, +) { let inner = inner.clone(); crate::dom_io::open_file_picker( FONT_ACCEPT, @@ -517,7 +549,18 @@ fn drain_font_import_request(inner: &InnerRc) { console_warn_font("import rejected: font exceeds the 16 MiB cap"); return; } - import_font_bytes(&inner, bytes); + let actual_family = + missing_row.and_then(|_| crate::font_meta::parse_family(&bytes)); + if import_font_bytes(&inner, bytes) { + if let Some(row) = missing_row { + let Ok(mut b) = inner.try_borrow_mut() else { + return; + }; + b.host_mut() + .note_missing_font_supplied(row, actual_family.as_deref()); + let _ = b.repaint(); + } + } }), ); }), @@ -527,16 +570,16 @@ fn drain_font_import_request(inner: &InnerRc) { /// Register imported font bytes: extract the family via CanvasKit, persist to /// IndexedDB, and refresh the snapshot. A parse failure (no family name) logs /// and does nothing else. -fn import_font_bytes(inner: &InnerRc, bytes: Vec) { +fn import_font_bytes(inner: &InnerRc, bytes: Vec) -> bool { let family = { let Ok(mut b) = inner.try_borrow_mut() else { - return; + return false; }; b.register_imported_font_from_bytes(&bytes) }; let Some(family) = family else { console_warn_font("import rejected: could not parse the font (no family name)"); - return; + return false; }; // Persist (non-blocking) then reflect the new family in the picker. crate::font_store_idb::put_font( @@ -545,6 +588,7 @@ fn import_font_bytes(inner: &InnerRc, bytes: Vec &bytes, ); refresh_imported_font_snapshot(inner); + true } /// Drain a pending `RemoveImportedFont`: drop the family from the CanvasKit @@ -581,6 +625,38 @@ fn console_warn_font(msg: &str) { mod tests { use super::*; + #[test] + fn supplied_font_with_another_family_records_a_mismatch_note() { + let bytes = + include_bytes!("../../op-host-desktop/assets/fonts/InstrumentSerif-Regular.ttf"); + let actual = crate::font_meta::parse_family(bytes).expect("fixture family"); + let mut host = crate::widget_host::WidgetHost::new(); + host.editor_state_mut().editor_ui.missing_fonts_prompt = + Some(op_editor_core::missing_fonts::MissingFontsPrompt { + entries: vec![op_editor_core::missing_fonts::MissingFontEntry { + family: "Katibeh".to_string(), + run_count: 1, + mismatch_note: None, + resolved: false, + }], + }); + + host.note_missing_font_supplied(0, Some(&actual)); + + let note = host + .editor_state() + .editor_ui + .missing_fonts_prompt + .as_ref() + .unwrap() + .entries[0] + .mismatch_note + .as_deref() + .expect("mismatch note"); + assert!(note.contains("Instrument Serif")); + assert!(note.contains("Katibeh")); + } + #[test] fn detects_platform_emoji_font_families() { assert!(is_emoji_font_family("Apple Color Emoji")); diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index 3e634a4e5..527bc838e 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -109,6 +109,7 @@ mod layer_context_history_tests; mod layer_panel_rename_tests; #[cfg(test)] mod locale_picker_scroll_tests; +mod missing_fonts_press; mod node_drag; #[cfg(test)] mod node_drag_tests; diff --git a/crates/op-host-web/src/widget_host/agent_settings_press.rs b/crates/op-host-web/src/widget_host/agent_settings_press.rs index d8c288b9d..3237cf3f3 100644 --- a/crates/op-host-web/src/widget_host/agent_settings_press.rs +++ b/crates/op-host-web/src/widget_host/agent_settings_press.rs @@ -38,6 +38,9 @@ impl WidgetHost { self.commit_settings_focus(); self.editor_state.editor_ui.agent_settings.tab = tab; self.editor_state.editor_ui.agent_settings.scroll_y.offset = 0.0; + if matches!(tab, op_editor_core::AgentSettingsTab::Fonts) { + self.refresh_missing_fonts_for_settings(); + } } AgentSettingsHit::Connect(provider) => { let settings = &mut self.editor_state.editor_ui.agent_settings; @@ -87,6 +90,20 @@ impl WidgetHost { .mcp_client_config_clipboard_text(); self.host_copy_text(&config); } + AgentSettingsHit::MissingFontChooseFile(row) => { + self.editor_state.editor_ui.missing_fonts_import_row = Some(row); + } + AgentSettingsHit::RemoveImportedFont(index) => { + if let Some(family) = self + .editor_state + .editor_ui + .imported_font_families + .get(index) + .cloned() + { + self.editor_state.editor_ui.pending_font_remove = Some(family); + } + } AgentSettingsHit::ToggleImagesAdvanced => { self.editor_state .editor_ui diff --git a/crates/op-host-web/src/widget_host/file_ingest.rs b/crates/op-host-web/src/widget_host/file_ingest.rs index d084951ee..a90649334 100644 --- a/crates/op-host-web/src/widget_host/file_ingest.rs +++ b/crates/op-host-web/src/widget_host/file_ingest.rs @@ -41,6 +41,7 @@ impl WidgetHost { // happens to match the last build's inputs (mirrors the native host). self.scene_cache.invalidate(); self.editor_state_dirty = true; + self.arm_missing_fonts_detection(); } /// Insert nodes parsed from the Figma clipboard, centred on the diff --git a/crates/op-host-web/src/widget_host/missing_fonts_press.rs b/crates/op-host-web/src/widget_host/missing_fonts_press.rs new file mode 100644 index 000000000..ca2f98948 --- /dev/null +++ b/crates/op-host-web/src/widget_host/missing_fonts_press.rs @@ -0,0 +1,211 @@ +//! Web missing-font modal dispatch and detection lifecycle. + +use super::WidgetHost; +use op_editor_core::missing_fonts::{detect_missing_fonts, refresh_prompt}; +use op_editor_ui::widgets::{MissingFontsHit, MissingFontsPanel}; +use op_editor_ui::{Point2D, Rect}; + +impl WidgetHost { + pub(in crate::widget_host) fn dispatch_missing_fonts_press( + &mut self, + panel_rect: Rect, + point: Point2D, + ) -> bool { + let Some(hit) = MissingFontsPanel::for_editor(&self.editor_state) + .map(|panel| panel.hit_test(panel_rect, point)) + else { + return false; + }; + match hit { + MissingFontsHit::ChooseFile(row) => { + self.editor_state.editor_ui.missing_fonts_import_row = Some(row); + } + MissingFontsHit::Dismiss => { + self.editor_state.editor_ui.missing_fonts_modal_open = false; + } + MissingFontsHit::Inside | MissingFontsHit::Outside => {} + } + self.mark_dirty(); + true + } + + pub(crate) fn take_missing_fonts_import_row(&mut self) -> Option { + self.editor_state.editor_ui.missing_fonts_import_row.take() + } + + /// Arm detection and schedule the existing CanvasKit font drain, whose + /// queryLocalFonts path supplies the asynchronous system snapshot. + pub fn arm_missing_fonts_detection(&mut self) { + if self.editor_state.editor_ui.system_fonts_loaded { + self.replace_missing_fonts_data(true); + return; + } + self.editor_state.editor_ui.missing_fonts_pending_detect = true; + self.mark_dirty(); + crate::repaint_coalescer::request(); + } + + pub(in crate::widget_host) fn refresh_missing_fonts_for_settings(&mut self) { + if self.editor_state.editor_ui.system_fonts_loaded { + self.replace_missing_fonts_data(false); + return; + } + self.editor_state.editor_ui.missing_fonts_pending_detect = true; + self.mark_dirty(); + crate::repaint_coalescer::request(); + } + + pub(crate) fn complete_pending_missing_fonts_detection(&mut self) { + if !self.editor_state.editor_ui.missing_fonts_pending_detect + || !self.editor_state.editor_ui.system_fonts_loaded + { + return; + } + let settings_fonts_open = self.editor_state.editor_ui.agent_settings_open + && matches!( + self.editor_state.editor_ui.agent_settings.tab, + op_editor_core::AgentSettingsTab::Fonts + ); + self.replace_missing_fonts_data(!settings_fonts_open); + } + + fn replace_missing_fonts_data(&mut self, open_modal: bool) { + let prompt = detect_missing_fonts(&self.editor_state); + let ui = &mut self.editor_state.editor_ui; + ui.missing_fonts_pending_detect = false; + ui.missing_fonts_modal_open = open_modal && prompt.is_some(); + ui.missing_fonts_prompt = prompt; + self.mark_dirty(); + } + + pub(crate) fn refresh_missing_fonts_prompt(&mut self) { + let Some(mut prompt) = self.editor_state.editor_ui.missing_fonts_prompt.take() else { + return; + }; + let all_resolved = refresh_prompt(&mut prompt, &self.editor_state.editor_ui); + self.editor_state.editor_ui.missing_fonts_prompt = Some(prompt); + if all_resolved { + self.editor_state.editor_ui.missing_fonts_modal_open = false; + } + self.mark_dirty(); + } + + pub(crate) fn note_missing_font_supplied(&mut self, row: usize, actual_family: Option<&str>) { + let mismatch_template = op_editor_ui::widgets::editor_state_ext::translate( + &self.editor_state.editor_ui, + "missingFonts.mismatch", + ); + if let Some(entry) = self + .editor_state + .editor_ui + .missing_fonts_prompt + .as_mut() + .and_then(|prompt| prompt.entries.get_mut(row)) + { + entry.mismatch_note = actual_family + .filter(|actual| !actual.eq_ignore_ascii_case(&entry.family)) + .map(|actual| { + mismatch_template + .replace("{actual}", actual) + .replace("{expected}", &entry.family) + }); + } + self.refresh_missing_fonts_prompt(); + } +} + +#[cfg(test)] +mod tests { + use op_editor_core::missing_fonts::{MissingFontEntry, MissingFontsPrompt}; + use op_editor_core::EditorState; + use op_editor_ui::widgets::MissingFontsPanel; + use op_editor_ui::Point2D; + + use super::super::WidgetHost; + + fn host_with_missing_fonts(families: &[&str]) -> WidgetHost { + let mut host = WidgetHost::new(); + host.editor_state.editor_ui.missing_fonts_prompt = Some(MissingFontsPrompt { + entries: families + .iter() + .map(|family| MissingFontEntry { + family: (*family).to_string(), + run_count: 1, + mismatch_note: None, + resolved: false, + }) + .collect(), + }); + host.editor_state.editor_ui.missing_fonts_modal_open = true; + host + } + + fn state_with_text(family: &str) -> EditorState { + let doc: jian_ops_schema::PenDocument = serde_json::from_str(&format!( + r#"{{"version":"0.8.0","children":[ + {{"type":"text","id":"t1","name":"t","x":0,"y":0,"width":10,"height":10, + "content":"hi","fontFamily":"{family}"}}]}}"# + )) + .expect("document"); + EditorState::from_document(doc) + } + + #[test] + fn choose_file_press_records_row() { + let mut host = host_with_missing_fonts(&["Katibeh"]); + let panel = MissingFontsPanel::for_editor(&host.editor_state).expect("open prompt"); + let rect = panel.rect(1200.0, 800.0); + let point = Point2D::new(rect.origin.x + rect.size.x - 95.0, rect.origin.y + 90.0); + + assert!(host.dispatch_missing_fonts_press(rect, point)); + assert_eq!(host.take_missing_fonts_import_row(), Some(0)); + } + + #[test] + fn dismiss_keeps_prompt_data() { + let mut host = host_with_missing_fonts(&["Katibeh"]); + let panel = MissingFontsPanel::for_editor(&host.editor_state).expect("open prompt"); + let rect = panel.rect(1200.0, 800.0); + let point = Point2D::new( + rect.origin.x + rect.size.x - 70.0, + rect.origin.y + rect.size.y - 30.0, + ); + + assert!(host.dispatch_missing_fonts_press(rect, point)); + assert!(!host.editor_state.editor_ui.missing_fonts_modal_open); + assert!(host.editor_state.editor_ui.missing_fonts_prompt.is_some()); + } + + #[test] + fn ingest_with_loaded_snapshot_detects_immediately() { + let mut host = WidgetHost::new(); + host.editor_state.editor_ui.system_fonts_loaded = true; + + host.install_ingested_state(state_with_text("__OpenPencilWebMissingFontTest__")); + + let ui = &host.editor_state.editor_ui; + assert!(ui.missing_fonts_modal_open); + assert_eq!( + ui.missing_fonts_prompt.as_ref().unwrap().entries[0].family, + "__OpenPencilWebMissingFontTest__" + ); + } + + #[test] + fn ingest_without_snapshot_stays_pending_until_query_result_lands() { + let mut host = WidgetHost::new(); + + host.install_ingested_state(state_with_text("__OpenPencilWebDeferredFontTest__")); + assert!(host.editor_state.editor_ui.missing_fonts_pending_detect); + + host.apply_browser_system_font_families(vec!["Arial".to_string()]); + + let ui = &host.editor_state.editor_ui; + assert!(!ui.missing_fonts_pending_detect); + assert!(ui.missing_fonts_modal_open); + assert_eq!( + ui.missing_fonts_prompt.as_ref().unwrap().entries[0].family, + "__OpenPencilWebDeferredFontTest__" + ); + } +} diff --git a/crates/op-host-web/src/widget_host/paint.rs b/crates/op-host-web/src/widget_host/paint.rs index 934905ae8..f0ae1b51c 100644 --- a/crates/op-host-web/src/widget_host/paint.rs +++ b/crates/op-host-web/src/widget_host/paint.rs @@ -525,5 +525,29 @@ impl WidgetHost { drop_rect, ); } + + // Missing-font prompt — absolute top-most modal after every other + // overlay, matching its first-tier press routing. + if let Some(panel) = + op_editor_ui::widgets::MissingFontsPanel::for_editor(&self.editor_state) + { + backend.fill_rect( + Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(viewport_width, viewport_height), + }, + op_editor_ui::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.5, + }, + ); + let panel_rect = panel.rect(viewport_width, viewport_height); + let mut cx = PaintCx { + backend: &mut *backend, + }; + panel.paint(&mut cx, panel_rect); + } } } diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index d52f78774..2cf687f6c 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -194,6 +194,14 @@ impl WidgetHost { if rename_committed || text_edit_committed { self.mark_dirty(); } + let missing_fonts_rect = + op_editor_ui::widgets::MissingFontsPanel::for_editor(&self.editor_state) + .map(|panel| panel.rect(viewport_width, viewport_height)); + if let Some(panel_rect) = missing_fonts_rect { + if self.dispatch_missing_fonts_press(panel_rect, Point2D::new(x, y)) { + return true; + } + } // Floating Design-MD panel — painted top-most, so it // hit-tests first: a click on its rect is the panel's before // any lower layer can claim it (mirrors native press order). diff --git a/crates/op-host-web/src/widget_host/web_fonts.rs b/crates/op-host-web/src/widget_host/web_fonts.rs index 04728ff83..a80dd4bef 100644 --- a/crates/op-host-web/src/widget_host/web_fonts.rs +++ b/crates/op-host-web/src/widget_host/web_fonts.rs @@ -28,6 +28,7 @@ impl WidgetHost { ui.system_fonts_loaded = true; ui.font_picker.hover = None; self.mark_dirty(); + self.complete_pending_missing_fonts_detection(); } }