forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
288ccff95a
commit
586df77b2c
7 files changed
+369
-19
No files matched your search
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+61
-2
@@ -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()
|
||||
|
||||
+8
-1
@@ -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()
|
||||
}
|
||||
|
||||
+26
-15
@@ -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<AtomicU8>,
|
||||
) -> 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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user