diff --git a/src/app/mod.rs b/src/app/mod.rs index dd84633c2..a8c407776 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -671,6 +671,8 @@ pub(super) struct OpenCADStudio { main_window: Option, /// Hides drawing UI overlays for one thumbnail capture frame. thumbnail_capture_clean: bool, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: Option, #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: Option, // ── Floating panel windows ──────────────────────────────────────────── @@ -1227,6 +1229,18 @@ pub(super) enum SaveContinuation { Quit, } +#[derive(Debug, Clone)] +#[cfg(not(target_arch = "wasm32"))] +pub(super) struct PendingNativeThumbnailSave { + tab_id: u64, + path: PathBuf, + version: acadrust::DxfVersion, + purpose: SavePurpose, + continuation: SaveContinuation, + set_current_path: bool, + check_external_change: bool, +} + #[derive(Debug, Clone)] #[cfg(target_arch = "wasm32")] pub(super) struct PendingWebThumbnailSave { @@ -2049,6 +2063,8 @@ pub enum Message { AutoSave, /// A clean viewport frame is ready for thumbnail capture. ThumbnailCaptureFrame, + /// Restore drawing UI after the compositor screenshot is captured. + ThumbnailCaptureFinished, /// Native background save/autosave completed. #[cfg(not(target_arch = "wasm32"))] SaveFinished(SaveOutcome), @@ -3373,6 +3389,8 @@ impl OpenCADStudio { last_point: None, main_window: None, thumbnail_capture_clean: false, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: None, #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: None, color_pick_target: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 4978c24ab..3defb1f27 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1709,7 +1709,22 @@ pub(super) fn on_open_file(&mut self) -> Task { #[cfg(not(target_arch = "wasm32"))] pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task { - Task::none() + let Some(pending) = self.pending_native_thumbnail_save.take() else { + return Task::none(); + }; + let Some(i) = self.tabs.iter().position(|tab| tab.id == pending.tab_id) else { + self.thumbnail_capture_clean = false; + return Task::none(); + }; + self.queue_native_save( + i, + pending.path, + pending.version, + pending.purpose, + pending.continuation, + pending.set_current_path, + pending.check_external_change, + ) } #[cfg(target_arch = "wasm32")] @@ -1801,11 +1816,27 @@ pub(super) fn on_open_file(&mut self) -> Task { check_external_change: bool, ) -> Task { let tab_id = self.tabs[i].id; + if self + .pending_native_thumbnail_save + .as_ref() + .is_some_and(|pending| pending.tab_id == tab_id) + { + if purpose != crate::app::SavePurpose::Autosave { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + } + return Task::none(); + } + let capture_ready = + self.thumbnail_capture_clean && self.pending_native_thumbnail_save.is_none(); if self.active_save_jobs.contains_key(&tab_id) { if purpose != crate::app::SavePurpose::Autosave { self.command_line .push_info(crate::t!("Save already running for this drawing.").as_ref()); } + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::none(); } let destination_is_current = self.tabs[i] @@ -1823,6 +1854,9 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_tab = i; self.save_dialog_for_unsaved = continuation != crate::app::SaveContinuation::None; + if capture_ready { + self.thumbnail_capture_clean = false; + } return self.open_save_dialog_window(i); } if purpose != crate::app::SavePurpose::Autosave @@ -1847,6 +1881,33 @@ pub(super) fn on_open_file(&mut self) -> Task { }); self.restore_failed_save_continuation(continuation, i); self.active_modal = Some(crate::app::ModalKind::FileInUse); + if capture_ready { + self.thumbnail_capture_clean = false; + } + return Task::none(); + } + + if purpose != crate::app::SavePurpose::Autosave + && i == self.active_tab + && !self.thumbnail_capture_clean + && self.main_window.is_some() + && crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) + .is_some() + { + self.pending_native_thumbnail_save = Some( + crate::app::PendingNativeThumbnailSave { + tab_id, + path, + version, + purpose, + continuation, + set_current_path, + check_external_change, + }, + ); + self.thumbnail_capture_clean = true; return Task::none(); } @@ -1873,6 +1934,9 @@ pub(super) fn on_open_file(&mut self) -> Task { }); self.restore_failed_save_continuation(continuation, i); self.active_modal = Some(crate::app::ModalKind::FileInUse); + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::none(); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { @@ -1886,6 +1950,13 @@ pub(super) fn on_open_file(&mut self) -> Task { let epoch = self.tabs[i].scene.geometry_epoch; let revision = self.tabs[i].edit_revision; let camera_generation = self.tabs[i].scene.camera_generation; + let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab) + .then_some(version >= acadrust::DxfVersion::AC1027); + let capture_bounds = thumbnail.and_then(|_| { + crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) + }); let clone_started = iced::time::Instant::now(); let snapshot = self.tabs[i].scene.document.clone(); let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; @@ -1933,6 +2004,9 @@ pub(super) fn on_open_file(&mut self) -> Task { let (expected_fingerprint, verify_reader) = match verification { Ok(verification) => verification, Err(error) => { + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::perform( async move { crate::app::SaveOutcome { @@ -1956,40 +2030,97 @@ pub(super) fn on_open_file(&mut self) -> Task { } }; let worker_path = path.clone(); - Task::perform( - async move { - let result = std::thread::spawn(move || { - crate::io::save_owned_as_version_atomic( - snapshot, - &worker_path, + let capture_window = thumbnail.and(capture_bounds).and(self.main_window); + let mut work = Some(( + snapshot, + thumbnail, + capture_bounds, + worker_path, + expected_fingerprint, + verify_reader, + path, + previous_autosave, + )); + let mut run_save = move |screenshot: Option| { + let ( + mut snapshot, + thumbnail, + capture_bounds, + worker_path, + expected_fingerprint, + verify_reader, + path, + previous_autosave, + ) = work.take().expect("save capture produced more than one result"); + Task::perform( + async move { + let (result, refreshed_preview) = std::thread::spawn(move || { + let mut refreshed_preview = None; + if let Some(png) = thumbnail { + let started = iced::time::Instant::now(); + if let Some(preview) = screenshot.as_ref().and_then(|screenshot| { + capture_bounds.and_then(|bounds| { + crate::io::thumbnail::from_screenshot(screenshot, bounds, png) + }) + }) { + snapshot.preview = Some(preview); + refreshed_preview = Some(snapshot.preview.clone()); + } + if crate::perf::enabled() { + crate::perf_record!( + "[perf] save-thumbnail {:.1}ms", + started.elapsed().as_secs_f64() * 1000.0, + ); + } + } + let result = crate::io::save_owned_as_version_atomic( + snapshot, + &worker_path, + version, + backup, + expected_fingerprint, + verify_reader, + ); + (result, refreshed_preview) + }) + .join() + .unwrap_or_else(|_| { + ( + Err(crate::io::SaveFailure::other("save worker panicked")), + None, + ) + }); + crate::app::SaveOutcome { + job_id, + tab_id, + epoch, + revision, + camera_generation, + path, version, - backup, - expected_fingerprint, - verify_reader, - ) - }) - .join() - .unwrap_or_else(|_| { - Err(crate::io::SaveFailure::other("save worker panicked")) - }); - crate::app::SaveOutcome { - job_id, - tab_id, - epoch, - revision, - camera_generation, - path, - version, - previous_autosave, - set_current_path, - purpose, - continuation, - refreshed_preview: None, - result, - } - }, - Message::SaveFinished, - ) + previous_autosave, + set_current_path, + purpose, + continuation, + refreshed_preview, + result, + } + }, + Message::SaveFinished, + ) + }; + match capture_window { + Some(window) => iced::window::screenshot(window).map(Some).then(move |screenshot| { + Task::batch([ + Task::done(Message::ThumbnailCaptureFinished), + run_save(screenshot), + ]) + }), + None => { + self.thumbnail_capture_clean = false; + run_save(None) + } + } } #[cfg(not(target_arch = "wasm32"))] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 374ad10ea..62ab2c13c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6994,6 +6994,11 @@ impl OpenCADStudio { Message::ThumbnailCaptureFrame => self.on_thumbnail_capture_frame(), + Message::ThumbnailCaptureFinished => { + self.thumbnail_capture_clean = false; + Task::none() + } + #[cfg(not(target_arch = "wasm32"))] Message::SaveFinished(outcome) => self.on_save_finished(outcome), diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index 21d3b45ab..28272b409 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -7,18 +7,12 @@ //! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS //! thumbnail handlers). -#[cfg(any(target_arch = "wasm32", test))] use acadrust::{Preview, PreviewFormat}; -#[cfg(target_arch = "wasm32")] use iced::Rectangle; -use image::ImageFormat; -#[cfg(any(target_arch = "wasm32", test))] -use image::RgbImage; -#[cfg(any(target_arch = "wasm32", test))] +use image::{ImageFormat, RgbImage}; use std::io::Cursor; /// Build a preview from the visible drawing area of an Iced window screenshot. -#[cfg(target_arch = "wasm32")] pub fn from_screenshot( screenshot: &iced::window::Screenshot, bounds: Rectangle, @@ -65,7 +59,6 @@ pub fn from_screenshot( const MAX_DIM: u32 = 256; /// Canvas dimensions for an aspect ratio, longest edge = [`MAX_DIM`]. -#[cfg(any(target_arch = "wasm32", test))] fn canvas_dims(aspect: f64) -> (u32, u32) { if aspect >= 1.0 { (MAX_DIM, ((MAX_DIM as f64 / aspect).round() as u32).clamp(16, MAX_DIM)) @@ -75,7 +68,6 @@ fn canvas_dims(aspect: f64) -> (u32, u32) { } /// Encode PNG for R2013+; older targets receive a BMP/DIB. -#[cfg(any(target_arch = "wasm32", test))] fn encode(img: RgbImage, png: bool) -> Option { if png { let mut buf = Cursor::new(Vec::new()); @@ -89,7 +81,6 @@ fn encode(img: RgbImage, png: bool) -> Option { /// Build an 8-bit palettised, `BI_RLE8`-compressed DIB. `None` when the image /// holds more than 256 distinct colours (the caller then uses 24-bit). -#[cfg(any(target_arch = "wasm32", test))] fn rle8_dib(img: &RgbImage) -> Option> { let (w, h) = (img.width(), img.height()); // Exact palette + per-pixel index (top-to-bottom, left-to-right). @@ -158,7 +149,6 @@ fn rle8_dib(img: &RgbImage) -> Option> { } /// 24-bit uncompressed DIB (fallback): the `image` BMP minus its file header. -#[cfg(any(target_arch = "wasm32", test))] fn bmp24_dib(img: &RgbImage) -> Option> { let mut buf = Cursor::new(Vec::new()); img.write_to(&mut buf, ImageFormat::Bmp).ok()?;