diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index a1e2e6c64..5e7ed8145 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -426,8 +426,10 @@ impl OpenCADStudio { pub(super) fn control_state(&self) -> Value { let tab = &self.tabs[self.active_tab]; let command = tab.active_cmd.as_deref().map(active_command_metadata); + let missing_fonts = crate::io::font_repo::missing_shx_fonts(&tab.scene.document); json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"), "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, + "read_only":self.read_only,"missing_fonts":missing_fonts, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, "hand_seed":format!("{:X}",tab.scene.document.header.handle_seed.max(tab.scene.document.next_handle())), "plugins":plugin_ids(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 4000a21b7..09970e0a3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1258,6 +1258,15 @@ pub(super) struct OpenCADStudio { /// Missing SHX fonts of the last opened drawing, offered for download /// from the community repository (see `crate::io::font_repo`). missing_fonts: Option>, + /// Drawing that produced `missing_fonts`; the active tab may change while + /// a download is in flight. + missing_fonts_path: Option, + /// Prevent duplicate download tasks and keep modal contents visible while + /// the background request is running. + missing_fonts_downloading: bool, + /// Fonts skipped or unavailable during this process lifetime. Reopening a + /// drawing must not repeatedly nag for the same unresolved file. + suppressed_missing_fonts: rustc_hash::FxHashSet, /// Drawings handed to us by other launches while `opening` was busy. /// `opening` is a single slot that a second `OpenPathPicked` would /// overwrite, and `on_file_opened` drops any result arriving once it is @@ -4178,6 +4187,9 @@ impl OpenCADStudio { open_job_serial: 0, recovery_report: None, missing_fonts: None, + missing_fonts_path: None, + missing_fonts_downloading: false, + suppressed_missing_fonts: rustc_hash::FxHashSet::default(), pending_opens: std::collections::VecDeque::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 02058be7d..06f09ce77 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1911,12 +1911,22 @@ impl OpenCADStudio { // fetch them from the community repository before the user // studies garbled substitute text (unless recovery already // owns the modal slot). - let missing = crate::io::font_repo::missing_shx_fonts( + let mut missing = crate::io::font_repo::missing_shx_fonts( &self.tabs[i].scene.document, ); - if !missing.is_empty() && self.check_missing_fonts { + missing.retain(|name| { + !self + .suppressed_missing_fonts + .contains(&crate::io::font_repo::font_key(name)) + }); + // Read-only/MCP evaluation sessions must remain non-blocking: + // report missing fonts through control state, but never offer + // a network/download mutation from a read-only launch. + if !missing.is_empty() && self.check_missing_fonts && !self.read_only { self.font_source_input = self.font_source_url.clone(); self.missing_fonts = Some(missing); + self.missing_fonts_path = self.tabs[i].current_path.clone(); + self.missing_fonts_downloading = false; self.active_modal = Some(crate::app::ModalKind::MissingFonts); } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d8ebf02f4..a10001672 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1180,13 +1180,20 @@ impl OpenCADStudio { Task::none() } Message::MissingFontsDownload => { + if self.missing_fonts_downloading { + return Task::none(); + } // Remember the source across sessions — and across drawings. let source = self.font_source_input.trim().to_string(); if self.font_source_url != source { self.font_source_url = source.clone(); self.save_config(); } - let fonts = self.missing_fonts.take().unwrap_or_default(); + let fonts = self.missing_fonts.clone().unwrap_or_default(); + if fonts.is_empty() { + return Task::none(); + } + self.missing_fonts_downloading = true; Task::perform( async move { let source = crate::io::font_repo::FontSource::from_url(&source); @@ -1196,31 +1203,66 @@ impl OpenCADStudio { ) } Message::MissingFontsDismiss => { - self.missing_fonts = None; + if self.missing_fonts_downloading { + return Task::none(); + } + for name in self.missing_fonts.take().unwrap_or_default() { + self.suppressed_missing_fonts + .insert(crate::io::font_repo::font_key(&name)); + } + self.missing_fonts_path = None; self.close_active_modal(); Task::none() } Message::MissingFontsResult(result) => { - self.missing_fonts = None; - self.close_active_modal(); + self.missing_fonts_downloading = false; match result { Ok(pairs) if pairs.is_empty() => { + let unavailable = self.missing_fonts.take().unwrap_or_default(); + for name in &unavailable { + self.suppressed_missing_fonts + .insert(crate::io::font_repo::font_key(name)); + } + self.missing_fonts_path = None; + self.close_active_modal(); self.command_line.push_error(crate::t!( - "None of the missing fonts are in the community repository yet. Contribute them at github.com/huaninstratech/OpenCADStudio/tree/main/fonts." + "None of the requested fonts are available from the selected source. Substitute fonts remain active; this notice will not repeat during this session." ).as_ref()); } Ok(pairs) => { + let requested = self.missing_fonts.take().unwrap_or_default(); + let downloaded: rustc_hash::FxHashSet = pairs + .iter() + .map(|(name, _)| crate::io::font_repo::font_key(name)) + .collect(); + let unavailable: Vec = requested + .into_iter() + .filter(|name| { + !downloaded.contains(&crate::io::font_repo::font_key(name)) + }) + .collect(); + for name in &unavailable { + self.suppressed_missing_fonts + .insert(crate::io::font_repo::font_key(name)); + } for (name, path) in &pairs { self.command_line.push_output(crate::tf!( "FONT Downloaded {name} → {path}", path = path.display() ).as_ref()); } + if !unavailable.is_empty() { + self.command_line.push_info(crate::tf!( + "FONT Not available from the selected source: {fonts}. Substitute fonts remain active.", + fonts = unavailable.join(", ") + ).as_ref()); + } // The downloaded files change glyph resolution for the // whole drawing — reload it through the standard open // pipeline so every wire is rebuilt with the real fonts. - let i = self.active_tab; - if let Some(path) = self.tabs[i].current_path.clone() { + let path = self.missing_fonts_path.take(); + self.close_active_modal(); + if let Some(path) = path { return Task::done(Message::OpenExternal(path)); } self.command_line.push_info(crate::t!( @@ -1228,6 +1270,8 @@ impl OpenCADStudio { ).as_ref()); } Err(e) => { + // Keep the prompt open: the user can correct a custom + // source or retry a transient network failure. self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); } } @@ -8043,6 +8087,12 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // The title-bar X, Escape, and MCP `close_modal` action mean + // the same thing as Skip for this prompt. Record that choice + // so reopening the drawing does not immediately nag again. + if self.active_modal == Some(super::ModalKind::MissingFonts) { + return self.update(Message::MissingFontsDismiss); + } // The Options window's × and Esc behave like its Close button. if self.active_modal == Some(super::ModalKind::Options) { return self.update(Message::OptionsClose); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index b7a7c092e..624c80a98 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -1675,6 +1675,7 @@ impl OpenCADStudio { crate::ui::window::missing_fonts::view_window( fonts, &font_source, + self.missing_fonts_downloading, flow, ) }) diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs index 5b53e8ec7..66f330fcb 100644 --- a/src/io/font_repo.rs +++ b/src/io/font_repo.rs @@ -42,6 +42,15 @@ pub enum FontSource { Custom(String), } +/// Stable key for comparing bare font names without path or case differences. +pub fn font_key(name: &str) -> String { + name.rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim() + .to_ascii_lowercase() +} + impl FontSource { /// Empty / whitespace input selects the community repository. pub fn from_url(url: &str) -> Self { @@ -265,6 +274,12 @@ mod tests { assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx"); } + #[test] + fn font_keys_are_bare_trimmed_and_case_insensitive() { + assert_eq!(font_key(r#" C:\Fonts\ROMANS.SHX "#), "romans.shx"); + assert_eq!(font_key("folder/simplex.shx"), "simplex.shx"); + } + #[test] fn rejects_a_non_listing_response() { assert!(parse_contents("{\"message\":\"Not Found\"}").is_err()); diff --git a/src/ui/window/missing_fonts.rs b/src/ui/window/missing_fonts.rs index cdc002d15..875dedc62 100644 --- a/src/ui/window/missing_fonts.rs +++ b/src/ui/window/missing_fonts.rs @@ -17,6 +17,7 @@ fn muted_style(theme: &Theme) -> text::Style { pub fn view_window<'a>( fonts: &'a [String], font_source: &'a str, + downloading: bool, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let content_width = if matches!(sizing.width, Length::Fill) { @@ -33,16 +34,24 @@ pub fn view_window<'a>( .iter() .map(|name| text(format!(" {name}")).size(12).into()) .collect(); + let download = button( + text(if downloading { + crate::t!("Downloading...") + } else { + crate::t!("Download available") + }) + .size(12), + ) + .on_press_maybe((!downloading).then_some(Message::MissingFontsDownload)) + .style(button::primary) + .padding([6, 14]); let actions = row![ Space::new().width(content_width), button(text(crate::t!("Skip")).size(12)) - .on_press(Message::MissingFontsDismiss) + .on_press_maybe((!downloading).then_some(Message::MissingFontsDismiss)) .style(button::secondary) .padding([6, 14]), - button(text(crate::t!("Download")).size(12)) - .on_press(Message::MissingFontsDownload) - .style(button::primary) - .padding([6, 14]), + download, ] .spacing(8); @@ -50,7 +59,7 @@ pub fn view_window<'a>( column![ text(crate::t!("Missing fonts")).size(20), text(crate::t!( - "This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?" + "This drawing uses fonts that are not installed on this machine. OpenCADStudio can download any redistributable fonts that exist in the selected repository; unavailable fonts will keep using a substitute." )) .size(11) .style(muted_style),