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:
parent
78cce85f88
commit
d88238e471
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
198
crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs
Normal file
198
crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
211
crates/op-host-web/src/widget_host/missing_fonts_press.rs
Normal file
211
crates/op-host-web/src/widget_host/missing_fonts_press.rs
Normal 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__"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue