New Document: save named presets (Saved category) (#1516)

Save Preset... in File > New stores width, height, resolution, mode, depth, background (with
its colour) and display units in preferences. They are listed under a Saved category, where a
preset can be selected or deleted. Adds document.presets.save/list/get/delete; get returns
params for file.new, which gains an optional backgroundColor. Bad saved entries are skipped one
at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jason Xiang 2026-10-08 21:54:37 -07:00 committed by GitHub
parent b7e0d3810d
commit 1bc25d363f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 764 additions and 27 deletions

View File

@ -167,7 +167,7 @@ pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] {
_ => default,
}
}
fn parse_hex(s: &str) -> Option<[f32; 4]> {
pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
let s = s.trim_start_matches('#');
let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0);
match s.len() {
@ -314,35 +314,29 @@ fn build() -> Vec<CommandSpec> {
"New…",
["File"],
Some("Cmd+N"),
r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","resolution":ppi=72,"name":str}"##,
r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":ppi=72,"name":str}"##,
always,
|s, p| {
use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type};
// A size given as a float (`512.0`, as JSON from a UI field) is still that size (#254).
let px = |k: &str, d: u32| match p.get(k) {
Some(v) => v
.as_u64()
.map(|n| n.clamp(1, 300_000) as u32)
.or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round().clamp(1.0, 300_000.0) as u32))
.map(|n| n.clamp(1, MAX_DIMENSION as u64) as u32)
.or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round().clamp(1.0, MAX_DIMENSION as f64) as u32))
.unwrap_or(d),
None => d,
};
let (w, h) = (px("width", 1920), px("height", 1080));
let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") {
"gray" | "grayscale" => ColorMode::Grayscale,
"cmyk" => ColorMode::Cmyk,
"lab" => ColorMode::Lab,
_ => ColorMode::Rgb,
};
let depth = match p.get("depth").and_then(Value::as_u64).unwrap_or(8) {
16 => SampleType::U16,
32 => SampleType::F32,
_ => SampleType::U8,
};
let mode = color_mode(p.get("mode").and_then(Value::as_str).unwrap_or("rgb")).unwrap_or(ColorMode::Rgb);
let depth = sample_type(p.get("depth").and_then(Value::as_u64).unwrap_or(8)).unwrap_or(SampleType::U8);
let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string();
let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, 30_000.0) as f32;
let bgc = s.tools.background;
let res = p.get("resolution").and_then(Value::as_f64).unwrap_or(72.0).clamp(1.0, MAX_RESOLUTION) as f32;
let mut doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") {
"backgroundColor" => Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0)),
"backgroundColor" => {
let bgc = background_color(p, s.tools.background, "file.new")?;
Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(bgc[0], bgc[1], bgc[2], 1.0))
}
"transparent" => {
let mut d = Document::new(name, Size::new(w, h), mode, depth);
d.layers.push(Layer::raster("Layer 1", d.pixel_format()));
@ -1121,6 +1115,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::notes_cmds::specs());
v.extend(crate::proof_sim::specs());
v.extend(crate::presets::specs());
v.extend(crate::document_preset_cmds::specs());
v.extend(crate::render_cmds::specs());
v.extend(crate::slice_cmds::specs());
v.extend(crate::web_cmds::specs());

View File

@ -0,0 +1,419 @@
//! Named New Document configurations. The existing preferences services persist these with the
//! other presets; selecting one only returns settings, and document creation stays in `file.new`.
use photocraft_color::{ColorMode, SampleType};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::presets::{always, bad, req_str};
use crate::{Result, Session};
pub(crate) const MAX_DIMENSION: u32 = 300_000;
pub(crate) const MAX_RESOLUTION: f64 = 30_000.0;
const MAX_PRESETS: usize = 256;
/// The formats accepted by `file.new`, also used when validating saved configurations.
pub(crate) fn color_mode(mode: &str) -> Option<ColorMode> {
match mode {
"rgb" => Some(ColorMode::Rgb),
"gray" | "grayscale" => Some(ColorMode::Grayscale),
"cmyk" => Some(ColorMode::Cmyk),
"lab" => Some(ColorMode::Lab),
_ => None,
}
}
pub(crate) fn sample_type(depth: u64) -> Option<SampleType> {
match depth {
8 => Some(SampleType::U8),
16 => Some(SampleType::U16),
32 => Some(SampleType::F32),
_ => None,
}
}
/// A saved Background Color must not follow later changes to the toolbox colour.
pub(crate) fn background_color(p: &Value, fallback: [f32; 4], cmd: &str) -> Result<[f32; 4]> {
let c: [f32; 3] = match p.get("backgroundColor") {
Some(v) => serde_json::from_value(v.clone()).map_err(|_| bad(cmd, "`backgroundColor` must be [r,g,b] in 0..1"))?,
None => [fallback[0], fallback[1], fallback[2]],
};
if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) {
return Err(bad(cmd, "`backgroundColor` must be [r,g,b] in 0..1"));
}
Ok([c[0], c[1], c[2], 1.0])
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSettings {
pub width: u32,
pub height: u32,
pub resolution: f32,
pub mode: String,
pub depth: u8,
pub background: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_color: Option<[f32; 3]>,
/// Display units only; width/height are always pixels and resolution is always ppi.
pub unit: String,
pub resolution_unit: String,
}
impl DocumentSettings {
fn validate(&self) -> std::result::Result<(), String> {
if !(1..=MAX_DIMENSION).contains(&self.width) || !(1..=MAX_DIMENSION).contains(&self.height) {
return Err(format!("Width and height must be within 1..={MAX_DIMENSION} pixels"));
}
if !self.resolution.is_finite() || !(1.0..=MAX_RESOLUTION as f32).contains(&self.resolution) {
return Err(format!("Resolution must be within 1..={MAX_RESOLUTION} ppi"));
}
if color_mode(&self.mode).is_none() || sample_type(self.depth.into()).is_none() {
return Err("Use RGB, Grayscale, CMYK or Lab at 8, 16 or 32 bits".into());
}
if !matches!(self.background.as_str(), "white" | "black" | "transparent" | "backgroundColor") && crate::commands::parse_hex(&self.background).is_none()
{
return Err("Unknown background choice or invalid colour".into());
}
if self.background == "backgroundColor" && self.background_color.is_none() {
return Err("A saved Background Color needs its colour value".into());
}
if self.background_color.is_some_and(|c| !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))) {
return Err("Background colour components must be within 0..1".into());
}
if !matches!(self.unit.as_str(), "px" | "in" | "cm" | "mm" | "pt" | "pica") || !matches!(self.resolution_unit.as_str(), "in" | "cm") {
return Err("Unknown dimension or resolution display unit".into());
}
Ok(())
}
fn from_params(s: &Session, p: &Value, cmd: &str) -> Result<Self> {
let mut fields = p.as_object().cloned().ok_or_else(|| bad(cmd, "`settings` must be an object"))?;
let defaults = json!({"resolution":72.0,"mode":"rgb","depth":8,"background":"white","unit":"px","resolutionUnit":"in"});
if let Some(defaults) = defaults.as_object() {
for (k, v) in defaults {
fields.entry(k.clone()).or_insert_with(|| v.clone());
}
}
// Like file.new, accept pixel counts sent as floats by a UI. Unlike file.new's fallback
// for legacy callers, refuse invalid input rather than saving a different configuration.
for key in ["width", "height"] {
let n = fields
.get(key)
.and_then(Value::as_f64)
.filter(|n| n.is_finite() && (1.0..=MAX_DIMENSION as f64).contains(n))
.ok_or_else(|| bad(cmd, format!("`{key}` must be within 1..={MAX_DIMENSION} pixels")))?;
fields.insert(key.into(), json!(n.round() as u32));
}
if fields.get("mode").and_then(Value::as_str) == Some("grayscale") {
fields.insert("mode".into(), json!("gray"));
}
if fields.get("background").and_then(Value::as_str) == Some("backgroundColor") {
let c = background_color(&Value::Object(fields.clone()), s.tools.background, cmd)?;
fields.insert("backgroundColor".into(), json!([c[0], c[1], c[2]]));
}
let settings: Self = serde_json::from_value(Value::Object(fields)).map_err(|e| bad(cmd, format!("invalid settings: {e}")))?;
settings.validate().map_err(|e| bad(cmd, e))?;
Ok(settings)
}
/// Creation params deliberately exclude both the preset name and display-only units.
pub fn command_params(&self) -> Value {
let mut p = json!({"width":self.width,"height":self.height,"resolution":self.resolution,
"mode":self.mode,"depth":self.depth,"background":self.background});
if let Some(c) = self.background_color {
p["backgroundColor"] = json!(c);
}
p
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DocumentPreset {
pub name: String,
pub settings: DocumentSettings,
}
fn checked_name(name: &str) -> std::result::Result<String, String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > crate::brush_preset_cmds::MAX_NAME {
return Err("Preset names must contain 1–255 characters after trimming".into());
}
Ok(name.into())
}
/// Read entries independently so a damaged preset cannot discard other presets or preferences.
pub(crate) fn load(value: Value) -> Vec<DocumentPreset> {
let Some(entries) = value.as_array() else { return Vec::new() };
let mut presets: Vec<DocumentPreset> = Vec::new();
for entry in entries.iter().take(MAX_PRESETS) {
let Ok(mut preset) = serde_json::from_value::<DocumentPreset>(entry.clone()) else { continue };
let Ok(name) = checked_name(&preset.name) else { continue };
if preset.settings.validate().is_err() || presets.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) {
continue;
}
preset.name = name;
if preset.settings.mode == "grayscale" {
preset.settings.mode = "gray".into();
}
presets.push(preset);
}
presets
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "document.presets.save";
let name = checked_name(req_str(p, "name", CMD)?).map_err(|e| bad(CMD, e))?;
if s.presets.documents.iter().any(|pr| pr.name.eq_ignore_ascii_case(&name)) {
return Err(bad(CMD, format!("A preset named \"{name}\" already exists; choose another name")));
}
if s.presets.documents.len() >= MAX_PRESETS {
return Err(bad(CMD, format!("At most {MAX_PRESETS} document presets can be saved")));
}
let settings = DocumentSettings::from_params(s, p.get("settings").unwrap_or(&Value::Null), CMD)?;
let preset = DocumentPreset { name, settings };
let result = json!({"preset":preset});
s.presets.documents.push(preset);
s.presets_changed();
Ok(result)
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "document.presets.get";
let name = req_str(p, "name", CMD)?;
let preset = s.presets.documents.iter().find(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such document preset"))?;
Ok(json!({"preset":preset,"params":preset.settings.command_params()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "document.presets.delete";
let name = req_str(p, "name", CMD)?;
let index = s.presets.documents.iter().position(|pr| pr.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(CMD, "No such document preset"))?;
s.presets.documents.remove(index);
s.presets_changed();
Ok(json!({"deleted":name}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: "document.presets.list",
label: "List Document Presets",
menu: &[],
shortcut: None,
params: "{}",
enabled: always,
run: |s, _| Ok(json!({"presets":s.presets.documents})),
journal: false,
},
CommandSpec {
id: "document.presets.get",
label: "Get Document Preset",
menu: &[],
shortcut: None,
params: r#"{"name":str} -> {preset,params} (params for file.new; no document is created)"#,
enabled: always,
run: get,
journal: false,
},
CommandSpec {
id: "document.presets.save",
label: "Save Document Preset",
menu: &[],
shortcut: None,
params: r##"{"name":str (trimmed, ASCII case-insensitive unique, 1..255 characters),"settings":{"width":px,"height":px,"resolution":ppi=72,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|transparent|backgroundColor|#rrggbb"="white","backgroundColor":[r,g,b]?,"unit":"px|in|cm|mm|pt|pica"="px","resolutionUnit":"in|cm"="in"}}"##,
enabled: always,
run: save,
journal: true,
},
CommandSpec {
id: "document.presets.delete",
label: "Delete Document Preset",
menu: &[],
shortcut: None,
params: r#"{"name":str}"#,
enabled: always,
run: delete,
journal: true,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn settings() -> Value {
json!({"width":16,"height":24,"resolution":254.5,"mode":"lab","depth":16,
"background":"backgroundColor","backgroundColor":[0.125,0.375,0.625],"unit":"mm","resolutionUnit":"cm"})
}
fn saved(s: &mut Session, name: &str, settings: Value) -> Value {
s.execute("document.presets.save", json!({"name":name,"settings":settings})).unwrap()["preset"].clone()
}
fn reload(s: &Session) -> Session {
let mut next = Session::new();
next.load_prefs_json(&s.prefs_to_json()).unwrap();
next
}
#[test]
fn every_field_survives_save_reload_and_file_new() {
for mode in ["rgb", "gray", "cmyk", "lab"] {
for depth in [8, 16, 32] {
let mut s = Session::new();
let mut config = settings();
config["mode"] = json!(mode);
config["depth"] = json!(depth);
let preset = saved(&mut s, " Product square ", config.clone());
assert_eq!(preset["name"], "Product square");
assert_eq!(preset["settings"], config);
let mut next = reload(&s);
assert_eq!(next.presets.documents, s.presets.documents);
let rev = next.prefs.rev();
let selected = next.execute("document.presets.get", json!({"name":"product SQUARE"})).unwrap();
assert_eq!(selected["preset"], preset);
assert!(next.documents().is_empty(), "save/get do not create a document");
assert_eq!(next.prefs.rev(), rev, "queries do not dirty preferences");
let mut params = selected["params"].clone();
assert!(params.get("name").is_none());
params["name"] = json!("Actual document");
next.execute("file.new", params).unwrap();
let doc = &next.active().unwrap().doc;
assert_eq!((doc.size.width, doc.size.height, doc.resolution_dpi), (16, 24, 254.5));
assert_eq!(doc.name, "Actual document");
assert_eq!(doc.mode, color_mode(mode).unwrap());
assert_eq!(doc.depth, sample_type(depth).unwrap());
}
}
}
#[test]
fn background_choice_and_captured_colour_are_independent_of_the_toolbox() {
for (background, expected) in [
("white", [1.0, 1.0, 1.0, 1.0]),
("black", [0.0, 0.0, 0.0, 1.0]),
("transparent", [0.0; 4]),
("#ff0000", [1.0, 0.0, 0.0, 1.0]),
("backgroundColor", [0.125, 0.375, 0.625, 1.0]),
] {
let mut s = Session::new();
s.tools.background = [0.125, 0.375, 0.625, 1.0];
saved(&mut s, "Background", json!({"width":4,"height":4,"background":background,"depth":32}));
let mut s = reload(&s);
s.tools.background = [1.0, 0.0, 1.0, 1.0];
let p = s.execute("document.presets.get", json!({"name":"Background"})).unwrap();
assert_eq!(p["preset"]["settings"]["background"], background);
s.execute("file.new", p["params"].clone()).unwrap();
let pixel = s.execute("document.pixel", json!({"x":0,"y":0})).unwrap();
let rgba = pixel.as_array().unwrap();
for (actual, expected) in rgba.iter().zip(expected) {
assert!((actual.as_f64().unwrap() - expected).abs() < 0.001, "{background}: {pixel}");
}
}
}
#[test]
fn deletion_marks_preferences_dirty_and_survives_reload() {
let mut s = Session::new();
saved(&mut s, "Delete me", settings());
saved(&mut s, "Keep me", settings());
let mut s = reload(&s);
let rev = s.prefs.rev();
s.execute("document.presets.delete", json!({"name":" delete ME "})).unwrap();
assert!(s.prefs.rev() > rev);
let mut s = reload(&s);
assert_eq!(s.execute("document.presets.list", json!({})).unwrap()["presets"].as_array().unwrap().len(), 1);
assert_eq!(s.presets.documents[0].name, "Keep me");
assert!(s.execute("document.presets.get", json!({"name":"Delete me"})).is_err());
assert!(s.execute("document.presets.delete", json!({"name":"Delete me"})).is_err());
}
#[test]
fn old_preferences_and_malformed_presets_keep_unrelated_settings() {
let mut original = Session::new();
original.edit_prefs(|p| p.general.beep_when_done = true);
original.presets.tool_presets[0].name = "Unrelated tool preset".into();
let valid = saved(&mut original, "Keep me", settings());
for invalid in [json!("bad collection"), json!([false, {}, {"name":"broken","settings":{}}])] {
let mut value = original.prefs_value();
value["presets"]["documents"] = invalid;
let mut s = Session::new();
s.load_prefs_json(&value.to_string()).unwrap();
assert!(s.prefs().general.beep_when_done);
assert_eq!(s.presets.tool_presets[0].name, "Unrelated tool preset");
assert!(s.presets.documents.is_empty());
}
let mut value = original.prefs_value();
let mut bad = valid.clone();
bad["settings"]["width"] = json!(0);
value["presets"]["documents"] = json!([bad, valid, valid]);
let mut s = Session::new();
s.load_prefs_json(&value.to_string()).unwrap();
assert_eq!(s.presets.documents.len(), 1, "keep valid entries, discard bad/duplicate entries");
assert_eq!(s.presets.documents[0].name, "Keep me");
for without_presets in [false, true] {
let mut value = original.prefs_value();
if without_presets {
value.as_object_mut().unwrap().remove("presets");
} else {
value["presets"].as_object_mut().unwrap().remove("documents");
}
let mut old = Session::new();
old.load_prefs_json(&value.to_string()).unwrap();
assert!(old.presets.documents.is_empty());
assert!(old.prefs().general.beep_when_done);
}
}
#[test]
fn bad_input_and_duplicate_names_never_replace_or_mutate_a_snapshot() {
let mut s = Session::new();
let mut config = settings();
saved(&mut s, "Keep me", config.clone());
config["width"] = json!(99);
assert_eq!(s.presets.documents[0].settings.width, 16);
let before = s.prefs_value();
let rev = s.prefs.rev();
for name in ["", " \t\n ", "Keep me", " keep ME ", &"x".repeat(256)] {
assert!(s.execute("document.presets.save", json!({"name":name,"settings":config})).is_err());
}
for (key, bad) in [
("width", json!(0)),
("height", json!(-1)),
("width", json!(300_001)),
("width", json!("wrong")),
("resolution", json!(0)),
("resolution", json!(30_001)),
("resolution", Value::Null),
("mode", json!("indexed")),
("depth", json!(64)),
("depth", json!("16")),
("background", json!("invalid")),
("backgroundColor", json!([1, 0])),
("backgroundColor", json!([2, 0, 0])),
("unit", json!("yards")),
("resolutionUnit", json!("px")),
] {
let mut config = settings();
config[key] = bad;
assert!(s.execute("document.presets.save", json!({"name":"Bad","settings":config})).is_err(), "{key}");
}
for p in [json!({}), Value::Null, json!({"name":12}), json!({"name":"Bad","settings":[]})] {
assert!(s.execute("document.presets.save", p).is_err());
}
assert_eq!(s.prefs_value(), before);
assert_eq!(s.prefs.rev(), rev);
}
#[test]
fn invalid_explicit_background_colour_does_not_create_a_document() {
let mut s = Session::new();
for color in [Value::Null, json!([1, 2]), json!([1, -1, 0]), json!("red")] {
assert!(s.execute("file.new", json!({"background":"backgroundColor","backgroundColor":color})).is_err());
assert!(s.documents().is_empty());
}
}
}

View File

@ -26,6 +26,7 @@ pub mod comps_cmds;
pub mod cutout_cmds;
pub mod display_color;
pub mod distort_cmds;
pub mod document_preset_cmds;
pub mod edit_cmds;
pub mod edit_menu_cmds;
pub mod eraser_cmds;

View File

@ -79,6 +79,8 @@ pub struct PresetState {
/// The pattern the Patterns panel selected (Fill, Pattern fill layers default to it).
pub pattern: Option<String>,
pub tool_presets: Vec<tools::ToolPreset>,
/// File › New: named snapshots, separate from the name of the document being created.
pub documents: Vec<crate::document_preset_cmds::DocumentPreset>,
pub clone: clone_source::CloneSources,
/// The user defaults "Make Default" in the Layer Style dialog saves
/// (effect kind → param set); "Reset to Default" restores them.
@ -99,6 +101,7 @@ impl Default for PresetState {
pattern_groups: patterns::builtin_groups(),
pattern: None,
tool_presets: tools::builtin(),
documents: Vec::new(),
clone: Default::default(),
layer_defaults: BTreeMap::new(),
swatches: swatches::builtin(),
@ -122,6 +125,9 @@ struct Persisted {
pattern_groups: Option<Vec<Group<String>>>,
#[serde(default)]
tool_presets: Option<Vec<tools::ToolPreset>>,
// Read this set independently: a bad entry must not discard the other preset kinds.
#[serde(default)]
documents: Option<Value>,
#[serde(default)]
custom_shapes: Option<Vec<crate::edit_menu_cmds::CustomShape>>,
#[serde(default)]
@ -139,6 +145,7 @@ impl PresetState {
shapes: Some(self.shapes.clone()),
pattern_groups: Some(self.pattern_groups.clone()),
tool_presets: Some(self.tool_presets.clone()),
documents: Some(json!(self.documents)),
custom_shapes: Some(s.edit_state.custom_shapes.clone()),
layer_defaults: Some(self.layer_defaults.clone()),
swatches: Some(self.swatches.clone()),
@ -171,6 +178,9 @@ impl Session {
if let Some(t) = p.tool_presets {
st.tool_presets = t;
}
if let Some(d) = p.documents {
st.documents = crate::document_preset_cmds::load(d);
}
if let Some(c) = p.custom_shapes {
self.edit_state.custom_shapes = c;
}

View File

@ -133,7 +133,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
crate::widgets::hairline(ui);
ui.add_space(8.0);
match d.kind {
DialogKind::NewDocument => crate::new_doc_ui::body(ui, &mut fields),
DialogKind::NewDocument => crate::new_doc_ui::body(app, ui, &mut fields),
DialogKind::About if fields.get("systemInfo").and_then(Value::as_bool) == Some(true) => {
let lines = crate::gpu_status::system_info(app);
for l in &lines {

View File

@ -1484,6 +1484,10 @@
↑↓ navigate {key} run esc close ↑↓ pohyb {key} spustit esc zavřít
Version {version} Verze {version}
New Document Nový dokument
Saved Uložené
Save Preset… Uložit předvolbu…
Preset Name Název předvolby
No saved presets yet. Zatím nejsou uloženy žádné předvolby.
Error Chyba
Command Příkaz

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1468,6 +1468,9 @@
↑↓ navigate {key} run esc close ↑↓ Navigieren {key} Ausführen Esc Schließen
Version {version} Version {version}
New Document Neues Dokument
Save Preset… Vorgabe speichern…
Preset Name Name der Vorgabe
No saved presets yet. Noch keine gespeicherten Vorgaben.
Error Fehler
Command Befehl
Adjustment layers have nothing to transform Anpassungsebenen haben nichts zu transformieren

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1469,6 +1469,9 @@
↑↓ navigate {key} run esc close ↑↓ navegar {key} ejecutar esc cerrar
Version {version} Versión {version}
New Document Documento nuevo
Save Preset… Guardar ajuste preestablecido…
Preset Name Nombre del ajuste preestablecido
No saved presets yet. Todavía no hay ajustes preestablecidos guardados.
Error Error
Command Comando

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1454,6 +1454,8 @@
↑↓ navigate {key} run esc close ↑↓ naviguer {key} exécuter échap fermer
Version {version} Version {version}
New Document Nouveau document
Preset Name Nom du préréglage
No saved presets yet. Aucun préréglage enregistré pour le moment.
Error Erreur
Command Commande
Adjustment layers have nothing to transform Les calques d'ajustement n'ont rien à transformer

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1448,6 +1448,8 @@
↑↓ navigate {key} run esc close ↑↓ navigasi {key} jalankan esc tutup
Version {version} Versi {version}
New Document Dokumen Baru
Preset Name Nama Preset
No saved presets yet. Belum ada preset tersimpan.
Error Error
Command Perintah
Adjustment layers have nothing to transform Layer penyesuaian tidak ada yang bisa ditransformasi

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1468,6 +1468,9 @@
↑↓ navigate {key} run esc close ↑↓ naviga {key} esegui esc chiudi
Version {version} Versione {version}
New Document Nuovo documento
Save Preset… Salva predefinito…
Preset Name Nome del predefinito
No saved presets yet. Nessun predefinito salvato.
Error Errore
Command Comando

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1474,6 +1474,10 @@
↑↓ navigate {key} run esc close ↑↓ 移動 {key} 実行 esc 閉じる
Version {version} バージョン {version}
New Document 新規ドキュメント
Saved 保存済み
Save Preset… プリセットを保存…
Preset Name プリセット名
No saved presets yet. 保存済みのプリセットはありません。
Error エラー
Command コマンド

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1446,6 +1446,10 @@
↑↓ navigate {key} run esc close ↑↓ 이동 {key} 실행 esc 닫기
Version {version} 버전 {version}
New Document 새 문서
Saved 저장됨
Save Preset… 사전 설정 저장…
Preset Name 사전 설정 이름
No saved presets yet. 저장된 사전 설정이 없습니다.
Error 오류
Command 명령
Adjustment layers have nothing to transform 조정 레이어에는 변형할 내용이 없습니다

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -1469,6 +1469,8 @@
↑↓ navigate {key} run esc close ↑↓ nawigacja {key} uruchom esc zamknij
Version {version} Wersja {version}
New Document Nowy dokument
Preset Name Nazwa ustawienia wstępnego
No saved presets yet. Nie ma jeszcze zapisanych ustawień wstępnych.
Error Błąd
Command Polecenie

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1453,6 +1453,8 @@
↑↓ navigate {key} run esc close ↑↓ navegar {key} executar esc fechar
Version {version} Versão {version}
New Document Novo documento
Preset Name Nome da predefinição
No saved presets yet. Nenhuma predefinição salva ainda.
Error Erro
Command Comando
Adjustment layers have nothing to transform Camadas de ajuste não têm nada a transformar

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1666,6 +1666,8 @@
Divide Разделить
Result Результат
New Document Новый документ
Preset Name Имя набора
No saved presets yet. Сохранённых наборов пока нет.
New Channel Новый канал
Selection Выделение
Save Selection Сохранить выделение

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1480,6 +1480,10 @@
↑↓ navigate ↵ run esc close ↑↓ 导航 ↵ 运行 esc 关闭
Version {version} 版本 {version}
New Document 新建文档
Saved 已保存
Save Preset… 保存预设…
Preset Name 预设名称
No saved presets yet. 尚无已保存的预设。
Error 错误
Command 命令
Adds grain to the soft edges of the brush tip. 在笔尖的柔软边缘添加颗粒。

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1475,6 +1475,10 @@
↑↓ navigate {key} run esc close ↑↓ 瀏覽 {key} 執行 esc 關閉
Version {version} 版本 {version}
New Document 新增文件
Saved 已儲存
Save Preset… 儲存預設…
Preset Name 預設集名稱
No saved presets yet. 尚無已儲存的預設集。
Error 錯誤
Command 指令
Adjustment layers have nothing to transform 調整圖層沒有可變形的內容

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -127,6 +127,7 @@ pub fn set_size(f: &mut Map<String, Value>, key: &str, v: f32, unit: &str, ppi:
f.insert(key.into(), px_value(from_unit(v, unit, ppi)));
f.insert(typed_key(key), json!({"value": v, "unit": unit}));
f.remove("__preset");
f.remove("__savedPreset");
}
/// What Width or Height (`key`, `d` pixels when unset) shows in `unit`: the value the user typed
@ -149,6 +150,7 @@ fn typed_key(key: &str) -> String {
/// Swap Width and Height (the Orientation buttons), with the values typed for them, so a typed
/// 841 x 1189 mm turns into 1189 x 841 mm, not 1188.9 x 841.
pub fn swap_size(f: &mut Map<String, Value>) {
f.remove("__savedPreset");
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
f.insert("width".into(), px_value(h));
f.insert("height".into(), px_value(w));
@ -163,6 +165,7 @@ pub fn swap_size(f: &mut Map<String, Value>) {
/// Apply a preset to the dialog fields.
pub fn apply_preset(f: &mut Map<String, Value>, p: &Preset) {
f.remove("__savedPreset");
f.insert("width".into(), json!(p.1));
f.insert("height".into(), json!(p.2));
f.insert("resolution".into(), json!(p.3));
@ -182,6 +185,9 @@ pub fn command_params(f: &Map<String, Value>) -> Value {
pub fn set_resolution(f: &mut Map<String, Value>, new_ppi: f32) {
let old_ppi = get_f(f, "resolution", 72.0);
f.insert("resolution".into(), json!(new_ppi));
if old_ppi != new_ppi {
f.remove("__savedPreset");
}
if get_s(f, "__unit", "px") == "px" || !(old_ppi > 0.0 && new_ppi > 0.0) || old_ppi == new_ppi {
return;
}
@ -190,6 +196,7 @@ pub fn set_resolution(f: &mut Map<String, Value>, new_ppi: f32) {
f.insert("width".into(), px_value(w * scale));
f.insert("height".into(), px_value(h * scale));
f.remove("__preset");
f.remove("__savedPreset");
}
fn get_f(f: &Map<String, Value>, k: &str, d: f32) -> f32 {
@ -221,15 +228,114 @@ fn page_icon(ui: &egui::Ui, r: Rect, w: u32, h: u32, t: &Tokens) {
ui.painter().rect_stroke(page, 1.0, Stroke::new(1.2, t.text_dim), StrokeKind::Inside);
}
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
/// Restore a snapshot without replacing the new document's name or its clipboard offer.
pub fn apply_saved_preset(f: &mut Map<String, Value>, preset: &photocraft_engine::document_preset_cmds::DocumentPreset) {
f.remove("backgroundColor");
if let Some(params) = preset.settings.command_params().as_object() {
f.extend(params.clone());
}
f.insert("__unit".into(), json!(preset.settings.unit));
f.insert("__resUnit".into(), json!(preset.settings.resolution_unit));
f.insert("__savedPreset".into(), json!(preset.name));
f.remove("__preset");
// Typed Width/Height belong to the previous values (`shown_size`).
f.remove(&typed_key("width"));
f.remove(&typed_key("height"));
}
fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let presets = app.session.presets.documents.clone();
if presets.is_empty() {
ui.label(tl!("No saved presets yet."));
return;
}
egui::ScrollArea::vertical().id_salt("saved-document-presets").max_height(320.0).show(ui, |ui| {
for preset in &presets {
ui.push_id(&preset.name, |ui| {
ui.horizontal(|ui| {
let selected = get_s(f, "__savedPreset", "") == preset.name;
let label = egui::Button::new(&preset.name).selected(selected).truncate();
if ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name).clicked() {
apply_saved_preset(f, preset);
}
if widgets::secondary_button(ui, tl!("Delete"), 76.0).clicked() {
match app.run("document.presets.delete", json!({"name":preset.name})) {
Ok(_) => {
f.remove("__presetError");
if selected {
f.remove("__savedPreset");
}
}
Err(e) => {
f.insert("__presetError".into(), json!(e.to_string()));
}
}
}
});
let s = &preset.settings;
small_label(ui, &format!("{} × {} px @ {} ppi · {}/{}", s.width, s.height, s.resolution, s.mode.to_uppercase(), s.depth));
ui.add_space(8.0);
});
}
});
}
fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.add_space(12.0);
if f.get("__savingPreset").and_then(Value::as_bool) != Some(true) {
if widgets::secondary_button(ui, tl!("Save Preset…"), 240.0).clicked() {
f.insert("__savingPreset".into(), json!(true));
f.remove("__presetError");
}
} else {
// Consume before TextEdit sees Enter, so naming can never confirm the outer dialog.
let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let label = ui.label(tl!("Preset Name"));
let mut name = get_s(f, "__presetName", "");
ui.add(egui::TextEdit::singleline(&mut name).id_salt("document-preset-name").desired_width(240.0).char_limit(255)).labelled_by(label.id);
f.insert("__presetName".into(), json!(name));
ui.horizontal(|ui| {
if widgets::secondary_button(ui, tl!("Save"), 110.0).clicked() || enter {
let mut settings = command_params(f);
settings["unit"] = json!(get_s(f, "__unit", "px"));
settings["resolutionUnit"] = json!(get_s(f, "__resUnit", "in"));
match app.run("document.presets.save", json!({"name":name,"settings":settings})) {
Ok(_) => {
if let Some(preset) = app.session.presets.documents.last() {
apply_saved_preset(f, preset);
}
f.insert("__category".into(), json!("Saved"));
f.remove("__savingPreset");
f.remove("__presetName");
f.remove("__presetError");
ui.ctx().request_repaint();
}
Err(e) => {
f.insert("__presetError".into(), json!(e.to_string()));
}
}
}
if widgets::secondary_button(ui, tl!("Cancel"), 110.0).clicked() {
f.remove("__savingPreset");
f.remove("__presetError");
}
});
}
if let Some(error) = f.get("__presetError").and_then(Value::as_str) {
ui.add(egui::Label::new(error).wrap());
}
}
pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
let cat = get_s(f, "__category", "Recent");
// Category tabs.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 18.0;
for (name, _) in CATEGORIES {
let on = cat == *name;
let r = ui.add(egui::Label::new(RichText::new(tl!(name)).size(13.0).color(if on { t.text } else { t.text_dim })).sense(Sense::click()));
for name in CATEGORIES.iter().map(|c| c.0).chain(["Saved"]) {
let on = cat == name;
let label = if name == "Saved" { tl!("Saved") } else { tl!(name) };
let r = ui.add(egui::Label::new(RichText::new(label).size(13.0).color(if on { t.text } else { t.text_dim })).sense(Sense::click()));
if on {
ui.painter().line_segment([r.rect.left_bottom() + vec2(0.0, 3.0), r.rect.right_bottom() + vec2(0.0, 3.0)], Stroke::new(2.0, t.text));
}
@ -249,6 +355,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
// Left: preset grid.
ui.vertical(|ui| {
ui.set_width(520.0);
if cat == "Saved" {
saved_presets(app, ui, f);
return;
}
ui.label(RichText::new(crate::i18n::fmt(tl!("BLANK DOCUMENT PRESETS ({n})"), &[("n", &presets.len().to_string())])).size(11.0).color(t.text_faint));
ui.add_space(6.0);
let card = vec2(164.0, 112.0);
@ -319,6 +429,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let opts: Vec<(String, &str)> = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect();
if widgets::dropdown(ui, "nd-unit", &mut unit, &opts, 120.0) {
f.insert("__unit".into(), json!(unit));
f.remove("__savedPreset");
}
});
small_label(ui, tl!("Height"));
@ -348,6 +459,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
if widgets::dropdown(ui, "nd-resunit", &mut ru, &[("in".to_string(), tl!("Pixels/Inch")), ("cm".to_string(), tl!("Pixels/Centimeter"))], 120.0)
{
f.insert("__resUnit".into(), json!(ru));
f.remove("__savedPreset");
}
});
ui.add_space(4.0);
@ -367,11 +479,13 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
110.0,
) {
f.insert("mode".into(), json!(mode));
f.remove("__savedPreset");
}
let mut depth = f.get("depth").and_then(Value::as_u64).unwrap_or(8);
let depth_options: Vec<(u64, &str, &str)> = DEPTH_OPTIONS.iter().map(|(bits, label, tooltip)| (*bits, *label, *tooltip)).collect();
if widgets::dropdown_with_tooltips(ui, "nd-depth", &mut depth, &depth_options, 120.0) {
f.insert("depth".into(), json!(depth));
f.remove("__savedPreset");
}
});
ui.add_space(4.0);
@ -385,7 +499,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
];
if widgets::dropdown(ui, "nd-bg", &mut bg, &opts, 240.0) {
f.insert("background".into(), json!(bg));
f.remove("__savedPreset");
// A fresh choice of Background Color uses today's toolbox colour. Selecting a
// saved preset instead restores the colour captured when it was saved.
f.remove("backgroundColor");
}
save_preset(app, ui, f);
});
});
}
@ -512,6 +631,39 @@ mod tests {
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (2480, 3508, 300.0));
}
#[test]
fn saved_selection_restores_all_settings_but_keeps_the_document_name() {
let mut session = photocraft_engine::Session::new();
session
.execute(
"document.presets.save",
json!({"name":"Print proof","settings":{
"width":1600,"height":800,"resolution":254.0,"mode":"cmyk","depth":16,
"background":"backgroundColor","backgroundColor":[0.2,0.3,0.4],"unit":"mm","resolutionUnit":"cm"
}}),
)
.unwrap();
let preset = session.presets.documents[0].clone();
let mut f = crate::state::UiState::new_document_fields();
set_clipboard(&mut f, 20, 30);
f.insert("name".into(), json!("Catalog cover"));
apply_saved_preset(&mut f, &preset);
assert_eq!(f["name"], "Catalog cover");
assert_eq!(f["__unit"], "mm");
assert_eq!(f["__resUnit"], "cm");
assert_eq!(f["__savedPreset"], "Print proof");
assert!(f.get("__preset").is_none());
assert_eq!(clipboard_preset(&f), Some((CLIPBOARD, 20, 30, 72.0)));
let mut expected = preset.settings.command_params();
expected["name"] = json!("Catalog cover");
assert_eq!(command_params(&f), expected);
f.insert("width".into(), json!(42));
assert_eq!(session.presets.documents[0], preset, "editing the form does not edit the snapshot");
apply_preset(&mut f, &(CLIPBOARD, 20, 30, 72.0));
assert!(f.get("__savedPreset").is_none());
assert_eq!((f["width"].as_u64(), f["height"].as_u64()), (Some(20), Some(30)));
}
/// The real dialog (#254): a typed size must reach `file.new`, however it is confirmed.
mod dialog {
use super::super::{CATEGORIES, apply_preset};
@ -519,6 +671,113 @@ mod tests {
use crate::state::{DialogKind, UiState};
use egui::accesskit::Role;
use egui_kittest::{Harness, kittest::Queryable};
use std::sync::{Arc, Mutex};
/// The real preference services, backed by an in-memory store shared across app restarts.
fn harness_with_store(store: &Arc<Mutex<Option<String>>>) -> Harness<'static, PhotocraftApp> {
let (read, write) = (store.clone(), store.clone());
let services = crate::Services {
load_prefs: Some(Box::new(move || read.lock().unwrap().clone())),
save_prefs: Some(Box::new(move |text| {
*write.lock().unwrap() = Some(text.to_string());
Ok(())
})),
..Default::default()
};
let app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|ui, app| {
crate::prefs_ui::tick(app, ui.ctx());
crate::dialogs::show(app, ui.ctx());
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.state_mut().ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields());
h.run_steps(3);
h
}
fn name_preset(h: &mut Harness<'static, PhotocraftApp>, name: &str) {
h.get_by_label("Save Preset…").click();
h.run_steps(2);
h.get_by_role_and_label(Role::TextInput, "Preset Name").click();
h.run_steps(1);
h.event(egui::Event::Text(name.into()));
h.run_steps(1);
}
#[test]
fn save_restart_select_create_and_delete_through_the_dialog() {
let store = Arc::new(Mutex::new(None));
let mut h = harness_with_store(&store);
type_into(&mut h, 0, "1600");
type_into(&mut h, 1, "1600");
let mut f = fields(&h);
f.insert("background".into(), serde_json::json!("transparent"));
set_fields(&mut h, f);
name_preset(&mut h, " Product square ");
h.get_by_label("Save").click();
h.run_steps(3);
assert!(h.state().session.documents().is_empty(), "saving does not create a document");
assert_eq!(h.state().session.presets.documents[0].name, "Product square");
type_into(&mut h, 0, "32");
assert_eq!(h.state().session.presets.documents[0].settings.width, 1600);
drop(h);
let mut h = harness_with_store(&store);
h.get_by_label("Saved").click();
h.run_steps(2);
h.get_by_label("Product square").click();
h.run_steps(2);
assert!(h.state().session.documents().is_empty(), "selection only populates the form");
assert_eq!(fields(&h)["name"], "Untitled-1");
assert_eq!(fields(&h)["background"], "transparent");
h.get_by_label("Create").click();
h.run_steps(3);
assert_eq!(created(&h), (1600, 1600, 72.0));
let pixel = h.state_mut().session.execute("document.pixel", serde_json::json!({"x":0,"y":0})).unwrap();
assert_eq!(pixel[3], 0.0);
h.state_mut().ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields());
h.run_steps(2);
h.get_by_label("Saved").click();
h.run_steps(2);
h.get_by_label("Delete").click();
h.run_steps(3);
drop(h);
let mut h = harness_with_store(&store);
h.get_by_label("Saved").click();
h.run_steps(2);
assert!(h.state().session.presets.documents.is_empty());
assert!(h.query_by_label("No saved presets yet.").is_some());
}
#[test]
fn enter_saves_the_preset_and_bad_names_leave_the_form_open() {
let mut h = harness();
name_preset(&mut h, "Enter test");
enter(&mut h);
assert_eq!(h.state().session.presets.documents.len(), 1);
assert!(h.state().session.documents().is_empty(), "Enter must not also press Create");
assert_eq!(h.state().ui.dialogs.len(), 1);
name_preset(&mut h, " ENTER TEST ");
h.get_by_label("Save").click();
h.run_steps(2);
assert!(fields(&h).get("__presetError").is_some());
assert_eq!(h.state().session.presets.documents.len(), 1);
assert!(h.state().session.documents().is_empty());
h.get_by_label("Cancel").click();
h.run_steps(2);
name_preset(&mut h, "");
// Cancel keeps the entered name; clear it as an automation edit, then use the button.
h.state_mut().ui.dialogs[0].fields.insert("__presetName".into(), serde_json::json!(" "));
h.run_steps(2);
h.get_by_label("Save").click();
h.run_steps(2);
assert!(fields(&h).get("__presetError").is_some());
assert_eq!(h.state().session.presets.documents.len(), 1);
}
fn harness() -> Harness<'static, PhotocraftApp> {
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());

View File

@ -85,6 +85,10 @@ store expose and persist the same setting; scripts keep using canonical command
| `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` |
| `document.inspect` | `{}`: layer tree, history, selection bounds |
| `document.pixel` | `{"x":10,"y":10}`: composite RGBA |
| `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations |
| `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected |
| `document.presets.get` | `{"name":"Product square"}` → `{preset,params}`: fetch settings; pass `params` to `file.new` to create a document, optionally adding a document `name` |
| `document.presets.delete` | `{"name":"Product square"}`: delete the named saved configuration |
| `type.hitTest` | `{"layer":id?,"x":px,"y":px}`: character under a document point. No `layer` picks the topmost visible type layer there. Result `{"layer","index","line","inside"}` |
| `type.caret` | `{"layer":id?,"index":char}`: caret segment in document pixels, `{"index","line","segment":[[x,y],[x,y]]}` (rotated and vertical type included) |
| `type.navigate` | `{"layer":id?,"index":char,"move":"wordPrev\|wordNext\|linePrev\|lineNext\|lineStart\|lineEnd\|start\|end","x":px?}`: neighbouring caret. `x` keeps the column across line moves. Result `{"index"}` |
@ -157,6 +161,16 @@ Headless CLI/MCP sessions and the web build keep brush presets and the action li
only, unless a store is attached. Gradient presets (including imported `.grd` groups) persist with
the preferences.
Named New Document presets also persist with preferences (`presets.documents`) on desktop and
web. File › New › Save Preset… saves the current form; the Saved tab selects a configuration or
deletes it. Selection only fills the form: Create still runs `file.new`, and the document name
is independent of the preset name. Settings include pixel width/height, resolution in ppi,
mode, depth, background, and display `unit` (`px`, `in`, `cm`, `mm`, `pt`, `pica`) and
`resolutionUnit` (`in`, `cm`). The defaults match File › New. Background Color captures the
toolbox colour as `backgroundColor: [r,g,b]` (0..1), so reusing it does not follow later toolbox
changes. Up to 256 presets are kept. Invalid saved entries are skipped independently; other
presets and preferences still load. Automatic Recent configurations are separate follow-up work.
## Snapping
With View › Snap on, tool gestures snap to the View › Snap To targets (guides and grid while they

View File

@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 10 | 14 | 8 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 6 | 17 | settings that do nothing: 56 of 140 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 56 of 140 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
@ -173,7 +173,7 @@ Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipbo
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | missing | [#217](https://github.com/storytold/photocraft/issues/217) | prefs.rs:61 UiScale unused; input_tests.rs:71 asserts zoom_factor stays 1.0 |
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents |
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs:28 five themes |
| UI-217-12 | Save custom New Document presets; a real Recent category | missing | [#217](https://github.com/storytold/photocraft/issues/217) | new_doc_ui.rs:17 'Recent' is a static list |
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | partial | [#217](https://github.com/storytold/photocraft/issues/217) | widgets.rs:132 DragValue scrubs and takes + - * / arithmetic; no units in expressions, label scrub or reset |
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1108 plain dropdown; trash is click-only (panels.rs:1213) |
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | partial | [#217](https://github.com/storytold/photocraft/issues/217) | adjust_dialog.rs adds black/gray/white eyedroppers; smoothing and pencil modes remain missing |

View File

@ -85,9 +85,9 @@ note = "theme.rs:28 five themes"
[[item]]
id = "UI-217-12"
target = "Save custom New Document presets; a real Recent category"
status = "missing"
status = "partial"
issue = 217
note = "new_doc_ui.rs:17 'Recent' is a static list"
note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard"
[[item]]
id = "UI-217-13"