From c1f96cc6f8494255c6dbf6c9731044e0a2815e40 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 23 Oct 2026 02:22:09 +0700 Subject: [PATCH] =?UTF-8?q?Honor=20Preferences=20=E2=80=BA=20Performance?= =?UTF-8?q?=20=E2=80=BA=20Real-time=20Drawing=20and=20Editing=20during=20t?= =?UTF-8?q?ool=20drags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #2942 onto main. --- crates/engine/src/lib.rs | 17 +++++ crates/engine/src/tests_prefs.rs | 110 +++++++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 98 ++++++++++++++++++++++++--- 3 files changed, 217 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b500f2dd5..4ba284d72 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1415,6 +1415,9 @@ pub struct Session { /// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]). /// Tests set it. pub search_rules: Option, + /// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared + /// when the preview params change or the interaction ends. + pub(crate) deferred_overlay_cache: Option<(String, Value, Vec)>, /// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set /// it. pub search_threads: Option, @@ -1489,6 +1492,7 @@ impl Session { font_search: None, link_search: None, search_rules: None, + deferred_overlay_cache: None, search_threads: None, link_stamps: Default::default(), link_scan: None, @@ -1941,12 +1945,23 @@ impl Session { // ---------- interactions (live drags) ---------- + pub(crate) fn clear_deferred_overlay_cache(&mut self) { + self.deferred_overlay_cache = None; + } + + /// The interaction's latest preview command when the document is still its snapshot (deferred + /// Real-time Drawing), regardless of the preference at commit or overlay time. + pub(crate) fn unapplied_interaction_preview(&self) -> Option<(String, Value)> { + self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone())) + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { // Typing in progress is kept first: a drag or a dialog's preview would otherwise start // from the snapshot taken before the typing, and drop it (#1881). if label != "Typing" && cmd::typing_in_progress(self) { cmd::settle_interaction(self)?; } + self.clear_deferred_overlay_cache(); let st = self.doc_mut()?; if st.interaction.is_some() { return Ok(()); @@ -2000,6 +2015,7 @@ impl Session { } pub fn commit_interaction(&mut self) -> Result<()> { + self.clear_deferred_overlay_cache(); self.remember_pending_paint(); let st = self.doc_mut()?; let Some(mut it) = st.interaction.take() else { return Ok(()) }; @@ -2052,6 +2068,7 @@ impl Session { } pub fn cancel_interaction(&mut self) -> Result<()> { + self.clear_deferred_overlay_cache(); self.pending_paint = None; let guides_locked = self.menu.guides_locked; let st = self.doc_mut()?; diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 8c7cb3861..502c56aa7 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -1,6 +1,8 @@ //! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`. use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_tools::{PointerEvent, PointerKind}; use super::*; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; @@ -1393,6 +1395,114 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() { assert!(!s.prefs.highlight_alternate_glyphs); } +/// Preferences › Performance › Real-time Drawing and Editing (#394): off, the document isn't +/// previewed while dragging but the pending shape still shows as a canvas overlay until commit. +#[test] +fn real_time_drawing_off_skips_tool_previews_until_commit() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + let paths = |s: &Session| { + s.doc() + .unwrap() + .doc + .layers + .first() + .and_then(|l| l.children()) + .map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count()) + }; + let rev = s.doc().unwrap().revision; + assert_eq!(paths(&s), 0); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 150.0), v).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev, "no preview steps"); + assert_eq!(paths(&s), 0, "the rectangle isn't in the document yet"); + assert!(s.overlays(v).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Path { .. })), "the deferred rectangle shows as an outline"); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 150.0), v).unwrap(); + assert!(s.doc().unwrap().revision > rev, "commit applies the shape"); + assert_eq!(paths(&s), 1); + set_pref(&mut s, "realTimeDrawing", json!(true)); + let rev = s.doc().unwrap().revision; + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 120.0), v).unwrap(); + assert!(s.doc().unwrap().revision > rev, "on again: previews run while dragging"); +} + +/// Real-time Drawing turned on mid-drag still applies the deferred preview on commit. +#[test] +fn real_time_drawing_turned_on_mid_drag_still_commits_deferred_preview() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + let paths = |s: &Session| { + s.doc() + .unwrap() + .doc + .layers + .first() + .and_then(|l| l.children()) + .map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count()) + }; + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 150.0), v).unwrap(); + assert_eq!(paths(&s), 0); + set_pref(&mut s, "realTimeDrawing", json!(true)); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 150.0), v).unwrap(); + assert_eq!(paths(&s), 1, "the deferred rectangle is committed"); +} + +/// Repeated overlay redraws with Real-time Drawing off reuse the cached deferred feedback. +#[test] +fn deferred_preview_overlays_are_cached_across_redraws() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 150.0), v).unwrap(); + let rev = s.doc().unwrap().revision; + let count = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { .. })).count(); + let first = count(&mut s); + assert!(first > 0); + assert_eq!(s.doc().unwrap().revision, rev); + let second = count(&mut s); + assert_eq!(first, second); + assert_eq!(s.doc().unwrap().revision, rev, "the preview command is not run again"); +} + +/// A deferred commit that fails its preview cancels the interaction instead of leaving it open. +#[test] +fn deferred_commit_preview_failure_cancels_interaction() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.begin_interaction("Bad").unwrap(); + s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("no.such.command".into(), json!({}))); + s.apply_actions(vec![vectorcraft_tools::Action::Commit]).unwrap(); + assert!(s.doc().unwrap().interaction.is_none()); +} + +/// A deferred commit applies preview warnings to the UI, as live previews do. +#[test] +fn deferred_commit_forwards_preview_warnings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let text = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap(); + s.execute("select.set", &json!({"ids": [rect["id"], text["id"]]})).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.begin_interaction("Bloat").unwrap(); + let p = json!({"tool": "bloat", "points": [[290.0, 200.0]], "width": 200.0, "height": 200.0}); + s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("object.liquify".into(), p)); + let ui = s.apply_actions(vec![vectorcraft_tools::Action::Commit]).unwrap(); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))), "{ui:?}"); +} + /// File Handling › Use Low Resolution Proxy for Linked EPS (#394): off by default; toggling it does /// not replace full-resolution pixels already in the document. #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 0068ccd82..067aaf806 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1,8 +1,10 @@ //! Hosting the active tool: pointer/key events → actions → commands. use serde_json::{Map, Value}; +use vectorcraft_doc::Selection; use vectorcraft_geom::Point; use vectorcraft_tools::distort::perspective::widget::WidgetPlace; +use vectorcraft_tools::draw2::FEEDBACK; use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings}; use crate::{EngineError, Prefs, Result, Session}; @@ -450,11 +452,15 @@ impl Session { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); } + let deferred_preview = self.unapplied_interaction_preview(); if let Some(d) = self.active() { let w = self.prefs.perspective_widget; let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place)); } + if let Some((cmd, p)) = deferred_preview { + v.extend(self.deferred_preview_overlays_from_command(&cmd, &p)); + } v } @@ -562,19 +568,49 @@ impl Session { for a in acts { match a { Action::Begin(label) => self.begin_interaction(&label)?, - Action::Preview(cmd, p) => match self.preview(&cmd, &p) { - Ok(v) => { - if let Some(w) = v.get("warning").and_then(Value::as_str) - && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) - { - ui.push(UiRequest::Status(w.to_string())); + Action::Preview(cmd, p) => { + // Performance › Real-time Drawing and Editing (#394): off, remember the latest + // params and apply them once on Commit instead of every move. + if !self.prefs.real_time_drawing { + self.clear_deferred_overlay_cache(); + let st = self.doc_mut()?; + if let Some(it) = &mut st.interaction { + it.preview = Some((cmd, p)); } + continue; } - Err(e) => log::warn!("preview {cmd}: {e}"), - }, + self.clear_deferred_overlay_cache(); + match self.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) + && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) + { + ui.push(UiRequest::Status(w.to_string())); + } + } + Err(e) => log::warn!("preview {cmd}: {e}"), + } + } // The drag is over: the dabs of a Liquify stroke have no further use. Action::Commit => { self.liquify_stroke = None; + let deferred = self.unapplied_interaction_preview(); + if let Some((cmd, p)) = deferred { + match self.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) + && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) + { + ui.push(UiRequest::Status(w.to_string())); + } + } + Err(e) => { + log::warn!("preview {cmd}: {e}"); + self.cancel_interaction()?; + continue; + } + } + } self.commit_interaction()? } Action::Cancel => { @@ -598,4 +634,50 @@ impl Session { } Ok(ui) } + + /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take + /// outlines from the objects it touched, then put the document back at the interaction snapshot. + fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { + if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) { + return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default(); + } + let (snapshot, selection, revision, paint, interaction_fields) = { + let Some(st) = self.active() else { return vec![] }; + let Some(it) = st.interaction.as_ref() else { return vec![] }; + if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) { + return vec![]; + } + let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone()); + (st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields) + }; + let overlays = if self.preview(cmd, p).is_ok() { + self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default() + } else { + vec![] + }; + if let Ok(st) = self.doc_mut() { + st.doc = snapshot; + st.selection = selection; + st.revision = revision; + if let Some(it) = &mut st.interaction { + it.preview = interaction_fields.0; + it.copies = interaction_fields.1; + it.perspective_again = interaction_fields.2; + } + } + self.pending_paint = paint; + self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone())); + overlays + } +} + +fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec { + selection + .objects + .iter() + .filter_map(|&id| { + let n = doc.node(id)?; + vectorcraft_render::drawn_outline(doc, n).map(|(path, _)| Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: false }) + }) + .collect() }