mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
Merge main
This commit is contained in:
@@ -1031,7 +1031,7 @@ mod tests {
|
||||
read
|
||||
};
|
||||
|
||||
let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
|
||||
let r = read(r#"{"interface":{"showTooltips":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
|
||||
assert!(!r.allow_agent_control, "a bad value in another section");
|
||||
assert_eq!(r.control_port, 7878);
|
||||
let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#);
|
||||
|
||||
@@ -65,10 +65,6 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
|
||||
let mut keep: Vec<usize> = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() };
|
||||
keep.sort_unstable();
|
||||
keep.dedup();
|
||||
let mut is_corner = vec![false; n];
|
||||
for &k in &keep {
|
||||
is_corner[k] = true;
|
||||
}
|
||||
if closed && keep.len() < 2 {
|
||||
// A smooth loop: split it at two far-apart points (not corners) to simplify each half.
|
||||
let a = keep.first().copied().unwrap_or(0);
|
||||
@@ -92,8 +88,6 @@ pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
|
||||
verts.push(n - 1);
|
||||
}
|
||||
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
|
||||
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
|
||||
let _ = &corner;
|
||||
let mut out = v.clone();
|
||||
if closed {
|
||||
out.extend(v.first().copied());
|
||||
|
||||
@@ -303,7 +303,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_images_stop_before_the_transpose_and_row_passes() {
|
||||
fn empty_images_trace_to_nothing() {
|
||||
for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] {
|
||||
let t = trace(&[], r, &settings(4, 2.0, 1.0));
|
||||
assert!(t.regions.is_empty());
|
||||
|
||||
@@ -44,8 +44,8 @@ pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize)
|
||||
(comp, n as usize)
|
||||
}
|
||||
|
||||
/// Merges small and sliver components into their neighbours. Returns each pixel's region and
|
||||
/// each region's mean colour.
|
||||
/// Merges components smaller than the Edge Simplicity area into the neighbour they share the most
|
||||
/// border with. Returns each pixel's region and each region's mean colour.
|
||||
pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec<u32>, Vec<Px>) {
|
||||
let mut area = vec![0u32; n];
|
||||
let mut sum = vec![[0.0f64; 3]; n];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, TextLayer};
|
||||
use photocraft_doc::{Document, Fill, GradientStyle, Layer, LayerContent, TextLayer};
|
||||
use photocraft_geom::{Affine, Point, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
@@ -52,8 +52,12 @@ fn map_angle(a: &Affine, deg: f32) -> f32 {
|
||||
}
|
||||
|
||||
fn map_fill(a: &Affine, f: &mut Fill) {
|
||||
if let Fill::Gradient { angle, offset, .. } = f {
|
||||
if let Fill::Gradient { angle, offset, style, reverse, .. } = f {
|
||||
*angle = map_angle(a, *angle);
|
||||
// An Angle gradient sweeps clockwise from its angle; a mirror turns the sweep around.
|
||||
if *style == GradientStyle::Angle && a.m[0] * a.m[3] - a.m[1] * a.m[2] < 0.0 {
|
||||
*reverse = !*reverse;
|
||||
}
|
||||
// The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns
|
||||
// and flips, where the frame's sides swap with the axes. Scale (Image Size) resizes the
|
||||
// frame too, so only each axis's direction applies.
|
||||
@@ -138,6 +142,26 @@ pub(crate) fn retarget_type_resolution(doc: &mut Document, dpi: f32) {
|
||||
}
|
||||
retag_text(dpi, t);
|
||||
});
|
||||
// The styles travelling with the copies scale the same way: a style a copy brings to a
|
||||
// document that lacks it defines the rewritten size, so the copy has no local override.
|
||||
let st = &mut doc.text_styles;
|
||||
let sets = st
|
||||
.character
|
||||
.iter_mut()
|
||||
.map(|c| &mut c.attrs)
|
||||
.chain(st.paragraph.iter_mut().chain([&mut st.basic]).flat_map(|p| [&mut p.para_attrs, &mut p.char_attrs]));
|
||||
for attrs in sets {
|
||||
for (key, v) in attrs.iter_mut() {
|
||||
let Some(n) = v.as_f64().filter(|n| n.is_finite()) else { continue };
|
||||
let n = n as f32;
|
||||
let scaled = match key.as_str() {
|
||||
"size_pt" | "leading_pt" => point_size(n, k),
|
||||
"baseline_shift_pt" | "first_line_indent_pt" | "start_indent_pt" | "end_indent_pt" | "space_before_pt" | "space_after_pt" => grown(n, k),
|
||||
_ => continue,
|
||||
};
|
||||
*v = serde_json::json!(scaled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A positive size in points times `k`, kept finite and above zero (a tiny ratio must not make
|
||||
|
||||
@@ -221,6 +221,32 @@ fn gradient_fill_offset_stays_a_fraction_of_the_frame() {
|
||||
assert!((offset.0 - 0.1).abs() < 1e-6 && (offset.1 - 0.25).abs() < 1e-6, "{offset:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_carry_an_angle_gradient_fill_with_its_pixels() {
|
||||
// An Angle gradient sweeps clockwise: a mirror must reverse it, a turn must not.
|
||||
for t in TURNS {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H})).unwrap();
|
||||
let p = json!({"from": [20, 14], "to": [50, 4], "style": "angle", "dither": false});
|
||||
s.execute("gradient.fill.create", p).unwrap();
|
||||
let before = photocraft_compose::flatten(doc(&s));
|
||||
s.execute(command(t), json!({})).unwrap();
|
||||
let want = expected(&before, t, doc(&s).size.width);
|
||||
let (mean, big) = diff(&photocraft_compose::flatten(doc(&s)).px, &want);
|
||||
assert!(mean < 2.0 / 255.0 && big < 0.02, "{t:?}: mean {mean} big {big}");
|
||||
let flip = matches!(t, Turn::FlipHorizontal | Turn::FlipVertical);
|
||||
assert_eq!(s.execute("gradient.fill.get", json!({})).unwrap()["reverse"], json!(flip), "{t:?}");
|
||||
// Turning back restores the fill model (Reverse toggled twice for flips).
|
||||
s.execute(command(t), json!({})).unwrap();
|
||||
if matches!(t, Turn::Cw90 | Turn::Ccw90) {
|
||||
s.execute(command(t), json!({})).unwrap();
|
||||
s.execute(command(t), json!({})).unwrap();
|
||||
}
|
||||
let g = s.execute("gradient.fill.get", json!({})).unwrap();
|
||||
assert_eq!(g["reverse"], json!(false), "{t:?}: {g}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape's (fill, stroke) gradient angles after `cmd` on a shape whose fill and stroke both
|
||||
/// start as a 0° (left to right) gradient.
|
||||
fn shape_gradient_angles_after(cmd: &str, p: serde_json::Value) -> (f32, f32) {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use photocraft_cms::{Builtin, ColorSpace, GamutCheck, Intent, Lut3d, Profile, SampleKind, Transform};
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{DocId, Document, Duotone, Effect, Fill, FxPaint, Layer, LayerContent};
|
||||
use photocraft_doc::{DocId, Document, Duotone, Effect, Fill, FxPaint, Layer, LayerContent, TextStyles};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -763,6 +763,26 @@ fn convert_layer_colors(l: &mut Layer, cc: &mut dyn FnMut(&mut Color)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `cc` to every colour a type style sets (character styles, paragraph styles and Basic
|
||||
/// Paragraph), so type converted with its document still matches its styles instead of showing
|
||||
/// a local override.
|
||||
fn convert_style_colors(st: &mut TextStyles, cc: &mut dyn FnMut(&mut Color)) {
|
||||
let sets = st
|
||||
.character
|
||||
.iter_mut()
|
||||
.map(|c| &mut c.attrs)
|
||||
.chain(st.paragraph.iter_mut().chain([&mut st.basic]).flat_map(|p| [&mut p.para_attrs, &mut p.char_attrs]));
|
||||
for attrs in sets {
|
||||
for v in attrs.values_mut().filter(|v| v.is_object()) {
|
||||
let Ok(mut c) = serde_json::from_value::<Color>(v.clone()) else { continue };
|
||||
cc(&mut c);
|
||||
if let Ok(n) = serde_json::to_value(c) {
|
||||
*v = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_colors(p: &mut FxPaint, cc: &mut dyn FnMut(&mut Color)) {
|
||||
match p {
|
||||
FxPaint::Color(c) => cc(c),
|
||||
@@ -793,6 +813,7 @@ pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc:
|
||||
convert_layer_colors(l, &mut |c| convert_color(c, from_mode, to_mode, &t));
|
||||
// Preserved PSD blocks describing colours in the old mode no longer apply.
|
||||
}
|
||||
convert_style_colors(&mut doc.text_styles, &mut |c| convert_color(c, from_mode, to_mode, &t));
|
||||
doc.mode = to_mode;
|
||||
doc.icc_profile = Some(dst.to_bytes());
|
||||
// Leaving Indexed / Duotone: the palette and inks no longer apply.
|
||||
@@ -819,6 +840,12 @@ fn ink_duotone(doc: &mut Document) {
|
||||
}
|
||||
});
|
||||
}
|
||||
convert_style_colors(&mut doc.text_styles, &mut |c| {
|
||||
if matches!(c.mode, ColorMode::Grayscale | ColorMode::Duotone) {
|
||||
let [r, g, b] = d.render(c.c[0]);
|
||||
*c = Color::rgba(r, g, b, c.alpha);
|
||||
}
|
||||
});
|
||||
doc.mode = ColorMode::Rgb;
|
||||
doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes());
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use rayon::prelude::*;
|
||||
|
||||
use super::{
|
||||
ColorMode, Document, EngineError, Intent, PixelFormat, Profile, Result, SampleType, Surface, Transform, cms_err, convert_color, convert_document,
|
||||
convert_layer_colors, convert_surface, document_profile, space_mode,
|
||||
convert_layer_colors, convert_style_colors, convert_surface, document_profile, space_mode,
|
||||
};
|
||||
|
||||
const TILE_PIXELS: u128 = (TILE_SIZE * TILE_SIZE) as u128;
|
||||
@@ -197,6 +197,7 @@ pub(super) fn convert(doc: &mut Document, dst: &Profile, intent: Intent, bpc: bo
|
||||
for layer in &mut doc.layers {
|
||||
convert_layer_colors(layer, &mut |c| convert_color(c, from, to_mode, &t));
|
||||
}
|
||||
convert_style_colors(&mut doc.text_styles, &mut |c| convert_color(c, from, to_mode, &t));
|
||||
doc.mode = to_mode;
|
||||
doc.icc_profile = Some(dst.to_bytes());
|
||||
doc.color_table = None;
|
||||
|
||||
@@ -69,6 +69,7 @@ fn scratch_doc(doc: &Document) -> Document {
|
||||
copies.icc_profile = doc.icc_profile.clone();
|
||||
copies.resolution_dpi = doc.resolution_dpi;
|
||||
copies.patterns = doc.patterns.clone();
|
||||
copies.text_styles = doc.text_styles.clone();
|
||||
copies
|
||||
}
|
||||
|
||||
@@ -576,8 +577,10 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32, board: Opt
|
||||
linked.push((*signature, *key, missing));
|
||||
}
|
||||
}
|
||||
let styles = std::mem::take(&mut copies.text_styles);
|
||||
let id = s.edit("Paste", |doc, active| {
|
||||
doc.patterns.extend(patterns);
|
||||
crate::layer_copy_cmds::land_text_styles(doc, styles);
|
||||
doc.metadata.psd_global_blocks.extend(linked);
|
||||
// The first pasted layer goes where a new layer would; the rest stack above it.
|
||||
let mut place = crate::layer_place::Place::AutoEditable;
|
||||
|
||||
@@ -112,6 +112,7 @@ pub(super) fn content_aware_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (region.width() as usize, region.height() as usize);
|
||||
let (nw, nh) = (dst.width() as usize, dst.height() as usize);
|
||||
let label = "Content-Aware Scale";
|
||||
let background = s.tools.background;
|
||||
// A background job when started with `Session::start` (#210): seam carving runs on a worker
|
||||
// against the document snapshot and checks for cancellation before every seam.
|
||||
crate::jobs::run(
|
||||
@@ -128,8 +129,11 @@ pub(super) fn content_aware_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
move |s, out| {
|
||||
s.edit(label, |doc, _| {
|
||||
// The scaled area is vacated as Delete leaves it: transparent, or on the Background,
|
||||
// which can't hold transparency, the background colour (Photoshop).
|
||||
writable_surface(doc, id)?;
|
||||
crate::edit_cmds::clear_area(doc, id, region, None, background)?;
|
||||
let surf = writable_surface(doc, id)?;
|
||||
crate::pixels::try_clear_surface(surf, region, None)?;
|
||||
surf.write_region(dst, &out);
|
||||
surf.prune();
|
||||
Ok(())
|
||||
|
||||
@@ -14,6 +14,10 @@ fn px(s: &Session, x: i32, y: i32) -> Vec<f32> {
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)
|
||||
}
|
||||
|
||||
fn px_rgba(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
active(s).surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> &Layer {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap()
|
||||
@@ -545,6 +549,38 @@ fn content_aware_scale_keeps_subject() {
|
||||
}
|
||||
}
|
||||
|
||||
/// On the Background, which can't hold transparency, the area a selection's Content-Aware Scale
|
||||
/// vacates takes the background colour, as Delete leaves it (Photoshop); the layer stays the
|
||||
/// Background. On a normal layer it is left transparent.
|
||||
#[test]
|
||||
fn content_aware_scale_on_the_background_vacates_to_the_background_colour() {
|
||||
for depth in [8, 16] {
|
||||
let mut s = session(depth);
|
||||
s.tools.background = [0.0, 0.0, 1.0, 1.0];
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
|
||||
let r = s.execute("edit.contentAwareScale", json!({"width": 10})).unwrap();
|
||||
assert_eq!(r["bounds"], json!([10, 10, 10, 10]), "{depth}-bit");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc.is_background(id), "{depth}-bit: still the Background");
|
||||
let px = |x, y| st.doc.layer(id).unwrap().surface().unwrap().rgba(x, y);
|
||||
for (x, y) in [(20, 10), (25, 15), (29, 19)] {
|
||||
assert_eq!(px(x, y), [0.0, 0.0, 1.0, 1.0], "{depth}-bit: ({x}, {y}) is vacated");
|
||||
}
|
||||
assert_eq!(px(12, 15), [1.0, 1.0, 1.0, 1.0], "{depth}-bit: the scaled pixels");
|
||||
assert_eq!(px(5, 5), [1.0, 1.0, 1.0, 1.0], "{depth}-bit: outside the selection");
|
||||
// A normal layer's vacated area is transparent.
|
||||
let mut s = session(depth);
|
||||
s.tools.background = [0.0, 0.0, 1.0, 1.0];
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.contentAwareScale", json!({"width": 10})).unwrap();
|
||||
assert_eq!(px_rgba(&s, 25, 15)[3], 0.0, "{depth}-bit: a layer's hole is transparent");
|
||||
assert_eq!(px_rgba(&s, 12, 15), [1.0, 0.0, 0.0, 1.0], "{depth}-bit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_scale_params() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -3323,10 +3323,26 @@ fn reveal_in_finder_on_windows_passes_the_clicked_tabs_saved_path() {
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "name": "other"})).unwrap();
|
||||
|
||||
let r = s.execute("file.revealInFinder", json!({"document": 0, "dryRun": true})).unwrap();
|
||||
assert_eq!(r["program"], "explorer");
|
||||
// The Windows Explorer by full path: a bare name would first find an explorer.exe in the app's folder.
|
||||
let explorer = crate::layer_menu_cmds::windows_root(std::env::var_os("SystemRoot")).join("explorer.exe");
|
||||
assert_eq!(r["program"], json!(explorer.to_string_lossy()));
|
||||
assert_eq!(r["args"], json!(["/select,", r"C:\项目 图片\海报 🖼.png"]));
|
||||
}
|
||||
|
||||
/// Windows tools run from the Windows folder `SystemRoot` names, and from `C:\Windows` when it is
|
||||
/// unset or relative (which would resolve against the working directory).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_windows_folder_falls_back_when_system_root_is_unset_or_relative() {
|
||||
use crate::layer_menu_cmds::windows_root;
|
||||
use std::path::PathBuf;
|
||||
assert_eq!(windows_root(Some(r"D:\WinNT".into())), PathBuf::from(r"D:\WinNT"));
|
||||
assert_eq!(windows_root(None), PathBuf::from(r"C:\Windows"));
|
||||
assert_eq!(windows_root(Some(r"Windows".into())), PathBuf::from(r"C:\Windows"));
|
||||
assert_eq!(windows_root(Some(r"\Windows".into())), PathBuf::from(r"C:\Windows"));
|
||||
assert_eq!(windows_root(Some("".into())), PathBuf::from(r"C:\Windows"));
|
||||
}
|
||||
|
||||
/// "Include All Subfolders" (AUTO-219-5, #3753): Batch and Image Processor take the images in
|
||||
/// subfolders too, each result keeping its subfolder under the output folder (so same-named files
|
||||
/// don't collide), and skip the output folder when it sits inside the input folder.
|
||||
|
||||
@@ -1067,8 +1067,14 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"bottomRight" => buf.px.last().copied(),
|
||||
_ => None,
|
||||
};
|
||||
// Transparency is judged on the image as stored at its depth: alpha under half a step rounds to 0.
|
||||
let half_step = match d.doc.depth {
|
||||
SampleType::U8 => Some(0.5 / 255.0),
|
||||
SampleType::U16 => Some(0.5 / 65535.0),
|
||||
SampleType::F32 => None,
|
||||
};
|
||||
let differs = |q: [f32; 4]| match reference {
|
||||
None => q[3] > 0.0,
|
||||
None => half_step.map_or(q[3] > 0.0, |h| q[3] >= h),
|
||||
Some(r) => (0..4).any(|c| (q[c] * q[3] - r[c] * r[3]).abs() > 0.5 / 255.0 || (c == 3 && (q[3] - r[3]).abs() > 0.5 / 255.0)),
|
||||
};
|
||||
let mut b = Rect::EMPTY;
|
||||
|
||||
@@ -988,6 +988,28 @@ fn trim_based_on_top_left_colour() {
|
||||
assert_eq!(doc(&s).size, Size::new(10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_transparent_drops_alpha_that_rounds_to_zero_at_the_depth() {
|
||||
// A faint pixel at (0, 0) on a 40 % layer: alpha 1 step × 0.4 rounds to 0 at 8 and 16 bits
|
||||
// (the stored image is transparent there), so Trim keeps only the opaque square. A 1/255
|
||||
// pixel is 0.4 × 257 ≈ 103 steps at 16 bits and any alpha is content at 32 bits: kept.
|
||||
for (depth, faint, keeps) in [(8, 1.0 / 255.0, false), (16, 1.0 / 65535.0, false), (16, 1.0 / 255.0, true), (32, 1.0 / 65535.0, true)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.edit("paint", |doc, _| {
|
||||
let surf = doc.layers[0].surface_mut().unwrap();
|
||||
surf.fill_rect(Rect::new(40, 40, 50, 50), &[1.0, 0.0, 0.0, 1.0]);
|
||||
surf.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, faint]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": doc(&s).layers[0].id.0, "opacity": 0.4})).unwrap();
|
||||
let r = s.execute("image.trim", json!({"basedOn": "transparent"})).unwrap();
|
||||
let want = if keeps { json!({"x": 0, "y": 0, "width": 50, "height": 50}) } else { json!({"x": 40, "y": 40, "width": 10, "height": 10}) };
|
||||
assert_eq!(r, want, "{depth}-bit, faint alpha {faint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_two_bit_holds_linear_light() {
|
||||
// Photoshop 27.11: an 8-bit sRGB grey 128 becomes 0.2159 in 32 bits (and 32 → 0.0144),
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
//! document. Pixels are converted to the destination's colour profile (Color Settings' intent and
|
||||
//! black point compensation) and bit depth, as Photoshop converts dragged layers.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId};
|
||||
use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId, TextStyles};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -122,11 +123,76 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document)
|
||||
// application (24 pt at 72 ppi becomes 5.76 pt at 300 ppi, with no transform). Left at the
|
||||
// source's, a copy re-laid out at the destination's resolution on its next edit (#9560).
|
||||
crate::canvas_geom::retarget_type_resolution(copies, dest.resolution_dpi);
|
||||
adopt_text_styles(copies, &dest.text_styles)?;
|
||||
let new_canvas = copies.bounds() != dest.bounds();
|
||||
prepare_shapes(&mut copies.layers, dest, new_canvas);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Points the copies' type at `dest`'s character and paragraph styles, as the reference
|
||||
/// application does: a style is matched by name and the destination's definition wins (a copy
|
||||
/// whose rewritten size differs from it shows a local override), and a style `dest` lacks comes
|
||||
/// along (already scaled by [`crate::canvas_geom::retarget_type_resolution`]) under a fresh id.
|
||||
/// Afterwards `copies.text_styles` holds just the styles to add; [`land_text_styles`] adds them.
|
||||
fn adopt_text_styles(copies: &mut Document, dest: &TextStyles) -> Result<()> {
|
||||
let src = std::mem::take(&mut copies.text_styles);
|
||||
let mut used = (BTreeSet::new(), BTreeSet::new());
|
||||
crate::image_cmds::for_each_layer(&mut copies.layers, &mut |l| {
|
||||
if let LayerContent::Text(t) = &l.content {
|
||||
used.0.extend(t.runs.iter().filter_map(|r| r.style.style_sheet));
|
||||
used.1.extend(t.paragraphs.iter().filter_map(|p| p.style.style_sheet));
|
||||
}
|
||||
});
|
||||
let full = || EngineError::Other("no free text style id".into());
|
||||
let (mut chars, mut paras) = (HashMap::new(), HashMap::new());
|
||||
let mut next = dest.next_char_id();
|
||||
for id in used.0 {
|
||||
let Some(def) = src.char_style(id) else { continue };
|
||||
let to = match dest.character.iter().find(|d| d.name == def.name) {
|
||||
Some(d) => d.id,
|
||||
None => {
|
||||
let fresh = next.ok_or_else(full)?;
|
||||
next = fresh.checked_add(1);
|
||||
copies.text_styles.character.push(CharacterStyleDef { id: fresh, ..def.clone() });
|
||||
fresh
|
||||
}
|
||||
};
|
||||
chars.insert(id, to);
|
||||
}
|
||||
let mut next = dest.next_para_id();
|
||||
for id in used.1 {
|
||||
// Basic Paragraph (id 0) is implicit; every document has its own.
|
||||
let Some(def) = src.para_style(id).filter(|_| id != 0) else { continue };
|
||||
let to = match dest.paragraph.iter().find(|d| d.name == def.name) {
|
||||
Some(d) => d.id,
|
||||
None => {
|
||||
let fresh = next.ok_or_else(full)?;
|
||||
next = fresh.checked_add(1);
|
||||
copies.text_styles.paragraph.push(ParagraphStyleDef { id: fresh, ..def.clone() });
|
||||
fresh
|
||||
}
|
||||
};
|
||||
paras.insert(id, to);
|
||||
}
|
||||
crate::image_cmds::for_each_layer(&mut copies.layers, &mut |l| {
|
||||
if let LayerContent::Text(t) = &mut l.content {
|
||||
for r in &mut t.runs {
|
||||
r.style.style_sheet = r.style.style_sheet.and_then(|id| chars.get(&id).copied());
|
||||
}
|
||||
for p in &mut t.paragraphs {
|
||||
p.style.style_sheet = p.style.style_sheet.and_then(|id| paras.get(&id).copied());
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds the styles [`adopt_text_styles`] left in `styles` to `doc`.
|
||||
pub(crate) fn land_text_styles(doc: &mut Document, styles: TextStyles) {
|
||||
doc.text_styles.character.extend(styles.character);
|
||||
doc.text_styles.paragraph.extend(styles.paragraph);
|
||||
}
|
||||
|
||||
/// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement).
|
||||
pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx: i32, dy: i32) {
|
||||
if dx != 0 || dy != 0 {
|
||||
@@ -181,6 +247,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
copies.icc_profile = sdoc.icc_profile.clone();
|
||||
// Type re-lays out at the source's resolution, so it keeps its size in pixels.
|
||||
copies.resolution_dpi = sdoc.resolution_dpi;
|
||||
copies.text_styles.clone_from(&sdoc.text_styles);
|
||||
for id in &ids {
|
||||
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut l = src_layer.duplicate();
|
||||
@@ -219,10 +286,12 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (dx.round() as i32, dy.round() as i32);
|
||||
translate_copies(&mut copies, &ddoc, dx, dy);
|
||||
let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect();
|
||||
let styles = std::mem::take(&mut copies.text_styles);
|
||||
s.set_active(dest);
|
||||
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
|
||||
let edited = s.edit(label, |doc, active| {
|
||||
doc.patterns.extend(patterns);
|
||||
land_text_styles(doc, styles);
|
||||
let mut above = *active;
|
||||
let mut new = Vec::with_capacity(copies.layers.len());
|
||||
// The first copy goes where a new layer would; the rest stack above it.
|
||||
@@ -279,6 +348,7 @@ pub(crate) fn duplicate_to_new_document(s: &mut Session, p: &Value) -> Result<Va
|
||||
nd.icc_profile.clone_from(&sdoc.icc_profile);
|
||||
nd.patterns.clone_from(&sdoc.patterns);
|
||||
nd.global_light = sdoc.global_light;
|
||||
nd.text_styles.clone_from(&sdoc.text_styles);
|
||||
// A name other than the copy's own was typed (#9023).
|
||||
let mut typed = None;
|
||||
for id in &ids {
|
||||
@@ -304,6 +374,10 @@ pub(crate) fn duplicate_to_new_document(s: &mut Session, p: &Value) -> Result<Va
|
||||
sm.contents_id = *contents.entry(sm.contents_id).or_insert_with(SmartContentsId::fresh);
|
||||
}
|
||||
}
|
||||
// The new document gets the styles its type uses.
|
||||
adopt_text_styles(&mut nd, &TextStyles::default())?;
|
||||
// Its own Basic Paragraph is the source's: type that inherits from it must not gain overrides.
|
||||
nd.text_styles.basic.clone_from(&sdoc.text_styles.basic);
|
||||
let new_ids: Vec<LayerId> = nd.layers.iter().map(|l| l.id).collect();
|
||||
let index = s.add_document(nd, None);
|
||||
if let (Some(name), [id]) = (typed, new_ids.as_slice()) {
|
||||
|
||||
@@ -500,3 +500,166 @@ fn retargeting_type_survives_bad_resolutions() {
|
||||
crate::canvas_geom::retarget_type_resolution(&mut d, f32::MAX);
|
||||
assert!(size(&d).is_finite() && size(&d) > 0.0, "{}", size(&d));
|
||||
}
|
||||
|
||||
/// Document 0 at 72 ppi with "Hello" in Paragraph Style 1 (24 pt) and "He" also in Character
|
||||
/// Style 1 (24 pt, underlined); document 1 at 300 ppi, its own styles made by `dest`.
|
||||
fn styled_type_docs(dest: impl FnOnce(&mut Session)) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 72})).unwrap();
|
||||
let t = s.execute("type.create", json!({"text": "Hello", "size": 12, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("type.paragraphStyle.new", json!({"attrs": {"size": 24}, "fromSelection": false, "apply": true, "layer": t})).unwrap();
|
||||
s.execute("type.characterStyle.new", json!({"attrs": {"size": 24, "underline": true}, "fromSelection": false, "apply": true, "layer": t, "range": [0, 2]}))
|
||||
.unwrap();
|
||||
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 300})).unwrap();
|
||||
dest(&mut s);
|
||||
s.set_active(0);
|
||||
s.execute("layer.select", json!({"layer": t})).unwrap();
|
||||
(s, LayerId(t))
|
||||
}
|
||||
|
||||
/// The styles panel state of `range` in layer `id` of document 1.
|
||||
fn styles_of(s: &mut Session, id: LayerId, range: [usize; 2]) -> (Value, Value) {
|
||||
s.set_active(1);
|
||||
let c = s.execute("type.characterStyle.list", json!({"layer": id.0, "range": range})).unwrap();
|
||||
let p = s.execute("type.paragraphStyle.list", json!({"layer": id.0, "range": range})).unwrap();
|
||||
(c, p)
|
||||
}
|
||||
|
||||
/// #10500: type copied to a document of another resolution brings the styles it uses, as in
|
||||
/// the reference application (measured with Duplicate Layer from 72 to 300 ppi): a style the
|
||||
/// destination lacks arrives under its name, scaled like the text (24 pt → 5.76 pt), so the
|
||||
/// copy has no override; it never takes over an unrelated destination style with the same id.
|
||||
#[test]
|
||||
fn copied_type_brings_its_styles_scaled() {
|
||||
for paste in [false, true] {
|
||||
let (mut s, _) = styled_type_docs(|s| {
|
||||
s.execute("type.characterStyle.new", json!({"name": "Unrelated", "attrs": {"size": 50}, "fromSelection": false})).unwrap();
|
||||
s.execute("type.paragraphStyle.new", json!({"name": "Unrelated", "attrs": {"size": 50}, "fromSelection": false})).unwrap();
|
||||
});
|
||||
let id = if paste {
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.set_active(1);
|
||||
LayerId(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap()["layer"].as_u64().unwrap())
|
||||
} else {
|
||||
copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap())
|
||||
};
|
||||
assert!(close(text(&s, 1, id).runs[0].style.size_pt, 5.76), "paste {paste}");
|
||||
let (c, p) = styles_of(&mut s, id, [0, 2]);
|
||||
let names = |v: &Value| v["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&c), ["None", "Unrelated", "Character Style 1"], "paste {paste}");
|
||||
assert_eq!(names(&p), ["Basic Paragraph", "Unrelated", "Paragraph Style 1"], "paste {paste}");
|
||||
let (cs, ps) = (&c["styles"][2], &p["styles"][2]);
|
||||
assert!(close(cs["attrs"]["size_pt"].as_f64().unwrap() as f32, 5.76), "{cs}");
|
||||
assert!(close(ps["characterAttrs"]["size_pt"].as_f64().unwrap() as f32, 5.76), "{ps}");
|
||||
assert_eq!((&c["current"]["character"], &c["current"]["characterOverride"]), (&cs["id"], &json!(false)), "paste {paste}");
|
||||
assert_eq!((&p["current"]["paragraph"], &p["current"]["paragraphOverride"]), (&ps["id"], &json!(false)), "paste {paste}");
|
||||
// The source keeps its 24 pt styles.
|
||||
assert_eq!(doc(&s, 0).text_styles.character[0].attrs["size_pt"], json!(24.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// A destination that already has a style of the same name keeps its definition (24 pt), and
|
||||
/// the copy, at 5.76 pt, shows a local override ("+"), as in the reference application.
|
||||
#[test]
|
||||
fn copied_type_uses_the_destinations_same_named_style() {
|
||||
let (mut s, _) = styled_type_docs(|s| {
|
||||
s.execute("type.characterStyle.new", json!({"name": "Filler", "fromSelection": false})).unwrap();
|
||||
s.execute("type.characterStyle.new", json!({"name": "Character Style 1", "attrs": {"size": 24, "underline": true}, "fromSelection": false})).unwrap();
|
||||
});
|
||||
let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap());
|
||||
let (c, _) = styles_of(&mut s, id, [0, 2]);
|
||||
assert_eq!(c["styles"].as_array().unwrap().len(), 3, "no second Character Style 1: {c}");
|
||||
let existing = &c["styles"][2];
|
||||
assert_eq!(existing["attrs"]["size_pt"], json!(24.0));
|
||||
assert_eq!((&c["current"]["character"], &c["current"]["characterOverride"]), (&existing["id"], &json!(true)));
|
||||
assert!(close(text(&s, 1, id).runs[0].style.size_pt, 5.76));
|
||||
}
|
||||
|
||||
/// Duplicate Layer › New Document brings the styles the type uses (at the same size).
|
||||
#[test]
|
||||
fn type_duplicated_to_a_new_document_keeps_its_styles() {
|
||||
let (mut s, t) = styled_type_docs(|_| {});
|
||||
let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap();
|
||||
let (i, id) = (r["document"].as_u64().unwrap() as usize, LayerId(r["layer"].as_u64().unwrap()));
|
||||
s.set_active(i);
|
||||
let c = s.execute("type.characterStyle.list", json!({"layer": id.0, "range": [0, 2]})).unwrap();
|
||||
let p = s.execute("type.paragraphStyle.list", json!({"layer": id.0, "range": [0, 2]})).unwrap();
|
||||
assert_eq!(c["styles"][1]["name"], "Character Style 1");
|
||||
assert_eq!(c["styles"][1]["attrs"]["size_pt"], json!(24.0));
|
||||
assert_eq!((&c["current"]["character"], &c["current"]["characterOverride"]), (&c["styles"][1]["id"], &json!(false)));
|
||||
assert_eq!((&p["current"]["paragraph"], &p["current"]["paragraphOverride"]), (&p["styles"][1]["id"], &json!(false)));
|
||||
assert_eq!(text(&s, i, id).runs, text(&s, 0, t).runs);
|
||||
}
|
||||
|
||||
/// Document 0 (RGB, 72 ppi) with "Hello" whose "He" is in Character Style 1 (red); document 1
|
||||
/// (CMYK, 72 ppi). The type layer is selected in document 0.
|
||||
fn red_styled_type_docs() -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap();
|
||||
let t = s.execute("type.create", json!({"text": "Hello", "size": 12, "x": 20, "y": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("type.characterStyle.new", json!({"attrs": {"color": "#ff0000"}, "fromSelection": false, "apply": true, "layer": t, "range": [0, 2]})).unwrap();
|
||||
s.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72, "mode": "cmyk"})).unwrap();
|
||||
s.set_active(0);
|
||||
s.execute("layer.select", json!({"layer": t})).unwrap();
|
||||
(s, LayerId(t))
|
||||
}
|
||||
|
||||
/// The colour Character Style 1 of document `i` sets, and whether "He" of `id` overrides it.
|
||||
fn style_color(s: &mut Session, i: usize, id: LayerId) -> (photocraft_color::Color, bool) {
|
||||
s.set_active(i);
|
||||
let c = s.execute("type.characterStyle.list", json!({"layer": id.0, "range": [0, 2]})).unwrap();
|
||||
let style = c["styles"].as_array().unwrap().iter().find(|x| x["name"] == "Character Style 1").unwrap();
|
||||
(serde_json::from_value(style["attrs"]["color"].clone()).unwrap(), c["current"]["characterOverride"].as_bool().unwrap())
|
||||
}
|
||||
|
||||
/// Type copied to a document of another colour mode converts its run colours, and the style it
|
||||
/// brings converts with the same transform: the run matches its style (no override), so a later
|
||||
/// change to the style's colour still reaches it. Converting the document itself (Image › Mode,
|
||||
/// Convert to Profile with and without dither) keeps its styles in step the same way.
|
||||
#[test]
|
||||
fn copied_type_styles_convert_their_colours_with_the_type() {
|
||||
let (mut s, _) = red_styled_type_docs();
|
||||
let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap());
|
||||
let run = text(&s, 1, id).runs[0].style.color;
|
||||
assert_eq!(run.mode, ColorMode::Cmyk, "the run converted");
|
||||
let (style, over) = style_color(&mut s, 1, id);
|
||||
assert_eq!(style, run, "the copied style converted with the run");
|
||||
assert!(!over, "no false override");
|
||||
for (cmd, p) in [
|
||||
("image.mode.cmyk", json!({})),
|
||||
("edit.convertToProfile", json!({"profile": "coated-cmyk", "dither": false})),
|
||||
("edit.convertToProfile", json!({"profile": "coated-cmyk", "dither": true})),
|
||||
("edit.convertToProfile", json!({"profile": "lab-d50"})),
|
||||
] {
|
||||
let (mut s, t) = red_styled_type_docs();
|
||||
s.execute(cmd, p.clone()).unwrap();
|
||||
let run = text(&s, 0, t).runs[0].style.color;
|
||||
assert_ne!(run.mode, ColorMode::Rgb, "{cmd} {p}: the run converted");
|
||||
let (style, over) = style_color(&mut s, 0, t);
|
||||
assert_eq!(style, run, "{cmd} {p}: the style converted with the run");
|
||||
assert!(!over, "{cmd} {p}: no false override");
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicate Layer › New Document keeps the source's Basic Paragraph: type that inherits its
|
||||
/// size from it has no override there, and Clear Override leaves it as it was.
|
||||
#[test]
|
||||
fn type_duplicated_to_a_new_document_keeps_the_sources_basic_paragraph() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let t = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 12, "x": 20, "y": 50})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("type.paragraphStyle.set", json!({"id": 0, "attrs": {"size": 30}})).unwrap();
|
||||
assert!(close(text(&s, 0, t).runs[0].style.size_pt, 30.0), "the type inherits Basic Paragraph");
|
||||
let basic = doc(&s, 0).text_styles.basic.clone();
|
||||
s.execute("layer.select", json!({"layer": t.0})).unwrap();
|
||||
let r = s.execute("layer.duplicate", json!({"document": "new"})).unwrap();
|
||||
let (i, id) = (r["document"].as_u64().unwrap() as usize, LayerId(r["layer"].as_u64().unwrap()));
|
||||
assert_eq!(doc(&s, i).text_styles.basic, basic);
|
||||
s.set_active(i);
|
||||
let c = s.execute("type.characterStyle.list", json!({"layer": id.0})).unwrap();
|
||||
let p = s.execute("type.paragraphStyle.list", json!({"layer": id.0})).unwrap();
|
||||
assert_eq!((&c["current"]["characterOverride"], &p["current"]["paragraphOverride"]), (&json!(false), &json!(false)), "{c} {p}");
|
||||
s.execute("type.paragraphStyle.clearOverride", json!({"layer": id.0})).unwrap();
|
||||
s.execute("type.characterStyle.clearOverride", json!({"layer": id.0})).unwrap();
|
||||
assert!(close(text(&s, i, id).runs[0].style.size_pt, 30.0), "{}", text(&s, i, id).runs[0].style.size_pt);
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ pub(crate) fn reveal(path: &str, dry_run: bool) -> Result<Value> {
|
||||
if dry_run {
|
||||
return Ok(json!({"program": program, "args": args}));
|
||||
}
|
||||
ensure_folder(program, &args)?;
|
||||
spawn(program, &args)?;
|
||||
ensure_folder(&program, &args)?;
|
||||
spawn(&program, &args)?;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
@@ -453,8 +453,28 @@ fn ensure_folder(program: &str, args: &[String]) -> Result<()> {
|
||||
}
|
||||
|
||||
/// The file-manager invocation that selects `path` on this platform.
|
||||
pub fn reveal_command(path: &str) -> Result<(&'static str, Vec<String>)> {
|
||||
reveal_command_on(std::env::consts::OS, path)
|
||||
pub fn reveal_command(path: &str) -> Result<(String, Vec<String>)> {
|
||||
let (program, args) = reveal_command_on(std::env::consts::OS, path)?;
|
||||
Ok((system_program(program), args))
|
||||
}
|
||||
|
||||
/// Windows tools run by full path: by bare name, Windows first looks in the app's own folder, so
|
||||
/// an `explorer.exe` lying next to a portable PhotoCraft would start instead (#9666).
|
||||
#[cfg(windows)]
|
||||
fn system_program(program: &str) -> String {
|
||||
windows_root(std::env::var_os("SystemRoot")).join(format!("{program}.exe")).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The Windows folder for the `SystemRoot` value `system_root`: `C:\Windows` when it is unset or
|
||||
/// not an absolute path (a relative one would resolve against the working directory).
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_root(system_root: Option<std::ffi::OsString>) -> std::path::PathBuf {
|
||||
system_root.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn system_program(program: &str) -> String {
|
||||
program.to_string()
|
||||
}
|
||||
|
||||
/// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value).
|
||||
|
||||
+106
-1
@@ -23,7 +23,7 @@ pub enum EngineError {
|
||||
PsdApprovalRequired { name: String, required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option<u64> },
|
||||
/// The file's estimated memory cannot be admitted within the hard ceiling, which no approval
|
||||
/// can raise. The requirement may equal the ceiling when the estimate saturated.
|
||||
#[error("cannot open “{name}”: its estimated memory requirement of {required} bytes cannot be admitted within the {limit} byte hard limit on this machine")]
|
||||
#[error("cannot open “{name}”: its estimated memory requirement of {required_text} ({required} bytes) cannot be admitted within the {limit_text} ({limit} byte) hard limit{scope}", required_text = bytes_text(.required), limit_text = bytes_text(.limit), scope = build_limit_scope(build_memory_limit()))]
|
||||
PsdHardBudget { name: String, required: u64, limit: u64 },
|
||||
/// The file behind an opened handle is not the one whose memory estimate was approved.
|
||||
/// The document is discarded and is not opened.
|
||||
@@ -36,6 +36,59 @@ pub enum EngineError {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Where this build's hard memory limit comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum BuildMemoryLimit {
|
||||
/// Measured from the computer's memory (64-bit desktop).
|
||||
Measured,
|
||||
/// The 32-bit desktop build: its cap, or the computer's memory if lower.
|
||||
Desktop32Bit,
|
||||
/// Fixed by the browser build.
|
||||
FixedWeb,
|
||||
}
|
||||
|
||||
/// This build's memory-limit source, from its target.
|
||||
fn build_memory_limit() -> BuildMemoryLimit {
|
||||
memory_limit_for(cfg!(target_arch = "wasm32"), cfg!(target_pointer_width = "32"))
|
||||
}
|
||||
|
||||
/// The limit source for a target. The wasm32 check comes first: wasm32 is also 32-bit.
|
||||
fn memory_limit_for(is_wasm32: bool, is_32_bit: bool) -> BuildMemoryLimit {
|
||||
if is_wasm32 {
|
||||
BuildMemoryLimit::FixedWeb
|
||||
} else if is_32_bit {
|
||||
BuildMemoryLimit::Desktop32Bit
|
||||
} else {
|
||||
BuildMemoryLimit::Measured
|
||||
}
|
||||
}
|
||||
|
||||
/// The text after "hard limit": where the limit comes from and, where another build could open
|
||||
/// the file, where to get it. Its sentences stand apart so the action isn't lost in the cause.
|
||||
fn build_limit_scope(limit: BuildMemoryLimit) -> String {
|
||||
let releases = crate::updates_cmds::RELEASES;
|
||||
match limit {
|
||||
BuildMemoryLimit::Measured => " on this machine".to_owned(),
|
||||
BuildMemoryLimit::Desktop32Bit => format!(
|
||||
" on this machine. This 32-bit build of PhotoCraft can use at most 2 GiB of memory for a file, or less if the computer has less memory free. The 64-bit build may be able to open it: download it from {releases}"
|
||||
),
|
||||
BuildMemoryLimit::FixedWeb => format!(" of the browser version of PhotoCraft. To open it, download the desktop app from {releases}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A size for people, next to the exact byte count: "21.5 GiB", "900 MiB", "17 B".
|
||||
fn bytes_text(bytes: &u64) -> String {
|
||||
const GIB: u64 = 1 << 30;
|
||||
const MIB: u64 = 1 << 20;
|
||||
if *bytes >= GIB {
|
||||
format!("{:.1} GiB", *bytes as f64 / GIB as f64)
|
||||
} else if *bytes >= MIB {
|
||||
format!("{} MiB", bytes / MIB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
impl EngineError {
|
||||
@@ -70,3 +123,55 @@ impl From<photocraft_raster::AllocationError> for EngineError {
|
||||
Self::Other(format!("not enough memory for this operation ({error}); the document was not changed"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hard_budget_scope_text_per_build() {
|
||||
assert_eq!(build_limit_scope(BuildMemoryLimit::Measured), " on this machine");
|
||||
let releases = crate::updates_cmds::RELEASES;
|
||||
assert_eq!(
|
||||
build_limit_scope(BuildMemoryLimit::Desktop32Bit),
|
||||
format!(
|
||||
" on this machine. This 32-bit build of PhotoCraft can use at most 2 GiB of memory for a file, or less if the computer has less memory free. The 64-bit build may be able to open it: download it from {releases}"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
build_limit_scope(BuildMemoryLimit::FixedWeb),
|
||||
format!(" of the browser version of PhotoCraft. To open it, download the desktop app from {releases}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_budget_message_names_file_numbers_and_scope() {
|
||||
let text = EngineError::PsdHardBudget { name: "big.psd".into(), required: 23_049_071_616, limit: 2_147_483_648 }.to_string();
|
||||
for part in ["big.psd", "21.5 GiB", "23049071616 bytes", "2.0 GiB", "2147483648 byte", "hard limit"] {
|
||||
assert!(text.contains(part), "{text}");
|
||||
}
|
||||
assert!(text.ends_with(&build_limit_scope(build_memory_limit())), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_text_picks_a_readable_unit() {
|
||||
assert_eq!(bytes_text(&17), "17 B");
|
||||
assert_eq!(bytes_text(&(900 << 20)), "900 MiB");
|
||||
assert_eq!(bytes_text(&(2 << 30)), "2.0 GiB");
|
||||
assert_eq!(bytes_text(&23_049_071_616), "21.5 GiB");
|
||||
assert!(bytes_text(&u64::MAX).ends_with(" GiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_limit_follows_the_target() {
|
||||
assert_eq!(memory_limit_for(true, true), BuildMemoryLimit::FixedWeb);
|
||||
assert_eq!(memory_limit_for(true, false), BuildMemoryLimit::FixedWeb);
|
||||
assert_eq!(memory_limit_for(false, true), BuildMemoryLimit::Desktop32Bit);
|
||||
assert_eq!(memory_limit_for(false, false), BuildMemoryLimit::Measured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_gib_in_the_text_matches_the_cap() {
|
||||
assert_eq!(photocraft_io::memory::NATIVE_32BIT_PEAK_CAP, 2 << 30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,9 +287,6 @@ pub struct Interface {
|
||||
/// objects in a large document stay visible (Photoshop's Clip Thumbnails to Layer Bounds; #4281).
|
||||
pub clip_thumbnails_to_layer_bounds: bool,
|
||||
pub show_tooltips: bool,
|
||||
/// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new
|
||||
/// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273).
|
||||
pub enable_animations: bool,
|
||||
/// Move tool drags show only the layer's outline and an arrow, leaving its pixels in place
|
||||
/// until release. Off (the default), the pixels follow the pointer live inside the outline.
|
||||
pub show_bounding_box_when_dragging_layer: bool,
|
||||
@@ -330,7 +327,6 @@ impl Default for Interface {
|
||||
show_menu_colors: true,
|
||||
clip_thumbnails_to_layer_bounds: false,
|
||||
show_tooltips: true,
|
||||
enable_animations: true,
|
||||
show_bounding_box_when_dragging_layer: false,
|
||||
system_title_bar: false,
|
||||
show_file_name_in_title: true,
|
||||
|
||||
@@ -1025,6 +1025,25 @@ fn a_wrongly_typed_value_resets_only_that_field() {
|
||||
assert_eq!(invalid[1].keys, ["shortcuts", "file.open"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_removed_enable_animations_preference_still_loads_and_is_unknown() {
|
||||
// #9479: Interface › Enable animations is gone (Photoshop has no such setting). Files saved
|
||||
// with it still load whole, without a reported bad value, and the key isn't written back.
|
||||
let mut s = Session::new();
|
||||
let invalid =
|
||||
s.load_prefs_json(r#"{"version": 2, "interface": {"enableAnimations": false, "showTooltips": false}, "performance": {"historyStates": 20}}"#).unwrap();
|
||||
assert!(invalid.is_empty(), "{invalid:?}");
|
||||
assert!(!s.prefs().interface.show_tooltips, "its neighbours load");
|
||||
assert_eq!(s.prefs().performance.history_states, 20);
|
||||
assert!(!s.prefs_to_json().contains("enableAnimations"));
|
||||
for value in [json!(true), json!(false), json!("nope")] {
|
||||
let e = s.execute("prefs.set", json!({"values": {"interface.enableAnimations": value}})).unwrap_err();
|
||||
assert!(e.to_string().contains("unknown preference"), "{e}");
|
||||
assert!(s.execute("prefs.set", json!({"path": "interface.enableAnimations", "value": value})).is_err());
|
||||
}
|
||||
assert!(s.execute("prefs.get", json!({"path": "interface.enableAnimations"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrongly_typed_section_or_colour_setting_resets_only_that_part() {
|
||||
let mut s = Session::new();
|
||||
|
||||
+119
-2
@@ -504,12 +504,60 @@ fn no_room(dir: &Path, cap: Option<u64>) -> String {
|
||||
}
|
||||
|
||||
/// (available, total) bytes of the volume holding `dir`: the mount point that is its longest
|
||||
/// prefix. `None` if unknown, which disables only the free-space check, not spilling.
|
||||
/// prefix. `None` if unknown, which disables only the free-space check, not spilling. On Windows a
|
||||
/// network scratch folder isn't listed, so it reports `None` (no free-space reserve; import
|
||||
/// allowance only up to a Scratch Size Limit, if one is set). On Linux an unlisted network mount
|
||||
/// matches its listed parent mount, such as `/`, and uses that volume's numbers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn free_space(dir: &Path) -> Option<(u64, u64)> {
|
||||
let dir = std::fs::canonicalize(dir).ok()?;
|
||||
let dir = without_verbatim_disk_prefix(&dir).unwrap_or(dir);
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()).ok()?;
|
||||
disks.iter().filter(|d| dir.starts_with(d.mount_point())).max_by_key(|d| d.mount_point().as_os_str().len()).map(|d| (d.available_space(), d.total_space()))
|
||||
disks
|
||||
.iter()
|
||||
.filter(|d| dir.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.and_then(|d| known_space(available_to_caller(d.mount_point()).unwrap_or_else(|| d.available_space()), d.total_space()))
|
||||
}
|
||||
|
||||
/// sysinfo keeps a volume whose space query failed, with both numbers zero; treating that as an
|
||||
/// empty disk would pause spilling, so it counts as unknown.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn known_space(available: u64, total: u64) -> Option<(u64, u64)> {
|
||||
(total != 0).then_some((available, total))
|
||||
}
|
||||
|
||||
/// Bytes the current user can write to the volume at `mount`, which a per-user disk quota can make
|
||||
/// less than the volume's free space that sysinfo reports. `None` if the query fails.
|
||||
#[cfg(windows)]
|
||||
fn available_to_caller(mount: &Path) -> Option<u64> {
|
||||
let mut available = 0;
|
||||
winsafe::GetDiskFreeSpaceEx(Some(mount.to_str()?), Some(&mut available), None, None).ok()?;
|
||||
Some(available)
|
||||
}
|
||||
|
||||
/// Elsewhere sysinfo's figure already is what the user can write.
|
||||
#[cfg(all(not(windows), not(target_arch = "wasm32")))]
|
||||
fn available_to_caller(_mount: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The plain form of a verbatim disk path (`\\?\C:\x` to `C:\x`), or `None` to keep the path as
|
||||
/// is. `canonicalize` returns `\\?\C:\x` on Windows, whose prefix never equals a mount point's
|
||||
/// plain `C:\`. Unix paths never have a prefix component, so this is `None` there.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn without_verbatim_disk_prefix(path: &Path) -> Option<PathBuf> {
|
||||
use std::path::{Component, Prefix};
|
||||
let mut components = path.components();
|
||||
let Some(Component::Prefix(prefix)) = components.next() else {
|
||||
return None;
|
||||
};
|
||||
let Prefix::VerbatimDisk(letter) = prefix.kind() else {
|
||||
return None;
|
||||
};
|
||||
let mut out = PathBuf::from(format!("{}:", char::from(letter)));
|
||||
out.extend(components);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -784,6 +832,75 @@ fn log_error(msg: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_is_stripped_only_for_disks() {
|
||||
let cases: &[(&str, Option<&str>)] = &[
|
||||
(r"\\?\C:\x\y", Some(r"C:\x\y")),
|
||||
(r"\\?\C:\", Some(r"C:\")),
|
||||
(r"\\?\UNC\server\share\x", None),
|
||||
(r"\\?\Volume{0000-1}\x", None),
|
||||
(r"C:\x", None),
|
||||
(r"\\server\share\x", None),
|
||||
];
|
||||
for (input, want) in cases {
|
||||
assert_eq!(without_verbatim_disk_prefix(Path::new(input)).as_deref(), want.map(Path::new), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_keeps_non_utf8_names() {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
let mut wide: Vec<u16> = r"\\?\C:\a".encode_utf16().collect();
|
||||
wide.push(0xD800);
|
||||
let path = PathBuf::from(std::ffi::OsString::from_wide(&wide));
|
||||
let plain = without_verbatim_disk_prefix(&path).unwrap();
|
||||
assert!(plain.starts_with(r"C:\"));
|
||||
assert!(plain.to_str().is_none());
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn verbatim_disk_prefix_is_none_on_unix() {
|
||||
assert_eq!(without_verbatim_disk_prefix(Path::new("/tmp/x")), None);
|
||||
assert_eq!(without_verbatim_disk_prefix(Path::new(r"\\?\C:\x")), None);
|
||||
}
|
||||
|
||||
// `free_space` returned `None` on Windows before the fix: `canonicalize` gives `\\?\C:\..`,
|
||||
// which never started with the mount point `C:\`.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn free_space_finds_the_temp_volume_on_windows() {
|
||||
let temp = std::env::temp_dir();
|
||||
let (available, total) = free_space(&temp).unwrap();
|
||||
assert!(total > 0);
|
||||
assert!(available <= total);
|
||||
let mut temp_volume_total = 0;
|
||||
winsafe::GetDiskFreeSpaceEx(Some(temp.to_str().unwrap()), None, Some(&mut temp_volume_total), None).unwrap();
|
||||
assert_eq!(total, temp_volume_total);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_volume_with_no_total_is_unknown() {
|
||||
assert_eq!(known_space(0, 0), None);
|
||||
assert_eq!(known_space(5, 0), None);
|
||||
assert_eq!(known_space(0, 10), Some((0, 10)));
|
||||
assert_eq!(known_space(5, 10), Some((5, 10)));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn available_to_caller_reads_the_temp_volume() {
|
||||
let temp = std::fs::canonicalize(std::env::temp_dir()).unwrap();
|
||||
let temp = without_verbatim_disk_prefix(&temp).unwrap_or(temp);
|
||||
let mut temp_volume_total = 0;
|
||||
winsafe::GetDiskFreeSpaceEx(Some(temp.to_str().unwrap()), None, Some(&mut temp_volume_total), None).unwrap();
|
||||
assert!(temp_volume_total > 0);
|
||||
assert!(available_to_caller(&temp).unwrap() <= temp_volume_total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_read_retains_backing_and_blocks_commit_until_repaired() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id()));
|
||||
|
||||
+43
-7
@@ -136,18 +136,30 @@ fn os_locale() -> Option<String> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_locale() -> Option<String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let out = std::process::Command::new("reg")
|
||||
.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.ok()?;
|
||||
let out = locale_query().output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// " LocaleName REG_SZ ja-JP"
|
||||
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// `reg query` for the UI locale, with `reg.exe` by full path: by bare name, Windows first looks in
|
||||
/// the app's own folder, so a `reg.exe` lying next to a portable PhotoCraft would start (#9666).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn locale_query() -> std::process::Command {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut cmd = std::process::Command::new(windows_root(std::env::var_os("SystemRoot")).join("System32").join("reg.exe"));
|
||||
cmd.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"]).creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The Windows folder for the `SystemRoot` value `system_root`: `C:\Windows` when it is unset or
|
||||
/// not an absolute path (a relative one would resolve against the working directory).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_root(system_root: Option<std::ffi::OsString>) -> std::path::PathBuf {
|
||||
system_root.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows"))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn os_locale() -> Option<String> {
|
||||
None
|
||||
@@ -654,6 +666,30 @@ mod tests {
|
||||
use super::*;
|
||||
use CjkScript::*;
|
||||
|
||||
/// `reg.exe` runs from System32, not from wherever a bare name would find one first (#9666),
|
||||
/// and the query still answers the UI locale.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn locale_query_runs_the_system_reg() {
|
||||
let cmd = locale_query();
|
||||
let system = windows_root(std::env::var_os("SystemRoot")).join("System32").join("reg.exe");
|
||||
assert_eq!(std::path::Path::new(cmd.get_program()), system);
|
||||
assert!(os_locale().is_some_and(|l| !l.is_empty()));
|
||||
}
|
||||
|
||||
/// `SystemRoot` unset or relative (which would resolve against the working directory) falls
|
||||
/// back to `C:\Windows`.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn the_windows_folder_falls_back_when_system_root_is_unset_or_relative() {
|
||||
use std::path::PathBuf;
|
||||
assert_eq!(windows_root(Some(r"D:\WinNT".into())), PathBuf::from(r"D:\WinNT"));
|
||||
assert_eq!(windows_root(None), PathBuf::from(r"C:\Windows"));
|
||||
assert_eq!(windows_root(Some(r"Windows".into())), PathBuf::from(r"C:\Windows"));
|
||||
assert_eq!(windows_root(Some(r"\Windows".into())), PathBuf::from(r"C:\Windows"));
|
||||
assert_eq!(windows_root(Some("".into())), PathBuf::from(r"C:\Windows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_locale_scripts_are_distinct_from_the_non_cjk_default() {
|
||||
assert_eq!(locale_script(Some("ja_JP.UTF-8")), Some(Japanese));
|
||||
|
||||
@@ -69,7 +69,7 @@ use display::texture_key;
|
||||
pub(crate) use display::{cache_key, canvas_display, display_doc, display_image};
|
||||
use display::{checker, hdr_preview, sync_display_lut};
|
||||
pub use drag::{Drag, fixed_size_px, marquee_corners, marquee_px, marquee_readout, readout_in, size_readout, square_px};
|
||||
pub(crate) use drag::{angle_readout, draw_readout, draw_value_readout};
|
||||
pub(crate) use drag::{angle_readout, doc_box_corners, draw_readout, draw_value_readout};
|
||||
#[cfg(test)]
|
||||
pub(crate) use drag::{marquee_drag_ants, marquee_preview_px};
|
||||
#[cfg(test)]
|
||||
@@ -601,7 +601,8 @@ fn update_canvas_navigation(app: &mut PhotocraftApp, view: &mut View, input: Can
|
||||
let bars_id = egui::Id::unique(("pc-canvas-bars-hover", input.idx));
|
||||
let over_bars = input.ctx.data(|d| d.get_temp::<bool>(bars_id)).unwrap_or(false);
|
||||
let nav_hovered = input.response.hovered() || input.free_hover || over_bars;
|
||||
// Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721).
|
||||
// Like Photoshop, a wheel notch moves the canvas the whole step in one frame, without a
|
||||
// glide (`smooth` off; #5721, #9479).
|
||||
// General › Animated Zoom: the wheel zoom eases over the frames of a notch, or is one instant step.
|
||||
// Enhanced Controls › Enable Gestures off: two fingers on a touch screen neither zoom nor
|
||||
// turn the view (#204); the mouse and trackpad are unaffected.
|
||||
@@ -613,7 +614,7 @@ fn update_canvas_navigation(app: &mut PhotocraftApp, view: &mut View, input: Can
|
||||
app.session.prefs().general.animated_zoom,
|
||||
input.wheel_owner,
|
||||
nav_hovered,
|
||||
app.session.prefs().interface.enable_animations,
|
||||
false,
|
||||
touch_gestures,
|
||||
);
|
||||
if nav_hovered {
|
||||
@@ -1879,7 +1880,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
app.session.prefs().general.animated_zoom,
|
||||
wheel_owner,
|
||||
hover_here,
|
||||
app.session.prefs().interface.enable_animations,
|
||||
false,
|
||||
app.session.prefs().enhanced_controls.touch_gestures,
|
||||
)
|
||||
.is_some());
|
||||
|
||||
@@ -294,6 +294,15 @@ pub(crate) fn marquee_drag_ants(d: &Drag, xf: &ViewXform, marquee: Option<[f64;
|
||||
marquee_ants(xf, d.tool, a, b)
|
||||
}
|
||||
|
||||
/// The corners on screen of the document-axis box dragged from `a` to `b`, clockwise from its
|
||||
/// top-left: a turned parallelogram in a rotated view, as the box the release creates (the Type
|
||||
/// tools' text box, the Move tool's layer marquee).
|
||||
pub(crate) fn doc_box_corners(xf: &ViewXform, a: [f64; 2], b: [f64; 2]) -> [Pos2; 4] {
|
||||
let (x0, x1) = (a[0].min(b[0]) as f32, a[0].max(b[0]) as f32);
|
||||
let (y0, y1) = (a[1].min(b[1]) as f32, a[1].max(b[1]) as f32);
|
||||
[xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)]
|
||||
}
|
||||
|
||||
/// The size readout shown beside the cursor while dragging a marquee: the width and height values.
|
||||
pub fn marquee_readout(r: [f64; 4]) -> [String; 2] {
|
||||
[format!("{} px", r[2] - r[0]), format!("{} px", r[3] - r[1])]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::display::{canvas_display, checker, display_doc, display_image};
|
||||
use super::drag::{
|
||||
angle_readout, draw_marquee_readout, draw_value_readout, marquee_ants, marquee_drag_ants, marquee_preview_px, single_line_drag_px, size_readout,
|
||||
angle_readout, doc_box_corners, draw_marquee_readout, draw_value_readout, marquee_ants, marquee_drag_ants, marquee_preview_px, single_line_drag_px,
|
||||
size_readout,
|
||||
};
|
||||
use super::gesture::constrained_polygon_point;
|
||||
use super::strokes::{EngineStroke, SPOT_HEALING_TRAIL, draw_smoothing_string, strokes_live};
|
||||
@@ -615,8 +616,8 @@ pub(super) fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter
|
||||
painter.circle_filled(b, 3.0, Color32::WHITE);
|
||||
}
|
||||
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => {
|
||||
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
|
||||
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
let [p0, p1, p2, p3] = doc_box_corners(xf, d.start, last);
|
||||
let pts = [p0, p1, p2, p3, p0];
|
||||
painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 3.0, 3.0));
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@
|
||||
//! (the Photoshop behaviour, measured frame by frame on a screen recording). Zooming reads the
|
||||
//! wheel events themselves and ignores egui's smoothed tail.
|
||||
//!
|
||||
//! In both modes panning keeps the smoothed delta, and ⇧/Shift snap zoom reads the wheel events
|
||||
//! and stays instant. The Alt state is taken from the wheel events (and the Alt key's own press
|
||||
//! In both modes the canvas pans by this frame's wheel events, the whole notch at once with no
|
||||
//! glide, as in Photoshop (#9479), while [`read`] keeps egui's smoothed delta; ⇧/Shift snap zoom
|
||||
//! reads the wheel events and stays instant. The Alt state is taken from the wheel events (and the Alt key's own press
|
||||
//! and release, `wheel_events`) and kept until the next wheel event: releasing Alt while the tail
|
||||
//! is still settling must not turn it into a pan.
|
||||
|
||||
@@ -366,9 +367,10 @@ fn alt_gesture(m: &Modifiers) -> bool {
|
||||
m.alt && (!m.command || (m.ctrl && !m.mac_cmd && !m.shift))
|
||||
}
|
||||
|
||||
/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's
|
||||
/// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch
|
||||
/// in the frame the wheel turns and stops there (#5721), as scroll areas already do.
|
||||
/// [`read`], choosing how a wheel scroll pans: `smooth` glides it over a few frames (egui's
|
||||
/// smoothed delta, as in the Liquify and Camera Raw previews); off, the view moves the whole notch
|
||||
/// in the frame the wheel turns and stops there (#5721). The canvas pans with it off, as Photoshop
|
||||
/// does (#9479).
|
||||
/// `touch_gestures` is Preferences › Enhanced Controls › Enable Gestures: off, a touch screen's
|
||||
/// two-finger pinch doesn't zoom ([`pinch_factor`]).
|
||||
/// `animated_zoom` is Preferences › General › Animated Zoom: on, the wheel zoom is eased over the
|
||||
@@ -384,16 +386,16 @@ pub fn read_with(
|
||||
smooth: bool,
|
||||
touch_gestures: bool,
|
||||
) -> Option<Wheel> {
|
||||
let notch = ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let options = ctx.options(|o| o.input_options);
|
||||
let notch = options.line_scroll_speed;
|
||||
|
||||
// --- Modifier tracking (always runs for all callers) ---
|
||||
let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| {
|
||||
let (smoothed, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| {
|
||||
let wheel = wheel_events(i);
|
||||
let latest_alt = wheel.last().map(|(_, _, m, _)| alt_gesture(m));
|
||||
let latest_shift = wheel.last().map(|(_, _, m, _)| m.shift && !m.command && !m.ctrl);
|
||||
let latest_alt = wheel.last().map(|(_, _, m, _)| gesture_mode(m).0);
|
||||
let latest_shift = wheel.last().map(|(_, _, m, _)| gesture_mode(m).1);
|
||||
(i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height())
|
||||
});
|
||||
let wheel = mark_unsmoothed(ctx, wheel);
|
||||
let alt = match latest_alt {
|
||||
Some(a) => {
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), a));
|
||||
@@ -408,6 +410,8 @@ pub fn read_with(
|
||||
}
|
||||
None => ctx.data(|d| d.get_temp::<bool>(shift_id())).unwrap_or(false),
|
||||
};
|
||||
let scroll = if smooth { smoothed } else { unsmoothed_scroll(&wheel, (alt, shift), &options, page) };
|
||||
let wheel = mark_unsmoothed(ctx, wheel);
|
||||
|
||||
if !hovered {
|
||||
return None;
|
||||
@@ -510,17 +514,55 @@ pub fn read_with(
|
||||
let eased = ease_zoom_delta(ctx, owner, along(raw - instant)) + along(instant);
|
||||
// The plain wheel zooms through `y`; its sideways part (⌘/Ctrl + wheel, a trackpad's
|
||||
// horizontal scroll) is not a zoom event, so it pans like any scroll: smoothed, or
|
||||
// with Enable Animations off at once (#5721).
|
||||
if alt { Vec2::new(0.0, eased) } else { Vec2::new(if smooth { scroll.x } else { raw.x }, eased) }
|
||||
// unsmoothed at once (#5721, #9479).
|
||||
if alt { Vec2::new(0.0, eased) } else { Vec2::new(scroll.x, eased) }
|
||||
} else {
|
||||
ctx.data_mut(|d| d.remove::<ZoomEase>(zoom_ease_id(owner)));
|
||||
// Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail).
|
||||
if smooth || alt || shift { scroll } else { raw }
|
||||
// Unsmoothed, a scroll pans by this frame's own wheel events (none: no tail).
|
||||
scroll
|
||||
};
|
||||
classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, animated_zoom, notch })
|
||||
}
|
||||
}
|
||||
|
||||
/// The ⌥/Alt and ⇧/Shift gesture a wheel event belongs to: ⌥ as [`alt_gesture`] decides; ⇧ with
|
||||
/// ⌘/Ctrl is not one (it scrolls sideways, [`configure`]), so it belongs to the plain wheel.
|
||||
fn gesture_mode(m: &Modifiers) -> (bool, bool) {
|
||||
(alt_gesture(m), m.shift && !m.command && !m.ctrl)
|
||||
}
|
||||
|
||||
/// This frame's wheel scroll without egui's smoothing (points, content direction): what egui's
|
||||
/// smoothed delta spreads over the next frames, all of it now and no tail after. ⇧ or ⌘/Ctrl fold
|
||||
/// it sideways as egui does ([`configure`]), so every pan, plain or sideways, moves at once.
|
||||
/// Only the wheel events of the gesture in progress (`mode`, the ⌥ and ⇧ of [`gesture_mode`])
|
||||
/// count: a ⌥ zoom notch in the same frame as a plain pan notch doesn't pan too.
|
||||
fn unsmoothed_scroll(wheel: &[(MouseWheelUnit, Vec2, Modifiers, TouchPhase)], mode: (bool, bool), options: &egui::InputOptions, page: f32) -> Vec2 {
|
||||
wheel.iter().fold(Vec2::ZERO, |sum, (unit, delta, modifiers, phase)| {
|
||||
// egui scrolls only by `Move` events; a trackpad's `Start`, `End` or `Cancel` delta
|
||||
// (`WheelState::on_wheel_event`) is not a scroll.
|
||||
if *phase != TouchPhase::Move || gesture_mode(modifiers) != mode {
|
||||
return sum;
|
||||
}
|
||||
// egui turns these into its own zoom, not a scroll.
|
||||
if modifiers.matches_any(options.zoom_modifier) {
|
||||
return sum;
|
||||
}
|
||||
let d = *delta
|
||||
* match unit {
|
||||
MouseWheelUnit::Point => 1.0,
|
||||
MouseWheelUnit::Line => options.line_scroll_speed,
|
||||
MouseWheelUnit::Page => page,
|
||||
};
|
||||
let sideways = modifiers.matches_any(options.horizontal_scroll_modifier);
|
||||
let upright = modifiers.matches_any(options.vertical_scroll_modifier);
|
||||
sum + match (sideways, upright) {
|
||||
(true, false) => Vec2::new(d.x + d.y, 0.0),
|
||||
(false, true) => Vec2::new(0.0, d.x + d.y),
|
||||
_ => d,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_alt_key(key: Key) -> bool {
|
||||
matches!(key, Key::AltLeft | Key::AltRight)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,41 @@ fn unsmoothed_wheel_pans_at_once_without_a_glide() {
|
||||
assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows");
|
||||
}
|
||||
|
||||
/// Interface › Enable Animations off only stops the *pan* gliding. A plain wheel that zooms
|
||||
/// Unsmoothed, only `Move` wheel events pan, as egui scrolls by them alone: a trackpad's
|
||||
/// `Start`, `End` or `Cancel` event with a delta doesn't move the canvas.
|
||||
#[test]
|
||||
fn unsmoothed_pan_ignores_wheel_events_outside_the_move_phase() {
|
||||
let ctx = Context::default();
|
||||
configure(&ctx);
|
||||
let owner = Id::unique("test-unsmoothed-phase");
|
||||
let event = |phase: TouchPhase| Event::MouseWheel {
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
unit: MouseWheelUnit::Point,
|
||||
delta: Vec2::new(0.0, -30.0),
|
||||
phase,
|
||||
modifiers: Modifiers::NONE,
|
||||
};
|
||||
let pan_of = |n: u32, events: Vec<Event>| {
|
||||
let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
|
||||
let mut pan = Vec2::ZERO;
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, true, owner, true, false, true) {
|
||||
pan = d;
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
pan
|
||||
};
|
||||
for (n, phase) in [TouchPhase::Start, TouchPhase::End, TouchPhase::Cancel].into_iter().enumerate() {
|
||||
let n = u32::try_from(n).unwrap_or(0) * 2;
|
||||
assert_eq!(pan_of(n, vec![event(phase)]), Vec2::ZERO, "{phase:?} doesn't pan");
|
||||
assert_eq!(pan_of(n + 1, Vec::new()), Vec2::ZERO, "nor after {phase:?}");
|
||||
}
|
||||
let moved = pan_of(10, vec![event(TouchPhase::Start), event(TouchPhase::Move), event(TouchPhase::End)]);
|
||||
assert!(near(moved, Vec2::new(0.0, -30.0)), "only the Move delta pans: {moved:?}");
|
||||
}
|
||||
|
||||
/// Pan smoothing off (the canvas, #9479) only stops the *pan* gliding. A plain wheel that zooms
|
||||
/// (Zoom with Scroll Wheel) with Animated Zoom on must still ease over the frames of a notch,
|
||||
/// exactly as it does with the pan smoothing on.
|
||||
#[test]
|
||||
@@ -372,7 +406,7 @@ fn animated_zoom_does_not_ease_a_trackpad() {
|
||||
assert!(frames[1..].iter().all(|f| *f == 1.0), "no tail after it: {frames:?}");
|
||||
}
|
||||
|
||||
/// Zoom with Scroll Wheel + Animated Zoom with Enable Animations off: the wheel zooms eased, but
|
||||
/// Zoom with Scroll Wheel + Animated Zoom with pan smoothing off: the wheel zooms eased, but
|
||||
/// a sideways wheel still pans without a glide (#5721), all of it in the frame it turns.
|
||||
#[test]
|
||||
fn animated_zoom_keeps_the_sideways_wheel_unsmoothed_with_animations_off() {
|
||||
@@ -397,6 +431,44 @@ fn animated_zoom_keeps_the_sideways_wheel_unsmoothed_with_animations_off() {
|
||||
assert!(pans[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it: {pans:?}");
|
||||
}
|
||||
|
||||
/// One frame of `events` read with pan smoothing off (the canvas, #9479) and Animated Zoom on.
|
||||
fn unsmoothed_frame(events: Vec<Event>, zoom_with_wheel: bool) -> Option<Wheel> {
|
||||
let ctx = Context::default();
|
||||
configure(&ctx);
|
||||
let raw = RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
|
||||
let mut wheel = None;
|
||||
let mut out = ctx.run_ui(raw, |ui| wheel = read_with(ui.ctx(), zoom_with_wheel, true, true, Id::unique("test-mixed"), true, false, true));
|
||||
out.textures_delta.clear();
|
||||
wheel
|
||||
}
|
||||
|
||||
/// Unsmoothed, the canvas pans by the wheel events of the gesture in progress only (the latest
|
||||
/// event's modifiers pick it, as for zooming): a ⌥ notch (a zoom) or a ⇧ notch (a snap zoom)
|
||||
/// arriving in the same frame as the panning notch must not pan the canvas too.
|
||||
#[test]
|
||||
fn unsmoothed_pan_takes_only_the_wheel_events_of_the_gesture_in_progress() {
|
||||
let down = Vec2::new(0.0, -1.0);
|
||||
// ⌥ zooms, then a plain notch pans: one notch, not two.
|
||||
let pan = unsmoothed_frame(vec![wheel(down, Modifiers::ALT), wheel(down, Modifiers::NONE)], false);
|
||||
assert_eq!(pan, Some(Wheel::Pan(Vec2::new(0.0, -40.0))), "only the plain notch pans");
|
||||
// ⇧ + wheel pans sideways: a plain notch before it in the frame is not part of that.
|
||||
let pan = unsmoothed_frame(vec![wheel(down, Modifiers::NONE), wheel(down, Modifiers::SHIFT)], false);
|
||||
assert_eq!(pan, Some(Wheel::Pan(Vec2::new(-40.0, 0.0))), "only the ⇧ notch pans, sideways");
|
||||
// Zoom with Scroll Wheel: ⌥ + wheel pans; the plain notch before it zooms and doesn't pan.
|
||||
let pan = unsmoothed_frame(vec![wheel(down, Modifiers::NONE), wheel(down, Modifiers::ALT)], true);
|
||||
assert_eq!(pan, Some(Wheel::Pan(Vec2::new(0.0, -40.0))), "only the ⌥ notch pans");
|
||||
// ...and with the plain wheel zooming, a ⇧ snap notch earlier in the frame doesn't pan sideways.
|
||||
let zoom = unsmoothed_frame(vec![wheel(Vec2::new(0.0, 1.0), Modifiers::SHIFT), wheel(down, Modifiers::NONE)], true);
|
||||
assert!(matches!(zoom, Some(Wheel::Zoom(f)) if f < 1.0), "the plain notch zooms, nothing pans: {zoom:?}");
|
||||
// ⌘/Ctrl + wheel is part of the plain gesture: it pans sideways alongside a plain notch.
|
||||
let pan = unsmoothed_frame(vec![wheel(down, CTRL), wheel(down, Modifiers::NONE)], false);
|
||||
assert_eq!(pan, Some(Wheel::Pan(Vec2::new(-40.0, -40.0))), "⌘/Ctrl + wheel and a plain notch both pan");
|
||||
// A held ⌥ the wheel event itself misses (#8049) still makes it the ⌥ gesture's.
|
||||
let alt_key = |pressed: bool| Event::Key { key: Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||||
let pan = unsmoothed_frame(vec![wheel(down, Modifiers::NONE), alt_key(true), wheel(down, Modifiers::NONE), alt_key(false)], true);
|
||||
assert_eq!(pan, Some(Wheel::Pan(Vec2::new(0.0, -40.0))), "only the notch under the held ⌥ pans");
|
||||
}
|
||||
|
||||
/// A diagonal point step with ⌥ is measured as egui measures it, folded onto one axis: (5, 5)
|
||||
/// is a 10-point step, which egui smooths, so Animated Zoom eases it.
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
|
||||
//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` opens the tool's canvas context menu (the pasteboard colour menu outside the image) or Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase)
|
||||
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points
|
||||
//! (`count` at most [`MAX_CLICKS`])
|
||||
//! (`count` at most [`MAX_CLICKS`], `x` and `y` within ±[`MAX_SCREEN_POINT`])
|
||||
//! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input
|
||||
//! - The synthetic input methods reject unknown fields, wrong types, unknown enum values and
|
||||
//! out-of-range numbers before acting, naming the parameter (#2449)
|
||||
@@ -51,7 +51,7 @@ use dispatch::dispatch;
|
||||
pub use inspect::{inspect, save_screenshot, save_screenshot_rgba};
|
||||
#[cfg(test)]
|
||||
use params::screen_point;
|
||||
pub use params::{MAX_CLICKS, UI_SET_FIELDS};
|
||||
pub use params::{MAX_CLICKS, MAX_SCREEN_POINT, UI_SET_FIELDS};
|
||||
use params::{err, ok};
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::METHODS;
|
||||
use super::inspect::inspect;
|
||||
use super::params::{
|
||||
BUTTONS, KEY_MODIFIERS, MAX_CLICKS, POINTER_MODIFIERS, UI_SET_FIELDS, bool_field, err, f32_field, f32_range, input_choice, input_modifiers, input_number,
|
||||
input_object, merged_object, modifiers_from, num_field, ok, screen_point, uint_field, whole_object,
|
||||
BUTTONS, KEY_MODIFIERS, MAX_CLICKS, MAX_SCREEN_POINT, POINTER_MODIFIERS, UI_SET_FIELDS, bool_field, err, f32_field, f32_range, input_choice,
|
||||
input_modifiers, input_number, input_object, merged_object, modifiers_from, num_field, ok, screen_point, uint_field, whole_object,
|
||||
};
|
||||
use super::{ControlRequest, DialogKind, Outcome, PhotocraftApp, SHELL_DIALOG, Tool, ToolEvent, Value, json, run_waiting, tool_event, wrap};
|
||||
use photocraft_engine::jobs::{CancelReport, JobId};
|
||||
@@ -1155,8 +1155,9 @@ fn click(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value, click: bool) -
|
||||
let parsed = (|| -> std::result::Result<_, String> {
|
||||
let p = input_object(p, None, fields)?;
|
||||
// Screen coordinates in points (as reported by ui.inspect window size).
|
||||
let x = input_number(&p, "x", "x", None)?.unwrap_or(0.0) as f32;
|
||||
let y = input_number(&p, "y", "y", None)?.unwrap_or(0.0) as f32;
|
||||
let range = Some((-MAX_SCREEN_POINT, MAX_SCREEN_POINT));
|
||||
let x = input_number(&p, "x", "x", range)?.unwrap_or(0.0) as f32;
|
||||
let y = input_number(&p, "y", "y", range)?.unwrap_or(0.0) as f32;
|
||||
let button = match input_choice(&p, "button", "button", &BUTTONS)?.unwrap_or("left") {
|
||||
"right" | "secondary" => egui::PointerButton::Secondary,
|
||||
"middle" => egui::PointerButton::Middle,
|
||||
|
||||
@@ -61,6 +61,10 @@ pub const UI_SET_FIELDS: [&str; 49] = [
|
||||
/// the same ceiling as the steps of one batch request.
|
||||
pub const MAX_CLICKS: u64 = 256;
|
||||
|
||||
/// Largest `ui.click` / `ui.move` coordinate, in screen points either way (#9137). Far beyond any
|
||||
/// display, yet small enough that egui's pointer delta between two such points stays finite.
|
||||
pub const MAX_SCREEN_POINT: f64 = 1e6;
|
||||
|
||||
pub(super) fn ok(v: Value) -> Outcome {
|
||||
Outcome::Done(json!({"ok": true, "result": v}))
|
||||
}
|
||||
|
||||
@@ -2022,6 +2022,35 @@ fn huge_click_count_is_rejected() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9137: a coordinate past f32's range became `inf`, and on the next frame egui's pointer
|
||||
/// delta `inf - inf` panicked outside the dispatch guard, closing the app.
|
||||
#[test]
|
||||
fn huge_click_coordinate_is_rejected() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
for method in ["ui.click", "ui.move"] {
|
||||
for (field, v) in [("x", 1e308), ("y", -1e308), ("x", 3.5e38), ("y", 1e7), ("x", -1.5e6)] {
|
||||
let (req, _rx) = ControlRequest::new(method, json!({ field: v }));
|
||||
let Outcome::Done(r) = handle(&mut app, &ctx, &req) else { panic!("{method} {field}={v}: accepted") };
|
||||
assert_eq!(r["ok"], false, "{method} {field}={v}: {r}");
|
||||
assert!(r["error"].as_str().unwrap().starts_with(&format!("{field} must be between")), "{r}");
|
||||
assert!(app.synthetic.is_empty(), "a rejected {method} queues nothing");
|
||||
}
|
||||
}
|
||||
// Points at the edges of the range, off-window included, reach egui and run a frame each.
|
||||
let edge = MAX_SCREEN_POINT;
|
||||
for (method, x, y) in [("ui.move", edge, -edge), ("ui.click", -edge, edge), ("ui.click", -5.0, 20.0)] {
|
||||
let (req, _rx) = ControlRequest::new(method, json!({"x": x, "y": y}));
|
||||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::AfterInput));
|
||||
while !app.synthetic.is_empty() {
|
||||
let events = app.take_synthetic_step();
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |_| {}).textures_delta.clear();
|
||||
}
|
||||
let pos = ctx.input(|i| i.pointer.latest_pos());
|
||||
assert_eq!(pos, Some(egui::pos2(x as f32, y as f32)), "{method} {x},{y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2449: the error of a rejected synthetic input call, after checking it changed nothing.
|
||||
fn rejected(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value, names: &str) -> String {
|
||||
let (tool, revision) = (app.ui.tool, app.session.active().map(|d| d.revision));
|
||||
|
||||
@@ -47,7 +47,7 @@ fn every_adjustment_with_an_editor_has_a_dialog() {
|
||||
for kind in adjust_editors::KINDS {
|
||||
assert!(has_dialog(&format!("image.adjustments.{kind}")), "{kind}");
|
||||
}
|
||||
for id in ["image.adjustments.invert", "image.adjustments.selectiveColor", "image.adjustments.desaturate", "filter.blur.gaussianBlur"] {
|
||||
for id in ["image.adjustments.invert", "image.adjustments.desaturate", "filter.blur.gaussianBlur"] {
|
||||
assert!(!has_dialog(id), "{id}");
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ fn sample_params(kind: &str) -> Value {
|
||||
"posterize" => json!({"levels": 3}),
|
||||
"threshold" => json!({"level": 90}),
|
||||
"gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}),
|
||||
"selectiveColor" => json!({"method": "absolute", "blues": [10, 0, -20, 0]}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
@@ -967,3 +968,354 @@ fn captured_filter_mask_curves_stay_gray_after_selecting_native_channels() {
|
||||
assert!(add_point_at(&mut app, &egui::Context::default(), 8.0, 8.0, false));
|
||||
assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(id).unwrap().fields), before);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Selective Color: one editor for the dialog and the Properties panel. The Colors dropdown is
|
||||
// driven here through the dialog's `colors` field (what it writes, and what `ui.dialog.set`
|
||||
// sets), not by opening the list.
|
||||
|
||||
const SELECTIVE: &str = "image.adjustments.selectiveColor";
|
||||
|
||||
/// A red left half and a blue right half, so each colour range has pixels to change.
|
||||
fn selective_app() -> PhotocraftApp {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#c03020"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 32, "y": 0, "width": 32, "height": 48})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#2030c0"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
}
|
||||
|
||||
fn selective_dialog() -> (Harness<'static, PhotocraftApp>, u64) {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1200.0, 900.0))
|
||||
.with_step_dt(1.0 / 60.0)
|
||||
.build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), selective_app());
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let id = open(h.state_mut(), SELECTIVE).unwrap();
|
||||
h.run_steps(3);
|
||||
(h, id)
|
||||
}
|
||||
|
||||
fn set_field(h: &mut Harness<'static, PhotocraftApp>, id: u64, key: &str, value: Value) {
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(key.into(), value);
|
||||
}
|
||||
|
||||
/// What the Cyan, Magenta, Yellow and Black sliders show.
|
||||
fn slider_values(h: &Harness<'static, PhotocraftApp>) -> Vec<f64> {
|
||||
h.query_all_by_role(egui::accesskit::Role::Slider).filter_map(|n| n.accesskit_node().numeric_value()).collect()
|
||||
}
|
||||
|
||||
/// Press the `index`th slider in its middle and drag past its right (or left) end.
|
||||
fn drag_slider(h: &mut Harness<'static, PhotocraftApp>, index: usize, right: bool) {
|
||||
let r = h.query_all_by_role(egui::accesskit::Role::Slider).nth(index).expect("slider").rect();
|
||||
let from = r.center();
|
||||
let to = egui::pos2(if right { r.right() + 40.0 } else { r.left() - 40.0 }, from.y);
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.hover_at(from + (to - from) * (i as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn pixel(app: &PhotocraftApp, x: usize, y: usize) -> [f32; 4] {
|
||||
let st = app.session.active().unwrap();
|
||||
photocraft_compose::render(&st.doc, st.doc.bounds()).px[y * 64 + x]
|
||||
}
|
||||
|
||||
fn row_is_zero(v: &Value) -> bool {
|
||||
v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|x| x.as_f64() == Some(0.0)))
|
||||
}
|
||||
|
||||
/// The reported bug (#11463), through the real dropdown: choosing another colour shows that
|
||||
/// colour's own values instead of carrying the sliders over, and keeps the first colour's edit.
|
||||
#[test]
|
||||
fn selective_color_colors_dropdown_shows_each_colours_own_values() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
set_field(&mut h, id, "reds", json!([30.0, 0.0, 0.0, 0.0]));
|
||||
h.run_steps(3);
|
||||
assert_eq!(slider_values(&h), [30.0, 0.0, 0.0, 0.0]);
|
||||
let named = |n: &egui_kittest::Node<'_>| {
|
||||
let a = n.accesskit_node();
|
||||
a.value().as_deref() == Some("Reds") || a.label().as_deref() == Some("Reds")
|
||||
};
|
||||
let at = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| named(n)).expect("the Colors dropdown").rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(3);
|
||||
let blues = h.query_by_label("Blues").expect("the list opened on the press").rect().center();
|
||||
for k in 1..=6 {
|
||||
h.hover_at(at + (blues - at) * (k as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(blues);
|
||||
h.run_steps(3);
|
||||
assert_eq!(field(&h, id, "colors"), json!("blues"));
|
||||
assert_eq!(slider_values(&h), [0.0; 4], "Blues shows its own row");
|
||||
assert_eq!(field(&h, id, "reds"), json!([30.0, 0.0, 0.0, 0.0]), "Reds keeps its edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_dialog_edits_one_colour_at_a_time() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
assert_eq!(slider_values(&h), [0.0; 4]);
|
||||
assert_eq!(field(&h, id, "colors"), Value::Null, "opens on Reds");
|
||||
drag_slider(&mut h, 0, true);
|
||||
let reds = field(&h, id, "reds");
|
||||
let red_cyan = reds[0].as_f64().unwrap();
|
||||
assert!(red_cyan > 0.0, "{reds}");
|
||||
assert_eq!(slider_values(&h)[0], red_cyan);
|
||||
// Another colour: its own row, still at 0; Reds keep theirs.
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(slider_values(&h), [0.0; 4], "the sliders show the Blues row");
|
||||
assert_eq!(field(&h, id, "reds"), reds);
|
||||
assert_eq!(field(&h, id, "colors"), json!("blues"), "the editor keeps the colour field");
|
||||
drag_slider(&mut h, 0, false);
|
||||
assert!(field(&h, id, "blues")[0].as_f64().unwrap() < 0.0);
|
||||
assert_eq!(field(&h, id, "reds"), reds, "editing Blues leaves Reds alone");
|
||||
// Back to Reds: intact.
|
||||
set_field(&mut h, id, "colors", json!("reds"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(slider_values(&h)[0], red_cyan);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing is committed before OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_dialog_ok_applies_every_colour_in_one_step() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
let (before_red, before_blue) = (pixel(h.state(), 8, 8), pixel(h.state(), 56, 8));
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
set_field(&mut h, id, "method", json!("absolute"));
|
||||
set_field(&mut h, id, "reds", json!([0.0, 0.0, 0.0, 60.0]));
|
||||
set_field(&mut h, id, "blues", json!([0.0, 0.0, 0.0, 60.0]));
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
h.run_steps(3);
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
let app = h.state();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "one history step");
|
||||
let (cmd, params) = app.session.journal.last().unwrap();
|
||||
assert_eq!(cmd, SELECTIVE);
|
||||
for key in photocraft_engine::adjust_cmds::RANGES {
|
||||
assert!(params[key].is_array(), "{key} in {params}");
|
||||
}
|
||||
assert_eq!(params["reds"][3].as_f64(), Some(60.0));
|
||||
assert_eq!(params["blues"][3].as_f64(), Some(60.0));
|
||||
assert_ne!(pixel(app, 8, 8), before_red, "Reds changed");
|
||||
assert_ne!(pixel(app, 56, 8), before_blue, "Blues changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_dialog_cancel_leaves_the_document_untouched() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
let before = h.state().session.active().unwrap().doc.clone();
|
||||
drag_slider(&mut h, 0, true);
|
||||
set_field(&mut h, id, "blues", json!([0.0, 0.0, 0.0, 60.0]));
|
||||
h.run_steps(2);
|
||||
h.state_mut().ui.close_dialog(id);
|
||||
h.run_steps(2);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_reset_zeroes_every_colour_and_returns_to_reds() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
set_field(&mut h, id, "reds", json!([10.0, 0.0, 0.0, 0.0]));
|
||||
set_field(&mut h, id, "blues", json!([0.0, 20.0, 0.0, 0.0]));
|
||||
set_field(&mut h, id, "method", json!("absolute"));
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
h.run_steps(2);
|
||||
// Alt turns Cancel into Reset; click the real button.
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
|
||||
h.run_steps(2);
|
||||
let reset = h.get_by_label("Reset").rect();
|
||||
let click = |pressed| egui::Event::PointerButton { pos: reset.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT };
|
||||
input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(reset.center()), click(true), click(false)]);
|
||||
assert!(h.state().ui.dialogs.iter().any(|d| d.id == id), "Reset keeps the dialog open");
|
||||
assert_eq!(field(&h, id, "colors"), Value::Null, "back to Reds");
|
||||
for key in photocraft_engine::adjust_cmds::RANGES {
|
||||
assert!(row_is_zero(&field(&h, id, key)), "{key}: {}", field(&h, id, key));
|
||||
}
|
||||
assert_eq!(field(&h, id, "method"), json!("relative"));
|
||||
assert_eq!(slider_values(&h), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_ok_on_malformed_params_reports_and_commits_nothing() {
|
||||
let cases = [
|
||||
json!({"reds": "oops"}),
|
||||
json!({"blues": [1, "x", 0, 0]}),
|
||||
json!({"colors": "blues", "cyan": "not a number"}),
|
||||
json!({"colors": "purples"}),
|
||||
json!({"method": "sideways"}),
|
||||
];
|
||||
for case in cases {
|
||||
let (mut h, id) = selective_dialog();
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let journal = h.state().session.journal.len();
|
||||
for (k, v) in case.as_object().unwrap() {
|
||||
set_field(&mut h, id, k, v.clone());
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(crate::dialogs::confirm(h.state_mut(), id).is_err(), "{case}: OK reports an error");
|
||||
let app = h.state();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps, "{case}: no history step");
|
||||
assert_eq!(app.session.journal.len(), journal, "{case}: nothing journaled");
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "{case}: document untouched");
|
||||
}
|
||||
}
|
||||
|
||||
fn click_radio(h: &mut Harness<'static, PhotocraftApp>, label: &str) {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, label).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_method_radios_set_the_method() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
click_radio(&mut h, "Absolute");
|
||||
assert_eq!(field(&h, id, "method"), json!("absolute"));
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "nothing is committed before OK");
|
||||
click_radio(&mut h, "Relative");
|
||||
assert_eq!(field(&h, id, "method"), json!("relative"));
|
||||
click_radio(&mut h, "Absolute");
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
let app = h.state();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
|
||||
let (_, params) = app.session.journal.last().unwrap();
|
||||
assert_eq!(params["method"], json!("absolute"));
|
||||
}
|
||||
|
||||
/// The layer preview shows every colour's row (not just the one the sliders show) until the
|
||||
/// dialog closes, and never touches the committed document.
|
||||
#[test]
|
||||
fn selective_color_preview_shows_every_colour_until_cancel() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let (before_red, before_blue) = (pixel(h.state(), 8, 8), pixel(h.state(), 56, 8));
|
||||
set_field(&mut h, id, "method", json!("absolute"));
|
||||
set_field(&mut h, id, "reds", json!([0.0, 0.0, 0.0, 60.0]));
|
||||
set_field(&mut h, id, "blues", json!([0.0, 0.0, 0.0, 60.0]));
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
h.run_steps(3);
|
||||
let app = h.state_mut();
|
||||
assert!(crate::adjust_preview::on_layer(app, 0), "previews through the layer");
|
||||
let (shown, _) = crate::adjust_preview::display_doc(app, 0).expect("a preview document");
|
||||
let shown = photocraft_compose::render(&shown, shown.bounds());
|
||||
assert_ne!(shown.px[8 * 64 + 8], before_red, "Reds shown");
|
||||
assert_ne!(shown.px[8 * 64 + 56], before_blue, "Blues shown");
|
||||
assert_eq!(pixel(app, 8, 8), before_red, "the committed document is unchanged");
|
||||
assert_eq!(pixel(app, 56, 8), before_blue);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc));
|
||||
h.state_mut().ui.close_dialog(id);
|
||||
h.run_steps(2);
|
||||
let app = h.state_mut();
|
||||
assert!(crate::adjust_preview::display_doc(app, 0).is_none(), "closing the dialog drops the preview");
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc));
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_dialog_folds_the_single_colour_form_into_the_rows() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
set_field(&mut h, id, "cyan", json!(30));
|
||||
h.run_steps(3);
|
||||
for key in ["cyan", "magenta", "yellow", "black"] {
|
||||
assert_eq!(field(&h, id, key), Value::Null, "{key} folded");
|
||||
}
|
||||
assert_eq!(field(&h, id, "blues")[0].as_f64(), Some(30.0));
|
||||
assert_eq!(field(&h, id, "colors"), json!("blues"));
|
||||
assert_eq!(slider_values(&h)[0], 30.0);
|
||||
// A later slider edit is not overridden by the old scalars.
|
||||
drag_slider(&mut h, 0, false);
|
||||
assert!(field(&h, id, "blues")[0].as_f64().unwrap() < 30.0);
|
||||
}
|
||||
|
||||
/// `relative` overrides `method` in the engine, so a dialog holding both would show one and apply
|
||||
/// the other. The editor keeps only `method`, and the edits made so far.
|
||||
#[test]
|
||||
fn selective_color_dialog_folds_relative_into_the_method() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
set_field(&mut h, id, "reds", json!([10.0, 0.0, 0.0, 0.0]));
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
set_field(&mut h, id, "method", json!("relative"));
|
||||
set_field(&mut h, id, "relative", json!(false));
|
||||
h.run_steps(3);
|
||||
assert_eq!(field(&h, id, "relative"), Value::Null, "folded");
|
||||
assert_eq!(field(&h, id, "method"), json!("absolute"));
|
||||
assert_eq!(field(&h, id, "reds")[0].as_f64(), Some(10.0));
|
||||
assert_eq!(field(&h, id, "colors"), json!("blues"));
|
||||
// The Method button now decides what OK applies.
|
||||
set_field(&mut h, id, "method", json!("relative"));
|
||||
h.run_steps(2);
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
let (_, params) = h.state().session.journal.last().unwrap();
|
||||
assert_eq!(params["method"], json!("relative"));
|
||||
assert!(params.get("relative").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_open_last_restores_every_row_and_the_method() {
|
||||
let mut app = selective_app();
|
||||
let id = open(&mut app, SELECTIVE).unwrap();
|
||||
let dialog = app.ui.dialog_mut(id).unwrap();
|
||||
dialog.fields.insert("method".into(), json!("absolute"));
|
||||
dialog.fields.insert("reds".into(), json!([10.0, -20.0, 30.0, 5.0]));
|
||||
dialog.fields.insert("blues".into(), json!([-15.0, 0.0, 25.0, 0.0]));
|
||||
dialog.fields.insert("colors".into(), json!("blues"));
|
||||
let fields = dialog.fields.clone();
|
||||
remember(&mut app, &fields);
|
||||
app.ui.dialogs.clear();
|
||||
let id = open_last(&mut app, SELECTIVE).unwrap();
|
||||
let restored = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!(restored["method"], json!("absolute"));
|
||||
assert_eq!(restored["reds"], json!([10.0, -20.0, 30.0, 5.0]));
|
||||
assert_eq!(restored["blues"], json!([-15.0, 0.0, 25.0, 0.0]));
|
||||
assert!(!restored.contains_key("colors"), "reopens on Reds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_open_last_reads_an_old_single_colour_pref() {
|
||||
let mut app = selective_app();
|
||||
app.session.prefs.edit(|prefs| prefs.dialogs.insert(SELECTIVE.into(), json!({"colors": "blues", "cyan": 30})));
|
||||
let id = open_last(&mut app, SELECTIVE).unwrap();
|
||||
let restored = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!(restored["blues"][0].as_f64(), Some(30.0));
|
||||
assert!(!restored.contains_key("cyan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_editor_survives_malformed_params() {
|
||||
let (mut h, id) = selective_dialog();
|
||||
for colors in [json!(3), json!("purples"), json!(null), json!(["reds"])] {
|
||||
set_field(&mut h, id, "colors", colors);
|
||||
h.run_steps(2);
|
||||
assert_eq!(slider_values(&h), [0.0; 4]);
|
||||
}
|
||||
set_field(&mut h, id, "cyan", json!("not a number"));
|
||||
h.run_steps(2);
|
||||
set_field(&mut h, id, "cyan", Value::Null);
|
||||
set_field(&mut h, id, "reds", json!("oops"));
|
||||
h.run_steps(2);
|
||||
set_field(&mut h, id, "blues", json!([1, "x"]));
|
||||
set_field(&mut h, id, "colors", json!("blues"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(slider_values(&h), [1.0, 0.0, 0.0, 0.0], "a malformed row reads as zeros where it is not numbers");
|
||||
drag_slider(&mut h, 1, true);
|
||||
let row = field(&h, id, "blues");
|
||||
assert!(row.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(Value::is_number)), "rebuilt as four numbers: {row}");
|
||||
assert!(row[1].as_f64().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Every editor edits the kind's complete parameter set (`photocraft_engine::adjust_params`) as a
|
||||
//! JSON object and reports an [`Edit`]: `changed` while values move (the host previews them live)
|
||||
//! and `commit` when a gesture ends (the Properties host then runs one `layer.setAdjustment`; a
|
||||
//! dialog commits on OK). Selective Color and Color Lookup keep their editors in `adjust_ui`.
|
||||
//! dialog commits on OK). Color Lookup keeps its editor in `adjust_ui`.
|
||||
|
||||
use crate::point_curve as curve_edit;
|
||||
use crate::state::CurvesEditorState as CurveUi;
|
||||
@@ -28,6 +28,7 @@ mod curves;
|
||||
mod gradient_map;
|
||||
mod hue_saturation;
|
||||
mod levels;
|
||||
mod selective_color;
|
||||
mod session;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -48,10 +49,12 @@ use hue_saturation::hue_saturation;
|
||||
use hue_saturation::{lightness_track, move_hue_range_handle, saturation_track};
|
||||
use levels::levels;
|
||||
pub use levels::{auto_levels, gamma_from_pos, gamma_pos};
|
||||
pub(crate) use selective_color::clear_folded_params;
|
||||
use selective_color::selective_color;
|
||||
pub use session::{layer_editor, layer_mem, properties_coalesce, properties_reset, session_start};
|
||||
|
||||
/// Kinds edited here (Selective Color and Color Lookup have their own editors; Invert has none).
|
||||
pub const KINDS: [&str; 13] = [
|
||||
/// Kinds edited here (Color Lookup has its own editor in `adjust_ui`; Invert has none).
|
||||
pub const KINDS: [&str; 14] = [
|
||||
"brightnessContrast",
|
||||
"levels",
|
||||
"curves",
|
||||
@@ -65,6 +68,7 @@ pub const KINDS: [&str; 13] = [
|
||||
"posterize",
|
||||
"threshold",
|
||||
"gradientMap",
|
||||
"selectiveColor",
|
||||
];
|
||||
|
||||
pub fn has_editor(kind: &str) -> bool {
|
||||
@@ -161,6 +165,7 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed
|
||||
"posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "", None)]),
|
||||
"threshold" => threshold(ui, v, cx),
|
||||
"gradientMap" => gradient_map(ui, v, cx),
|
||||
"selectiveColor" => selective_color(ui, v, cx),
|
||||
_ => Edit::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use photocraft_engine::adjust_cmds::RANGES;
|
||||
|
||||
use super::{Edit, EditorCx, RichText, Tokens, Value, adjust_params, json, nums, widgets};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Selective Color
|
||||
|
||||
/// The nine colours, in [`RANGES`] order.
|
||||
const RANGE_LABELS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"];
|
||||
|
||||
/// The single-colour scalar form of the params (`"colors":"blues","cyan":30`), which this editor
|
||||
/// folds into the per-colour rows.
|
||||
const SCALARS: [&str; 4] = ["cyan", "magenta", "yellow", "black"];
|
||||
|
||||
/// For an edit that merges into stored params (`layer.smartFilter.setParams`): nulls for the keys
|
||||
/// the editor folded away, so stored copies stop overriding the rows and Method. The engine reads
|
||||
/// a null as missing. Keys still present keep their values.
|
||||
pub(crate) fn clear_folded_params(params: &mut Value) {
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
for key in SCALARS.into_iter().chain(["relative"]) {
|
||||
o.entry(key).or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour the editor shows. The dialog keeps it in the `"colors"` field (so automation can
|
||||
/// drive it with `ui.dialog.set`); the Properties panel keeps it in egui memory, because its
|
||||
/// params go to `layer.setAdjustment`.
|
||||
fn selected_range(ui: &egui::Ui, v: &Value, cx: &EditorCx) -> usize {
|
||||
let range = if cx.dialog {
|
||||
v.get("colors").and_then(Value::as_str).and_then(|c| RANGES.iter().position(|r| *r == c)).unwrap_or(0)
|
||||
} else {
|
||||
ui.data(|d| d.get_temp::<usize>(cx.mem.with("selc-range"))).unwrap_or(0)
|
||||
};
|
||||
range.min(RANGES.len() - 1)
|
||||
}
|
||||
|
||||
/// Selective Color: a colour dropdown, the Cyan/Magenta/Yellow/Black sliders of that colour's row,
|
||||
/// and Relative/Absolute. Changing the colour is no edit of the values.
|
||||
pub(super) fn selective_color(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut e = Edit::default();
|
||||
// Params in the older single-colour form move into the rows, and `relative`, which the engine
|
||||
// reads over `method`, into `method`, so the controls show what OK applies. Unparsable ones
|
||||
// stay as they are, for the command to report on OK.
|
||||
if (SCALARS.iter().any(|k| v.get(*k).is_some()) || v.get("relative").is_some())
|
||||
&& let Ok(adj) = adjust_params::from_params("selectiveColor", &*v, None, photocraft_doc::ColorMode::Rgb)
|
||||
{
|
||||
let mut folded = adjust_params::to_params(&adj);
|
||||
if cx.dialog
|
||||
&& let Some(colors) = v.get("colors").cloned()
|
||||
&& let Some(o) = folded.as_object_mut()
|
||||
{
|
||||
o.insert("colors".into(), colors);
|
||||
}
|
||||
*v = folded;
|
||||
e.changed = true;
|
||||
}
|
||||
let range = selected_range(ui, v, cx);
|
||||
let mut picked = range;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!("Colors")).color(t.text_dim));
|
||||
let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect();
|
||||
widgets::dropdown(ui, &format!("selc-colors-{:x}", cx.mem.value()), tl!("Color"), &mut picked, &opts, 150.0);
|
||||
});
|
||||
if picked != range {
|
||||
let picked = picked.min(RANGES.len() - 1);
|
||||
if cx.dialog {
|
||||
if let (Some(o), Some(name)) = (v.as_object_mut(), RANGES.get(picked)) {
|
||||
o.insert("colors".into(), json!(name));
|
||||
e.changed = true;
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(cx.mem.with("selc-range"), picked));
|
||||
}
|
||||
}
|
||||
let range = picked.min(RANGES.len() - 1);
|
||||
let key = RANGES.get(range).copied().unwrap_or("reds");
|
||||
ui.add_space(4.0);
|
||||
let mut row = nums(v, key, [0.0; 4]);
|
||||
let mut moved = false;
|
||||
for (k, text) in [tl!("Cyan"), tl!("Magenta"), tl!("Yellow"), tl!("Black")].into_iter().enumerate() {
|
||||
let mut x = row.get(k).copied().unwrap_or(0.0).clamp(-100.0, 100.0);
|
||||
let r = widgets::slider_row(ui, text, &mut x, -100.0..=100.0, "%", None);
|
||||
if r.changed()
|
||||
&& let Some(slot) = row.get_mut(k)
|
||||
{
|
||||
*slot = x.round();
|
||||
moved = true;
|
||||
}
|
||||
e.add(Edit::of(&r));
|
||||
}
|
||||
if moved && let Some(o) = v.as_object_mut() {
|
||||
// A whole new row, so a missing or malformed one becomes four numbers.
|
||||
o.insert(key.into(), json!(row));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("relative").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
for (m, text) in [("relative", tl!("Relative")), ("absolute", tl!("Absolute"))] {
|
||||
if ui.radio(method == m, text).clicked()
|
||||
&& method != m
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.insert("method".into(), json!(m));
|
||||
e.add(Edit::discrete(true));
|
||||
}
|
||||
}
|
||||
});
|
||||
e
|
||||
}
|
||||
@@ -85,7 +85,6 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let kind = photocraft_engine::commands::adjustment_kind(adj);
|
||||
match adj {
|
||||
Adjustment::SelectiveColor { .. } => return crate::adjust_ui::selective_color_editor(app, ui, id, adj),
|
||||
Adjustment::ColorLookup { .. } => return crate::adjust_ui::color_lookup_editor(app, ui, id, adj),
|
||||
Adjustment::Invert => {
|
||||
ui.label(RichText::new(tl!("Invert has no settings.")).color(t.text_faint));
|
||||
|
||||
@@ -995,3 +995,75 @@ fn gradient_map_stop_picker_closed_without_a_colour_changes_nothing() {
|
||||
assert!(h.state().live_adjust.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Selective Color in Properties: the Colors dropdown's choice lives in editor memory, so
|
||||
/// switching colour commits nothing, and each colour keeps its own row.
|
||||
#[test]
|
||||
fn selective_color_properties_switch_colour_without_committing() {
|
||||
let mut h = app_harness("selectiveColor", "rgb", 8);
|
||||
let (_, before) = layer(&h);
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let nudge = |h: &mut Harness<'static, PhotocraftApp>| {
|
||||
let r =
|
||||
h.query_all_by_role(egui::accesskit::Role::Slider).rfind(|n| n.accesskit_node().label().as_deref() == Some("Cyan")).expect("Cyan slider").rect();
|
||||
drag(h, r.center(), Pos2::new(r.right() + 40.0, r.center().y));
|
||||
};
|
||||
nudge(&mut h);
|
||||
let (_, reds) = layer(&h);
|
||||
assert_ne!(reds, before);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
|
||||
// Switch to Blues through the Colors dropdown: press it, drag onto the option, release.
|
||||
let named = |n: &egui_kittest::Node<'_>| {
|
||||
let a = n.accesskit_node();
|
||||
a.value().as_deref() == Some("Reds") || a.label().as_deref() == Some("Reds")
|
||||
};
|
||||
let at = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| named(n)).expect("the Colors dropdown").rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(3);
|
||||
let blues = h.query_by_label("Blues").expect("the list opened on the press").rect().center();
|
||||
for k in 1..=6 {
|
||||
h.hover_at(at + (blues - at) * (k as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(blues);
|
||||
h.run_steps(4);
|
||||
assert!(
|
||||
h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| {
|
||||
let a = n.accesskit_node();
|
||||
a.value().as_deref() == Some("Blues") || a.label().as_deref() == Some("Blues")
|
||||
}),
|
||||
"the dropdown keeps its choice"
|
||||
);
|
||||
assert_eq!(layer(&h).1, reds, "switching colour changes nothing");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
|
||||
assert!(h.state().live_adjust.is_none());
|
||||
nudge(&mut h);
|
||||
let (Adjustment::SelectiveColor { adjustments: first, .. }, (_, Adjustment::SelectiveColor { adjustments: now, .. })) = (&reds, layer(&h)) else {
|
||||
panic!("Selective Color layer")
|
||||
};
|
||||
assert_eq!(now[0], first[0], "Reds untouched by editing Blues");
|
||||
assert_ne!(now[4], [0.0; 4], "Blues edited");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 2);
|
||||
}
|
||||
|
||||
/// The Method radios in Properties commit through the real controls; the current one is a no-op.
|
||||
#[test]
|
||||
fn selective_color_properties_method_radios_commit() {
|
||||
let mut h = app_harness("selectiveColor", "rgb", 8);
|
||||
let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len();
|
||||
let start = steps(&h);
|
||||
let radio = |h: &mut Harness<'static, PhotocraftApp>, text: &str| {
|
||||
let at = h.query_all_by_role_and_label(egui::accesskit::Role::RadioButton, text).last().expect("method radio").rect().center();
|
||||
click(h, at);
|
||||
};
|
||||
radio(&mut h, "Absolute");
|
||||
assert!(matches!(layer(&h).1, Adjustment::SelectiveColor { relative: false, .. }));
|
||||
assert_eq!(steps(&h), start + 1);
|
||||
radio(&mut h, "Absolute");
|
||||
assert_eq!(steps(&h), start + 1, "the current method is no step");
|
||||
radio(&mut h, "Relative");
|
||||
assert!(matches!(layer(&h).1, Adjustment::SelectiveColor { relative: true, .. }));
|
||||
assert_eq!(steps(&h), start + 2);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
//! RGB, adjusts and converts back (CMYK even changes when the settings are neutral), which a
|
||||
//! composite in display RGB can't reproduce;
|
||||
//! - in Grayscale documents, kinds that make colour from gray (Vibrance, Color Balance, Black &
|
||||
//! White, Photo Filter, Channel Mixer, Gradient Map, colorized Hue/Saturation): the command
|
||||
//! folds each pixel back to gray, the composite would carry the colour through;
|
||||
//! White, Photo Filter, Channel Mixer, Gradient Map, Selective Color, colorized Hue/Saturation):
|
||||
//! the command folds each pixel back to gray, the composite would carry the colour through;
|
||||
//! - on the GPU canvas, documents the wgpu compositor doesn't cover (it would fall back to a
|
||||
//! full CPU composite per change); on the CPU canvas, documents above the proxy size.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use photocraft_color::{BlendMode, Color, PixelFormat, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
use serde_json::json;
|
||||
|
||||
const KINDS: [&str; 13] = crate::adjust_editors::KINDS;
|
||||
const KINDS: [&str; 14] = crate::adjust_editors::KINDS;
|
||||
|
||||
/// Settings that move every kind well away from neutral.
|
||||
pub(crate) fn sample(kind: &str) -> Value {
|
||||
@@ -21,6 +21,7 @@ pub(crate) fn sample(kind: &str) -> Value {
|
||||
"posterize" => json!({"levels": 3}),
|
||||
"threshold" => json!({"level": 90}),
|
||||
"gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}),
|
||||
"selectiveColor" => json!({"method": "absolute", "reds": [10, -20, 30, 5], "blues": [-15, 0, 25, 0]}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
@@ -105,7 +106,7 @@ pub(crate) fn max_diff(a: &Document, b: &Document) -> f32 {
|
||||
}
|
||||
|
||||
/// Kinds that make colour from gray: grayscale documents preview them on the CPU proxy.
|
||||
const GRAY_COLOUR: [&str; 6] = ["vibrance", "colorBalance", "blackWhite", "photoFilter", "channelMixer", "gradientMap"];
|
||||
const GRAY_COLOUR: [&str; 7] = ["vibrance", "colorBalance", "blackWhite", "photoFilter", "channelMixer", "gradientMap", "selectiveColor"];
|
||||
|
||||
#[test]
|
||||
fn preview_composite_matches_the_destructive_command() {
|
||||
|
||||
@@ -306,7 +306,6 @@ pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 {
|
||||
|
||||
/// Commands outside `filter.*` that get the schema dialog *with* live preview.
|
||||
pub const PREVIEWED: &[&str] = &[
|
||||
"image.adjustments.selectiveColor",
|
||||
"image.adjustments.colorLookup",
|
||||
"image.adjustments.shadowsHighlights",
|
||||
"image.adjustments.replaceColor",
|
||||
|
||||
@@ -905,20 +905,6 @@ fn notes_after_a_spec_are_not_parameters() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_dialog_ok_applies() {
|
||||
// With the junk `reds: 0` the dialog's OK failed (`reds` must be an array of 4 numbers).
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let id = open(&mut app, "image.adjustments.selectiveColor").unwrap();
|
||||
let fields = app.ui.dialogs.last().unwrap().fields.clone();
|
||||
assert!(fields.keys().all(|k| k.starts_with("__") || k.chars().all(|ch| ch.is_ascii_alphanumeric())), "{fields:?}");
|
||||
assert!(!fields.contains_key("reds"), "the per-range arrays are not dialog fields");
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("black".into(), json!(40.0));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(app.session.journal.last().map(|j| j.0.as_str()), Some("image.adjustments.selectiveColor"));
|
||||
}
|
||||
|
||||
/// A filtered test image with structure at every scale (low-frequency waves plus a ramp).
|
||||
fn waves(n: u32) -> Document {
|
||||
let mut doc = Document::with_background(
|
||||
|
||||
@@ -191,9 +191,12 @@ impl LiquifyOpts {
|
||||
if let Some(t) = v.get("tool") {
|
||||
let tool: LiquifyTool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?;
|
||||
// Strokes can twirl counterclockwise, but no tool does: hold Alt with Twirl Clockwise.
|
||||
if !LiquifyTool::ALL.contains(&tool) {
|
||||
if tool == LiquifyTool::TwirlCcw {
|
||||
return Err(format!("{t} is not a Liquify tool: choose twirlCw and hold Alt (Option) to twirl counterclockwise"));
|
||||
}
|
||||
if !LiquifyTool::ALL.contains(&tool) {
|
||||
return Err(format!("{t} is not a Liquify tool"));
|
||||
}
|
||||
self.tool = tool;
|
||||
}
|
||||
Ok(())
|
||||
@@ -656,7 +659,12 @@ fn open_on(
|
||||
// Liquify opens as it was left (#418). Saved values are clamped; a bad one is skipped.
|
||||
d.opts.size = ((canvas.width().max(canvas.height()) as f32) / 10.0).round().clamp(10.0, 1500.0);
|
||||
if let Some(saved) = app.session.prefs().dialogs.get(REMEMBERED) {
|
||||
let _ = d.opts.apply(saved);
|
||||
// Older versions had a Twirl Counterclockwise tool; its saved choice is Twirl Clockwise now.
|
||||
let mut saved = saved.clone();
|
||||
if saved.get("tool").and_then(Value::as_str) == Some("twirlCcw") {
|
||||
saved["tool"] = json!("twirlCw");
|
||||
}
|
||||
let _ = d.opts.apply(&saved);
|
||||
}
|
||||
// Layer ids belong to one document: a remembered backdrop Use starts at All Layers.
|
||||
d.opts.backdrop_layer = None;
|
||||
|
||||
@@ -199,8 +199,9 @@ fn brush_settings_are_remembered_between_uses() {
|
||||
}
|
||||
|
||||
/// The reference application's Liquify has Twirl Clockwise only: no Twirl Counterclockwise
|
||||
/// tool on the strip or a shortcut, and a script or saved setting naming it is refused with a
|
||||
/// clear error instead of selecting it (Alt with Twirl Clockwise twirls counterclockwise).
|
||||
/// tool on the strip or a shortcut. A script naming it is refused with a clear error (Alt with
|
||||
/// Twirl Clockwise twirls counterclockwise); a setting saved by an older version opens on Twirl
|
||||
/// Clockwise.
|
||||
#[test]
|
||||
fn the_tool_strip_has_no_twirl_counterclockwise() {
|
||||
assert!(!LiquifyTool::ALL.contains(&LiquifyTool::TwirlCcw));
|
||||
@@ -218,12 +219,18 @@ fn the_tool_strip_has_no_twirl_counterclockwise() {
|
||||
let err = control(&mut app, &json!({"tool": "twirlCcw"})).unwrap_err();
|
||||
assert!(err.contains("twirlCw") && err.contains("Alt"), "{err}");
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays");
|
||||
// A preference saved by an older version that names it opens, and on another tool.
|
||||
// Actions that are not tools are refused too, without the Alt hint that fits twirlCcw only.
|
||||
for action in ["reconstructAll", "freezeAll", "thawAll", "invertFreeze"] {
|
||||
let err = control(&mut app, &json!({ "tool": action })).unwrap_err();
|
||||
assert!(err.contains("not a Liquify tool") && !err.contains("Alt"), "{action}: {err}");
|
||||
}
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays");
|
||||
// A preference saved by an older version that names it opens on Twirl Clockwise.
|
||||
cancel(&mut app);
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"tool": "twirlCcw", "density": 70})));
|
||||
open(&mut app, &ctx).unwrap();
|
||||
let o = &app.distort.liquify.as_ref().unwrap().opts;
|
||||
assert_ne!(o.tool, LiquifyTool::TwirlCcw);
|
||||
assert_eq!(o.tool, LiquifyTool::TwirlCw, "the saved twirl keeps its tool");
|
||||
assert_eq!(o.density, 70.0, "the rest of the saved settings still apply");
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,11 @@ pub(crate) fn edit_command(app: &PhotocraftApp, fields: &Map<String, Value>) ->
|
||||
}
|
||||
Ok((COMMAND.into(), params))
|
||||
} else {
|
||||
Ok(("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "index": index, "params": crate::filter_dialog::params_of(fields)})))
|
||||
let mut params = crate::filter_dialog::params_of(fields);
|
||||
if fields.get("__adjust").and_then(Value::as_str) == Some("selectiveColor") {
|
||||
crate::adjust_editors::clear_folded_params(&mut params);
|
||||
}
|
||||
Ok(("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "index": index, "params": params})))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2058,7 +2058,6 @@
|
||||
Show only crosshair while painting إظهار نقطة التصويب فقط أثناء الرسم
|
||||
Show slice numbers إظهار أرقام الشرائح
|
||||
Show tooltips إظهار تلميحات الأدوات
|
||||
Enable animations تمكين الحركات
|
||||
Show highlight on rollover إظهار التمييز عند التمرير
|
||||
Show bounding box when dragging layer إظهار مربع الإحاطة عند سحب الطبقة
|
||||
System title bar شريط عنوان النظام
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2173,7 +2173,6 @@
|
||||
Show only crosshair while painting Při malování zobrazit jen nitkový kříž
|
||||
Show slice numbers Zobrazit čísla řezů
|
||||
Show tooltips Zobrazit popisky nástrojů
|
||||
Enable animations Povolit animace
|
||||
Show highlight on rollover Zobrazit zvýraznění při přejetí
|
||||
Show bounding box when dragging layer Při přetahování vrstvy zobrazit ohraničovací rámeček
|
||||
System title bar Systémové záhlaví okna
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2478,7 +2478,6 @@
|
||||
Show only crosshair while painting Vis kun trådkors under maling
|
||||
Show slice numbers Vis udsnitsnumre
|
||||
Show tooltips Vis værktøjstips
|
||||
Enable animations Aktivér animationer
|
||||
Show highlight on rollover Fremhæv ved pegning
|
||||
Show bounding box when dragging layer Vis afgrænsningsramme, når et lag trækkes
|
||||
System title bar Systemtitellinje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2134,7 +2134,6 @@
|
||||
Show only crosshair while painting Beim Malen nur Fadenkreuz zeigen
|
||||
Show slice numbers Slice-Nummern anzeigen
|
||||
Show tooltips Tooltips anzeigen
|
||||
Enable animations Animationen aktivieren
|
||||
Show highlight on rollover Hervorhebung bei Rollover anzeigen
|
||||
Show bounding box when dragging layer Begrenzungsrahmen beim Ziehen einer Ebene anzeigen
|
||||
System title bar System-Titelleiste
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1612,7 +1612,6 @@
|
||||
Show only crosshair while painting Εμφάνιση μόνο σταυρονήματος κατά τη ζωγραφική
|
||||
Show slice numbers Εμφάνιση αριθμών τομών
|
||||
Show tooltips Εμφάνιση επεξηγήσεων
|
||||
Enable animations Ενεργοποίηση κινούμενων εφέ
|
||||
Show highlight on rollover Εμφάνιση επισήμανσης κατά την κατάδειξη
|
||||
Show bounding box when dragging layer Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση επιπέδου
|
||||
Show transformation values Εμφάνιση τιμών μετασχηματισμού
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 47 and column 53.
|
@@ -2104,7 +2104,6 @@
|
||||
Show only crosshair while painting Mostrar solo la retícula al pintar
|
||||
Show slice numbers Mostrar los números de sector
|
||||
Show tooltips Mostrar las descripciones emergentes
|
||||
Enable animations Activar las animaciones
|
||||
Show highlight on rollover Mostrar resaltado al pasar el cursor
|
||||
Show bounding box when dragging layer Mostrar el cuadro delimitador al arrastrar la capa
|
||||
System title bar Barra de título del sistema
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2479,7 +2479,6 @@
|
||||
Show only crosshair while painting Näytä vain hiusristikko maalattaessa
|
||||
Show slice numbers Näytä viipaleiden numerot
|
||||
Show tooltips Näytä työkaluvihjeet
|
||||
Enable animations Ota animaatiot käyttöön
|
||||
Show highlight on rollover Korosta kohde osoittimen alla
|
||||
Show bounding box when dragging layer Näytä rajauslaatikko tasoa vedettäessä
|
||||
System title bar Järjestelmän otsikkorivi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2110,7 +2110,6 @@
|
||||
Show only crosshair while painting N'afficher que le réticule pendant la peinture
|
||||
Show slice numbers Afficher les numéros de tranche
|
||||
Show tooltips Afficher les info-bulles
|
||||
Enable animations Activer les animations
|
||||
Show highlight on rollover Afficher la mise en surbrillance au survol
|
||||
Show transformation values Afficher les valeurs de transformation
|
||||
Slice color Couleur des tranches
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3299,7 +3299,6 @@ cameraRaw Yellow Sárga
|
||||
Ellipse Tool Ellipszis
|
||||
Elliptical Marquee Tool Ellipszis-kijelölés
|
||||
Enable Bekapcsolás
|
||||
Enable animations Animációk engedélyezése
|
||||
Enable flick panning Lendítéses pásztázás engedélyezése
|
||||
Enable floating document window docking Lebegő dokumentumablakok dokkolásának engedélyezése
|
||||
Enable narrow options bar Keskeny beállítósáv engedélyezése
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2121,7 +2121,6 @@
|
||||
Show only crosshair while painting Tampilkan crosshair saja saat melukis
|
||||
Show slice numbers Tampilkan nomor slice
|
||||
Show tooltips Tampilkan tooltip
|
||||
Enable animations Aktifkan animasi
|
||||
Show highlight on rollover Tampilkan sorotan saat kursor melintas
|
||||
Show transformation values Tampilkan nilai transformasi
|
||||
Slice color Warna slice
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2149,7 +2149,6 @@
|
||||
Show only crosshair while painting Mostra solo mirino durante pittura
|
||||
Show slice numbers Mostra numeri sezioni
|
||||
Show tooltips Mostra tooltip
|
||||
Enable animations Attiva animazioni
|
||||
Show highlight on rollover Mostra evidenziazione al passaggio del mouse
|
||||
Show bounding box when dragging layer Mostra riquadro selezione al trascinamento livello
|
||||
System title bar Barra del titolo di sistema
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 79 and column 53.
|
@@ -2160,7 +2160,6 @@
|
||||
Show only crosshair while painting 描画中は十字線のみ表示
|
||||
Show slice numbers スライス番号を表示
|
||||
Show tooltips ツールチップを表示
|
||||
Enable animations アニメーションを有効にする
|
||||
Show highlight on rollover ロールオーバー時にハイライトを表示
|
||||
Show bounding box when dragging layer レイヤーのドラッグ中にバウンディングボックスを表示
|
||||
System title bar システムのタイトルバー
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2133,7 +2133,6 @@
|
||||
Show only crosshair while painting 칠하는 동안 십자선만 표시
|
||||
Show slice numbers 분할 영역 번호 표시
|
||||
Show tooltips 도구 설명 표시
|
||||
Enable animations 애니메이션 사용
|
||||
Show highlight on rollover 롤오버 시 강조 표시
|
||||
Show transformation values 변형 값 표시
|
||||
Slice color 분할 영역 색상
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -2478,7 +2478,6 @@
|
||||
Show only crosshair while painting Vis bare trådkors under maling
|
||||
Show slice numbers Vis segmentnumre
|
||||
Show tooltips Vis verktøytips
|
||||
Enable animations Aktiver animasjoner
|
||||
Show highlight on rollover Vis utheving ved pekerberøring
|
||||
Show bounding box when dragging layer Vis avgrensningsboks når lag dras
|
||||
System title bar Systemets tittellinje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2081,7 +2081,6 @@
|
||||
Show only crosshair while painting Alleen dradenkruis tonen tijdens schilderen
|
||||
Show slice numbers Segmentnummers tonen
|
||||
Show tooltips Knopinfo tonen
|
||||
Enable animations Animaties inschakelen
|
||||
Show highlight on rollover Markering bij aanwijzen tonen
|
||||
Show bounding box when dragging layer Selectiekader tonen bij slepen van laag
|
||||
System title bar Titelbalk van systeem
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2145,7 +2145,6 @@
|
||||
Show only crosshair while painting Podczas malowania pokaż tylko celownik
|
||||
Show slice numbers Pokaż numery plasterków
|
||||
Show tooltips Pokaż podpowiedzi
|
||||
Enable animations Włącz animacje
|
||||
Show highlight on rollover Pokaż podświetlenie po najechaniu
|
||||
Show bounding box when dragging layer Pokaż ramkę ograniczającą podczas przeciągania warstwy
|
||||
Show transformation values Pokaż wartości przekształcenia
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2104,7 +2104,6 @@
|
||||
Show only crosshair while painting Mostrar apenas a mira ao pintar
|
||||
Show slice numbers Mostrar números das fatias
|
||||
Show tooltips Mostrar dicas de ferramentas
|
||||
Enable animations Ativar animações
|
||||
Show highlight on rollover Mostrar realce ao passar o mouse
|
||||
Show transformation values Mostrar valores de transformação
|
||||
Slice color Cor das fatias
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2571,7 +2571,6 @@
|
||||
Show only crosshair while painting Только перекрестье при рисовании
|
||||
Show slice numbers Показывать номера фрагментов
|
||||
Show tooltips Показывать подсказки
|
||||
Enable animations Включить анимацию
|
||||
Show highlight on rollover Показывать подсветку при наведении
|
||||
Show bounding box when dragging layer Показывать рамку при перетаскивании слоя
|
||||
System title bar Системная строка заголовка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2120,7 +2120,6 @@
|
||||
Show only crosshair while painting Prikaži samo krstić tokom slikanja
|
||||
Show slice numbers Prikaži brojeve isečaka
|
||||
Show tooltips Prikaži opise alatki
|
||||
Enable animations Omogući animacije
|
||||
Show highlight on rollover Istakni pri prelasku pokazivačem
|
||||
Show transformation values Prikaži vrednosti transformacije
|
||||
Slice color Boja isečka
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2476,7 +2476,6 @@
|
||||
Show only crosshair while painting Visa bara hårkors vid målning
|
||||
Show slice numbers Visa skivnummer
|
||||
Show tooltips Visa verktygstips
|
||||
Enable animations Aktivera animeringar
|
||||
Show highlight on rollover Visa framhävning när pekaren förs över
|
||||
Show bounding box when dragging layer Visa begränsningsram när lager dras
|
||||
System title bar Systemets namnlist
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2556,7 +2556,6 @@ cameraRaw Color Renk
|
||||
Remove All Tümünü Kaldır
|
||||
Unlock Kilidi Aç
|
||||
Enable Etkinleştir
|
||||
Enable animations Animasyonları etkinleştir
|
||||
Disable Devre dışı bırak
|
||||
On Açık
|
||||
Selected Seçilen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2537,7 +2537,6 @@
|
||||
Show only crosshair while painting Показувати лише перехрестя під час малювання
|
||||
Show slice numbers Показувати номери фрагментів
|
||||
Show tooltips Показувати підказки
|
||||
Enable animations Увімкнути анімацію
|
||||
Show highlight on rollover Показувати підсвічування під час наведення
|
||||
Show bounding box when dragging layer Показувати рамку під час перетягування шару
|
||||
System title bar Системний рядок заголовка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 152 and column 53.
|
@@ -3678,7 +3678,6 @@ cameraData Lens Ống kính
|
||||
New Document With Mask Tài liệu mới có mặt nạ
|
||||
New Layer With Mask Lớp mới có mặt nạ
|
||||
Convert Point Tool Công cụ Chuyển đổi điểm
|
||||
Enable animations Bật hiệu ứng chuyển động
|
||||
Gloss Contour Đường bao bóng
|
||||
Cone - Inverted Hình nón - Đảo ngược
|
||||
Cove - Deep Lõm - Sâu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2186,7 +2186,6 @@
|
||||
Show only crosshair while painting 绘画时仅显示十字线
|
||||
Show slice numbers 显示切片编号
|
||||
Show tooltips 显示工具提示
|
||||
Enable animations 启用动画
|
||||
Show highlight on rollover 鼠标悬停时显示高亮
|
||||
Show bounding box when dragging layer 拖动图层时显示边界框
|
||||
System title bar 系统标题栏
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 63 and column 53.
|
@@ -2154,7 +2154,6 @@
|
||||
Show only crosshair while painting 繪圖時僅顯示十字線
|
||||
Show slice numbers 顯示切片編號
|
||||
Show tooltips 顯示工具提示
|
||||
Enable animations 啟用動畫
|
||||
Show highlight on rollover 滑鼠懸停時顯示醒目提示
|
||||
Show bounding box when dragging layer 拖曳圖層時顯示邊界框
|
||||
System title bar 系統標題列
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -651,6 +651,31 @@ fn alt_scroll_zooms_when_the_modifier_state_misses_the_held_alt() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9479: like Photoshop (measured: one notch moves the view in a single frame and nothing
|
||||
/// after), a wheel notch pans the canvas the whole step at once, with no glide: plain, and
|
||||
/// sideways with ⇧ or ⌘/Ctrl.
|
||||
#[test]
|
||||
fn a_notch_pans_the_canvas_at_once_without_a_glide() {
|
||||
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
let cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
||||
for (m, axis) in [(Modifiers::NONE, 1), (Modifiers::SHIFT, 0), (ctrl, 0), (cmd, 0)] {
|
||||
let mut h = harness();
|
||||
let p = h.state().last_canvas_rect.center();
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let notch = h.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let (z, c0) = (zoom(&h), h.state().ui.views[0].center);
|
||||
wheel(&h, -1.0, m);
|
||||
h.run_steps(1);
|
||||
let c1 = h.state().ui.views[0].center;
|
||||
assert!(((c1[axis] - c0[axis]) * z - notch).abs() < 0.5, "{m:?}: the whole notch in the first frame: {c0:?} -> {c1:?} at {z}, notch {notch}");
|
||||
assert_eq!(c1[1 - axis], c0[1 - axis], "{m:?}: along one axis");
|
||||
h.run_steps(30);
|
||||
assert_eq!(h.state().ui.views[0].center, c1, "{m:?}: no glide after it");
|
||||
assert_eq!(zoom(&h), z, "{m:?}: never zooms");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_alt_arrows_kern_the_pair() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
|
||||
@@ -454,6 +454,115 @@ fn the_live_outline_matches_the_committed_selection_in_a_rotated_view() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed outlines the last frame drew as a solid white line under black dashes: the Type
|
||||
/// tools' drag box (four corners, the first repeated).
|
||||
fn white_box_outlines(h: &Harness<'static, PhotocraftApp>) -> Vec<Vec<Pos2>> {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Path(p)
|
||||
if matches!(p.stroke.color, egui::epaint::ColorMode::Solid(col) if col == egui::Color32::WHITE)
|
||||
&& p.points.len() == 5
|
||||
&& p.points.first() == p.points.last() =>
|
||||
{
|
||||
Some(p.points.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The slanted edges the last frame drew as marching ants (#7530): each is a white base segment
|
||||
/// under black dashes (`ants::shapes`); upright edges go into a pixel mesh instead. The Move tool's
|
||||
/// layer marquee in a rotated view.
|
||||
fn white_ant_edges(h: &Harness<'static, PhotocraftApp>) -> Vec<[Pos2; 2]> {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::LineSegment { points, stroke } if stroke.color == egui::Color32::WHITE => Some(*points),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every drawn corner of `outline` maps back, through the view, to the document corner of `want`
|
||||
/// ([x0, y0, x1, y1]) with the same index, clockwise from the top-left.
|
||||
fn assert_doc_box(xf: &ViewXform, outline: &[Pos2], want: [f64; 4], what: &str) {
|
||||
let corners = [[want[0], want[1]], [want[2], want[1]], [want[2], want[3]], [want[0], want[3]]];
|
||||
for (i, (p, w)) in outline.iter().zip(corners).enumerate() {
|
||||
let [x, y] = xf.to_doc(*p);
|
||||
assert!(
|
||||
(x - w[0]).abs() <= 1.0 && (y - w[1]).abs() <= 1.0,
|
||||
"{what}: corner {i} drawn at {p:?} is document ({x:.1}, {y:.1}), the box's corner is {w:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #7063: in a rotated view the Type tools' drag box previews the document-axis text box the
|
||||
/// release creates. An upright screen box through two of its corners is another shape (at 45° a
|
||||
/// dragged square previews as a sliver).
|
||||
#[test]
|
||||
fn the_type_drag_box_matches_the_committed_text_box_in_a_rotated_view() {
|
||||
for rotation in [30.0, 45.0] {
|
||||
let mut h = harness(Tool::Type);
|
||||
h.state_mut().ui.extras.snap = false;
|
||||
h.state_mut().ui.views[0].rotation = rotation;
|
||||
h.run_steps(2);
|
||||
press_at(&mut h, 50.0, 50.0, Modifiers::NONE);
|
||||
move_to(&mut h, 150.0, 90.0);
|
||||
let xf = view_xform(&h);
|
||||
let drawn = white_box_outlines(&h);
|
||||
assert_eq!(drawn.len(), 1, "{rotation}°: one text box is previewed: {drawn:?}");
|
||||
release_at(&mut h, 150.0, 90.0, Modifiers::NONE);
|
||||
// The text box the release created, in document pixels.
|
||||
let committed = {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||
let photocraft_doc::LayerContent::Text(t) = &layer.content else { panic!("{rotation}°: a type layer") };
|
||||
let photocraft_doc::text::TextShape::Box { x, y, width, height } = t.shape else { panic!("{rotation}°: paragraph text") };
|
||||
let (tx, ty) = (t.transform.m[4], t.transform.m[5]);
|
||||
[tx + f64::from(x), ty + f64::from(y), tx + f64::from(x + width), ty + f64::from(y + height)]
|
||||
};
|
||||
assert!((committed[0] - 50.0).abs() <= 1.0 && (committed[3] - 90.0).abs() <= 1.0, "{rotation}°: the drag made {committed:?}");
|
||||
assert_doc_box(&xf, &drawn[0], committed, &format!("{rotation}°: the Type preview"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #7063: in a rotated view the Move tool's layer marquee (#2844) previews the document-axis box
|
||||
/// `layer.selectInRect` gets on the release, not an upright screen box through two of its corners.
|
||||
#[test]
|
||||
fn the_move_layer_marquee_follows_the_document_axes_in_a_rotated_view() {
|
||||
for rotation in [30.0, 45.0] {
|
||||
let mut h = harness(Tool::Move);
|
||||
let app = h.state_mut();
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.tool_options.move_auto_select = true;
|
||||
app.ui.views[0].rotation = rotation;
|
||||
h.run_steps(2);
|
||||
// On the locked Background, so the drag draws the marquee (#4058).
|
||||
press_at(&mut h, 50.0, 50.0, Modifiers::NONE);
|
||||
move_to(&mut h, 150.0, 90.0);
|
||||
assert!(h.state().layer_box.is_some(), "{rotation}°: the drag draws the layer marquee");
|
||||
let xf = view_xform(&h);
|
||||
// Four slanted ant edges, each from one corner of the document box to the next. An upright
|
||||
// screen box would have none (its edges go into the ants' pixel mesh).
|
||||
let edges = white_ant_edges(&h);
|
||||
assert_eq!(edges.len(), 4, "{rotation}°: the marquee's four edges are drawn slanted: {edges:?}");
|
||||
let ends: Vec<Pos2> = edges.iter().map(|e| e[0]).collect();
|
||||
assert_doc_box(&xf, &ends, [50.0, 50.0, 150.0, 90.0], &format!("{rotation}°: the layer marquee (edge starts)"));
|
||||
let ends: Vec<Pos2> = edges.iter().map(|e| e[1]).collect();
|
||||
assert_doc_box(
|
||||
&xf,
|
||||
&ends[3..].iter().chain(&ends[..3]).copied().collect::<Vec<_>>(),
|
||||
[50.0, 50.0, 150.0, 90.0],
|
||||
&format!("{rotation}°: the layer marquee (edge ends)"),
|
||||
);
|
||||
release_at(&mut h, 150.0, 90.0, Modifiers::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
/// 800 %, snapping off, the view centred on (30, 30): the pointer lands between pixel edges.
|
||||
fn zoomed(tool: Tool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = harness(tool);
|
||||
|
||||
@@ -16,8 +16,10 @@ fn background_blocks(id: &str, has_selection: bool) -> bool {
|
||||
// Global Light and Hide All Effects are document-wide.
|
||||
return !matches!(id, "layer.layerStyle.globalLight" | "layer.layerStyle.hideAllEffects" | "layer.layerStyle.copyLayerStyle");
|
||||
}
|
||||
// Transforms need a selection on the Background (they then move the selected pixels).
|
||||
if !has_selection && (id == "edit.freeTransform" || (id.starts_with("edit.transform.") && id != "edit.transform.again")) {
|
||||
// Transforms and Content-Aware Scale need a selection on the Background (they then move the
|
||||
// selected pixels).
|
||||
if !has_selection && (matches!(id, "edit.freeTransform" | "edit.contentAwareScale") || (id.starts_with("edit.transform.") && id != "edit.transform.again"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
@@ -27,7 +29,6 @@ fn background_blocks(id: &str, has_selection: bool) -> bool {
|
||||
| "layer.mergeDown"
|
||||
| "layer.lockLayers"
|
||||
| "layer.maskAllObjects"
|
||||
| "edit.contentAwareScale"
|
||||
| "edit.puppetWarp"
|
||||
| "edit.perspectiveWarp"
|
||||
)
|
||||
@@ -218,6 +219,24 @@ mod tests {
|
||||
assert!(!disabled(&app, "edit.freeTransform") && !disabled(&app, "edit.transform.flipHorizontal"));
|
||||
}
|
||||
|
||||
/// Photoshop treats Content-Aware Scale like Free Transform: on the Background it needs a
|
||||
/// selection, and then scales the selected pixels.
|
||||
#[test]
|
||||
fn content_aware_scale_on_the_background_needs_a_selection() {
|
||||
let mut s = session();
|
||||
assert!(off(&s, "edit.contentAwareScale"));
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
assert!(!off(&s, "edit.contentAwareScale"));
|
||||
let mut app = crate::PhotocraftApp::new(s, crate::Services::default());
|
||||
assert!(!disabled(&app, "edit.contentAwareScale"));
|
||||
let r = app.session.execute("edit.contentAwareScale", json!({"scaleX": 70})).unwrap();
|
||||
assert_eq!(r["to"], json!([14, 20]));
|
||||
// A normal layer takes it without a selection.
|
||||
app.session.execute("select.deselect", json!({})).unwrap();
|
||||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(!disabled(&app, "edit.contentAwareScale"));
|
||||
}
|
||||
|
||||
/// With several layers selected, Rasterize is judged by the whole selection, not by the layer
|
||||
/// that happens to be active (or was right-clicked).
|
||||
#[test]
|
||||
|
||||
@@ -107,7 +107,11 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result<Val
|
||||
// Each view stays within its own document's zoom range (a view not drawn yet has no
|
||||
// `doc_size`, so the size comes from the document).
|
||||
let sizes: Vec<[u32; 2]> = app.session.documents().iter().map(|d| [d.doc.size.width, d.doc.size.height]).collect();
|
||||
let apply = |v: &mut crate::state::View, doc: usize| {
|
||||
// A default-mode crop turns the active view's camera; that turn is the crop's, not the
|
||||
// view's own rotation (which picking the Crop tool made 0), so it isn't copied (#9308).
|
||||
let crop = crate::crop_mode::turns_view(app);
|
||||
let rotation = if crop { 0.0 } else { src.rotation };
|
||||
let apply = |v: &mut crate::state::View, doc: usize, cropped: bool| {
|
||||
if zoom {
|
||||
v.zoom = crate::zoom_levels::clamp(src.zoom, sizes.get(doc).copied().unwrap_or(v.doc_size));
|
||||
v.request_zoom_animation(animated_zoom);
|
||||
@@ -115,14 +119,15 @@ pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result<Val
|
||||
if loc {
|
||||
v.center = src.center;
|
||||
}
|
||||
if rot {
|
||||
v.rotation = src.rotation;
|
||||
if rot && !cropped {
|
||||
v.rotation = rotation;
|
||||
}
|
||||
v.fit_pending = false;
|
||||
};
|
||||
app.ui.views.iter_mut().enumerate().for_each(|(d, v)| apply(v, d));
|
||||
app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view, w.document));
|
||||
Ok(json!({"zoom": src.zoom, "center": src.center, "rotation": src.rotation}))
|
||||
app.ui.views.iter_mut().enumerate().for_each(|(d, v)| apply(v, d, crop && d == i));
|
||||
app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view, w.document, false));
|
||||
// Match Zoom and Match Location copy no rotation: they report the camera's as it is.
|
||||
Ok(json!({"zoom": src.zoom, "center": src.center, "rotation": if rot { rotation } else { src.rotation }}))
|
||||
}
|
||||
_ => Err(format!("unknown arrangement {k}")),
|
||||
}
|
||||
|
||||
@@ -1,85 +1,18 @@
|
||||
//! Properties-panel editors for the Selective Color and Color Lookup adjustment layers.
|
||||
//! Properties-panel editor for the Color Lookup adjustment layer.
|
||||
//!
|
||||
//! Both commit through `layer.setAdjustment` (which merges these kinds' params into the current
|
||||
//! values). Selective Color previews live while a slider drags, via `app.live_adjust` carrying
|
||||
//! the full parameter set; Color Lookup commits on every change (its table isn't in the params).
|
||||
//! It commits through `layer.setAdjustment` (which merges the params into the current values) on
|
||||
//! every change, since its table isn't in the params. The other kinds, Selective Color included,
|
||||
//! are edited by `adjust_editors`.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use photocraft_doc::{Adjustment, LayerId};
|
||||
use photocraft_engine::adjust_cmds::RANGES;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
const RANGE_LABELS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"];
|
||||
|
||||
/// The full parameter set of a Selective Color adjustment (every range as `[c, m, y, k]`).
|
||||
pub fn selective_values(adj: &Adjustment) -> Value {
|
||||
let Adjustment::SelectiveColor { relative, adjustments } = adj else {
|
||||
return json!({});
|
||||
};
|
||||
let mut v = json!({"method": if *relative { "relative" } else { "absolute" }});
|
||||
for (i, key) in RANGES.iter().enumerate() {
|
||||
v[*key] = json!(adjustments[i]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn selective_color_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let committed = selective_values(adj);
|
||||
let mut values = match &app.live_adjust {
|
||||
Some((l, v)) if *l == id && v.get("reds").is_some() => v.clone(),
|
||||
_ => committed,
|
||||
};
|
||||
let key = egui::Id::unique(("selc-range", id.0));
|
||||
let mut range: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Colors")).color(t.text_dim));
|
||||
let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect();
|
||||
if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), tl!("Color"), &mut range, &opts, 150.0) {
|
||||
ui.data_mut(|d| d.insert_temp(key, range));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let mut live = false;
|
||||
let mut commit = false;
|
||||
for (k, label) in [tl!("Cyan"), tl!("Magenta"), tl!("Yellow"), tl!("Black")].iter().enumerate() {
|
||||
let mut v = values[RANGES[range]][k].as_f64().unwrap_or(0.0) as f32;
|
||||
let r = widgets::slider_row(ui, label, &mut v, -100.0..=100.0, "%", None);
|
||||
if r.changed() {
|
||||
values[RANGES[range]][k] = json!(v.round());
|
||||
live = true;
|
||||
}
|
||||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||||
commit = true;
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let mut method = values["method"].as_str().unwrap_or("relative").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
for (m, label) in [("relative", tl!("Relative")), ("absolute", tl!("Absolute"))] {
|
||||
if ui.radio(method == m, label).clicked() && method != m {
|
||||
method = m.to_string();
|
||||
values["method"] = json!(m);
|
||||
commit = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
if live {
|
||||
app.live_adjust = Some((id, values.clone()));
|
||||
}
|
||||
if commit {
|
||||
let mut p = values;
|
||||
p["layer"] = json!(id.0);
|
||||
let _ = app.run("layer.setAdjustment", p);
|
||||
app.live_adjust = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the platform for one LUT. Send embedded text through the same engine command on
|
||||
/// desktop and web, so the result doesn't depend on ambient filesystem permissions.
|
||||
fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result<Value, String> {
|
||||
|
||||
@@ -844,28 +844,45 @@ fn selecting_an_adjustment_layer_from_another_layer_targets_its_mask() {
|
||||
// The reference application: an adjustment layer picked by its name targets its mask; a
|
||||
// masked pixel layer targets its contents; the content thumbnail still targets contents.
|
||||
for command in ["layer.newAdjustmentLayer.hueSaturation", "layer.newAdjustmentLayer.curves"] {
|
||||
let (mut s, masked, other) = session();
|
||||
s.execute(command, json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
let mut h = harness(s, 0, 1.0, 360.0);
|
||||
assert!(layer(&h, id).mask.is_some(), "{command}: the adjustment layer has a mask");
|
||||
let name_of =
|
||||
|h: &Harness<'_, PhotocraftApp>, l: u64| crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == l).unwrap().name.unwrap().center();
|
||||
let at = name_of(&h, other);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
let at = name_of(&h, id);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id);
|
||||
assert!(h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a name click targets the mask");
|
||||
assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"));
|
||||
// The content thumbnail targets the contents.
|
||||
let thumb = layer_thumb_center(&h, id);
|
||||
click_with(&mut h, thumb, Modifiers::NONE);
|
||||
assert!(!h.state().ui.mask_target, "{command}: the content thumbnail targets contents");
|
||||
// A masked pixel layer picked by its name targets its contents.
|
||||
let at = name_of(&h, masked);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, masked);
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command}: a pixel layer targets its contents");
|
||||
for ppp in [1.0, 2.0] {
|
||||
let (mut s, masked, other) = session();
|
||||
s.execute(command, json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
let path = json!({"subpaths": [{"knots": [[20, 10], [120, 10], [120, 90], [20, 90]]}]});
|
||||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": path})).unwrap();
|
||||
let mut h = harness(s, 0, ppp, 360.0);
|
||||
assert!(layer(&h, id).mask.is_some(), "{command}: the adjustment layer has a mask");
|
||||
let name_of = |h: &Harness<'_, PhotocraftApp>, l: u64| {
|
||||
crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == l).unwrap().name.unwrap().center()
|
||||
};
|
||||
// Each mask thumbnail targets its own mask.
|
||||
for kind in [MaskKind::Pixel, MaskKind::Vector] {
|
||||
let at = mask_rect(&h, id, kind).center();
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().ui.mask_target, kind == MaskKind::Pixel, "{command} @{ppp}x {kind:?}");
|
||||
assert_eq!(h.state().ui.vector_mask_target, kind == MaskKind::Vector, "{command} @{ppp}x {kind:?}");
|
||||
}
|
||||
let at = name_of(&h, other);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
let at = name_of(&h, id);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, id);
|
||||
assert!(h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x: a name click targets the mask");
|
||||
assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"));
|
||||
// Fill goes to the mask and leaves the adjustment as it is.
|
||||
let content = layer(&h, id).content;
|
||||
h.state_mut().run("edit.fill", json!({"contents": "black"})).unwrap();
|
||||
assert_eq!(layer(&h, id).content, content, "{command} @{ppp}x: filling must preserve the adjustment");
|
||||
assert_eq!(layer(&h, id).mask.unwrap().surface.sample_channel(50, 50, 0), 0.0, "{command} @{ppp}x: Fill edits the mask");
|
||||
// The content thumbnail targets the contents.
|
||||
let thumb = layer_thumb_center(&h, id);
|
||||
click_with(&mut h, thumb, Modifiers::NONE);
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x: the content thumbnail targets contents");
|
||||
// A masked pixel layer picked by its name targets its contents.
|
||||
let at = name_of(&h, masked);
|
||||
click_with(&mut h, at, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, masked);
|
||||
assert!(!h.state().ui.mask_target && !h.state().ui.vector_mask_target, "{command} @{ppp}x: a pixel layer targets its contents");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +778,59 @@ mod tests {
|
||||
assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0));
|
||||
}
|
||||
|
||||
/// #11463: a Selective Color smart filter recorded in the single-colour form, re-edited. Its
|
||||
/// old `cyan` and `relative` must not survive the merge and override the edit when the filter
|
||||
/// renders and reopens.
|
||||
#[test]
|
||||
fn a_selective_color_smart_filter_in_the_old_form_keeps_its_edit() {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#2030c0"})).unwrap();
|
||||
let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("image.adjustments.selectiveColor", json!({"colors": "blues", "cyan": 30, "relative": false})).unwrap();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
app.background_jobs = false;
|
||||
let recorded = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content {
|
||||
LayerContent::Smart(sm) => sm.clone(),
|
||||
other => panic!("expected a smart object, got {other:?}"),
|
||||
};
|
||||
let sm = recorded(&app);
|
||||
assert_eq!(sm.smart_filters[0].params.get("cyan").and_then(Value::as_f64), Some(30.0), "recorded in the old form");
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let ctx = h.ctx.clone();
|
||||
open_editor(h.state_mut(), &ctx, id, &sm, 0);
|
||||
let dialog = h.state().ui.dialogs.last().unwrap().id;
|
||||
h.run_steps(3);
|
||||
let fields = &h.state_mut().ui.dialog_mut(dialog).unwrap().fields;
|
||||
assert!(!fields.contains_key("cyan") && !fields.contains_key("relative"), "folded: {fields:?}");
|
||||
assert_eq!(fields["blues"][0].as_f64(), Some(30.0));
|
||||
assert_eq!(fields["method"], json!("absolute"));
|
||||
// Edit the row and the Method, then OK.
|
||||
let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields;
|
||||
fields.insert("blues".into(), json!([10.0, 0.0, 0.0, 0.0]));
|
||||
fields.insert("method".into(), json!("relative"));
|
||||
h.run_steps(2);
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
let sm = recorded(h.state());
|
||||
assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked");
|
||||
let photocraft_doc::Adjustment::SelectiveColor { relative, adjustments } =
|
||||
photocraft_engine::adjust_cmds::selective_from_params(&sm.smart_filters[0].params, None).unwrap()
|
||||
else {
|
||||
panic!("Selective Color params");
|
||||
};
|
||||
assert!(relative, "the edited Method renders");
|
||||
assert_eq!(adjustments[4], [10.0, 0.0, 0.0, 0.0], "the edited row renders");
|
||||
// And reopens as edited.
|
||||
open_editor(h.state_mut(), &ctx, id, &sm, 0);
|
||||
let dialog = h.state().ui.dialogs.last().unwrap().id;
|
||||
h.run_steps(3);
|
||||
let fields = &h.state_mut().ui.dialog_mut(dialog).unwrap().fields;
|
||||
assert_eq!(fields["blues"][0].as_f64(), Some(10.0));
|
||||
assert_eq!(fields["method"], json!("relative"));
|
||||
}
|
||||
|
||||
/// #6324: a smart filter opened for editing previews, and its preview replaces the filter in
|
||||
/// the stack (as OK does) instead of stacking a second one.
|
||||
#[test]
|
||||
|
||||
@@ -185,21 +185,6 @@ pub(super) fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface › Enable Animations: off, egui's fades and slides take no time and scroll areas jump
|
||||
/// straight to their target (#3273). Re-checked every frame because a theme change rebuilds the
|
||||
/// style; both the dark and light styles are set, so a system appearance switch keeps it.
|
||||
pub(super) fn sync_animations(app: &PhotocraftApp, ctx: &egui::Context) {
|
||||
let on = app.session.prefs().interface.enable_animations;
|
||||
let time = if on { egui::Style::default().animation_time } else { 0.0 };
|
||||
if ctx.global_style().animation_time != time {
|
||||
let scroll = if on { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() };
|
||||
ctx.all_styles_mut(|s| {
|
||||
s.animation_time = time;
|
||||
s.scroll_animation = scroll;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the saved appearance mode without changing it or either theme slot.
|
||||
pub(crate) fn sync_appearance(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// A restored or embedding-supplied pin must not suppress native appearance events.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::appearance::{canvas_style, sync_animations, sync_appearance, sync_display_scale, sync_tooltips};
|
||||
use super::appearance::{canvas_style, sync_appearance, sync_display_scale, sync_tooltips};
|
||||
use super::startup::{load, presets_store, recovery, sync_rulers};
|
||||
use super::{DocId, HashMap, PhotocraftApp, Value, prefs};
|
||||
|
||||
@@ -14,7 +14,6 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
crate::theme::set_ui_font_size(ctx, app.session.prefs().interface.ui_font_size);
|
||||
presets_store(app);
|
||||
sync_tooltips(app, ctx);
|
||||
sync_animations(app, ctx);
|
||||
app.sync_recent();
|
||||
// Monotonic, not `ctx.input(|i| i.time)`: logic also runs on hidden ticks, where egui's input
|
||||
// time is that of the last UI pass.
|
||||
|
||||
@@ -1534,28 +1534,27 @@ fn show_tooltips_preferences_turn_tooltips_off() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_animations_off_stops_fades_and_scroll_glides_and_is_remembered() {
|
||||
let on = egui::Style::default().animation_time;
|
||||
let both = |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time);
|
||||
// Saved off: the first frame after a restart applies it to both appearance styles.
|
||||
let (mut app, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string()));
|
||||
let ctx = egui::Context::default();
|
||||
tick(&mut app, &ctx);
|
||||
assert_eq!(both(&ctx), [0.0, 0.0]);
|
||||
assert_eq!(ctx.global_style().scroll_animation.duration.max, 0.0);
|
||||
// A theme change rebuilds the style; the next frame turns animations off again.
|
||||
app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight);
|
||||
tick(&mut app, &ctx);
|
||||
assert_eq!(both(&ctx), [0.0, 0.0]);
|
||||
app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).unwrap();
|
||||
tick(&mut app, &ctx);
|
||||
assert_eq!(both(&ctx), [on, on]);
|
||||
assert!(on > 0.0 && ctx.global_style().scroll_animation.duration.max > 0.0);
|
||||
// A hand-edited value of the wrong type doesn't crash: the defaults (animations on) stay.
|
||||
let (mut bad, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": "nope"}}).to_string()));
|
||||
let ctx2 = egui::Context::default();
|
||||
tick(&mut bad, &ctx2);
|
||||
assert_eq!(both(&ctx2), [on, on]);
|
||||
fn a_saved_enable_animations_loads_and_leaves_egui_animating() {
|
||||
// #9479: Interface › Enable animations is gone (Photoshop has none). A file saved with it
|
||||
// off still loads whole, and egui's fades and scroll glides keep their defaults.
|
||||
let style = egui::Style::default();
|
||||
let both =
|
||||
|ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| (ctx.style_of(t).animation_time, ctx.style_of(t).scroll_animation.duration.max));
|
||||
let expected = (style.animation_time, style.scroll_animation.duration.max);
|
||||
assert!(expected.0 > 0.0 && expected.1 > 0.0);
|
||||
for value in [json!(false), json!(true), json!("nope")] {
|
||||
let saved = json!({"version": 2, "interface": {"enableAnimations": value, "showTooltips": false}});
|
||||
let (mut app, _) = app_with_saved(Some(saved.to_string()));
|
||||
let ctx = egui::Context::default();
|
||||
tick(&mut app, &ctx);
|
||||
assert!(app.ui.status.is_empty(), "{value}: {}", app.ui.status);
|
||||
assert!(!app.session.prefs().interface.show_tooltips, "{value}: the rest of the file loads");
|
||||
assert_eq!(both(&ctx), [expected, expected], "{value}");
|
||||
app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight);
|
||||
tick(&mut app, &ctx);
|
||||
assert_eq!(both(&ctx), [expected, expected], "{value}: after a theme change");
|
||||
assert!(app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -295,4 +295,24 @@ mod tests {
|
||||
let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
|
||||
assert_eq!([b.x0, b.y0, b.width() as i32, b.height() as i32], [8, 8, 24, 24]);
|
||||
}
|
||||
|
||||
/// On the Background the box's preview shows the vacated area in the background colour, as
|
||||
/// the command leaves it.
|
||||
#[test]
|
||||
fn on_the_background_the_preview_vacates_to_the_background_colour() {
|
||||
for depth in [8, 16] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "white"})).unwrap();
|
||||
app.session.tools.background = [0.0, 0.0, 1.0, 1.0];
|
||||
app.run("select.rect", json!({"x": 8, "y": 8, "width": 40, "height": 24})).unwrap();
|
||||
app.sync_views();
|
||||
crate::menus::invoke(&mut app, &ctx, COMMAND, json!({})).unwrap();
|
||||
let pv = app.transform_preview.as_ref().unwrap();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
let px = |x, y| pv.doc.layer(id).unwrap().surface().unwrap().rgba(x, y);
|
||||
assert_eq!(px(20, 20), [0.0, 0.0, 1.0, 1.0], "{depth}-bit: under the box");
|
||||
assert_eq!(px(4, 4), [1.0, 1.0, 1.0, 1.0], "{depth}-bit: outside it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +458,61 @@ fn commit_is_the_same_in_both_modes_and_the_view_stays_upright() {
|
||||
assert!(out[0].1 == out[1].1, "same pixels");
|
||||
}
|
||||
|
||||
/// #9308: the default mode's camera turn is the crop's, not a view rotation, so Match Rotation
|
||||
/// and Match All copy 0 to the other views and leave the active view showing the box upright.
|
||||
#[test]
|
||||
fn match_rotation_leaves_a_pending_crops_turn_out() {
|
||||
let ctx = egui::Context::default();
|
||||
for id in ["window.arrange.matchRotation", "window.arrange.matchAll"] {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 300, "height": 150})).unwrap();
|
||||
app.sync_views();
|
||||
assert!(app.session.set_active(0));
|
||||
app.sync_views();
|
||||
let v = &mut app.ui.views[0];
|
||||
(v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0);
|
||||
app.ui.views[1].rotation = 15.0;
|
||||
let view = app.ui.views[1].clone();
|
||||
app.ui.windows.push(crate::state::DocWindow { id: 7, document: 1, view, open: true, position: None, floating: false, focused: false });
|
||||
with_frame(&mut app);
|
||||
screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE);
|
||||
assert!((view_rotation(&app) - 90.0).abs() < 0.01, "{id}: the view turned with the frame");
|
||||
let r = crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap();
|
||||
assert_eq!(r["rotation"], 0.0, "{id}: {r}");
|
||||
assert_eq!(app.ui.views[1].rotation, 0.0, "{id}: the other document's view");
|
||||
assert_eq!(app.ui.windows[0].view.rotation, 0.0, "{id}: the floating window");
|
||||
assert!((view_rotation(&app) - 90.0).abs() < 0.01, "{id}: the crop still drives its own view");
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
app.ui.tool = Tool::Brush;
|
||||
assert_eq!(view_rotation(&app), 0.0, "{id}: after ↵");
|
||||
assert_eq!(app.ui.views[1].rotation, 0.0, "{id}: the other view isn't left turned");
|
||||
}
|
||||
}
|
||||
|
||||
/// Match Zoom and Match Location copy no rotation, so with a crop turning the active view they
|
||||
/// report its camera's turn as it is and leave every view's rotation alone (#9308).
|
||||
#[test]
|
||||
fn match_zoom_and_location_report_the_turned_camera() {
|
||||
let ctx = egui::Context::default();
|
||||
for id in ["window.arrange.matchZoom", "window.arrange.matchLocation"] {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 300, "height": 150})).unwrap();
|
||||
app.sync_views();
|
||||
assert!(app.session.set_active(0));
|
||||
app.sync_views();
|
||||
let v = &mut app.ui.views[0];
|
||||
(v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [100.0, 50.0], false, 0.0);
|
||||
app.ui.views[1].rotation = 15.0;
|
||||
with_frame(&mut app);
|
||||
screen_drag(&mut app, &[pos2(450.0, 300.0), pos2(440.0, 320.0), pos2(370.0, 380.0)], NONE);
|
||||
let turned = view_rotation(&app);
|
||||
assert!((turned - 90.0).abs() < 0.01, "{id}: the view turned with the frame");
|
||||
let r = crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap();
|
||||
assert!((r["rotation"].as_f64().unwrap() - f64::from(turned)).abs() < 0.01, "{id}: {r}");
|
||||
assert_eq!(app.ui.views[1].rotation, 15.0, "{id}: the other view keeps its rotation");
|
||||
}
|
||||
}
|
||||
|
||||
/// #9202: switching documents drops a turned pending crop and turns its own document's view
|
||||
/// upright, as Esc would, not only the view of the document switched to.
|
||||
#[test]
|
||||
|
||||
@@ -82,8 +82,7 @@ pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool {
|
||||
/// The marquee as marching ants.
|
||||
pub(crate) fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||
let Some(b) = app.layer_box else { return };
|
||||
let r = egui::Rect::from_two_pos(xf.to_screen(b.start[0] as f32, b.start[1] as f32), xf.to_screen(b.end[0] as f32, b.end[1] as f32));
|
||||
crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true);
|
||||
crate::tool_feedback::draw_ants(painter, &crate::canvas::doc_box_corners(xf, b.start, b.end), true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -315,11 +315,21 @@ pub(crate) fn begin_content_aware(
|
||||
crate::type_tool::commit(app);
|
||||
let rect = [region.x0 as f64, region.y0 as f64, region.x1 as f64, region.y1 as f64];
|
||||
let session = app.ui.alloc_id();
|
||||
// The command clears the area before writing the result (a layer without transparency keeps it).
|
||||
// The command vacates the area before writing the result, as Delete does: transparent, or on
|
||||
// the Background the background colour (a layer without transparency otherwise keeps it).
|
||||
let background = doc.is_background(id).then_some(app.session.tools.background);
|
||||
let mut pd = (*doc).clone();
|
||||
if let Some(s) = pd.layer_mut(id).and_then(|l| l.surface_mut()).filter(|s| s.format().alpha) {
|
||||
let clear = vec![0.0; s.format().channels()];
|
||||
s.fill_rect(region, &clear);
|
||||
if let Some(s) = pd.layer_mut(id).and_then(|l| l.surface_mut()).filter(|s| background.is_some() || s.format().alpha) {
|
||||
let fmt = s.format();
|
||||
let mut fill = vec![0.0; fmt.channels()];
|
||||
if let Some([r, g, b, _]) = background {
|
||||
let mut enc = [0.0f32; 8];
|
||||
let n = photocraft_raster::from_rgba_into(&fmt, [r, g, b, 1.0], &mut enc);
|
||||
if let (Some(dst), Some(src)) = (fill.get_mut(..n), enc.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
s.fill_rect(region, &fill);
|
||||
s.prune();
|
||||
}
|
||||
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
|
||||
|
||||
@@ -29,7 +29,7 @@ fn all_registered_languages_render_camera_raw_and_switch_without_resetting_view_
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, ThemeKind::Pro);
|
||||
// Labels and retained state are under test, not the collapse animation.
|
||||
h.state_mut().session.prefs.edit(|p| p.interface.enable_animations = false);
|
||||
h.ctx.all_styles_mut(|style| style.animation_time = 0.0);
|
||||
h.state_mut().session.prefs.edit(|p| p.interface.language = lang.code().into());
|
||||
h.run_steps(4);
|
||||
let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s);
|
||||
|
||||
@@ -87,10 +87,10 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted.
|
||||
- Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it.
|
||||
- With a painting tool, Shift+right-click (without Alt, Ctrl or Command) opens that tool's menu, as in the reference application, even when right-click painting is set to Erase: Edit Brush… (opens the Brush Preset picker at the click) and then the tool's own Mode list from its options bar. Brush and Pencil: Edit Brush…, the painting modes with Behind and Clear; Clone Stamp, Pattern Stamp, History Brush: Edit Brush…, the blend modes; Mixer Brush, Color Replacement, Background Eraser, Blur, Sharpen, Smudge: Edit Brush… only; Eraser: Brush / Pencil / Block; Dodge and Burn: Edit Brush…, Shadows / Midtones / Highlights; Sponge: Edit Brush…, Desaturate / Saturate; Healing Brush: its blend modes; Gradient: the blend modes; Paint Bucket: the blend modes with Clear. Other tools (Spot Healing Brush, Magic Eraser, non-painting tools) keep their plain right-click route. `ui.inspect.brushModeMenu` reports its screen position, `tool`, current `mode` (the checked row's label), `choices` (the row labels, in order) and `items` (`{label, enabled, checked}` or `{separator: true}`); Behind and Clear are disabled on the Background layer. Pick a row with `ui.click`, or with `ui.context.choose {id}` where `id` is the row's English label from `choices` (an unknown or disabled row is an error that changes nothing). A row sets what the tool's options-bar control sets: a brush-based tool's blend mode goes through `tools.setBrush`; the Eraser, Range, Sponge, Gradient and Paint Bucket rows set the tool option. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document.
|
||||
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing
|
||||
- `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing. `x` and `y` lie within ±1,000,000 points (off-window is fine); a larger magnitude is an error naming the field
|
||||
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988)
|
||||
- `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default
|
||||
- `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`, the `ui.click`/`ui.move` `x`/`y`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default
|
||||
- Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted).
|
||||
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
|
||||
- `ui.resize {width, height}`: resize the main window
|
||||
|
||||
+14
-7
@@ -822,7 +822,10 @@ physical RAM (excluding swap) and, where measurable, system commit headroom:
|
||||
modification time (a different file with both preserved is not distinguished there). The
|
||||
`file.approveMemory` permission is one-shot and is not journaled or recorded in an action. A
|
||||
refusal above the hard ceiling is the typed `PsdHardBudget` error (name, required, limit), not
|
||||
a string, and it releases the queued source.
|
||||
a string, and it releases the queued source. Its message says where the limit comes from:
|
||||
the computer's memory on 64-bit desktop; the web build names its fixed cap; on 32-bit desktop the
|
||||
limit may be either, so the message mentions the 2 GiB cap and that the 64-bit build may open the file.
|
||||
Those two also link the releases page. Sizes appear in GiB or MiB next to the exact bytes.
|
||||
- A layered TIFF (Photoshop layer data in tags 37724/34377) is admitted by the same policy
|
||||
before its composite is decoded. The page's tags are read into the PSD model first, without
|
||||
pixels; then that model's layer/workspace estimate, the decoded composite and its
|
||||
@@ -963,23 +966,27 @@ cargo xtask psd-write-oracle xtask/psd-write-oracle/starter.json corpus/photosho
|
||||
```
|
||||
|
||||
The output directory must not exist. It contains `report.json`, `manifest.json`, a
|
||||
candidate PSD per successful export, and CLI stdout/stderr for diagnosis. A failed
|
||||
case does not prevent the other cases from running, but the command exits nonzero.
|
||||
candidate PSD per successful export, a `base.psd` copy of the hashed base bytes, and
|
||||
CLI stdout/stderr for diagnosis. The CLI runs on that copy, so it never opens the corpus.
|
||||
A failed case does not prevent the other cases from running, but the command exits nonzero.
|
||||
Once an export succeeded, a failing case keeps its candidate in the manifest, with an
|
||||
`exported file: ...` error if reopening or checking it failed.
|
||||
The manifest records the checkout revision, whether tracked files were modified, the
|
||||
CLI binary hash, recipe hash, corpus revision and candidate hashes (revision and
|
||||
dirty state are `unknown` outside a git checkout). The checkout
|
||||
revision does not establish where a supplied binary was built; use a binary built from
|
||||
that revision and preserve the CI run's provenance. Missing fonts and other warnings
|
||||
are recorded. The runner never overwrites its input PSDs.
|
||||
are recorded. The runner never overwrites or opens its input PSDs beyond hashing them.
|
||||
|
||||
Recipes are versioned JSON (`version` must be `1`; any other value is rejected). Unknown keys anywhere in a recipe are an error, so a typo never passes as "nothing to check". A case has a unique `id`, a relative `base` PSD path,
|
||||
Recipes are versioned JSON (`version` must be `1`; any other value is rejected). Unknown keys anywhere in a recipe are an error, so a typo never passes as "nothing to check". A case has an `id` that is unique ignoring letter case (it names the case's output directory), a relative `base` PSD path,
|
||||
its `base_sha256`, a `target` with layer `kind` and optional `name`, and `checks`.
|
||||
An optional list of `commands` contains a `command` id and `params`; the runner
|
||||
supplies the resolved `layer` id. Targets that match zero or multiple layers fail.
|
||||
Only `type.*` and `layer.layerStyle.*` commands are accepted. Checks use JSON pointers
|
||||
Only `type.*` and `layer.layerStyle.*` commands are accepted, except `layer.layerStyle.contours.*`,
|
||||
which edit the contour library rather than the document and can read or write any file. Checks use JSON pointers
|
||||
relative to the inspected layer: `unchanged` requires a present, equal value before
|
||||
and after; `equals` checks the exported value; `changed_to` requires the exported
|
||||
value to match and differ from the base. Every edited case needs a `changed_to` check.
|
||||
value to match and differ from the base. Numbers compare by exact value, so `17` equals `17.0`. Every edited case needs a `changed_to` check.
|
||||
Every check is run and reported (not only the first failure), each result naming the layer and the property path. See the starter recipes for examples. Regenerate hashes when deliberately changing
|
||||
the corpus pin, and review them against `xtask/photoshop-corpus.sha256`.
|
||||
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 |
|
||||
| [Tools](#tools) | 36 | 9 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 193 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 192 (target 0) |
|
||||
| [Type](#type) | 5 | 5 | 0 | |
|
||||
| [Automation](#automation) | 5 | 3 | 3 | 660 of 660 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present |
|
||||
@@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 10 of 193** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 10 of 192** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.
|
||||
|
||||
@@ -224,7 +224,7 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.
|
||||
| UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay |
|
||||
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet |
|
||||
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (17 presets: 75 to 125 in 5 % steps since #3222, then 150 to 300; more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting |
|
||||
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) |
|
||||
| UI-217-10 | Appearance: canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) |
|
||||
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | done | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); the light themes outline the slider knob with Tokens::knob_outline (dim text, at least 3:1 against dock, card and chrome; #7927) |
|
||||
| UI-217-12 | Save custom New Document presets; a real Recent category | done | [#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 lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category |
|
||||
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click |
|
||||
|
||||
@@ -46,6 +46,12 @@ spilling resumes on its own once the directory is usable and has room, and chang
|
||||
preferences retries at once. Preferences → Scratch Disks → Scratch Size Limit
|
||||
(`scratchDisks.sizeLimitMb`, 0 = no limit; `spill::set_size_cap`) caps the scratch file:
|
||||
reaching it pauses spilling the same way, and the free-space reserve applies regardless.
|
||||
On Windows, network scratch folders aren't listed as disks, so their free space isn't
|
||||
measured: the free-space reserve doesn't apply to them, and they earn a scratch allowance for
|
||||
large imports only up to the Scratch Size Limit, if one is set. On Linux, an unlisted network
|
||||
mount (NFS or CIFS) counts the free space of the listed mount above it, such as `/`, for both
|
||||
the allowance and the reserve. With neither free space nor a size limit known, imports get no
|
||||
scratch allowance.
|
||||
A failed read retains its backing extent and blocks editing, saving and exporting of the
|
||||
document that holds the tile until it is readable again; other open documents keep working.
|
||||
Each commit checks the health of its own document (before and after an edit, the document a
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -70,10 +70,10 @@ note = "prefs::UiScale offers Auto plus 75 to 300 percent (17 presets: 75 to 125
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-10"
|
||||
target = "Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents"
|
||||
target = "Appearance: canvas pattern toggle per theme, start-screen recents"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382)"
|
||||
note = "the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382)"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-11"
|
||||
|
||||
@@ -136,6 +136,30 @@
|
||||
"op": "changed_to",
|
||||
"path": "/text/runs/1/style/tracking",
|
||||
"value": -60.0
|
||||
},
|
||||
{
|
||||
"op": "unchanged",
|
||||
"path": "/text/runs/0/style/tracking"
|
||||
},
|
||||
{
|
||||
"op": "equals",
|
||||
"path": "/text/runs/2/style/tracking",
|
||||
"value": 200.0
|
||||
},
|
||||
{
|
||||
"op": "equals",
|
||||
"path": "/text/runs/0/len",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"op": "equals",
|
||||
"path": "/text/runs/1/len",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"op": "equals",
|
||||
"path": "/text/runs/2/len",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+220
-14
@@ -91,7 +91,8 @@ fn validate(recipes: &Recipes) -> Result<(), String> {
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
for case in &recipes.cases {
|
||||
if !valid_id(&case.id) || !seen.insert(&case.id) {
|
||||
// An id names the case's output directory, and `A` and `a` are one directory on a case-insensitive filesystem.
|
||||
if !valid_id(&case.id) || !seen.insert(case.id.to_ascii_lowercase()) {
|
||||
return Err(format!("invalid or duplicate case id: {}", case.id));
|
||||
}
|
||||
let path = Path::new(&case.base);
|
||||
@@ -108,7 +109,9 @@ fn validate(recipes: &Recipes) -> Result<(), String> {
|
||||
return Err(format!("{}: an edit needs a changed_to assertion", case.id));
|
||||
}
|
||||
for step in &case.commands {
|
||||
// The contour library commands edit no document, and their `path` param reaches any file.
|
||||
if !(step.command.starts_with("type.") || step.command.starts_with("layer.layerStyle."))
|
||||
|| step.command.starts_with("layer.layerStyle.contours.")
|
||||
|| !step.params.is_object()
|
||||
|| step.params.get("layer").is_some()
|
||||
|| step.params.get("layers").is_some()
|
||||
@@ -162,6 +165,39 @@ fn target<'a>(doc: &'a Value, wanted: &Target) -> Result<&'a Value, String> {
|
||||
found.into_iter().next().ok_or_else(|| "missing target".into())
|
||||
}
|
||||
|
||||
/// Compare two JSON numbers by exact value, with no tolerance: `17` equals `17.0`, and integers too
|
||||
/// large for `f64` stay distinct.
|
||||
fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
|
||||
let int = |n: &serde_json::Number| n.as_i64().map(i128::from).or_else(|| n.as_u64().map(i128::from));
|
||||
let whole_float_is = |float: &serde_json::Number, i: i128| float.as_f64().is_some_and(|f| f.fract() == 0.0 && f as i128 == i);
|
||||
match (int(a), int(b)) {
|
||||
(Some(x), Some(y)) => x == y,
|
||||
(Some(i), None) => whole_float_is(b, i),
|
||||
(None, Some(i)) => whole_float_is(a, i),
|
||||
(None, None) => a.as_f64() == b.as_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare JSON by value: numbers as in `numbers_equal`; arrays and objects element-wise. Recursion is
|
||||
/// bounded because serde_json's parser already limits nesting depth.
|
||||
fn values_equal(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(x), Value::Number(y)) => numbers_equal(x, y),
|
||||
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| values_equal(x, y)),
|
||||
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| values_equal(v, w))),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// `values_equal` over optional values: both absent are equal, one absent is not.
|
||||
fn options_equal(a: Option<&Value>, b: Option<&Value>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => values_equal(a, b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(checks: &[Check], before: &Value, after: &Value) -> Vec<Value> {
|
||||
let mut results = Vec::new();
|
||||
let layer = after.get("name").or_else(|| before.get("name"));
|
||||
@@ -174,9 +210,9 @@ fn verify(checks: &[Check], before: &Value, after: &Value) -> Vec<Value> {
|
||||
let old = before.pointer(path);
|
||||
let new = after.pointer(path);
|
||||
let pass = match check {
|
||||
Check::ChangedTo { value, .. } => new == Some(value) && old != new,
|
||||
Check::Equals { value, .. } => new == Some(value),
|
||||
Check::Unchanged { .. } => old.is_some() && old == new,
|
||||
Check::ChangedTo { value, .. } => new.is_some_and(|n| values_equal(n, value)) && !options_equal(old, new),
|
||||
Check::Equals { value, .. } => new.is_some_and(|n| values_equal(n, value)),
|
||||
Check::Unchanged { .. } => old.is_some() && options_equal(old, new),
|
||||
};
|
||||
results.push(
|
||||
json!({"layer": layer, "path": path, "op": op, "expected": expected, "before": old, "after": new, "status": if pass { "pass" } else { "fail" }}),
|
||||
@@ -224,8 +260,34 @@ fn inspect(cli: &Path, file: &Path, dir: &Path, tag: &str) -> Result<Value, Stri
|
||||
serde_json::from_str(&out).map_err(|e| format!("invalid CLI inspection: {e}"))
|
||||
}
|
||||
|
||||
/// Reference-editor verdicts, which this runner never produces itself.
|
||||
fn not_run() -> Value {
|
||||
json!({"opens": "not_run", "meaning": "not_run", "rendering": "not_run"})
|
||||
}
|
||||
|
||||
/// Reopen the exported candidate and check it against the recipe.
|
||||
fn check_candidate(
|
||||
case: &Recipe,
|
||||
cli: &Path,
|
||||
dir: &Path,
|
||||
candidate_sha256: &str,
|
||||
before: &Value,
|
||||
layer: &Value,
|
||||
export_warnings: &str,
|
||||
) -> Result<Value, String> {
|
||||
let candidate = dir.join("candidate.psd");
|
||||
let after = inspect(cli, &candidate, dir, "after")?;
|
||||
let checks = verify(&case.checks, layer, target(&after, &case.target)?);
|
||||
let passed = checks.iter().all(|v| v.get("status").and_then(Value::as_str) == Some("pass"));
|
||||
Ok(json!({"id": case.id, "status": if passed { "pass" } else { "fail" }, "base": case.base, "base_sha256": case.base_sha256,
|
||||
"candidate": format!("{}/candidate.psd", case.id), "candidate_sha256": candidate_sha256, "checks": checks,
|
||||
"warnings": {"input": before.get("warnings"), "export": export_warnings, "output": after.get("warnings")},
|
||||
"photoshop": not_run()}))
|
||||
}
|
||||
|
||||
/// Export and check one case, retaining candidate provenance after export succeeds.
|
||||
fn execute(case: &Recipe, corpus: &Path, cli: &Path, output: &Path) -> Result<Value, String> {
|
||||
use std::io::Write;
|
||||
let input = input_path(corpus, &case.base)?;
|
||||
let input_bytes = read_limited(&input)?;
|
||||
if crate::sha256::hex(&input_bytes) != case.base_sha256 {
|
||||
@@ -233,11 +295,16 @@ fn execute(case: &Recipe, corpus: &Path, cli: &Path, output: &Path) -> Result<Va
|
||||
}
|
||||
let dir = output.join(&case.id);
|
||||
fs::create_dir(&dir).map_err(|e| e.to_string())?;
|
||||
let before = inspect(cli, &input, &dir, "before")?;
|
||||
// Run on a private copy of the hashed bytes so the CLI never opens the corpus.
|
||||
let pinned = dir.join("base.psd");
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&pinned).map_err(|e| e.to_string())?;
|
||||
f.write_all(&input_bytes).map_err(|e| e.to_string())?;
|
||||
drop(f);
|
||||
let before = inspect(cli, &pinned, &dir, "before")?;
|
||||
let layer = target(&before, &case.target)?;
|
||||
let layer_id = layer.get("id").and_then(Value::as_u64).ok_or("target lacks numeric id")?;
|
||||
let candidate = dir.join("candidate.psd");
|
||||
let mut args = vec!["run".to_string(), input.to_str().ok_or("non-UTF-8 input path")?.to_string()];
|
||||
let mut args = vec!["run".to_string(), pinned.to_str().ok_or("non-UTF-8 input path")?.to_string()];
|
||||
for step in &case.commands {
|
||||
let mut params = step.params.clone();
|
||||
params.as_object_mut().ok_or("params must be an object")?.insert("layer".into(), json!(layer_id));
|
||||
@@ -246,13 +313,15 @@ fn execute(case: &Recipe, corpus: &Path, cli: &Path, output: &Path) -> Result<Va
|
||||
args.extend(["--out".into(), candidate.to_str().ok_or("non-UTF-8 output path")?.to_string()]);
|
||||
let (_, warnings) = invoke(cli, &args, &dir, "export")?;
|
||||
let bytes = read_limited(&candidate)?;
|
||||
let after = inspect(cli, &candidate, &dir, "after")?;
|
||||
let checks = verify(&case.checks, layer, target(&after, &case.target)?);
|
||||
let passed = checks.iter().all(|v| v.get("status").and_then(Value::as_str) == Some("pass"));
|
||||
Ok(json!({"id": case.id, "status": if passed { "pass" } else { "fail" }, "base": case.base, "base_sha256": case.base_sha256,
|
||||
"candidate": format!("{}/candidate.psd", case.id), "candidate_sha256": crate::sha256::hex(&bytes), "checks": checks,
|
||||
"warnings": {"input": before.get("warnings"), "export": warnings, "output": after.get("warnings")},
|
||||
"photoshop": {"opens": "not_run", "meaning": "not_run", "rendering": "not_run"}}))
|
||||
let candidate_sha256 = crate::sha256::hex(&bytes);
|
||||
match check_candidate(case, cli, &dir, &candidate_sha256, &before, layer, &warnings) {
|
||||
Ok(record) => Ok(record),
|
||||
Err(e) => Ok(json!({"id": case.id, "status": "fail", "base": case.base, "base_sha256": case.base_sha256,
|
||||
"candidate": format!("{}/candidate.psd", case.id), "candidate_sha256": candidate_sha256,
|
||||
"error": format!("exported file: {e}"),
|
||||
"warnings": {"input": before.get("warnings"), "export": warnings},
|
||||
"photoshop": not_run()})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a report artifact without overwriting an existing file.
|
||||
@@ -294,7 +363,7 @@ pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
for case in &recipes.cases {
|
||||
let result = execute(case, &corpus, &cli, output).unwrap_or_else(|e| {
|
||||
json!({"id": case.id, "status": "fail", "error": e,
|
||||
"photoshop": {"opens": "not_run", "meaning": "not_run", "rendering": "not_run"}})
|
||||
"photoshop": not_run()})
|
||||
});
|
||||
cases.push(result);
|
||||
}
|
||||
@@ -389,6 +458,16 @@ mod tests {
|
||||
assert!(validate(&parsed).is_err());
|
||||
let duplicate = json!({"version":1,"corpus_revision":"pin","cases":[{"id":"x","base":"x.psd","base_sha256":"a".repeat(64),"target":{"kind":"Text"},"checks":[{"op":"unchanged","path":"/name"}]},{"id":"x","base":"x.psd","base_sha256":"a".repeat(64),"target":{"kind":"Text"},"checks":[{"op":"unchanged","path":"/name"}]}]});
|
||||
assert!(validate(&serde_json::from_value(duplicate).unwrap()).is_err());
|
||||
let case_only = json!({"version":1,"corpus_revision":"pin","cases":[{"id":"A","base":"x.psd","base_sha256":"a".repeat(64),"target":{"kind":"Text"},"checks":[{"op":"unchanged","path":"/name"}]},{"id":"a","base":"x.psd","base_sha256":"a".repeat(64),"target":{"kind":"Text"},"checks":[{"op":"unchanged","path":"/name"}]}]});
|
||||
assert!(validate(&serde_json::from_value(case_only).unwrap()).unwrap_err().contains("duplicate"));
|
||||
let contour = |command: &str| {
|
||||
json!({"version":1,"corpus_revision":"pin","cases":[{"id":"x","base":"x.psd","base_sha256":"a".repeat(64),"target":{"kind":"Text"},
|
||||
"commands":[{"command":command,"params":{"path":"base.psd"}}],"checks":[{"op":"changed_to","path":"/name","value":"y"}]}]})
|
||||
};
|
||||
for command in ["layer.layerStyle.contours.save", "layer.layerStyle.contours.load"] {
|
||||
assert!(validate(&serde_json::from_value(contour(command)).unwrap()).unwrap_err().contains("invalid command"));
|
||||
}
|
||||
assert!(validate(&serde_json::from_value(contour("layer.layerStyle.dropShadow")).unwrap()).is_ok());
|
||||
let edit = json!({"version":1,"corpus_revision":"pin","cases":[{"id":"x","base":"x.psd","base_sha256":"a".repeat(64),"target":{"kind":"Text"},"commands":[{"command":"type.setStyle"}],"checks":[{"op":"unchanged","path":"/name"}]}]});
|
||||
assert!(validate(&serde_json::from_value(edit).unwrap()).is_err());
|
||||
}
|
||||
@@ -443,4 +522,131 @@ exit 1
|
||||
assert_eq!(good["photoshop"]["opens"], "not_run");
|
||||
assert!(execute(&make_case("bad", "type.bad"), &corpus, &cli, &output).unwrap_err().contains("bad command"));
|
||||
}
|
||||
|
||||
/// Fake CLI that logs its arguments, tampers with the file it is asked to run on, and reports
|
||||
/// `candidate_info` for the exported file.
|
||||
#[cfg(unix)]
|
||||
fn fake_cli(root: &Path, log: &Path, candidate_info: &str, export_fails: bool) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let cli = root.join("fake-cli");
|
||||
let script = r##"#!/bin/sh
|
||||
echo "$@" >> 'LOG'
|
||||
if [ "$1" = info ]; then
|
||||
case "$2" in
|
||||
*/candidate.psd) echo 'CANDIDATE' ;;
|
||||
*) echo '{"layers":[{"kind":"Type","id":4,"text":{"antialias":"None"}}]}' ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [ EXPORT_FAILS = 1 ]; then
|
||||
echo 'export broke' >&2
|
||||
exit 1
|
||||
fi
|
||||
input="$2"
|
||||
cp "$input" "$input.received"
|
||||
echo tamper >> "$input"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = --out ]; then cp "$input" "$2"; exit 0; fi
|
||||
shift
|
||||
done
|
||||
exit 1
|
||||
"##
|
||||
.replace("LOG", &log.display().to_string())
|
||||
.replace("CANDIDATE", candidate_info)
|
||||
.replace("EXPORT_FAILS", if export_fails { "1" } else { "0" });
|
||||
fs::write(&cli, script).unwrap();
|
||||
fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
cli
|
||||
}
|
||||
|
||||
/// Run one recipe case through `run`; returns (temp dir guard, result, output dir, corpus dir, log path).
|
||||
#[cfg(unix)]
|
||||
fn run_case(id: &str, candidate_info: &str, export_fails: bool) -> (tempfile::TempDir, Result<(), String>, PathBuf, PathBuf, PathBuf) {
|
||||
let tmp = tempfile::Builder::new().prefix("psd-write-oracle-").tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let corpus = root.join("corpus");
|
||||
fs::create_dir(&corpus).unwrap();
|
||||
fs::write(corpus.join("base.psd"), b"base").unwrap();
|
||||
let log = root.join("calls.log");
|
||||
let cli = fake_cli(&root, &log, candidate_info, export_fails);
|
||||
let recipes = json!({"version":1,"corpus_revision":"pin","cases":[{"id":id,"base":"base.psd","base_sha256":crate::sha256::hex(b"base"),
|
||||
"target":{"kind":"Type"},"commands":[{"command":"type.antiAlias.crisp","params":{}}],
|
||||
"checks":[{"op":"changed_to","path":"/text/antialias","value":"Crisp"}]}]});
|
||||
let recipe_path = root.join("recipes.json");
|
||||
fs::write(&recipe_path, recipes.to_string()).unwrap();
|
||||
let out = root.join("out");
|
||||
let args = [recipe_path.to_str().unwrap(), corpus.to_str().unwrap(), cli.to_str().unwrap(), out.to_str().unwrap()];
|
||||
let result = run(&root, &args);
|
||||
(tmp, result, out, corpus, log)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn target_lost_after_export_keeps_candidate_in_manifest() {
|
||||
let (_tmp, result, out, _, _) = run_case("lost", r#"{"layers":[]}"#, false);
|
||||
assert!(result.is_err());
|
||||
let report: Value = serde_json::from_slice(&fs::read(out.join("report.json")).unwrap()).unwrap();
|
||||
assert_eq!(report["cases"][0]["status"], "fail");
|
||||
assert!(report["cases"][0]["error"].as_str().unwrap().starts_with("exported file: "));
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(out.join("manifest.json")).unwrap()).unwrap();
|
||||
let entry = &manifest["candidates"][0];
|
||||
assert_eq!(entry["id"], "lost");
|
||||
assert_eq!(entry["path"], "lost/candidate.psd");
|
||||
assert_eq!(entry["sha256"], crate::sha256::hex(&fs::read(out.join("lost/candidate.psd")).unwrap()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn export_failure_has_no_manifest_entry() {
|
||||
let (_tmp, result, out, _, _) = run_case("broken", r#"{"layers":[]}"#, true);
|
||||
assert!(result.is_err());
|
||||
let report: Value = serde_json::from_slice(&fs::read(out.join("report.json")).unwrap()).unwrap();
|
||||
assert_eq!(report["cases"][0]["status"], "fail");
|
||||
assert!(report["cases"][0]["error"].as_str().unwrap().contains("export broke"));
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(out.join("manifest.json")).unwrap()).unwrap();
|
||||
assert!(manifest["candidates"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cli_runs_on_pinned_base_never_on_corpus() {
|
||||
let (_tmp, result, out, corpus, log) = run_case("pin", r#"{"layers":[{"kind":"Type","id":4,"text":{"antialias":"Crisp"}}]}"#, false);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(fs::read(corpus.join("base.psd")).unwrap(), b"base");
|
||||
let pinned = out.join("pin/base.psd");
|
||||
assert!(pinned.exists());
|
||||
assert_eq!(fs::read(out.join("pin/base.psd.received")).unwrap(), b"base");
|
||||
let log = fs::read_to_string(log).unwrap();
|
||||
let corpus_path = corpus.join("base.psd").display().to_string();
|
||||
assert!(!log.contains(&corpus_path));
|
||||
let pinned = pinned.display().to_string();
|
||||
let base_calls: Vec<&str> = log.lines().filter(|l| !l.starts_with("info ") || !l.contains("candidate.psd")).collect();
|
||||
assert_eq!(base_calls.len(), 2);
|
||||
assert!(base_calls.iter().all(|l| l.contains(&pinned)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_compare_by_value() {
|
||||
assert!(values_equal(&json!(17), &json!(17.0)));
|
||||
assert!(!values_equal(&json!(17), &json!(18.0)));
|
||||
assert!(!values_equal(&json!(17), &json!(17.5)));
|
||||
assert!(values_equal(&json!(0.5), &json!(0.5)));
|
||||
assert!(values_equal(&json!(-3), &json!(-3.0)));
|
||||
assert!(values_equal(&json!(u64::MAX), &json!(u64::MAX)));
|
||||
assert!(!values_equal(&json!(9_007_199_254_740_993_u64), &json!(9_007_199_254_740_992_u64)));
|
||||
assert!(!values_equal(&json!(9_007_199_254_740_993_u64), &json!(9_007_199_254_740_992.0)));
|
||||
assert!(values_equal(&json!(9_007_199_254_740_992_u64), &json!(9_007_199_254_740_992.0)));
|
||||
assert!(!values_equal(&json!(u64::MAX), &json!(1e300)));
|
||||
assert!(values_equal(&json!({"a":[1,2.0,{"b":3}],"c":"x"}), &json!({"c":"x","a":[1.0,2,{"b":3.0}]})));
|
||||
assert!(!values_equal(&json!({"a":1}), &json!({"b":1})));
|
||||
assert!(!values_equal(&json!({"a":1}), &json!({"a":1,"b":1})));
|
||||
assert!(!values_equal(&json!([1, 2]), &json!([1])));
|
||||
let before = json!({"n":17});
|
||||
let after = json!({"n":17.0});
|
||||
assert_eq!(verify(&[Check::ChangedTo { path: "/n".into(), value: json!(17) }], &before, &after)[0]["status"], "fail");
|
||||
assert_eq!(verify(&[Check::Equals { path: "/n".into(), value: json!(17) }], &before, &after)[0]["status"], "pass");
|
||||
assert_eq!(verify(&[Check::Unchanged { path: "/n".into() }], &before, &after)[0]["status"], "pass");
|
||||
assert_eq!(verify(&[Check::ChangedTo { path: "/n".into(), value: json!(17) }], &json!({}), &after)[0]["status"], "pass");
|
||||
assert_eq!(verify(&[Check::Equals { path: "/m".into(), value: json!(1) }], &before, &after)[0]["status"], "fail");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user