M8.2: tool options last across tool switches and launches

Each tool keeps its options (a table in vectorcraft_tools::settings) when another
tool is chosen, across document switches, and in the saved preferences
(Prefs::tool_settings). The 7 Liquify tools share one set of Global Brush
Dimensions; the Symbolism tools share their brush. tool.setOption takes
{values} and a `tool` (another tool's options are stored for later), returns
the options, and works headless (MCP host command).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-06 09:23:44 +07:00
1 parent 2c02502f43
commit 08108f693f
11 files changed
+346 -13

No files matched your search

+11 -1
View File
@@ -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<String, serde_json::Map<String, Value>>,
}
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;
+98
View File
@@ -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));
}
+90 -5
View File
@@ -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<dyn Tool> {
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<R>(&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<String, Value>) -> 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<Value, String> {
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`]):
+6
View File
@@ -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()),
}
}
+29
View File
@@ -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));
}
+11 -1
View File
@@ -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();
+1
View File
@@ -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;
+74
View File
@@ -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<Item = (&'static str, &'static [&'static str])> + '_ {
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);
}
}
+7 -6
View File
@@ -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<R
Some(t) => 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" => {
+5
View File
@@ -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`).
+14
View File
@@ -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}}}}
```