From 586df77b2c1e9a9ea5457d33c2b585c9f33169fb Mon Sep 17 00:00:00 2001 From: Hakan Date: Wed, 13 May 2026 09:59:07 +0300 Subject: [PATCH] feat(io): show loading overlay while opening CAD files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big DWG/DXF files block the UI for seconds during parse + cache build, leaving the user wondering whether the app froze. Split the file-open flow into pick → load and show a modal overlay during the load: file name, size, phase label (Reading / Parsing / Building caches / Finalizing), animated indeterminate bar, and Cancel button. Phase is tracked via a shared AtomicU8 written by the loader thread and read by the overlay each frame. Cancel hides the overlay and discards the result; the parser thread runs to completion in the background since acadrust has no interrupt hook. Co-Authored-By: Claude Opus 4.7 --- src/app/commands.rs | 3 +- src/app/mod.rs | 30 +++++ src/app/update.rs | 63 ++++++++++- src/app/view.rs | 9 +- src/io/mod.rs | 41 ++++--- src/ui/mod.rs | 1 + src/ui/open_progress.rs | 241 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 src/ui/open_progress.rs diff --git a/src/app/commands.rs b/src/app/commands.rs index b341e0f12..a95b24a28 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -18,7 +18,8 @@ impl H7CAD { if let Some(path_str) = cmd.strip_prefix("OPEN_RECENT:") { let path = PathBuf::from(path_str); - return Task::perform(crate::io::open_path(path), Message::FileOpened); + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + return Task::done(Message::OpenPathPicked(Some((path, size)))); } match cmd { diff --git a/src/app/mod.rs b/src/app/mod.rs index 1b1e6ad19..533dfbbc0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -20,10 +20,28 @@ use acadrust::CadDocument; use iced::time::Instant; use iced::window; use iced::{mouse, Point, Task, Theme}; +use std::sync::atomic::AtomicU8; +use std::sync::Arc; pub(super) const POLY_START_DELAY_MS: u128 = 150; pub(super) const VARIES_LABEL: &str = "*VARIES*"; +// ── File-open progress ───────────────────────────────────────────────────── +// Phase encoding for OpenProgress.phase atomic. Updated from the background +// loader thread, read by the UI overlay on every frame. +pub const OPEN_PHASE_READING: u8 = 0; +pub const OPEN_PHASE_PARSING: u8 = 1; +pub const OPEN_PHASE_CACHING: u8 = 2; +pub const OPEN_PHASE_FINALIZING: u8 = 3; + +#[derive(Debug, Clone)] +pub(super) struct OpenProgress { + pub name: String, + pub size_bytes: u64, + pub phase: Arc, + pub started: Instant, +} + // ── Application state ────────────────────────────────────────────────────── pub(super) struct H7CAD { @@ -148,6 +166,11 @@ pub(super) struct H7CAD { ps_lineweight_buf: String, ps_screening_buf: String, + // ── File-open progress ──────────────────────────────────────────────── + /// `Some` while a CAD file is loading — drives the modal overlay. + /// Cleared when the load finishes, errors, or the user cancels. + pub(super) opening: Option, + // ── Unsaved-changes dialog ──────────────────────────────────────────── /// Set when the user tries to close a tab or quit while there are unsaved changes. pending_close: Option, @@ -250,6 +273,12 @@ pub enum DsField { pub enum Message { Tick(Instant), OpenFile, + /// File picker returned. `Some((path, size_in_bytes))` → start loading; + /// `None` → user cancelled the dialog (no overlay shown). + OpenPathPicked(Option<(PathBuf, u64)>), + /// User clicked Cancel on the loading overlay. The parser thread keeps + /// running but its result is discarded. + OpenCancel, FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>), SaveFile, SaveAs, @@ -698,6 +727,7 @@ impl H7CAD { page_setup_offset_y: "0.0".to_string(), page_setup_rotation: "0".to_string(), page_setup_scale: "Fit".to_string(), + opening: None, pending_close: None, unsaved_dialog_window: None, save_dialog_window: None, diff --git a/src/app/update.rs b/src/app/update.rs index 6c47fdecc..36e83602a 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -15,6 +15,22 @@ use iced::{mouse, Task}; const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; +fn format_size(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let b = bytes as f64; + if b >= GB { + format!("{:.2} GB", b / GB) + } else if b >= MB { + format!("{:.1} MB", b / MB) + } else if b >= KB { + format!("{:.1} KB", b / KB) + } else { + format!("{bytes} B") + } +} + impl H7CAD { pub fn update(&mut self, msg: Message) -> Task { match msg { @@ -35,9 +51,49 @@ impl H7CAD { Task::none() } - Message::OpenFile => Task::perform(crate::io::pick_and_open(), Message::FileOpened), + Message::OpenFile => { + Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked) + } + + Message::OpenPathPicked(None) => Task::none(), + + Message::OpenPathPicked(Some((path, size_bytes))) => { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "unknown".into()); + let phase = + std::sync::Arc::new(std::sync::atomic::AtomicU8::new(super::OPEN_PHASE_READING)); + self.opening = Some(super::OpenProgress { + name: name.clone(), + size_bytes, + phase: phase.clone(), + started: Instant::now(), + }); + let size_label = format_size(size_bytes); + self.command_line + .push_info(&format!("Opening \"{name}\" ({size_label})…")); + Task::perform( + crate::io::open_path_with_phase(path, phase), + Message::FileOpened, + ) + } + + Message::OpenCancel => { + if let Some(p) = self.opening.take() { + self.command_line + .push_info(&format!("Open cancelled: \"{}\"", p.name)); + } + Task::none() + } Message::FileOpened(Ok((name, path, doc, caches))) => { + // If the user clicked Cancel while the parser was running, the + // overlay state was cleared and we silently drop the result. + if self.opening.is_none() { + return Task::none(); + } + self.opening = None; let entity_count = doc.entities().count(); self.command_line .push_output(&format!("Opened \"{name}\" — {entity_count} entities")); @@ -114,7 +170,10 @@ impl H7CAD { } Message::FileOpened(Err(e)) => { - if e != "Cancelled" { + // If the user cancelled, the overlay was already cleared and + // we suppress the noise. + let was_open = self.opening.take().is_some(); + if was_open && e != "Cancelled" { self.command_line.push_error(&format!("Open failed: {e}")); } Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index 5107d4e3c..c51564150 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -577,6 +577,12 @@ impl H7CAD { } }; + let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening { + crate::ui::open_progress::view(p, iced::time::Instant::now()) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + stack![ main_ui, self.app_menu.view(), @@ -584,7 +590,8 @@ impl H7CAD { scale_layer, dropdown_layer, layout_ctx_layer, - viewport_ctx_layer + viewport_ctx_layer, + open_progress_layer, ] .into() } diff --git a/src/io/mod.rs b/src/io/mod.rs index 2a605ecb0..ba526cfe8 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -16,12 +16,21 @@ use acadrust::entities::{Dimension, EntityType}; use acadrust::io::dwg::DwgReader; use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::Arc; + +// Phase tags written into the shared atomic so the UI overlay can display a +// human-readable label. Kept in sync with the constants in `crate::app`. +const PHASE_PARSING: u8 = 1; +const PHASE_CACHING: u8 = 2; +const PHASE_FINALIZING: u8 = 3; // ── Open ────────────────────────────────────────────────────────────────── -/// Show a file-open dialog and load the selected DWG or DXF file. -/// Returns `(filename, path, document, caches)` or an error string. -pub async fn pick_and_open() -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { +/// Show the file picker and return the chosen path plus its size in bytes. +/// Returning size up-front lets the loading overlay display "47.3 MB" before +/// the parser thread starts. +pub async fn pick_open_path() -> Option<(PathBuf, u64)> { let handle = rfd::AsyncFileDialog::new() .set_title("Open CAD file") .add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"]) @@ -29,31 +38,33 @@ pub async fn pick_and_open() -> Result<(String, PathBuf, CadDocument, DerivedCac .add_filter("DXF Files", &["dxf", "DXF"]) .add_filter("All Files", &["*"]) .pick_file() - .await; - - let handle = match handle { - Some(h) => h, - None => return Err("Cancelled".into()), - }; - + .await?; let path = handle.path().to_path_buf(); - open_path(path).await + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + Some((path, size)) } -/// Load a CAD file from a known path (used by recent files). -/// Parsing and cache building run on a dedicated OS thread so the async -/// executor stays free for rendering during the load. -pub async fn open_path( +/// Load a CAD file from a known path. Parsing and cache building run on a +/// dedicated OS thread so the async executor stays free for rendering during +/// the load. Writes phase markers into `phase` so the UI can show +/// "Parsing entities…" / "Building caches…" / "Finalizing…" while the loader +/// thread runs. +pub async fn open_path_with_phase( path: PathBuf, + phase: Arc, ) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "unknown".into()); let path2 = path.clone(); + let phase2 = phase.clone(); let (doc, caches) = std::thread::spawn(move || -> Result<_, String> { + phase2.store(PHASE_PARSING, Ordering::Relaxed); let doc = load_file(&path2)?; + phase2.store(PHASE_CACHING, Ordering::Relaxed); let caches = crate::scene::build_derived_caches(&doc); + phase2.store(PHASE_FINALIZING, Ordering::Relaxed); Ok((doc, caches)) }) .join() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b438937bb..ec8156d7a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,6 +9,7 @@ pub mod dimstyle; pub mod layers; pub mod layout_manager; pub mod mlstyle; +pub mod open_progress; pub mod overlay; pub mod page_setup; pub mod plotstyle; diff --git a/src/ui/open_progress.rs b/src/ui/open_progress.rs new file mode 100644 index 000000000..8895de380 --- /dev/null +++ b/src/ui/open_progress.rs @@ -0,0 +1,241 @@ +//! Modal overlay shown while a CAD file is being loaded. +//! +//! Displays the file name, size, current phase, an indeterminate animated +//! progress bar, and a Cancel button. + +use iced::time::Instant; +use iced::widget::{button, column, container, row, stack, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; +use std::sync::atomic::Ordering; + +use crate::app::{ + Message, OpenProgress, OPEN_PHASE_CACHING, OPEN_PHASE_FINALIZING, OPEN_PHASE_PARSING, + OPEN_PHASE_READING, +}; + +const CARD_WIDTH: f32 = 420.0; +const BAR_TRACK_WIDTH: f32 = 380.0; +const BAR_TRACK_HEIGHT: f32 = 6.0; +const BAR_WINDOW_WIDTH: f32 = 100.0; +/// Period of one back-and-forth bounce, in milliseconds. +const BAR_PERIOD_MS: f32 = 1800.0; + +fn phase_label(phase: u8) -> &'static str { + match phase { + OPEN_PHASE_READING => "Reading file…", + OPEN_PHASE_PARSING => "Parsing entities…", + OPEN_PHASE_CACHING => "Building scene caches…", + OPEN_PHASE_FINALIZING => "Finalizing…", + _ => "Working…", + } +} + +fn format_size(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let b = bytes as f64; + if b >= GB { + format!("{:.2} GB", b / GB) + } else if b >= MB { + format!("{:.1} MB", b / MB) + } else if b >= KB { + format!("{:.1} KB", b / KB) + } else { + format!("{bytes} B") + } +} + +/// Compute the left-offset of the moving highlight inside the track. +/// Bounces left↔right using a triangle wave so the user sees motion even when +/// the actual phase atomic stays put for a while. +fn bar_offset(elapsed_ms: f32) -> f32 { + let travel = (BAR_TRACK_WIDTH - BAR_WINDOW_WIDTH).max(0.0); + let cycle = (elapsed_ms / BAR_PERIOD_MS).fract(); + let tri = if cycle < 0.5 { + cycle * 2.0 + } else { + (1.0 - cycle) * 2.0 + }; + tri * travel +} + +pub fn view<'a>(progress: &'a OpenProgress, now: Instant) -> Element<'a, Message> { + let phase = progress.phase.load(Ordering::Relaxed); + let elapsed_ms = now.saturating_duration_since(progress.started).as_millis() as f32; + + // ── Animated indeterminate bar ──────────────────────────────────────── + let offset = bar_offset(elapsed_ms); + let trailing = (BAR_TRACK_WIDTH - BAR_WINDOW_WIDTH - offset).max(0.0); + + let bar_window: Element<'_, Message> = container(Space::new().width(Length::Fixed(BAR_WINDOW_WIDTH)).height(Length::Fixed(BAR_TRACK_HEIGHT))) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.30, + g: 0.62, + b: 0.95, + a: 1.0, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into(); + + let bar_moving: Element<'_, Message> = row![ + Space::new() + .width(Length::Fixed(offset)) + .height(Length::Fixed(BAR_TRACK_HEIGHT)), + bar_window, + Space::new() + .width(Length::Fixed(trailing)) + .height(Length::Fixed(BAR_TRACK_HEIGHT)), + ] + .into(); + + let bar_track: Element<'_, Message> = container( + stack![ + container(Space::new().width(Length::Fixed(BAR_TRACK_WIDTH)).height(Length::Fixed(BAR_TRACK_HEIGHT))) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.18, + g: 0.18, + b: 0.18, + a: 1.0, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }), + bar_moving, + ] + .width(Length::Fixed(BAR_TRACK_WIDTH)) + .height(Length::Fixed(BAR_TRACK_HEIGHT)), + ) + .into(); + + // ── Card body ──────────────────────────────────────────────────────── + let title = text("Opening file") + .size(15) + .color(Color::WHITE); + + let name_line = text(format!( + "{} ({})", + progress.name, + format_size(progress.size_bytes) + )) + .size(13) + .color(Color { + r: 0.82, + g: 0.82, + b: 0.82, + a: 1.0, + }); + + let phase_line = text(phase_label(phase)) + .size(12) + .color(Color { + r: 0.70, + g: 0.80, + b: 0.95, + a: 1.0, + }); + + let cancel_btn: Element<'_, Message> = button(text("Cancel").size(12).color(Color::WHITE)) + .on_press(Message::OpenCancel) + .style(|_: &Theme, status| { + let bg = match status { + button::Status::Hovered => Color { + r: 0.32, + g: 0.32, + b: 0.32, + a: 1.0, + }, + button::Status::Pressed => Color { + r: 0.42, + g: 0.18, + b: 0.18, + a: 1.0, + }, + _ => Color { + r: 0.22, + g: 0.22, + b: 0.22, + a: 1.0, + }, + }; + button::Style { + background: Some(Background::Color(bg)), + border: Border { + color: Color { + r: 0.40, + g: 0.40, + b: 0.40, + a: 1.0, + }, + width: 1.0, + radius: 3.0.into(), + }, + text_color: Color::WHITE, + ..Default::default() + } + }) + .padding([4, 14]) + .into(); + + let cancel_row: Element<'_, Message> = container(cancel_btn).align_right(Fill).into(); + + let card = container( + column![title, name_line, bar_track, phase_line, cancel_row] + .spacing(10) + .width(Length::Fixed(CARD_WIDTH)), + ) + .padding([18, 22]) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.13, + g: 0.13, + b: 0.13, + a: 0.98, + })), + border: Border { + color: Color { + r: 0.45, + g: 0.45, + b: 0.45, + a: 1.0, + }, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }); + + // ── Backdrop (click-blocker + dim) ──────────────────────────────────── + let backdrop: Element<'_, Message> = container(Space::new().width(Fill).height(Fill)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.55, + })), + ..Default::default() + }) + .width(Fill) + .height(Fill) + .into(); + + let centered: Element<'_, Message> = container(card) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Fill) + .into(); + + stack![backdrop, centered].width(Fill).height(Fill).into() +}