From 392fae1af4e013aab6ba330ce15529d4ba7df2fa Mon Sep 17 00:00:00 2001 From: bherbruck Date: Tue, 13 Oct 2026 06:59:20 -0400 Subject: [PATCH] fix: Keep the window draggable while a design opens (#643) The loading box was a full-window modal whose backdrop covered the custom title bar. It is now a light overlay under the title bar, so dragging, snapping, resizing and the window buttons keep working during open, import and recovery. The timeline rebuild shows feature N of M, and Cancel or Esc stops it at the next feature (solidcraft_doc::progress). Recovery restore now runs off the window thread too. A control or MCP doc.open or import is answered once the file is in and doesn't block frames; edits sent meanwhile wait their turn. The web build still opens in place. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/eval.rs | 5 +- crates/doc/src/lib.rs | 1 + crates/doc/src/progress.rs | 133 ++++++++ crates/engine/src/cmd/file.rs | 29 ++ crates/ui-egui/src/documents.rs | 15 +- crates/ui-egui/src/file_load.rs | 396 +++++++++++++++++------ crates/ui-egui/src/file_load_ui_tests.rs | 218 +++++++++++++ crates/ui-egui/src/lib.rs | 31 +- crates/ui-egui/src/recovery_ui.rs | 12 +- crates/ui-egui/src/scenario.rs | 6 + crates/ui-egui/src/toolbar.rs | 13 +- 11 files changed, 734 insertions(+), 125 deletions(-) create mode 100644 crates/doc/src/progress.rs create mode 100644 crates/ui-egui/src/file_load_ui_tests.rs diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 972ed7d3..ab158cc9 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -888,8 +888,11 @@ impl Model { let mut restored = 0; let marker = doc.marker.unwrap_or(usize::MAX); let ctx = context_digest(doc, &vals); + let watched = crate::progress::begin(doc.features.len()); for (i, f) in doc.features.iter().enumerate() { let rolled_back = i >= marker; + // Opening was cancelled: the rest is skipped and the result discarded. + let cancelled = watched.step(i); let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back); prev_fp = fp; if reuse @@ -905,7 +908,7 @@ impl Model { continue; } reuse = false; - if f.suppressed || rolled_back { + if f.suppressed || rolled_back || cancelled { out.push(FeatureResult { id: f.id, name: f.name.clone(), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b0aba13d..c188a9b1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -32,6 +32,7 @@ pub mod naming; mod params; mod piece_names; mod profile_refs; +pub mod progress; pub use profile_refs::{capture_profile_selection, profile_selection_indices}; pub mod plastic; pub mod project; diff --git a/crates/doc/src/progress.rs b/crates/doc/src/progress.rs new file mode 100644 index 00000000..a0f54aaa --- /dev/null +++ b/crates/doc/src/progress.rs @@ -0,0 +1,133 @@ +//! Progress and cooperative cancellation for a long evaluation (opening a large design) run +//! off the window thread. The caller wraps the work in [`watch`]; the timeline's evaluation +//! loop reports each feature it reaches and, once [`Progress::cancel`] is called, skips the +//! rest. Nothing changes for evaluations that are not watched. + +use std::cell::RefCell; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +/// Shared between the worker evaluating a design and the window showing its progress. +#[derive(Debug, Default)] +pub struct Progress { + cancelled: AtomicBool, + done: AtomicUsize, + total: AtomicUsize, +} + +impl Progress { + /// Ask the watched evaluation to stop at its next feature. + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Relaxed); + } + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + /// Features evaluated so far and the timeline's length (0 before evaluation starts). + pub fn features(&self) -> (usize, usize) { + (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed)) + } +} + +struct Current { + progress: Arc, + /// Nested evaluations (derived designs) don't overwrite the outer timeline's count. + depth: usize, +} + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +/// Run `f` with `progress` watching every evaluation it makes on this thread. +pub fn watch(progress: Arc, f: impl FnOnce() -> R) -> R { + let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 }))); + struct Restore(Option>); + impl Drop for Restore { + fn drop(&mut self) { + if let Some(previous) = self.0.take() { + CURRENT.with(|c| *c.borrow_mut() = previous); + } + } + } + let _restore = Restore(Some(previous)); + f() +} + +/// Entered at the start of an evaluation; the guard leaves it. +pub(crate) struct Evaluation { + outer: bool, +} + +pub(crate) fn begin(total: usize) -> Evaluation { + let outer = CURRENT.with(|c| { + let mut c = c.borrow_mut(); + let Some(cur) = c.as_mut() else { return false }; + cur.depth += 1; + if cur.depth == 1 { + cur.progress.total.store(total, Ordering::Relaxed); + cur.progress.done.store(0, Ordering::Relaxed); + true + } else { + false + } + }); + Evaluation { outer } +} + +impl Evaluation { + /// Feature `index` is next. True when the watcher asked to stop: skip it. + pub(crate) fn step(&self, index: usize) -> bool { + CURRENT.with(|c| { + let c = c.borrow(); + let Some(cur) = c.as_ref() else { return false }; + if self.outer { + cur.progress.done.store(index, Ordering::Relaxed); + } + cur.progress.is_cancelled() + }) + } +} + +impl Drop for Evaluation { + fn drop(&mut self) { + CURRENT.with(|c| { + if let Some(cur) = c.borrow_mut().as_mut() { + cur.depth = cur.depth.saturating_sub(1); + if self.outer { + let total = cur.progress.total.load(Ordering::Relaxed); + cur.progress.done.store(total, Ordering::Relaxed); + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unwatched_evaluations_never_stop_and_watched_ones_report_and_stop() { + let e = begin(3); + assert!(!e.step(0)); + drop(e); + let p = Arc::new(Progress::default()); + watch(p.clone(), || { + let e = begin(5); + assert!(!e.step(2)); + assert_eq!(p.features(), (2, 5)); + { + let inner = begin(100); + assert!(!inner.step(50)); + } + assert_eq!(p.features(), (2, 5), "a nested evaluation keeps the outer count"); + p.cancel(); + assert!(e.step(3)); + }); + assert_eq!(p.features(), (5, 5)); + assert!(!begin(1).step(0), "the watch ends with its scope"); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e9e3cc9e..107c135a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -732,4 +732,33 @@ mod tests { assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/")); std::fs::remove_dir_all(dir).unwrap(); } + + /// A watched open reports the timeline's progress; cancelled, it skips the rebuild. + #[test] + fn watched_open_reports_progress_and_stops_when_cancelled() { + use solidcraft_doc::progress::{Progress, watch}; + use std::sync::Arc; + let dir = std::env::temp_dir().join(format!("solidcraft-watched-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("three.solidcraft").to_string_lossy().to_string(); + let mut s = Session::default(); + for x in [0, 20, 40] { + s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap(); + } + s.execute("file.save", &json!({"path":path})).unwrap(); + + let progress = Arc::new(Progress::default()); + let mut opened = Session::default(); + watch(progress.clone(), || opened.execute("doc.open", &json!({"path":path}))).unwrap(); + assert_eq!(progress.features(), (3, 3)); + assert_eq!(opened.world_state().bodies.len(), 3); + + let progress = Arc::new(Progress::default()); + progress.cancel(); + let mut stopped = Session::default(); + watch(progress, || stopped.execute("doc.open", &json!({"path":path}))).unwrap(); + assert!(stopped.model.results.iter().all(|r| r.skipped), "no feature was rebuilt after Cancel"); + assert!(stopped.world_state().bodies.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 3c06b668..20045986 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -327,7 +327,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { if app.home.open { t.text } else { t.app_bar_text }, ); crate::scenario::publish_handle("tab:home", home.center()); - if response.clicked() { + if response.clicked() && !app.file_load.busy() { app.home.show(app.session.revision); } let x0 = x0 + 70.0; @@ -428,13 +428,16 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { let s = Stroke::new(1.6, t.app_bar_text); ui.painter().line_segment([c - vec2(6.0, 0.0), c + vec2(6.0, 0.0)], s); ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s); - if presp.on_hover_text("New Design (Ctrl+N)").clicked() { + // A file loading into the window keeps the tabs as they are (dragging still moves it). + let idle = !app.file_load.busy(); + if presp.on_hover_text("New Design (Ctrl+N)").clicked() && idle { new_design(app); } - if let Some(i) = shut { - close(app, i, false); - } else if let Some(i) = pick { - switch(app, i); + match (shut, pick) { + _ if !idle => {} + (Some(i), _) => close(app, i, false), + (None, Some(i)) => switch(app, i), + (None, None) => {} } } diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 6065677c..00cf1581 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -1,24 +1,69 @@ //! Interactive file operations run off the window thread. The old design stays intact until //! the command and display meshes are ready; failures and cancelled jobs never replace it. +//! +//! While one runs, the window keeps drawing frames: the title bar still moves, resizes, +//! minimizes and maximizes the window, and a light overlay over the design shows the file's +//! progress with Cancel. The overlay takes the pointer, shortcuts and commands wait, and +//! control-channel commands that change the design are held until the file is in. A control +//! (MCP) `doc.open` or import is answered when its file finishes loading. use std::collections::VecDeque; use std::sync::{ Arc, - atomic::{AtomicBool, AtomicU8, Ordering}, - mpsc::{Receiver, TryRecvError}, + atomic::{AtomicU8, Ordering}, + mpsc::{Receiver, Sender, TryRecvError}, }; -use serde_json::Value; -#[cfg(not(target_arch = "wasm32"))] -use serde_json::json; -use solidcraft_engine::{Session, doc::Document}; +use serde_json::{Value, json}; +use solidcraft_engine::{Session, doc::Document, doc::progress::Progress}; use crate::SolveApp; +use crate::control::ControlResponse; -struct Request { - path: String, - insert: bool, +/// What a file operation does with its file. +#[derive(Clone, Debug, PartialEq)] +pub enum Kind { + /// Open the design (or a STEP/mesh file as a new design) in a tab of its own. + Open, + /// Insert a STEP or mesh file's bodies into the active design. + Insert, + /// Open a design an earlier run autosaved before it closed. + Recover { id: String, dir: Option }, } + +pub struct Request { + /// The file, or for a recovery the design's name. + pub path: String, + pub kind: Kind, + /// A control-channel caller waiting for the command's reply. + pub reply: Option>, +} + +impl Request { + pub fn new(path: &str, kind: Kind) -> Self { + Request { path: path.into(), kind, reply: None } + } + fn insert(&self) -> bool { + self.kind == Kind::Insert + } + fn command(&self) -> (&'static str, Value) { + match &self.kind { + Kind::Open => ("doc.open", json!({"path": self.path})), + Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", json!({"path": self.path})), + Kind::Insert => ("file.insert_step", json!({"path": self.path})), + Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})), + } + } + fn answer(&self, r: Result) { + if let Some(reply) = &self.reply { + let _ = reply.send(match r { + Ok(v) => json!({"ok": true, "result": v}), + Err(e) => json!({"ok": false, "error": e}), + }); + } + } +} + struct Made { session: Session, result: Value, @@ -32,7 +77,7 @@ struct Running { before: Vec, rx: Receiver, phase: Arc, - cancelled: Arc, + progress: Arc, preview_rx: Option>, preview: Option, } @@ -41,6 +86,9 @@ impl Running { fn matches(&self, session: &Session) -> bool { Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision } + fn cancelled(&self) -> bool { + self.progress.is_cancelled() + } } #[derive(Default)] @@ -50,65 +98,132 @@ pub struct FileLoad { } impl FileLoad { + /// A file operation is under way: the design waits for it. pub fn busy(&self) -> bool { - self.running.as_ref().is_some_and(|r| !r.cancelled.load(Ordering::Relaxed)) || !self.queued.is_empty() + self.running.as_ref().is_some_and(|r| !r.cancelled()) || !self.queued.is_empty() + } + /// Nothing running or queued (a cancelled job may still be finishing in [`Self::busy`]'s eyes). + pub fn idle(&self) -> bool { + self.running.is_none() && self.queued.is_empty() + } + /// Stop the running operation at its next feature and drop the queued ones. + pub fn cancel(&mut self) { + if let Some(r) = &self.running { + r.progress.cancel(); + } + for q in self.queued.drain(..) { + q.answer(Err("file operation cancelled".into())); + } } } pub fn begin(app: &mut SolveApp, path: &str, insert: bool) { + let _ = begin_request(app, Request::new(path, if insert { Kind::Insert } else { Kind::Open })); +} + +/// Queue a file operation; on the web (no threads) it runs now. +pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> { #[cfg(target_arch = "wasm32")] { - if insert { - app.insert_path(path); - } else { - app.open_path(path); - } + let r = match &request.kind { + Kind::Insert => { + app.insert_path(&request.path); + Ok(Value::Null) + } + Kind::Open => { + app.open_path(&request.path); + Ok(Value::Null) + } + Kind::Recover { .. } => { + let (command, params) = request.command(); + let r = app.run(command, params); + if r.is_ok() { + recovered(app, &request); + } + r + } + }; + request.answer(r); + Ok(()) } #[cfg(not(target_arch = "wasm32"))] { if app.file_load.queued.len() >= crate::documents::MAX_OPEN { - app.set_status("too many queued files", true); - return; + let e = "too many queued files".to_string(); + app.set_status(e.clone(), true); + request.answer(Err(e.clone())); + return Err(e); } - app.file_load.queued.push_back(Request { path: path.into(), insert }); + app.file_load.queued.push_back(request); start(app); + Ok(()) } } +/// A control-channel (MCP) `doc.open` or STEP/mesh import runs like File > Open: off the window +/// thread, answered when the file is in. Other requests come back to be handled now. +pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Option { + if cfg!(target_arch = "wasm32") || !matches!(req.method.as_str(), "engine.execute" | "command") { + return Some(req); + } + let command = req.params.get("command").or(req.params.get("id")).and_then(Value::as_str); + let kind = match command { + Some("doc.open") => Kind::Open, + Some("file.insert_step" | "file.insert_mesh") => Kind::Insert, + _ => return Some(req), + }; + let Some(path) = req.params.get("params").and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) }; + let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()) }); + None +} + +/// A recovered design is ours now: autosave it under this app's entry before the old one goes. +fn recovered(app: &mut SolveApp, request: &Request) { + let Kind::Recover { id, dir } = &request.kind else { return }; + let kept = app.autosave.as_mut().map(|a| a.save(&app.session)); + if let Some(Ok(_)) = kept { + let _ = app.session.execute("doc.recovery_discard", &json!({"id": id, "dir": dir})); + } + app.set_status(format!("recovered {}: save it to keep it", request.path), false); +} + #[cfg(not(target_arch = "wasm32"))] fn start(app: &mut SolveApp) { if app.file_load.running.is_some() { return; } let Some(request) = app.file_load.queued.pop_front() else { return }; - if !request.insert && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) { - app.set_status("too many open designs; close a tab before opening another", true); + if !request.insert() && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) { + let e = "too many open designs; close a tab before opening another"; + app.set_status(e, true); + request.answer(Err(e.into())); return; } - let before = if request.insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() }; - let mut scratch = if request.insert { app.session.scratch() } else { Session::default() }; - if request.insert { + let insert = request.insert(); + let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() }; + let mut scratch = if insert { app.session.scratch() } else { Session::default() }; + if insert { scratch.selection = app.session.selection.clone(); } let base = app.session.doc.clone(); let base_revision = app.session.revision; let phase = Arc::new(AtomicU8::new(0)); let worker_phase = phase.clone(); - let cancelled = Arc::new(AtomicBool::new(false)); - let worker_cancelled = cancelled.clone(); + let progress = Arc::new(Progress::default()); + let worker_progress = progress.clone(); let (tx, rx) = std::sync::mpsc::channel(); let (preview_tx, preview_rx) = std::sync::mpsc::channel(); - let path = request.path.clone(); - let insert = request.insert; + let (command, params) = request.command(); + let thumbnail = request.kind == Kind::Open; let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || { let preview_began = std::time::Instant::now(); - if !insert { - let cancelled = worker_cancelled.clone(); + if command == "doc.open" { + let progress = worker_progress.clone(); scratch.open_preview = Some(Box::new(move |preview| { - if !cancelled.load(Ordering::Relaxed) { + if !progress.is_cancelled() { // Render on the file worker, never the window thread. let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| saved_picture(preview))).ok().flatten(); - if let Some(picture) = picture.filter(|_| !cancelled.load(Ordering::Relaxed)) { + if let Some(picture) = picture.filter(|_| !progress.is_cancelled()) { let _ = preview_tx.send(picture); if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() { eprintln!("SolidCraft saved preview: ready_ms={:.3}", preview_began.elapsed().as_secs_f64() * 1000.0); @@ -117,16 +232,21 @@ fn start(app: &mut SolveApp) { } })); } - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled))) - .unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, command, ¶ms, thumbnail, &worker_phase, &worker_progress))) + .unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); let _ = tx.send(result); }); match spawned { Ok(_) => { app.file_load.running = - Some(Running { request, base, base_revision, before, rx, phase, cancelled, preview_rx: Some(preview_rx), preview: None }) + Some(Running { request, base, base_revision, before, rx, phase, progress, preview_rx: Some(preview_rx), preview: None }) + } + Err(e) => { + let e = format!("could not start file operation: {e}"); + request.answer(Err(e.clone())); + app.set_status(e, true); } - Err(e) => app.set_status(format!("could not start file operation: {e}"), true), } } @@ -220,20 +340,18 @@ fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcra } #[cfg(not(target_arch = "wasm32"))] -fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job { +fn compute(mut session: Session, command: &str, params: &Value, thumbnail: bool, phase: &AtomicU8, progress: &Arc) -> Job { let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some(); let began = std::time::Instant::now(); - if cancelled.load(Ordering::Relaxed) { - return Err("file operation cancelled".into()); + let cancelled = || Err("file operation cancelled".to_string()); + if progress.is_cancelled() { + return cancelled(); + } + // The timeline's rebuild reports each feature and stops at the next one after Cancel. + let result = solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params)).map_err(|e| e.to_string())?; + if progress.is_cancelled() { + return cancelled(); } - let command = if !insert { - "doc.open" - } else if solidcraft_engine::io::is_mesh_path(path) { - "file.insert_mesh" - } else { - "file.insert_step" - }; - let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?; let geometry_ms = began.elapsed().as_secs_f64() * 1000.0; let view_began = std::time::Instant::now(); phase.store(1, Ordering::Relaxed); @@ -242,8 +360,8 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can let mesh_began = std::time::Instant::now(); // Fit, picking and the first viewport frame must not trigger cold tessellation on the UI. for b in &world.bodies { - if cancelled.load(Ordering::Relaxed) { - return Err("file operation cancelled".into()); + if progress.is_cancelled() { + return cancelled(); } let _ = b.mesh(); } @@ -251,7 +369,7 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can phase.store(2, Ordering::Relaxed); let view_ms = view_began.elapsed().as_secs_f64() * 1000.0; let thumbnail_began = std::time::Instant::now(); - let image = if insert { None } else { crate::home::remember(&session) }; + let image = if thumbnail { crate::home::remember(&session) } else { None }; if profile { eprintln!( "SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}", @@ -264,7 +382,7 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { if let Some(running) = &mut app.file_load.running { - if !running.cancelled.load(Ordering::Relaxed) && running.matches(&app.session) { + if !running.cancelled() && running.matches(&app.session) { if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR)); } @@ -280,32 +398,49 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { if let Some(job) = result && let Some(running) = app.file_load.running.take() { + let request = &running.request; match job { - _ if running.cancelled.load(Ordering::Relaxed) => {} + _ if running.cancelled() => request.answer(Err("file operation cancelled".into())), Ok(made) if running.matches(&app.session) => { - if running.request.insert { + if request.insert() { app.session.accept_computed(made.session); app.big_operation = true; } else { crate::documents::adopt(app, made.session); - app.home.add_recent(&running.request.path); - crate::home::install(&mut app.home, ctx, &running.request.path, made.image); + if request.kind == Kind::Open { + app.home.add_recent(&request.path); + crate::home::install(&mut app.home, ctx, &request.path, made.image); + } } app.home.open = false; app.fit_view(); - crate::stand_up::offer(app, &running.request.path, &running.before); + if request.kind == Kind::Open || request.insert() { + crate::stand_up::offer(app, &request.path, &running.before); + } let warnings = made.result.get("warnings").and_then(Value::as_array); - app.set_status( - if let Some(w) = warnings.filter(|w| !w.is_empty()) { - format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")) - } else { - format!("{} done", if running.request.insert { "Insert" } else { "Open" }) - }, - false, - ); + if let Kind::Recover { .. } = request.kind { + recovered(app, request); + } else { + app.set_status( + if let Some(w) = warnings.filter(|w| !w.is_empty()) { + format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")) + } else { + format!("{} done", if request.insert() { "Insert" } else { "Open" }) + }, + false, + ); + } + request.answer(Ok(made.result)); + } + Ok(_) => { + let e = "the active design changed while loading; the file operation was discarded"; + request.answer(Err(e.into())); + app.set_status(e, true); + } + Err(e) => { + request.answer(Err(e.clone())); + app.set_status(e, true); } - Ok(_) => app.set_status("the active design changed while loading; the file operation was discarded", true), - Err(e) => app.set_status(e, true), } } #[cfg(not(target_arch = "wasm32"))] @@ -315,49 +450,99 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { } } +/// The light overlay over everything under the title bar while a file loads. The title bar, +/// its window buttons and the window's resize edges stay live; the design underneath takes no +/// pointer input until the file is in. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some(r) = &app.file_load.running else { return }; - let cancelled = r.cancelled.load(Ordering::Relaxed); + let cancelled = r.cancelled(); if cancelled && app.file_load.queued.is_empty() { return; } - let name = std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy()).unwrap_or_default(); + let name = match &r.request.kind { + Kind::Recover { .. } => r.request.path.clone(), + _ => std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(), + }; let phase = r.phase.load(Ordering::Relaxed); - let mut cancel = false; - egui::Modal::new(egui::Id::new("file_loading")).show(ctx, |ui| { - ui.heading(if cancelled { - "Waiting for file operation" - } else if r.request.insert { - "Inserting geometry" - } else { - "Opening design" - }); - ui.label(name.as_ref()); - if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) { - ui.image((preview.id(), egui::vec2(320.0, 200.0))); - ui.label("Saved preview. Geometry is being checked before editing."); + let (done, total) = r.progress.features(); + let screen = ctx.content_rect(); + // Leave the title bar to the window. The resize edges are a foreground layer above this. + let cover = egui::Rect::from_min_max(egui::pos2(screen.left(), screen.top() + crate::toolbar::APP_BAR_HEIGHT), screen.max); + if cover.width() <= 0.0 || cover.height() <= 0.0 { + return; + } + let centre = app.viewport.rect.filter(|v| cover.contains_rect(*v)).unwrap_or(cover).center(); + // Typing goes nowhere until the design is in. + ctx.memory_mut(|m| { + if let Some(id) = m.focused() { + m.surrender_focus(id); } - ui.horizontal(|ui| { - ui.spinner(); - ui.label(if cancelled { - "Waiting for the previous operation to stop..." - } else { - match phase { - 1 => "Preparing the view...", - 2 => "Preparing the thumbnail...", - _ => "Reading and building geometry...", - } + }); + let t = crate::theme::Tokens::get(); + let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let id = egui::Id::new("file_loading"); + let card_id = id.with("card"); + // Above the dialogs over the design; menus, tooltips and the resize edges stay above it. + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, card_id)); + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(cover.min).interactable(true).show(ctx, |ui| { + ui.set_min_size(cover.size()); + ui.interact(cover, egui::Id::new("file_loading_block"), egui::Sense::click_and_drag()); + ui.painter().rect_filled(cover, 0.0, egui::Color32::from_black_alpha(70)); + }); + let width = 340.0f32.min(cover.width() - 16.0).max(120.0); + egui::Area::new(card_id).order(egui::Order::Middle).pivot(egui::Align2::CENTER_CENTER).fixed_pos(centre).show(ctx, |child| { + egui::Frame::popup(child.style()).fill(t.panel).show(child, |ui| { + ui.set_width(width - 16.0); + ui.label( + egui::RichText::new(if cancelled { + "Waiting for file operation" + } else { + match r.request.kind { + Kind::Insert => "Inserting geometry", + Kind::Open => "Opening design", + Kind::Recover { .. } => "Recovering design", + } + }) + .strong() + .size(14.0), + ); + ui.label(egui::RichText::new(format!("{name}…")).weak()); + if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) { + let w = ui.available_width().min(320.0); + ui.image((preview.id(), egui::vec2(w, w * 200.0 / 320.0))); + ui.label(egui::RichText::new("Saved preview. Geometry is being checked before editing.").small().weak()); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label(if cancelled { + "Waiting for the previous operation to stop...".to_string() + } else { + match phase { + 1 => "Preparing the view...".into(), + 2 => "Preparing the thumbnail...".into(), + _ if total > 0 => format!("Rebuilding feature {} of {total}", (done + 1).min(total)), + _ => "Reading the file...".into(), + } + }); + }); + if phase == 0 && total > 0 && !cancelled { + ui.add(egui::ProgressBar::new(done as f32 / total as f32).desired_height(6.0)); + } + ui.add_space(4.0); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.button("Cancel"); + crate::scenario::publish_handle("file_loading:cancel", b.rect.center()); + cancel |= b.clicked(); }); }); - cancel = ui.button("Cancel").clicked(); }); - if cancel { + if cancel && !cancelled { // Keep the running job until it exits: cancelling must not allow another large - // import to overlap it in memory. The user can continue editing the current design. - if let Some(r) = &app.file_load.running { - r.cancelled.store(true, Ordering::Relaxed); - } - app.file_load.queued.clear(); + // import to overlap it in memory. The rebuild stops at its next feature and the + // current design stays as it was. + app.file_load.cancel(); app.set_status("file operation cancelled", false); } } @@ -375,13 +560,13 @@ mod tests { let (preview_tx, preview_rx) = std::sync::mpsc::channel(); preview_tx.send(egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4])).unwrap(); app.file_load.running = Some(Running { - request: Request { path: "old.solidcraft".into(), insert }, + request: Request::new("old.solidcraft", if insert { Kind::Insert } else { Kind::Open }), base, base_revision: app.session.revision, before: Vec::new(), rx, phase: Arc::new(AtomicU8::new(0)), - cancelled: Arc::new(AtomicBool::new(false)), + progress: Arc::new(Progress::default()), preview_rx: Some(preview_rx), preview: None, }); @@ -437,13 +622,13 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); tx.send(Err("bad STEP".into())).unwrap(); app.file_load.running = Some(Running { - request: Request { path: "bad.step".into(), insert: true }, + request: Request::new("bad.step", Kind::Insert), base: old.clone(), base_revision: app.session.revision, before: Vec::new(), rx, phase: Arc::new(AtomicU8::new(0)), - cancelled: Arc::new(AtomicBool::new(false)), + progress: Arc::new(Progress::default()), preview_rx: None, preview: None, }); @@ -457,15 +642,16 @@ mod tests { let mut app = SolveApp::new(Session::default(), Default::default()); let old = app.session.doc.clone(); let (tx, rx) = std::sync::mpsc::channel(); - let cancelled = Arc::new(AtomicBool::new(true)); + let progress = Arc::new(Progress::default()); + progress.cancel(); app.file_load.running = Some(Running { - request: Request { path: "cancelled.step".into(), insert: true }, + request: Request::new("cancelled.step", Kind::Insert), base: old.clone(), base_revision: app.session.revision, before: Vec::new(), rx, phase: Arc::new(AtomicU8::new(0)), - cancelled, + progress, preview_rx: None, preview: None, }); diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs new file mode 100644 index 00000000..07cdc950 --- /dev/null +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -0,0 +1,218 @@ +//! Opening a large design keeps the window alive: frames keep coming, the title bar still +//! moves the window, the design underneath waits, and the result is the synchronous open's. + +use egui::{Event, PointerButton, Pos2, RawInput, Rect, ViewportCommand, ViewportId, pos2, vec2}; +use serde_json::json; +use solidcraft_engine::Session; + +use crate::SolveApp; + +/// The whole app in a headless 1600 x 1000 window with the custom title bar. +struct Win { + ctx: egui::Context, + app: SolveApp, + time: f64, +} + +impl Win { + fn new() -> Self { + let mut app = SolveApp::new(Session::default(), Default::default()); + app.custom_titlebar = true; + app.viewport.no_pixels = true; + let mut w = Win { ctx: egui::Context::default(), app, time: 0.0 }; + for _ in 0..3 { + w.frame(vec![]); + } + w + } + + /// One frame: (the viewport commands it sent, how long it took). + fn frame(&mut self, events: Vec) -> (Vec, std::time::Duration) { + self.time += 1.0 / 60.0; + let raw = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), + time: Some(self.time), + focused: true, + events, + ..Default::default() + }; + let began = std::time::Instant::now(); + let app = &mut self.app; + let mut out = self.ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + (out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(), began.elapsed()) + } + + /// The frames of a press-drag-release along `path`: the viewport commands they sent. + fn gesture(&mut self, path: &[Pos2]) -> Vec { + let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]]; + events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)])); + events.push(vec![button(*path.last().unwrap(), false)]); + events.into_iter().flat_map(|e| self.frame(e).0).collect() + } +} + +/// A design whose timeline takes a while to rebuild: a plate with `n` posts joined and `n` +/// holes cut, saved into `dir`. +fn large_design(dir: &std::path::Path, n: usize) -> String { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 400, "width": 400, "height": 10, "corner": [-200, -200, 0]})).unwrap(); + for i in 0..n { + let (x, y) = ((i % 12) as f64 * 30.0 - 165.0, (i / 12) as f64 * 30.0 - 165.0); + s.execute("solid.cylinder", &json!({"radius": 6, "height": 20, "base": [x, y, 5], "operation": "join"})).unwrap(); + s.execute("solid.cylinder", &json!({"radius": 2, "height": 40, "base": [x + 9.0, y + 9.0, -5], "operation": "cut"})).unwrap(); + } + let path = dir.join("large.solidcraft").to_string_lossy().to_string(); + s.execute("file.save", &json!({"path": path})).unwrap(); + path +} + +fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("solidcraft-file-load-{name}-{}", std::process::id())); + let _ = std::fs::create_dir_all(&d); + d +} + +/// What two sessions must agree on after opening the same file. +fn fingerprint(s: &Session) -> serde_json::Value { + let world = s.world_state(); + let bodies: Vec<_> = world.bodies.iter().map(|b| (b.name.clone(), b.mesh().triangles.len())).collect(); + let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); + let b = solidcraft_engine::view::bounds(s); + json!({ + "name": s.doc.name, + "path": s.path, + "features": s.doc.features.iter().map(|f| f.name.clone()).collect::>(), + "bodies": bodies, + "errors": errors, + "bounds": [b.min.x, b.min.y, b.min.z, b.max.x, b.max.y, b.max.z], + "dirty": s.is_dirty(), + }) +} + +#[test] +fn the_window_keeps_drawing_and_moving_while_a_large_design_opens() { + let dir = scratch("drag"); + let path = large_design(&dir, 4); + let began = std::time::Instant::now(); + let mut sync = Session::default(); + sync.execute("doc.open", &json!({"path": path})).unwrap(); + let sync_ms = began.elapsed().as_secs_f64() * 1000.0; + + let mut w = Win::new(); + let blank = w.app.session.doc.clone(); + w.app.open_path_async(&path); + assert!(w.app.file_load.busy(), "the open runs off the window thread"); + let bar = pos2(500.0, 17.0); + let viewport = w.app.viewport.rect.map_or(pos2(800.0, 500.0), |r| r.center()); + let (mut busy_frames, mut slowest) = (0, std::time::Duration::ZERO); + let (mut moved, mut overlay, mut viewport_ignored) = (false, false, false); + for _ in 0..100_000 { + if !w.app.file_load.busy() { + break; + } + busy_frames += 1; + if busy_frames == 2 { + // The title bar still moves the window, and maximizes it on a double click. + let cmds = w.gesture(&[bar, bar + vec2(12.0, 4.0), bar + vec2(40.0, 10.0)]); + moved = cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)) && w.app.file_load.busy(); + // A drag on the design reaches the overlay, not the viewport (no orbit). + let cam = serde_json::to_value(w.app.cam).unwrap(); + w.gesture(&[viewport, viewport + vec2(30.0, 0.0), viewport + vec2(80.0, 20.0)]); + viewport_ignored = serde_json::to_value(w.app.cam).unwrap() == cam && w.app.tool.is_none(); + overlay = crate::scenario::handle_at("file_loading:cancel").is_some(); + // Shortcuts and commands wait for the file. + assert!(w.app.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err()); + continue; + } + let (_, took) = w.frame(vec![]); + slowest = slowest.max(took); + } + assert!(!w.app.file_load.busy(), "the open finished"); + assert!(busy_frames >= 3, "frames kept coming during the {sync_ms:.0} ms open: {busy_frames}"); + assert!(moved, "the title bar drag was handled during the open"); + assert!(overlay, "the Opening overlay with Cancel was shown"); + assert!(viewport_ignored, "the design underneath took no input"); + assert!(slowest.as_secs_f64() * 1000.0 < (sync_ms * 0.5).max(250.0), "no frame waited for the open: slowest {slowest:?}, open {sync_ms:.0} ms"); + for _ in 0..3 { + w.frame(vec![]); + } + assert!(!std::sync::Arc::ptr_eq(&w.app.session.doc, &blank)); + assert_eq!(fingerprint(&w.app.session), fingerprint(&sync), "the background open matches a synchronous one"); + assert_eq!(fingerprint(&w.app.session)["errors"], 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn cancel_stops_the_open_and_keeps_the_current_design() { + let dir = scratch("cancel"); + let path = large_design(&dir, 4); + let mut w = Win::new(); + w.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + let current = w.app.session.doc.clone(); + w.app.open_path_async(&path); + // The overlay is laid out on its first frame and shown from the next. + for _ in 0..3 { + w.frame(vec![]); + } + assert!(w.app.file_load.busy()); + let cancel = crate::scenario::handle_at("file_loading:cancel").expect("the overlay's Cancel button"); + w.gesture(&[cancel]); + assert!(!w.app.file_load.busy(), "Cancel gives the window back at once"); + for _ in 0..100_000 { + if w.app.file_load.idle() { + break; + } + w.frame(vec![]); + } + for _ in 0..3 { + w.frame(vec![]); + } + assert!(std::sync::Arc::ptr_eq(&w.app.session.doc, ¤t), "the current design stays"); + assert_eq!(w.app.docs.count(), 1, "no tab was opened"); + assert!(w.app.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_ok(), "editing continues"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A control-channel (MCP) open is answered when the file is in, with the synchronous +/// command's reply, and the window draws frames while it waits. Commands sent meanwhile wait. +#[test] +fn a_control_open_answers_after_loading_without_freezing_the_window() { + let dir = scratch("control"); + let path = large_design(&dir, 4); + let mut sync = Session::default(); + let expected = sync.execute("doc.open", &json!({"path": path})).unwrap(); + let mut w = Win::new(); + let (tx, rx) = std::sync::mpsc::channel(); + w.app.control_rx = Some(rx); + let (open, open_rx) = crate::control::ControlRequest::new("engine.execute", json!({"command": "doc.open", "params": {"path": path}})); + let (count, count_rx) = crate::control::ControlRequest::new("document.inspect", json!({})); + let (edit, edit_rx) = + crate::control::ControlRequest::new("engine.execute", json!({"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}})); + for r in [open, count, edit] { + tx.send(r).unwrap(); + } + let mut frames = 0; + let reply = loop { + frames += 1; + w.frame(vec![]); + if let Ok(v) = open_rx.try_recv() { + break v; + } + assert!(frames < 100_000); + }; + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"], expected); + assert!(count_rx.try_recv().is_ok(), "inspection answered while the file loaded"); + for _ in 0..3 { + w.frame(vec![]); + } + let edited = edit_rx.try_recv().expect("the held edit ran after the open"); + assert_eq!(edited["ok"], true, "{edited}"); + assert_eq!(w.app.session.doc.features.len(), sync.doc.features.len() + 1, "the edit went into the opened design"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e3d9d3d6..a12174a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ mod expression_complete; #[cfg(test)] mod extrude_edit_tests; mod file_load; +#[cfg(test)] +mod file_load_ui_tests; pub mod palette; pub mod params_dialog; pub mod prefs; @@ -263,6 +265,8 @@ pub struct SolveApp { /// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves). pub synthetic_mods: egui::Modifiers, control_rx: Option>, + /// Control requests that change the design, held while a file loads. + control_held: std::collections::VecDeque, /// The agent cursor acting out control requests. pub agent: agent_cursor::AgentCursor, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, @@ -323,6 +327,7 @@ impl SolveApp { synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), control_rx: None, + control_held: Default::default(), agent: Default::default(), pending_shots: Vec::new(), queued_shots: Vec::new(), @@ -506,6 +511,10 @@ impl SolveApp { /// Start a command the way a toolbar click does: interactive tools and dialogs for commands /// that have them, otherwise run it with defaults. pub fn start(&mut self, id: &str) { + // A file is loading: the design waits for it. + if self.file_load.busy() { + return; + } // Another command ends Edit Text (its dialog is not a feature edit to keep). if sketch_text::editing(self) { dialogs::cancel(self); @@ -1059,13 +1068,33 @@ impl SolveApp { /// queued for the next frame. const PER_FRAME: usize = 64; + /// Held requests beyond this are refused rather than kept. + const MAX_HELD: usize = 256; + let Some(rx) = self.control_rx.take() else { return }; // While the agent cursor acts out a request, later ones wait their turn. for _ in 0..PER_FRAME { if self.agent.busy() { break; } - let Ok(req) = rx.try_recv() else { break }; + // Held requests go first, in order, once the file is in. + let held = if self.file_load.busy() { None } else { self.control_held.pop_front() }; + let Some(req) = held.or_else(|| rx.try_recv().ok()) else { break }; + // A file is loading: commands that change the design wait for it (the window, + // inspection and screenshots still answer). + if self.file_load.busy() && matches!(req.method.as_str(), "engine.execute" | "command" | "engine.script") { + if self.control_held.len() < MAX_HELD { + self.control_held.push_back(req); + } else { + let _ = req.reply.send(json!({"ok": false, "error": "a file operation is in progress"})); + } + continue; + } + // Opening or importing a file: answered when the file is in, without freezing the window. + let Some(req) = file_load::control(self, req) else { + ctx.request_repaint(); + continue; + }; let reply = req.reply.clone(); if agent_cursor::intercept(self, &req.method, &req.params, &reply) { ctx.request_repaint(); diff --git a/crates/ui-egui/src/recovery_ui.rs b/crates/ui-egui/src/recovery_ui.rs index 85266897..57ffd796 100644 --- a/crates/ui-egui/src/recovery_ui.rs +++ b/crates/ui-egui/src/recovery_ui.rs @@ -94,14 +94,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.recoverable.insert(0, e); return; } - if app.run("doc.recover", json!({"id": e.id, "dir": dir_s})).is_ok() { - // Ours now: autosave it under this app's entry before the old entry goes. - let kept = app.autosave.as_mut().map(|a| a.save(&app.session)); - if let Some(Ok(_)) = kept { - let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s})); - } - app.set_status(format!("recovered {}: save it to keep it", e.name), false); - } + // Rebuilt off the window thread like any other file; once it is in, it is + // autosaved under this app's entry before the old entry goes. + let kind = crate::file_load::Kind::Recover { id: e.id.clone(), dir: dir_s }; + let _ = crate::file_load::begin_request(app, crate::file_load::Request::new(&e.name, kind)); } else { let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s})); } diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index e54e82bf..ba2275c8 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -225,6 +225,12 @@ impl Harness { } self.frame(); } + // A file opening or recovering in the background: wait for it (as a person would). + let began = std::time::Instant::now(); + while !self.app.file_load.idle() && began.elapsed() < std::time::Duration::from_secs(120) { + self.frame(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } self.frames(4); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d7b1b956..0391211d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -128,9 +128,12 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32) -> (Rect, bool) { (Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled) } +/// The application bar's height: the window's title bar on desktop builds. +pub const APP_BAR_HEIGHT: f32 = 34.0; + pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); - egui::Panel::top("sc_appbar").exact_size(34.0).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| { + egui::Panel::top("sc_appbar").exact_size(APP_BAR_HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| { let r = ui.max_rect(); // The bar is the window's title bar: its empty space moves the window. if app.custom_titlebar || app.integrated_titlebar { @@ -211,9 +214,11 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let clicked = resp.on_hover_text(tip).clicked(); enabled && clicked }; - let file = click(ui, "folder", app.tr("ui.file"), true); + // While a file loads the design waits: no menus or commands from the bar. + let idle = !app.file_load.busy(); + let file = click(ui, "folder", app.tr("ui.file"), idle); // Save, Undo and Redo act on the design, which Home hides. - let shown = design_shown(app); + let shown = design_shown(app) && idle; let tip = |id: &str, key: &str| { let label = app.tr(id); if shown { format!("{label} ({key})") } else { crate::i18n::format("ui.needs_design", &[("command", label)]) } @@ -228,7 +233,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if click(ui, "redo", &redo_tip, shown) { let _ = app.run("edit.redo", json!({})); } - if click(ui, "home", app.tr("ui.home"), true) { + if click(ui, "home", app.tr("ui.home"), idle) { let rev = app.session.revision; app.home.show(rev); }