Merge pull request #2942 from nrq6993-a11y/stream/vc-feat3-g12-real-time-drawing

Honor Preferences › Performance › Real-time Drawing and Editing during tool drags
This commit is contained in:
BFlat
2026-10-23 02:44:15 +07:00
committed by GitHub
3 changed files with 217 additions and 8 deletions
+17
View File
@@ -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<cmd::findfiles::Rules>,
/// 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<vectorcraft_tools::Overlay>)>,
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
@@ -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()?;
+110
View File
@@ -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]
+90 -8
View File
@@ -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<Overlay> {
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<Overlay> {
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()
}