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 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 06:59:20 -04:00
1 parent 8be0f64f5a
commit 392fae1af4
11 files changed
+734 -125

No files matched your search

+4 -1
View File
@@ -888,8 +888,11 @@ impl Model {
let mut restored = 0; let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX); let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals); let ctx = context_digest(doc, &vals);
let watched = crate::progress::begin(doc.features.len());
for (i, f) in doc.features.iter().enumerate() { for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker; 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); let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
prev_fp = fp; prev_fp = fp;
if reuse if reuse
@@ -905,7 +908,7 @@ impl Model {
continue; continue;
} }
reuse = false; reuse = false;
if f.suppressed || rolled_back { if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult { out.push(FeatureResult {
id: f.id, id: f.id,
name: f.name.clone(), name: f.name.clone(),
+1
View File
@@ -32,6 +32,7 @@ pub mod naming;
mod params; mod params;
mod piece_names; mod piece_names;
mod profile_refs; mod profile_refs;
pub mod progress;
pub use profile_refs::{capture_profile_selection, profile_selection_indices}; pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic; pub mod plastic;
pub mod project; pub mod project;
+133
View File
@@ -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<Progress>,
/// Nested evaluations (derived designs) don't overwrite the outer timeline's count.
depth: usize,
}
thread_local! {
static CURRENT: RefCell<Option<Current>> = const { RefCell::new(None) };
}
/// Run `f` with `progress` watching every evaluation it makes on this thread.
pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 })));
struct Restore(Option<Option<Current>>);
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");
}
}
+29
View File
@@ -732,4 +732,33 @@ mod tests {
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/")); assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
std::fs::remove_dir_all(dir).unwrap(); 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();
}
} }
+9 -6
View File
@@ -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 }, if app.home.open { t.text } else { t.app_bar_text },
); );
crate::scenario::publish_handle("tab:home", home.center()); crate::scenario::publish_handle("tab:home", home.center());
if response.clicked() { if response.clicked() && !app.file_load.busy() {
app.home.show(app.session.revision); app.home.show(app.session.revision);
} }
let x0 = x0 + 70.0; 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); 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(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); 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); new_design(app);
} }
if let Some(i) = shut { match (shut, pick) {
close(app, i, false); _ if !idle => {}
} else if let Some(i) = pick { (Some(i), _) => close(app, i, false),
switch(app, i); (None, Some(i)) => switch(app, i),
(None, None) => {}
} }
} }
+291 -105
View File
@@ -1,24 +1,69 @@
//! Interactive file operations run off the window thread. The old design stays intact until //! 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. //! 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::collections::VecDeque;
use std::sync::{ use std::sync::{
Arc, Arc,
atomic::{AtomicBool, AtomicU8, Ordering}, atomic::{AtomicU8, Ordering},
mpsc::{Receiver, TryRecvError}, mpsc::{Receiver, Sender, TryRecvError},
}; };
use serde_json::Value; use serde_json::{Value, json};
#[cfg(not(target_arch = "wasm32"))] use solidcraft_engine::{Session, doc::Document, doc::progress::Progress};
use serde_json::json;
use solidcraft_engine::{Session, doc::Document};
use crate::SolveApp; use crate::SolveApp;
use crate::control::ControlResponse;
struct Request { /// What a file operation does with its file.
path: String, #[derive(Clone, Debug, PartialEq)]
insert: bool, 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<String> },
} }
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<Sender<ControlResponse>>,
}
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<Value, String>) {
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 { struct Made {
session: Session, session: Session,
result: Value, result: Value,
@@ -32,7 +77,7 @@ struct Running {
before: Vec<String>, before: Vec<String>,
rx: Receiver<Job>, rx: Receiver<Job>,
phase: Arc<AtomicU8>, phase: Arc<AtomicU8>,
cancelled: Arc<AtomicBool>, progress: Arc<Progress>,
preview_rx: Option<Receiver<egui::ColorImage>>, preview_rx: Option<Receiver<egui::ColorImage>>,
preview: Option<egui::TextureHandle>, preview: Option<egui::TextureHandle>,
} }
@@ -41,6 +86,9 @@ impl Running {
fn matches(&self, session: &Session) -> bool { fn matches(&self, session: &Session) -> bool {
Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision
} }
fn cancelled(&self) -> bool {
self.progress.is_cancelled()
}
} }
#[derive(Default)] #[derive(Default)]
@@ -50,65 +98,132 @@ pub struct FileLoad {
} }
impl FileLoad { impl FileLoad {
/// A file operation is under way: the design waits for it.
pub fn busy(&self) -> bool { 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) { 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")] #[cfg(target_arch = "wasm32")]
{ {
if insert { let r = match &request.kind {
app.insert_path(path); Kind::Insert => {
} else { app.insert_path(&request.path);
app.open_path(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"))] #[cfg(not(target_arch = "wasm32"))]
{ {
if app.file_load.queued.len() >= crate::documents::MAX_OPEN { if app.file_load.queued.len() >= crate::documents::MAX_OPEN {
app.set_status("too many queued files", true); let e = "too many queued files".to_string();
return; 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); 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<crate::control::ControlRequest> {
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"))] #[cfg(not(target_arch = "wasm32"))]
fn start(app: &mut SolveApp) { fn start(app: &mut SolveApp) {
if app.file_load.running.is_some() { if app.file_load.running.is_some() {
return; return;
} }
let Some(request) = app.file_load.queued.pop_front() else { 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) { 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); let e = "too many open designs; close a tab before opening another";
app.set_status(e, true);
request.answer(Err(e.into()));
return; return;
} }
let before = if request.insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() }; let insert = request.insert();
let mut scratch = if request.insert { app.session.scratch() } else { Session::default() }; let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
if request.insert { let mut scratch = if insert { app.session.scratch() } else { Session::default() };
if insert {
scratch.selection = app.session.selection.clone(); scratch.selection = app.session.selection.clone();
} }
let base = app.session.doc.clone(); let base = app.session.doc.clone();
let base_revision = app.session.revision; let base_revision = app.session.revision;
let phase = Arc::new(AtomicU8::new(0)); let phase = Arc::new(AtomicU8::new(0));
let worker_phase = phase.clone(); let worker_phase = phase.clone();
let cancelled = Arc::new(AtomicBool::new(false)); let progress = Arc::new(Progress::default());
let worker_cancelled = cancelled.clone(); let worker_progress = progress.clone();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let (preview_tx, preview_rx) = std::sync::mpsc::channel(); let (preview_tx, preview_rx) = std::sync::mpsc::channel();
let path = request.path.clone(); let (command, params) = request.command();
let insert = request.insert; let thumbnail = request.kind == Kind::Open;
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || { let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
let preview_began = std::time::Instant::now(); let preview_began = std::time::Instant::now();
if !insert { if command == "doc.open" {
let cancelled = worker_cancelled.clone(); let progress = worker_progress.clone();
scratch.open_preview = Some(Box::new(move |preview| { 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. // Render on the file worker, never the window thread.
let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| saved_picture(preview))).ok().flatten(); 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); let _ = preview_tx.send(picture);
if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() { 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); 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))) let result =
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, command, &params, thumbnail, &worker_phase, &worker_progress)))
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
let _ = tx.send(result); let _ = tx.send(result);
}); });
match spawned { match spawned {
Ok(_) => { Ok(_) => {
app.file_load.running = 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"))] #[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<Progress>) -> Job {
let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some(); let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some();
let began = std::time::Instant::now(); let began = std::time::Instant::now();
if cancelled.load(Ordering::Relaxed) { let cancelled = || Err("file operation cancelled".to_string());
return Err("file operation cancelled".into()); 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 geometry_ms = began.elapsed().as_secs_f64() * 1000.0;
let view_began = std::time::Instant::now(); let view_began = std::time::Instant::now();
phase.store(1, Ordering::Relaxed); 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(); let mesh_began = std::time::Instant::now();
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI. // Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &world.bodies { for b in &world.bodies {
if cancelled.load(Ordering::Relaxed) { if progress.is_cancelled() {
return Err("file operation cancelled".into()); return cancelled();
} }
let _ = b.mesh(); let _ = b.mesh();
} }
@@ -251,7 +369,7 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
phase.store(2, Ordering::Relaxed); phase.store(2, Ordering::Relaxed);
let view_ms = view_began.elapsed().as_secs_f64() * 1000.0; let view_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let thumbnail_began = std::time::Instant::now(); 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 { if profile {
eprintln!( 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}", "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) { pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(running) = &mut app.file_load.running { 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()) { 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)); 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 if let Some(job) = result
&& let Some(running) = app.file_load.running.take() && let Some(running) = app.file_load.running.take()
{ {
let request = &running.request;
match job { 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) => { Ok(made) if running.matches(&app.session) => {
if running.request.insert { if request.insert() {
app.session.accept_computed(made.session); app.session.accept_computed(made.session);
app.big_operation = true; app.big_operation = true;
} else { } else {
crate::documents::adopt(app, made.session); crate::documents::adopt(app, made.session);
app.home.add_recent(&running.request.path); if request.kind == Kind::Open {
crate::home::install(&mut app.home, ctx, &running.request.path, made.image); app.home.add_recent(&request.path);
crate::home::install(&mut app.home, ctx, &request.path, made.image);
}
} }
app.home.open = false; app.home.open = false;
app.fit_view(); 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); let warnings = made.result.get("warnings").and_then(Value::as_array);
app.set_status( if let Kind::Recover { .. } = request.kind {
if let Some(w) = warnings.filter(|w| !w.is_empty()) { recovered(app, request);
format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")) } else {
} else { app.set_status(
format!("{} done", if running.request.insert { "Insert" } else { "Open" }) 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(""))
false, } 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"))] #[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) { pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(r) = &app.file_load.running else { return }; 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() { if cancelled && app.file_load.queued.is_empty() {
return; 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 phase = r.phase.load(Ordering::Relaxed);
let mut cancel = false; let (done, total) = r.progress.features();
egui::Modal::new(egui::Id::new("file_loading")).show(ctx, |ui| { let screen = ctx.content_rect();
ui.heading(if cancelled { // Leave the title bar to the window. The resize edges are a foreground layer above this.
"Waiting for file operation" let cover = egui::Rect::from_min_max(egui::pos2(screen.left(), screen.top() + crate::toolbar::APP_BAR_HEIGHT), screen.max);
} else if r.request.insert { if cover.width() <= 0.0 || cover.height() <= 0.0 {
"Inserting geometry" return;
} else { }
"Opening design" let centre = app.viewport.rect.filter(|v| cover.contains_rect(*v)).unwrap_or(cover).center();
}); // Typing goes nowhere until the design is in.
ui.label(name.as_ref()); ctx.memory_mut(|m| {
if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) { if let Some(id) = m.focused() {
ui.image((preview.id(), egui::vec2(320.0, 200.0))); m.surrender_focus(id);
ui.label("Saved preview. Geometry is being checked before editing.");
} }
ui.horizontal(|ui| { });
ui.spinner(); let t = crate::theme::Tokens::get();
ui.label(if cancelled { let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape));
"Waiting for the previous operation to stop..." let id = egui::Id::new("file_loading");
} else { let card_id = id.with("card");
match phase { // Above the dialogs over the design; menus, tooltips and the resize edges stay above it.
1 => "Preparing the view...", ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
2 => "Preparing the thumbnail...", ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, card_id));
_ => "Reading and building geometry...", 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 // 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. // import to overlap it in memory. The rebuild stops at its next feature and the
if let Some(r) = &app.file_load.running { // current design stays as it was.
r.cancelled.store(true, Ordering::Relaxed); app.file_load.cancel();
}
app.file_load.queued.clear();
app.set_status("file operation cancelled", false); app.set_status("file operation cancelled", false);
} }
} }
@@ -375,13 +560,13 @@ mod tests {
let (preview_tx, preview_rx) = std::sync::mpsc::channel(); let (preview_tx, preview_rx) = std::sync::mpsc::channel();
preview_tx.send(egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4])).unwrap(); preview_tx.send(egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4])).unwrap();
app.file_load.running = Some(Running { 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,
base_revision: app.session.revision, base_revision: app.session.revision,
before: Vec::new(), before: Vec::new(),
rx, rx,
phase: Arc::new(AtomicU8::new(0)), phase: Arc::new(AtomicU8::new(0)),
cancelled: Arc::new(AtomicBool::new(false)), progress: Arc::new(Progress::default()),
preview_rx: Some(preview_rx), preview_rx: Some(preview_rx),
preview: None, preview: None,
}); });
@@ -437,13 +622,13 @@ mod tests {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
tx.send(Err("bad STEP".into())).unwrap(); tx.send(Err("bad STEP".into())).unwrap();
app.file_load.running = Some(Running { 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: old.clone(),
base_revision: app.session.revision, base_revision: app.session.revision,
before: Vec::new(), before: Vec::new(),
rx, rx,
phase: Arc::new(AtomicU8::new(0)), phase: Arc::new(AtomicU8::new(0)),
cancelled: Arc::new(AtomicBool::new(false)), progress: Arc::new(Progress::default()),
preview_rx: None, preview_rx: None,
preview: None, preview: None,
}); });
@@ -457,15 +642,16 @@ mod tests {
let mut app = SolveApp::new(Session::default(), Default::default()); let mut app = SolveApp::new(Session::default(), Default::default());
let old = app.session.doc.clone(); let old = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel(); 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 { 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: old.clone(),
base_revision: app.session.revision, base_revision: app.session.revision,
before: Vec::new(), before: Vec::new(),
rx, rx,
phase: Arc::new(AtomicU8::new(0)), phase: Arc::new(AtomicU8::new(0)),
cancelled, progress,
preview_rx: None, preview_rx: None,
preview: None, preview: None,
}); });
+218
View File
@@ -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<Event>) -> (Vec<ViewportCommand>, 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<ViewportCommand> {
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::<Vec<_>>(),
"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, &current), "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);
}
+30 -1
View File
@@ -66,6 +66,8 @@ mod expression_complete;
#[cfg(test)] #[cfg(test)]
mod extrude_edit_tests; mod extrude_edit_tests;
mod file_load; mod file_load;
#[cfg(test)]
mod file_load_ui_tests;
pub mod palette; pub mod palette;
pub mod params_dialog; pub mod params_dialog;
pub mod prefs; 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). /// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
pub synthetic_mods: egui::Modifiers, pub synthetic_mods: egui::Modifiers,
control_rx: Option<Receiver<ControlRequest>>, control_rx: Option<Receiver<ControlRequest>>,
/// Control requests that change the design, held while a file loads.
control_held: std::collections::VecDeque<ControlRequest>,
/// The agent cursor acting out control requests. /// The agent cursor acting out control requests.
pub agent: agent_cursor::AgentCursor, pub agent: agent_cursor::AgentCursor,
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>, pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
@@ -323,6 +327,7 @@ impl SolveApp {
synthetic: Vec::new(), synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(), synthetic_mods: egui::Modifiers::default(),
control_rx: None, control_rx: None,
control_held: Default::default(),
agent: Default::default(), agent: Default::default(),
pending_shots: Vec::new(), pending_shots: Vec::new(),
queued_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 /// Start a command the way a toolbar click does: interactive tools and dialogs for commands
/// that have them, otherwise run it with defaults. /// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) { 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). // Another command ends Edit Text (its dialog is not a feature edit to keep).
if sketch_text::editing(self) { if sketch_text::editing(self) {
dialogs::cancel(self); dialogs::cancel(self);
@@ -1059,13 +1068,33 @@ impl SolveApp {
/// queued for the next frame. /// queued for the next frame.
const PER_FRAME: usize = 64; 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 }; let Some(rx) = self.control_rx.take() else { return };
// While the agent cursor acts out a request, later ones wait their turn. // While the agent cursor acts out a request, later ones wait their turn.
for _ in 0..PER_FRAME { for _ in 0..PER_FRAME {
if self.agent.busy() { if self.agent.busy() {
break; 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(); let reply = req.reply.clone();
if agent_cursor::intercept(self, &req.method, &req.params, &reply) { if agent_cursor::intercept(self, &req.method, &req.params, &reply) {
ctx.request_repaint(); ctx.request_repaint();
+4 -8
View File
@@ -94,14 +94,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.recoverable.insert(0, e); app.recoverable.insert(0, e);
return; return;
} }
if app.run("doc.recover", json!({"id": e.id, "dir": dir_s})).is_ok() { // Rebuilt off the window thread like any other file; once it is in, it is
// Ours now: autosave it under this app's entry before the old entry goes. // autosaved under this app's entry before the old entry goes.
let kept = app.autosave.as_mut().map(|a| a.save(&app.session)); let kind = crate::file_load::Kind::Recover { id: e.id.clone(), dir: dir_s };
if let Some(Ok(_)) = kept { let _ = crate::file_load::begin_request(app, crate::file_load::Request::new(&e.name, kind));
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);
}
} else { } else {
let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s})); let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s}));
} }
+6
View File
@@ -225,6 +225,12 @@ impl Harness {
} }
self.frame(); 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); self.frames(4);
} }
+9 -4
View File
@@ -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) (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) { pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get(); 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(); let r = ui.max_rect();
// The bar is the window's title bar: its empty space moves the window. // The bar is the window's title bar: its empty space moves the window.
if app.custom_titlebar || app.integrated_titlebar { 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(); let clicked = resp.on_hover_text(tip).clicked();
enabled && 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. // 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 tip = |id: &str, key: &str| {
let label = app.tr(id); let label = app.tr(id);
if shown { format!("{label} ({key})") } else { crate::i18n::format("ui.needs_design", &[("command", label)]) } 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) { if click(ui, "redo", &redo_tip, shown) {
let _ = app.run("edit.redo", json!({})); 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; let rev = app.session.revision;
app.home.show(rev); app.home.show(rev);
} }