mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 13:26:54 -06:00
M3: paint: colour profiles (Display P3), image adjustment presets, style picker (#54)
* M3: colour profiles: sRGB or Display P3 per file, convert or keep values, tagged exports
- color: ColorProfile (srgb, displayP3) with conversions derived from the primaries (shared D65
white and sRGB curve; out-of-gamut channels clip), table-driven pixel conversion, a generated
ICC v4 matrix/TRC profile and PNG cICP code points.
- doc: Document.color_profile (serde default sRGB, written only when set); every colour value
(paints, text runs, effects, grids, page and prototype backgrounds, overlay backdrops, styles,
colour variables, document colours) can be mapped; set_color_profile converts or reinterprets.
- engine: file.colorProfile {profile?, convert?} (File > Color profile...; without a profile it
opens the dialog); document.inspect reports colorProfile.
- render: images are taken as sRGB and managed into the file's space; encode_export tags PNG
(sRGB chunk, or iCCP + cICP for Display P3) and embeds the ICC profile in Display P3 JPG/WebP;
SVG and PDF export Display P3 files converted to sRGB. The CLI exports the same way.
- ui: Color profile dialog (keep appearance / keep values) and a Color profile row on the page
panel; the canvas, presenter, thumbnails, swatches and colour picker show a Display P3 file's
colours converted to the screen's sRGB, and Copy as PNG converts too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M3: image adjustment presets: built-in, saved in the file, reset
- color: ImageFilters::PRESETS (Vivid, Muted, Warm, Cool, Black & white, Fade, Reset), lookup by
id or label, and sanitized values.
- doc: Document.image_presets (name + adjustments; written only when there are some).
- engine: image.adjust {ids?, target?, index?, preset?, filters?} sets the adjustments of image
paints from a preset (built-in or saved) with values on top, one undo step; image.savePreset,
image.deletePreset and image.presets.
- ui: the colour picker's image section gets a presets menu named after the preset in effect
("Custom" otherwise), saved presets with delete, "Save as preset...", a Reset button, and
aligned adjustment sliders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M3: style picker: search, folders, list or swatch grid, edit, create, detach
The Fill, Stroke, Effects and Layout grid sections' Styles button opens a popover laid out like
the variable picker (crates/ui-egui/src/style_picker.rs):
- search over style names; styles grouped by the folders of their `/`-separated names, which
collapse (searching opens them); colour styles as a list or a grid of swatches;
- click applies, a check marks the applied style; the row's edit button (or a right-click) edits
the name, description and values: colour style paints open the colour picker on the style,
effect and grid styles update from the selection; delete;
- `+` creates a style from the selection's current values and opens it for naming; detach;
- colour slots still list colour variables to bind;
- the applied style chip opens the picker; new icons Adjust, ViewList, ViewGrid (drawn in code);
- control channel: ui.set {stylePicker, stylePickerEdit, stylePickerView}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("File", SEP, "", ""),
|
||||
m!("File", "Duplicate", "file.duplicate", ""),
|
||||
m!("File", "Rename", "file.rename", ""),
|
||||
m!("File", "Color profile…", "file.colorProfile", ""),
|
||||
m!("File", "Revert to saved", "file.revert", ""),
|
||||
m!("File", "Close tab", "file.close", "Cmd+W"),
|
||||
m!("File", "Open recent", "file.openRecent", ""),
|
||||
|
||||
@@ -70,6 +70,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.doc_mut()?.name = name;
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"file.colorProfile",
|
||||
"Color profile",
|
||||
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
|
||||
true,
|
||||
color_profile
|
||||
),
|
||||
spec!("file.duplicate", "Duplicate file", "{}", false, |s, _| {
|
||||
let ds = s.ds()?;
|
||||
let mut d = (*ds.doc).clone();
|
||||
@@ -546,6 +553,18 @@ pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
|
||||
figcraft_format::save_bytes_with_versions(&doc, thumb.as_deref(), &ds.versions).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.colorProfile";
|
||||
let Some(name) = string(p, "profile") else {
|
||||
s.request_ui("dialog.colorProfile", json!({}));
|
||||
return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false}));
|
||||
};
|
||||
let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?;
|
||||
let convert = boolean(p, "convert").unwrap_or(true);
|
||||
let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert);
|
||||
Ok(json!({"profile": profile.id(), "changed": changed}))
|
||||
}
|
||||
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
let bytes = file_bytes(ds)?;
|
||||
@@ -590,10 +609,12 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
|
||||
return Err(EngineError::Disabled("file.export".into(), "nothing to export".into()));
|
||||
}
|
||||
match format {
|
||||
ExportFormat::Svg => figcraft_svg::export_svg(doc, ids, &Default::default())
|
||||
// SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
|
||||
ExportFormat::Svg => figcraft_svg::export_svg(&doc.in_srgb(), ids, &Default::default())
|
||||
.map(String::into_bytes)
|
||||
.ok_or_else(|| EngineError::Other("nothing to export".into())),
|
||||
ExportFormat::Pdf => {
|
||||
let doc = &*doc.in_srgb();
|
||||
// Frames export one page each; anything else shares one page.
|
||||
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
||||
let r = if all_frames {
|
||||
@@ -622,12 +643,7 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
|
||||
})
|
||||
};
|
||||
let pix = pix.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
match format {
|
||||
ExportFormat::Jpg => figcraft_render::encode_jpeg(&pix, figcraft_color::Color::WHITE, 90),
|
||||
ExportFormat::Webp => figcraft_render::encode_webp(&pix),
|
||||
_ => figcraft_render::encode_png(&pix),
|
||||
}
|
||||
.map_err(EngineError::Other)
|
||||
figcraft_render::encode_export(&pix, format, doc.color_profile).map_err(EngineError::Other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Image paints: adjustment presets, built in and saved in the file.
|
||||
|
||||
use figcraft_color::{ImageFilters, PaintKind};
|
||||
use figcraft_doc::{Document, ImagePreset};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"image.adjust",
|
||||
"Adjust image",
|
||||
"{ids?, target? (fill|stroke, default fill), index? (default every image paint), preset? (vivid|muted|warm|cool|blackAndWhite|fade|reset, or the name of a preset saved in the file), filters? ({exposure, contrast, saturation, temperature, tint, highlights, shadows}: each -1..1, applied over the preset)} sets the adjustments of the layers' image paints; returns {changed}",
|
||||
true,
|
||||
adjust
|
||||
),
|
||||
spec!(
|
||||
"image.savePreset",
|
||||
"Save image preset",
|
||||
"{name, filters? (default the adjustments of the first image paint of `ids`/the selection), ids?, target?, index?} saved in the file; a preset with the same name is replaced",
|
||||
true,
|
||||
save_preset
|
||||
),
|
||||
spec!("image.deletePreset", "Delete image preset", "{name}", true, |s, p| {
|
||||
let name = req_string("image.deletePreset", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = doc.image_presets.len();
|
||||
doc.image_presets.retain(|x| x.name != name);
|
||||
if doc.image_presets.len() == before {
|
||||
return Err(bad("image.deletePreset", format!("no preset named `{name}`")));
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
|
||||
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
|
||||
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// The longest name a saved preset may have.
|
||||
const MAX_NAME: usize = 100;
|
||||
/// The most presets a file keeps.
|
||||
const MAX_PRESETS: usize = 500;
|
||||
|
||||
/// Whether `p` asks for strokes (`target: stroke`) rather than fills.
|
||||
fn strokes(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match string(p, "target").as_deref() {
|
||||
None | Some("fill") | Some("fills") => Ok(false),
|
||||
Some("stroke") | Some("strokes") => Ok(true),
|
||||
Some(_) => Err(bad(cmd, "target: fill|stroke")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset `name`: one saved in the file under that exact name, else a built-in one.
|
||||
pub fn find_preset(doc: &Document, name: &str) -> Option<ImageFilters> {
|
||||
doc.image_presets.iter().find(|x| x.name == name).map(|x| x.filters).or_else(|| ImageFilters::preset(name))
|
||||
}
|
||||
|
||||
/// `base` with the adjustments `v` names (an object of -1..1 numbers) set.
|
||||
fn with_values(cmd: &str, base: ImageFilters, v: &Value) -> Result<ImageFilters> {
|
||||
let Value::Object(m) = v else { return Err(bad(cmd, "filters: an object of adjustments")) };
|
||||
let mut f = base;
|
||||
for (k, val) in m {
|
||||
let x = f64_of(val).ok_or_else(|| bad(cmd, format!("filters.{k}: a number")))? as f32;
|
||||
let slot = match k.as_str() {
|
||||
"exposure" => &mut f.exposure,
|
||||
"contrast" => &mut f.contrast,
|
||||
"saturation" => &mut f.saturation,
|
||||
"temperature" => &mut f.temperature,
|
||||
"tint" => &mut f.tint,
|
||||
"highlights" => &mut f.highlights,
|
||||
"shadows" => &mut f.shadows,
|
||||
_ => return Err(bad(cmd, format!("unknown adjustment `{k}`"))),
|
||||
};
|
||||
*slot = x;
|
||||
}
|
||||
Ok(f.sanitized())
|
||||
}
|
||||
|
||||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.adjust";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let stroke = strokes(cmd, p)?;
|
||||
let index = match get(p, "index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(req_index(cmd, p, "index")?),
|
||||
};
|
||||
let preset = match string(p, "preset") {
|
||||
Some(name) => Some(find_preset(s.doc()?, &name).ok_or_else(|| bad(cmd, format!("no preset named `{name}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let values = get(p, "filters").filter(|v| !v.is_null());
|
||||
if preset.is_none() && values.is_none() {
|
||||
return Err(bad(cmd, "pass `preset` or `filters`"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let mut changed = 0;
|
||||
for id in ids {
|
||||
let n = doc.get(id)?;
|
||||
if !if stroke { &n.strokes } else { &n.fills }.iter().any(|x| matches!(x.kind, PaintKind::Image(_))) {
|
||||
continue;
|
||||
}
|
||||
let n = doc.get_mut(id)?;
|
||||
let list = if stroke { &mut n.strokes } else { &mut n.fills };
|
||||
let mut touched = false;
|
||||
for (i, paint) in list.iter_mut().enumerate() {
|
||||
let PaintKind::Image(img) = &mut paint.kind else { continue };
|
||||
if index.is_some_and(|k| k != i) {
|
||||
continue;
|
||||
}
|
||||
let base = preset.unwrap_or(img.filters);
|
||||
img.filters = match values {
|
||||
Some(v) => with_values(cmd, base, v)?,
|
||||
None => base,
|
||||
};
|
||||
touched = true;
|
||||
changed += 1;
|
||||
}
|
||||
if touched {
|
||||
// Like any paint edit, the layer leaves its paint style.
|
||||
if stroke {
|
||||
n.styles.stroke = None;
|
||||
} else {
|
||||
n.styles.fill = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed == 0 {
|
||||
return Err(bad(cmd, if index.is_some() { "no image paint at that index" } else { "the layers have no image paints" }));
|
||||
}
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.savePreset";
|
||||
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "`name` is empty"));
|
||||
}
|
||||
let filters = match get(p, "filters").filter(|v| !v.is_null()) {
|
||||
Some(v) => with_values(cmd, ImageFilters::NONE, v)?,
|
||||
None => {
|
||||
let stroke = strokes(cmd, p)?;
|
||||
let index = num(p, "index").filter(|k| k.is_finite() && *k >= 0.0).map(|k| k as usize);
|
||||
let doc = s.doc()?;
|
||||
targets(s, p)
|
||||
.iter()
|
||||
.filter_map(|id| doc.node(*id))
|
||||
.flat_map(|n| if stroke { n.strokes.iter() } else { n.fills.iter() }.enumerate())
|
||||
.find_map(|(i, x)| match &x.kind {
|
||||
PaintKind::Image(img) if index.is_none_or(|k| k == i) => Some(img.filters),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, "pass `filters`, or select a layer with an image paint"))?
|
||||
}
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
let preset = ImagePreset { name: name.clone(), filters };
|
||||
match doc.image_presets.iter().position(|x| x.name == name) {
|
||||
Some(i) => {
|
||||
if let Some(x) = doc.image_presets.get_mut(i) {
|
||||
*x = preset;
|
||||
}
|
||||
}
|
||||
None if doc.image_presets.len() >= MAX_PRESETS => return Err(bad(cmd, format!("a file keeps at most {MAX_PRESETS} presets"))),
|
||||
None => doc.image_presets.push(preset),
|
||||
}
|
||||
Ok(json!({"name": name, "filters": filters}))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod component;
|
||||
pub mod create;
|
||||
pub mod edit;
|
||||
pub mod file;
|
||||
pub mod image;
|
||||
pub mod layout;
|
||||
pub mod library;
|
||||
pub mod page;
|
||||
@@ -73,6 +74,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(view::specs());
|
||||
list.extend(text::specs());
|
||||
list.extend(styles::specs());
|
||||
list.extend(image::specs());
|
||||
list.extend(variables::specs());
|
||||
list.extend(component::specs());
|
||||
list.extend(library::specs());
|
||||
|
||||
@@ -237,6 +237,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
"styles": doc.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "type": st.kind.type_name()})).collect::<Vec<_>>(),
|
||||
"variables": doc.variables.variables.len(),
|
||||
"images": doc.images.len(),
|
||||
"colorProfile": doc.color_profile.id(),
|
||||
"textEdit": ds.text_edit,
|
||||
"history": {"undo": ds.history.undo.iter().rev().take(20).map(|h| h.label.clone()).collect::<Vec<_>>(), "redo": ds.history.redo.len()},
|
||||
})
|
||||
|
||||
@@ -887,6 +887,8 @@ mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_multi_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_select_tests;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Paint through the session: the file's colour profile (convert or assign, undo, tagged exports)
|
||||
//! and image adjustment presets.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_color::{Color, ColorProfile};
|
||||
|
||||
fn red_rect(s: &mut Session) -> figcraft_doc::NodeId {
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap()["id"]).unwrap();
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
|
||||
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap()
|
||||
}
|
||||
|
||||
/// Whether `bytes` holds a PNG chunk of `kind` (with `data`, when given).
|
||||
fn has_chunk(bytes: &[u8], kind: &[u8; 4], data: Option<&[u8]>) -> bool {
|
||||
bytes.windows(4).enumerate().any(|(i, w)| w == kind && data.is_none_or(|d| bytes.get(i + 4..i + 4 + d.len()) == Some(d)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_profile_converts_or_assigns_and_undoes() {
|
||||
let mut s = Session::new();
|
||||
let r = red_rect(&mut s);
|
||||
let v = s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
|
||||
assert_eq!(v, json!({"profile": "displayP3", "changed": true}));
|
||||
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::DisplayP3);
|
||||
let p3 = fill(&s, r);
|
||||
assert_eq!(p3, ColorProfile::Srgb.conversion_to(ColorProfile::DisplayP3).apply(Color::new(1.0, 0.0, 0.0, 1.0)), "kept the appearance");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::Srgb);
|
||||
assert_eq!(fill(&s, r).to_hex(), "FF0000");
|
||||
// Keep the values: only the profile changes.
|
||||
s.execute("file.colorProfile", &json!({"profile": "Display P3", "convert": false})).unwrap();
|
||||
assert_eq!(fill(&s, r).to_hex(), "FF0000");
|
||||
assert_eq!(s.doc().unwrap().color_profile, ColorProfile::DisplayP3);
|
||||
// The same profile again changes nothing (and adds no undo step).
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("file.colorProfile", &json!({"profile": "p3"})).unwrap()["changed"], false);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
assert!(s.execute("file.colorProfile", &json!({"profile": "cmyk"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_profile_without_a_profile_asks_the_ui() {
|
||||
let mut s = Session::new();
|
||||
let v = s.execute("file.colorProfile", &json!({})).unwrap();
|
||||
assert_eq!(v["profile"], "srgb");
|
||||
assert!(s.ui_requests.iter().any(|u| u.kind == "dialog.colorProfile"));
|
||||
assert!(s.ds().unwrap().history.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_are_tagged_with_the_profile() {
|
||||
let mut s = Session::new();
|
||||
let r = red_rect(&mut s);
|
||||
let png = |s: &mut Session, format: &str| {
|
||||
let v = s.execute("file.export", &json!({"ids": [r], "format": format})).unwrap();
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v["data"].as_str().unwrap()).unwrap()
|
||||
};
|
||||
let srgb = png(&mut s, "png");
|
||||
assert!(has_chunk(&srgb, b"sRGB", None) && !has_chunk(&srgb, b"iCCP", None) && !has_chunk(&srgb, b"cICP", None));
|
||||
s.execute("file.colorProfile", &json!({"profile": "displayP3"})).unwrap();
|
||||
let p3 = png(&mut s, "png");
|
||||
assert!(has_chunk(&p3, b"iCCP", None) && has_chunk(&p3, b"cICP", Some(&[12, 13, 0, 1])) && !has_chunk(&p3, b"sRGB", None));
|
||||
for f in ["jpg", "webp"] {
|
||||
assert!(png(&mut s, f).windows(4).any(|w| w == b"mntr"), "{f} embeds the ICC profile");
|
||||
}
|
||||
// SVG colours are sRGB: the converted red goes back to #FF0000.
|
||||
let svg = String::from_utf8(png(&mut s, "svg")).unwrap().to_ascii_uppercase();
|
||||
assert!(svg.contains("#FF0000"), "{svg}");
|
||||
let pdf = png(&mut s, "pdf");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_profile_survives_save_and_load() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.colorProfile", &json!({"profile": "displayP3", "convert": false})).unwrap();
|
||||
let bytes = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
|
||||
let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert_eq!(doc.color_profile, ColorProfile::DisplayP3);
|
||||
}
|
||||
|
||||
fn image_rect(s: &mut Session) -> figcraft_doc::NodeId {
|
||||
let r = red_rect(s);
|
||||
s.execute("fill.add", &json!({"ids": [r], "paint": {"type": "image", "image": "img"}})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn filters(s: &Session, id: figcraft_doc::NodeId) -> figcraft_color::ImageFilters {
|
||||
match &s.doc().unwrap().node(id).unwrap().fills[1].kind {
|
||||
figcraft_color::PaintKind::Image(img) => img.filters,
|
||||
_ => panic!("not an image"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_presets_apply_combine_and_reset() {
|
||||
let mut s = Session::new();
|
||||
let r = image_rect(&mut s);
|
||||
let v = s.execute("image.adjust", &json!({"ids": [r], "preset": "Black & white"})).unwrap();
|
||||
assert_eq!(v["changed"], 1);
|
||||
assert_eq!(filters(&s, r).saturation, -1.0);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000", "solid fills untouched");
|
||||
// Values apply over a preset, or over what the paint has.
|
||||
s.execute("image.adjust", &json!({"ids": [r], "preset": "warm", "filters": {"exposure": 0.25}})).unwrap();
|
||||
let f = filters(&s, r);
|
||||
assert_eq!((f.temperature, f.exposure, f.saturation), (0.5, 0.25, 0.1));
|
||||
s.execute("image.adjust", &json!({"ids": [r], "filters": {"tint": 7}})).unwrap();
|
||||
assert_eq!(filters(&s, r).tint, 1.0, "clamped");
|
||||
s.execute("image.adjust", &json!({"ids": [r], "preset": "reset"})).unwrap();
|
||||
assert!(filters(&s, r).is_identity());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(filters(&s, r).tint, 1.0, "one undo step per adjust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_adjust_refuses_what_it_cannot_do() {
|
||||
let mut s = Session::new();
|
||||
let plain = red_rect(&mut s);
|
||||
let r = image_rect(&mut s);
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [plain], "preset": "vivid"})).is_err(), "no image paint");
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [r], "preset": "sepia"})).is_err(), "unknown preset");
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [r]})).is_err(), "nothing to set");
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [r], "filters": {"glow": 1}})).is_err());
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [r], "index": 0, "preset": "vivid"})).is_err(), "index 0 is the solid fill");
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [r], "target": "stroke", "preset": "vivid"})).is_err());
|
||||
assert!(s.execute("image.adjust", &json!({"ids": [r], "target": "glow", "preset": "vivid"})).is_err());
|
||||
// Mixed selections adjust the layers that have images.
|
||||
assert_eq!(s.execute("image.adjust", &json!({"ids": [plain, r], "index": 1, "preset": "vivid"})).unwrap()["changed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_image_presets() {
|
||||
let mut s = Session::new();
|
||||
let r = image_rect(&mut s);
|
||||
s.execute("image.adjust", &json!({"ids": [r], "filters": {"contrast": 0.4, "shadows": -0.2}})).unwrap();
|
||||
let v = s.execute("image.savePreset", &json!({"ids": [r], "name": " Moody "})).unwrap();
|
||||
assert_eq!(v["name"], "Moody");
|
||||
s.execute("image.adjust", &json!({"ids": [r], "preset": "reset"})).unwrap();
|
||||
s.execute("image.adjust", &json!({"ids": [r], "preset": "Moody"})).unwrap();
|
||||
assert_eq!((filters(&s, r).contrast, filters(&s, r).shadows), (0.4, -0.2));
|
||||
// Same name replaces; explicit values need no selection.
|
||||
s.execute("image.savePreset", &json!({"name": "Moody", "filters": {"exposure": -0.5}})).unwrap();
|
||||
let list = s.execute("image.presets", &json!({})).unwrap();
|
||||
assert_eq!(list["saved"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(list["saved"][0]["filters"]["exposure"], -0.5);
|
||||
assert_eq!(list["builtIn"].as_array().unwrap().len(), figcraft_color::ImageFilters::PRESETS.len());
|
||||
// Saved presets travel with the file.
|
||||
let bytes = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
|
||||
assert_eq!(figcraft_format::load_bytes(&bytes).unwrap().0.image_presets.len(), 1);
|
||||
s.execute("image.deletePreset", &json!({"name": "Moody"})).unwrap();
|
||||
assert!(s.execute("image.deletePreset", &json!({"name": "Moody"})).is_err());
|
||||
assert!(s.execute("image.savePreset", &json!({"name": " "})).is_err());
|
||||
let plain = red_rect(&mut s);
|
||||
assert!(s.execute("image.savePreset", &json!({"ids": [plain], "name": "X"})).is_err(), "no image to save from");
|
||||
}
|
||||
Reference in New Issue
Block a user