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]
|
||||
real_name = "Martin Ball"
|
||||
|
||||
[people.ronibalthazar]
|
||||
sync_github_name = true
|
||||
|
||||
[people.batuhangozegu]
|
||||
real_name = "Batuhan Gözegü"
|
||||
sync_github_name = false
|
||||
|
||||
[people.drunkeninja42]
|
||||
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 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).
|
||||
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)) {
|
||||
(Some("Chnl"), Target::Property(prop)) if prop == "fsel" => {
|
||||
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
|
||||
/// (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.
|
||||
|
||||
@@ -416,7 +416,7 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
|
||||
*l = l.duplicate();
|
||||
}
|
||||
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();
|
||||
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
|
||||
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");
|
||||
}
|
||||
|
||||
#[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
|
||||
/// takes them whole and Paste recreates them, in the same or another document, in one step.
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, SmartContentsId};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId};
|
||||
use photocraft_geom::Rect;
|
||||
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]
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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`,
|
||||
@@ -107,13 +118,20 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document)
|
||||
*surf = surf.convert(f);
|
||||
});
|
||||
}
|
||||
let new_canvas = copies.bounds() != dest.bounds();
|
||||
prepare_shapes(&mut copies.layers, dest, new_canvas);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 {
|
||||
crate::commands::translate_layer(&snapshot, l, dx, 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()) {
|
||||
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)?;
|
||||
// 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).
|
||||
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 (c, d) = (center(content), center(ddoc.bounds()));
|
||||
(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]))
|
||||
};
|
||||
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();
|
||||
s.set_active(dest);
|
||||
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
|
||||
|
||||
@@ -250,7 +250,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Transform Selection",
|
||||
menu: &["Select"],
|
||||
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,
|
||||
run: transform_selection,
|
||||
journal: true,
|
||||
@@ -297,6 +297,26 @@ mod tests {
|
||||
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]
|
||||
fn sky_selects_the_sky_at_every_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
@@ -859,7 +859,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Free Transform",
|
||||
menu: &[],
|
||||
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,
|
||||
journal: true,
|
||||
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 inside = sh.cache.as_ref().is_some_and(|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 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 command_lookup;
|
||||
mod convert_keeps_references;
|
||||
mod copy_shape_placement;
|
||||
mod corpus_atn;
|
||||
mod crop_content_aware;
|
||||
mod current_affinity_artboards;
|
||||
|
||||
@@ -450,6 +450,12 @@ fn read_lookup(d: &[u8]) -> Result<Adjustment, String> {
|
||||
_ => ("x.cube", "CUBE"),
|
||||
};
|
||||
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();
|
||||
Ok(Adjustment::ColorLookup {
|
||||
name,
|
||||
@@ -1112,6 +1118,46 @@ mod tests {
|
||||
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]
|
||||
fn malformed_falls_back_to_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_io::*;
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||
use photocraft_psd::{Compression, LayerFlags, LayerRecord, PsdFile, TaggedBlock, Version, testgen};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
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).
|
||||
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";
|
||||
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 text = |t: &str| Value::Text(UnicodeString::new_nul(t));
|
||||
let desc = Descriptor::new("null")
|
||||
@@ -61,6 +66,46 @@ fn photoshop_clr_l_block() -> Vec<u8> {
|
||||
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]
|
||||
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
|
||||
@@ -109,3 +154,59 @@ fn short_color_lookup_table_warns_on_open() {
|
||||
let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default());
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag.
|
||||
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).
|
||||
fn parse_at(b: &[u8]) -> Result<(Reply, usize)> {
|
||||
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 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"));
|
||||
}
|
||||
}
|
||||
parse_message(b).map(|parsed| (parsed.reply, parsed.end))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result<Reply> {
|
||||
/// Parses one IPP message while preserving each group instance for private consumers that need
|
||||
/// 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 head = format!(
|
||||
"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 reply = Reply::parse(&body)?;
|
||||
if reply.status >= 0x0400 {
|
||||
return Err(Error::Status(reply.status, reply.status_message()));
|
||||
let reply = parse_message(&body)?;
|
||||
if reply.reply.status >= 0x0400 {
|
||||
return Err(Error::Status(reply.reply.status, reply.reply.status_message()));
|
||||
}
|
||||
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>> {
|
||||
let mut r = Request::new(op, 1);
|
||||
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,
|
||||
// CUPS answers "not found" ("No destinations added.") when no printer is set up.
|
||||
Err(Error::Status(0x0406, _)) => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut current: Option<Printer> = None;
|
||||
for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) {
|
||||
match a.name.as_str() {
|
||||
"printer-name" => {
|
||||
if let Some(p) = current.take() {
|
||||
out.push(p);
|
||||
}
|
||||
current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class });
|
||||
let attributes = &parsed.reply.attributes;
|
||||
for group in parsed.groups.iter().filter(|group| group.tag == TAG_PRINTER_GROUP) {
|
||||
let Some(group_attributes) = attributes.get(group.start..group.end) else {
|
||||
return Err(Error::Protocol("invalid printer attribute group range".into()));
|
||||
};
|
||||
let mut name = None;
|
||||
let mut info = String::new();
|
||||
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 {
|
||||
p.info = a.text();
|
||||
}
|
||||
}
|
||||
"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));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(name) = name.filter(|name| !name.is_empty()) {
|
||||
out.push(Printer { name, info, state, accepting_jobs, is_default: false, is_class });
|
||||
}
|
||||
}
|
||||
out.extend(current);
|
||||
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);
|
||||
r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]);
|
||||
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.
|
||||
Err(Error::Status(0x0406, _)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
@@ -642,7 +675,7 @@ pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result<u32> {
|
||||
}
|
||||
let mut body = r.end();
|
||||
body.extend_from_slice(job.document);
|
||||
let reply = post(endpoint, &path, &body)?;
|
||||
let reply = post(endpoint, &path, &body)?.reply;
|
||||
reply
|
||||
.attributes
|
||||
.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]
|
||||
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);
|
||||
|
||||
@@ -279,6 +279,33 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
||||
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
|
||||
/// selection — both ways of selecting a mask must give the same dropdown (#5335).
|
||||
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;
|
||||
}
|
||||
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.
|
||||
crate::actions::resume(app, &d.fields)?;
|
||||
} else {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
Batch… Toplu…
|
||||
Create Droplet… Damlacık Oluştur…
|
||||
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…
|
||||
Fit Image… Görüntüyü Sığdır…
|
||||
Lens Correction… Mercek Düzeltme…
|
||||
@@ -247,7 +247,7 @@
|
||||
Threshold… Eşik…
|
||||
Gradient Map… Degrade Eşlemesi…
|
||||
Selective Color… Seçici Renk…
|
||||
Shadows/Highlights… Gölgeler/Vurgular…
|
||||
Shadows/Highlights… Gölgeler/Parlak Tonlar…
|
||||
HDR Toning… HDR Tonlama…
|
||||
Desaturate Doygunluğu Azalt
|
||||
Match Color… Rengi Eşleştir…
|
||||
@@ -410,7 +410,7 @@
|
||||
Stamp Down Aşağıya Damgala
|
||||
Flatten Image Görüntüyü Düzleştir
|
||||
Matting Matlama
|
||||
Color Decontaminate… Renkli Dekontamine…
|
||||
Color Decontaminate… Renkleri Arındır…
|
||||
Remove Black Matte Siyah Matı Kaldır
|
||||
Remove White Matte Beyaz Matı Kaldır
|
||||
Defringe… Kenar Halesini Kaldır…
|
||||
@@ -438,7 +438,7 @@
|
||||
Right Edges Sağ Kenarlar
|
||||
Distribute Dağıt
|
||||
Vertically Dikey Olarak
|
||||
Horizontally Yatay olarak
|
||||
Horizontally Yatay Olarak
|
||||
Layer Edges Katman Kenarları
|
||||
Isolate Layers Katmanları Yalıt
|
||||
Layer Content Options… Katman İçerik Seçenekleri…
|
||||
@@ -777,9 +777,9 @@
|
||||
Other… Diğer…
|
||||
Remove Warp Split Çarpıtma Bölünmesini Kaldır
|
||||
Shape Şekil
|
||||
Split Warp Crosswise Çözgüyü Çapraz Olarak Böl
|
||||
Split Warp Horizontally Çözgüyü Yatay Olarak Böl
|
||||
Split Warp Vertically Çözgüyü Dikey Olarak Böl
|
||||
Split Warp Crosswise Çarpıtmayı Çapraz Olarak Böl
|
||||
Split Warp Horizontally Çarpıtmayı Yatay Olarak Böl
|
||||
Split Warp Vertically Çarpıtmayı Dikey Olarak Böl
|
||||
Statistics… İstatistikler…
|
||||
Refine Strokes Fırça Vuruşlarını Hassaslaştır
|
||||
Edits Düzenlemeler
|
||||
@@ -889,7 +889,7 @@
|
||||
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 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 extension color: Tuval uzatma rengi:
|
||||
Capture Yakala
|
||||
@@ -946,7 +946,7 @@
|
||||
Commit warp ({key}) Çarpıtmayı onayla ({key})
|
||||
Constant Sabit
|
||||
Constrain proportions Oranları koru
|
||||
Contact Sheet II İletişim Sayfası II
|
||||
Contact Sheet II Kontak Sayfası II
|
||||
Content-Aware İçeriğe Duyarlı
|
||||
Contiguous Bitişik
|
||||
Contrast Kontrast
|
||||
@@ -1056,7 +1056,7 @@
|
||||
Dots Noktalar
|
||||
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 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
|
||||
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.
|
||||
@@ -1147,7 +1147,7 @@
|
||||
Hide Properties Özellikleri Gizle
|
||||
Highlight Compression Parlak Ton Sıkıştırma
|
||||
Highlight Priority Parlak Ton Önceliği
|
||||
Highlights Öne Çıkanlar
|
||||
Highlights Parlak Tonlar
|
||||
Home Ana sayfa
|
||||
Horizontal scale Yatay ölçek
|
||||
Hue Ton
|
||||
@@ -1183,7 +1183,7 @@
|
||||
Kerning Karakter Aralığı
|
||||
Keyboard Shortcuts Klavye Kısayolları
|
||||
Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler
|
||||
Kind tür
|
||||
Kind Tür
|
||||
Label Size Etiket Boyutu
|
||||
Labels Etiketler
|
||||
Lanczos (sharp) Lanczos (keskin)
|
||||
@@ -1219,7 +1219,7 @@
|
||||
Lock: Kilit:
|
||||
Logical Length: Mantıksal Uzunluk:
|
||||
Logical Units: Mantıksal Birimler:
|
||||
Luminance parlaklık
|
||||
Luminance Lüminans
|
||||
Luminance Detail Lüminans Ayrıntısı
|
||||
Magenta Macenta
|
||||
Magenta · Green Macenta · Yeşil
|
||||
@@ -1521,7 +1521,7 @@
|
||||
Tracking (1/1000 em) İzleme (1/1000 em)
|
||||
Transparency Saydamlık
|
||||
Transparent Saydam
|
||||
Trilinear Üç çizgili
|
||||
Trilinear Üç Doğrusal
|
||||
Type Styles Metin Stilleri
|
||||
Type: Tür:
|
||||
Underline Altı çizili
|
||||
@@ -1644,7 +1644,7 @@
|
||||
ExtraBold Ekstra Kalın
|
||||
ExtraLight Ekstra Hafif
|
||||
Eyedropper Tool Damlalık Aracı
|
||||
Fade Solmaya
|
||||
Fade Solma
|
||||
Fill Opacity Dolgu Opaklığı
|
||||
Film & Video Film ve Video
|
||||
From Şuradan
|
||||
@@ -1657,14 +1657,14 @@
|
||||
Hard Sert
|
||||
Hard Mix Sert Karışım
|
||||
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ı
|
||||
Horizontal Type Tool Yatay Metin Aracı
|
||||
Horizontal Type Mask Tool Yatay Metin Maskesi Aracı
|
||||
Vertical Type Mask Tool Dikey Metin Maskesi Aracı
|
||||
Image Size: Görüntü Boyutu:
|
||||
Inches İnç
|
||||
Indexed Dizine eklendi
|
||||
Indexed İndekslenmiş
|
||||
Initial Direction Başlangıç Yönü
|
||||
Inner Bevel İç Eğim
|
||||
Inner Glow İç Işıma
|
||||
@@ -1676,7 +1676,7 @@
|
||||
Lasso Kement
|
||||
Lasso Tool Kement Aracı
|
||||
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
|
||||
Letter Letter
|
||||
Light Hafif
|
||||
@@ -2037,7 +2037,7 @@
|
||||
Expand 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)
|
||||
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)
|
||||
Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr)
|
||||
Move Group Down Grubu Aşağı Taşı
|
||||
@@ -2059,10 +2059,10 @@
|
||||
Reset GPU Backend GPU Arka Ucunu Sıfırla
|
||||
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
|
||||
{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
|
||||
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ı
|
||||
Type Layer Metin Katmanı
|
||||
Shape Layer Şekil Katmanı
|
||||
@@ -2468,7 +2468,7 @@ cameraRaw Blue Mavi
|
||||
cameraRaw Purple Mor
|
||||
cameraRaw Magenta Macenta
|
||||
cameraRaw Blending Karıştırma
|
||||
cameraRaw Luminance Parlaklık
|
||||
cameraRaw Luminance Lüminans
|
||||
cameraRaw Temperature Sıcaklık
|
||||
cameraRaw Color Renk
|
||||
Patch Tool Yama Aracı
|
||||
@@ -2937,9 +2937,9 @@ cameraRaw Color Renk
|
||||
Gray Value (Median) Gri Değeri (Medyan)
|
||||
Integrated Density Entegre Yoğunluk
|
||||
Length Uzunluk
|
||||
In Black Siyah
|
||||
In White Beyaz
|
||||
Out Black Siyah
|
||||
In Black Giriş Siyahı
|
||||
In White Giriş Beyazı
|
||||
Out Black Çıkış Siyahı
|
||||
Out White Çıkış Beyazı
|
||||
Tint Color Ton Rengi
|
||||
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.
|
||||
Adobe PDF Presets… Adobe PDF Ön Ayarları…
|
||||
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.
|
||||
Alternates for Current Selection Geçerli Seçimin Alternatifleri
|
||||
Apply Data Set Veri Kümesini Uygula
|
||||
@@ -3314,7 +3314,7 @@ cameraRaw Color Renk
|
||||
Channel4 Kanal 4
|
||||
Clipping Path Kırpma Yolu
|
||||
Clone Klon
|
||||
Color Decontaminate Renkli Dekontaminasyon
|
||||
Color Decontaminate Renkleri Arındır
|
||||
Color Halftone Renk Yarım Tonu
|
||||
Color Lookup Tables Renk Arama Tabloları
|
||||
Color Settings Renk Ayarları
|
||||
@@ -3490,7 +3490,7 @@ cameraRaw Color Renk
|
||||
Select and Mask Seç ve Maskele
|
||||
Semi Bold Yarı Kalın
|
||||
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ığı
|
||||
Shear Kesme
|
||||
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…
|
||||
Onion Skin Soğan Kabuğu
|
||||
Marching Ants Yürüyen Karıncalar
|
||||
On Black Siyah
|
||||
On White Beyaz
|
||||
On Black Siyah Üzerinde
|
||||
On White Beyaz Üzerinde
|
||||
On Layers Katmanlarda
|
||||
Show Edge Kenarı Göster
|
||||
Show Original Orijinali Göster
|
||||
@@ -4088,7 +4088,7 @@ cameraData Lens Lens
|
||||
Next Brush Sonraki fırça
|
||||
First Brush İlk fırça
|
||||
Last Brush Son fırça
|
||||
Preview size Preview size
|
||||
Preview size Önizleme Boyutu
|
||||
Memory Usage Bellek kullanımı
|
||||
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}%
|
||||
@@ -4120,10 +4120,10 @@ cameraData Lens Lens
|
||||
# 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
|
||||
Pro Medium Gray Pro Orta Gri
|
||||
Solarized Solarize
|
||||
Breeze Esinti
|
||||
Solarized Solarized
|
||||
Breeze Breeze
|
||||
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
|
||||
EXR EXR
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
@@ -4132,7 +4132,7 @@ cameraData Lens Lens
|
||||
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.
|
||||
# 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)
|
||||
Hue Cube Renk Tonu 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 {
|
||||
let ctx = ui.ctx().clone();
|
||||
let id = alt_click(id, ctx.input(|i| i.modifiers.alt));
|
||||
if let Err(e) = invoke(app, &ctx, &id, json!({})) {
|
||||
let alt = ctx.input(|i| i.modifiers.alt);
|
||||
// ⌥ 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_error = true;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,15 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
||||
let by_position = physical_key
|
||||
.filter(|p| POSITIONAL.contains(p))
|
||||
.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,
|
||||
};
|
||||
@@ -250,7 +258,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc
|
||||
i.events.remove(at);
|
||||
Some(hit)
|
||||
});
|
||||
let Some((id, tab)) = next else { break };
|
||||
let Some((id, tab, last)) = next else { break };
|
||||
if tab {
|
||||
// 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.
|
||||
@@ -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 owner = key_owner(app, ctx);
|
||||
dispatch(app, ctx, &id);
|
||||
dispatch_as(app, ctx, &id, last);
|
||||
ran = true;
|
||||
if key_owner(app, ctx) != owner {
|
||||
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.
|
||||
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 command_id = if action.is_some() { "actions.play" } else { 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) }
|
||||
})
|
||||
} 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)
|
||||
} else {
|
||||
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);
|
||||
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
|
||||
|
||||
> **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
|
||||
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) | |
|
||||
| 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_*`) | |
|
||||
| ⌥ + 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) |
|
||||
|
||||
## 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 | 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-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