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.
This commit is contained in:
Kayshen-X 2026-07-19 20:45:03 +08:00
parent 78cce85f88
commit d88238e471
20 changed files with 689 additions and 22 deletions

View file

@ -1214,7 +1214,17 @@ impl ApplicationHandler<DesktopEvent> 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

View file

@ -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"));
}
}

View file

@ -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(())
}

View file

@ -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) {

View file

@ -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,

View file

@ -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)]

View file

@ -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

View file

@ -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<usize> {
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);
}
}

View file

@ -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);
}
}
}

View file

@ -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

View file

@ -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.

View file

@ -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);
},
)?;
}

View file

@ -114,10 +114,21 @@ pub(crate) fn drain_font_requests<C: RepaintContext + 'static>(inner: &InnerRc<C
start_system_font_query(inner);
}
load_used_system_fonts(inner);
drain_missing_font_import_request(inner);
drain_font_import_request(inner);
drain_font_remove_request(inner);
}
/// Re-check deferred detection after the CanvasKit font drain. The async query
/// completion also calls this through `apply_browser_system_font_families`;
/// this synchronous re-check is the fallback if the query path changes later.
pub(crate) fn drain_missing_fonts_detection<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
let Ok(mut b) = inner.try_borrow_mut() else {
return;
};
b.host_mut().complete_pending_missing_fonts_detection();
}
fn should_query_system_fonts<C: RepaintContext + 'static>(inner: &InnerRc<C>) -> bool {
let Ok(b) = inner.try_borrow() else {
return false;
@ -463,6 +474,7 @@ fn refresh_imported_font_snapshot<C: RepaintContext + 'static>(inner: &InnerRc<C
.editor_ui
.imported_font_families = Arc::new(families);
b.host_mut().mark_editor_state_dirty();
b.host_mut().refresh_missing_fonts_prompt();
// A font change alters text metrics/rendering without touching the doc, and
// the web scene cache has no font-generation signal — force a rebuild so the
// layout scene isn't left stale (measured/shaped against the old fonts).
@ -470,6 +482,19 @@ fn refresh_imported_font_snapshot<C: RepaintContext + 'static>(inner: &InnerRc<C
let _ = b.repaint();
}
fn drain_missing_font_import_request<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
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<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
if !requested {
return;
}
open_font_import_picker(inner, None);
}
fn open_font_import_picker<C: RepaintContext + 'static>(
inner: &InnerRc<C>,
missing_row: Option<usize>,
) {
let inner = inner.clone();
crate::dom_io::open_file_picker(
FONT_ACCEPT,
@ -517,7 +549,18 @@ fn drain_font_import_request<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
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<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
/// 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<C: RepaintContext + 'static>(inner: &InnerRc<C>, bytes: Vec<u8>) {
fn import_font_bytes<C: RepaintContext + 'static>(inner: &InnerRc<C>, bytes: Vec<u8>) -> 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<C: RepaintContext + 'static>(inner: &InnerRc<C>, 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"));

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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<usize> {
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__"
);
}
}

View file

@ -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);
}
}
}

View file

@ -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).

View file

@ -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();
}
}