Files
photocraft/crates/engine/src/presets/tools.rs
T
Vikram Bhaskaran 4bf0625b04 Export and import saved tool presets (#6560) (#6889)
* Include tool presets in export and import so crop presets are portable.

* Pass presets_io_body to the harness without a redundant closure (#6560).

CI clippy -D warnings failed on clippy::redundant_closure in the
presets export/import regression.
2026-10-19 13:04:14 -04:00

247 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Window › Tool Presets: a tool plus its options under a name.
//!
//! The engine doesn't know the shell's options-bar fields, so a preset's `options` are opaque
//! JSON that the frontend writes and reads back (the desktop shell stores its `ToolOptions` under
//! `"toolOptions"`). Two keys the engine understands itself: `"brush"` (brush settings, applied
//! to the session brush like `tools.setBrush`) and `"foreground"` (Include Color).
//!
//! The Crop tool's options-bar presets (New / Delete Crop Preset…) are tool presets of the
//! `"crop"` tool, as in Photoshop, so they also show in the Tool Presets panel.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{Group, Named, always, bad, edit_groups, req_str, str_param};
use crate::commands::CommandSpec;
use crate::{Result, Session};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolPreset {
pub name: String,
/// Tool name as the shell spells it (`"brush"`, `"eraser"`, `"gradient"`, …), case-insensitive.
pub tool: String,
#[serde(default)]
pub options: Value,
}
impl Named for ToolPreset {
fn name(&self) -> &str {
&self.name
}
fn set_name(&mut self, n: String) {
self.name = n;
}
}
/// The most tool presets kept (saving more is refused; a larger preferences file loads the first).
pub const MAX_PRESETS: usize = 1000;
/// A preset name: trimmed, 1–[`MAX_NAME`](crate::brush_preset_cmds::MAX_NAME) characters.
pub(crate) fn checked_name(name: &str, cmd: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
return Err(bad(cmd, "the preset name is empty"));
}
if name.chars().count() > crate::brush_preset_cmds::MAX_NAME {
return Err(bad(cmd, format!("preset names are at most {} characters", crate::brush_preset_cmds::MAX_NAME)));
}
Ok(name.to_string())
}
/// Read saved tool presets entry by entry, so a damaged entry (wrong type, empty or overlong name,
/// a duplicate name) is skipped without discarding the others.
pub(crate) fn load(value: Value) -> Vec<ToolPreset> {
let Some(entries) = value.as_array() else { return Vec::new() };
let mut presets: Vec<ToolPreset> = Vec::new();
for entry in entries {
if presets.len() >= MAX_PRESETS {
break;
}
let Ok(mut preset) = serde_json::from_value::<ToolPreset>(entry.clone()) else { continue };
let Ok(name) = checked_name(&preset.name, "") else { continue };
if preset.tool.trim().is_empty() || presets.iter().any(|p| p.name == name) {
continue;
}
if !preset.options.is_object() {
preset.options = json!({});
}
preset.name = name;
presets.push(preset);
}
presets
}
fn norm(t: &str) -> String {
t.to_ascii_lowercase().replace([' ', '_', '-'], "").trim_end_matches("tool").to_string()
}
/// A few starting presets (our own).
pub fn builtin() -> Vec<ToolPreset> {
let brush = |size: f32, hardness: f32| json!({"brush": {"size": size, "hardness": hardness}});
vec![
ToolPreset { name: "Soft Round 100 px".into(), tool: "brush".into(), options: brush(100.0, 0.0) },
ToolPreset { name: "Hard Round 12 px".into(), tool: "brush".into(), options: brush(12.0, 1.0) },
ToolPreset { name: "Soft Eraser 60 px".into(), tool: "eraser".into(), options: brush(60.0, 0.0) },
ToolPreset { name: "Radial Gradient".into(), tool: "gradient".into(), options: json!({"toolOptions": {"gradient_style": "radial"}}) },
ToolPreset { name: "Clone Soft 40 px".into(), tool: "cloneStamp".into(), options: brush(40.0, 0.2) },
]
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let filter = str_param(p, "tool").map(norm);
let items: Vec<Value> = s
.presets
.tool_presets
.iter()
.filter(|t| filter.as_ref().is_none_or(|f| norm(&t.tool) == *f))
.map(|t| json!({"name": t.name, "tool": t.tool}))
.collect();
Ok(json!({"presets": items}))
}
fn new_preset(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "tool.presets.new";
let tool = req_str(p, "tool", CMD)?.to_string();
let mut options = p.get("options").cloned().unwrap_or_else(|| json!({}));
if !options.is_object() {
return Err(bad(CMD, "`options` must be an object"));
}
// Brush-based tools carry the current brush unless the caller sent one.
let brushy = [
"brush",
"pencil",
"eraser",
"clonestamp",
"healing",
"spothealing",
"historybrush",
"blur",
"sharpen",
"smudge",
"dodge",
"burn",
"sponge",
"mixerbrush",
"colorreplacement",
];
if options.get("brush").is_none() && brushy.contains(&norm(&tool).as_str()) {
options["brush"] = serde_json::to_value(&s.tools.brush).unwrap_or(Value::Null);
}
if p.get("includeColor").and_then(Value::as_bool).unwrap_or(false) {
let f = s.tools.foreground;
options["foreground"] = json!(f);
}
if s.presets.tool_presets.len() >= MAX_PRESETS {
return Err(bad(CMD, format!("at most {MAX_PRESETS} tool presets can be saved; delete some first")));
}
let base = match p.get("name") {
None | Some(Value::Null) => tool.clone(),
Some(Value::String(n)) if n.trim().is_empty() => tool.clone(),
Some(Value::String(n)) => checked_name(n, CMD)?,
Some(_) => return Err(bad(CMD, "`name` must be a string")),
};
let groups = [Group::new("", s.presets.tool_presets.clone())];
let name = super::unique_name(&groups, &base);
s.presets.tool_presets.push(ToolPreset { name: name.clone(), tool: tool.clone(), options });
s.presets_changed();
Ok(json!({"name": name, "tool": tool}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "tool.presets.select";
let name = req_str(p, "preset", CMD)?;
let t = s
.presets
.tool_presets
.iter()
.find(|t| t.name == name)
.cloned()
.ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?;
if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) {
super::call(s, "tools.setBrush", json!({"brush": b}))?;
}
if let Some(c) = t.options.get("foreground").and_then(Value::as_array).filter(|a| a.len() >= 3) {
let v: Vec<f32> = c.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
s.tools.foreground = [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)];
}
Ok(json!({"name": t.name, "tool": t.tool, "options": t.options}))
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "tool.presets.edit";
let action = req_str(p, "action", CMD)?.to_string();
if !matches!(action.as_str(), "rename" | "delete") {
return Err(bad(CMD, "tool presets support rename and delete"));
}
if action == "rename" {
checked_name(req_str(p, "name", CMD)?, CMD)?;
}
let mut groups = vec![Group::new("", std::mem::take(&mut s.presets.tool_presets))];
let r = edit_groups(&mut groups, &action, p, CMD);
s.presets.tool_presets = groups.pop().map(|g| g.items).unwrap_or_default();
let r = r?;
s.presets_changed();
Ok(r)
}
fn reset(s: &mut Session, _: &Value) -> Result<Value> {
s.presets.tool_presets = builtin();
s.presets_changed();
Ok(json!({"presets": s.presets.tool_presets.len()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: "tool.presets.list",
label: "Tool Presets",
menu: &[],
shortcut: None,
params: r##"{"tool":name? (Current Tool Only)} → {presets:[{name,tool}]}"##,
enabled: always,
run: list,
journal: false,
},
CommandSpec {
id: "tool.presets.new",
label: "New Tool Preset…",
menu: &[],
shortcut: None,
params: r##"{"name":str?=tool (at most 255 characters; a taken name gets " 2", " 3"…),"tool":name ("crop" for Crop presets),"options":{…}? (opaque; "brush" defaults to the current brush for painting tools),"includeColor":bool=false}"##,
enabled: always,
run: new_preset,
journal: true,
},
CommandSpec {
id: "tool.presets.select",
label: "Select Tool Preset",
menu: &[],
shortcut: None,
params: r##"{"preset":name} → {name,tool,options} (applies "brush" and "foreground"; the shell switches tool and options)"##,
enabled: always,
run: select,
journal: true,
},
CommandSpec {
id: "tool.presets.edit",
label: "Edit Tool Presets",
menu: &[],
shortcut: None,
params: r##"{"action":"rename|delete","preset":name|[names],"name":str (rename)}"##,
enabled: always,
run: edit,
journal: true,
},
CommandSpec {
id: "tool.presets.reset",
label: "Reset Tool Presets",
menu: &[],
shortcut: None,
params: "{}",
enabled: always,
run: reset,
journal: true,
},
]
}