Timeline marker, undo and redo compute off the window thread (#1675)

Thanks @phanoob.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-16 06:26:10 -04:00
1 parent 9b2a40781c
commit d5640b5efb
7 files changed
+285 -11

No files matched your search

+21
View File
@@ -156,6 +156,16 @@ pub struct Snapshot {
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
}
/// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]).
pub struct Ahead(Session);
impl Ahead {
/// Run `commands` (their results stay in the shared evaluation cache).
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> {
self.0.preview_in_place(commands).map(|_| ())
}
}
/// What some commands would make, evaluated on a scratch copy of a session
/// ([`Session::preview`]). The session itself is not changed.
#[derive(Clone, Debug)]
@@ -887,6 +897,17 @@ impl Session {
self.scratch().preview_in_place(commands).map(|(after, replies)| Preview { before: visible(self), after: visible(&after), replies })
}
/// Run `commands` on a [`Session::scratch`] copy that keeps the undo and redo stacks: what
/// they evaluate lands in the evaluation cache the copy shares with this session, so running
/// them here afterwards finds it there. For a front end that computes a command which may
/// take a while (undo, the history marker moved) off its window thread first.
pub fn ahead(&self) -> Ahead {
let mut copy = self.scratch();
copy.undo.clone_from(&self.undo);
copy.redo.clone_from(&self.redo);
Ahead(copy)
}
fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<(Self, Vec<Value>)> {
if commands.is_empty() {
return Err(EngineError::Other("nothing to preview".into()));
+123
View File
@@ -0,0 +1,123 @@
//! Commands that recompute the history, computed off the window thread as OK is (#1201): the
//! history marker dragged or clicked, a feature suppressed, reordered or deleted from the
//! timeline, undo and redo, the marker put back after a sketch. On a part with a slow feature
//! after the marker (a line of text cut into a block), each of these ran that feature's boolean
//! on the window thread, and the window froze until it was done.
//!
//! The command is evaluated on a copy of the session ([`solidcraft_engine::Session::ahead`]),
//! which leaves what it computes in the evaluation cache the session shares. Done within the
//! app's wait ([`crate::dialogs::APPLY_AT_ONCE`]), the command just runs; otherwise the window
//! goes on, saying it computes, and the command runs for real once the evaluation is in, finding
//! its features in the cache. Commands asked for meanwhile wait their turn; a marker moved again
//! replaces the move still waiting, so a drag computes where it ends rather than every slot it
//! crossed.
use serde_json::Value;
use crate::SolveApp;
/// A command computing off the window thread.
pub struct Deferred {
done: std::sync::mpsc::Receiver<()>,
/// When it started (ms).
pub(crate) started: f64,
command: (String, Value),
/// The design's revision it was computed for: a design changed meanwhile computes it again.
revision: u64,
}
impl Deferred {
fn finished(&self) -> bool {
!matches!(self.done.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))
}
}
/// Commands a later one of the same kind replaces while they wait: where the marker ends is all
/// that counts.
fn replaced_by_a_later_one(id: &str) -> bool {
id == "timeline.roll_to"
}
impl SolveApp {
/// Run a command that may recompute much of the history, off the window thread when it takes
/// a while (see the module notes). `Ok(Null)` when it is left computing or waiting its turn:
/// its outcome then shows as any command's does (an error in the status line).
pub fn run_heavy(&mut self, id: &str, params: Value) -> Result<Value, String> {
if self.deferred.is_some() {
if replaced_by_a_later_one(id) && self.deferred_queue.last().is_some_and(|(q, _)| q == id) {
self.deferred_queue.pop();
}
self.deferred_queue.push((id.to_string(), params));
return Ok(Value::Null);
}
match self.begin_deferred(id, &params) {
Some(job) => {
self.deferred = Some(job);
Ok(Value::Null)
}
None => self.run(id, params),
}
}
/// Since when a command has been computing off the window thread (ms), if one is.
pub fn computing_since(&self) -> Option<f64> {
self.deferred.as_ref().map(|d| d.started)
}
/// Each frame: a command whose evaluation is in runs, then the next one waiting starts.
pub(crate) fn poll_deferred(&mut self, ctx: &egui::Context) {
if let Some(job) = &self.deferred {
if !job.finished() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
return;
}
if let Some(job) = self.deferred.take() {
let (id, params) = job.command;
let superseded = replaced_by_a_later_one(&id) && self.deferred_queue.first().is_some_and(|(q, _)| *q == id);
if job.revision != self.session.revision {
// The design changed meanwhile: computed again for the design as it is now.
let _ = self.run_heavy(&id, params);
} else if !superseded {
let _ = self.run(&id, params);
}
}
}
if self.deferred.is_none() && !self.deferred_queue.is_empty() {
let (id, params) = self.deferred_queue.remove(0);
let _ = self.run_heavy(&id, params);
}
}
/// Start evaluating a command ahead. `None` when it should just run: not asked for
/// ([`SolveApp::apply_off_thread`]), a file operation in progress, no threads (the web), or
/// done within the wait.
#[cfg(not(target_arch = "wasm32"))]
fn begin_deferred(&self, id: &str, params: &Value) -> Option<Deferred> {
let wait = self.apply_off_thread?;
if self.file_load.busy() || self.file_picker.is_some() {
return None;
}
let ahead = self.session.ahead();
let command = (id.to_string(), params.clone());
let commands = vec![command.clone()];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("history".into())
.spawn(move || {
let _ = ahead.evaluate(&commands);
let _ = tx.send(());
})
.ok()?;
match rx.recv_timeout(wait) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
}
_ => None,
}
}
#[cfg(target_arch = "wasm32")]
fn begin_deferred(&self, _id: &str, _params: &Value) -> Option<Deferred> {
None
}
}
@@ -0,0 +1,100 @@
//! Commands that recompute the history off the window thread (`deferred`): the marker moved,
//! undo, each computed beside the window and run once in; a marker moved again while one move
//! computes replaces the move still waiting.
use std::time::Duration;
use serde_json::json;
use crate::scenario::Harness;
/// A 60 × 20 × 10 block, a 10 × 10 × 5 boss on it, and a 4 mm hole through the boss, the
/// marker at the end. Every command computes off the window thread (no wait at all).
fn block_boss_hole() -> Harness {
let mut h = Harness::new();
h.app.home.open = false;
for (id, p) in [
("solid.box", json!({"length": 60, "width": 20, "height": 10})),
("solid.box", json!({"corner": [5, 5, 10], "length": 10, "width": 10, "height": 5, "operation": "join"})),
("solid.cylinder", json!({"base": [10, 10, -1], "axis": [0, 0, 1], "radius": 2, "height": 20, "operation": "cut"})),
] {
h.app.run(id, p).unwrap();
}
h.app.apply_off_thread = Some(Duration::ZERO);
h
}
/// Frames until nothing computes or waits (a few seconds at most).
fn until_settled(h: &mut Harness) {
for _ in 0..500 {
if h.app.deferred.is_none() && h.app.deferred_queue.is_empty() {
break;
}
h.frames(1);
std::thread::sleep(Duration::from_millis(10));
}
h.frames(1);
}
fn marker(h: &Harness) -> Option<usize> {
h.app.session.doc.marker
}
#[test]
fn the_marker_moved_computes_off_the_window_thread_then_moves() {
let mut h = block_boss_hole();
h.app.run_heavy("timeline.roll_to", json!({"position": 1})).unwrap();
assert!(h.app.computing_since().is_some(), "computing");
assert_eq!(marker(&h), None, "the design is as it was while it computes");
until_settled(&mut h);
assert_eq!(marker(&h), Some(1));
h.step(&json!({"expect": {"volume": 12000}})).unwrap();
// And back to the end, the boss and the hole recomputed (or found in the cache).
h.app.run_heavy("timeline.roll_to", json!({})).unwrap();
until_settled(&mut h);
assert_eq!(marker(&h), None);
let hole = std::f64::consts::PI * 4.0 * 15.0;
let v = h.app.run("inspect.measure", json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
assert!((v - (12500.0 - hole)).abs() < 1.0, "{v}");
}
#[test]
fn a_marker_moved_again_replaces_the_move_still_waiting() {
let mut h = block_boss_hole();
h.app.run_heavy("timeline.roll_to", json!({"position": 1})).unwrap();
assert!(h.app.computing_since().is_some());
// A drag: two more slots while the first computes. Only the last one waits.
h.app.run_heavy("timeline.roll_to", json!({"position": 2})).unwrap();
h.app.run_heavy("timeline.roll_to", json!({"position": 3})).unwrap();
assert_eq!(h.app.deferred_queue.len(), 1, "{:?}", h.app.deferred_queue);
until_settled(&mut h);
assert_eq!(marker(&h), None, "where the drag ended: the end of the history");
}
#[test]
fn undo_computes_off_the_window_thread() {
let mut h = block_boss_hole();
let features = h.app.session.doc.features.len();
h.app.start("edit.undo");
assert!(h.app.computing_since().is_some(), "undo computing");
assert_eq!(h.app.session.doc.features.len(), features, "nothing undone yet");
until_settled(&mut h);
assert_eq!(h.app.session.doc.features.len(), features - 1, "the hole undone");
h.step(&json!({"expect": {"volume": 12500}})).unwrap();
h.app.start("edit.redo");
until_settled(&mut h);
assert_eq!(h.app.session.doc.features.len(), features, "and redone");
}
#[test]
fn a_design_changed_while_a_move_computes_is_computed_again() {
let mut h = block_boss_hole();
h.app.run_heavy("timeline.roll_to", json!({"position": 2})).unwrap();
assert!(h.app.computing_since().is_some());
// Something else changes the design meanwhile (a command that just runs).
h.app.run("solid.box", json!({"corner": [40, 5, 10], "length": 5, "width": 5, "height": 5, "operation": "join"})).unwrap();
let revision = h.app.session.revision;
until_settled(&mut h);
assert!(h.app.session.revision > revision, "the move ran after the change");
assert_eq!(marker(&h), Some(2));
}
+18 -1
View File
@@ -20,6 +20,7 @@ mod colour_vision_tests;
pub mod context_menu;
pub mod control;
pub mod crash_ui;
pub mod deferred;
pub mod delete;
pub mod design_properties;
pub mod dialogs;
@@ -97,6 +98,8 @@ mod expression_complete;
mod extrude_edit_tests;
mod file_load;
mod file_picker;
#[cfg(test)]
mod history_off_thread_tests;
pub use file_picker::FilePickerFuture;
#[cfg(test)]
mod blend_arrow_tests;
@@ -321,6 +324,10 @@ pub struct SolveApp {
pub preview: preview::PreviewState,
/// OK pressed on a feature that takes a while: its evaluation runs off the window thread.
pub applying: Option<dialogs::Applying>,
/// A command recomputing the history (the marker moved, undo) computing off the window
/// thread, and those asked for meanwhile ([`deferred`]).
pub deferred: Option<deferred::Deferred>,
pub deferred_queue: Vec<(String, Value)>,
/// How long OK waits for a feature before computing it off the window thread, saying so;
/// `None`: it waits as long as it takes (headless use, tests). The desktop app sets it.
pub apply_off_thread: Option<std::time::Duration>,
@@ -435,6 +442,8 @@ impl SolveApp {
slicer_menu: dialogs_save_mesh::SlicerMenu::default(),
preview: preview::PreviewState::default(),
applying: None,
deferred: None,
deferred_queue: Vec::new(),
apply_off_thread: None,
last_command: None,
esc_handled: false,
@@ -843,6 +852,11 @@ impl SolveApp {
self.dialog = Some(d);
return;
}
// Undo and redo can recompute much of the history: off the window thread when slow.
if matches!(id, "edit.undo" | "edit.redo") {
let _ = self.run_heavy(id, json!({}));
return;
}
let _ = self.run(id, json!({}));
}
@@ -928,7 +942,9 @@ impl SolveApp {
}
fn restore_marker(&mut self, marker: Option<usize>) {
let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
// Rolling forward past the features after the sketch recomputes them (an edited sketch
// under a text cut): off the window thread when it takes a while.
let _ = self.run_heavy("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
}
/// Everything shown as selected: the selection plus the open dialog's inputs.
@@ -1320,6 +1336,7 @@ impl SolveApp {
fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option<issue_report::CadInput>) {
scenario::clear_handles();
self.poll_deferred(ui.ctx());
toolbar::app_bar(self, ui);
issue_report::CadInput::block(self, ui, report_input);
file_load::show(self, ui.ctx());
+6 -6
View File
@@ -81,7 +81,7 @@ pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec<u64>) {
}
}
if with.is_empty() && fail.is_empty() {
let _ = app.run("timeline.delete", json!({ "features": ids }));
let _ = app.run_heavy("timeline.delete", json!({ "features": ids }));
} else {
app.dialog = Some(crate::dialogs::Dialog::confirm_delete_many(ids, with.into_iter().collect(), fail.into_iter().collect()));
}
@@ -192,7 +192,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
Some(k) if k < n => json!({"position": k}),
_ => json!({}),
};
let _ = app.run("timeline.roll_to", pos);
let _ = app.run_heavy("timeline.roll_to", pos);
}
x += 24.0;
}
@@ -341,11 +341,11 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.close();
}
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() {
let _ = app.run("timeline.suppress", json!({"feature": id}));
let _ = app.run_heavy("timeline.suppress", json!({"feature": id}));
ui.close();
}
if ui.button(crate::i18n::tr_label("Roll History Marker Here")).clicked() {
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
let _ = app.run_heavy("timeline.roll_to", json!({ "feature": id }));
ui.close();
}
if ui.button(crate::i18n::tr_label("Find in Browser")).clicked() {
@@ -398,14 +398,14 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
let slot = slot_at(&xs, end, pp.x);
match dg {
Drag::Marker => {
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
}
Drag::Feature(id) => {
let from = app.session.doc.feature_index(id).unwrap_or(0);
// Dropping after itself means the slot shifts by one.
let to = if slot > from { slot - 1 } else { slot };
if to != from {
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
let _ = app.run_heavy("timeline.reorder", json!({ "feature": id, "position": to }));
}
}
}
+4 -4
View File
@@ -117,7 +117,7 @@ fn timeline_transport(app: &mut SolveApp, ui: &mut egui::Ui) {
Some(position) if position < n => json!({"position": position}),
_ => json!({}),
};
let _ = app.run("timeline.roll_to", params);
let _ = app.run_heavy("timeline.roll_to", params);
}
}
});
@@ -794,11 +794,11 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.close();
}
if ui.button(if *suppressed { "Unsuppress Feature" } else { "Suppress Feature" }).clicked() {
let _ = app.run("timeline.suppress", json!({"feature": id}));
let _ = app.run_heavy("timeline.suppress", json!({"feature": id}));
ui.close();
}
if ui.button("Roll Back Here").clicked() {
let _ = app.run("timeline.roll_to", json!({"feature": id}));
let _ = app.run_heavy("timeline.roll_to", json!({"feature": id}));
ui.close();
}
ui.separator();
@@ -875,7 +875,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
{
match active {
Drag::Marker => {
let _ = app.run("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) });
let _ = app.run_heavy("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) });
}
Drag::Items(items) => {
let panel_left = ui.min_rect().left();
+13
View File
@@ -2591,6 +2591,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::select_other::show(app, ui.ctx());
axis_view(app, ui, rect);
nav_bar(app, ui, rect);
// A command recomputing the history off the window thread (the marker moved, undo): the
// window goes on, and says so above the status chip.
if let Some(since) = app.computing_since() {
let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0);
let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]);
let pos = pos2(rect.left() + 12.0, rect.bottom() - 76.0);
let galley = painter.layout_no_wrap(text, FontId::proportional(12.5), t.text);
let r = Rect::from_min_size(pos, galley.size() + vec2(34.0, 8.0));
painter.rect_filled(r, 4.0, t.overlay);
egui::Spinner::new().size(12.0).paint_at(ui, Rect::from_center_size(pos2(r.left() + 13.0, r.center().y), vec2(12.0, 12.0)));
painter.galley(pos + vec2(26.0, 4.0), galley, t.text);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
// Status chip.
if let Some((msg, at, err)) = app.status.clone() {
if crate::now_ms() - at < 6000.0 {