feat(io): show loading overlay while opening CAD files

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 <noreply@anthropic.com>
This commit is contained in:
Hakan 2026-05-13 09:59:07 +03:00
commit 586df77b2c
7 changed files with 369 additions and 19 deletions

View file

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

View file

@ -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<AtomicU8>,
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<OpenProgress>,
// ── Unsaved-changes dialog ────────────────────────────────────────────
/// Set when the user tries to close a tab or quit while there are unsaved changes.
pending_close: Option<PendingClose>,
@ -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,

View file

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

View file

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