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:
parent
b7e0d3810d
commit
1bc25d363f
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue