mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
Merge remote-tracking branch 'origin/main' into integration-batch-7
# Conflicts: # crates/ui-egui/src/i18n/tr.tsv # docs/ui-parity.md
This commit is contained in:
@@ -255,5 +255,12 @@ sync_github_name = true
|
|||||||
[people.benandez]
|
[people.benandez]
|
||||||
real_name = "Martin Ball"
|
real_name = "Martin Ball"
|
||||||
|
|
||||||
|
[people.ronibalthazar]
|
||||||
|
sync_github_name = true
|
||||||
|
|
||||||
|
[people.batuhangozegu]
|
||||||
|
real_name = "Batuhan Gözegü"
|
||||||
|
sync_github_name = false
|
||||||
|
|
||||||
[people.drunkeninja42]
|
[people.drunkeninja42]
|
||||||
real_name = "Kushagra Sharma"
|
real_name = "Kushagra Sharma"
|
||||||
|
|||||||
@@ -2033,7 +2033,15 @@ fn transform_from(d: &Descriptor) -> Mapped {
|
|||||||
let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0));
|
let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0));
|
||||||
let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels;
|
let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels;
|
||||||
// The resampling applies to a selection as much as to a layer (#7515).
|
// The resampling applies to a selection as much as to a layer (#7515).
|
||||||
let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest"));
|
// Photoshop's bicubic variants are kept (the transforms render them as Bicubic, and they export
|
||||||
|
// back unchanged); Automatic and Preserve Details become the nearest bicubic choice. Dropping
|
||||||
|
// them fell back to the command's default, bilinear for a selection (#8697).
|
||||||
|
let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).and_then(|i| match i {
|
||||||
|
"bicubic" | "bilinear" | "nearest" | "bicubicSharper" | "bicubicSmoother" | "bicubicAutomatic" => Some(i),
|
||||||
|
"automatic" => Some("bicubicAutomatic"),
|
||||||
|
"preserveDetails" => Some("bicubic"),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
match (ref_class(r).as_deref(), target(r)) {
|
match (ref_class(r).as_deref(), target(r)) {
|
||||||
(Some("Chnl"), Target::Property(prop)) if prop == "fsel" => {
|
(Some("Chnl"), Target::Property(prop)) if prop == "fsel" => {
|
||||||
if let Some(i) = interpolation {
|
if let Some(i) = interpolation {
|
||||||
|
|||||||
@@ -2172,6 +2172,28 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #8697: Photoshop's other transform interpolation modes survive import: its bicubic variants
|
||||||
|
/// are kept (they render as Bicubic and export back unchanged), Automatic becomes Bicubic
|
||||||
|
/// Automatic and Preserve Details Bicubic; before, they fell back to the command's default.
|
||||||
|
#[test]
|
||||||
|
fn photoshop_transform_interpolation_modes_survive_import() {
|
||||||
|
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
|
||||||
|
for id in ["select.transformSelection", "edit.transform"] {
|
||||||
|
for (mode, imported) in [
|
||||||
|
("bicubicSharper", "bicubicSharper"),
|
||||||
|
("bicubicSmoother", "bicubicSmoother"),
|
||||||
|
("bicubicAutomatic", "bicubicAutomatic"),
|
||||||
|
("automatic", "bicubicAutomatic"),
|
||||||
|
("preserveDetails", "bicubic"),
|
||||||
|
] {
|
||||||
|
let (bytes, _) = export("Audit8", &[&one(id, json!({"scaleX": 140, "interpolation": mode}))], &[], false).unwrap();
|
||||||
|
let steps = import(&bytes).unwrap().actions[0].steps.clone();
|
||||||
|
let got = steps.first().and_then(|(_, p)| p.get("interpolation")).and_then(Value::as_str).map(str::to_owned);
|
||||||
|
assert_eq!(got.as_deref(), Some(imported), "{id} {mode}: {steps:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written
|
/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written
|
||||||
/// (their descriptors carry every setting, filling absent ones with defaults), so strict export
|
/// (their descriptors carry every setting, filling absent ones with defaults), so strict export
|
||||||
/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export.
|
/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export.
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
|
|||||||
*l = l.duplicate();
|
*l = l.duplicate();
|
||||||
}
|
}
|
||||||
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
|
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
|
||||||
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy);
|
crate::layer_copy_cmds::translate_copies(&mut copies, &d.doc, dx, dy);
|
||||||
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
|
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
|
||||||
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
|
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
|
||||||
let mut linked = Vec::new();
|
let mut linked = Vec::new();
|
||||||
@@ -1932,6 +1932,78 @@ mod tests {
|
|||||||
assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
|
assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_document_group_paste_renders_shapes_on_destination_canvas() {
|
||||||
|
for (copy_to_document, depth) in [(false, 8), (true, 16)] {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width":100,"height":100})).unwrap();
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for x in [10, 50] {
|
||||||
|
s.execute("shape.create", json!({"kind":"rect","rect":[x,20,20,30],"fill":"#ff0000"})).unwrap();
|
||||||
|
s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap();
|
||||||
|
ids.push(s.active().unwrap().active_layer.unwrap());
|
||||||
|
}
|
||||||
|
s.active_mut().unwrap().selected_layers = ids;
|
||||||
|
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||||
|
s.execute("edit.copy", json!({})).unwrap();
|
||||||
|
s.execute("file.new", json!({"width":800,"height":600,"depth":depth})).unwrap();
|
||||||
|
if copy_to_document {
|
||||||
|
s.execute("layer.copyToDocument", json!({"source":0,"document":1,"center":true})).unwrap();
|
||||||
|
} else {
|
||||||
|
s.execute("edit.paste", json!({"center":[400,300]})).unwrap();
|
||||||
|
}
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
let group = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||||
|
assert!(!crate::transform_cmds::transform_bounds(&st.doc, group).is_empty());
|
||||||
|
assert_eq!(group.children().unwrap().len(), 2);
|
||||||
|
for child in group.children().unwrap() {
|
||||||
|
let LayerContent::Shape(shape) = &child.content else { panic!("shape") };
|
||||||
|
let cache = shape.cache.as_ref().unwrap();
|
||||||
|
assert_eq!(cache.format().sample, st.doc.depth);
|
||||||
|
assert!(!cache.content_bounds().is_empty(), "destination cache must not be clipped to source canvas");
|
||||||
|
assert!(cache.content_bounds().x0 > 100);
|
||||||
|
let fresh = photocraft_vector::render_shape(shape, st.doc.pixel_format(), st.doc.bounds());
|
||||||
|
assert_eq!(cache, &fresh);
|
||||||
|
}
|
||||||
|
let before = st.doc.clone();
|
||||||
|
s.execute("layer.translate", json!({"dx":10,"dy":10})).unwrap();
|
||||||
|
assert_ne!(s.active().unwrap().doc, before);
|
||||||
|
assert!(s.undo());
|
||||||
|
assert_eq!(s.active().unwrap().doc, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_repairs_uncached_shapes_before_transform_and_effect_rendering() {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("shape.create", json!({"kind":"rect","rect":[10,10,40,20],"fill":"#ff0000"})).unwrap();
|
||||||
|
s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap();
|
||||||
|
s.execute("edit.copy", json!({})).unwrap();
|
||||||
|
let layers = s.clipboard.as_mut().unwrap().layers.as_mut().unwrap();
|
||||||
|
let LayerContent::Shape(sh) = &mut layers.layers[0].content else { panic!("shape") };
|
||||||
|
sh.cache = None;
|
||||||
|
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||||
|
let LayerContent::Shape(sh) = &layer.content else { panic!("shape") };
|
||||||
|
assert!(sh.cache.is_some());
|
||||||
|
assert!(!crate::transform_cmds::transform_bounds(&st.doc, layer).is_empty());
|
||||||
|
let expected = photocraft_vector::render_shape(sh, st.doc.pixel_format(), st.doc.bounds());
|
||||||
|
assert_eq!(sh.cache.as_ref().unwrap(), &expected);
|
||||||
|
let rendered = photocraft_compose::render_layer_in(&st.doc, layer, st.doc.bounds());
|
||||||
|
let mut fresh_doc = (*st.doc).clone();
|
||||||
|
let snapshot = fresh_doc.clone();
|
||||||
|
if let LayerContent::Shape(shape) = &mut fresh_doc.layer_mut(layer.id).unwrap().content {
|
||||||
|
crate::vector_cmds::refresh_shape(&snapshot, shape);
|
||||||
|
}
|
||||||
|
let fresh = photocraft_compose::render_layer_in(&fresh_doc, fresh_doc.layer(layer.id).unwrap(), fresh_doc.bounds());
|
||||||
|
assert_eq!(rendered.px, fresh.px, "pasted effects match a freshly rebuilt shape");
|
||||||
|
assert!(s.undo());
|
||||||
|
assert!(s.redo());
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
assert!(st.doc.layer(st.active_layer.unwrap()).unwrap().surface().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
/// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy
|
/// #2344: fill and adjustment layers have no pixels of their own, so with no selection Copy
|
||||||
/// takes them whole and Paste recreates them, in the same or another document, in one step.
|
/// takes them whole and Paste recreates them, in the same or another document, in one step.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use photocraft_color::ColorMode;
|
use photocraft_color::ColorMode;
|
||||||
use photocraft_doc::{Document, LayerContent, LayerId, SmartContentsId};
|
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId};
|
||||||
use photocraft_geom::Rect;
|
use photocraft_geom::Rect;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -59,6 +59,17 @@ fn center(r: Rect) -> [f64; 2] {
|
|||||||
[(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0]
|
[(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore missing shape caches, or refresh every shape for a changed canvas, including groups.
|
||||||
|
fn prepare_shapes(layers: &mut [Layer], doc: &Document, new_canvas: bool) {
|
||||||
|
for layer in layers {
|
||||||
|
match &mut layer.content {
|
||||||
|
LayerContent::Shape(shape) if new_canvas || shape.cache.is_none() => crate::vector_cmds::refresh_shape(doc, shape),
|
||||||
|
LayerContent::Group(group) => prepare_shapes(&mut group.children, doc, new_canvas),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for
|
/// Readies `copies` (a scratch document of the source's colour holding duplicated layers) for
|
||||||
/// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the
|
/// landing in `dest`: smart objects get fresh contents ids, keeping only the sharing within the
|
||||||
/// batch (contents ids belong to a document, so the copies never join objects already in `dest`,
|
/// batch (contents ids belong to a document, so the copies never join objects already in `dest`,
|
||||||
@@ -107,13 +118,20 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document)
|
|||||||
*surf = surf.convert(f);
|
*surf = surf.convert(f);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let new_canvas = copies.bounds() != dest.bounds();
|
||||||
|
prepare_shapes(&mut copies.layers, dest, new_canvas);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement).
|
/// Moves every layer of `copies` by whole pixels (content, masks, vectors and smart placement).
|
||||||
pub(crate) fn translate_copies(copies: &mut Document, dx: i32, dy: i32) {
|
pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx: i32, dy: i32) {
|
||||||
if dx != 0 || dy != 0 {
|
if dx != 0 || dy != 0 {
|
||||||
let snapshot = copies.clone();
|
let mut snapshot = copies.clone();
|
||||||
|
// Vector caches are clipped and quantized for the destination, not the clipboard's source.
|
||||||
|
snapshot.size = destination.size;
|
||||||
|
snapshot.mode = destination.mode;
|
||||||
|
snapshot.depth = destination.depth;
|
||||||
|
snapshot.icc_profile = destination.icc_profile.clone();
|
||||||
for l in &mut copies.layers {
|
for l in &mut copies.layers {
|
||||||
crate::commands::translate_layer(&snapshot, l, dx, dy);
|
crate::commands::translate_layer(&snapshot, l, dx, dy);
|
||||||
crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));
|
crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));
|
||||||
@@ -172,11 +190,14 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) {
|
if let (Some(name), [l]) = (p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()), copies.layers.as_mut_slice()) {
|
||||||
l.name = name.to_string();
|
l.name = name.to_string();
|
||||||
}
|
}
|
||||||
|
// Measure in source space: destination refresh may clip a shape before it is moved.
|
||||||
|
// Missing caches must participate too, including shapes nested inside groups.
|
||||||
|
prepare_shapes(&mut copies.layers, &sdoc, false);
|
||||||
|
let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b));
|
||||||
|
let content = if bounds.is_empty() { sdoc.bounds() } else { bounds };
|
||||||
adapt_copies(s, &mut copies, &ddoc)?;
|
adapt_copies(s, &mut copies, &ddoc)?;
|
||||||
// Placement: centred on the destination's canvas, centred on a point, or offset from where
|
// Placement: centred on the destination's canvas, centred on a point, or offset from where
|
||||||
// the layers are in the source (default: the same canvas position).
|
// the layers are in the source (default: the same canvas position).
|
||||||
let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b));
|
|
||||||
let content = if bounds.is_empty() { sdoc.bounds() } else { bounds };
|
|
||||||
let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) {
|
let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
let (c, d) = (center(content), center(ddoc.bounds()));
|
let (c, d) = (center(content), center(ddoc.bounds()));
|
||||||
(d[0] - c[0], d[1] - c[1])
|
(d[0] - c[0], d[1] - c[1])
|
||||||
@@ -187,7 +208,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1]))
|
point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1]))
|
||||||
};
|
};
|
||||||
let (dx, dy) = (dx.round() as i32, dy.round() as i32);
|
let (dx, dy) = (dx.round() as i32, dy.round() as i32);
|
||||||
translate_copies(&mut copies, dx, dy);
|
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 patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect();
|
||||||
s.set_active(dest);
|
s.set_active(dest);
|
||||||
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
|
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
label: "Transform Selection",
|
label: "Transform Selection",
|
||||||
menu: &["Select"],
|
menu: &["Select"],
|
||||||
shortcut: None,
|
shortcut: None,
|
||||||
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest"="bilinear"}"##,
|
params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##,
|
||||||
enabled: has_selection,
|
enabled: has_selection,
|
||||||
run: transform_selection,
|
run: transform_selection,
|
||||||
journal: true,
|
journal: true,
|
||||||
@@ -297,6 +297,26 @@ mod tests {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #8697: Photoshop's bicubic variants are accepted and render as Bicubic, for a selection
|
||||||
|
/// and for a layer (an imported action keeps them).
|
||||||
|
#[test]
|
||||||
|
fn photoshop_bicubic_variants_transform_as_bicubic() {
|
||||||
|
let run = |id: &str, interpolation: &str| {
|
||||||
|
let mut s = landscape(8);
|
||||||
|
s.execute("select.rect", json!({"x": 20, "y": 10, "width": 60, "height": 40, "feather": 3})).unwrap();
|
||||||
|
s.execute(id, json!({"scaleX": 137, "scaleY": 81, "rotate": 7, "interpolation": interpolation})).unwrap();
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
(st.doc.selection.clone(), st.doc.layers.iter().map(|l| l.surface().cloned()).collect::<Vec<_>>())
|
||||||
|
};
|
||||||
|
for id in ["select.transformSelection", "edit.transform"] {
|
||||||
|
let bicubic = run(id, "bicubic");
|
||||||
|
assert_ne!(bicubic, run(id, "bilinear"), "{id}: the interpolation matters here");
|
||||||
|
for variant in ["bicubicSharper", "bicubicSmoother", "bicubicAutomatic"] {
|
||||||
|
assert_eq!(run(id, variant), bicubic, "{id} {variant}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sky_selects_the_sky_at_every_depth() {
|
fn sky_selects_the_sky_at_every_depth() {
|
||||||
for depth in [8, 16, 32] {
|
for depth in [8, 16, 32] {
|
||||||
|
|||||||
@@ -859,7 +859,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
label: "Free Transform",
|
label: "Free Transform",
|
||||||
menu: &[],
|
menu: &[],
|
||||||
shortcut: None,
|
shortcut: None,
|
||||||
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
||||||
enabled: has_layer,
|
enabled: has_layer,
|
||||||
journal: true,
|
journal: true,
|
||||||
run: transform,
|
run: transform,
|
||||||
|
|||||||
@@ -506,7 +506,8 @@ pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64)
|
|||||||
let canvas = doc.bounds();
|
let canvas = doc.bounds();
|
||||||
let inside = sh.cache.as_ref().is_some_and(|c| {
|
let inside = sh.cache.as_ref().is_some_and(|c| {
|
||||||
let b = photocraft_compose::bounds::content_bounds(c);
|
let b = photocraft_compose::bounds::content_bounds(c);
|
||||||
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
|
// An empty cache can be an off-canvas shape moving back into view.
|
||||||
|
!b.is_empty() && b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1
|
||||||
});
|
});
|
||||||
let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil()));
|
let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil()));
|
||||||
let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| {
|
let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! Copy placement must use source-space bounds before destination cache clipping.
|
||||||
|
use photocraft_doc::LayerContent;
|
||||||
|
use photocraft_engine::Session;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// Cover source clipping, groups, missing caches, destination depth, placement and Undo/Redo.
|
||||||
|
#[test]
|
||||||
|
fn copying_shapes_to_smaller_documents_preserves_requested_center() {
|
||||||
|
for x in [90, 700] {
|
||||||
|
for grouped in [false, true] {
|
||||||
|
for missing_cache in [false, true] {
|
||||||
|
for depth in [8, 16] {
|
||||||
|
for at in [None, Some([40, 60])] {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", json!({"width":800,"height":600,"background":"transparent"})).unwrap();
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for offset in if grouped { vec![0, 30] } else { vec![0] } {
|
||||||
|
s.execute("shape.create", json!({"kind":"rect","rect":[x+offset,20,20,20],"fill":"#ff0000"})).unwrap();
|
||||||
|
s.execute("layer.layerStyle.dropShadow", json!({"size":4,"distance":3})).unwrap();
|
||||||
|
ids.push(s.active().unwrap().active_layer.unwrap());
|
||||||
|
}
|
||||||
|
if grouped {
|
||||||
|
s.active_mut().unwrap().selected_layers = ids.clone();
|
||||||
|
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||||
|
}
|
||||||
|
if missing_cache {
|
||||||
|
s.edit("drop cached shapes", |doc, _| {
|
||||||
|
for id in &ids {
|
||||||
|
let LayerContent::Shape(shape) = &mut doc.layer_mut(*id).unwrap().content else { panic!("shape") };
|
||||||
|
shape.cache = None;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
s.execute("file.new", json!({"width":100,"height":100,"depth":depth,"background":"transparent"})).unwrap();
|
||||||
|
let before = s.active().unwrap().doc.clone();
|
||||||
|
let mut params = json!({"source":0,"document":1,"center":true});
|
||||||
|
if let Some(at) = at {
|
||||||
|
params["center"] = json!(false);
|
||||||
|
params["at"] = json!(at);
|
||||||
|
}
|
||||||
|
let result = s.execute("layer.copyToDocument", params).unwrap();
|
||||||
|
let target = at.unwrap_or([50, 50]);
|
||||||
|
let dx = target[0] - (x + if grouped { 25 } else { 10 });
|
||||||
|
let dy = target[1] - 30;
|
||||||
|
assert_eq!(result["offset"], json!([dx, dy]), "x={x}, grouped={grouped}, cache missing={missing_cache}");
|
||||||
|
let st = s.active().unwrap();
|
||||||
|
let shapes: Vec<_> = st
|
||||||
|
.doc
|
||||||
|
.walk()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, _, layer)| if let LayerContent::Shape(shape) = &layer.content { Some(shape) } else { None })
|
||||||
|
.collect();
|
||||||
|
assert_eq!(shapes.len(), if grouped { 2 } else { 1 });
|
||||||
|
for shape in shapes {
|
||||||
|
let fresh = photocraft_vector::render_shape(shape, st.doc.pixel_format(), st.doc.bounds());
|
||||||
|
assert!(
|
||||||
|
shape.cache.as_ref() == Some(&fresh),
|
||||||
|
"cache mismatch: x={x}, grouped={grouped}, cache missing={missing_cache}, depth={depth}, at={at:?}"
|
||||||
|
);
|
||||||
|
assert!(!fresh.content_bounds().is_empty());
|
||||||
|
}
|
||||||
|
let after = st.doc.clone();
|
||||||
|
assert!(s.undo());
|
||||||
|
assert_eq!(s.active().unwrap().doc, before);
|
||||||
|
assert!(s.redo());
|
||||||
|
assert_eq!(s.active().unwrap().doc, after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ mod clipping_semantics;
|
|||||||
mod close_document;
|
mod close_document;
|
||||||
mod command_lookup;
|
mod command_lookup;
|
||||||
mod convert_keeps_references;
|
mod convert_keeps_references;
|
||||||
|
mod copy_shape_placement;
|
||||||
mod corpus_atn;
|
mod corpus_atn;
|
||||||
mod crop_content_aware;
|
mod crop_content_aware;
|
||||||
mod current_affinity_artboards;
|
mod current_affinity_artboards;
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ fn read_lookup(d: &[u8]) -> Result<Adjustment, String> {
|
|||||||
_ => ("x.cube", "CUBE"),
|
_ => ("x.cube", "CUBE"),
|
||||||
};
|
};
|
||||||
let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?;
|
let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?;
|
||||||
|
if !lut.domain_is_default() {
|
||||||
|
return Err(format!(
|
||||||
|
"its LUT has a non-default input domain (DOMAIN_MIN {:?}, DOMAIN_MAX {:?}), which is not supported yet",
|
||||||
|
lut.domain_min, lut.domain_max
|
||||||
|
));
|
||||||
|
}
|
||||||
let name = desc_text(&desc, "LUT3DFileName").or_else(|| desc_text(&desc, "NM ")).unwrap_or_default();
|
let name = desc_text(&desc, "LUT3DFileName").or_else(|| desc_text(&desc, "NM ")).unwrap_or_default();
|
||||||
Ok(Adjustment::ColorLookup {
|
Ok(Adjustment::ColorLookup {
|
||||||
name,
|
name,
|
||||||
@@ -1112,6 +1118,46 @@ mod tests {
|
|||||||
assert!(matches!(parse(b"selc", &[0, 1, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. }));
|
assert!(matches!(parse(b"selc", &[0, 1, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cube_lookup_block(cube: &[u8]) -> Vec<u8> {
|
||||||
|
let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) };
|
||||||
|
let desc = Descriptor::new("null")
|
||||||
|
.with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE"))
|
||||||
|
.with("LUT3DFileName", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("domain.cube")))
|
||||||
|
.with("LUT3DFileData", Value::RawData(cube.to_vec()));
|
||||||
|
let mut block = vec![0, 1];
|
||||||
|
block.extend(VersionedDescriptor::new(desc).to_bytes());
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_cube(domain: &str) -> Vec<u8> {
|
||||||
|
format!("{domain}LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_lookup_custom_domains_are_kept_unsupported() {
|
||||||
|
let domains = ["DOMAIN_MIN 0 0 0\nDOMAIN_MAX 0.5 0.5 0.5\n", "DOMAIN_MIN 0 0.25 0\nDOMAIN_MAX 1 0.75 1\n", "LUT_3D_INPUT_RANGE 0 0.5\n"];
|
||||||
|
for domain in domains {
|
||||||
|
let raw = cube_lookup_block(&identity_cube(domain));
|
||||||
|
let Adjustment::Unsupported { psd_key, raw: kept } = parse(b"clrL", &raw, None, Channels::Rgb) else {
|
||||||
|
panic!("custom LUT domain was applied instead of kept unsupported: {domain}")
|
||||||
|
};
|
||||||
|
assert_eq!(psd_key, "clrL");
|
||||||
|
assert_eq!(*kept, *raw);
|
||||||
|
let reason = unreadable_reason(b"clrL", &raw).expect("custom domain has an explanation");
|
||||||
|
assert!(reason.contains("DOMAIN_MIN") && reason.contains("DOMAIN_MAX"), "{reason}");
|
||||||
|
assert!(reason.to_ascii_lowercase().contains("not supported"), "{reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_lookup_default_domains_stay_supported() {
|
||||||
|
for domain in ["", "DOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n"] {
|
||||||
|
let raw = cube_lookup_block(&identity_cube(domain));
|
||||||
|
assert!(matches!(parse(b"clrL", &raw, None, Channels::Rgb), Adjustment::ColorLookup { size: 2, .. }));
|
||||||
|
assert_eq!(unreadable_reason(b"clrL", &raw), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_falls_back_to_unsupported() {
|
fn malformed_falls_back_to_unsupported() {
|
||||||
assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. }));
|
assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. }));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
|
|||||||
use photocraft_geom::Size;
|
use photocraft_geom::Size;
|
||||||
use photocraft_io::*;
|
use photocraft_io::*;
|
||||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||||
|
use photocraft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen};
|
||||||
use photocraft_raster::Surface;
|
use photocraft_raster::Surface;
|
||||||
|
|
||||||
fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec<String>) {
|
fn reopen(adj: Adjustment, path: &str, opts: &ExportOptions) -> (Document, Vec<String>) {
|
||||||
@@ -48,6 +49,10 @@ fn readable_adjustments_open_without_warnings() {
|
|||||||
/// cyan, so the table is not the identity).
|
/// cyan, so the table is not the identity).
|
||||||
fn photoshop_clr_l_block() -> Vec<u8> {
|
fn photoshop_clr_l_block() -> Vec<u8> {
|
||||||
let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
let cube = b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||||
|
photoshop_clr_l_block_from_cube(cube)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn photoshop_clr_l_block_from_cube(cube: &[u8]) -> Vec<u8> {
|
||||||
let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) };
|
let en = |t: &str, val: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(val) };
|
||||||
let text = |t: &str| Value::Text(UnicodeString::new_nul(t));
|
let text = |t: &str| Value::Text(UnicodeString::new_nul(t));
|
||||||
let desc = Descriptor::new("null")
|
let desc = Descriptor::new("null")
|
||||||
@@ -61,6 +66,46 @@ fn photoshop_clr_l_block() -> Vec<u8> {
|
|||||||
raw
|
raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn identity_cube(domain: &str) -> Vec<u8> {
|
||||||
|
format!("{domain}LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_lookup_psd(version: Version, depth: u16, cube: &[u8]) -> Vec<u8> {
|
||||||
|
const LAYER_NAME: &str = "Custom-domain LUT";
|
||||||
|
let mut file = testgen::layered(version, photocraft_psd::ColorMode::Rgb, depth, Compression::Rle);
|
||||||
|
let mut flags = LayerFlags::default();
|
||||||
|
flags.set_pixel_data_irrelevant(true);
|
||||||
|
// Photoshop stores layer-block alignment inside the block length. Make the source payload
|
||||||
|
// canonical so the exporter can preserve it exactly rather than adding alignment on save.
|
||||||
|
let mut clrl = photoshop_clr_l_block_from_cube(cube);
|
||||||
|
clrl.resize(clrl.len().next_multiple_of(2), 0);
|
||||||
|
let record = LayerRecord {
|
||||||
|
name: LAYER_NAME.as_bytes().to_vec(),
|
||||||
|
flags,
|
||||||
|
blocks: vec![TaggedBlock::new(*b"clrL", clrl), TaggedBlock::unicode_name(LAYER_NAME), TaggedBlock::layer_id(9876)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
file.layers_mut().insert(0, record);
|
||||||
|
file.to_bytes().expect("serialize synthetic PSD/PSB")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_lookup_raw(doc: &Document) -> &[u8] {
|
||||||
|
let layer = doc.layers.iter().find(|layer| layer.name == "Custom-domain LUT").expect("lookup layer");
|
||||||
|
let LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) = &layer.content else {
|
||||||
|
panic!("expected preserved unsupported Color Lookup, got {:?}", layer.content)
|
||||||
|
};
|
||||||
|
assert_eq!(psd_key, "clrL");
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsed_lookup_block(bytes: &[u8]) -> (String, Vec<u8>) {
|
||||||
|
let file = PsdFile::from_bytes(bytes).expect("parse PSD/PSB");
|
||||||
|
let layer = file.layers().iter().find(|layer| layer.name() == "Custom-domain LUT").expect("lookup layer record");
|
||||||
|
let blocks: Vec<_> = layer.blocks.iter().filter(|block| block.key == *b"clrL").collect();
|
||||||
|
assert_eq!(blocks.len(), 1, "lookup blocks on layer: {blocks:?}");
|
||||||
|
(layer.name(), blocks[0].data.clone())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn photoshop_layout_color_lookup_round_trips_through_psb() {
|
fn photoshop_layout_color_lookup_round_trips_through_psb() {
|
||||||
// A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a
|
// A kept-raw `clrL` block in Photoshop's layout survives a PSB round trip and reopens as a
|
||||||
@@ -109,3 +154,59 @@ fn short_color_lookup_table_warns_on_open() {
|
|||||||
let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default());
|
let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default());
|
||||||
assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}");
|
assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_domain_lookup_warns_and_round_trips_verbatim() {
|
||||||
|
let domains = ["DOMAIN_MIN 0 0 0\nDOMAIN_MAX 0.5 0.5 0.5\n", "DOMAIN_MIN 0 0.25 0\nDOMAIN_MAX 1 0.75 1\n", "LUT_3D_INPUT_RANGE 0 0.5\n"];
|
||||||
|
for domain in domains {
|
||||||
|
let cube = identity_cube(domain);
|
||||||
|
for (version, depth, extension) in [
|
||||||
|
(Version::Psd, 8, "psd"),
|
||||||
|
(Version::Psd, 16, "psd"),
|
||||||
|
(Version::Psd, 32, "psd"),
|
||||||
|
(Version::Psb, 8, "psb"),
|
||||||
|
(Version::Psb, 16, "psb"),
|
||||||
|
(Version::Psb, 32, "psb"),
|
||||||
|
] {
|
||||||
|
let input = synthetic_lookup_psd(version, depth, &cube);
|
||||||
|
let (_, source_block) = parsed_lookup_block(&input);
|
||||||
|
let name = format!("input.{extension}");
|
||||||
|
let opened = import(&name, &input).expect("open synthetic PSD/PSB");
|
||||||
|
assert_eq!(unsupported_lookup_raw(&opened.document), source_block, "{extension}, {depth}-bit, {domain:?}");
|
||||||
|
let warnings: Vec<_> = opened.warnings.iter().filter(|warning| warning.contains("Custom-domain LUT")).collect();
|
||||||
|
assert_eq!(warnings.len(), 1, "{extension}, {depth}-bit: {:?}", opened.warnings);
|
||||||
|
assert!(
|
||||||
|
warnings[0].contains("Color Lookup")
|
||||||
|
&& warnings[0].contains("DOMAIN_MIN")
|
||||||
|
&& warnings[0].contains("DOMAIN_MAX")
|
||||||
|
&& warnings[0].to_ascii_lowercase().contains("not supported"),
|
||||||
|
"{}",
|
||||||
|
warnings[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
let native = export(&opened.document, "preserved.pcraft", &ExportOptions::default()).expect("save native document");
|
||||||
|
let native_open = import("preserved.pcraft", &native.bytes).expect("reopen native document");
|
||||||
|
assert_eq!(unsupported_lookup_raw(&native_open.document), source_block, "native preservation, {extension}, {depth}-bit");
|
||||||
|
|
||||||
|
let output_name = format!("roundtrip.{extension}");
|
||||||
|
let options = ExportOptions { force_psb: version.is_psb(), ..Default::default() };
|
||||||
|
let saved = export(&native_open.document, &output_name, &options).expect("re-export PSD/PSB");
|
||||||
|
let (_, output_block) = parsed_lookup_block(&saved.bytes);
|
||||||
|
assert_eq!(output_block, source_block, "saved block, {extension}, {depth}-bit, {domain:?}");
|
||||||
|
let reopened = import(&output_name, &saved.bytes).expect("reopen re-exported PSD/PSB");
|
||||||
|
assert_eq!(unsupported_lookup_raw(&reopened.document), source_block, "reopened block, {extension}, {depth}-bit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_domain_lookup_stays_readable() {
|
||||||
|
for (version, domain, extension) in [(Version::Psd, "", "psd"), (Version::Psb, "DOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n", "psb")] {
|
||||||
|
let cube = identity_cube(domain);
|
||||||
|
let input = synthetic_lookup_psd(version, 8, &cube);
|
||||||
|
let opened = import(&format!("default.{extension}"), &input).expect("open default-domain LUT");
|
||||||
|
let layer = opened.document.layers.iter().find(|layer| layer.name == "Custom-domain LUT").expect("lookup layer");
|
||||||
|
assert!(matches!(layer.content, LayerContent::Adjustment(Adjustment::ColorLookup { size: 2, .. })), "{:?}", layer.content);
|
||||||
|
assert!(opened.warnings.iter().all(|warning| !warning.contains("Custom-domain LUT")), "{:?}", opened.warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+109
-76
@@ -323,56 +323,31 @@ pub struct Reply {
|
|||||||
pub attributes: Vec<Attribute>,
|
pub attributes: Vec<Attribute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The attribute range for one group delimiter in a parsed message. Ranges are half-open and
|
||||||
|
/// include empty groups, so repeated group tags remain distinct.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct GroupRange {
|
||||||
|
tag: u8,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed message details kept private so the public flat `Reply` API stays source-compatible.
|
||||||
|
struct ParsedMessage {
|
||||||
|
reply: Reply,
|
||||||
|
end: usize,
|
||||||
|
groups: Vec<GroupRange>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Reply {
|
impl Reply {
|
||||||
/// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag.
|
/// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag.
|
||||||
pub fn parse(b: &[u8]) -> Result<Reply> {
|
pub fn parse(b: &[u8]) -> Result<Reply> {
|
||||||
Self::parse_at(b).map(|(r, _)| r)
|
parse_message(b).map(|parsed| parsed.reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a message and returns where its attributes end (document data may follow).
|
/// Parses a message and returns where its attributes end (document data may follow).
|
||||||
fn parse_at(b: &[u8]) -> Result<(Reply, usize)> {
|
fn parse_at(b: &[u8]) -> Result<(Reply, usize)> {
|
||||||
let bad = |m: &str| Error::Protocol(m.to_string());
|
parse_message(b).map(|parsed| (parsed.reply, parsed.end))
|
||||||
if b.len() < 9 {
|
|
||||||
return Err(bad("reply shorter than an IPP header"));
|
|
||||||
}
|
|
||||||
let status = u16::from_be_bytes([b[2], b[3]]);
|
|
||||||
let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]);
|
|
||||||
let mut pos = 8;
|
|
||||||
let mut group = 0u8;
|
|
||||||
let mut attributes: Vec<Attribute> = Vec::new();
|
|
||||||
loop {
|
|
||||||
let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?;
|
|
||||||
pos += 1;
|
|
||||||
if tag == TAG_END {
|
|
||||||
return Ok((Reply { status, request_id, attributes }, pos));
|
|
||||||
}
|
|
||||||
if tag < 0x10 {
|
|
||||||
group = tag;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name_len =
|
|
||||||
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
|
|
||||||
pos += 2;
|
|
||||||
let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?;
|
|
||||||
pos += name_len;
|
|
||||||
let value_len =
|
|
||||||
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
|
|
||||||
pos += 2;
|
|
||||||
let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec();
|
|
||||||
pos += value_len;
|
|
||||||
if name_len == 0 {
|
|
||||||
// An additional value of the previous attribute.
|
|
||||||
match attributes.last_mut() {
|
|
||||||
Some(last) if last.group == group => last.values.push(value),
|
|
||||||
_ => return Err(bad("an additional value with no attribute before it")),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] });
|
|
||||||
}
|
|
||||||
if attributes.len() > MAX_ATTRIBUTES {
|
|
||||||
return Err(bad("too many attributes"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The server's own message for an error status, or a name for the code.
|
/// The server's own message for an error status, or a name for the code.
|
||||||
@@ -394,8 +369,72 @@ impl Reply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POSTs `body` (IPP, with any document data after it) to `path` and returns the reply.
|
/// Parses one IPP message while preserving each group instance for private consumers that need
|
||||||
fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<Reply> {
|
/// object boundaries. The public reply remains a flat list of attributes.
|
||||||
|
fn parse_message(b: &[u8]) -> Result<ParsedMessage> {
|
||||||
|
let bad = |m: &str| Error::Protocol(m.to_string());
|
||||||
|
if b.len() < 9 {
|
||||||
|
return Err(bad("reply shorter than an IPP header"));
|
||||||
|
}
|
||||||
|
let status = u16::from_be_bytes([b[2], b[3]]);
|
||||||
|
let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]);
|
||||||
|
let mut pos = 8;
|
||||||
|
let mut group = 0u8;
|
||||||
|
let mut active_group = None;
|
||||||
|
let mut last_attribute_group = None;
|
||||||
|
let mut attributes: Vec<Attribute> = Vec::new();
|
||||||
|
let mut groups: Vec<GroupRange> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?;
|
||||||
|
pos += 1;
|
||||||
|
if tag == TAG_END {
|
||||||
|
if let Some(last) = groups.last_mut() {
|
||||||
|
last.end = attributes.len();
|
||||||
|
}
|
||||||
|
return Ok(ParsedMessage { reply: Reply { status, request_id, attributes }, end: pos, groups });
|
||||||
|
}
|
||||||
|
if tag < 0x10 {
|
||||||
|
if let Some(last) = groups.last_mut() {
|
||||||
|
last.end = attributes.len();
|
||||||
|
}
|
||||||
|
if groups.len() >= MAX_ATTRIBUTES {
|
||||||
|
return Err(bad("too many attribute groups"));
|
||||||
|
}
|
||||||
|
group = tag;
|
||||||
|
let group_index = groups.len();
|
||||||
|
groups.push(GroupRange { tag, start: attributes.len(), end: attributes.len() });
|
||||||
|
active_group = Some(group_index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name_len =
|
||||||
|
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
|
||||||
|
pos += 2;
|
||||||
|
let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?;
|
||||||
|
pos += name_len;
|
||||||
|
let value_len =
|
||||||
|
usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?]));
|
||||||
|
pos += 2;
|
||||||
|
let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec();
|
||||||
|
pos += value_len;
|
||||||
|
if name_len == 0 {
|
||||||
|
// Additional values can continue an attribute only within this exact group instance.
|
||||||
|
match attributes.last_mut() {
|
||||||
|
Some(last) if last_attribute_group == active_group => last.values.push(value),
|
||||||
|
_ => return Err(bad("an additional value with no attribute before it")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] });
|
||||||
|
last_attribute_group = active_group;
|
||||||
|
}
|
||||||
|
if attributes.len() > MAX_ATTRIBUTES {
|
||||||
|
return Err(bad("too many attributes"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POSTs `body` (IPP, with any document data after it) to `path` and returns the parsed reply with
|
||||||
|
/// its private group boundaries.
|
||||||
|
fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<ParsedMessage> {
|
||||||
let mut stream = endpoint.connect()?;
|
let mut stream = endpoint.connect()?;
|
||||||
let head = format!(
|
let head = format!(
|
||||||
"POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
"POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
@@ -417,9 +456,9 @@ fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<Reply> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() };
|
let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() };
|
||||||
let reply = Reply::parse(&body)?;
|
let reply = parse_message(&body)?;
|
||||||
if reply.status >= 0x0400 {
|
if reply.reply.status >= 0x0400 {
|
||||||
return Err(Error::Status(reply.status, reply.status_message()));
|
return Err(Error::Status(reply.reply.status, reply.reply.status_message()));
|
||||||
}
|
}
|
||||||
Ok(reply)
|
Ok(reply)
|
||||||
}
|
}
|
||||||
@@ -563,41 +602,35 @@ pub fn printers(endpoint: &Endpoint) -> Result<Vec<Printer>> {
|
|||||||
fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result<Vec<Printer>> {
|
fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result<Vec<Printer>> {
|
||||||
let mut r = Request::new(op, 1);
|
let mut r = Request::new(op, 1);
|
||||||
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]);
|
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]);
|
||||||
let reply = match post(endpoint, "/", &r.end()) {
|
let parsed = match post(endpoint, "/", &r.end()) {
|
||||||
Ok(reply) => reply,
|
Ok(reply) => reply,
|
||||||
// CUPS answers "not found" ("No destinations added.") when no printer is set up.
|
// CUPS answers "not found" ("No destinations added.") when no printer is set up.
|
||||||
Err(Error::Status(0x0406, _)) => return Ok(Vec::new()),
|
Err(Error::Status(0x0406, _)) => return Ok(Vec::new()),
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut current: Option<Printer> = None;
|
let attributes = &parsed.reply.attributes;
|
||||||
for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) {
|
for group in parsed.groups.iter().filter(|group| group.tag == TAG_PRINTER_GROUP) {
|
||||||
match a.name.as_str() {
|
let Some(group_attributes) = attributes.get(group.start..group.end) else {
|
||||||
"printer-name" => {
|
return Err(Error::Protocol("invalid printer attribute group range".into()));
|
||||||
if let Some(p) = current.take() {
|
};
|
||||||
out.push(p);
|
let mut name = None;
|
||||||
}
|
let mut info = String::new();
|
||||||
current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class });
|
let mut state = 3;
|
||||||
|
let mut accepting_jobs = true;
|
||||||
|
for a in group_attributes {
|
||||||
|
match a.name.as_str() {
|
||||||
|
"printer-name" if name.is_none() => name = Some(a.text()),
|
||||||
|
"printer-info" => info = a.text(),
|
||||||
|
"printer-state" => state = a.int().unwrap_or(3).clamp(0, 5) as u32,
|
||||||
|
"printer-is-accepting-jobs" => accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
"printer-info" => {
|
}
|
||||||
if let Some(p) = &mut current {
|
if let Some(name) = name.filter(|name| !name.is_empty()) {
|
||||||
p.info = a.text();
|
out.push(Printer { name, info, state, accepting_jobs, is_default: false, is_class });
|
||||||
}
|
|
||||||
}
|
|
||||||
"printer-state" => {
|
|
||||||
if let Some(p) = &mut current {
|
|
||||||
p.state = a.int().unwrap_or(3).clamp(0, 5) as u32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"printer-is-accepting-jobs" => {
|
|
||||||
if let Some(p) = &mut current {
|
|
||||||
p.accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.extend(current);
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,7 +639,7 @@ pub fn default_printer(endpoint: &Endpoint) -> Result<Option<String>> {
|
|||||||
let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2);
|
let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2);
|
||||||
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]);
|
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]);
|
||||||
match post(endpoint, "/", &r.end()) {
|
match post(endpoint, "/", &r.end()) {
|
||||||
Ok(reply) => Ok(reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())),
|
Ok(reply) => Ok(reply.reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())),
|
||||||
// CUPS answers "not found" when no default is set.
|
// CUPS answers "not found" when no default is set.
|
||||||
Err(Error::Status(0x0406, _)) => Ok(None),
|
Err(Error::Status(0x0406, _)) => Ok(None),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
@@ -642,7 +675,7 @@ pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result<u32> {
|
|||||||
}
|
}
|
||||||
let mut body = r.end();
|
let mut body = r.end();
|
||||||
body.extend_from_slice(job.document);
|
body.extend_from_slice(job.document);
|
||||||
let reply = post(endpoint, &path, &body)?;
|
let reply = post(endpoint, &path, &body)?.reply;
|
||||||
reply
|
reply
|
||||||
.attributes
|
.attributes
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -137,6 +137,161 @@ fn printers_are_listed_with_state_and_default() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn printer_attributes_before_name_are_preserved() {
|
||||||
|
let state = 5u32.to_be_bytes();
|
||||||
|
let base: [Attr<'_>; 4] = [
|
||||||
|
(TAG_TEXT, "printer-info", &[b"Studio Printer"]),
|
||||||
|
(TAG_ENUM, "printer-state", &[&state]),
|
||||||
|
(TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]),
|
||||||
|
(TAG_NAME, "printer-name", &[b"studio"]),
|
||||||
|
];
|
||||||
|
let body = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &base)]);
|
||||||
|
for chunked in [false, true] {
|
||||||
|
let (ep, _) = fake(body.clone(), chunked, 200);
|
||||||
|
let list = printers(&ep).unwrap();
|
||||||
|
assert_eq!(list.len(), 1, "chunked {chunked}");
|
||||||
|
assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state, list[0].accepting_jobs), ("studio", "Studio Printer", 5, false));
|
||||||
|
assert!(!list[0].is_class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn printer_groups_do_not_share_metadata() {
|
||||||
|
let state_a = 4u32.to_be_bytes();
|
||||||
|
let state_b = 5u32.to_be_bytes();
|
||||||
|
let body = ipp(
|
||||||
|
0,
|
||||||
|
&[
|
||||||
|
ok_header(),
|
||||||
|
(
|
||||||
|
TAG_PRINTER_GROUP,
|
||||||
|
&[(TAG_NAME, "printer-name", &[b"first"]), (TAG_TEXT, "printer-info", &[b"First printer"]), (TAG_ENUM, "printer-state", &[&state_a])],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
TAG_PRINTER_GROUP,
|
||||||
|
&[(TAG_TEXT, "printer-info", &[b"Second printer"]), (TAG_ENUM, "printer-state", &[&state_b]), (TAG_NAME, "printer-name", &[b"second"])],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (ep, _) = fake(body, false, 200);
|
||||||
|
let list = queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state), ("first", "First printer", 4));
|
||||||
|
assert_eq!((list[1].name.as_str(), list[1].info.as_str(), list[1].state), ("second", "Second printer", 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn printer_attribute_order_does_not_change_results() {
|
||||||
|
let state = 5u32.to_be_bytes();
|
||||||
|
let fields: [Attr<'_>; 4] = [
|
||||||
|
(TAG_TEXT, "printer-info", &[b"Studio Printer"]),
|
||||||
|
(TAG_ENUM, "printer-state", &[&state]),
|
||||||
|
(TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]),
|
||||||
|
(TAG_NAME, "printer-name", &[b"studio"]),
|
||||||
|
];
|
||||||
|
let expected = Printer { name: "studio".into(), info: "Studio Printer".into(), state: 5, accepting_jobs: false, is_default: false, is_class: false };
|
||||||
|
let mut permutations = 0;
|
||||||
|
for first in 0..4 {
|
||||||
|
for second in 0..4 {
|
||||||
|
for third in 0..4 {
|
||||||
|
for fourth in 0..4 {
|
||||||
|
let order = [first, second, third, fourth];
|
||||||
|
if order[0] == order[1]
|
||||||
|
|| order[0] == order[2]
|
||||||
|
|| order[0] == order[3]
|
||||||
|
|| order[1] == order[2]
|
||||||
|
|| order[1] == order[3]
|
||||||
|
|| order[2] == order[3]
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
permutations += 1;
|
||||||
|
let ordered = order.iter().map(|index| fields[*index]).collect::<Vec<_>>();
|
||||||
|
let body = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &ordered)]);
|
||||||
|
let (ep, _) = fake(body, false, 200);
|
||||||
|
assert_eq!(queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap(), vec![expected.clone()], "order {order:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(permutations, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn class_attributes_before_name_are_preserved() {
|
||||||
|
let state = 5u32.to_be_bytes();
|
||||||
|
let body = ipp(
|
||||||
|
0,
|
||||||
|
&[
|
||||||
|
ok_header(),
|
||||||
|
(
|
||||||
|
TAG_PRINTER_GROUP,
|
||||||
|
&[
|
||||||
|
(TAG_BOOLEAN, "printer-is-accepting-jobs", &[b"\0"]),
|
||||||
|
(TAG_TEXT, "printer-info", &[b"Shared studio"]),
|
||||||
|
(TAG_ENUM, "printer-state", &[&state]),
|
||||||
|
(TAG_NAME, "printer-name", &[b"studio-class"]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (ep, rx) = fake(body, true, 200);
|
||||||
|
let classes = queues(&ep, OP_CUPS_GET_CLASSES, true).unwrap();
|
||||||
|
assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_CLASSES);
|
||||||
|
assert_eq!(classes.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
(classes[0].name.as_str(), classes[0].info.as_str(), classes[0].state, classes[0].accepting_jobs, classes[0].is_class),
|
||||||
|
("studio-class", "Shared studio", 5, false, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_or_unnamed_groups_do_not_leak_metadata() {
|
||||||
|
let body = ipp(
|
||||||
|
0,
|
||||||
|
&[
|
||||||
|
ok_header(),
|
||||||
|
(TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"named"])]),
|
||||||
|
(TAG_PRINTER_GROUP, &[]),
|
||||||
|
(TAG_PRINTER_GROUP, &[(TAG_TEXT, "printer-info", &[b"orphan metadata"])]),
|
||||||
|
(TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"next"]), (TAG_ENUM, "printer-state", &[&3u32.to_be_bytes()])]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (ep, _) = fake(body, false, 200);
|
||||||
|
let list = queues(&ep, OP_CUPS_GET_PRINTERS, false).unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
assert_eq!((list[0].name.as_str(), list[0].info.as_str()), ("named", ""));
|
||||||
|
assert_eq!((list[1].name.as_str(), list[1].info.as_str()), ("next", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additional_values_do_not_cross_group_boundaries() {
|
||||||
|
let valid = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_KEYWORD, "supported", &[b"one", b"two"])])]);
|
||||||
|
let reply = Reply::parse(&valid).unwrap();
|
||||||
|
assert_eq!(reply.attributes.iter().find(|a| a.name == "supported").map(|a| a.values.as_slice()), Some(&[b"one".to_vec(), b"two".to_vec()][..]));
|
||||||
|
|
||||||
|
let mut body = vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_PRINTER_GROUP];
|
||||||
|
body.extend_from_slice(&[TAG_NAME, 0, 12]);
|
||||||
|
body.extend_from_slice(b"printer-name");
|
||||||
|
body.extend_from_slice(&[0, 5]);
|
||||||
|
body.extend_from_slice(b"first");
|
||||||
|
body.extend_from_slice(&[TAG_TEXT, 0, 12]);
|
||||||
|
body.extend_from_slice(b"printer-info");
|
||||||
|
body.extend_from_slice(&[0, 6]);
|
||||||
|
body.extend_from_slice(b"before");
|
||||||
|
body.push(TAG_PRINTER_GROUP);
|
||||||
|
body.extend_from_slice(&[TAG_TEXT, 0, 0, 0, 5]);
|
||||||
|
body.extend_from_slice(b"after");
|
||||||
|
body.extend_from_slice(&[TAG_NAME, 0, 12]);
|
||||||
|
body.extend_from_slice(b"printer-name");
|
||||||
|
body.extend_from_slice(&[0, 6]);
|
||||||
|
body.extend_from_slice(b"second");
|
||||||
|
body.push(TAG_END);
|
||||||
|
let (ep, _) = fake(body, true, 200);
|
||||||
|
assert!(matches!(queues(&ep, OP_CUPS_GET_PRINTERS, false), Err(Error::Protocol(_))));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_printer_is_read_and_absence_is_not_an_error() {
|
fn default_printer_is_read_and_absence_is_not_an_error() {
|
||||||
let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200);
|
let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200);
|
||||||
|
|||||||
@@ -279,6 +279,33 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
|||||||
Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields))
|
Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the dialog for `command` at the settings last confirmed in it (Photoshop's ⌥ plus the
|
||||||
|
/// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they
|
||||||
|
/// don't fit the current colour mode.
|
||||||
|
pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
||||||
|
let id = open(app, command)?;
|
||||||
|
let kind = command.strip_prefix(PREFIX).unwrap_or_default();
|
||||||
|
let mode = curve_mode(app, kind);
|
||||||
|
let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) };
|
||||||
|
let Ok(adjustment) = photocraft_engine::adjust_params::from_params(kind, &last, None, mode) else { return Some(id) };
|
||||||
|
if let (Some(dialog), Value::Object(values)) = (app.ui.dialog_mut(id), photocraft_engine::adjust_params::to_params(&adjustment)) {
|
||||||
|
dialog.fields.retain(|k, _| k.starts_with("__"));
|
||||||
|
dialog.fields.extend(values);
|
||||||
|
}
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a
|
||||||
|
/// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`).
|
||||||
|
pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map<String, Value>) {
|
||||||
|
if fields.contains_key("__smartFilter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(command) = fields.get("__command").and_then(Value::as_str).filter(|c| has_dialog(c)) else { return };
|
||||||
|
let values = crate::filter_dialog::params_of(fields);
|
||||||
|
app.session.prefs.edit(|prefs| prefs.dialogs.insert(command.into(), values));
|
||||||
|
}
|
||||||
|
|
||||||
/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask
|
/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask
|
||||||
/// selection — both ways of selecting a mask must give the same dropdown (#5335).
|
/// selection — both ways of selecting a mask must give the same dropdown (#5335).
|
||||||
fn isolated(app: &PhotocraftApp) -> Option<photocraft_engine::channel_cmds::IsolatedCurveChannel> {
|
fn isolated(app: &PhotocraftApp) -> Option<photocraft_engine::channel_cmds::IsolatedCurveChannel> {
|
||||||
|
|||||||
@@ -859,7 +859,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
|||||||
app.ui.view.guide_layout = params;
|
app.ui.view.guide_layout = params;
|
||||||
}
|
}
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
crate::filter_dialog::remember(app, &cmd, &d.fields);
|
if crate::adjust_dialog::owns(&d.fields) {
|
||||||
|
crate::adjust_dialog::remember(app, &d.fields);
|
||||||
|
} else {
|
||||||
|
crate::filter_dialog::remember(app, &cmd, &d.fields);
|
||||||
|
}
|
||||||
// A step dialog of a playing action: the action continues after it.
|
// A step dialog of a playing action: the action continues after it.
|
||||||
crate::actions::resume(app, &d.fields)?;
|
crate::actions::resume(app, &d.fields)?;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
Batch… Toplu…
|
Batch… Toplu…
|
||||||
Create Droplet… Damlacık Oluştur…
|
Create Droplet… Damlacık Oluştur…
|
||||||
Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt
|
Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt
|
||||||
Contact Sheet II… İletişim Sayfası II…
|
Contact Sheet II… Kontak Sayfası II…
|
||||||
Conditional Mode Change… Koşullu Mod Değişikliği…
|
Conditional Mode Change… Koşullu Mod Değişikliği…
|
||||||
Fit Image… Görüntüyü Sığdır…
|
Fit Image… Görüntüyü Sığdır…
|
||||||
Lens Correction… Mercek Düzeltme…
|
Lens Correction… Mercek Düzeltme…
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
Threshold… Eşik…
|
Threshold… Eşik…
|
||||||
Gradient Map… Degrade Eşlemesi…
|
Gradient Map… Degrade Eşlemesi…
|
||||||
Selective Color… Seçici Renk…
|
Selective Color… Seçici Renk…
|
||||||
Shadows/Highlights… Gölgeler/Vurgular…
|
Shadows/Highlights… Gölgeler/Parlak Tonlar…
|
||||||
HDR Toning… HDR Tonlama…
|
HDR Toning… HDR Tonlama…
|
||||||
Desaturate Doygunluğu Azalt
|
Desaturate Doygunluğu Azalt
|
||||||
Match Color… Rengi Eşleştir…
|
Match Color… Rengi Eşleştir…
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
Stamp Down Aşağıya Damgala
|
Stamp Down Aşağıya Damgala
|
||||||
Flatten Image Görüntüyü Düzleştir
|
Flatten Image Görüntüyü Düzleştir
|
||||||
Matting Matlama
|
Matting Matlama
|
||||||
Color Decontaminate… Renkli Dekontamine…
|
Color Decontaminate… Renkleri Arındır…
|
||||||
Remove Black Matte Siyah Matı Kaldır
|
Remove Black Matte Siyah Matı Kaldır
|
||||||
Remove White Matte Beyaz Matı Kaldır
|
Remove White Matte Beyaz Matı Kaldır
|
||||||
Defringe… Kenar Halesini Kaldır…
|
Defringe… Kenar Halesini Kaldır…
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
Right Edges Sağ Kenarlar
|
Right Edges Sağ Kenarlar
|
||||||
Distribute Dağıt
|
Distribute Dağıt
|
||||||
Vertically Dikey Olarak
|
Vertically Dikey Olarak
|
||||||
Horizontally Yatay olarak
|
Horizontally Yatay Olarak
|
||||||
Layer Edges Katman Kenarları
|
Layer Edges Katman Kenarları
|
||||||
Isolate Layers Katmanları Yalıt
|
Isolate Layers Katmanları Yalıt
|
||||||
Layer Content Options… Katman İçerik Seçenekleri…
|
Layer Content Options… Katman İçerik Seçenekleri…
|
||||||
@@ -777,9 +777,9 @@
|
|||||||
Other… Diğer…
|
Other… Diğer…
|
||||||
Remove Warp Split Çarpıtma Bölünmesini Kaldır
|
Remove Warp Split Çarpıtma Bölünmesini Kaldır
|
||||||
Shape Şekil
|
Shape Şekil
|
||||||
Split Warp Crosswise Çözgüyü Çapraz Olarak Böl
|
Split Warp Crosswise Çarpıtmayı Çapraz Olarak Böl
|
||||||
Split Warp Horizontally Çözgüyü Yatay Olarak Böl
|
Split Warp Horizontally Çarpıtmayı Yatay Olarak Böl
|
||||||
Split Warp Vertically Çözgüyü Dikey Olarak Böl
|
Split Warp Vertically Çarpıtmayı Dikey Olarak Böl
|
||||||
Statistics… İstatistikler…
|
Statistics… İstatistikler…
|
||||||
Refine Strokes Fırça Vuruşlarını Hassaslaştır
|
Refine Strokes Fırça Vuruşlarını Hassaslaştır
|
||||||
Edits Düzenlemeler
|
Edits Düzenlemeler
|
||||||
@@ -889,7 +889,7 @@
|
|||||||
Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc)
|
Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc)
|
||||||
Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc)
|
Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc)
|
||||||
Cancel transform (Esc) Dönüşümü iptal et (Esc)
|
Cancel transform (Esc) Dönüşümü iptal et (Esc)
|
||||||
Cancel warp (Esc) Çözgüyü iptal et (Esc)
|
Cancel warp (Esc) Çarpıtmayı İptal Et (Esc)
|
||||||
Canvas Tuval
|
Canvas Tuval
|
||||||
Canvas extension color: Tuval uzatma rengi:
|
Canvas extension color: Tuval uzatma rengi:
|
||||||
Capture Yakala
|
Capture Yakala
|
||||||
@@ -946,7 +946,7 @@
|
|||||||
Commit warp ({key}) Çarpıtmayı onayla ({key})
|
Commit warp ({key}) Çarpıtmayı onayla ({key})
|
||||||
Constant Sabit
|
Constant Sabit
|
||||||
Constrain proportions Oranları koru
|
Constrain proportions Oranları koru
|
||||||
Contact Sheet II İletişim Sayfası II
|
Contact Sheet II Kontak Sayfası II
|
||||||
Content-Aware İçeriğe Duyarlı
|
Content-Aware İçeriğe Duyarlı
|
||||||
Contiguous Bitişik
|
Contiguous Bitişik
|
||||||
Contrast Kontrast
|
Contrast Kontrast
|
||||||
@@ -1056,7 +1056,7 @@
|
|||||||
Dots Noktalar
|
Dots Noktalar
|
||||||
Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin
|
Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin
|
||||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın.
|
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın.
|
||||||
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın.
|
Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift tuşunu kullanın; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın.
|
||||||
Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin
|
Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin
|
||||||
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı
|
Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı
|
||||||
Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun.
|
Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun.
|
||||||
@@ -1147,7 +1147,7 @@
|
|||||||
Hide Properties Özellikleri Gizle
|
Hide Properties Özellikleri Gizle
|
||||||
Highlight Compression Parlak Ton Sıkıştırma
|
Highlight Compression Parlak Ton Sıkıştırma
|
||||||
Highlight Priority Parlak Ton Önceliği
|
Highlight Priority Parlak Ton Önceliği
|
||||||
Highlights Öne Çıkanlar
|
Highlights Parlak Tonlar
|
||||||
Home Ana sayfa
|
Home Ana sayfa
|
||||||
Horizontal scale Yatay ölçek
|
Horizontal scale Yatay ölçek
|
||||||
Hue Ton
|
Hue Ton
|
||||||
@@ -1183,7 +1183,7 @@
|
|||||||
Kerning Karakter Aralığı
|
Kerning Karakter Aralığı
|
||||||
Keyboard Shortcuts Klavye Kısayolları
|
Keyboard Shortcuts Klavye Kısayolları
|
||||||
Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler
|
Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler
|
||||||
Kind tür
|
Kind Tür
|
||||||
Label Size Etiket Boyutu
|
Label Size Etiket Boyutu
|
||||||
Labels Etiketler
|
Labels Etiketler
|
||||||
Lanczos (sharp) Lanczos (keskin)
|
Lanczos (sharp) Lanczos (keskin)
|
||||||
@@ -1219,7 +1219,7 @@
|
|||||||
Lock: Kilit:
|
Lock: Kilit:
|
||||||
Logical Length: Mantıksal Uzunluk:
|
Logical Length: Mantıksal Uzunluk:
|
||||||
Logical Units: Mantıksal Birimler:
|
Logical Units: Mantıksal Birimler:
|
||||||
Luminance parlaklık
|
Luminance Lüminans
|
||||||
Luminance Detail Lüminans Ayrıntısı
|
Luminance Detail Lüminans Ayrıntısı
|
||||||
Magenta Macenta
|
Magenta Macenta
|
||||||
Magenta · Green Macenta · Yeşil
|
Magenta · Green Macenta · Yeşil
|
||||||
@@ -1521,7 +1521,7 @@
|
|||||||
Tracking (1/1000 em) İzleme (1/1000 em)
|
Tracking (1/1000 em) İzleme (1/1000 em)
|
||||||
Transparency Saydamlık
|
Transparency Saydamlık
|
||||||
Transparent Saydam
|
Transparent Saydam
|
||||||
Trilinear Üç çizgili
|
Trilinear Üç Doğrusal
|
||||||
Type Styles Metin Stilleri
|
Type Styles Metin Stilleri
|
||||||
Type: Tür:
|
Type: Tür:
|
||||||
Underline Altı çizili
|
Underline Altı çizili
|
||||||
@@ -1644,7 +1644,7 @@
|
|||||||
ExtraBold Ekstra Kalın
|
ExtraBold Ekstra Kalın
|
||||||
ExtraLight Ekstra Hafif
|
ExtraLight Ekstra Hafif
|
||||||
Eyedropper Tool Damlalık Aracı
|
Eyedropper Tool Damlalık Aracı
|
||||||
Fade Solmaya
|
Fade Solma
|
||||||
Fill Opacity Dolgu Opaklığı
|
Fill Opacity Dolgu Opaklığı
|
||||||
Film & Video Film ve Video
|
Film & Video Film ve Video
|
||||||
From Şuradan
|
From Şuradan
|
||||||
@@ -1657,14 +1657,14 @@
|
|||||||
Hard Sert
|
Hard Sert
|
||||||
Hard Mix Sert Karışım
|
Hard Mix Sert Karışım
|
||||||
Healing Brush Tool Düzeltme Fırçası Aracı
|
Healing Brush Tool Düzeltme Fırçası Aracı
|
||||||
Highlights from: Öne çıkanlar:
|
Highlights from: Parlak tonlar (başlangıç):
|
||||||
History Brush Tool Geçmiş Fırçası Aracı
|
History Brush Tool Geçmiş Fırçası Aracı
|
||||||
Horizontal Type Tool Yatay Metin Aracı
|
Horizontal Type Tool Yatay Metin Aracı
|
||||||
Horizontal Type Mask Tool Yatay Metin Maskesi Aracı
|
Horizontal Type Mask Tool Yatay Metin Maskesi Aracı
|
||||||
Vertical Type Mask Tool Dikey Metin Maskesi Aracı
|
Vertical Type Mask Tool Dikey Metin Maskesi Aracı
|
||||||
Image Size: Görüntü Boyutu:
|
Image Size: Görüntü Boyutu:
|
||||||
Inches İnç
|
Inches İnç
|
||||||
Indexed Dizine eklendi
|
Indexed İndekslenmiş
|
||||||
Initial Direction Başlangıç Yönü
|
Initial Direction Başlangıç Yönü
|
||||||
Inner Bevel İç Eğim
|
Inner Bevel İç Eğim
|
||||||
Inner Glow İç Işıma
|
Inner Glow İç Işıma
|
||||||
@@ -1676,7 +1676,7 @@
|
|||||||
Lasso Kement
|
Lasso Kement
|
||||||
Lasso Tool Kement Aracı
|
Lasso Tool Kement Aracı
|
||||||
Layer Count Katman Sayısı
|
Layer Count Katman Sayısı
|
||||||
Layer Knocks Out Drop Shadow Katman Düşen Gölgeyi Deler
|
Layer Knocks Out Drop Shadow Katman Alt Gölgeyi Gizler
|
||||||
Legal Legal
|
Legal Legal
|
||||||
Letter Letter
|
Letter Letter
|
||||||
Light Hafif
|
Light Hafif
|
||||||
@@ -2037,7 +2037,7 @@
|
|||||||
Expand Genişlet
|
Expand Genişlet
|
||||||
Expand Panel Group Panel Grubunu Genişlet
|
Expand Panel Group Panel Grubunu Genişlet
|
||||||
Expand group ({key}-click: all groups) Grubu genişlet ({key}-tıklayın: tüm gruplar)
|
Expand group ({key}-click: all groups) Grubu genişlet ({key}-tıklayın: tüm gruplar)
|
||||||
Fade steps Adımları soldur
|
Fade steps Solma Adımı Sayısı
|
||||||
Hide the layer's effects ({key}-click: all layers) Katmanın efektlerini gizle ({key}-tıklama: tüm katmanlar)
|
Hide the layer's effects ({key}-click: all layers) Katmanın efektlerini gizle ({key}-tıklama: tüm katmanlar)
|
||||||
Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr)
|
Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr)
|
||||||
Move Group Down Grubu Aşağı Taşı
|
Move Group Down Grubu Aşağı Taşı
|
||||||
@@ -2059,10 +2059,10 @@
|
|||||||
Reset GPU Backend GPU Arka Ucunu Sıfırla
|
Reset GPU Backend GPU Arka Ucunu Sıfırla
|
||||||
Applies at next launch. Bir sonraki açılışta geçerlidir.
|
Applies at next launch. Bir sonraki açılışta geçerlidir.
|
||||||
Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın
|
Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın
|
||||||
{key}-click to set the source {key}-kaynağı ayarlamak için tıklayın
|
{key}-click to set the source Kaynağı ayarlamak için {key} tuşuna basarak tıklayın
|
||||||
{key} to subtract {key} çıkarmak için
|
{key} to subtract {key} çıkarmak için
|
||||||
Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın)
|
Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın)
|
||||||
Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı ({key}-Klon Damgası ile tıklayın)
|
Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı (Klonlama Damgası ile {key} tuşuna basarak tıklayın)
|
||||||
Pixel Layer Piksel Katmanı
|
Pixel Layer Piksel Katmanı
|
||||||
Type Layer Metin Katmanı
|
Type Layer Metin Katmanı
|
||||||
Shape Layer Şekil Katmanı
|
Shape Layer Şekil Katmanı
|
||||||
@@ -2468,7 +2468,7 @@ cameraRaw Blue Mavi
|
|||||||
cameraRaw Purple Mor
|
cameraRaw Purple Mor
|
||||||
cameraRaw Magenta Macenta
|
cameraRaw Magenta Macenta
|
||||||
cameraRaw Blending Karıştırma
|
cameraRaw Blending Karıştırma
|
||||||
cameraRaw Luminance Parlaklık
|
cameraRaw Luminance Lüminans
|
||||||
cameraRaw Temperature Sıcaklık
|
cameraRaw Temperature Sıcaklık
|
||||||
cameraRaw Color Renk
|
cameraRaw Color Renk
|
||||||
Patch Tool Yama Aracı
|
Patch Tool Yama Aracı
|
||||||
@@ -2937,9 +2937,9 @@ cameraRaw Color Renk
|
|||||||
Gray Value (Median) Gri Değeri (Medyan)
|
Gray Value (Median) Gri Değeri (Medyan)
|
||||||
Integrated Density Entegre Yoğunluk
|
Integrated Density Entegre Yoğunluk
|
||||||
Length Uzunluk
|
Length Uzunluk
|
||||||
In Black Siyah
|
In Black Giriş Siyahı
|
||||||
In White Beyaz
|
In White Giriş Beyazı
|
||||||
Out Black Siyah
|
Out Black Çıkış Siyahı
|
||||||
Out White Çıkış Beyazı
|
Out White Çıkış Beyazı
|
||||||
Tint Color Ton Rengi
|
Tint Color Ton Rengi
|
||||||
Stops Duraklar
|
Stops Duraklar
|
||||||
@@ -3280,7 +3280,7 @@ cameraRaw Color Renk
|
|||||||
Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler.
|
Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler.
|
||||||
Adobe PDF Presets… Adobe PDF Ön Ayarları…
|
Adobe PDF Presets… Adobe PDF Ön Ayarları…
|
||||||
Advanced Blending Gelişmiş Karıştırma
|
Advanced Blending Gelişmiş Karıştırma
|
||||||
After (X) (X)'ten sonra
|
After (X) Sonra (X)
|
||||||
Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder.
|
Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder.
|
||||||
Alternates for Current Selection Geçerli Seçimin Alternatifleri
|
Alternates for Current Selection Geçerli Seçimin Alternatifleri
|
||||||
Apply Data Set Veri Kümesini Uygula
|
Apply Data Set Veri Kümesini Uygula
|
||||||
@@ -3314,7 +3314,7 @@ cameraRaw Color Renk
|
|||||||
Channel4 Kanal 4
|
Channel4 Kanal 4
|
||||||
Clipping Path Kırpma Yolu
|
Clipping Path Kırpma Yolu
|
||||||
Clone Klon
|
Clone Klon
|
||||||
Color Decontaminate Renkli Dekontaminasyon
|
Color Decontaminate Renkleri Arındır
|
||||||
Color Halftone Renk Yarım Tonu
|
Color Halftone Renk Yarım Tonu
|
||||||
Color Lookup Tables Renk Arama Tabloları
|
Color Lookup Tables Renk Arama Tabloları
|
||||||
Color Settings Renk Ayarları
|
Color Settings Renk Ayarları
|
||||||
@@ -3490,7 +3490,7 @@ cameraRaw Color Renk
|
|||||||
Select and Mask Seç ve Maskele
|
Select and Mask Seç ve Maskele
|
||||||
Semi Bold Yarı Kalın
|
Semi Bold Yarı Kalın
|
||||||
Set Measurement Scale Ölçüm Ölçeğini Ayarla
|
Set Measurement Scale Ölçüm Ölçeğini Ayarla
|
||||||
Shadows/Highlights Gölgeler/Vurgular
|
Shadows/Highlights Gölgeler/Parlak Tonlar
|
||||||
Shape Blur Şekil Bulanıklığı
|
Shape Blur Şekil Bulanıklığı
|
||||||
Shear Kesme
|
Shear Kesme
|
||||||
Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar)
|
Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar)
|
||||||
@@ -3792,8 +3792,8 @@ cameraData Lens Lens
|
|||||||
Loading fonts… Yazı tipleri yükleniyor…
|
Loading fonts… Yazı tipleri yükleniyor…
|
||||||
Onion Skin Soğan Kabuğu
|
Onion Skin Soğan Kabuğu
|
||||||
Marching Ants Yürüyen Karıncalar
|
Marching Ants Yürüyen Karıncalar
|
||||||
On Black Siyah
|
On Black Siyah Üzerinde
|
||||||
On White Beyaz
|
On White Beyaz Üzerinde
|
||||||
On Layers Katmanlarda
|
On Layers Katmanlarda
|
||||||
Show Edge Kenarı Göster
|
Show Edge Kenarı Göster
|
||||||
Show Original Orijinali Göster
|
Show Original Orijinali Göster
|
||||||
@@ -4088,7 +4088,7 @@ cameraData Lens Lens
|
|||||||
Next Brush Sonraki fırça
|
Next Brush Sonraki fırça
|
||||||
First Brush İlk fırça
|
First Brush İlk fırça
|
||||||
Last Brush Son fırça
|
Last Brush Son fırça
|
||||||
Preview size Preview size
|
Preview size Önizleme Boyutu
|
||||||
Memory Usage Bellek kullanımı
|
Memory Usage Bellek kullanımı
|
||||||
Per-document RAM limit (MB) Belge başına RAM sınırı (MB)
|
Per-document RAM limit (MB) Belge başına RAM sınırı (MB)
|
||||||
Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}%
|
Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}%
|
||||||
@@ -4120,10 +4120,10 @@ cameraData Lens Lens
|
|||||||
# Actions: a step dialog holds a suspended recording (#7365)
|
# Actions: a step dialog holds a suspended recording (#7365)
|
||||||
Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin
|
Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin
|
||||||
Pro Medium Gray Pro Orta Gri
|
Pro Medium Gray Pro Orta Gri
|
||||||
Solarized Solarize
|
Solarized Solarized
|
||||||
Breeze Esinti
|
Breeze Breeze
|
||||||
Pro Light Gray Pro Açık Gri
|
Pro Light Gray Pro Açık Gri
|
||||||
Pro Light Profesyonel Işık
|
Pro Light Pro Açık
|
||||||
Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır
|
Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır
|
||||||
EXR EXR
|
EXR EXR
|
||||||
# Paragraph direction (TYPE-218-2)
|
# Paragraph direction (TYPE-218-2)
|
||||||
@@ -4132,7 +4132,7 @@ cameraData Lens Lens
|
|||||||
Right-to-left paragraph direction Sağdan sola paragraf yönü
|
Right-to-left paragraph direction Sağdan sola paragraf yönü
|
||||||
Not rendered: this adjustment ({key}) is kept as imported. Görüntülenmiyor: bu ayarlama ({key}) içe aktarıldığı gibi korunuyor.
|
Not rendered: this adjustment ({key}) is kept as imported. Görüntülenmiyor: bu ayarlama ({key}) içe aktarıldığı gibi korunuyor.
|
||||||
# Print preview image (#8236)
|
# Print preview image (#8236)
|
||||||
Print preview image Print preview image
|
Print preview image Baskı Önizleme Görüntüsü
|
||||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||||
Hue Cube Renk Tonu Küpü
|
Hue Cube Renk Tonu Küpü
|
||||||
Brightness Cube Parlaklık Küpü
|
Brightness Cube Parlaklık Küpü
|
||||||
|
|||||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1302,8 +1302,19 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
|||||||
}
|
}
|
||||||
if let Some(id) = clicked {
|
if let Some(id) = clicked {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let id = alt_click(id, ctx.input(|i| i.modifiers.alt));
|
let alt = ctx.input(|i| i.modifiers.alt);
|
||||||
if let Err(e) = invoke(app, &ctx, &id, json!({})) {
|
// ⌥ while choosing an Image › Adjustments item opens it at its last settings (#8900).
|
||||||
|
let r = if alt && crate::adjust_dialog::has_dialog(&id) {
|
||||||
|
let opened = crate::adjust_dialog::open_last(app, &id).map(|_| Value::Null).ok_or_else(|| "no document".to_string());
|
||||||
|
// Edit › Search's recents, as `invoke` notes a plain click.
|
||||||
|
if opened.is_ok() {
|
||||||
|
crate::palette::note_recent(&ctx, &id);
|
||||||
|
}
|
||||||
|
opened
|
||||||
|
} else {
|
||||||
|
invoke(app, &ctx, &alt_click(id, alt), json!({}))
|
||||||
|
};
|
||||||
|
if let Err(e) = r {
|
||||||
app.ui.status = e;
|
app.ui.status = e;
|
||||||
app.ui.status_error = true;
|
app.ui.status_error = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,15 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
|||||||
let by_position = physical_key
|
let by_position = physical_key
|
||||||
.filter(|p| POSITIONAL.contains(p))
|
.filter(|p| POSITIONAL.contains(p))
|
||||||
.and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers)));
|
.and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers)));
|
||||||
by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab))
|
let bound =
|
||||||
|
by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab, false));
|
||||||
|
// Photoshop: ⌥ added to an adjustment's own key opens it at its last settings (⌥⌘L,
|
||||||
|
// ⌥⌘M, #8900), unless that key is bound to something else.
|
||||||
|
bound.or_else(|| {
|
||||||
|
let plain = egui::Modifiers { alt: false, ..*modifiers };
|
||||||
|
let last = modifiers.alt.then(|| table.iter().find(|(id, sc)| crate::adjust_dialog::has_dialog(id) && key_matches(sc, *key, plain))).flatten();
|
||||||
|
last.map(|(id, _)| (id.clone(), false, true))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -250,7 +258,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
|||||||
i.events.remove(at);
|
i.events.remove(at);
|
||||||
Some(hit)
|
Some(hit)
|
||||||
});
|
});
|
||||||
let Some((id, tab)) = next else { break };
|
let Some((id, tab, last)) = next else { break };
|
||||||
if tab {
|
if tab {
|
||||||
// egui already read this Tab as "focus the next widget" (Photoshop's Tab hides the
|
// egui already read this Tab as "focus the next widget" (Photoshop's Tab hides the
|
||||||
// panels instead, #1313): the shortcut took it, so cancel that move.
|
// panels instead, #1313): the shortcut took it, so cancel that move.
|
||||||
@@ -258,7 +266,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
|||||||
}
|
}
|
||||||
let id = clip_key_command(app, delete_key_command(app, id));
|
let id = clip_key_command(app, delete_key_command(app, id));
|
||||||
let owner = key_owner(app, ctx);
|
let owner = key_owner(app, ctx);
|
||||||
dispatch(app, ctx, &id);
|
dispatch_as(app, ctx, &id, last);
|
||||||
ran = true;
|
ran = true;
|
||||||
if key_owner(app, ctx) != owner {
|
if key_owner(app, ctx) != owner {
|
||||||
break;
|
break;
|
||||||
@@ -345,6 +353,11 @@ fn label(id: &str) -> String {
|
|||||||
|
|
||||||
/// Run a shortcut's command like its menu item (dialogs included), or say why it can't run.
|
/// Run a shortcut's command like its menu item (dialogs included), or say why it can't run.
|
||||||
pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
|
pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
|
||||||
|
dispatch_as(app, ctx, id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`dispatch`]; with `last`, an adjustment dialog opens at its last confirmed settings.
|
||||||
|
fn dispatch_as(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, last: bool) {
|
||||||
let action = id.strip_prefix(crate::actions::SHORTCUT_PREFIX);
|
let action = id.strip_prefix(crate::actions::SHORTCUT_PREFIX);
|
||||||
let command_id = if action.is_some() { "actions.play" } else { id };
|
let command_id = if action.is_some() { "actions.play" } else { id };
|
||||||
let outcome = if !crate::menus::is_enabled(app, command_id) {
|
let outcome = if !crate::menus::is_enabled(app, command_id) {
|
||||||
@@ -364,7 +377,11 @@ pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) {
|
|||||||
if v.get("failed").is_some_and(|f| f.is_object()) { Err(app.ui.status.clone()) } else { Ok(v) }
|
if v.get("failed").is_some_and(|f| f.is_object()) { Err(app.ui.status.clone()) } else { Ok(v) }
|
||||||
})
|
})
|
||||||
} else if crate::adjust_dialog::has_dialog(id) {
|
} else if crate::adjust_dialog::has_dialog(id) {
|
||||||
crate::adjust_dialog::open(app, id);
|
if last {
|
||||||
|
crate::adjust_dialog::open_last(app, id);
|
||||||
|
} else {
|
||||||
|
crate::adjust_dialog::open(app, id);
|
||||||
|
}
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
} else {
|
} else {
|
||||||
crate::menus::invoke(app, ctx, id, json!({}))
|
crate::menus::invoke(app, ctx, id, json!({}))
|
||||||
|
|||||||
@@ -846,3 +846,117 @@ fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() {
|
|||||||
tap(&mut h, egui::Key::Comma, egui::Key::M);
|
tap(&mut h, egui::Key::Comma, egui::Key::M);
|
||||||
assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]);
|
assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #8900: ⌥ added to an adjustment's shortcut opens its dialog with the settings last confirmed
|
||||||
|
/// there (Photoshop's ⌥⌘L for Levels, ⌥⌘M for Curves); the plain shortcut still opens at the
|
||||||
|
/// defaults, Cancel remembers nothing, each adjustment remembers its own, and a key assigned to
|
||||||
|
/// another command in Edit › Keyboard Shortcuts stays that command's.
|
||||||
|
#[test]
|
||||||
|
fn alt_adjustment_shortcut_reopens_the_last_confirmed_settings() {
|
||||||
|
use photocraft_doc::ColorMode;
|
||||||
|
use photocraft_engine::adjust_params::{default_for, from_params, to_params};
|
||||||
|
let mut h = harness();
|
||||||
|
put_focus(&mut h, Place::Canvas);
|
||||||
|
let top = |h: &Harness<'_, PhotocraftApp>| h.state().ui.dialogs.last().map(|d| (d.id, d.fields.clone())).expect("a dialog");
|
||||||
|
let set = |h: &mut Harness<'_, PhotocraftApp>, id: u64, values: serde_json::Value| {
|
||||||
|
let d = h.state_mut().ui.dialog_mut(id).unwrap();
|
||||||
|
d.fields.extend(values.as_object().unwrap().clone());
|
||||||
|
h.run_steps(2);
|
||||||
|
};
|
||||||
|
let levels = to_params(&from_params("levels", &json!({"inBlack": 30, "gamma": 1.3}), None, ColorMode::Rgb).unwrap());
|
||||||
|
let level_defaults = to_params(&default_for("levels", ColorMode::Rgb).unwrap());
|
||||||
|
assert_ne!(levels["inBlack"], level_defaults["inBlack"]);
|
||||||
|
|
||||||
|
press(&mut h, "Cmd+L");
|
||||||
|
let (id, _) = top(&h);
|
||||||
|
set(&mut h, id, json!({"inBlack": 30, "gamma": 1.3}));
|
||||||
|
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
|
||||||
|
press(&mut h, "Cmd+L");
|
||||||
|
let (id, fields) = top(&h);
|
||||||
|
assert_eq!((&fields["inBlack"], &fields["gamma"]), (&level_defaults["inBlack"], &level_defaults["gamma"]), "⌘L opens at the defaults");
|
||||||
|
crate::dialogs::cancel(h.state_mut(), id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
|
||||||
|
press(&mut h, "Cmd+Alt+L");
|
||||||
|
assert_eq!(logged(&h), ["image.adjustments.levels", "image.adjustments.levels", "image.adjustments.levels"]);
|
||||||
|
let (id, fields) = top(&h);
|
||||||
|
assert_eq!((&fields["inBlack"], &fields["gamma"]), (&levels["inBlack"], &levels["gamma"]), "⌥⌘L opens at the last OK'd settings");
|
||||||
|
set(&mut h, id, json!({"inBlack": 50}));
|
||||||
|
crate::dialogs::cancel(h.state_mut(), id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
press(&mut h, "Cmd+Alt+L");
|
||||||
|
let (id, fields) = top(&h);
|
||||||
|
assert_eq!(fields["inBlack"], levels["inBlack"], "Cancel remembers nothing");
|
||||||
|
crate::dialogs::cancel(h.state_mut(), id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
|
||||||
|
// Curves has nothing of its own yet: its defaults, not Levels' settings.
|
||||||
|
let curve_defaults = to_params(&default_for("curves", ColorMode::Rgb).unwrap());
|
||||||
|
press(&mut h, "Cmd+Alt+M");
|
||||||
|
let (id, fields) = top(&h);
|
||||||
|
assert_eq!(fields["__adjust"], json!("curves"));
|
||||||
|
assert_eq!(fields["points"], curve_defaults["points"], "⌥⌘M with no Curves confirmed yet");
|
||||||
|
let curve = json!({"points": [[0, 0], [100, 150], [255, 255]]});
|
||||||
|
set(&mut h, id, curve.clone());
|
||||||
|
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
press(&mut h, "Cmd+Alt+M");
|
||||||
|
let (id, fields) = top(&h);
|
||||||
|
let curves = to_params(&from_params("curves", &curve, None, ColorMode::Rgb).unwrap());
|
||||||
|
assert_eq!(fields["points"], curves["points"], "⌥⌘M opens at the last OK'd curve");
|
||||||
|
crate::dialogs::cancel(h.state_mut(), id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
take_log(&h.ctx);
|
||||||
|
|
||||||
|
h.state_mut().session.edit_prefs(|p| {
|
||||||
|
p.shortcuts.insert("image.adjustments.desaturate".into(), "Cmd+Alt+L".into());
|
||||||
|
});
|
||||||
|
press(&mut h, "Cmd+Alt+L");
|
||||||
|
assert_eq!(logged(&h), ["image.adjustments.desaturate"], "a key assigned to another command is that command's");
|
||||||
|
assert!(h.state().ui.dialogs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #8900: in Photoshop ⌥ held while choosing an Image › Adjustments item opens it at its last
|
||||||
|
/// settings too; a plain click still opens it at the defaults.
|
||||||
|
#[test]
|
||||||
|
fn alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings() {
|
||||||
|
use photocraft_doc::ColorMode;
|
||||||
|
use photocraft_engine::adjust_params::{default_for, from_params, to_params};
|
||||||
|
let mut h = harness();
|
||||||
|
h.state_mut().session.edit_prefs(|p| {
|
||||||
|
p.dialogs.insert("image.adjustments.levels".into(), json!({"inBlack": 30, "gamma": 1.3}));
|
||||||
|
});
|
||||||
|
let levels = to_params(&from_params("levels", &json!({"inBlack": 30, "gamma": 1.3}), None, ColorMode::Rgb).unwrap());
|
||||||
|
let defaults = to_params(&default_for("levels", ColorMode::Rgb).unwrap());
|
||||||
|
let choose = |h: &mut Harness<'_, PhotocraftApp>, m: Modifiers| {
|
||||||
|
h.get_by_label("Image").click();
|
||||||
|
h.run_steps(3);
|
||||||
|
// The Image menu's row, not the Adjustments panel's tab in the dock on the right.
|
||||||
|
let adjustments = h.get_all_by_label_contains("Adjustments").map(|n| n.rect()).min_by(|a, b| a.left().total_cmp(&b.left())).unwrap().center();
|
||||||
|
h.hover_at(adjustments);
|
||||||
|
h.run_steps(30);
|
||||||
|
let at = h.get_by_label_contains("Levels…").rect().center();
|
||||||
|
h.hover_at(at);
|
||||||
|
h.run_steps(2);
|
||||||
|
h.event(egui::Event::ModifiersChanged(m));
|
||||||
|
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: m });
|
||||||
|
h.run_steps(1);
|
||||||
|
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m });
|
||||||
|
h.run_steps(1);
|
||||||
|
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||||
|
h.run_steps(3);
|
||||||
|
let d = h.state().ui.dialogs.last().expect("Levels opened").clone();
|
||||||
|
assert_eq!(d.fields["__adjust"], json!("levels"));
|
||||||
|
crate::dialogs::cancel(h.state_mut(), d.id).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
(d.fields["inBlack"].clone(), d.fields["gamma"].clone())
|
||||||
|
};
|
||||||
|
assert_eq!(choose(&mut h, Modifiers::NONE), (defaults["inBlack"].clone(), defaults["gamma"].clone()), "a click opens at the defaults");
|
||||||
|
assert_eq!(choose(&mut h, Modifiers::ALT), (levels["inBlack"].clone(), levels["gamma"].clone()), "⌥-click opens at the last settings");
|
||||||
|
// The plain click noted Levels already: put another command in front of it first.
|
||||||
|
crate::palette::note_recent(&h.ctx, "file.new");
|
||||||
|
choose(&mut h, Modifiers::ALT);
|
||||||
|
assert_eq!(crate::palette::recent_ids(&h.ctx).first().map(String::as_str), Some("image.adjustments.levels"), "Edit › Search's most recent");
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
# UI parity: tools, handles, modifiers and feel
|
# UI parity: tools, handles, modifiers and feel
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||||
|
|
||||||
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
||||||
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
||||||
@@ -207,6 +207,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|
|||||||
| Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | |
|
| Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | |
|
||||||
| Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | |
|
| Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | |
|
||||||
| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | |
|
| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | |
|
||||||
|
| ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` |
|
||||||
| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) |
|
| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) |
|
||||||
|
|
||||||
## Panels and workspace
|
## Panels and workspace
|
||||||
@@ -298,3 +299,4 @@ Paragraph; the rest open as in-app floating windows.
|
|||||||
| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) |
|
| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) |
|
||||||
| 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) |
|
| 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) |
|
||||||
| 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) |
|
| 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) |
|
||||||
|
| 2026-10-23 | minor | ⌥ + an adjustment's shortcut (⌥⌘L Levels, ⌥⌘M Curves) opens its dialog at the settings of its last OK, as in Photoshop (#8900); no percentage changed |
|
||||||
|
|||||||
Reference in New Issue
Block a user