diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0872cd56..ce953a5a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -476,6 +476,12 @@ pub struct Prefs { /// the Properties panel and the Control bar (their size fields pass `proportional` to /// `object.setBounds`). pub constrain_proportions: bool, + /// The tools' persistent options by store (a tool id, or a store a family shares: `liquify` + /// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]: + /// kept across tool switches and saved with the preferences. Not a Preferences dialog field: + /// resetting the preferences keeps them; `tool.setOption` edits them. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub tool_settings: std::collections::BTreeMap>, } impl Default for Prefs { @@ -609,6 +615,7 @@ impl Default for Prefs { web_export_settings: None, print_presets: vec![], constrain_proportions: false, + tool_settings: Default::default(), } } } @@ -756,7 +763,8 @@ impl Session { if self.batch_stash.is_none() { let _ = self.cancel_interaction(); } - self.tool = vectorcraft_tools::create(self.tool.id()); + self.keep_tool_settings(); + self.tool = self.make_tool(self.tool.id()); } pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { @@ -1302,6 +1310,8 @@ mod tests_tileedge; #[cfg(test)] mod tests_tints; #[cfg(test)] +mod tests_toolsettings; +#[cfg(test)] mod tests_transparencygrid; #[cfg(test)] mod tests_units; diff --git a/crates/engine/src/tests_toolsettings.rs b/crates/engine/src/tests_toolsettings.rs new file mode 100644 index 00000000..49b930af --- /dev/null +++ b/crates/engine/src/tests_toolsettings.rs @@ -0,0 +1,98 @@ +//! Tool options that persist across tool switches, documents and launches (`Prefs::tool_settings`). + +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn select(s: &mut Session, tool: &str) { + s.select_tool(tool, ViewInfo::default()).unwrap(); +} + +#[test] +fn options_survive_a_tool_switch() { + let mut s = session(); + select(&mut s, "mirrorCut"); + s.set_tool_option("axis", &json!("horizontal")); + s.set_tool_option("keep", &json!("bottom")); + select(&mut s, "selection"); + select(&mut s, "mirrorCut"); + assert_eq!(s.tool_options(), json!({"axis": "horizontal", "keep": "bottom"})); + // Interaction state isn't kept: the Rotate tool's reference point starts afresh. + select(&mut s, "rotate"); + s.set_tool_option("origin", &json!([10, 20])); + select(&mut s, "selection"); + select(&mut s, "rotate"); + assert_eq!(s.tool_options(), json!({"origin": null})); +} + +#[test] +fn liquify_tools_share_the_global_brush_but_not_their_own_options() { + let mut s = session(); + select(&mut s, "warp"); + s.set_tool_option("width", &json!(60)); + s.set_tool_option("intensity", &json!(80)); + s.set_tool_option("detail", &json!(7)); + select(&mut s, "twirl"); + let o = s.tool_options(); + assert_eq!((o["width"].as_f64(), o["intensity"].as_f64(), o["detail"].as_f64()), (Some(60.0), Some(0.8), Some(2.0))); + s.set_tool_option("rate", &json!(-90)); + select(&mut s, "warp"); + assert_eq!(s.tool_options()["detail"], json!(7.0)); + select(&mut s, "twirl"); + assert_eq!(s.tool_options()["rate"], json!(-90.0)); +} + +#[test] +fn another_tools_options_are_stored_and_shared_ones_reach_the_active_tool() { + let mut s = session(); + select(&mut s, "warp"); + let o = s.set_tool_option_cmd(&json!({"tool": "pucker", "values": {"width": 40, "height": 30, "detail": 5}})).unwrap(); + assert_eq!((o["tool"].as_str(), o["detail"].as_f64()), (Some("pucker"), Some(5.0))); + assert_eq!((s.tool_id(), s.tool_options()["width"].as_f64(), s.tool_options()["detail"].as_f64()), ("warp", Some(40.0), Some(2.0))); + assert_eq!(s.tool_options_of("pucker")["height"], json!(30.0)); + select(&mut s, "pucker"); + assert_eq!(s.tool_options()["detail"], json!(5.0)); + // `{}` reads the options; an unknown tool is an error. + assert_eq!(s.set_tool_option_cmd(&json!({})).unwrap(), s.tool_options()); + assert!(s.set_tool_option_cmd(&json!({"tool": "nope", "key": "width", "value": 1})).is_err()); +} + +#[test] +fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() { + let mut s = session(); + select(&mut s, "spiral"); + s.set_tool_option("segments", &json!(14)); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + assert_eq!(s.tool_id(), "spiral"); + assert_eq!(s.tool_options()["segments"], json!(14)); + s.set_active(0); + assert_eq!(s.tool_options()["segments"], json!(14)); +} + +#[test] +fn options_are_saved_with_the_preferences() { + let mut s = session(); + select(&mut s, "polygon"); + s.set_tool_option("sides", &json!(9)); + select(&mut s, "selection"); + let saved = serde_json::to_string(&s.prefs).unwrap(); + assert!(saved.contains("toolSettings"), "{saved}"); + // Next launch. + let mut t = session(); + t.apply_prefs(serde_json::from_str(&saved).unwrap()); + select(&mut t, "polygon"); + assert_eq!(t.tool_options()["sides"], json!(9)); + // Older preference files have none; none are written until a tool keeps something. + assert!(Prefs::default().to_json().get("toolSettings").is_none()); + let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); + assert!(old.tool_settings.is_empty()); + // Resetting the preferences keeps them. + t.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9)); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index a8a6cd13..172b22bd 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1,10 +1,10 @@ //! Hosting the active tool: pointer/key events → actions → commands. -use serde_json::Value; +use serde_json::{Map, Value}; use vectorcraft_geom::Point; -use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey}; +use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings}; -use crate::{EngineError, Result, Session}; +use crate::{EngineError, Prefs, Result, Session}; /// View state the tools need from the frontend. #[derive(Clone, Copy, Debug)] @@ -56,10 +56,50 @@ impl Session { } let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); self.apply_actions(acts)?; - self.tool = vectorcraft_tools::create(id); + self.keep_tool_settings(); + self.tool = self.make_tool(id); Ok(()) } + /// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this + /// session or a saved one. + pub(crate) fn make_tool(&self, id: &str) -> Box { + let mut t = vectorcraft_tools::create(id); + Self::restore_tool_settings(&self.prefs, t.as_mut()); + t + } + + /// Give `t` the persistent options stored for it in `prefs`. + fn restore_tool_settings(prefs: &Prefs, t: &mut dyn Tool) { + for (store, keys) in settings::stores(t.id()) { + let Some(saved) = prefs.tool_settings.get(store) else { continue }; + for k in keys { + if let Some(v) = saved.get(*k) { + t.set_option(k, v); + } + } + } + } + + /// Store the persistent options of `t` in `prefs` (so they are saved with them too). + fn store_tool_settings(prefs: &mut Prefs, t: &dyn Tool) { + let opts = t.options(); + for (store, keys) in settings::stores(t.id()) { + let saved = prefs.tool_settings.entry(store.to_string()).or_default(); + for k in keys { + if let Some(v) = opts.get(*k) { + saved.insert(k.to_string(), v.clone()); + } + } + } + } + + /// Keep the active tool's persistent options for the next time it, or a tool sharing them, is + /// made. + pub(crate) fn keep_tool_settings(&mut self) { + Self::store_tool_settings(&mut self.prefs, self.tool.as_ref()); + } + pub(crate) fn with_tool_cx(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn vectorcraft_tools::Tool, &ToolContext) -> R) -> R where R: Default, @@ -98,7 +138,7 @@ impl Session { Err(msg) => { // A bug in the tool: start it afresh and drop its half-done drag. let id = self.tool.id(); - self.tool = vectorcraft_tools::create(id); + self.tool = self.make_tool(id); let _ = self.cancel_interaction(); self.tool_panic = Some(EngineError::Internal { cmd: format!("tool `{id}`"), msg }); R::default() @@ -116,6 +156,10 @@ impl Session { self.last_view = view; let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); self.take_tool_panic()?; + // A gesture may change options (Alt-drag sizes a Liquify brush): keep them. + if ev.kind == PointerKind::Up { + self.keep_tool_settings(); + } self.apply_actions(acts) } @@ -162,6 +206,47 @@ impl Session { } pub fn set_tool_option(&mut self, key: &str, v: &Value) { self.tool.set_option(key, v); + self.keep_tool_settings(); + } + + /// The options of tool `id`: the active tool's, or those it would have if chosen now. + pub fn tool_options_of(&self, id: &str) -> Value { + if id == self.tool.id() { self.tool.options() } else { self.make_tool(id).options() } + } + + /// Set options of tool `id` (default: the active tool). Another tool's persistent options are + /// stored for when it is chosen, and the active tool takes those it shares with it (the + /// Liquify tools' Global Brush Dimensions). Returns the tool's options. + pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map) -> Value { + let active = id.is_none_or(|id| id == self.tool.id()); + let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default())); + let t = other.as_deref_mut().unwrap_or(self.tool.as_mut()); + for (k, v) in values { + t.set_option(k, v); + } + Self::store_tool_settings(&mut self.prefs, t); + let out = t.options(); + if other.is_some() { + Self::restore_tool_settings(&self.prefs, self.tool.as_mut()); + } + out + } + + /// `tool.setOption {tool?, key?, value?, values?}` (a command of the desktop app and of the + /// headless host): set one option (`key`, `value`) or several (`values`) of `tool` (default: + /// the active tool) → its options. + pub fn set_tool_option_cmd(&mut self, p: &Value) -> std::result::Result { + let tool = p.get("tool").and_then(Value::as_str); + if let Some(t) = tool + && vectorcraft_tools::tool_info(t).is_none() + { + return Err(format!("unknown tool `{t}`")); + } + let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); + if let Some(k) = p.get("key").and_then(Value::as_str) { + values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + } + Ok(self.set_tool_options(tool, &values)) } /// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]): diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 1bd0b46f..69ac0e41 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -38,6 +38,11 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ "{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", ), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), + ( + "tool.setOption", + "Tool Option", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them); kept options last across tool switches", + ), ]; fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { @@ -113,6 +118,7 @@ impl Headless { "file.export" => self.export(params), "file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()), "tool.select" => self.select_tool(params), + "tool.setOption" => self.session.set_tool_option_cmd(params), _ => self.session.execute(id, params).map_err(|e| e.to_string()), } } diff --git a/crates/mcp/src/tests_liquify.rs b/crates/mcp/src/tests_liquify.rs new file mode 100644 index 00000000..572dc92f --- /dev/null +++ b/crates/mcp/src/tests_liquify.rs @@ -0,0 +1,29 @@ +//! The Liquify tools through MCP: their options (`tool.setOption`, kept across tool switches) and +//! brush strokes with `pointer_gesture`. + +use serde_json::{Value, json}; + +use crate::{Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +fn command(h: &mut Headless, command: &str, params: Value) -> Value { + ok(h, "run_command", json!({"command": command, "params": params})) +} + +#[test] +fn tool_options_are_set_headless_and_kept_across_tool_switches() { + let mut h = Headless::with_document(); + command(&mut h, "tool.select", json!({"tool": "warp"})); + let o = command(&mut h, "tool.setOption", json!({"values": {"width": 64, "detail": 6}})); + assert_eq!((o["width"].as_f64(), o["detail"].as_f64()), (Some(64.0), Some(6.0))); + command(&mut h, "tool.select", json!({"tool": "bloat"})); + let o = command(&mut h, "tool.setOption", json!({})); + assert_eq!((o["tool"].as_str(), o["width"].as_f64(), o["detail"].as_f64()), (Some("bloat"), Some(64.0), Some(2.0))); + command(&mut h, "tool.select", json!({"tool": "warp"})); + assert_eq!(command(&mut h, "tool.setOption", json!({}))["detail"], json!(6.0)); +} diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index a02bf22f..ff747367 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -157,6 +157,16 @@ impl LiquifyParams { v } + /// Every option, whichever tool these are (the tool's options: [`Self::to_json`] gives only + /// the parameters its kind uses). + pub fn options_json(&self) -> Value { + let mut v = self.to_json(); + for (k, x) in [("rate", self.rate), ("complexity", self.complexity), ("horizontal", self.horizontal), ("vertical", self.vertical)] { + v[k] = json!(x); + } + v + } + fn radii(&self) -> (f64, f64) { (self.width / 2.0, self.height / 2.0) } @@ -528,7 +538,7 @@ impl Tool for LiquifyTool { Cursor::Crosshair } fn options(&self) -> Value { - self.params.to_json() + self.params.options_json() } fn set_option(&mut self, key: &str, value: &Value) { let mut v = self.params.to_json(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3b8fd60b..6030bfa2 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -23,6 +23,7 @@ pub mod pen; pub mod place; pub mod printtiling; pub mod select; +pub mod settings; pub mod shape; pub mod slice; pub mod symbolism; diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs new file mode 100644 index 00000000..38c92b3e --- /dev/null +++ b/crates/tools/src/settings.rs @@ -0,0 +1,74 @@ +//! Tool options that persist: a tool keeps them when another tool is chosen and they are saved +//! with the preferences, as the reference app keeps each tool's options. Options that are the +//! state of an interaction (pins, a reference point, the text being edited, the files a place +//! cursor holds) aren't listed: they start afresh. +//! +//! Each row names the tools it covers, the store their values live in and the option keys. A row +//! with a shared store gives every tool it lists one set of values: the Liquify tools share their +//! Global Brush Dimensions, the Symbolism tools their brush. Otherwise each tool has its own store, +//! named after its id. + +/// (tools, the store they share (None: each tool's own), option keys). +type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static str]); + +/// The Liquify tools. +pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"]; + +const SYMBOLISM: &[&str] = + &["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"]; + +const ROWS: &[Row] = &[ + // Global Brush Dimensions. + (LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity"]), + (LIQUIFY, None, &["detail", "simplify", "rate", "complexity", "horizontal", "vertical"]), + (SYMBOLISM, Some("symbolism"), &["diameter", "intensity", "density"]), + (&["mirrorCut"], None, &["axis", "keep"]), + (&["puppetWarp"], None, &["showMesh", "expand"]), + (&["arc"], None, &["closed"]), + (&["spiral"], None, &["decay", "segments", "clockwise"]), + (&["rectangularGrid"], None, &["rows", "columns"]), + (&["polarGrid"], None, &["concentric", "radial"]), + (&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]), + (&["smooth"], None, &["fidelity"]), + (&["blobBrush", "eraser"], None, &["size"]), + (&["polygon"], None, &["sides"]), + (&["star"], None, &["points"]), + (&["freeTransform"], None, &["constrain"]), + (&["artboard"], None, &["moveArt"]), +]; + +/// The stores `tool`'s persistent options live in, each with the option keys it holds. +pub fn stores(tool: &str) -> impl Iterator + '_ { + ROWS.iter().filter_map(move |(tools, shared, keys)| { + let id = tools.iter().find(|t| **t == tool)?; + Some((shared.unwrap_or(id), *keys)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_listed_option_is_one_the_tool_reports() { + for (tools, _, keys) in ROWS { + for id in *tools { + assert!(crate::tool_info(id).is_some(), "{id} is a tool"); + let opts = crate::create(id).options(); + for k in *keys { + assert!(opts.get(*k).is_some(), "{id} reports `{k}` in {opts}"); + } + } + } + } + + #[test] + fn liquify_tools_share_the_global_brush() { + let warp: Vec<_> = stores("warp").collect(); + let twirl: Vec<_> = stores("twirl").collect(); + assert_eq!(warp[0], twirl[0]); + assert_eq!(warp[0].0, "liquify"); + assert_eq!((warp[1].0, twirl[1].0), ("warp", "twirl")); + assert_eq!(stores("selection").count(), 0); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9003c03f..453cc25b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -170,7 +170,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.workspace.list", "List Workspaces", "", "{}"), ("window.newWindow", "New Window", "", "{}"), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), - ("tool.setOption", "Tool Option", "", "{key, value}"), + ( + "tool.setOption", + "Tool Option", + "", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", + ), ( "effect.dialog", "Effect…", @@ -786,11 +791,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err(format!("unknown tool `{t}`")), None => Err("missing `tool`".into()), }, - "tool.setOption" => { - let k = s("key").unwrap_or_default(); - app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null)); - Ok(app.session.tool_options()) - } + "tool.setOption" => app.session.set_tool_option_cmd(p), "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), "ui.paramDialog" => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 213c886f..114d6884 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -444,3 +444,8 @@ Colour adjustment effects (Effect → Color Adjustments) open the `effect` dialo Halftone… (`engine.execute {command: "ui.menuDialog", params: {command: "object.vectorHalftone"}}`) opens the `vectorHalftone` dialog (fields `shape`, `frequency`, `angle`, `mode`, `color`, `invert`, `clip`, `keepOriginal`, `preview`): it previews live and `ui.dialog.confirm` keeps the halftone as one undo step. + +Tool options last: `tool.setOption {key, value}` or `{values: {…}}` (and `tool` to set another tool's) sets them, and +the options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, Symbolism, drawing tools) stay +when you switch tools or documents and are saved with the preferences. The Liquify tools share their Global Brush +Dimensions (`width`, `height`, `angle`, `intensity`). diff --git a/docs/mcp.md b/docs/mcp.md index a85a2ae0..1a7486f2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1729,3 +1729,17 @@ font that became available redraws in it, without editing the document. {"name":"run_command","arguments":{"command":"text.fontList","params":{"family":"Source Serif 4"}}} {"name":"run_command","arguments":{"command":"text.rescanFonts","params":{}}} ``` + +## Tool options + +`tool.setOption` reads and sets a tool's options, headless too: `{key, value}` or `{values: {…}}` sets them on the +active tool, `tool` names another one, and `{}` just reads them; the result is the tool's options. The options a +tool keeps last across tool switches (and documents) and are saved with the preferences (`toolSettings` in +`prefs.get`'s full object), as the reference app keeps them: the Liquify tools' brush and tool options, Mirror & Cut's +axis and side, Puppet Warp's mesh, the Symbolism brush, the line, grid, pencil, brush and eraser tools' options, +polygon sides and star points. The Liquify tools share one set of Global Brush Dimensions (width, height, angle, +intensity), and the Symbolism tools one brush. Interaction state (pins, a reference point) starts afresh. + +```json +{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"twirl","values":{"width":60,"rate":90}}}} +```